작업

결제 · 정산 시스템 · 2026

결제 성공이 곧 주문 완료는 아니다

앞선 중개 플랫폼에 실제 결제를 붙였습니다. 흉내내는 모듈을 만들면 정작 어려운 부분이 전부 사라지기 때문에 토스페이먼츠에 실제로 연동했습니다.

돈이 오가는 시스템에서 제일 무서운 건 실패가 아닙니다. 실패는 다시 하면 됩니다. 무서운 건 한쪽만 성공한 상태입니다.

라이브 데모 열기

0원

조작으로 결제된 금액

금액은 서버가 정하고 승인 전에 대조한다

1건

동시 취소 시 환불 횟수

세 번 눌러도 한 번만 나간다

48건

돈 관련 자동 검증

계산 25 · 실제 DB 23

5만원짜리를 1,000원에 결제할 수 있었다

결제창은 금액을 클라이언트에서 받아 갑니다. 그 값을 그대로 믿고 승인하면 개발자 도구로 숫자만 바꿔 5만원짜리를 1,000원에 결제할 수 있습니다.

주문을 만드는 시점에 서버가 단가표에서 금액을 확정해 저장하고, 승인 직전에 그 값과 대조합니다. 다르면 결제사를 부르지도 않고 거절합니다.

실제로 조작된 금액을 넣어보면 422로 막히고, 결제 기록에는 결제사 키가 비어 있는 채로 실패가 남습니다. 승인을 시도조차 하지 않았다는 뜻입니다.

// 클라이언트가 보낸 금액은 타입도 좁히지 않는다.
// 숫자로 받으면 "믿을 만한 값"처럼 보이기 시작한다.
export function checkAmount(expected: number, got: unknown): AmountCheck {
  const n = typeof got === "string" ? Number(got) : got;
  if (typeof n !== "number" || !Number.isInteger(n)) {
    return { ok: false, reason: "금액 형식이 올바르지 않습니다" };
  }
  if (n !== expected) {
    return { ok: false, reason: "결제 금액이 주문 금액과 다릅니다" };
  }
  return { ok: true };
}
승인 전 대조 — 통과해야만 결제사를 부른다

승인은 됐는데 우리 서버가 죽으면

결제사에 승인을 요청해 성공했는데 그 응답을 받아 저장하기 직전에 서버가 멈추면, 고객 돈은 나갔고 우리 주문은 미결제로 남습니다. 두 시스템에 걸친 작업이라 이 상태는 막을 수 없습니다.

그래서 막는 대신 찾아내기로 했습니다. 승인을 부르기 전에 결제 시도를 먼저 기록하고, 나중에 결제사가 아는 것과 우리가 아는 것을 맞춰봅니다.

운영자 화면에는 맞는 건을 보여주지 않습니다. 전부 나열하면 이상한 게 묻히니까요. 어긋난 것만 올리고, 돈이 한쪽에만 있는 건은 즉시 확인으로 따로 셉니다.

결제창을 닫아 중단된 결제는 매일 생기는 정상적인 일이라 위험도를 낮춰 분류합니다. 이걸 구분하지 않으면 진짜 문제가 정상 잡음에 묻힙니다.

환불한 돈까지 기사에게 지급될 뻔했다

고객이 작업 중에 취소하면 출장비를 제외하고 환불합니다. 그러면 기사에게 줄 정산액도 그만큼 줄어야 합니다.

그런데 정산 코드가 결제 상태를 완료로만 찾고 있었습니다. 부분 환불된 건은 상태가 부분취소라서 결제가 아예 없는 것으로 취급됐고, 원래 금액 기준으로 지급됐습니다. 고객에게 돌려준 돈까지 기사에게 나가는 셈입니다.

화면으로는 드러나지 않습니다. 숫자가 그럴듯해 보이니까요. 이건 제가 그 상황을 미리 만들어 확인하는 검사를 짜뒀기 때문에 잡혔습니다.

// 부분취소된 결제도 "승인된 결제"다.
// done만 찾으면 환불 이력이 통째로 무시된다.
.where(and(
  eq(payments.requestId, requestId),
  inArray(payments.status, ["done", "partial_canceled"]),
))
검사가 없었으면 조용히 틀린 금액이 나갔다

화면이 30초 동안 응답하지 않았다

운영자 대시보드가 열리지 않았습니다. 로컬에서는 멀쩡했고, 다른 화면은 전부 정상이었습니다.

커넥션이 부족한가 싶어 확인했지만 활성 연결은 하나뿐이었고, 락도 없었으며, 로딩 화면을 지워도 그대로였습니다. 세 가지 가설이 전부 틀렸습니다.

원인은 데이터베이스 연결 풀 크기였습니다. 대기 큐가 풀 크기의 두 배를 넘으면 풀러가 응답을 돌려주지 않습니다. 대시보드는 한 화면에서 여러 질의를 동시에 던져 그 선을 넘고 있었습니다.

실측해서 확인했습니다. 풀 3에 동시 5개는 79밀리초, 같은 풀에 8개는 멈춤, 풀 5에 8개는 다시 79밀리초. 로컬은 풀이 커서 재현되지 않았고, 배포 환경 변수를 로컬에 맞춰준 뒤에야 몇 분 만에 좁혀졌습니다. 배포할 때마다 확인하느라 왕복한 시간이 가장 큰 낭비였습니다.

이 설계의 한계

  • 테스트 키로 연동되어 있어 실제 청구는 일어나지 않습니다
  • 정산은 지급액을 계산하고 기록까지만 합니다. 실제 이체는 별도 계약이 필요합니다
  • 대사는 최근 결제만 훑습니다. 실무에서는 기간을 나눠 배치로 돌려야 합니다
  • 세금계산서와 현금영수증은 범위 밖입니다

역할

기획 · 설계 · 개발 · 배포

스택

Next.js 16 · 토스페이먼츠 · Drizzle · Supabase · TypeScript

다음생활 서비스 중개 플랫폼권한은 화면이 아니라 조회에서 갈린다