뱁새 다리찢기

들어가며

필터 기능 구현할 때마다 똑같은 고민이 반복되었습니다.  value와 label을 다르게 가져갈 때, 기획이 변경될 때 등등의 상황에 마주치면 config 객체가 하나하나 추가되고 관리포인트가 늘어납니다.

딱 봐도 중복이 많아지고… 기능 추가하다 빼먹으면 버그를 유발하기도 하고요

 

최근 회사에서 GEBRA SaaS 컨트롤 플레인 작업중에 고민하다가 이런 상황에서 벗어날 수 있는 패턴을 발견했습니다. 바로 Single Source of Truth(단일 진실 공급원) Config Pattern입니다.

문제 상황

온라인 쇼핑몰의 상품 필터 기능을 구현한다고 가정해 보겠습니다. 사용자는 카테고리(category), 배송 유형(deliveryType) 등으로 상품을 필터링할 수 있어야 합니다. 각 설정이 여러 파일에 흩어져 있다면 다음과 같은 모습일 겁니다.

// src/features/products/constants/categories.ts
// API 요청 및 유효성 검사에 사용될 카테고리 값 목록
export const PRODUCT_CATEGORIES = [
  'electronics',
  'fashion',
  'books',
  'home_goods',
];

// src/features/products/constants/delivery.ts
// 배송 유형 값 목록
export const DELIVERY_TYPES = [
  'rocket_delivery',
  'seller_direct',
  'overseas',
];

// src/features/products/utils/labels.ts
// 화면에 표시될 한글 라벨을 위한 별도의 맵 객체
export const CATEGORY_LABELS = {
  electronics: '전자기기',
  fashion: '패션/의류',
  books: '도서',
  home_goods: '생활용품',
};

// src/features/products/schemas/filter.ts
// Zod를 사용한 필터 유효성 검사 스키마
import { z } from 'zod';

export const ProductFilterSchema = z.object({
  category: z.enum([
    'electronics',
    'fashion',
    'books',
    'home_goods',
    'all', // 'all' 같은 예외 케이스도 하드코딩
  ]),
  deliveryType: z.enum([
    'rocket_delivery',
    'seller_direct',
    'overseas',
    'all',
  ]),
});

만약 여기에 새로운 카테고리인 '스포츠 용품(sports)'을 추가하려면 어떻게 해야 할까요? 최소 3개의 파일(categories.ts, labels.ts, filter.ts)을 모두 수정해야 합니다.

하나라도 빠뜨리면 UI에서는 보이는데 API 요청 값이 잘못되거나, 필터링이 제대로 동작하지 않는 등 버그가 발생하기 매우 쉬운 구조입니다.

해결책: 하나의 소스에서 모든 것을 파생시키기

핵심 아이디어는 간단합니다. 모든 필터 정보를 하나의 설정 객체(마스터 객체)에 담고, 나머지는 여기서 자동으로 파생시키는 것입니다. 관리 포인트를 하나로 줄이는 것이 핵심입니다.

// src/features/products/constants/filter.config.ts
import type { FilterOptionItem } from '@/types/filter';
import { z } from 'zod';

// =================================================================
// 1. 타입 정의: 각 필터의 value가 될 리터럴 타입들을 정의합니다.
// =================================================================
type Category = 'electronics' | 'fashion' | 'books' | 'home_goods';
type DeliveryType = 'rocket' | 'seller_direct' | 'overseas';
type Brand = 'samsung' | 'apple' | 'lg';


// =================================================================
// 2. 단일 진실 공급원 (Single Source of Truth)
// =================================================================
export const FILTER_OPTIONS = {
  category: [
    { value: 'electronics', label: '전자기기' },
    { value: 'fashion', label: '패션/의류' },
    { value: 'books', label: '도서' },
    { value: 'home_goods', label: '생활용품' },
  ],
  deliveryType: [
    { value: 'rocket', label: '로켓 배송' },
    { value: 'seller_direct', label: '판매자 직송' },
    { value: 'overseas', label: '해외 배송', disabled: true },
  ],
  brand: [
    { value: 'samsung', label: '삼성' },
    { value: 'apple', label: '애플' },
    { value: 'lg', label: 'LG' },
  ],
} as const satisfies {
  // `satisfies`를 통해 `FILTER_OPTIONS`의 구조와 타입을 검증합니다.
  // 만약 `value`에 오타가 있거나(예: 'fashionss'), 정의된 타입에 없는 값을 넣으면
  // 여기서 즉시 컴파일 에러가 발생합니다.
  category: readonly FilterOptionItem<Category>[];
  deliveryType: readonly FilterOptionItem<DeliveryType>[];
  brand: readonly FilterOptionItem<Brand>[];
};


// =================================================================
// 3. 필터 키(key) 그룹 정의
// =================================================================
export const SELECT_FILTER_KEYS = ['category', 'deliveryType', 'brand'] as const;
export const SEARCH_FILTER_KEYS = ['productName'] as const;


// =================================================================
// 4. 필터 표시 이름(label) 정의
// =================================================================
export const FILTER_KEY_LABELS = {
  category: '카테고리',
  deliveryType: '배송 유형',
  brand: '브랜드',
  productName: '상품명',
} as const;


// =================================================================
// 5. 모든 것이 여기서 자동으로 파생됩니다.
// =================================================================

// 각 필터 옵션의 라벨 맵 객체
export const CATEGORY_LABELS = Object.fromEntries(
  FILTER_OPTIONS.category.map(({ value, label }) => [value, label]),
);
export const DELIVERY_TYPE_LABELS = Object.fromEntries(
  FILTER_OPTIONS.deliveryType.map(({ value, label }) => [value, label]),
);

// 필터 초기 상태
export const INITIAL_FILTER_STATE = {
  category: [],
  deliveryType: [],
  brand: [],
  productName: '',
};

이제 '스포츠 용품' 카테고리를 추가하려면 어떻게 해야 할까요? FILTER_CONFIG 객체에 단 한 줄만 추가하면 됩니다.

// ...
export const FILTER_CONFIG = {
  category: {
    displayName: '카테고리',
    options: [
      { value: 'electronics', label: '전자기기' },
      { value: 'fashion', label: '패션/의류' },
      { value: 'books', label: '도서' },
      { value: 'home_goods', label: '생활용품' },
      { value: 'sports', label: '스포츠 용품' }, // 👈 이 한 줄만 추가
    ],
  },
//...

이렇게 하면 categoryConfig 내부의 labels, keys, schema가 모두 자동으로 업데이트됩니다. 더 이상 여러 파일을 찾아다니며 값을 동기화할 필요가 없고, 실수의 여지가 원천적으로 차단됩니다.

 

 

이 패턴을 사용해 페이팔, 세금명세서 결제수단 추가 요청을 두 줄 추가 딸깍으로 해결했습니다 vV

타입 안전성 챙기기

기본 구조는 단순하게 유지하면서, 필요에 따라 확장할 수 있도록 인터페이스, 컴포넌트를 설계했습니다:

// types/filter.ts 글로벌로 사용
export interface FilterOptionItem<T = string> {
  value: T;
  label: string;
}

// domains/payment/types.ts
export interface PaymentOptionItem extends FilterOptionItem {
  provider: string; // 결제 제공업체
  isEnabled: boolean; // 활성화 여부
}

// domains/user/types.ts
export interface UserRoleOptionItem extends FilterOptionItem {
  permissions: string[]; // 권한 목록
  level: number; // 권한 레벨
}

interface 선언 병합으로 기존 value, label 필드에 추가로 새로운 속성을 자유롭게 확장할 수 있습니다.

"이거 God Object 아닌가요?"

처음 이 패턴을 보면 "하나의 파일에 너무 많은 책임이 몰려있는 God Object 아니야?"라고 생각할 수 있습니다.

God Object가 문제가 되는 이유

객체지향 프로그래밍에서 God Object가 지양되는 이유는 명확합니다:

1. 너무 많은 책임(Behavior) 집중

// 안티패턴: 모든 걸 다 하는 God Object
class UserManager {
    public void validateUser() { /* ... */ }
    public void saveToDatabase() { /* ... */ }
    public void sendEmail() { /* ... */ }
    public void generateReport() { /* ... */ }
    public void processPayment() { /* ... */ }
    public void manageInventory() { /* ... */ }
    // ... 수십 개의 메서드들
}

2. 높은 결합도와 복잡한 의존성

  • 수많은 다른 클래스들과 얽혀있어서 하나 바꾸면 연쇄 반응
  • 의존성 주입이 복잡해져서 테스트하기 어려움
  • 코드 변경 시 예상치 못한 부작용 발생

3. 단일 책임 원칙(SRP) 위반

  • 하나의 클래스가 변경되는 이유가 너무 많음
  • 각각의 책임이 명확하게 분리되지 않음

우리 패턴은 왜 다른가?

하지만 우리가 다루는 설정 객체는 본질적으로 다릅니다:

1. 데이터 중심 vs 행위 중심

// 우리 패턴: 순수한 데이터 정의
export const FILTER_CONFIG = {
  category: {
    options: [
      { value: 'electronics', label: '전자기기' },
      { value: 'fashion', label: '패션/의류' },
    ],
  },
};

2. 읽기 전용 정적 데이터

  • 런타임에 변경되지 않는 const 객체
  • 상태 변화나 부작용이 없음
  • 순수 함수적 접근으로 파생값 생성

3. 명확한 파생 관계

// 모든 파생값이 FILTER_CONFIG를 기준으로 생성됨
// 의존성 방향이 단방향이고 명확함
CONFIG → LABELS
CONFIG → KEYS
CONFIG → SCHEMA

4. 높은 응집도, 낮은 결합도

  • 관련된 설정들이 한 곳에 모여있어 응집도 높음
  • 다른 모듈들은 이 설정을 '사용'만 할 뿐 직접 '의존'하지 않음
  • 설정 변경이 다른 모듈의 내부 로직에 영향을 주지 않음

실제로 이 패턴을 적용한 후 경험한 변화들:

Before: 분산된 설정들

  • 새 옵션 추가 시 3~4개 파일 수정 필요
  • 오타나 누락으로 인한 런타임 에러 빈발

After: 중앙 집중된 설정

  • 새 옵션 추가가 1줄로 끝남
  • 컴파일 타임에 모든 불일치 검출

결국 God Object의 진짜 문제는 '복잡한 행위들의 집중'이지, '관련된 데이터의 집중'이 아니라는 걸 깨달았습니다.

한계와 주의사항

물론 요 패턴도 은탄환이 아닙니다. 트레이드오프가 딱 봐도 보이죠

1. 간단한 경우엔 오버엔지니어링

모든 필터에 이 패턴을 적용할 필요는 없습니다.

  • 사용처가 한 곳뿐인 경우
  • 옵션이 2~3개로 고정인 경우
  • 다른 곳과 공유되지 않는 경우

이런 상황에서는 그냥 리터럴 배열이 더 나을 수 있습니다.

2. 애플리케이션이 거대해질 때: 도메인별 분리와 계층화

하나의 filter-config.ts 파일이 수백, 수천 줄로 비대해진다면, 이는 또 다른 형태의 'God Object'가 될 위험이 있습니다. 애플리케이션이 성장하면 이 패턴도 함께 진화해야 합니다.

해결책은 도메인별로 config 파일을 분리, 계층화하고, 필요에 따라 서로 참조하게 만드는 것입니다.

config/
├── domains/
│   ├── user/
│   │   ├── user-config.ts
│   │   └── types.ts
│   ├── payment/
│   │   ├── payment-config.ts
│   │   └── types.ts
│   └── product/
│       ├── product-config.ts
│       └── types.ts

이렇게 하면 각 도메인은 자신만의 '단일 진실 공급원'을 갖게 되어 응집도가 높아집니다. 더 나아가, 도메인 간의 의존성이 필요할 때 계층적인 구조를 만들 수 있습니다.

도메인 간 참조 예시:

// src/domains/user/user-config.ts
export const USER_ROLE_OPTIONS = [
  { value: 'guest', label: '비회원' },
  { value: 'member', label: '회원' },
  { value: 'vip', label: 'VIP' },
] as const;

// src/domains/payment/payment-config.ts
import { USER_ROLE_OPTIONS } from '@/domains/user/user-config';

const BASE_PAYMENT_OPTIONS = [
  { value: 'credit_card', label: '신용카드' },
  { value: 'bank_transfer', label: '계좌이체' },
];

const VIP_ONLY_PAYMENT_OPTION = {
  value: 'vip_pay',
  label: 'VIP 페이'
};

// 사용자 역할(다른 도메인)을 참조하여 동적으로 옵션 구성
export const getPaymentOptionsByRole = (role: string) => {
  if (role === 'vip') {
    return [...BASE_PAYMENT_OPTIONS, VIP_ONLY_PAYMENT_OPTION];
  }
  return BASE_PAYMENT_OPTIONS;
};

계층화의 장점:

  • 도메인별 응집도: 관련된 옵션들이 도메인 단위로 모여있음
  • 확장성: 새로운 도메인 추가가 기존 코드에 영향을 주지 않음
  • 재사용성: 다른 도메인의 옵션을 참조하여 조합 가능
  • 유지보수성: 특정 도메인 수정이 다른 도메인에 미치는 영향 최소화

 

뭐 사실 걍 label value를 한 설정객체에 합쳐버린거 아니냐? 라고 보실 수도 있습니다.

관련 있는 정보들을 시점이동 적게 한곳으로 뭉쳐서 휴먼에러를 방지하는데 의의를 둔 패턴이라고 보시면 될 것 같습니다.

아직까진 설계 후회한적 없습니다 아직까진...

profile

뱁새 다리찢기

@donghyk2-eric

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!