junyeokk
Blog
Design Pattern·2026. 02. 04

Facade 패턴 + Handler Map

백엔드에서 결제를 처리한다고 생각해보자. Stripe도 있고, KIS 단말기도 있고, 나중에 카카오페이가 추가될 수도 있다. 각 결제 게이트웨이마다 초기화 방식, 완료 검증, 취소 로직이 전부 다르다. 이걸 하나의 서비스에 if-else로 처리하면 어떻게 될까?

typescript
async initiatePayment(gateway: string, data: any) {
  if (gateway === 'stripe') {
    // Stripe PaymentIntent 생성...
    // 30줄
  } else if (gateway === 'kis') {
    // KIS는 클라이언트 사이드라 서버에서 할 게 없음
    // 10줄
  } else if (gateway === 'kakaopay') {
    // 카카오페이 결제 준비 API 호출...
    // 40줄
  }
}

async completePayment(gateway: string, data: any) {
  if (gateway === 'stripe') {
    // ...
  } else if (gateway === 'kis') {
    // ...
  }
  // 또 반복
}

게이트웨이가 추가될 때마다 모든 메서드의 if-else를 수정해야 한다. 한 게이트웨이의 로직을 고치다가 다른 게이트웨이의 분기를 건드릴 위험도 있다. 메서드 하나가 수백 줄이 되면 어디서 뭘 하는지 파악하기도 어렵다. 이건 개방-폐쇄 원칙(OCP)을 완전히 위반하는 구조다.


Facade 패턴이란

Facade(파사드)는 복잡한 하위 시스템에 대한 단순화된 인터페이스를 제공하는 패턴이다. 클라이언트는 여러 개의 복잡한 클래스를 직접 다루는 대신, Facade 하나만 호출하면 된다.

핵심은 "하나의 진입점"이다. 호출하는 쪽(컨트롤러, 다른 서비스)은 내부에 Stripe 핸들러가 있는지, KIS 핸들러가 있는지 알 필요가 없다. PaymentsService.initiatePayment()만 호출하면 된다.

text
Controller → PaymentsService(Facade) → StripeHandler
                                      → KisHandler
                                      → KakaoHandler

Facade 패턴을 단독으로 사용하면 결국 Facade 내부에 if-else가 생긴다. 그래서 Handler Map 패턴과 결합한다.


Handler Map 패턴

Handler Map은 문자열이나 enum 키를 실제 핸들러 인스턴스에 매핑하는 Map 자료구조를 사용하는 패턴이다. if-else 분기를 Map 조회(lookup)로 대체한다.

Strategy 패턴과 비슷하지만 미묘한 차이가 있다. Strategy 패턴은 "알고리즘을 캡슐화해서 교체 가능하게 만든다"는 개념에 초점이 있고, 보통 런타임에 전략을 주입받는다. Handler Map은 "여러 핸들러를 등록해두고 키로 디스패치한다"는 구조적 패턴에 가깝다. 실제로 둘은 자주 함께 쓰인다.

핵심 아이디어:

typescript
// if-else 대신
const handler = this.handlers.get(key);
handler.doSomething();

이게 전부다. 분기 로직이 사라지고, 새 핸들러를 추가할 때 Map에 등록만 하면 된다.


인터페이스 설계

Handler Map 패턴의 전제 조건은 모든 핸들러가 동일한 인터페이스를 구현하는 것이다. 인터페이스가 없으면 Map에서 꺼낸 핸들러의 메서드를 호출할 수 없다.

typescript
// 공통 인터페이스 정의
interface PaymentGatewayHandler {
  readonly gateway: PaymentGateway;

  initiate(context: InitiateContext): Promise<InitiateResult>;
  complete(context: CompleteContext): Promise<CompleteResult>;
  cancelPrevious?(externalId: string): Promise<void>;
}

몇 가지 설계 포인트가 있다.

readonly 식별자

각 핸들러가 자신이 어떤 게이트웨이인지 식별할 수 있는 필드를 갖는다. Map에 등록할 때 키로 사용하기도 하고, 런타임에 핸들러가 자신의 정체를 확인할 때도 유용하다.

typescript
class StripeHandler implements PaymentGatewayHandler {
  readonly gateway = PaymentGateway.STRIPE;
  // ...
}

선택적 메서드

모든 게이트웨이가 취소를 지원하는 건 아니다. KIS 단말기 결제는 서버에서 취소할 수 없고, Stripe는 PaymentIntent를 취소할 수 있다. 이런 경우 인터페이스에서 ?로 선택적 메서드로 정의한다.

typescript
cancelPrevious?(externalId: string): Promise<void>;

호출하는 쪽에서는 존재 여부를 확인한 후 호출한다:

typescript
if (handler.cancelPrevious) {
  await handler.cancelPrevious(oldPaymentId);
}

Context 객체 패턴

메서드의 파라미터를 개별 인자로 나열하는 대신 Context 객체로 묶는다. 이유는 단순하다. 파라미터가 추가되어도 인터페이스 시그니처가 변하지 않는다. 모든 핸들러 구현체를 수정할 필요가 없다.

typescript
// 나쁜 예: 파라미터 추가 시 모든 구현체 수정 필요
initiate(session: Session, amount: number, dto: InitiateDto): Promise<Result>;

// 좋은 예: Context에 필드 추가만 하면 됨
interface InitiateContext {
  session: Session;
  amount: number;
  dto: InitiateDto;
  existingPayment?: Payment;  // 나중에 추가해도 기존 핸들러에 영향 없음
}

initiate(context: InitiateContext): Promise<Result>;

Map 구성과 디스패치

Facade 서비스의 생성자에서 핸들러들을 Map에 등록한다.

typescript
@Injectable()
export class PaymentsService {
  private readonly handlers: Map<PaymentGateway, PaymentGatewayHandler>;

  constructor(
    private readonly kisHandler: KisPaymentHandlerService,
    private readonly stripeHandler: StripePaymentHandlerService,
  ) {
    this.handlers = new Map<PaymentGateway, PaymentGatewayHandler>([
      [PaymentGateway.KIS, this.kisHandler],
      [PaymentGateway.STRIPE, this.stripeHandler],
    ]);
  }
}

왜 Map인가

일반 객체({})로도 키-값 매핑은 가능하다. 하지만 Map이 더 적합한 이유가 있다.

비교 항목ObjectMap
키 타입문자열, Symbol만어떤 타입이든 가능 (enum 포함)
순회Object.keys() 필요for...of 직접 순회
크기 확인Object.keys(obj).lengthmap.size
프로토타입 오염__proto__ 등 예약 키 충돌 가능없음

특히 TypeScript에서 enum을 키로 사용할 때 Map이 자연스럽다. 객체는 키가 문자열로 변환되기 때문에 타입 안전성이 떨어진다.

getHandler 메서드

Map에서 핸들러를 꺼내는 로직을 별도 메서드로 분리한다. 존재하지 않는 키에 대한 방어 로직을 한 곳에서 처리한다.

typescript
private getHandler(gateway: PaymentGateway): PaymentGatewayHandler {
  const handler = this.handlers.get(gateway);
  if (!handler) {
    throw new BadRequestException(`Unsupported payment gateway: ${gateway}`);
  }
  return handler;
}

이 메서드가 있으면 Facade의 비즈니스 메서드들이 깔끔해진다:

typescript
async initiatePayment(sessionId: string, dto: InitiateDto) {
  const session = await this.sessionsRepo.findById(sessionId);
  const gateway = this.resolveGateway(session);
  const handler = this.getHandler(gateway);

  const result = await handler.initiate({
    session,
    amount: session.totalAmount,
    dto,
  });

  return result;
}

분기문이 완전히 사라졌다. resolveGateway()로 어떤 게이트웨이를 사용할지 결정하고, getHandler()로 해당 핸들러를 가져온 다음, 인터페이스 메서드를 호출할 뿐이다.


핸들러 구현

각 핸들러는 공통 인터페이스를 구현하되, 내부 로직은 완전히 독립적이다.

KIS 핸들러

KIS 단말기 결제는 클라이언트(키오스크) 사이드에서 처리된다. 서버는 결과만 검증한다.

typescript
@Injectable()
export class KisPaymentHandlerService implements PaymentGatewayHandler {
  readonly gateway = PaymentGateway.KIS;

  async initiate(context: InitiateContext): Promise<InitiateResult> {
    // KIS는 서버에서 초기화할 게 없다
    return {};
  }

  async complete(context: CompleteContext): Promise<CompleteResult> {
    const { dto } = context;
    const kisData = dto.gatewayData as KisPaymentResponse;

    if (kisData.replyCode !== '0000') {
      return {
        isSuccess: false,
        replyCode: kisData.replyCode,
        paymentData: this.mapToPaymentData(kisData),
      };
    }

    return {
      isSuccess: true,
      replyCode: kisData.replyCode,
      paymentData: this.mapToPaymentData(kisData),
    };
  }

  private mapToPaymentData(kisData: KisPaymentResponse): PaymentData {
    return {
      approvalNumber: kisData.approvalNumber,
      cardNumber: kisData.maskedCardNumber,
      // ... KIS 전용 필드 매핑
    };
  }
}

Stripe 핸들러

Stripe는 서버에서 PaymentIntent를 생성하고, 클라이언트에서 결제를 완료한 후, 서버에서 결과를 검증하는 3단계 흐름이다.

typescript
@Injectable()
export class StripePaymentHandlerService implements PaymentGatewayHandler {
  readonly gateway = PaymentGateway.STRIPE;

  constructor(private readonly stripe: Stripe) {}

  async initiate(context: InitiateContext): Promise<InitiateResult> {
    // 이전 결제가 있으면 취소
    if (context.existingPayment?.externalPaymentId) {
      await this.cancelPrevious(context.existingPayment.externalPaymentId);
    }

    const paymentIntent = await this.stripe.paymentIntents.create({
      amount: context.amount,
      currency: 'krw',
    });

    return {
      externalPaymentId: paymentIntent.id,
      clientSecret: paymentIntent.client_secret,
    };
  }

  async complete(context: CompleteContext): Promise<CompleteResult> {
    const intent = await this.stripe.paymentIntents.retrieve(
      context.payment.externalPaymentId,
    );

    return {
      isSuccess: intent.status === 'succeeded',
      replyCode: intent.status,
      paymentData: this.mapToPaymentData(intent),
    };
  }

  async cancelPrevious(externalId: string): Promise<void> {
    await this.stripe.paymentIntents.cancel(externalId);
  }

  private mapToPaymentData(intent: Stripe.PaymentIntent): PaymentData {
    return {
      approvalNumber: intent.id,
      // ... Stripe 전용 필드 매핑
    };
  }
}

두 핸들러는 같은 인터페이스를 구현하지만, 내부 로직은 전혀 다르다. KIS는 initiate에서 아무것도 안 하고 cancelPrevious도 구현하지 않는다. Stripe는 외부 API 호출이 필요하다. 이 차이가 인터페이스 뒤에 깔끔하게 숨는다.


게이트웨이 결정 로직 분리

어떤 핸들러를 사용할지 결정하는 로직도 Facade에 위치한다. 이 로직은 핸들러와 무관하게 독립적이다.

typescript
private resolveGateway(session: Session): PaymentGateway {
  return session.device.paymentGateway;
}

이 예시에서는 세션에 연결된 디바이스 설정에서 게이트웨이를 가져온다. 키오스크 기기마다 다른 결제 수단을 사용할 수 있기 때문이다. 결정 로직이 복잡해져도(국가별, 금액별, A/B 테스트 등) Facade 안에서만 변경하면 된다. 핸들러는 영향받지 않는다.


새 핸들러 추가하기

카카오페이를 추가한다고 하자. 변경 범위를 보자.

1단계: enum에 값 추가

typescript
enum PaymentGateway {
  KIS = 'kis',
  STRIPE = 'stripe',
  KAKAO = 'kakao',  // 추가
}

2단계: 핸들러 구현

typescript
@Injectable()
export class KakaoPaymentHandlerService implements PaymentGatewayHandler {
  readonly gateway = PaymentGateway.KAKAO;

  async initiate(context: InitiateContext): Promise<InitiateResult> {
    // 카카오페이 결제 준비 API 호출
    const response = await this.kakaoApi.ready({
      totalAmount: context.amount,
      // ...
    });
    return { externalPaymentId: response.tid };
  }

  async complete(context: CompleteContext): Promise<CompleteResult> {
    // 카카오페이 결제 승인 API 호출
    // ...
  }

  async cancelPrevious(externalId: string): Promise<void> {
    // 카카오페이 결제 취소 API 호출
    // ...
  }
}

3단계: Map에 등록

typescript
constructor(
  private readonly kisHandler: KisPaymentHandlerService,
  private readonly stripeHandler: StripePaymentHandlerService,
  private readonly kakaoHandler: KakaoPaymentHandlerService,  // 추가
) {
  this.handlers = new Map([
    [PaymentGateway.KIS, this.kisHandler],
    [PaymentGateway.STRIPE, this.stripeHandler],
    [PaymentGateway.KAKAO, this.kakaoHandler],  // 추가
  ]);
}

기존 코드를 수정한 부분은 Map 등록 한 줄뿐이다. initiatePayment, completePayment 등 비즈니스 메서드는 건드리지 않았다. 이게 개방-폐쇄 원칙(OCP)이다. 확장에는 열려 있고, 수정에는 닫혀 있다.


자동 등록: NestJS에서의 고급 패턴

핸들러가 10개, 20개로 늘어나면 생성자에서 일일이 주입하고 Map에 등록하는 게 번거롭다. NestJS의 DI 기능을 활용하면 자동으로 등록할 수 있다.

typescript
// 커스텀 토큰 정의
const PAYMENT_HANDLERS = Symbol('PAYMENT_HANDLERS');

// 모듈에서 배열로 주입
@Module({
  providers: [
    KisPaymentHandlerService,
    StripePaymentHandlerService,
    KakaoPaymentHandlerService,
    {
      provide: PAYMENT_HANDLERS,
      useFactory: (...handlers: PaymentGatewayHandler[]) =>
        new Map(handlers.map(h => [h.gateway, h])),
      inject: [
        KisPaymentHandlerService,
        StripePaymentHandlerService,
        KakaoPaymentHandlerService,
      ],
    },
  ],
})
export class PaymentsModule {}
typescript
// Facade에서 Map을 직접 주입받음
@Injectable()
export class PaymentsService {
  constructor(
    @Inject(PAYMENT_HANDLERS)
    private readonly handlers: Map<PaymentGateway, PaymentGatewayHandler>,
  ) {}
}

이렇게 하면 새 핸들러 추가 시 Provider 배열과 inject 배열에만 추가하면 된다. Facade 서비스의 코드를 아예 건드리지 않아도 된다.


if-else vs Handler Map 비교

두 접근 방식을 직접 비교해보자.

if-else 방식

typescript
async initiatePayment(sessionId: string, dto: InitiateDto) {
  const session = await this.findSession(sessionId);
  const gateway = session.device.paymentGateway;

  if (gateway === PaymentGateway.KIS) {
    return {};
  } else if (gateway === PaymentGateway.STRIPE) {
    const intent = await this.stripe.paymentIntents.create({ ... });
    return { externalPaymentId: intent.id, clientSecret: intent.client_secret };
  } else if (gateway === PaymentGateway.KAKAO) {
    const response = await this.kakaoApi.ready({ ... });
    return { externalPaymentId: response.tid };
  } else {
    throw new BadRequestException('Unsupported gateway');
  }
}

Handler Map 방식

typescript
async initiatePayment(sessionId: string, dto: InitiateDto) {
  const session = await this.findSession(sessionId);
  const gateway = this.resolveGateway(session);
  const handler = this.getHandler(gateway);

  return handler.initiate({ session, amount: session.totalAmount, dto });
}
비교 항목if-elseHandler Map
게이트웨이 추가 시모든 메서드의 분기 수정핸들러 1개 추가 + Map 등록
단일 책임Facade가 모든 로직 담당각 핸들러가 자기 로직만 담당
테스트Facade 전체를 테스트핸들러별 독립 테스트 가능
코드 크기게이트웨이 수 × 메서드 수만큼 증가Facade는 고정, 핸들러만 증가
타입 안전성분기 안에서 타입 캐스팅 필요인터페이스로 보장

테스트 전략

Handler Map 패턴의 큰 장점 중 하나는 테스트가 깔끔해진다는 것이다.

핸들러 단위 테스트

각 핸들러는 독립적으로 테스트할 수 있다. 다른 핸들러의 존재를 알 필요가 없다.

typescript
describe('StripePaymentHandlerService', () => {
  let handler: StripePaymentHandlerService;
  let stripeMock: jest.Mocked<Stripe>;

  beforeEach(() => {
    stripeMock = createMock<Stripe>();
    handler = new StripePaymentHandlerService(stripeMock);
  });

  it('should create PaymentIntent on initiate', async () => {
    stripeMock.paymentIntents.create.mockResolvedValue({
      id: 'pi_123',
      client_secret: 'secret_123',
    });

    const result = await handler.initiate({
      session: mockSession,
      amount: 5000,
      dto: mockDto,
    });

    expect(result.externalPaymentId).toBe('pi_123');
    expect(result.clientSecret).toBe('secret_123');
  });
});

Facade 테스트

Facade를 테스트할 때는 핸들러를 모킹한다. Facade의 역할은 "올바른 핸들러를 선택하고 호출하는 것"이기 때문에, 핸들러의 내부 로직은 관심사가 아니다.

typescript
describe('PaymentsService', () => {
  let service: PaymentsService;
  let mockHandler: jest.Mocked<PaymentGatewayHandler>;

  beforeEach(() => {
    mockHandler = {
      gateway: PaymentGateway.STRIPE,
      initiate: jest.fn(),
      complete: jest.fn(),
    };

    service = new PaymentsService(
      // Map에 모킹된 핸들러만 등록
      new Map([[PaymentGateway.STRIPE, mockHandler]]),
    );
  });

  it('should dispatch to correct handler', async () => {
    mockHandler.initiate.mockResolvedValue({ externalPaymentId: 'pi_123' });

    await service.initiatePayment(sessionId, dto);

    expect(mockHandler.initiate).toHaveBeenCalledTimes(1);
  });
});

이 패턴이 적합한 상황

Facade + Handler Map은 만능이 아니다. 적합한 상황과 그렇지 않은 상황이 있다.

적합한 경우:

  • 같은 작업을 여러 방식으로 처리: 결제 게이트웨이, 알림 채널(이메일/SMS/푸시), 파일 저장소(S3/GCS/로컬), 인증 방식(JWT/OAuth/API Key)
  • 핸들러가 3개 이상이거나 추가될 가능성이 높을 때: 2개까지는 if-else가 오히려 간단할 수 있다
  • 각 핸들러의 로직이 충분히 복잡할 때: 한 줄짜리 분기를 굳이 핸들러로 분리할 필요는 없다

과한 경우:

  • 분기가 2개이고 추가될 가능성이 낮을 때
  • 각 분기의 로직이 한두 줄일 때
  • 프로토타이핑 단계에서 빠르게 검증만 하면 될 때

패턴을 적용하는 기준은 "현재 복잡도"가 아니라 "예상되는 변경 빈도"다. 게이트웨이가 자주 추가/변경될 것 같다면 초기부터 이 패턴을 적용하는 게 나중에 리팩토링하는 것보다 비용이 적다.


Strategy 패턴과의 관계

Strategy 패턴 글에서 다룬 것처럼, Strategy도 인터페이스 + 런타임 선택이라는 점에서 Handler Map과 공통점이 있다. 차이를 정리하면:

  • Strategy 패턴: "알고리즘을 교체 가능하게" — 하나의 동작에 대한 여러 전략. 보통 런타임에 주입받는다.
  • Handler Map: "여러 핸들러를 키로 디스패치" — Map을 통한 구조적 라우팅에 초점. 핸들러가 여러 메서드를 가질 수 있다.

실제로는 Handler Map이 Strategy 패턴을 구현하는 방법 중 하나라고 볼 수 있다. 중요한 건 이름이 아니라, if-else를 인터페이스 + Map 조회로 대체해서 확장성을 얻는다는 핵심 아이디어다.


정리

  • Facade는 하나의 진입점을 제공하고, Handler Map은 if-else 분기를 Map 조회로 대체해서 게이트웨이 추가 시 기존 코드를 건드리지 않는 구조를 만든다
  • 모든 핸들러가 동일한 인터페이스를 구현하고, Context 객체로 파라미터를 묶으면 시그니처 변경 없이 확장할 수 있다
  • 핸들러가 3개 이상이거나 추가될 가능성이 높을 때 도입하고, 2개 이하의 단순 분기에는 if-else가 오히려 간결하다

관련 문서