← 글 목록
백엔드결제

과금 이후 권한까지: LMS 결제 정합성을 지키는 백엔드 설계

·정합성 · 멱등성 · PG · 환불 · SLO

Flown LMS에서 결제·주문·환불 도메인을 맡으며 가장 중요하게 본 것은 “결제가 성공했는가”가 아니라 과금 이후 서비스 권한까지 일관되게 맞았는가였다. PG 승인이 성공했는데 구독권이나 수강권이 생기지 않으면, 서버 입장에서는 일부 로직 실패일 수 있어도 사용자에게는 바로 신뢰 문제로 보인다.

그래서 결제 도메인의 불변식을 먼저 정했다.

과금이 확정됐다면, 사용자는 반드시 그에 해당하는 권한을 받아야 한다. 같은 결제는 여러 번 시도돼도 PG confirm은 한 번만 호출돼야 한다.

결제 흐름을 먼저 좁히기

이 글에서 다루는 결제 문제는 “결제 API가 동작한다”가 아니다. 실제로 위험했던 지점은 결제 버튼을 누른 뒤 여러 시스템의 상태가 순서대로 맞아야 한다는 점이었다.

Client
-> PaymentConfirmController
-> Idempotency-Key 검증
-> 주문 상태 재조회
-> Redis SETNX 락
-> 주문 상태 재조회
-> Toss confirm
-> 주문 PAID 처리
-> 수강권/구독권 지급
-> 결제 결과와 지급 실패 지표 기록

이 흐름에서 하나라도 조용히 실패하면 사용자는 돈은 냈는데 권한은 없는 상태를 만난다. 그래서 구현 기준도 세 가지로 나눴다. 외부 PG 호출은 중복되지 않아야 하고, 내부 주문 상태는 서버가 가진 값으로 판단해야 하며, 권한 지급 실패는 운영자가 볼 수 있는 형태로 남아야 한다.

서버가 진실로 삼아야 하는 값

결제 승인 요청에서 클라이언트가 보내는 값은 편의를 위한 입력일 뿐, 최종 판단 근거가 될 수 없다. 사용자는 요청 payload를 바꿀 수 있고, 화면에 보이는 금액과 실제 서버 주문 금액이 달라질 수도 있다.

그래서 승인 흐름에서는 요청의 orderId, amount, paymentKey를 그대로 믿지 않고 서버가 가진 주문 상태와 다시 대조했다. Toss 응답의 paymentKey, orderId, totalAmount도 요청값과 비교했다. 결제에서 검증은 부가 기능이 아니라, 잘못된 금액으로 주문이 확정되는 것을 막는 핵심 경계다.

중복 결제 방어

사용자는 결제 버튼을 여러 번 누를 수 있고, 네트워크 timeout 때문에 같은 요청을 다시 보낼 수 있다. 이때 목표는 “같은 응답을 예쁘게 돌려주는 것”보다 먼저 PG confirm이 두 번 호출되지 않게 하는 것이다.

현재 결제 확정 경로는 다음 순서로 중복을 막는다.

  1. Idempotency-Key 헤더의 null, blank, UUID 형식을 검증한다.
  2. 주문 상태를 먼저 조회한다.
  3. 이미 READY가 아니면 PG를 다시 호출하지 않고 중복 응답으로 반환한다.
  4. order:pay:lock:{Idempotency-Key} 형태의 Redis SETNX 락을 잡는다.
  5. 락 획득 후 주문 상태를 다시 조회한다.
  6. 여전히 READY일 때만 PG confirm을 호출한다.
  7. 결제 성공 후 주문을 PAID로 바꾸고 권한 지급을 시도한다.
  8. 락 해제는 Lua 스크립트로 토큰을 비교한 뒤 수행한다.

Redis 락만으로 결제를 완전히 설명하지 않은 이유는 분명하다. 락은 동시 진입을 줄이는 장치이고, 최종 진실은 DB의 주문 상태다. 락을 잡기 전과 잡은 후에 주문 상태를 다시 확인해야, 거의 동시에 들어온 요청도 이미 처리된 주문으로 안전하게 돌릴 수 있다.

실제 결제 확정 서비스의 핵심 흐름은 아래와 같다. READY가 아닌 주문은 바로 멱등 응답으로 돌리고, SETNX 락을 잡은 뒤에도 주문을 다시 읽는다. PG confirm은 이 두 방어선을 지난 뒤에만 호출된다.

원본 코드: ConfirmOrderPaymentService.java:75-121

// ConfirmOrderPaymentService.java
private Result doConfirm(Long memberId, String orderNo, String paymentKey, Integer amount, String idempotencyKey) {
    Order order = findOwnedOrder(memberId, orderNo);

    if (order.getStatus() != OrderStatus.READY) {
        return new Result(orderNo, order.getStatus(), null, true);
    }
    if (order.getFinalAmount() != amount) {
        throw new BusinessException(ErrorCode.ORDER_AMOUNT_MISMATCH);
    }

    String lockKey = LOCK_KEY_PREFIX + idempotencyKey;
    String lockValue = UUID.randomUUID().toString();
    Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, LOCK_TTL);
    if (acquired == null || !acquired) {
        throw new BusinessException(ErrorCode.DUPLICATE_PAYMENT_REQUEST);
    }

    try {
        Order raced = findOwnedOrder(memberId, orderNo);
        if (raced.getStatus() != OrderStatus.READY) {
            return new Result(orderNo, raced.getStatus(), null, true);
        }

        String pgTransactionId = pgClient.confirm(paymentKey, orderNo, amount);
        orderRepository.markPaid(orderNo, LocalDateTime.now(clock), pgTransactionId);
        dispatchAccessGrant(raced);
        return new Result(orderNo, OrderStatus.PAID, pgTransactionId, false);
    } finally {
        releaseLockSafely(lockKey, lockValue);
    }
}

락 해제도 단순 DEL이 아니라 토큰을 비교한 뒤 지운다. 락 TTL이 만료된 뒤 다른 요청이 같은 key를 잡았는데, 늦게 끝난 요청이 남의 락을 지우는 상황을 막기 위해서다.

원본 코드: UNLOCK_SCRIPT, releaseLockSafely

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
        "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
        Long.class);

이 멱등키는 백엔드 내부 구현으로만 두지 않았다. 프론트엔드 연동 문서와 Swagger에 POST /api/payments/confirm 호출 시 Idempotency-Key: {UUID v4} 헤더가 필요하다고 명시했다. 중복 결제 방어는 서버 코드만의 문제가 아니라, 클라이언트가 어떤 키를 생성하고 재시도 때 같은 키를 보내야 하는지까지 포함한 API 계약이었다.

원본 코드: PaymentConfirmController.java:43-71

// PaymentConfirmController.java
public ResponseEntity<ApiResponse<PaymentConfirmResponse>> confirm(
        @Valid @RequestBody PaymentConfirmRequest request,
        @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
        @AuthenticationPrincipal CustomUserDetails userDetails
) {
    if (idempotencyKey == null || idempotencyKey.isBlank()) {
        throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
    }

    String normalizedIdempotencyKey = idempotencyKey.trim();
    try {
        UUID.fromString(normalizedIdempotencyKey);
    } catch (IllegalArgumentException e) {
        throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
    }

    ConfirmOrderPaymentUseCase.Result result = confirmOrderPaymentUseCase.confirm(
            userDetails.getMemberId(),
            request.orderId(),
            request.paymentKey(),
            request.amount(),
            normalizedIdempotencyKey
    );

    PaymentConfirmResponse response = PaymentConfirmResponse.from(result);
    return result.duplicate()
            ? ApiResponse.success("이미 처리된 결제 요청입니다.", response)
            : ApiResponse.created("결제가 확정되었습니다.", response);
}

동시성 테스트에서는 동일 멱등키로 50개 스레드가 동시에 결제 확정을 호출했을 때 PG confirm과 DB markPaid가 각각 1회만 실행되는지 확인했다. 이 테스트가 증명하는 범위는 “같은 멱등키의 동시 요청에서 중복 PG 호출을 막는다”는 것이다. k6 p95, 처리량, 실서비스 장애율은 측정하지 않았으므로 성과 수치로 말하지 않았다.

또한 결제 결과를 로그 한 줄에만 남기지 않고 Micrometer 지표로 분리했다. 성공, 중복, 실패를 같은 counter의 status 태그로 남기고, 결제 처리 시간은 timer로 기록했다. 그래야 “중복 결제 0건”, “결제 성공률”, “P95 처리시간”을 각각 다른 SLO로 볼 수 있다.

원본 코드: ConfirmOrderPaymentService.java:58-72

// ConfirmOrderPaymentService.java
Counter.builder("order.payment.result")
        .tag("status", outcome)
        .register(meterRegistry)
        .increment();

sample.stop(Timer.builder("order.payment.processing.duration")
        .register(meterRegistry));

PG 호출과 DB 트랜잭션의 경계

외부 PG 호출을 DB 트랜잭션 안에 넣으면 구현은 단순해 보인다. 하지만 PG가 느려지는 동안 DB 커넥션을 계속 잡게 되고, 결제와 무관한 API까지 영향을 받을 수 있다.

그래서 PG 호출은 DB 트랜잭션 밖에 두고, 내부 상태 변경은 짧게 가져가는 쪽을 택했다. 이 선택은 하나의 큰 원자성을 포기하는 대신, 긴 네트워크 대기가 DB 트랜잭션을 붙잡지 않게 한다. 대신 PG 성공 후 DB 반영 실패 같은 보정 리스크가 남는다. 이 리스크는 로그, 메트릭, 재시도 가능 경로로 드러내야 한다.

Toss 연동부에서도 응답값을 그대로 신뢰하지 않고 요청값과 다시 대조했다. 클라이언트가 보낸 금액을 믿는 것이 아니라, PG 응답이 우리가 승인하려던 결제와 같은지 확인하는 경계다.

원본 코드: TossPaymentClient.java:47-78

// TossPaymentClient.java
Map<String, Object> response = restClient.post()
        .uri("/v1/payments/confirm")
        .body(Map.of(
                "paymentKey", paymentKey,
                "orderId", orderId,
                "amount", amount
        ))
        .retrieve()
        .body(Map.class);

String confirmedPaymentKey = Objects.toString(response != null ? response.get("paymentKey") : null, null);
String confirmedOrderId = Objects.toString(response != null ? response.get("orderId") : null, null);
Object totalAmount = response != null ? response.get("totalAmount") : null;

if (!confirmedPaymentKey.equals(paymentKey)) {
    throw new TossPaymentException("응답 paymentKey가 요청값과 다릅니다. orderId=" + orderId);
}
if (!Objects.equals(confirmedOrderId, orderId)) {
    throw new TossPaymentException("응답 orderId가 요청값과 다릅니다. orderId=" + orderId);
}
if (totalAmount == null || !String.valueOf(totalAmount).equals(String.valueOf(amount))) {
    throw new TossPaymentException("응답 금액이 요청 금액과 다릅니다. orderId=" + orderId);
}

지급 실패를 조용히 넘기지 않기

실제로 구독 결제 후 구독권이 생성되지 않은 문제가 있었다. 표면 원인은 subscriptions.subscription_idAUTO_INCREMENT가 빠져 INSERT가 실패한 것이었다. 더 중요한 원인은 결제 확정 서비스가 권한 지급 실패를 삼키고 결제 성공 응답을 반환했다는 점이다.

이 문제는 세 단계로 고쳤다.

  • subscriptions, video_progress처럼 같은 계열의 스키마 결함을 전수 점검하고 마이그레이션으로 수정했다.
  • 권한 지급 실패를 order.payment.access_grant.failed 메트릭과 ERROR 로그로 표면화했다.
  • 멱등 재호출 경로에서도 PAID 주문에 한해 권한 지급을 다시 시도하도록 했다.

권한 지급 실패는 주문을 되돌리지 않고 운영자가 볼 수 있는 실패로 남겼다. 결제는 이미 PG에서 확정된 외부 사실이기 때문에, 내부 지급 실패를 조용히 삼키는 대신 메트릭과 로그로 드러냈다.

원본 코드: ConfirmOrderPaymentService.java:132-158

// ConfirmOrderPaymentService.java
private void dispatchAccessGrant(Order order) {
    try {
        if (order.getType() == OrderType.SUBSCRIPTION) {
            subscribeUseCase.handle(order.getMemberId(), order.getId(), order.getFinalAmount());
            orderCartDeletePort.deleteAllByMemberId(order.getMemberId());
        } else {
            for (OrderItem item : order.getItems()) {
                if (item.getCourseId() != null) {
                    grantEnrollment(order.getMemberId(), item.getCourseId());
                }
            }
        }
    } catch (RuntimeException e) {
        meterRegistry.counter("order.payment.access_grant.failed",
                "type", order.getType().name()).increment();
        log.error("[ACCESS_GRANT_FAILED] 결제는 완료됐지만 수강권/구독권 지급 실패 — orderNo: {}, type: {}",
                order.getOrderNo(), order.getType(), e);
    }
}

지급 실패 시 결제를 바로 롤백하지 않은 이유도 있다. PG 승인은 외부 시스템에서 이미 확정된 사실이고, 이를 되돌리려면 결제 취소를 다시 호출해야 한다. 그 취소 역시 실패할 수 있다. 그래서 “과금은 유지하고, 지급은 반드시 맞춘다”는 방향으로 정리했다.

환불도 서버 정책이다

환불 가능 여부를 프론트에서 refundable=true로 보여주는 것은 UX일 뿐이다. 사용자는 화면을 오래 열어둘 수 있고, API를 직접 호출할 수도 있다. 따라서 환불 실행 시점에 서버가 다시 판단해야 한다.

강의 환불 조건은 결제 후 7일 이내, 진도율 10% 미만이다. 여기서 10% 이하는 아니고 10% 미만이다. 이 기준은 조회 화면과 실행 API가 같은 OrderRefundPolicy를 바라보게 해 판단이 갈라지지 않도록 했다. 실행 경로에서는 주문 항목 단위 Redis 락으로 동시 환불을 직렬화하고, Toss cancel 호출 이후 주문 상태를 PARTIAL_REFUNDED 또는 REFUNDED로 전이한다.

정책 숫자는 서비스나 컨트롤러에 흩어두지 않고 별도 정책 객체로 뺐다. 조회 화면과 환불 실행 경로가 같은 메서드를 보게 하려는 선택이다.

원본 코드: OrderRefundPolicy.java:14-34

// OrderRefundPolicy.java
public static final int REFUND_WINDOW_DAYS = 7;
public static final int MAX_REFUNDABLE_PROGRESS_PERCENT = 10;

public static boolean withinRefundWindow(LocalDateTime paidAt, LocalDateTime now) {
    if (paidAt == null) {
        return false;
    }
    return !now.isAfter(paidAt.plusDays(REFUND_WINDOW_DAYS));
}

public static boolean progressWithinLimit(int progressPercent) {
    return progressPercent < MAX_REFUNDABLE_PROGRESS_PERCENT;
}

public static boolean isCourseItemRefundable(LocalDateTime paidAt, LocalDateTime now, int progressPercent) {
    return withinRefundWindow(paidAt, now) && progressWithinLimit(progressPercent);
}

실행 서비스에서도 같은 원칙을 유지했다. 주문 항목 단위로 order:refund:lock:{orderId}:{courseId} 락을 잡고, 주문 소유자와 주문 상태, 이미 환불된 항목인지, 7일 이내인지, 진도율이 10% 미만인지 다시 확인한다. 그 뒤에야 Toss cancel을 호출하고 주문 상태를 갱신한다.

원본 코드: RefundOrderItemService.java:49-105

// RefundOrderItemService.java
public void refund(Long memberId, Long orderId, Long courseId, String idempotencyKey) {
    distributedLock.runWithLock(LOCK_KEY_PREFIX + orderId + ":" + courseId, LOCK_TTL, () -> {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND));

        if (!order.getMemberId().equals(memberId)) {
            throw new BusinessException(ErrorCode.ORDER_ACCESS_DENIED);
        }
        if (order.getStatus() != OrderStatus.PAID
                && order.getStatus() != OrderStatus.PARTIAL_REFUNDED) {
            throw new BusinessException(ErrorCode.ORDER_NOT_REFUNDABLE);
        }

        OrderItem item = order.getItems().stream()
                .filter(i -> courseId.equals(i.getCourseId()))
                .findFirst()
                .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_ITEM_NOT_FOUND));
        if (item.isRefunded()) {
            return;
        }

        LocalDateTime now = LocalDateTime.now(clock);
        if (!OrderRefundPolicy.withinRefundWindow(order.getPaidAt(), now)) {
            throw new BusinessException(ErrorCode.REFUND_WINDOW_EXPIRED);
        }
        int progressPercent = orderCourseProgressPort
                .findProgressPercents(memberId, List.of(courseId))
                .getOrDefault(courseId, 0);
        if (!OrderRefundPolicy.progressWithinLimit(progressPercent)) {
            throw new BusinessException(ErrorCode.REFUND_PROGRESS_EXCEEDED);
        }

        boolean allOthersAlreadyRefunded = order.getItems().stream()
                .filter(i -> !courseId.equals(i.getCourseId()))
                .allMatch(OrderItem::isRefunded);
        OrderStatus newStatus = allOthersAlreadyRefunded
                ? OrderStatus.REFUNDED
                : OrderStatus.PARTIAL_REFUNDED;

        pgClient.cancel(order.getPaymentKey(), item.getPrice(), CANCEL_REASON);
        orderRepository.refundItem(orderId, courseId, newStatus);
    });
}

구독 환불은 강의 진도율이 없으므로 별도 경로로 뒀다. 대신 order:refund:lock:subscription:{orderId} 락을 사용해 같은 구독 주문의 중복 환불을 직렬화했다. 강의 환불과 구독 환불의 조건은 다르지만, “외부 PG 취소를 두 번 보내지 않는다”는 기준은 같았다.

남은 한계

현재 결제 확정 경로에는 구형 구조에 있던 Redis 결과 캐시가 없다. 같은 멱등키의 응답 본문을 완전히 재현하는 것보다는 중복 과금 방어를 우선한 형태다. 또한 PG 성공 후 DB 반영 실패나 권한 지급 실패를 완전 자동 복구하려면 outbox, 보정 테이블, 재처리 worker, 관리자 보정 화면이 더 필요하다.

환불 쪽도 아직 주문 상태 전이 중심이다. 설계 문서에는 환불 자체를 PENDING, SUCCEEDED, FAILED 상태 머신으로 남기고 PG 장애 재시도나 Circuit Breaker를 붙이는 방향까지 적어두었지만, 현재 코드에 완성된 것처럼 쓰지는 않으려 한다. 지금 구현에서 확실히 말할 수 있는 것은 동일 환불 요청을 락으로 직렬화하고, 화면의 refundable 값을 믿지 않고 서버 정책으로 다시 검증했다는 점이다.

이 작업에서 남은 것은 “결제 API를 만들었다”가 아니라, 외부 PG와 내부 주문·권한 상태가 어긋날 수 있는 지점을 인정하고 어떤 상태를 진실로 삼을지, 실패를 어떻게 드러내고 보정할지 판단한 경험이었다.

글 목록으로