17. Proxy — 결제는 진짜 호출 전에 확인할 게 있다

작성일:2026.05.21|수정일:2026.08.04|조회수:0

17. Proxy — 결제는 진짜 호출 전에 확인할 게 있다

어댑터 덕분에 배달 앱들의 다양한 주문 데이터는 깔끔하게 정리되었다. 하지만 진짜 문제는 그 다음에 터졌다. 바로 '돈'이다. 배달 주문이 들어오면 우리는 PG(Payment Gateway)사 API를 호출해 결제를 진행해야 한다. 처음에는 아주 단순하게 생각했다. 결제 함수를 하나 만들고, 그 안에서 API를 호출하면 끝이라고 말이다.

하지만 현실은 냉혹했다. "주문 금액이 0원 이하인 건 아닌가?", "결제 시도 기록은 남겼는가?", "네트워크 문제로 응답을 못 받았다면, 이미 승인된 거래를 다시 결제해도 되는가?" 같은 질문들이 꼬리에 꼬리를 물고 이어졌다. 결제를 담당하는 클래스에 이런 부가 로직을 하나둘 채워 넣다 보니, 어느새 결제 로직은 알아보기 힘들 정도로 뚱뚱해져 있었다.

TS
class PaymentService {  async charge(amount: number, orderId: string) {    // 검증 로직이 들어오고...    if (amount <= 0) throw new Error('금액이 이상합니다.');    // 로깅 로직이 섞이고...    console.log(`결제 시도: ${orderId}`);    // 재시도 로직까지 침투했다.    try {      await pgApi.call(amount);    } catch (e) {      // 응답이 없었다고 바로 다시 호출하면 이중 결제가 될 수 있다.      // 먼저 같은 idempotency key의 승인·거래 상태를 확인한다.    }  }}

무엇이 불편한가

지금의 가장 큰 불편함은 '핵심'과 '부가'가 뒤섞여 있다는 점이다. PaymentService의 본분은 PG사 API를 호출해 결제를 성사시키는 것이다. 그런데 지금은 검증도 하고, 로그도 남기고, 재시도까지 관리한다. 만약 로깅 방식을 바꾸고 싶거나 재시도 횟수를 조정하고 싶다면, 우리는 결제 핵심 로직이 들어있는 이 예민한 파일을 매번 열어봐야 한다.

또한, 테스트도 문제다. 순수하게 결제 API 호출만 테스트하고 싶은데, 매번 검증과 로깅 로직이 함께 따라온다. 반대로 검증 로직만 테스트하고 싶어도 결제 API라는 무거운 짐을 같이 짊어져야 한다. 클래스 하나가 너무 많은 이유로 수정되어야 하는, '단일 책임 원칙(SRP)'의 비명이 들리는 상황이다.

우리는 결제 시스템의 핵심을 건드리지 않으면서, 그 주변에 필요한 기능을 투명하게 덧붙이고 싶었다. 결제 시스템을 쓰는 쪽에서는 그것이 순수한 API 호출기인지, 아니면 온갖 부가 기능이 붙은 장치인지 신경 쓰지 않게 말이다.

Proxy 패턴

Proxy 패턴은 어떤 객체에 대한 접근을 제어하기 위해 그 객체의 대리인(Proxy)을 제공하는 패턴이다. GoF의 원래 의도는 다음과 같다.

다른 객체에 대한 접근을 제어하기 위해 그 객체의 대리자나 자리표시자를 제공한다.

클라이언트는 실제 객체(Real Subject)를 직접 만나는 대신 대리인(Proxy)과 대화한다. 대리인은 실제 객체와 똑같은 인터페이스를 가지고 있어, 클라이언트는 자신이 대리인과 대화하고 있다는 사실조차 모를 수 있다.

에이든 피자에 대입해 보자. 실제 PG사와 통신하는 RealPaymentGateway가 있고, 이를 감싸는 PaymentGatewayProxy를 둔다. 클라이언트인 POS 시스템은 IPaymentGateway 인터페이스만 보고 결제를 요청한다.

이때 프록시는 실제 결제를 진행하기 전에 금액을 검증하고, 결제가 시작되었음을 기록한다. 재시도가 필요해 보여도 같은 idempotency key로 승인·거래 상태를 먼저 확인한다. 이미 승인되었다면 성공으로 처리하고, 상태가 확인 중이면 다시 호출하지 않는다. 거래가 없고 일시적 오류임이 확인된 경우에만 같은 키로 제한된 재시도를 한다. 핵심 로직은 프록시 덕분에 '결제 그 자체'에만 집중할 수 있게 된 셈이다.

Proxy vs Decorator: 닮았지만 다른 의도

7편에서 다뤘던 Decorator 패턴과 Proxy 패턴은 구조적으로 매우 비슷해 보인다. 둘 다 기존 객체를 감싸고 같은 인터페이스를 유지한다. 하지만 그 '의도'에서 결정적인 차이가 난다.

Decorator는 객체에 '새로운 책임(기능)'을 추가하는 것이 목적이다. 피자에 치즈를 추가하고 페퍼로니를 추가하듯, 기능을 겹겹이 쌓아 올리는 느낌이다. 반면 Proxy는 '접근 제어'가 주 목적이다. 권한이 있는지 확인하거나(보호 프록시), 요청을 대신 기록하거나(로깅 프록시), 무거운 객체를 필요할 때만 만들거나(가상 프록시) 하는 식이다. 겉모습은 비슷하지만 질문이 다르다. "무엇을 더 얹을까"가 데코레이터라면, "이 요청을 그대로 통과시켜도 될까"가 프록시에 가깝다.

데코레이터는 클라이언트가 직접 객체를 감싸며 조립하는 경우가 많지만, 프록시는 보통 시스템 내부에서 자동으로 설정되어 클라이언트에게 제공된다. 클라이언트 입장에서는 프록시가 있는지조차 모르는 것이 가장 이상적인 프록시의 모습이다.

TypeScript 포인트: 안전한 에러 핸들링

프록시에서 재시도(Retry) 로직을 구현할 때 가장 주의해야 할 점은 에러 처리와 결제 상태 확인이다. 네트워크 예외는 PG가 결제를 하지 않았다는 증거가 아니다. 따라서 주문마다 고정된 idempotency key를 만들고, 예외 뒤에는 반드시 그 키의 승인·거래 상태를 조회한다. 상태 확인 없이 catch에서 무조건 다시 charge()를 호출하는 코드는 이중 결제를 만들 수 있으므로 금지해야 한다. 특히 TypeScript에서는 catch(e)e가 기본적으로 unknown 타입이다. 이를 무턱대고 Error 타입이라고 단정하는 것은 위험하다.

에이든 피자의 프록시는 instanceof Error를 통해 타입을 엄격하게 확인하거나, 알 수 없는 에러일 경우 새로운 에러 객체로 감싸서 던지는 방식을 택했다. 또한, 마지막 시도에서 발생한 에러를 안전하게 보관했다가 최종적으로 던져줌으로써 클라이언트가 실패 원인을 정확히 알 수 있게 배려했다.

트레이드오프

Proxy 패턴의 가장 큰 매력은 '투명성'이다. 기존 코드를 한 줄도 건드리지 않고 새로운 부가 기능을 추가할 수 있다. 로깅 프록시를 붙였다가 떼는 것만으로 로깅 기능을 껐다 켤 수 있는 수준이다. 또한 핵심 로직과 부가 로직이 분리되어 코드가 읽기 쉬워지고 테스트하기도 편해진다.

다만, 객체 사이에 대리인이 하나 더 끼어들기 때문에 아주 미세한 성능 저하가 발생할 수 있다. 하지만 결제 API 호출처럼 네트워크 통신이 동반되는 작업에서는 이 정도 오버헤드는 무시해도 좋을 수준이다. 오히려 무분별한 프록시 사용으로 인해 객체 생성 관계가 복잡해져, 실제로 어떤 일이 어디서 일어나는지 파악하기 어려워지는 '추적의 난해함'을 더 경계해야 한다.

전체 코드

TS
type TransactionStatus = 'APPROVED' | 'NOT_FOUND' | 'PENDING';/** 결제 시스템의 표준 인터페이스 (Subject) */interface IPaymentGateway {  charge(amount: number, orderId: string, idempotencyKey: string): Promise<void>;  getTransactionStatus(idempotencyKey: string): Promise<TransactionStatus>;  refund(amount: number, orderId: string): Promise<void>;}/** 실제 PG사 API를 호출하는 클래스 (Real Subject) */class RealPaymentGateway implements IPaymentGateway {  private transactions = new Map<string, TransactionStatus>();  async charge(amount: number, orderId: string, idempotencyKey: string): Promise<void> {    if (this.transactions.get(idempotencyKey) === 'APPROVED') {      console.log(`[API 응답] ${orderId} 번 기존 승인 재사용`);      return;    }    console.log(`[API 호출] ${orderId} 번 주문 ${amount}원 결제 요청 중...`);    if (Math.random() < 0.3) throw new Error('PG사 일시적 네트워크 오류');    this.transactions.set(idempotencyKey, 'APPROVED');    console.log(`[API 응답] ${orderId} 번 결제 완료`);  }  async getTransactionStatus(idempotencyKey: string): Promise<TransactionStatus> {    return this.transactions.get(idempotencyKey) ?? 'NOT_FOUND';  }  async refund(amount: number, orderId: string): Promise<void> {    console.log(`[API 호출] ${orderId} 번 주문 ${amount}원 환불 완료`);  }}/** 결제 대리인 (Proxy): 검증, 로깅, 안전한 재시도를 담당한다. */class PaymentGatewayProxy implements IPaymentGateway {  constructor(private real: IPaymentGateway) {}  async charge(amount: number, orderId: string, idempotencyKey = `payment:${orderId}`): Promise<void> {    if (amount <= 0) throw new Error('결제 금액은 0원보다 커야 합니다.');    const maxAttempts = 3;    for (let attempt = 1; attempt <= maxAttempts; attempt++) {      try {        console.log(`[Proxy] ${orderId} 결제 시도 ${attempt}회차`);        await this.real.charge(amount, orderId, idempotencyKey);        console.log(`[Proxy] ${orderId} 번 결제 로그 기록 완료`);        return;      } catch (error) {        const message = error instanceof Error ? error.message : '알 수 없는 에러';        const status = await this.real.getTransactionStatus(idempotencyKey);        console.warn(`[Proxy] ${attempt}회차 실패, 거래 상태: ${status} (${message})`);        if (status === 'APPROVED') {          console.log(`[Proxy] ${orderId} 번은 이미 승인되어 성공으로 처리`);          return;        }        if (status === 'PENDING') {          throw new Error('PG 거래 상태가 확인 중입니다. 상태 확인 전 재시도하지 않습니다.');        }        if (attempt === maxAttempts) {          throw error instanceof Error ? error : new Error('결제 처리 중 알 수 없는 오류 발생');        }        // NOT_FOUND가 확인된 일시적 오류에만 같은 키로 제한 재시도한다.      }    }  }  getTransactionStatus(idempotencyKey: string): Promise<TransactionStatus> {    return this.real.getTransactionStatus(idempotencyKey);  }  async refund(amount: number, orderId: string): Promise<void> {    console.log(`[Proxy] 환불 요청 로깅: ${orderId}`);    await this.real.refund(amount, orderId);  }}async function processOrder(gateway: IPaymentGateway) {  try {    await gateway.charge(25000, 'ORDER-2024-001', 'payment:ORDER-2024-001');    console.log('매장 업무: 결제 성공 확인, 주방으로 주문 전송');  } catch (error) {    const message = error instanceof Error ? error.message : '알 수 없는 결제 오류';    console.error('매장 업무: 결제 실패 안내 고객님께 발송', message);  }}const paymentProxy = new PaymentGatewayProxy(new RealPaymentGateway());processOrder(paymentProxy);

프록시 덕분에 결제 시스템은 조금 더 견고해졌고, 핵심 코드는 한결 읽기 쉬워졌다. 이제 주문도 받고 돈도 받았으니 본격적으로 피자를 만들 시간이다. 그런데 문제가 하나 있다. 피자를 하나 만들려면 창고에서 재료를 빼고, 결제를 확인하고, 주방에 알리고, 손님께 메시지를 보내고, 매출 통계까지 잡아야 한다. 이 복잡한 서브시스템을 매번 하나하나 호출하는 건 너무 고된 일이다. 모든 과정을 버튼 하나로 압축해 줄 '만능 리모컨'이 필요하다.

댓글

댓글을 불러오는 중...