je개발 회고

[ 데이터 품질 관리 ] (2) Frontend : 아키텍쳐와 설계 철학

Je-chan 2025. 8. 18. 14:26

아키텍처와 설계 철학

이 글에서는 TypeScript 환경에서 구현된 데이터 품질 검증 시스템의 핵심 설계 철학과 아키텍처에 대해 설명합니다. 단순한 "어떻게"가 아닌 "왜" 이렇게 설계했는지에 대한 근본적인 이유를 다룹니다.

🎯 설계 철학

1. "실패는 예외가 아닌 일상" 철학

기존의 접근 방식에서는 에러가 발생하면 사용자 경험이 망가지는 경우가 많았습니다. 다음 코드를 보시면 문제점을 쉽게 이해할 수 있습니다.

❌ 기존 방식의 문제점
try {
  const data = await api.getData();
  // 런타임에 data.someField가 null이면 UI 크래시
  return data.someField.toFixed(2);
} catch (error) {
  // 에러가 발생해도 개발자는 모름
  return "에러 발생";
}
✅ 새로운 방식의 장점
const { data, isValidated } = await validatedApi.getData();
// 검증이 실패해도 UI는 정상 동작하고, 문제는 백그라운드에서 수집됨
return data?.someField?.toFixed(2) ?? "데이터 없음";

왜 이런 철학을 택했을까요?

  • 실제 운영 환경의 현실: 외부 API는 언제든 예상과 다른 데이터를 보낼 수 있습니다
  • 사용자 경험 우선: 데이터 문제가 있어도 앱이 멈추면 안됩니다
  • 개발자 경험 향상: 문제를 사전에 감지하고 자동으로 리포트합니다

2. "타입 안전성 = 런타임 안전성" 원칙

TypeScript의 컴파일 타임 체크만으로는 런타임 안전성을 보장할 수 없습니다. 다음 예시를 통해 이 문제를 명확히 이해할 수 있습니다.

// TypeScript 컴파일 타임 체크만으로는 부족
interface ApiResponse {
  value: number; // 컴파일러는 이게 항상 number라고 믿음
}

// 실제 런타임에는...
const response: ApiResponse = {
  value: null // 💥 실제로는 null이 올 수 있음
};

// 해결책: 런타임 검증을 통한 타입 가드
const schema = z.object({
  value: z.coerce.number() // 런타임에 실제로 검증하고 변환
});
✅ 장점
  • 컴파일 타임 + 런타임 이중 안전장치
  • 자동 타입 추론: Zod에서 TypeScript 타입 자동 생성
  • 문서화 효과: 스키마 자체가 API 명세 역할
❌ 단점
  • 초기 개발 시간 증가: 스키마 작성 필요
  • 런타임 오버헤드: 검증 과정에서 성능 비용
  • 학습 곡선: Zod 문법 숙지 필요

🏛️ 아키텍처 레이어

전체 시스템 구조

시스템 아키텍처 개요
Presentation Layer
(컴포넌트)
Business Logic Layer
(Hook)
Data Access Layer
(검증된 API)
Infrastructure Layer
(로깅)

레이어별 책임과 설계 근거

1. Presentation Layer (표현 계층)

표현 계층은 UI 렌더링에만 집중하며, 데이터 검증 실패 여부와 무관하게 안정적으로 동작합니다.

<template>
  <div class="data-widget">
    <!-- 데이터 검증 실패해도 UI는 정상 동작 -->
    <div v-if="isLoading">로딩중...</div>
    <div v-else-if="hasValidationErrors">
      ⚠️ 일부 데이터에 문제가 있지만 표시 가능한 데이터로 표시
    </div>
    <div v-else>
      {{ data.value }}
    </div>
  </div>
</template>

<script setup lang="ts">
// 컴포넌트는 검증 여부를 몰라도 됨 (관심사 분리)
const { data, isLoading, hasValidationErrors } = useApiData();
</script>
설계 근거
  • 관심사 분리: 컴포넌트는 UI 렌더링에만 집중합니다
  • 선언적 접근: 데이터 상태에 따른 UI 상태 자동 결정합니다
  • 에러 경계: 검증 실패가 컴포넌트 렌더링을 방해하지 않습니다

2. Business Logic Layer (비즈니스 로직 계층)

비즈니스 로직 계층은 도메인 지식을 캡슐화하고, 검증 실패 시 기본값을 제공하는 역할을 담당합니다.

// src/hooks/useApiData.ts
export const useApiData = () => {
  // 비즈니스 로직: "데이터 조회"
  const { data, isLoading, error } = useDataQuery();
  
  // 비즈니스 규칙: 검증 실패 시 기본값 제공
  const processedData = computed(() => {
    if (!data.value) return getDefaultData();
    
    return {
      value: data.value.apiValue ?? 0,
      hasValidationErrors: data.value._validationErrors?.length > 0,
      lastUpdated: data.value.timestamp
    };
  });

  return {
    data: processedData,
    isLoading,
    hasValidationErrors: computed(() => processedData.value.hasValidationErrors)
  };
};
설계 근거
  • 비즈니스 로직 캡슐화: 도메인 지식을 Hook에 집중합니다
  • 재사용성: 여러 컴포넌트에서 동일한 비즈니스 로직을 사용합니다
  • 테스트 용이성: 비즈니스 로직을 독립적으로 테스트할 수 있습니다

3. Data Access Layer (데이터 접근 계층)

데이터 접근 계층에서는 투명한 검증 기능을 제공하며, 기존 API 호출 방식과 100% 호환됩니다.

// src/utilities/validation/createValidatedApiCall.ts
export const createValidatedApiCall = <TParams, TResponse>(
  originalApiCall: (params: TParams) => Promise<TResponse>,
  validator: (data: TResponse, params?: TParams) => Promise<ValidationResult<TResponse>>
) => {
  return async (params: TParams): Promise<TResponse> => {
    try {
      // 1. 원본 API 호출
      const response = await originalApiCall(params);
      
      // 2. 백그라운드 검증 (UI 블로킹 없음)
      validator(response, params).catch(error => {
        // 검증 실패는 로깅만 하고 사용자 경험에 영향 없음
        console.debug('Validation failed but continuing:', error);
      });
      
      // 3. 원본 데이터 반환 (검증 실패와 무관)
      return response;
    } catch (error) {
      // API 호출 실패는 상위로 전파
      throw error;
    }
  };
};
설계 근거
  • 투명성: 기존 API 호출 방식과 100% 호환됩니다
  • 비침습성: 검증 로직이 비즈니스 로직을 방해하지 않습니다
  • 점진적 적용: 기존 코드 수정 없이 검증 기능을 추가할 수 있습니다

🔄 데이터 플로우와 의사결정 지점

일반적인 API 호출 플로우

1
컴포넌트에서 데이터 요청
사용자 액션 또는 라이프사이클에 따라 데이터를 요청합니다.
2
Hook에서 API 호출
비즈니스 로직을 포함한 Hook에서 검증된 API를 호출합니다.
3
원본 API 실행
실제 서버와 통신하여 데이터를 받아옵니다.
4
백그라운드 검증
받아온 데이터를 백그라운드에서 검증하고, 실패 시 로그를 수집합니다.
5
원본 데이터 반환
검증 결과와 무관하게 원본 데이터를 반환하여 사용자 경험을 보장합니다.

핵심 의사결정 지점들

Decision 1: 검증 실패 시 어떻게 할 것인가?

검증 실패 시 처리 방법에 대한 두 가지 접근법을 비교해보겠습니다.

// Option A: 검증 실패 시 에러 throw (기존 방식)
if (!isValid) {
  throw new Error('Data validation failed');
}

// Option B: 검증 실패해도 원본 데이터 반환 (채택한 방식)
// 장점: 사용자 경험 중단 없음, 점진적 개선 가능
// 단점: 잘못된 데이터로 계속 동작할 수 있음
왜 Option B를 선택했을까요?
  1. 사용자 경험 우선: 데이터 품질 문제로 앱이 멈추면 안됩니다
  2. 점진적 개선: 검증 로직을 하나씩 추가하면서 개선할 수 있습니다
  3. 레거시 호환: 기존 코드를 대폭 수정하지 않고도 적용할 수 있습니다

Decision 2: 언제 검증을 수행할 것인가?

// Option A: 동기적 검증 (UI 블로킹)
const data = await api.getData();
const validatedData = await validate(data); // UI 대기
return validatedData;

// Option B: 비동기적 검증 (채택한 방식)
const data = await api.getData();
validate(data); // 백그라운드에서 실행, UI는 즉시 진행
return data;
왜 Option B를 선택했을까요?
  1. 성능: 검증 시간이 UI 응답성에 영향을 주지 않습니다
  2. 사용자 경험: 데이터가 즉시 표시됩니다
  3. 확장성: 복잡한 검증 로직도 성능 영향 없이 적용할 수 있습니다

🎭 Design Patterns 적용

1. Decorator Pattern: createValidatedApiCall

Decorator 패턴을 활용하여 기존 API 함수에 검증 기능을 투명하게 추가했습니다.

// 기본 API 함수
const getApiData = (params) => axios.get('/api/data', { params });

// Decorator로 검증 기능 추가
const validatedGetApiData = createValidatedApiCall(
  getApiData,
  validateApiData
);

// 사용법은 동일하지만 내부적으로 검증 기능 추가됨
const data = await validatedGetApiData({ date: '2025-01-01' });
Decorator 패턴의 장점
  • 기존 코드 보존: 원본 함수 수정 없이 기능을 확장할 수 있습니다
  • 조합 가능: 여러 Decorator를 체인으로 연결할 수 있습니다
  • 선택적 적용: 필요한 API에만 선택적으로 적용할 수 있습니다

2. Strategy Pattern: Zod 스키마 전략

환경이나 상황에 따라 다른 검증 전략을 적용할 수 있도록 Strategy 패턴을 사용했습니다.

// 검증 전략 인터페이스
interface ValidationStrategy<T> {
  validate: (data: unknown) => ValidationResult<T>;
}

// 구체적인 전략들
const strictStrategy = z.object({
  value: z.number() // null 허용 안함
});

const lenientStrategy = z.object({
  value: z.number().nullable() // null 허용
});

const coerciveStrategy = z.object({
  value: z.coerce.number() // 강제 변환
});

// 상황에 따라 전략 선택
const validator = isDevelopment ? strictStrategy : lenientStrategy;
Strategy 패턴의 장점
  • 유연성: 환경이나 상황에 따라 검증 방식을 변경할 수 있습니다
  • 점진적 적용: 엄격한 검증에서 느슨한 검증으로 단계적 적용이 가능합니다
  • A/B 테스트: 다른 검증 전략의 효과를 비교할 수 있습니다

3. Observer Pattern: BatchLogger

로깅 시스템의 상태를 실시간으로 관찰할 수 있도록 Observer 패턴을 적용했습니다.

class BatchLogger {
  private observers: LogObserver[] = [];
  
  addObserver(observer: LogObserver) {
    this.observers.push(observer);
  }
  
  private notifyObservers(event: LogEvent) {
    this.observers.forEach(observer => observer.notify(event));
  }
  
  async flush() {
    // 로그 전송 시도
    this.notifyObservers({ type: 'FLUSH_START', queueSize: this.queue.length });
    
    try {
      await this.sendLogs();
      this.notifyObservers({ type: 'FLUSH_SUCCESS' });
    } catch (error) {
      this.notifyObservers({ type: 'FLUSH_FAILED', error });
    }
  }
}

// 개발자 도구에서 로그 상태 모니터링
batchLogger.addObserver({
  notify: (event) => {
    if (isDevelopment) {
      console.log(`BatchLogger: ${event.type}`, event);
    }
  }
});

🎯 핵심 설계 원칙

5가지 핵심 설계 원칙
  1. Fail-Safe Design (안전 실패)
    문제가 발생해도 시스템이 안전한 상태를 유지합니다
  2. Progressive Enhancement (점진적 향상)
    기본 기능은 보장하면서 추가 기능을 점진적으로 개선합니다
  3. Separation of Concerns (관심사 분리)
    각 레이어는 자신의 책임에만 집중합니다
  4. Observability (관찰 가능성)
    시스템의 내부 상태를 외부에서 관찰하고 디버깅할 수 있습니다
  5. Composability (조합성)
    작은 단위들을 조합해서 복잡한 기능을 구현합니다

📊 성능과 트레이드오프

성능 비용 분석

구성 요소 성능 비용 이유 최적화 방안
Zod 검증 +20~50ms 스키마 파싱, 객체 순회 스키마 캐싱, 필수 필드만 검증
백그라운드 로깅 +5~10ms 큐 추가, 메모리 할당 배치 크기 조정, 메모리 풀 사용
API 래퍼 +1~3ms 함수 호출 오버헤드 프록시 패턴 대신 직접 호출

메모리 사용량 최적화

메모리 효율성을 위해 큐 크기를 제한하고 FIFO 방식으로 오래된 로그를 제거합니다.

// 메모리 효율적인 배치 로거 설계
class BatchLogger {
  private queue: ValidationErrorLog[] = [];
  private readonly maxQueueSize = 100; // 메모리 사용량 제한
  
  addLog(log: ValidationErrorLog): void {
    // 큐가 가득 차면 오래된 로그 제거 (FIFO)
    if (this.queue.length >= this.maxQueueSize) {
      this.queue.shift(); // 메모리 누수 방지
    }
    
    this.queue.push(log);
  }
}

네트워크 효율성

배치 전송과 압축을 통해 네트워크 효율성을 확보했습니다.

// 배치 전송으로 네트워크 효율성 확보
const config = {
  maxBatchSize: 20,        // 한 번에 20개씩 전송
  flushInterval: 30000,    // 30초마다 전송
  compressionEnabled: true // gzip 압축 사용
};

🔮 확장성과 미래 고려사항

1. 다중 환경 지원

환경별로 다른 검증 전략을 적용하여 개발, 스테이징, 프로덕션 환경에 최적화된 검증을 수행합니다.

// 환경별 다른 검증 전략
const getValidationStrategy = () => {
  switch (process.env.NODE_ENV) {
    case 'development': return 'strict';
    case 'staging': return 'lenient';
    case 'production': return 'monitoring-only';
  }
};

2. A/B 테스트 지원

// 사용자 그룹별 다른 검증 활성화
const shouldEnableValidation = (userId: string) => {
  return hashUserId(userId) % 100 < 50; // 50% 사용자만 활성화
};

3. 실시간 설정 변경

// 원격에서 검증 설정 동적 변경
const validationConfig = reactive({
  enabled: true,
  batchSize: 20,
  apiFilters: ['critical-apis-only']
});

// 서버에서 설정 업데이트 받음
watchEffect(() => {
  batchLogger.updateConfig(validationConfig);
});
마무리
이 아키텍처 설계는 "실패에 강한 시스템"을 만들기 위한 철학적 기반을 제공하며, 실제 운영 환경에서의 예측 불가능한 상황들을 우아하게 처리할 수 있도록 설계되었습니다. 단순히 기술적인 구현을 넘어서, 사용자 경험과 개발자 경험 모두를 고려한 균형잡힌 접근법이라고 할 수 있습니다.