본문으로 건너뛰기
· backend · 3분 읽기

브라우저 결제 성공만으로 주문을 확정하지 않기

브라우저 성공 응답은 확정 근거가 아니다

결제 연동은 처음 보면 단순해 보인다.

브라우저에서 결제 SDK를 호출한다. 성공 응답을 받는다. 서버에 주문을 완료 처리한다. 화면에는 완료 페이지를 보여준다.

하지만 이 흐름에는 중요한 가정이 숨어 있다. 브라우저가 성공 응답을 받았다는 사실과 서버가 결제 완료를 신뢰할 수 있다는 사실은 다르다. 브라우저는 사용자의 네트워크, 탭 종료, 새로고침, 확장 프로그램, 모바일 웹뷰 같은 환경의 영향을 받는다. 결제 제공자가 성공을 반환했더라도 서버가 그 결과를 아직 모를 수 있다.

문제는 결제 성공 여부가 아니라, 어떤 시점에 주문을 확정할 수 있느냐다.

서버 confirm 요청이 누락되는 경우

처음에는 SDK 성공 콜백 이후 서버에 완료 요청을 보내면 충분하다고 생각하기 쉽다.

Browser SDK success
  -> POST /orders/{id}/confirm
  -> order.status = PAID

이 접근은 정상 경로에서는 잘 동작한다. 문제는 정상 경로가 아닌 순간에 생긴다.

  • 사용자가 결제 완료 직후 탭을 닫는다.
  • 모바일 네트워크가 끊겨 confirm 요청이 서버에 도달하지 않는다.
  • SDK 응답은 성공이지만 서버 검증 시 금액이 다르다.
  • 서버 confirm 요청은 실패했는데 결제 제공자에는 결제가 완료되어 있다.
  • webhook이 confirm 요청보다 먼저 또는 나중에 도착한다.

결제는 화면 이벤트가 아니라 서버 상태 전이다. 그래서 브라우저 응답을 그대로 확정 기준으로 삼으면 서버가 모르는 결제가 생기거나, 반대로 검증되지 않은 주문이 완료될 수 있다.

주문 확정 기준을 서버 검증으로 두기

결제 확정에는 세 가지 신호가 있다.

첫 번째는 브라우저 SDK 성공 응답이다. 사용자 경험을 진행시키는 데 필요하지만, 최종 신뢰 기준으로 쓰기에는 약하다.

두 번째는 서버의 결제 조회다. 서버가 결제 제공자의 API를 호출해 결제 상태, 금액, 통화를 확인한다. 주문 확정의 주 기준은 여기에 두는 편이 안전하다.

세 번째는 webhook이다. 사용자가 이탈하거나 confirm 요청이 실패했을 때 상태를 복구하는 보조 경로다.

핵심은 이 세 신호의 책임을 섞지 않는 것이다.

Browser SDK success: 사용자가 결제를 마쳤다는 UI 신호
Server verify: 주문을 확정해도 되는지 판단하는 서버 신호
Webhook: 누락된 서버 검증을 복구하는 비동기 신호

webhook은 누락된 검증을 복구한다

주문 확정 기준을 서버 검증으로 둔다.

브라우저는 SDK 성공 후 서버에 verifyPayment 같은 API를 호출한다. 서버는 저장된 주문 금액과 결제 제공자에서 조회한 금액을 비교한다. 상태와 금액이 맞을 때만 주문을 확정한다.

웹훅은 같은 검증 함수를 다시 호출하는 보조 경로로 둔다. 별도 로직으로 주문을 직접 완료하지 않는다. 그래야 브라우저 confirm과 webhook이 동시에 들어와도 같은 규칙을 통과한다.

1. Browser -> Payment SDK
2. Browser -> API: verifyPayment(order_id)
3. API -> Payment Provider: get payment
4. API: amount/currency/status 검증
5. API: order.status = CONFIRMED

또는

1. Provider -> API: webhook
2. API -> Payment Provider: get payment
3. API: amount/currency/status 검증
4. API: order.status = CONFIRMED

verifyPayment를 단일 진입점으로 만들기

실제 구현은 더 많은 상태와 에러 처리가 필요하지만, 판단 지점만 줄이면 다음 정도가 된다.

go
package payment

import (
    "context"
    "fmt"
)

type Order struct {
    ID        string
    PaymentID string
    Amount    int64
    Currency  string
    Status    string
}

type ProviderPayment struct {
    ID       string
    Amount   int64
    Currency string
    Status   string
}

type Provider interface {
    GetPayment(ctx context.Context, paymentID string) (*ProviderPayment, error)
}

type Repository interface {
    GetOrder(ctx context.Context, orderID string) (*Order, error)
    MarkPaymentNeedsRefund(ctx context.Context, orderID string) error
    MarkConfirmed(ctx context.Context, orderID string) error
}

type Service struct {
    repo     Repository
    provider Provider
}

func (s *Service) VerifyPayment(ctx context.Context, orderID string) error {
    order, err := s.repo.GetOrder(ctx, orderID)
    if err != nil {
        return fmt.Errorf("get order: %w", err)
    }
    if order.Status == "CONFIRMED" {
        return nil
    }
    if order.Status == "CANCELLED" {
        return nil
    }

    paid, err := s.provider.GetPayment(ctx, order.PaymentID)
    if err != nil {
        return fmt.Errorf("get provider payment: %w", err)
    }
    if paid.Status != "PAID" {
        return fmt.Errorf("payment is not paid")
    }
    if paid.Amount != order.Amount || paid.Currency != order.Currency {
        return fmt.Errorf("payment amount or currency mismatch")
    }
    if order.Status == "CANCEL_REQUESTED" {
        return s.repo.MarkPaymentNeedsRefund(ctx, order.ID)
    }

    return s.repo.MarkConfirmed(ctx, order.ID)
}

func (s *Service) HandleWebhook(ctx context.Context, paymentID string) error {
    orderID, err := s.findOrderIDByPaymentID(ctx, paymentID)
    if err != nil {
        return err
    }
    return s.VerifyPayment(ctx, orderID)
}

func (s *Service) findOrderIDByPaymentID(ctx context.Context, paymentID string) (string, error) {
    // 예제에서는 생략한다. 실제 구현에서는 payment_id에 unique index를 둔다.
    return "order-id", nil
}

중요한 건 webhook도 결국 같은 VerifyPayment 경로를 탄다는 점이다. 다만 같은 결제 완료 이벤트라도 현재 주문 상태에 따라 결과는 달라질 수 있다. 주문이 이미 CANCEL_REQUESTED라면 다시 확정하지 않고 환불 대기 상태로 수렴시켜야 한다. 이벤트 종류별로 주문 상태를 직접 바꾸기 시작하면, 시간이 지나면서 검증 규칙이 여러 곳에 흩어진다.

화면 상태와 서버 상태를 분리하기

결제 연동에서 브라우저 성공 응답은 편리하지만, 확정 기준으로 삼기에는 약하다. 서버가 외부 결제를 직접 조회하고, 저장된 주문 정보와 비교한 뒤 상태를 바꿔야 한다.

웹훅은 별도의 확정 로직이 아니라 복구 경로로 두는 편이 단순하다. 같은 검증 함수를 호출하게 만들면 중복 요청, 지연 이벤트, 사용자 이탈을 같은 규칙으로 처리할 수 있다.

작게 시작한다면 세 가지만 먼저 정하면 된다.

  • 브라우저 성공 응답은 UI 진행 신호로만 본다.
  • 주문 확정은 서버 검증 API가 담당한다.
  • 웹훅은 같은 검증 경로를 다시 실행한다.
공유하기 X LinkedIn

관련 글