권중운
Portfolio

결제 서버를 따로 둔 이유

2026.06.1411분 읽기

학원 운영 SaaS에서 결제는 단독 기능처럼 보이지만 실제로는 여러 업무와 붙어 있다. 청구서를 발행하고, 보호자에게 수납 안내를 보내고, 결제 링크나 단말기로 금액을 받고, 승인과 취소 결과를 다시 청구서 상태에 반영해야 한다. 관리자 화면에서는 미납, 부분 납부, 취소, 영수증 같은 상태가 자연스럽게 이어져야 한다.

이 글에서 1.0은 원래 운영하던 기존 서비스, 2.0은 같은 청구/수납 업무를 새 구조로 다시 만들던 운영 도구라고 부르겠다. 사용자 입장에서는 둘 다 “수강료를 청구하고 납부 상태를 확인하는 기능”이지만, 개발자 입장에서는 중요한 질문이 달랐다. 외부 결제사의 승인 결과와 우리 서비스의 청구 상태가 어디에서 만나야 하는가. 그 상태를 누가 최종 사실로 소유해야 하는가.

내가 맡은 범위는 이 전환기의 결제였다. 1.0은 이미 오래 운영된 코드였고, 초기 작성자들은 팀을 떠난 상태였다. 문서도 충분하지 않았다. 그래도 운영 중인 제품이라 기능은 계속 붙어야 했다. 2.0을 오픈하더라도 1.0을 바로 닫을 수는 없었다. 기존 사용자의 청구 흐름과 결제 링크, 운영 화면이 일정 기간 같이 살아야 했기 때문이다.

이 상황에서 결제만은 별도 서버로 빼야 한다고 판단했다. 1.0 안에도 결제 도메인은 있었지만 외부 연동 코드가 한곳에만 모여 있지는 않았다. 승인 요청, 콜백 처리, 상태 조회, 취소가 앱 내부 여러 흐름과 맞물려 있었고, 사용하는 쪽에서 provider 응답을 해석하거나 필요한 인터페이스를 그때그때 만드는 패턴이 생기기 쉬웠다.

분리 전에는 코드가 대략 이런 방향으로 흐르기 쉬웠다.

// 업무 서비스 안에서 외부 결제 흐름까지 같이 판단한다.
const approval = await provider.approve({
  transactionId,
  amount: invoice.unpaidAmount,
});
 
if (approval.resultCode === "0000") {
  await invoiceRepository.markPaid(invoice.id, {
    approvedAmount: approval.amount,
    approvalNo: approval.approvalNo,
    cardName: approval.cardName,
  });
}

작게 보면 문제 없는 코드처럼 보인다. 하지만 이런 코드가 여러 업무 흐름에 퍼지면 결제사의 응답 포맷, 우리 서비스의 청구 상태, 알림 발송, 운영 로그가 한 함수 안에서 같이 움직인다. 승인 자체가 실패한 것인지, 승인은 됐는데 우리 상태 반영이 실패한 것인지, 콜백이 늦게 온 것인지 구분하려면 호출 경로를 끝까지 따라가야 한다.

문서로 “외부 연동은 이렇게만 쓰자”고 정할 수는 있다. 하지만 코드 수준의 강제력이 없으면 시간이 지나면서 다시 흩어진다. 나는 결제 경계를 사람의 기억이 아니라 구조로 강제하고 싶었다.

분리 전

  • 업무 서비스가 provider 응답을 직접 해석한다.
  • 승인과 청구 상태 변경이 한 흐름에 섞인다.
  • 디버깅할 때 외부 거래와 내부 상태를 동시에 추적한다.

분리 후

  • 결제 실행과 거래 기록은 결제 서버가 맡는다.
  • 업무 서비스는 불투명 참조값과 결과 이벤트만 다룬다.
  • 승인, 실패, 취소가 같은 관찰 지점으로 모인다.

분리 서버의 기준은 source of truth였다. 링크 결제, 빌링, 단말기 결제, 결제 조회, provider adapter, outbox, health check를 결제 서버 안에 모았다. 상위 도메인은 “이 청구에 대한 결제를 만들어 달라”고 요청하고, 이후 결과 이벤트를 받아 자기 상태에 반영한다. 결제사가 어떤 필드를 요구하는지, 승인 거래를 어떤 단위로 저장하는지, 실패와 재시도를 어떻게 관찰하는지는 결제 서버의 책임이 된다.

분리 후 호출부는 이런 형태에 가까워졌다.

const intent = await paymentServer.buildPayment({
  externalRef: `invoice:${invoice.id}`,
  amount: invoice.unpaidAmount,
  callbackTarget: "INVOICE_PAYMENT",
});
 
await invoicePaymentRepository.insertPending({
  invoiceId: invoice.id,
  paymentIntentId: intent.paymentIntentId,
  requestedAmount: invoice.unpaidAmount,
});

여기서 중요한 값은 paymentIntentId다. 업무 서비스는 외부 결제사의 승인 필드를 장기 소유하지 않는다. 대신 결제 서버가 만든 결제 의도를 저장하고, 결제 결과가 오면 그 결과를 자기 projection으로 반영한다. 결제 서버 입장에서는 externalRef가 상위 도메인을 가리키는 불투명한 참조값이고, 상위 도메인 입장에서는 paymentIntentId가 결제 세계를 가리키는 참조값이다.

이벤트 수신도 같은 이유로 멱등하게 만들었다.

async function handlePaymentEvent(event: PaymentEvent) {
  const accepted = await eventInbox.createIfAbsent(event.id);
  if (!accepted) return;
 
  if (event.type === "PaymentApproved") {
    await invoiceProjection.markApproved(event.paymentIntentId, event.amount);
  }
 
  if (event.type === "PaymentCanceled") {
    await invoiceProjection.markCanceled(event.paymentIntentId, event.amount);
  }
}

외부 결제는 같은 사실이 두 번 도착할 수 있고, 늦게 도착할 수도 있다. 그래서 이벤트 id 기준으로 한 번만 처리하고, 이미 반영된 결제라면 다시 건드리지 않는 쪽이 안전했다. 이 구조가 생기면서 “콜백을 받았는가”, “outbox가 보냈는가”, “업무 서비스가 반영했는가”를 단계별로 나눠 볼 수 있었다.

전환 과정에서는 1.0의 외부 계약을 바로 깨지 않았다. 기존 화면과 API 응답 형태는 유지하고, 내부 build, cancel, status, callback 처리만 결제 서버 호출과 이벤트 수신으로 바꿨다. 2.0은 처음부터 결제 의도 생성, 이벤트 수신, projection 반영 흐름으로 설계했다. 두 버전이 같은 결제 원천을 바라보되, 각자 필요한 업무 상태만 소비하게 만든 셈이다.

의도하지 않았지만 이 선택이 특히 도움이 된 순간도 있었다. 보통 결제 연동에서는 한 번 계약한 외부 결제사를 자주 바꾸지 않는다. 그런데 2.0을 개발하는 과정에서 결제 계약 조건과 연동 방식이 달라지면서 두 계약사를 함께 다뤄야 하는 상황이 생겼다. 만약 외부 결제사별 코드가 1.0과 2.0 업무 코드 안에 각각 퍼져 있었다면, 전환 비용은 훨씬 커졌을 것이다. 결제 서버가 중간에 있었기 때문에 상위 서비스는 여전히 결제 의도와 결과 이벤트를 중심으로 바라볼 수 있었고, provider별 차이는 결제 서버 내부 adapter와 설정 쪽에서 흡수할 수 있었다. 처음부터 이 상황까지 정확히 예측한 것은 아니었지만, 결과적으로 결제 서버를 먼저 분리해 둔 선택이 맞았다고 느낀 지점이었다.

병행 운영에서 책임이 이동한 순서
  1. 1기존 계약 유지
  2. 2내부 호출 전환
  3. 3결과 이벤트 수신
  4. 42.0에서 같은 결제 원천 사용

좋아진 점은 분명했다. 외부 결제 연동만 확인하고 싶을 때 전체 서비스를 배포하지 않아도 되는 범위가 생겼고, 결제 서버 단위로 smoke test와 로그를 볼 수 있었다. 방화벽이나 콜백 도달성 때문에 늘 전체 인프라 배포 순서를 기다려야 했던 작업도 더 작게 쪼갤 수 있었다. 승인부터 취소까지의 상태를 한곳에서 따라가기 쉬워졌고, 문제가 생겼을 때도 “결제 서버의 거래 상태”와 “업무 서비스의 반영 상태”를 분리해서 볼 수 있었다.

대신 비용도 생겼다. 서버가 하나 늘면 배포, 시크릿, 네트워크, 헬스체크, 장애 대응 지점도 늘어난다. 운영자가 봐야 하는 대시보드도 늘고, 장애 원인을 판단할 때 서비스 간 계약을 함께 봐야 한다.

그래도 이 경우에는 그 비용을 감수할 만했다. 결제 서버 분리는 복잡도를 없앤 선택이 아니라 복잡도를 더 좁은 곳에 모은 선택이었다. 모든 도메인에 맞는 답은 아니지만, 결제처럼 외부 승인과 돈의 상태가 얽힌 영역에서는 기준이 달라진다. “잘 정리하자”는 약속보다 “잘못 쓰기 어렵게 만들자”는 구조가 더 오래 간다.