들어가며
필터 기능 구현할 때마다 똑같은 고민이 반복되었습니다. 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를 한 설정객체에 합쳐버린거 아니냐? 라고 보실 수도 있습니다.
관련 있는 정보들을 시점이동 적게 한곳으로 뭉쳐서 휴먼에러를 방지하는데 의의를 둔 패턴이라고 보시면 될 것 같습니다.
아직까진 설계 후회한적 없습니다 아직까진...
'<frontend>' 카테고리의 다른 글
| 주석대신 순수함수로 TODO적기 (20) | 2025.10.19 |
|---|---|
| 역할 기반 엑세스 제어(RBAC) (4) | 2025.09.29 |
| 프론트엔드 검색 알고리즘 최적화(Feat. Trie) (12) | 2025.08.09 |
| pretendard에 숫자 고정폭 적용하기 (4) | 2024.10.27 |
| javascript 비동기 순서보장 잡기술 (0) | 2024.09.29 |