Spring 개발일지

[팀프로젝트] MSA 기반 티켓팅 프로그램 - 토스 ACL + 결제 승인 API 구현 삽질기

김둘리 2026. 5. 1. 09:42

토스페이먼츠 연동은 생각보다 훨씬 많은 삽질이 있었다. ACL 패턴 구현부터 시크릿 키 불일치, OffsetDateTime 이슈까지 겪은 것들을 전부 기록한다.


1. ACL 패턴이란?

ACL(Anti-Corruption Layer)은 외부 시스템의 모델이 내부 도메인을 오염시키지 않도록 격리하는 레이어다.

토스페이먼츠 API는 외부 서비스다. 만약 토스의 응답 모델을 도메인 레이어에서 직접 사용하면 토스가 응답 구조를 바꿀 때마다 도메인 코드를 수정해야 한다. 또 PG사를 교체할 때 코드 변경 범위가 걷잡을 수 없이 커진다.

도메인 레이어 → TossPaymentsPort (interface)
infrastructure → TossPaymentsAdapter (구현체)

TossPaymentsPort는 도메인이 알아야 할 계약만 정의하고, 실제 토스 API 호출은 TossPaymentsAdapter에서 담당한다. PG사를 교체할 때 어댑터만 교체하면 된다.


2. 구현 구조

domain/service/
├── TossPaymentsPort.java        ← 인터페이스
└── dto/
    ├── TossConfirmResult.java   ← 도메인 VO
    └── TossCancelResult.java    ← 도메인 VO

infrastructure/external/
├── TossPaymentsAdapter.java     ← 구현체
└── dto/
    ├── TossConfirmRequest.java
    ├── TossConfirmResponse.java  ← 토스 응답 모델
    ├── TossCancelRequest.java
    └── TossCancelResponse.java

토스의 응답 모델(TossConfirmResponse)은 infrastructure에만 존재하고 도메인 레이어는 알지 못한다. 어댑터가 토스 모델을 도메인 VO(TossConfirmResult)로 변환해서 반환한다.


3. 결제 승인 흐름

클라이언트 → POST /api/v1/payments/confirm
→ orderId로 DB에서 결제 조회
→ 금액 위변조 검증
→ 토스 승인 API 호출
→ Payment 상태 SUCCESS로 업데이트
→ PaymentHistory 이력 추가

금액 위변조 검증이 핵심이다. 클라이언트가 confirm 요청 시 amount를 변조할 수 있기 때문에 DB에 저장된 finalAmount와 비교한다.

@Transactional
public PaymentResult confirmPayment(ConfirmPaymentCommand command) {
    // 1. orderId로 결제 조회
    Payment payment = paymentRepository.findByOrderId(command.orderId())
            .orElseThrow(() -> new IllegalArgumentException("결제를 찾을 수 없습니다."));

    // 2. 금액 위변조 검증
    if (!payment.getFinalAmount().equals(command.amount())) {
        throw new IllegalArgumentException("결제 금액이 일치하지 않습니다.");
    }

    // 3. 토스 승인 요청
    TossConfirmResult result = tossPaymentsPort.confirm(
            command.paymentKey(),
            command.orderId(),
            command.amount()
    );

    // 4. 결제 상태 변경
    payment.confirm(result.paymentKey(), result.approvedAt());
    paymentRepository.save(payment);

    return PaymentResult.from(payment);
}

4. 삽질 1 — INVALID_API_KEY (가장 오래 걸린 문제)

결제 승인 API를 만들고 테스트를 시작했는데 계속 이 에러가 났다.

400 Bad Request: {"code":"INVALID_API_KEY","message":"잘못된 시크릿키 연동 정보 입니다."}

시크릿 키를 로그로 찍어봤더니 값은 제대로 들어와 있었다. 한참을 헤매다가 원인을 찾았다.

문제: 토스 샌드박스 예제 코드의 clientKey가 샘플 키였다

토스 샌드박스 예제 checkout.jsx의 기본 clientKey는 이렇게 되어있었다.

const clientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm"; // 토스 샘플 키

이 샘플 키로 결제한 paymentKey는 토스 샘플 시크릿 키로만 confirm이 가능하다. 우리 시크릿 키와 불일치해서 계속 INVALID_API_KEY가 났던 것이다.

샘플 clientKey로 결제 → 샘플 paymentKey 발급
→ 우리 시크릿 키로 confirm 시도 → 키 불일치 → INVALID_API_KEY

해결 시도 1: clientKey를 우리 API 개별 연동 키로 교체

const clientKey = "test_ck_QbgMGZzorzmyQnGjmOXkVl5E1em4"; // 우리 키

저장하고 테스트하니까 이런 에러가 났다.

결제위젯 연동 키의 클라이언트 키로 SDK를 연동해주세요. API 개별 연동 키는 지원하지 않습니다.

토스 샌드박스 예제가 결제위젯 SDK를 사용하는 방식인데 결제위젯은 결제위젯 연동 키만 지원한다. 결제위젯 연동 키는 사업자 등록번호가 필요해서 발급이 안 된다.

해결: 로컬 test.html 방식으로 전환

결제위젯이 아닌 토스 일반 결제창(js.tosspayments.com/v1/payment)은 API 개별 연동 키로 동작한다.

결제하기
    const tossPayments = TossPayments("test_ck_QbgMGZzorzmyQnGjmOXkVl5E1em4");

    function payment() {
      tossPayments.requestPayment("카드", {
        amount: 50000,
        orderId: "여기에orderId",
        orderName: "First Ticket 예매",
        customerName: "김토스",
        successUrl: "http://localhost:8085/api/v1/payments/confirm-redirect",
        failUrl: "http://localhost:8085/fail"
      });
    }
  

터미널에서 파일 생성

cat > ~/Desktop/test.html << 'EOF'
...
EOF

브라우저에서 test.html을 열고 결제하기 버튼을 누르면 토스 결제창이 뜬다. 결제 완료 후 successUrl로 리다이렉트되면서 서버가 자동으로 confirm을 처리한다.


5. 삽질 2 — RestClientException (응답 역직렬화 실패)

INVALID_API_KEY 문제를 해결하고 다시 테스트했더니 새로운 에러가 났다.

Error while extracting response for type [class TossConfirmResponse] and content type [application/json]

토스 API 호출은 성공했는데 응답을 TossConfirmResponse로 변환하는 데 실패한 것이다.

원인: approvedAt 타입 불일치

토스가 approvedAt을 이런 형식으로 보내준다.

"2026-04-27T11:05:15+09:00"

+09:00이 포함된 형식이라 LocalDateTime으로는 파싱이 안 된다. OffsetDateTime을 써야 한다.

해결: LocalDateTime → OffsetDateTime

@JsonIgnoreProperties(ignoreUnknown = true)
public record TossConfirmResponse(
        String paymentKey,
        String orderId,
        Integer totalAmount,
        String status,
        OffsetDateTime approvedAt  // LocalDateTime → OffsetDateTime
) {
    public TossConfirmResult toResult() {
        return new TossConfirmResult(
                this.paymentKey,
                this.orderId,
                this.totalAmount,
                this.status,
                this.approvedAt != null ? this.approvedAt.toLocalDateTime() : null
        );
    }
}

OffsetDateTime으로 받아서 toLocalDateTime()으로 변환해서 도메인 VO에 넘긴다.


6. 삽질 3 — Config Server 없이 실행

서버 실행 시 이런 에러가 났다.

No spring.config.import property has been defined

Config Server가 없어서 발생하는 에러다. application.yml에 optional:configserver: 설정으로 해결했다.

spring:
  config:
    import: "optional:configserver:"  # 따옴표 필수! 없으면 yaml 파싱 에러

콜론이 포함된 값이라 따옴표로 감싸야 한다. 처음엔 따옴표 없이 작성했다가 mapping values are not allowed here 에러를 추가로 겪었다.


7. 최종 동작 확인

1단계: 결제 생성 API 호출

POST http://localhost:8085/internal/v1/payments
{
    "bookingId": "aa0e8400-e29b-41d4-a716-446655440050",
    "userId": "bb0e8400-e29b-41d4-a716-446655440060",
    "finalAmount": 50000
}

2단계: test.html에서 orderId 교체 후 결제

3단계: 토스 결제창에서 카드 결제 완료

4단계: successUrl로 리다이렉트 → 서버 자동 confirm

최종 응답

{
    "success": true,
    "code": "OK",
    "message": "요청이 성공했습니다",
    "data": {
        "paymentId": "c63e9443-be65-49a0-b51c-a98361c5de67",
        "orderId": "7e0fb8707d6d4454862b8e75be6843c9",
        "amount": 50000
    }
}

DB에서 update p_payments set status='SUCCESS' 쿼리가 찍히면서 결제 승인이 완료됐다.


마무리

토스 연동에서 겪은 삽질을 정리하면 이렇다.

삽질 1 — INVALID_API_KEY 샌드박스 예제 코드의 clientKey가 샘플 키라 우리 시크릿 키와 불일치. 결제위젯 SDK는 결제위젯 연동 키만 지원하므로 일반 결제창 방식으로 우회해서 해결.

삽질 2 — 응답 역직렬화 실패 토스 approvedAt 응답 형식이 OffsetDateTime이라 LocalDateTime으로 파싱 불가. OffsetDateTime으로 수정 후 해결.

삽질 3 — Config Server 없이 실행 optional:configserver: 값에 따옴표 필수. 없으면 yaml 파싱 에러 발생.

다음 편에서는 아웃박스 패턴 구현을 다룰 예정이다.