Solution
규칙을 한곳에 몰아넣고, 입력(Context)만 넣으면 결과(Available Providers)를 뱉어주는 자판기(Factory) 를 만들었습니다.
typescript
// PaymentFactory.tsexportconstPaymentFactory={getAvailableProviders(context:{ country:string; businessType?:string}){// 🇰🇷 한국: 사업자 유형에 따른 동적 필터링 (이곳에 분기 로직이 캡슐화됩니다!)if(context.country==='KR'){if(['CORPORATE','INDIVIDUAL'].includes(context.businessType)){return[PaymentProviderType.TOSS,PaymentProviderType.GENERAL];}return[PaymentProviderType.TOSS];}// ...},getAdapter(providerType:PaymentProviderType):IPaymentAdapter{const adapters:Partial<Record<PaymentProviderType,new()=>IPaymentAdapter>>={[PaymentProviderType.TOSS]:TossAdapter,};constAdapterClass= adapters[providerType];if(!AdapterClass){thrownewError(`${providerType}에 대한 어댑터를 찾을 수 없습니다.`);}returnnewAdapterClass();},};
⚖️ Trade-off:
(👍 장점) 복잡한 if-else 로직이 Factory 한곳으로 격리되어 관리가 쉬워집니다.
(👎 단점) 클래스나 객체가 늘어나고, 코드의 흐름을 따라가려면(Factory -> Adapter) 파일을 이동해야 하는 번거로움이 생깁니다.
🔗 3. 통합: "이 모든 것을 하나로 묶는 마법, usePayment Hook"
제아무리 좋은 설계도 사용하기 불편하면 실패합니다. usePayment 훅은 이 모든 복잡성을 감추고 단일 인터페이스를 제공합니다.
// usePayment.tsexportconstusePayment=()=>{constrequestPayment=async( context:{ country:string; businessType?:string}, providerType:PaymentProviderType, params:PaymentRequestParams,)=>{// 1. 비즈니스 로직 검증 (Factory에게 위임)const allowed =PaymentFactory.getAvailableProviders(context);if(!allowed.includes(providerType)){thrownewError('지원하지 않는 결제 수단입니다.');}// 2. 어댑터 획득 및 실행const adapter =PaymentFactory.getAdapter(providerType);await adapter.requestPayment(params);};return{ requestPayment };};
🛡️ 4. 디테일: Zod로 데이터 무결성 철통 방어
설계가 유연해진 만큼, 입력값에 대한 엄격한 검증이 중요해졌습니다. 팩토리가 올바른 객체를 생성하려면 입력값(Context)의 무결성이 보장되어야 하기 때문입니다.
"사업자일 때만 무통장 입금이 가능하다"는 규칙을 UI뿐만 아니라 데이터 스키마 레벨에서도 검증하고 싶었습니다. Zod의 discriminatedUnion이 빛을 발하는 순간입니다.
typescript
// schemas.ts// 1. 일반 유저 (해당 없음)constKrNormalSchema= z.object({ businessType: z.literal('NONE'), paymentMethod: z.literal(PaymentProviderType.TOSS),// 문자열 대신 enum 사용});// 2. 사업자 (개인/법인)constKrBusinessSchema= z.object({ businessType: z.enum(['INDIVIDUAL','CORPORATE']), paymentMethod: z.enum([PaymentProviderType.TOSS,PaymentProviderType.GENERAL,]),// 문자열 대신 enum 사용 registrationFile: z.instanceof(File),// 사업자등록증 필수});// 3. 통합 스키마exportconstKrPurchaseSchema= z.discriminatedUnion('businessType',[KrNormalSchema,KrBusinessSchema,]);
이제 잘못된 조합(예: NONE인데 GENERAL 선택)은 아예 타입 에러가 나거나 검증을 통과할 수 없습니다.
⚡️ Strategy Pattern을 활용한 'Lazy Loading UI'
로직만 분리한다고 끝이 아닙니다. UI도 전략 패턴으로 분리할 수 있습니다.
특히 React의 lazy를 활용하면, 한국 유저는 프랑스 결제 폼 코드를 다운로드할 필요가 없게 되어(Code Splitting) 초기 로딩 속도까지 잡을 수 있습니다.
typescript
// SelfPurchaseOrder/Create/index.tsximport{Suspense, lazy }from'react';// 1. 전략 정의 (Code Splitting)constUI_STRATEGIES={KR:lazy(()=>import('./KrCreate')),// 한국: 복잡한 사업자 로직 포함FR:lazy(()=>import('./FrCreate')),// 프랑스: 간소화된 UIDEFAULT:lazy(()=>import('./GlobalCreate')),};// 2. 전략 선택 (Strategy Selection)constPurchaseFormStrategy=({ country }:{ country:string})=>{// UI_STRATEGIES의 키에 대한 타입 가드 함수const isUIStrategyKey =(key:string): key iskeyoftypeofUI_STRATEGIES=> key inUI_STRATEGIES;constTargetForm=isUIStrategyKey(country)?UI_STRATEGIES[country]:UI_STRATEGIES.DEFAULT;return<TargetForm/>;};// 3. 상위 컴포넌트에서 우아한 로딩 처리 (Suspense at Page Level)exportconstPurchasePage=({ country }:{ country:string})=>{return(<Suspense fallback={<PageSkeleton/>}><PurchaseFormStrategy country={country}/></Suspense>);};
🎉 5. 결론: "복잡성은 숨기고, 유연함은 챙기고"
이제 팀장님의 질문에 자신 있게 대답할 수 있습니다.
"팀장님, 프랑스 추가든 한국 사업자 조건 변경이든 걱정 마세요. 비즈니스 규칙은 Factory에 모아뒀고, 실행은 Adapter가 알아서 하고, 데이터는 Zod가 지키고 있습니다. 요구사항만 말씀해 주세요."
✅ 이번 리팩토링으로 얻은 것
비즈니스 로직 격리: '사업자 유형별 제한' 같은 규칙이 PaymentFactory 한곳에 모였습니다.
안전한 확장: 새 국가나 결제 수단 추가 시 기존 코드를 건드리지 않아도 됩니다. (OCP)
타입 안전성: Zod를 통해 복잡한 조건부 유효성 검사를 선언적으로 해결했습니다.
📝 바로 적용해보기 체크리스트
비즈니스 규칙(if)이 UI 컴포넌트에 흩어져 있지 않은지 확인한다.
조건에 따라 객체 생성이 달라진다면 Factory Pattern을 도입한다.
실행 로직이 다양하다면 Adapter로 인터페이스를 통일한다.
복잡한 폼 검증은 Zod Discriminated Union으로 해결해본다.
여러분은 어떠신가요?
혹시 지금 보고 계신 코드에 "국가 코드"나 "유저 타입"을 체크하는 if문이 화면 곳곳에 흩어져 있나요? 그곳이 바로 팩토리 패턴이 필요한 지점일 수 있습니다. 댓글로 경험을 공유해주세요!