diff --git "a/doeun/03.\353\215\260\354\275\224\353\240\210\354\235\264\355\204\260_\355\214\250\355\204\264.md" "b/doeun/03.\353\215\260\354\275\224\353\240\210\354\235\264\355\204\260_\355\214\250\355\204\264.md" new file mode 100644 index 0000000..143d781 --- /dev/null +++ "b/doeun/03.\353\215\260\354\275\224\353\240\210\354\235\264\355\204\260_\355\214\250\355\204\264.md" @@ -0,0 +1,540 @@ +# 데코레이터 패턴 + +## 01. 들어가며 + +"이 버튼에 로딩 스피너 추가해줄 수 있어요?" +"API 요청에 인증 헤더 자동으로 붙여줬으면 해요." +"이 컴포넌트에 에러 바운더리 씌워주세요." + +이런 요구사항이 생길 때마다 기존 코드를 매번 수정하면 어떤 일이 벌어질까? + +초반에는 대응할 만하지만 수정이 반복될수록 코드는 비대해집니다. +결국 개발자는 변경 사항이 어디까지 영향을 주는지 가늠하기 어려워지고, 한 곳을 고쳤는데 예상치 못한 다른 곳이 깨지는 사이드 이펙트(Side Effect)가 발생한다. + +**데코레이터 패턴(Decorator Pattern)**은 바로 이런 상황에서 **'기존 코드를 건드리지 않고 기능을 확장'**하는 방법이다. + +데코레이터 패턴은 마치 커피 커스텀과 같다. +기본이 되는 '에스프레소' 샷은 절대 건드리지 않는다. +그 위에 물을 부으면 아메리카노가 되고, 우유를 넣으면 라떼가 되며, 휘핑크림을 얹으면 프라푸치노가 된다. +샷 자체의 로직(추출 방식)은 변하지 않지만, 바깥에서 무엇을 감싸느냐에 따라 기능과 성격이 확장되는 것이다. + +여기서 핵심은 +> 원본은 그대로 두고, 바깥에서 감싸 기능을 추가한다! + +--- + +## 02. 데코레이터 패턴이 뭔데? + +> "객체에 추가 요소를 동적으로 더할 수 있다. 데코레이터를 사용하면 서브클래스를 만들 때보다 훨씬 유연하게 기능을 확장할 수 있다." — Head First Design Patterns + +구조는 간단하다. 등장인물은 세 가지다. + +- **Component (원본)** — 실제 객체. 핵심 기능만 갖고 있다. +- **Decorator (데코레이터)** — 원본을 감싸는 래퍼. 원본과 **같은 타입**을 구현하면서 기능을 추가한다. +- **Client (사용하는 쪽)** — 원본인지 데코레이터인지 구분하지 않는다. 인터페이스가 같으니까. + +이걸 구조로 그려보면 이렇다. + +``` + ┌─────────────┐ + │ Component │ ← 공통 인터페이스 + │ (interface) │ + └──────┬───────┘ + │ + ┌────────┴────────┐ + │ │ + ┌─────┴─────┐ ┌─────┴──────┐ + │ Concrete │ │ Decorator │ ← Component를 감싼다 + │ Component │ │ (has-a │ + │ │ │ Component)│ + └────────────┘ └─────┬──────┘ + │ + ┌────────┴────────┐ + │ │ + ┌─────┴─────┐ ┌─────┴─────┐ + │ DecoratorA │ │ DecoratorB │ + └────────────┘ └────────────┘ +``` + +핵심은 **데코레이터가 원본과 같은 인터페이스를 구현한다**는 점이다. 그래서 원본 자리에 데코레이터를 끼워 넣어도 클라이언트는 눈치를 못 챈다. 그리고 데코레이터가 `Component`를 내부에 갖고 있기 때문에(**has-a 관계**), 여러 겹으로 쌓을 수도 있다. + +```ts +interface Coffee { + getDescription(): string; + getCost(): number; +} + +// 원본 +class Espresso implements Coffee { + getDescription() { return '에스프레소'; } + getCost() { return 2000; } +} + +// 데코레이터 — Coffee를 받아서 Coffee처럼 행동한다 +class MilkDecorator implements Coffee { + constructor(private coffee: Coffee) {} + getDescription() { return this.coffee.getDescription() + ', 우유'; } + getCost() { return this.coffee.getCost() + 500; } +} + +class ShotDecorator implements Coffee { + constructor(private coffee: Coffee) {} + getDescription() { return this.coffee.getDescription() + ', 샷 추가'; } + getCost() { return this.coffee.getCost() + 500; } +} + +// 사용: 데코레이터를 겹겹이 쌓는다 +let coffee: Coffee = new Espresso(); +coffee = new MilkDecorator(coffee); +coffee = new ShotDecorator(coffee); + +coffee.getDescription(); // "에스프레소, 우유, 샷 추가" +coffee.getCost(); // 3000 +``` + +에스프레소 클래스를 전혀 건드리지 않았다. 우유 데코레이터와 샷 데코레이터가 각자 자기 기능만 추가했을 뿐인데, 둘을 조합하니 "에스프레소 + 우유 + 샷" 음료가 만들어졌다. + +여기서 한 가지 짚고 넘어갈 점이 있다. 데코레이터는 상속(`is-a`)이 아니라 합성(`has-a`)을 사용한다. `MilkDecorator`는 `Coffee`를 **상속**하는 게 아니라, 내부에 `Coffee`를 **갖고** 있다. 같은 인터페이스를 구현하는 건 "타입 호환성"을 위해서일 뿐, 부모의 구현을 물려받기 위해서가 아니다. 이 차이가 뒤에서 다룰 "상속 대신 합성" 이야기의 핵심이다. + +--- + +## 03. 왜 필요할까? — OCP 원칙 + +헤드 퍼스트에서 강조하는 설계 원칙이 하나 등장한다. + +> **핵심 개념 — OCP (Open/Closed Principle)** +> "클래스는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다." +> 새 기능을 추가하고 싶을 때 기존 코드를 건드리는 것이 아니라, 새 코드를 추가하는 방식으로 확장해야 한다. + +이 원칙이 왜 중요한지 체감하기 어려울 수 있다. 좀 더 현실적인 상황을 생각해보자. + +어떤 `Button` 컴포넌트가 프로젝트 전체에서 47곳에 사용되고 있다. 여기에 로딩 상태를 추가하라는 요구사항이 들어왔다. `Button` 클래스를 직접 수정하면 47곳 전부가 영향을 받는다. 하지만 실제로 로딩이 필요한 곳은 3곳뿐이다. 나머지 44곳에서 예상치 못한 사이드 이펙트가 생기지 않을까 불안해진다. + +OCP를 지키면 이런 불안에서 벗어날 수 있다. Before/After로 비교해보자. + +#### ❌ Before — 클래스 직접 수정 +```ts +class Button { + label: string; + // 기능 추가마다 이 클래스를 열어야 한다 + isLoading: boolean; + isDisabled: boolean; + hasIcon: boolean; + tooltip: string; + + render() { + if (this.isLoading) { /* ... */ } + if (this.isDisabled) { /* ... */ } + if (this.hasIcon) { /* ... */ } + // if문이 계속 늘어난다 + } +} +``` + +#### ✅ After — 데코레이터로 확장 +```ts +// Button은 그대로 — 47곳에서 안전하게 동작한다 +class Button { + render() { return ''; } +} + +// 로딩 기능은 별도의 래퍼로 +class LoadingButton { + constructor(private btn: Button) {} + render() { return '⏳ ' + this.btn.render(); } +} + +// 필요한 3곳에서만 래핑해서 사용 +new LoadingButton(new Button()).render(); +``` + +Before 코드에서는 기능이 하나 추가될 때마다 `Button` 클래스에 새 속성과 분기문이 생기고, 그 `Button`을 쓰는 모든 곳에 잠재적 영향을 준다. After 코드에서는 `Button`은 절대 건드리지 않는다. 로딩 버튼, 아이콘 버튼 — 각 변형이 각자의 클래스를 가진다. + +--- + +## 04. 프론트엔드에서의 데코레이터 패턴 + +사실 우리는 이미 데코레이터 패턴을 매일 쓰고 있다. 가장 대표적인 예시가 React의 HOC, Axios 인터셉터, 그리고 고차 함수다. + +### HOC — 컴포넌트를 감싸는 컴포넌트 + +HOC(Higher-Order Component)는 데코레이터 패턴의 React 구현이다. 컴포넌트를 받아서 기능이 추가된 컴포넌트를 반환한다. + +```tsx +// 인증이 필요한 페이지에 withAuth를 씌운다 +function withAuth

(WrappedComponent: React.ComponentType

) { + return function AuthGuard(props: P) { + const { isLoggedIn } = useAuth(); + if (!isLoggedIn) return ; + return ; + }; +} + +// 에러 경계 데코레이터 +function withErrorBoundary

(WrappedComponent: React.ComponentType

) { + return function ErrorWrapper(props: P) { + return ( + }> + + + ); + }; +} + +// 데코레이터를 겹겹이 쌓을 수 있다 +const DashboardPage = withAuth(withErrorBoundary(Dashboard)); + +// Dashboard는 인증이고 에러고 신경 쓸 필요가 없다. +``` + +**참고: HOC vs 커스텀 훅** + +React 생태계에서 HOC는 커스텀 훅에 자리를 많이 내줬다. 인증 같은 경우 `useAuth()` 훅을 직접 쓰는 게 더 직관적일 수 있다. 하지만 HOC가 여전히 유용한 경우가 있다. + +- **컴포넌트를 렌더링 자체에서 차단**해야 할 때 (인증 안 된 사용자에게 아예 다른 화면을 보여주기) +- **여러 컴포넌트에 동일한 래핑 로직**을 반복 적용해야 할 때 +- **ErrorBoundary**처럼 클래스 컴포넌트 기반의 기능을 함수형으로 감쌀 때 + +즉, HOC = 데코레이터 패턴의 원형에 가깝고, 커스텀 훅 = 같은 문제를 다른 방식(합성)으로 풀어낸 거라고 볼 수 있다. + +### Axios 인터셉터 — 요청/응답을 감싸는 데코레이터 + +Axios 인터셉터도 정확히 같은 구조다. 실제 HTTP 요청 로직을 건드리지 않고, 앞뒤에 기능을 추가한다. + +```ts +// 인증 토큰을 자동으로 붙이는 데코레이터 +axios.interceptors.request.use((config) => { + const token = getToken(); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +// 에러 응답을 처리하는 데코레이터 +axios.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) logout(); + return Promise.reject(error); + } +); + +// 실제 API 호출 코드는 이 사실을 모른다 +// 그냥 axios.get('/api/data') 하면 토큰도 붙고 에러도 처리된다 +``` + +인터셉터의 동작 흐름을 그려보면 데코레이터의 "겹겹이 감싸기"가 명확하게 보인다. + +``` +요청 흐름: +axios.get('/api/data') + → [Request 인터셉터 1: 토큰 추가] + → [Request 인터셉터 2: 로깅] + → 실제 HTTP 요청 + → [Response 인터셉터 1: 에러 처리] + → [Response 인터셉터 2: 데이터 변환] +→ 최종 응답 +``` + +### 함수형 데코레이터 — 고차 함수 + +클래스 없이도 된다. 함수를 받아서 기능이 추가된 함수를 반환하는 고차 함수가 곧 데코레이터다. 사실 자바스크립트에서 가장 자연스러운 데코레이터 형태가 바로 이것이다. + +```ts +// 함수 실행 시간을 측정하는 데코레이터 +function withTiming any>(fn: T, name: string): T { + return function (...args) { + const start = performance.now(); + const result = fn(...args); + console.log(`[Timing] ${name}: ${(performance.now() - start).toFixed(2)}ms`); + return result; + } as T; +} + +// 에러를 잡아서 기본값을 반환하는 데코레이터 +function withFallback(fn: () => T, fallback: T): () => T { + return function () { + try { return fn(); } + catch { return fallback; } + }; +} + +// 여러 데코레이터 조합 +const safeParseJSON = withTiming(withFallback(JSON.parse, {}), 'JSON.parse'); + +safeParseJSON('{"name": "도은"}'); // {name: '도은'} + 시간 출력 +safeParseJSON('invalid json'); // {} (에러 대신 기본값) +``` + +이런 패턴은 lodash의 `_.debounce`, `_.throttle`에서도 찾아볼 수 있다. 원래 함수는 그대로인데, 감싸는 것만으로 디바운스/쓰로틀 기능이 추가된다. + +### TC39 데코레이터 — 언어 차원의 지원 + +잠깐 곁가지로 빠지면, JavaScript/TypeScript에서 데코레이터 문법 자체가 언어 표준으로 들어오고 있다. TC39 Stage 3에 도달한 Decorators 프로포절이 있고, TypeScript 5.0부터 이 표준 데코레이터를 지원한다. + +```ts +// TC39 표준 데코레이터 문법 +function logged(originalMethod: any, context: ClassMethodDecoratorContext) { + return function (...args: any[]) { + console.log(`[Call] ${String(context.name)}`); + return originalMethod.call(this, ...args); + }; +} + +class UserService { + @logged + getUser(id: string) { + return fetch(`/api/users/${id}`); + } +} +``` + +`@logged`라는 문법이 "이 메서드를 데코레이터로 감싸라"는 의미다. 우리가 직접 래퍼 함수를 만들 필요 없이 언어가 이 패턴을 지원하는 것이다. NestJS나 Angular 같은 프레임워크에서 `@Controller()`, `@Injectable()` 같은 문법을 봤다면, 그게 바로 이 패턴이다. + +--- + +## 05. 실무 활용 — API 레이어 구성하기 + +여러 기능이 조합되어야 하는 API 클라이언트 예시를 보자. 요구사항은 이렇다: 로깅, 재시도, 캐싱을 추가해야 한다. 각 기능을 독립적으로 개발하고, 필요에 따라 조합하고 싶다. + +```ts +// 기본 인터페이스 +interface ApiClient { + get(url: string): Promise; +} + +// 원본: 진짜 HTTP 요청만 담당한다 +class HttpClient implements ApiClient { + async get(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + } +} + +// 데코레이터 1: 로깅 +class LoggingClient implements ApiClient { + constructor(private client: ApiClient) {} + async get(url: string): Promise { + console.log(`[Request] GET ${url}`); + const start = performance.now(); + try { + const result = await this.client.get(url); + console.log(`[Response] GET ${url} ✓ (${(performance.now() - start).toFixed(0)}ms)`); + return result; + } catch (error) { + console.error(`[Error] GET ${url} ✗ (${(performance.now() - start).toFixed(0)}ms)`); + throw error; + } + } +} + +// 데코레이터 2: 실패 시 지수 백오프 재시도 +class RetryClient implements ApiClient { + constructor(private client: ApiClient, private maxRetries = 3) {} + async get(url: string): Promise { + let lastError: Error; + for (let i = 0; i < this.maxRetries; i++) { + try { return await this.client.get(url); } + catch (e) { + lastError = e as Error; + // 지수 백오프: 1초, 2초, 4초... + await new Promise(res => setTimeout(res, 1000 * Math.pow(2, i))); + } + } + throw lastError!; + } +} + +// 데코레이터 3: 캐싱 +class CachingClient implements ApiClient { + private cache = new Map(); + constructor(private client: ApiClient, private ttlMs = 60_000) {} + async get(url: string): Promise { + const cached = this.cache.get(url); + if (cached && cached.expiresAt > Date.now()) return cached.data as T; + const data = await this.client.get(url); + this.cache.set(url, { data, expiresAt: Date.now() + this.ttlMs }); + return data; + } +} +``` + +이제 이 데코레이터들을 조합한다. **안에서부터 바깥으로** 읽으면 실행 순서를 이해할 수 있다. + +```ts +const apiClient: ApiClient = new LoggingClient( // 4) 모든 요청/응답 로그 + new CachingClient( // 3) 캐시 확인 후 없으면 내부 호출 + new RetryClient(new HttpClient(), 3), // 2) 실패하면 재시도 → 1) 실제 요청 + 60_000 + ) +); + +// 사용하는 쪽은 이 모든 걸 모른다 +const data = await apiClient.get('/api/users'); +``` + +`HttpClient`는 자신이 재시도되는지, 캐싱되는지, 로깅되는지 전혀 모른다. 새 기능이 필요하면 데코레이터 하나를 추가하면 된다. 기존 코드는 건드리지 않는다. + +이 구조의 진가는 **환경별로 다른 조합**을 만들 수 있다는 데 있다. + +```ts +// 개발 환경: 로깅만 +const devClient = new LoggingClient(new HttpClient()); + +// 운영 환경: 재시도 + 캐싱 +const prodClient = new CachingClient( + new RetryClient(new HttpClient(), 3), + 60_000 +); + +// 테스트 환경: 모킹 +const testClient = new MockClient(); + +// 모두 같은 ApiClient 인터페이스. 사용하는 쪽은 차이를 모른다. +``` + +--- + +## 06. 상속 대신 합성 + +헤드 퍼스트에서 데코레이터 패턴을 설명하는 이유 중 하나는 **상속의 한계**를 보여주기 위해서다. + +#### ❌ 상속으로 모든 조합 만들기 +```ts +class Espresso {} +class EspressoWithMilk extends Espresso {} +class EspressoWithShot extends Espresso {} +class EspressoWithMilkAndShot extends EspressoWithMilk {} +// 옵션 3개 → 2³ = 8개 클래스 +// 옵션 5개 → 2⁵ = 32개 클래스 +// 옵션 10개 → 2¹⁰ = 1024개 클래스... 끝이 없다 +``` + +이것을 **클래스 폭발(class explosion)** 문제라고 부른다. 상속은 "컴파일 타임에 구조가 고정"되기 때문에, 가능한 모든 조합을 미리 만들어 놔야 한다. + +#### ✅ 데코레이터로 자유롭게 조합 +```ts +// 클래스는 4개면 충분하다 +class Espresso {} +class MilkDecorator {} +class ShotDecorator {} +class VanillaDecorator {} + +// 조합은 런타임에 자유롭게 +new VanillaDecorator( + new ShotDecorator( + new MilkDecorator(new Espresso()) + ) +); +// 옵션이 10개여도 클래스 11개(원본 1 + 데코레이터 10)면 끝 +// 조합의 수는 사실상 무한 +``` + +| 비교 항목 | 상속 | 데코레이터 (합성) | +|-----------|------|-------------------| +| 조합 방식 | 모든 경우의 수를 미리 클래스로 만듦 | 런타임에 필요한 것만 조합 | +| 옵션 N개일 때 | 최대 2^N개 클래스 | N+1개 클래스 | +| 구조 결정 시점 | 컴파일 타임 | 런타임 | +| 새 옵션 추가 | 기존 조합 × 2만큼 클래스 추가 | 데코레이터 1개 추가 | +| 기존 코드 수정 | 필요할 수 있음 | 불필요 | + +옵저버 패턴 글에서 "상속보다 합성"을 마지막에 다뤘는데, 데코레이터 패턴은 이 원칙이 **패턴 자체의 본질**이다. 데코레이터 패턴 = 합성으로 기능 확장하기. + +--- + +## 07. 유사 패턴과의 비교 + +데코레이터 패턴은 다른 구조 패턴들과 비슷해 보일 수 있다. 차이를 짚어두면 적재적소에 쓸 수 있다. + +**데코레이터 vs 어댑터(Adapter)**: 어댑터는 인터페이스를 **변환**한다. A 인터페이스를 B 인터페이스로 맞춰주는 게 목적이다. 반면 데코레이터는 인터페이스를 **유지**하면서 기능만 추가한다. + +**데코레이터 vs 프록시(Proxy)**: 프록시도 원본을 감싸지만, 목적이 다르다. 프록시는 접근 제어(지연 로딩, 권한 체크, 캐싱 등)가 주목적이고, 데코레이터는 기능 확장이 주목적이다. 구조는 거의 동일하기 때문에 "의도"로 구분한다. + +**데코레이터 vs 전략(Strategy)**: 전략 패턴은 **내부 알고리즘을 교체**한다. 데코레이터는 **외부에서 기능을 추가**한다. 전략은 "어떻게 할지"를 바꾸고, 데코레이터는 "무엇을 더할지"를 결정한다. + +--- + +## 08. 언제 도입해야 할까 + +아래 조건 중 2개 이상 해당되면 도입을 고려할 시점이다. + +1️⃣ **기존 클래스를 수정하기 어려울 때** — 라이브러리 코드거나 여러 곳에서 쓰이는 클래스라서 건드리기 부담스러울 때 + +2️⃣ **기능 조합의 경우의 수가 많을 때** — 로딩 + 비활성 + 에러 버튼처럼 조합을 상속으로 커버하면 클래스가 폭발한다 + +3️⃣ **런타임에 기능을 껐다 켰다 해야 할 때** — 상속은 컴파일 타임에 구조가 고정되지만, 데코레이터는 런타임에 조합을 바꿀 수 있다 + +4️⃣ **횡단 관심사를 분리하고 싶을 때** — 로깅, 인증, 캐싱, 에러 처리처럼 여러 곳에서 반복되는 부가 기능 + +반대로, 기능이 하나뿐이고 조합할 일이 없다면 굳이 데코레이터를 쓸 필요 없다. 간단한 if문이면 충분한 상황에서 패턴을 도입하면 오히려 과도한 추상화가 된다. + +--- + +## 09. 주의할 점 + +#### 타입이 깨질 수 있다 + +데코레이터가 원본과 같은 인터페이스를 구현해야 한다는 게 핵심인데, TypeScript에서 타입을 제대로 관리하지 않으면 조합 과정에서 타입 안전성이 무너진다. + +```ts +// ❌ 느슨한 타입 — 런타임에 터질 수 있다 +class BadDecorator { + constructor(private client: any) {} // any는 타입 체크를 포기하는 것 + get(url: string) { return this.client.get(url); } +} + +// ✅ 인터페이스를 명시적으로 구현 +class GoodDecorator implements ApiClient { + constructor(private client: ApiClient) {} + async get(url: string): Promise { + return this.client.get(url); + } +} +``` + +인터페이스를 선언하고, 데코레이터가 그 인터페이스를 `implements`하게 강제하는 습관이 중요하다. 컴파일 타임에 문제를 잡을 수 있다. + +#### 디버깅이 어려워질 수 있다 + +데코레이터가 여러 겹 쌓이면 에러 스택 트레이스가 래퍼 함수들로 가득 차서, 실제 문제가 어느 레이어에서 생겼는지 추적하기 까다롭다. 몇 가지 방법으로 완화할 수 있다. + +- 각 데코레이터에 **이름을 명시**한다 (함수형이라면 `Object.defineProperty`로 name 설정) +- 개발 환경에서 **현재 쌓인 데코레이터 체인을 출력**하는 유틸리티를 만들어둔다 +- 각 데코레이터의 진입/퇴장에 **로그를 남기는 디버그 모드**를 둔다 + +#### 데코레이터가 많아지면 복잡도가 올라간다 + +`new A(new B(new C(new D(new E(원본)))))` 같은 코드가 보이기 시작하면 경고 신호다. 팀 내에서 어떤 기능을 데코레이터로 빼고, 어떤 기능은 클래스 내부에 두는지 기준을 명확히 해야 한다. 자주 쓰는 조합은 **팩토리 함수로 묶어두는 것**도 방법이다. + +```ts +// 자주 쓰는 조합을 팩토리로 묶기 +function createApiClient(options?: { enableCache?: boolean }): ApiClient { + let client: ApiClient = new HttpClient(); + client = new RetryClient(client, 3); + if (options?.enableCache) client = new CachingClient(client); + client = new LoggingClient(client); + return client; +} +``` + +#### 순서가 중요하다 + +데코레이터는 쌓는 순서에 따라 동작이 달라질 수 있다. 예를 들어 로깅 데코레이터를 캐싱 바깥에 두면 캐시 히트도 로깅되지만, 안쪽에 두면 실제 요청만 로깅된다. 팀 내에서 데코레이터 조합 순서에 대한 컨벤션을 정해두면 혼란을 줄일 수 있다. + +--- + +## 10. 마무리 + +| 구분 | 내용 | +|------|------| +| **패턴** | 데코레이터 패턴 (Decorator Pattern) | +| **핵심 아이디어** | 원본을 감싸는 래퍼로 기능을 추가한다. 원본 코드는 건드리지 않는다. | +| **핵심 원칙** | OCP — 확장에는 열려 있고, 변경에는 닫혀 있어야 한다 | +| **상속 vs 합성** | 상속은 클래스 폭발을 일으킨다. 데코레이터는 합성으로 유연하게 조합한다. | +| **프론트엔드 예시** | HOC, Axios 인터셉터, 고차 함수, TC39 데코레이터 | +| **유사 패턴** | 어댑터(변환), 프록시(접근 제어), 전략(알고리즘 교체)과 구분 | + +도입 여부를 판단할 때 스스로에게 던질 질문은 하나다. + +> "기존 코드를 열지 않고 기능을 추가할 수 있는가?" +> 답이 **"아니오"** 라면, 데코레이터 패턴은 자연스러운 선택이 된다. diff --git "a/doeun/04.\355\214\251\355\206\240\353\246\254_\355\214\250\355\204\264" "b/doeun/04.\355\214\251\355\206\240\353\246\254_\355\214\250\355\204\264" new file mode 100644 index 0000000..65c2c8b --- /dev/null +++ "b/doeun/04.\355\214\251\355\206\240\353\246\254_\355\214\250\355\204\264" @@ -0,0 +1,685 @@ +# 팩토리 패턴 — "new를 직접 쓰지 마세요" + +## 들어가며 + +코드에서 `new`를 쓸 때마다 우리는 **구체 클래스에 의존**하게 됩니다. +지금은 요구사항이 2개지만, 곧 5개가 되고, 10개가 됩니다. +그때마다 `new` 주변의 코드가 줄줄이 바뀝니다. + +팩토리 패턴은 이 문제를 정면으로 해결합니다. +**객체 생성 로직을 캡슐화**해서, 클라이언트 코드는 "무엇을 만들지"만 요청하고 "어떻게 만드는지"는 전혀 모르게 하는 것이죠. + +이 글에서는 팩토리 패턴의 세 가지 단계를 점진적으로 살펴봅니다: + +1. **Simple Factory** — 관용적 패턴 (GoF 공식 패턴은 아님) +2. **Factory Method Pattern** — 서브클래스에게 생성을 위임 +3. **Abstract Factory Pattern** — 관련 제품군을 통째로 교체 + +개념을 잡은 뒤, **프론트엔드 실무에서 팩토리 패턴이 어떻게 쓰이는지**까지 다룹니다. + +--- + +## 1. 문제 상황 — Before + +피자 가게를 운영한다고 해봅시다. + +```typescript +function orderPizza(type: string): Pizza { + let pizza: Pizza; + + // 🚨 피자 종류가 바뀔 때마다 이 코드를 수정해야 한다 + if (type === "cheese") { + pizza = new CheesePizza(); + } else if (type === "pepperoni") { + pizza = new PepperoniPizza(); + } else if (type === "clam") { + pizza = new ClamPizza(); + } else { + throw new Error(`Unknown pizza type: ${type}`); + } + + // 이 아래는 변하지 않는 로직 + pizza.prepare(); + pizza.bake(); + pizza.cut(); + pizza.box(); + + return pizza; +} +``` + +얼핏 보면 문제 없어 보이지만, 이 코드에는 구조적인 약점이 있습니다. + +**첫째, 변경에 취약합니다.** +새 피자를 추가하거나 기존 피자를 제거할 때마다 `orderPizza` 함수 자체를 열어야 합니다. 피자 종류가 10개, 20개로 늘어나면 이 if-else 체인은 걷잡을 수 없이 길어집니다. + +**둘째, 생성과 사용이 강하게 결합되어 있습니다.** +`orderPizza`는 "어떤 피자를 만들지"와 "피자를 어떻게 처리할지"를 동시에 알고 있습니다. 단일 책임 원칙(SRP) 위반입니다. + +**셋째, OCP(개방-폐쇄 원칙)를 위반합니다.** +새로운 피자 종류를 추가하려면 기존 코드를 수정해야 합니다. 확장에는 열려 있어야 하지만, 변경에는 닫혀 있어야 한다는 원칙에 정면으로 어긋나죠. + +--- + +## 2. Simple Factory — 생성 로직을 분리하자 + +가장 먼저 할 수 있는 일은 **생성 로직을 별도 클래스로 추출**하는 것입니다. + +```typescript +class SimplePizzaFactory { + createPizza(type: string): Pizza { + switch (type) { + case "cheese": + return new CheesePizza(); + case "pepperoni": + return new PepperoniPizza(); + case "clam": + return new ClamPizza(); + default: + throw new Error(`Unknown pizza type: ${type}`); + } + } +} + +class PizzaStore { + private factory: SimplePizzaFactory; + + constructor(factory: SimplePizzaFactory) { + this.factory = factory; + } + + orderPizza(type: string): Pizza { + const pizza = this.factory.createPizza(type); // 생성은 팩토리에게 위임 + + pizza.prepare(); + pizza.bake(); + pizza.cut(); + pizza.box(); + + return pizza; + } +} +``` + +Simple Factory는 GoF가 정의한 공식 디자인 패턴이 아닙니다. 그보다는 **자주 쓰이는 프로그래밍 관용구(idiom)** 에 가깝습니다. 하지만 효과는 확실합니다. + +`PizzaStore`는 더 이상 구체 피자 클래스를 직접 참조하지 않습니다. 피자 메뉴가 바뀌면 `SimplePizzaFactory`만 수정하면 됩니다. 생성과 사용이 분리된 것이죠. + +**그런데 한계가 있습니다.** + +만약 피자 가게가 프랜차이즈로 확장되어 뉴욕 스타일, 시카고 스타일처럼 지역마다 다른 피자를 만들어야 한다면? Simple Factory 하나로는 모든 지역의 생성 로직을 담을 수 없고, 팩토리 자체를 통째로 교체해야 합니다. 확장에 유연하지 않죠. + +--- + +## 3. Factory Method Pattern — 서브클래스가 결정한다 + +> **정의:** 객체를 생성하기 위한 인터페이스를 정의하되, 어떤 클래스의 인스턴스를 만들지는 **서브클래스가 결정**하게 합니다. + +핵심 아이디어는 간단합니다. 상위 클래스에서 객체 생성 메서드를 `abstract`로 선언하고, 각 서브클래스가 자신만의 방식으로 이를 구현하는 것입니다. + +먼저 피자를 정의합니다: + +```typescript +abstract class Pizza { + name: string = ""; + dough: string = ""; + sauce: string = ""; + toppings: string[] = []; + + prepare(): void { + console.log(`준비 중: ${this.name}`); + console.log(`도우: ${this.dough}`); + console.log(`소스: ${this.sauce}`); + console.log(`토핑: ${this.toppings.join(", ")}`); + } + + bake(): void { + console.log("175도에서 25분 굽기"); + } + + cut(): void { + console.log("대각선으로 자르기"); + } + + box(): void { + console.log("포장하기"); + } +} + +// 뉴욕 스타일 +class NYStyleCheesePizza extends Pizza { + constructor() { + super(); + this.name = "뉴욕 스타일 치즈 피자"; + this.dough = "씬 크러스트"; + this.sauce = "마리나라 소스"; + this.toppings = ["모짜렐라 치즈"]; + } +} + +// 시카고 스타일 +class ChicagoStyleCheesePizza extends Pizza { + constructor() { + super(); + this.name = "시카고 스타일 치즈 피자"; + this.dough = "딥 디시"; + this.sauce = "플럼 토마토 소스"; + this.toppings = ["모짜렐라 치즈 (듬뿍)"]; + } + + override cut(): void { + console.log("네모나게 자르기"); // 시카고 스타일은 네모로 자른다 + } +} +``` + +이제 피자 가게를 팩토리 메서드로 구성합니다: + +```typescript +abstract class PizzaStore { + // 전체 흐름은 고정 (템플릿 메서드) + orderPizza(type: string): Pizza { + const pizza = this.createPizza(type); // 👈 팩토리 메서드 호출 + + pizza.prepare(); + pizza.bake(); + pizza.cut(); + pizza.box(); + + return pizza; + } + + // 👇 팩토리 메서드: 서브클래스가 구현 + protected abstract createPizza(type: string): Pizza; +} + +class NYPizzaStore extends PizzaStore { + protected createPizza(type: string): Pizza { + switch (type) { + case "cheese": + return new NYStyleCheesePizza(); + case "pepperoni": + return new NYStylePepperoniPizza(); + default: + throw new Error(`Unknown type: ${type}`); + } + } +} + +class ChicagoPizzaStore extends PizzaStore { + protected createPizza(type: string): Pizza { + switch (type) { + case "cheese": + return new ChicagoStyleCheesePizza(); + case "pepperoni": + return new ChicagoStylePepperoniPizza(); + default: + throw new Error(`Unknown type: ${type}`); + } + } +} +``` + +```typescript +// 사용 +const nyStore = new NYPizzaStore(); +const chicagoStore = new ChicagoPizzaStore(); + +nyStore.orderPizza("cheese"); +// → "뉴욕 스타일 치즈 피자", 씬 크러스트, 대각선 컷 + +chicagoStore.orderPizza("cheese"); +// → "시카고 스타일 치즈 피자", 딥 디시, 네모 컷 +``` + +### 왜 이렇게 하는 걸까? + +`PizzaStore`(상위 클래스)는 `createPizza`가 어떤 피자를 만드는지 전혀 모릅니다. 알 필요도 없습니다. `orderPizza`의 prepare → bake → cut → box 흐름은 어떤 피자든 동일하게 적용되고, "어떤 피자를 만들지"라는 결정만 서브클래스에 위임하는 것이죠. + +이 구조는 **템플릿 메서드 패턴**과 자연스럽게 결합됩니다. `orderPizza`가 전체 알고리즘의 뼈대를 정의하고, 그 중 하나의 단계(생성)만 서브클래스에게 맡기는 형태입니다. + +새로운 지역 스타일이 추가되어도 `PizzaStore`는 건드릴 필요가 없습니다. 새 서브클래스를 만들면 됩니다. OCP를 만족합니다. + +--- + +## 4. Abstract Factory Pattern — 제품군을 통째로 교체 + +Factory Method가 **하나의 제품**을 만드는 메서드를 서브클래싱한다면, Abstract Factory는 **관련된 제품군(family) 전체**를 만드는 인터페이스를 제공합니다. + +피자로 다시 생각해봅시다. 뉴욕 피자와 시카고 피자는 도우, 소스, 치즈 같은 **재료 세트**가 통째로 다릅니다. 이걸 개별적으로 관리하면 뉴욕 도우에 시카고 소스가 조합되는 불일치가 생길 수 있죠. + +```typescript +// 재료 인터페이스들 +interface Dough { + name: string; +} +interface Sauce { + name: string; +} +interface Cheese { + name: string; +} + +// 추상 팩토리: 재료 세트 전체를 만드는 인터페이스 +interface PizzaIngredientFactory { + createDough(): Dough; + createSauce(): Sauce; + createCheese(): Cheese; +} +``` + +```typescript +// 뉴욕 재료 팩토리 +class NYPizzaIngredientFactory implements PizzaIngredientFactory { + createDough(): Dough { + return { name: "씬 크러스트 도우" }; + } + createSauce(): Sauce { + return { name: "마리나라 소스" }; + } + createCheese(): Cheese { + return { name: "레지아노 치즈" }; + } +} + +// 시카고 재료 팩토리 +class ChicagoPizzaIngredientFactory implements PizzaIngredientFactory { + createDough(): Dough { + return { name: "딥 디시 도우" }; + } + createSauce(): Sauce { + return { name: "플럼 토마토 소스" }; + } + createCheese(): Cheese { + return { name: "모짜렐라 치즈" }; + } +} +``` + +```typescript +// 피자가 재료 팩토리를 사용 +class CheesePizza extends Pizza { + private ingredientFactory: PizzaIngredientFactory; + + constructor(ingredientFactory: PizzaIngredientFactory) { + super(); + this.ingredientFactory = ingredientFactory; + } + + override prepare(): void { + console.log(`준비 중: ${this.name}`); + const dough = this.ingredientFactory.createDough(); + const sauce = this.ingredientFactory.createSauce(); + const cheese = this.ingredientFactory.createCheese(); + console.log(`도우: ${dough.name}, 소스: ${sauce.name}, 치즈: ${cheese.name}`); + } +} +``` + +핵심은 `CheesePizza`가 **어떤 재료를 쓰는지 직접 알 필요가 없다**는 것입니다. 주입받은 팩토리가 알아서 일관된 재료 세트를 제공합니다. 팩토리만 바꾸면 뉴욕 스타일에서 시카고 스타일로 재료 전체가 한 번에 바뀝니다. + +### Factory Method vs Abstract Factory + +| 비교 | Factory Method | Abstract Factory | +|------|---------------|-----------------| +| 무엇을 만드나 | 하나의 제품 | 관련된 제품군(family) | +| 동작 방식 | **상속** — 서브클래스가 생성을 결정 | **구성(composition)** — 팩토리 객체를 주입 | +| 확장 방법 | 서브클래스 추가 | 새 팩토리 구현체 추가 | +| 새 제품 추가 시 | 비교적 쉬움 | 인터페이스 변경 필요 (모든 팩토리에 영향) | + +둘은 배타적이지 않습니다. Abstract Factory 내부에서 각 제품을 만들 때 Factory Method를 사용하는 경우도 흔합니다. + +--- + +## 5. 디자인 원칙: 의존성 역전 원칙 (DIP) + +팩토리 패턴의 이론적 근간이 되는 원칙입니다. + +> **"추상화에 의존하라. 구체 클래스에 의존하지 마라."** + +팩토리 패턴을 적용하기 전에는 상위 모듈(`PizzaStore`)이 하위 모듈(`NYStyleCheesePizza`, `ChicagoStyleCheesePizza`)에 직접 의존합니다. 새 피자가 추가될 때마다 상위 모듈을 수정해야 하죠. + +``` +❌ 팩토리 적용 전 (의존성이 위에서 아래로만 흐름) +PizzaStore → NYStyleCheesePizza +PizzaStore → ChicagoStyleCheesePizza +PizzaStore → NYStylePepperoniPizza +... +``` + +팩토리를 적용하면 상위 모듈과 하위 모듈 모두 **추상화(Pizza)**에 의존하게 됩니다. 의존성의 방향이 역전되는 것이죠. + +``` +✅ 팩토리 적용 후 (의존성 역전) +PizzaStore → Pizza (추상) +NYStyleCheese → Pizza (추상) +ChicagoStyleCheese → Pizza (추상) +``` + +이제 `PizzaStore`는 구체 클래스가 몇 개든, 어떤 것이든 상관없이 `Pizza` 추상화만 바라봅니다. + +**DIP를 지키기 위한 가이드라인:** + +- 변수에 구체 클래스 타입을 직접 쓰지 않기 +- 구체 클래스를 직접 상속하지 않기 (추상 클래스나 인터페이스에서 파생) +- 베이스 클래스에 이미 구현된 메서드를 오버라이드하지 않기 + +물론 이건 가이드라인이지 절대 법칙은 아닙니다. `new Date()`나 `new Map()` 같은 안정적인 클래스까지 팩토리로 감쌀 필요는 없습니다. **변경 가능성이 높은 구체 클래스에 대한 의존**을 줄이는 게 핵심입니다. + +--- + +## 6. 프론트엔드에서의 팩토리 패턴 + +"팩토리 패턴은 백엔드나 OOP에서나 쓰는 거 아닌가?" 싶을 수 있지만, 프론트엔드에서도 본질은 같습니다. **조건에 따라 다른 것을 만들어야 하는 상황**이 있다면, 그게 팩토리 패턴이 필요한 자리입니다. + +### 6-1. 컴포넌트 팩토리 + +조건에 따라 다른 UI를 렌더링하는 상황, 프론트엔드에서 가장 흔하게 마주치는 팩토리 패턴의 적용 지점입니다. + +```tsx +// ❌ Before: 컴포넌트 안에서 if-else 분기가 난무 +function NotificationBanner({ type, message }: Props) { + if (type === "success") { + return

{message}
; + } + if (type === "error") { + return
{message}
; + } + if (type === "warning") { + return
{message}
; + } + return
{message}
; +} +``` + +지금은 4가지뿐이지만, 새로운 알림 유형이 추가될 때마다 이 컴포넌트를 열어서 if-else를 추가해야 합니다. 앞서 본 피자 예제의 문제와 정확히 같은 구조입니다. + +```tsx +// ✅ After: 팩토리 맵으로 생성 로직 분리 +type NotificationType = "success" | "error" | "warning" | "info"; + +interface NotificationConfig { + bgColor: string; + textColor: string; + icon: React.ComponentType; +} + +const notificationFactory: Record = { + success: { bgColor: "bg-green-100", textColor: "text-green-800", icon: CheckIcon }, + error: { bgColor: "bg-red-100", textColor: "text-red-800", icon: XIcon }, + warning: { bgColor: "bg-yellow-100", textColor: "text-yellow-800", icon: AlertIcon }, + info: { bgColor: "bg-gray-100", textColor: "text-gray-800", icon: InfoIcon }, +}; + +function NotificationBanner({ type, message }: Props) { + const config = notificationFactory[type]; + const Icon = config.icon; + + return ( +
+ + {message} +
+ ); +} +``` + +`NotificationBanner` 컴포넌트는 이제 **렌더링만** 담당합니다. 새 알림 유형을 추가하려면 `notificationFactory` 맵에 한 줄만 추가하면 됩니다. 컴포넌트 코드는 건드릴 필요가 없습니다. + +이 패턴은 아이콘, 배지, 상태별 UI, 폼 필드 타입 등 **타입에 따라 다른 설정이 필요한 모든 컴포넌트**에 동일하게 적용됩니다. + +### 6-2. API 클라이언트 팩토리 + +환경(production, development, test)에 따라 서로 다른 HTTP 클라이언트를 사용해야 하는 상황은 실무에서 흔합니다. 프로덕션에서는 실제 API를, 개발 환경에서는 로컬 서버를, 테스트에서는 Mock을 쓰는 식이죠. + +```typescript +interface ApiClient { + get(url: string): Promise; + post(url: string, data: unknown): Promise; +} + +function createApiClient(env: "production" | "development" | "test"): ApiClient { + switch (env) { + case "production": + return new AxiosApiClient({ + baseURL: "https://api.example.com", + timeout: 5000, + interceptors: [authInterceptor, loggingInterceptor], + }); + + case "development": + return new AxiosApiClient({ + baseURL: "http://localhost:3000", + timeout: 30000, + interceptors: [loggingInterceptor], + }); + + case "test": + return new MockApiClient(); + } +} + +// 사용처에서는 환경을 신경 쓰지 않는다 +const api = createApiClient(process.env.NODE_ENV); +const users = await api.get("/users"); +``` + +사용하는 쪽에서는 `ApiClient` 인터페이스만 알면 됩니다. 내부적으로 Axios를 쓰든 fetch를 쓰든, mock을 쓰든 전혀 상관없습니다. 이것이 DIP가 실무에서 동작하는 모습입니다. + +이 패턴은 API 클라이언트 외에도 **로거(Logger)**, **스토리지 어댑터**, **분석 서비스(Analytics)** 등 환경별로 다른 구현체가 필요한 모든 인프라 레이어에 동일하게 적용됩니다. + +### 6-3. Query Key 팩토리 (TanStack Query) + +TanStack Query를 사용할 때 쿼리 키를 문자열 리터럴로 흩뿌려 놓으면, 오타 하나에 캐시가 꼬이고 invalidation이 의도대로 동작하지 않는 경험을 해보셨을 겁니다. + +쿼리 키를 **팩토리 객체**로 한 곳에서 관리하면 이 문제가 깔끔하게 해결됩니다. TanStack Query 공식 문서에서도 권장하는 패턴이며, [TkDodo의 블로그](https://tkdodo.eu/blog/effective-react-query-keys)에서 "Query Key Factory"라는 이름으로 정리된 바 있습니다. + +```typescript +// alarmQueryKeys.ts — 쿼리 키 팩토리 + +interface IAlarmParams { + status?: string; + type?: string; + startDate?: string; + endDate?: string; +} + +export const alarmQueryKeys = { + // 최상위: 도메인 전체 + all: () => ["alarm"] as const, + + // 목록 조회 + list: (params: IAlarmParams) => + [...alarmQueryKeys.all(), "info", params] as const, + + // count 관련 쿼리를 하나의 그룹으로 묶기 + counts: () => [...alarmQueryKeys.all(), "counts"] as const, + count: () => [...alarmQueryKeys.counts(), "total"] as const, + searchCount: (params: IAlarmParams) => + [...alarmQueryKeys.counts(), "search", params] as const, +}; +``` + +```typescript +// 쿼리에서 사용 +const { data: totalCount } = useQuery({ + queryKey: alarmQueryKeys.count(), + queryFn: getAlarmCount, +}); + +const { data: searchCount } = useQuery({ + queryKey: alarmQueryKeys.searchCount(alarmParams), + queryFn: () => getAlarmSearchCount(alarmParams), +}); +``` + +```typescript +// 뮤테이션 후 invalidation — counts 그룹을 한 번에 날리기 +const deleteAlarmMutation = useMutation({ + mutationFn: deleteAlarm, + onSuccess: () => { + // counts() 하위의 count, searchCount 모두 무효화 + queryClient.invalidateQueries({ queryKey: alarmQueryKeys.counts() }); + queryClient.invalidateQueries({ queryKey: alarmQueryKeys.list(alarmParams) }); + }, +}); +``` + +**이 구조가 좋은 이유:** + +`as const`로 튜플 타입이 추론되기 때문에 오타가 나면 TypeScript가 잡아줍니다. 계층 구조 덕분에 `counts()`만 invalidate하면 그 하위의 `count()`와 `searchCount()` 모두 자동으로 무효화됩니다. 그리고 새 쿼리가 추가되어도 팩토리 객체 하나만 수정하면 됩니다. + +앞서 본 Simple Factory와 본질이 같습니다. 쿼리 키 "생성 로직"을 한 곳으로 모아서, 사용처에서는 `alarmQueryKeys.count()`처럼 "뭘 원하는지"만 표현하는 것이죠. + +### 6-4. 라우트 경로 팩토리 + +React Router에서 path 문자열을 컴포넌트 곳곳에 하드코딩하면, 경로 하나 바꿀 때 `Cmd+Shift+F`로 전역 검색해서 여러 파일을 수정해야 합니다. 경로도 "생성할 수 있는 것"으로 보면, 팩토리로 관리할 수 있습니다. + +```typescript +// paths.ts — 경로 팩토리 + +const PATHS = { + AUTH: { + LOGIN: () => "/", + }, + + MAIN: { + ROOT: () => "/main", + USER_INFO: () => "/main/userinfo", + }, + + MANAGEMENT: { + HOUSING: () => "/main/management/housing", + HOUSING_DETAIL: (housingId = ":housingId") => + `/main/management/housing/${housingId}`, + VEHICLE: () => "/main/management/vehicle", + VEHICLE_DETAIL: (vehicleId = ":vehicleId") => + `/main/management/vehicle/${vehicleId}`, + }, + + CONSULTATION: { + APPROVAL_LIST: (groupId = ":documentGroupId") => + `/main/consultation/approval/${groupId}`, + APPROVAL_DETAIL: (groupId = ":documentGroupId", docId = ":documentId") => + `/main/consultation/approval/${groupId}/${docId}`, + DRAFT_LIST: (groupId = ":documentGroupId") => + `/main/consultation/draft/${groupId}`, + DRAFT_CREATE: (groupId = ":documentGroupId") => + `/main/consultation/draft/${groupId}/create`, + }, + + ADMIN: { + USER_LIST: () => "/admin/userlist", + USER_DETAIL: (employeeId = ":employeeId") => + `/admin/user/${employeeId}`, + CHECK_LIST_DETAIL: (id = ":checkListId") => + `/admin/checklist/${id}`, + CATEGORY_DETAIL: (id = ":documentCategoryId") => + `/admin/category/${id}`, + }, + + NOT_FOUND: () => "*", +} as const; + +export default PATHS; +``` + +이 팩토리의 핵심 트릭은 **기본값 매개변수**입니다: + +```tsx +// 라우트 정의 — 인자 없이 호출하면 :param 패턴을 반환 +} /> + +// 네비게이션 — 실제 값을 넘기면 구체 경로를 반환 +navigate(PATHS.MANAGEMENT.HOUSING_DETAIL("h-123")); +// → "/main/management/housing/h-123" +``` + +하나의 함수가 **라우트 정의용 패턴**과 **네비게이션용 구체 경로**를 동시에 생성합니다. 경로가 변경되면 `PATHS` 하나만 수정하면 되고, TypeScript가 모든 사용처의 타입 안전성을 보장합니다. + +### 6-5. 폼 스키마 팩토리 (Zod + React Hook Form) + +복잡한 폼에서 조건에 따라 다른 유효성 검증 스키마가 필요할 때, 스키마 생성을 팩토리로 분리하면 깔끔합니다. + +```typescript +import { z } from "zod"; + +// 사용자 유형별로 다른 검증 규칙이 필요한 상황 +type UserRole = "individual" | "business" | "admin"; + +function createUserSchema(role: UserRole) { + // 공통 필드 + const base = z.object({ + name: z.string().min(1, "이름을 입력해주세요"), + email: z.string().email("올바른 이메일을 입력해주세요"), + }); + + switch (role) { + case "individual": + return base.extend({ + phone: z.string().regex(/^01[016789]-?\d{3,4}-?\d{4}$/, "올바른 휴대폰 번호를 입력해주세요"), + }); + + case "business": + return base.extend({ + companyName: z.string().min(1, "회사명을 입력해주세요"), + businessNumber: z.string().length(10, "사업자번호는 10자리입니다"), + department: z.string().optional(), + }); + + case "admin": + return base.extend({ + employeeId: z.string().min(1, "사번을 입력해주세요"), + accessLevel: z.enum(["read", "write", "super"]), + }); + } +} +``` + +```tsx +// 컴포넌트에서 사용 +function RegistrationForm({ role }: { role: UserRole }) { + const schema = createUserSchema(role); + type FormData = z.infer; + + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }); + + return ( +
+ {/* role에 따라 동적으로 결정된 스키마가 적용됨 */} + + {role === "business" && } + {/* ... */} +
+ ); +} +``` + +`RegistrationForm`은 "어떤 역할이냐"만 알면 됩니다. 어떤 필드가 필요하고 어떤 규칙으로 검증하는지는 `createUserSchema` 팩토리가 결정합니다. 역할이 추가되면 팩토리에 case를 추가하면 되고, 폼 컴포넌트는 변경할 필요가 없습니다. + +--- + +## 7. 정리 + +### 언제 무엇을 쓸까 + +| 상황 | 선택 | +|------|------| +| 생성 로직이 한 곳에서만 쓰인다 | Simple Factory | +| 서브클래스마다 다른 제품을 만들어야 한다 | Factory Method | +| 관련된 제품군을 일관되게 교체해야 한다 | Abstract Factory | +| 타입별 컴포넌트 렌더링 | 컴포넌트 팩토리 맵 | +| 환경별 설정이 다른 객체 생성 | 팩토리 함수 | +| 쿼리 키 / 라우트 경로 관리 | 팩토리 객체 | +| 조건별 폼 유효성 검증 | 스키마 팩토리 함수 | + +### 핵심 요약 + +1. **팩토리 패턴의 본질은 `new`의 캡슐화**입니다. 클라이언트가 구체 클래스를 직접 알지 않아도 되게 만듭니다. +2. **Factory Method**는 상속을 통해, **Abstract Factory**는 구성(composition)을 통해 동작합니다. 둘은 배타적이지 않습니다. +3. **DIP(의존성 역전 원칙)**: 변경 가능성이 높은 구체 클래스가 아닌, 안정적인 추상화에 의존하세요. +4. 프론트엔드에서 팩토리 패턴은 거창한 클래스 구조가 아니라, **Record 맵**, **팩토리 함수**, **키 생성 객체** 같은 가벼운 형태로 자연스럽게 녹아듭니다. + +팩토리 패턴을 적용할지 판단하는 기준은 간단합니다. **"조건에 따라 다른 것을 만들어야 하는데, 그 조건이 앞으로도 변할 가능성이 높은가?"** 그렇다면 팩토리로 분리할 타이밍입니다.