모든 글
약 15분 분량 학습 프로젝트/pay

성능·취소·보상

목차

결제 시스템 시리즈: 성능·취소·보상. 원 연재 여러 편을 한 챕터로 묶었고, 각 절이 원래 한 편이다.

전체를 실제로 돌려보니: 부하테스트가 병목을 짚어줬다

확장 모듈까지 얹은 시스템을 처음으로 통째로 띄우고 k6 부하를 걸었다. 오류율 0%에 p95 3초. 범인은 요청마다 도는 BCrypt였다. 앱 로직은 멀쩡했다.

0. 또, 앱이 안 떴다

확장 모듈 8개(포인트·구독·월렛·FDS·암호화·감사·가상계좌·현금영수증)를 붙이고 앱을 띄우니 또 안 떴다. 7편에서 겪은 것과 같은 문제다. 확장으로 엔티티가 10개쯤 늘었는데, 그 테이블들이 Flyway 스키마에 없어서 validate가 막았다.

풀이도 같았다. Hibernate한테 엔티티에서 DDL을 파일로 뽑게 하고, 새 테이블만 골라 V3__extensions.sql로 만들었다.

Flyway: Migrating schema `pay` to version "1 - init"
Flyway: Migrating schema `pay` to version "2 - seed dev data"
Flyway: Migrating schema `pay` to version "3 - extensions"
Started PayApplication in 6.822 seconds

27개 테이블, 13개 모듈이 전부 올라왔다. 인증(ROLE_USER)이 붙은 상태로 스모크도 확인했다. 무인증 주문은 401, 인증 주문은 201.

1. 그런데 k6가 임계치를 넘었다

이제 준비해둔 k6를 다시 돌렸다. 처음엔 임계치를 넘겼다.

✗ 'p(95)<300ms' p(95)=3.08s ← 3초?!
http_req_failed rate=0.00% ← 근데 오류는 0%

오류율은 0%인데 p95가 3초. 이전 무인증 측정 때는 p95가 96ms였는데 30배가 됐다. 뭐가 달라졌나?

원인은 두 가지였다.

2. 범인 ①: 요청마다 도는 BCrypt

가장 큰 원인은 인증이었다. 보안 수정으로 결제 엔드포인트에 HTTP Basic 인증을 걸어둔 상태였다.

HTTP Basic 인증은 무상태다. 세션이 없으니 요청마다 비밀번호를 다시 검증한다. 그리고 비밀번호는 BCrypt로 해싱돼 있는데, BCrypt는 의도적으로 느리다(브루트포스 방어). 그래서 매 요청이 BCrypt 한 번씩을 돌리고, 주문+승인 한 번에 BCrypt를 두 번 돌린다.

측정에서도 이게 딱 보였다. 최소 응답시간이 110ms. 무인증 때 min이 3.5ms였으니, 이 110ms 바닥은 순수한 BCrypt 비용이다. 앱 로직과 무관하게 깔리는 인증 방식의 값인 셈이다.

실서비스가 토큰/세션을 쓰는 이유가 여기 있다. 로그인 때 한 번만 BCrypt 검증을 하고, 이후엔 JWT 서명 검증(빠름)이나 세션 조회로 요청당 재해싱을 피한다. 부하테스트가 이 설계 결정의 이유를 수치로 보여줬다.

3. 범인 ②: 머신 부하

두 번째는 환경이다. 프로젝트를 오래 돌리는 동안 로컬 머신의 로드가 6~7까지 올라가 있었다(빌드·컨테이너 여파). CPU가 포화된 상태에서 200 VU를 때리니 모든 게 느려졌다.

그래서 부하를 50 VU로 낮추고, BCrypt 비용을 감안한 임계치로 다시 쟀다.

✓ 'p(95)<1500ms' p(95)=567.84ms
✓ 'p(99)<3000ms' p(99)=712.27ms
✓ 'rate<0.01' rate=0.00%
http_reqs: 2778, 오류율 0%

오류율 0%, p95 567ms, p99 712ms. min 110ms(BCrypt 바닥)를 감안하면 앱 자체는 잘 견디고 있다.

4. 개선의 교훈

이번에 확인한 건 두 가지다.

  • “테스트 초록불 ≠ 실기동” (또): 확장 모듈이 다 통과해도 스키마 마이그레이션이 없으면 안 뜬다. 7편과 똑같은 구멍을 확장 때도 메워야 했다.
  • 부하테스트는 원인의 위치를 말해준다: “p95가 왜 3초지?”를 파고드니 답이 인증 방식이었다. 숫자를 감으로 해석하지 않고 “min이 110ms인 게 이상하다 → BCrypt다”로 좁혀 들어간 과정 자체가 성능 분석의 사례다.

“동시에 많이 결제하면?”이라는 질문에 “우리 앱은 빠릅니다”로 얼버무리는 대신 “현재 인증이 무상태 BCrypt라 요청당 ~110ms가 들고, 실서비스는 토큰으로 이걸 없앤다”라고 답할 수 있게 되는 것. 부하테스트를 실제로 돌려봐야만 나오는 답이다.

마치며

밑바닥부터 만들고, 확장하고, 실제로 돌리고, 부하까지 준 여정이 여기서 한 바퀴 돈다. 코드는 195개 테스트로 검증돼 있고, 실 MySQL 위에서 27개 테이블·13개 모듈이 라이브로 동작하는 걸 확인했다. 만드는 내내 “돌아가는 것”과 “실제로·안전하게 돌아가는 것” 사이의 구멍을 하나씩 메운 기록이기도 하다.

다만 숙제가 남았다. 일부러 미뤄둔 취소와 보상 흐름, 그리고 방금 지목만 해둔 BCrypt 병목. 아래 절들이 그 숙제를 하나씩 지우는 기록이다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 각 개선은 라이브 실측으로 검증했다.


취소는 결제의 거울상이 아니었다: 포인트 우선 환불과 부분취소의 재고 문제

미뤄둔 숙제 중 취소부터. 붙여 보니 취소는 환불 순서·재고 복원·재취소 정산이라는 세 개의 독립적인 판단이 필요한 별개 문제였다. 결제의 역연산으로 접근하면 풀리지 않았다.

0. 미뤄둔 숙제

승인 흐름을 만들 때 취소는 일부러 미뤄뒀다. 복합결제 편CheckoutService 하단엔 이런 주석이 붙어 있었다.

// NOTE: 주문 취소(cancel)는 이후 취소 단계로 미룬다 ...

실서비스 관점에서 취소가 없는 결제는 반쪽이다. 사람들은 잘못 눌러서, 마음이 바뀌어서, 배송이 안 돼서 주문을 되돌린다. 되돌리는 순간, 결제할 때는 없던 질문들이 한꺼번에 튀어나온다.

처음엔 “취소는 그냥 결제의 역연산 아닌가?” 싶었다. 결제가 포인트 차감 + 카드 승인이면, 취소는 포인트 복원 + 카드 취소. 끝. 그런데 세 군데서 막혔다.

1. 판단 ①: 무엇부터 되돌릴 것인가, 포인트 우선

복합결제는 한 주문을 포인트 + 카드로 나눠 냈다. 예를 들어 3만 원짜리를 포인트 5천 + 카드 2만 5천으로.

그럼 1만 원을 부분취소할 때, 이 1만 원을 어디서 빼서 돌려줘야 할까? 포인트에서? 카드에서? 반반?

결제 역연산으로만 생각하면 답이 안 나온다. 결제는 “포인트 먼저, 모자라면 카드”라는 순서가 있었지만, 취소엔 그 순서가 그대로 적용되지 않는다. 여기서 실서비스라면 어떻게 할지를 생각했다.

포인트를 먼저 되돌린다. 이유는 어뷰징 방지다. 카드를 먼저 환불하면 사용자는 “카드로 낸 돈은 다 돌려받고, 포인트는 계정에 그대로 남는” 상태를 만들 수 있다. 적립 포인트가 프로모션으로 준 거라면 실질적으로 “카드값 전액 환불 + 포인트 공짜 획득”이 된다. 그래서 되돌릴 때도 내부 자원(포인트)부터 회수하는 게 안전하다.

이 배분 규칙은 RefundAllocator라는 작은 순수 함수로 뽑았다. 부수효과 없이 “얼마를 포인트에서, 얼마를 카드에서”만 계산하니 단위 테스트가 쉽다.

// 포인트 우선: 취소액을 포인트 잔액 한도까지 포인트에서 빼고, 나머지를 카드에서 뺀다
long fromPoint = Math.min(cancelAmount, paidByPoint);
long fromCard = cancelAmount - fromPoint;

CancelService는 이 계산 결과를 받아 포인트 몫은 point 모듈에, 카드 몫은 payment 모듈에 위임한다. 배분(정책)과 실행(각 모듈)을 분리한 것이다.

2. 판단 ②: 부분취소는 재고를 되돌릴 수 없다

두 번째가 까다로웠다. 전액 취소는 명확하다. 주문이 통째로 없던 일이 되니, 차감했던 재고를 복원하고 주문을 CANCELED로 바꾸면 된다.

if (fully) {
for (OrderItem item : order.getItems()) {
stockDeductionService.restore(item.getProductId(), item.getQuantity());
}
order.cancel();
}

그런데 부분취소는? 3만 원 주문에서 1만 원만 취소하면, 재고를 얼마나 되돌려야 할까?

여기서 단위가 안 맞는다는 걸 깨달았다. 취소는 금액 단위(1만 원)인데, 재고는 수량 단위(책 몇 권)다. 1만 원어치가 “책 0.7권”이면 재고를 0.7권 되돌릴 수는 없다. 금액 부분취소를 수량 재고에 매핑하는 건 정의 자체가 안 된다.

그래서 이렇게 판단했다. 부분취소는 재고를 복원하지 않고, 주문 상태도 PAID로 유지한다. 돈만 일부 돌려주고, 주문은 여전히 “일부 결제가 살아있는” 상태로 둔다. 이 결정은 코드 주석에 명시적으로 남겼다. 나중에 “왜 부분취소는 재고를 안 건드리지?”가 버그로 오해받지 않게.

// 부분취소는 금액 단위라 수량 단위 재고에 매핑되지 않아 복원하지 않는다. 주문은 PAID 유지.

실무에서 “수량 단위 부분취소”(3권 중 1권만 반품)를 하려면 취소 API 자체를 금액 단위에서 주문 라인(item) 단위로 다시 설계해야 한다. 그건 별개의 기능이라, 지금은 “금액 부분취소 = 재고 불변”으로 경계를 그었다. 할 수 없는 걸 억지로 흉내 내는 것보다 못 하는 이유를 코드가 설명하게 두는 편이 낫다.

3. 판단 ③: 두 번 취소하면 두 번 환불되는가

세 번째는 재취소다. 부분취소는 한 주문에 여러 번 일어날 수 있다. 1만 원 취소하고, 나중에 또 1만 원 취소하고.

그럼 “환불 가능한 포인트”를 어떻게 알까? 결제할 때 쓴 포인트만 보면 이미 한 번 환불한 걸 또 환불해버린다. 그래서 정산이 필요하다.

public long refundableAmount(String orderNo) {
long used = historyRepository.sumAmountByOrderNoAndType(orderNo, USE);
long refunded = historyRepository.sumAmountByOrderNoAndType(orderNo, REFUND);
return Math.max(0, used - refunded); // 쓴 포인트 - 이미 환불한 포인트
}

포인트 이력을 append-only로 쌓아둔 게 여기서 빛을 발했다. 잔액을 덮어쓰지 않고 USE/REFUND를 계속 기록하니, “지금까지 얼마 쓰고 얼마 돌려줬는지”를 합산으로 정확히 구할 수 있다. 카드도 마찬가지로 payment 쪽 balanceAmount(취소 가능 잔액)를 본다.

여기에 취소 API도 승인과 똑같이 Idempotency-Key로 멱등 처리했다. 취소 버튼 “따닥” 더블클릭이나 응답 타임아웃 후 재시도가 두 번 환불로 이어지지 않게. 돈을 돌려주는 쪽이야말로 멱등성이 중요하다. 실수로 두 번 주면 그대로 손실이다.

4. 이벤트가 나머지를 알아서 이어줬다

카드 취소를 실행하면 PaymentCanceledEvent를 발행하는데, 이걸 이미 만들어둔 모듈들이 구독하고 있었다.

CancelService는 이것들을 전혀 모른다. “취소됐다”고 이벤트만 쏘면 영수증 취소도 회계 역분개도 알아서 이어진다. Spring Modulith 이벤트로 모듈을 느슨하게 묶어둔 덕에, 취소라는 새 흐름을 붙일 때 기존 모듈을 건드릴 필요가 없었다. 모듈 경계를 이벤트로 그은 보상이다.

5. 순서 하나가 보안이다

마지막으로, 사소해 보이지만 중요한 검증 순서다.

CancelService.cancel은 이 순서를 지킨다.

  1. 취소 금액이 0 이하인지 (방어)
  2. 주문 로드
  3. 소유권 검증, 무엇보다 먼저
  4. 상태 검증 (PAID만 취소 가능)
  5. 잔액 조회 → 배분 → 환불

3번을 앞에 두는 게 핵심이다. 소유권을 확인하기 전에 잔액을 조회하거나 계산하면, 남의 주문 정보가 응답 시간·에러 메시지로 새어나갈 수 있다(IDOR). 그래서 “이 주문이 당신 것인가”를 통과하기 전엔 잔액 조회조차 하지 않는다. 테스트에도 이걸 박아뒀다. 소유권 위반 케이스에서 pointService.refundableAmount한 번도 호출되지 않는지(never())를 검증한다.

- 전액취소(포인트+카드): 포인트 우선 환불 + 카드 취소 + 재고 복원 + CANCELED
- 전액취소(전액 포인트): 카드 취소 미호출, 포인트만 환불
- 부분취소: 포인트만 환불, 재고 복원 안 함, PAID 유지
- 소유권 위반: ORDER_FORBIDDEN, 잔액 조회조차 안 함
- PAID 아닌 주문: INVALID_STATE_TRANSITION
- 취소액 > 잔여: CANCEL_AMOUNT_EXCEEDED

마치며

취소를 “결제의 역연산”으로 보고 시작했지만, 실제로는 무엇부터 되돌릴지(포인트 우선), 되돌릴 수 없는 게 뭔지(부분취소의 재고), 이미 되돌린 걸 어떻게 뺄지(재취소 정산)라는 세 갈래 판단이었다. 결제는 한 방향으로 흐르지만 취소는 여러 방향으로 갈라진다. 그래서 별도 오케스트레이션이 필요했다. 되돌리는 쪽이 실은 더 조심스럽다. 이중환불 하나가 그대로 손실이니까.

이제 시스템이 “받는 것”과 사용자가 요청한 “되돌리는 것”을 다룬다. 그런데 시스템이 스스로 해야 하는 취소가 하나 더 남아 있다. 승인은 됐는데 재고가 없어 이미 나간 돈을 되돌려야 하는 경우다. 다음 절의 망취소가 그 이야기다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 취소 로직은 7종의 단위 테스트로 검증했다.


돈은 나갔는데 주문이 사라지면: 승인 후 재고 부족과 자동 망취소

두 번째 숙제는 보상이다. 카드 승인은 성공했는데 재고 차감이 실패하면 DB 롤백으로는 되돌릴 수 없다. PG에서 이미 일어난 승인은 롤백되지 않기 때문이다. 이 틈을 자동 망취소, 즉 성공할 때까지 재시도하는 보상 트랜잭션으로 메웠다.

0. 남겨둔 한 줄

승인 흐름을 만들 때 CheckoutService엔 이런 주석이 있었다.

// 차감 실패(품절 경합)는 예외 → 이후 망취소/보상 트랜잭션으로 승격.

미뤄둔 데는 이유가 있다. 이게 결제 시스템에서 가장 위험한 순간 중 하나이기 때문이다.

1. 위험한 순간: 승인 성공 → 재고 부족

주문 승인의 순서를 다시 보자.

  1. 포인트 선점
  2. 카드 승인 (외부 PG 호출)
  3. 재고 차감
  4. 주문 PAID

문제는 2와 3 사이다. 카드는 이미 승인됐는데(2), 재고 차감(3)이 품절 경합으로 실패하면 어떻게 될까?

원래 코드는 재고 차감 실패 시 예외를 던졌고, @Transactional이 전부 롤백했다. 그런데 여기에 무서운 구멍이 있다.

@Transactional이 롤백하는 건 DB뿐이다. 하지만 2번의 카드 승인은 PG(외부 시스템)에서 이미 일어났다. DB를 롤백해도 PG의 승인은 되돌아오지 않는다. 결과는 고객 카드에서는 돈이 빠져나갔는데 우리 DB엔 주문이 없는 상태. 고객 입장에선 “결제했는데 아무것도 못 받은” 최악의 경험이다.

이게 분산 트랜잭션의 본질적 문제다. 우리 DB와 PG는 한 트랜잭션으로 묶을 수 없다. 그래서 “둘 중 하나가 이미 커밋됐는데 다른 하나가 실패”하는 순간은 보상(compensation)으로 풀어야 한다. 롤백으로는 풀 수 없다.

2. 해법: 롤백 대신 자동 망취소

방향을 바꿨다. 재고 차감이 실패하면 롤백하지 않는다. 대신 이미 승인된 카드를 취소(망취소)하는 걸로 되돌린다.

그런데 망취소도 PG 호출이라 또 실패할 수 있다. 네트워크가 끊길 수도, PG가 잠깐 죽었을 수도 있다. 그래서 망취소는 지금 한 번 시도하고 끝내는 대신, durable하게 적재해두고 성공할 때까지 재시도하는 구조로 만들었다. 이게 compensation_tasks 테이블이다. outbox 패턴의 사촌이다.

흐름은 이렇다.

카드 승인 성공 → 재고 차감 시도 → 실패(품절)
├─ 이미 차감된 재고가 있으면 원복
├─ 선점한 포인트 복원 (내부 자원 — 즉시·확실)
├─ compensation_tasks에 "이 카드 망취소해" 태스크 적재
├─ 주문 FAILED
└─ 트랜잭션 커밋 (승인된 결제 + 보상 태스크가 함께 남는다)
[스케줄러] PENDING 태스크를 주기적으로 집어
→ PG 망취소 호출
├─ 성공 → DONE
└─ 실패 → 재시도 카운트++ , 지수 백오프로 다음 시도 예약
└─ 재시도 소진 → FAILED (운영 개입 알림)

핵심은 “내부적이고 확실한 것”과 “외부적이고 불확실한 것”을 나눈 것이다. 포인트 복원은 우리 DB 안이라 즉시 확실하게 처리하고, PG 망취소만 durable 재시도 큐로 뺐다. 불확실한 것만 재시도 인프라에 태우는 구조다.

3. 진짜 함정: 잡은 예외가 트랜잭션을 오염시킨다

여기서 예상 못 한 벽에 부딪혔다. 처음엔 이렇게 짰다.

try {
stockDeductionService.deductConditional(productId, qty); // 실패 시 OrderException 던짐
} catch (OrderException e) {
// 보상 처리...
compensationService.enqueueNetworkCancel(...);
order.markFailed();
// 예외를 안 던지고 정상 리턴 → 커밋되겠지?
}

“예외를 잡아서 삼켰으니 트랜잭션은 커밋되겠지” 했는데 안 됐다. 최종 커밋에서 UnexpectedRollbackException이 터지고, 보상 태스크 적재까지 다 롤백됐다.

원인은 Spring 트랜잭션의 미묘한 규칙이다.

deductConditional@Transactional 메서드고, 바깥 트랜잭션에 참여(join)한다. 이게 예외를 던지는 순간, Spring은 공유 트랜잭션을 rollback-only로 표시한다. 바깥에서 그 예외를 잡아도 트랜잭션은 이미 “이건 무조건 롤백”으로 낙인이 찍힌 상태다. 그래서 커밋 시도가 UnexpectedRollbackException으로 실패한다.

“잡았다”고 없던 일이 되는 게 아니다. 참여 트랜잭션 안에서 던져진 예외는 잡아도 전체를 오염시킨다.

해법은 애초에 예외를 던지지 않는 것이었다. 조건부 차감을 boolean으로 바꾼 tryDeduct를 새로 만들었다.

/** 예외 없는 조건부 차감 — 성공 true, 재고부족 false. */
@Transactional
public boolean tryDeduct(long productId, int qty) {
return stockRepository.deductConditionally(productId, qty) > 0;
}

이제 체크아웃 경로는 예외 대신 boolean 분기로만 흐른다. 트랜잭션을 오염시키는 예외가 없으니 보상 상태(승인된 결제 + 보상 태스크)가 온전히 함께 커밋된다. 기존 deductConditional(예외 버전)은 다른 호출부를 위해 그대로 뒀다.

“예외를 잡으면 안전하다”는 직관이 트랜잭션 경계 안에서는 틀린다. 문서로 아는 것과 커밋이 터지는 걸 눈으로 보는 건 달랐다.

4. 무한 재시도를 막는 멱등

재시도 구조엔 함정이 하나 더 있다. 망취소를 재시도하다가, 이미 다른 경로로 취소된 결제를 또 취소하려 하면? 이 프로젝트의 취소는 “성공한 결제가 없으면 PAYMENT_NOT_FOUND”를 던진다. 이걸 실패로 처리하면 영원히 재시도하게 된다. 이미 취소된 걸 계속 취소하려고.

그래서 이렇게 처리했다.

} catch (PaymentException e) {
if ("PAYMENT_NOT_FOUND".equals(e.code())) {
task.markDone(); // 취소할 게 없다 = 이미 보상됨 → 완료로 간주(멱등)
} else {
throw e; // 그 외 예외만 재시도 대상
}
}

“취소할 결제가 없다”는 건 이미 목적이 달성된 상태다. 실패로 볼 이유가 없다. 보상 작업에서 멱등성은 이렇게 “재시도해도 같은 결과”를 보장하는 안전장치다.

5. 소진하면 멈추고 알린다

지수 백오프로 재시도하되, maxRetries(5회)를 넘으면 태스크를 FAILED로 두고 더는 자동 재시도하지 않는다. 대신 compensation.exhausted 카운터를 올린다.

if (task.isExhausted()) {
meterRegistry.counter("compensation.exhausted").increment(); // 알림 룰의 소스
}

무한 재시도는 그 자체가 장애를 키운다(죽은 PG를 계속 때리기). 그래서 “자동으로 될 만큼 해보고, 안 되면 사람을 부른다”로 경계를 그었다. 이 카운터가 0보다 크면 운영이 개입해야 한다는 신호다. 운영 관측성 편에서 만든 메트릭 기반 알림의 연장선이다.

스케줄러 자체는 app.compensation.enabled 프로퍼티로 켜고 끈다. 기본은 꺼둬서 테스트·로컬 부팅에 부작용이 없고, 운영에서만 환경변수로 켠다. 복구 배치와 같은 방식이다. 배치 로직은 순수 메서드로 두고 테스트는 직접 호출한다.

마치며

카드 승인(PG)과 재고 차감(DB)은 각자 다른 세계에 있고, 두 시스템을 한 트랜잭션으로 묶을 수 없다는 현실을 이 절에서 정면으로 다뤘다. 둘의 불일치는 보상으로만 풀린다. 그 보상을 안전하게 만들려다 참여 트랜잭션의 rollback-only 오염이라는 Spring의 함정을 만났고, 예외를 안 던지는 설계로 우회했다. 결제의 신뢰성은 이런 부분 실패의 봉합에서 갈린다.

이걸로 미뤄둔 취소·보상 숙제는 끝났다. 남은 건 부하테스트가 지목해 둔 병목, 요청마다 도는 BCrypt다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 보상 흐름은 14개의 단위 테스트로 검증했다.


병목을 지목했으면 제거해야지: JWT로 요청당 BCrypt를 걷어낸 전후 수치

인증을 HTTP Basic에서 JWT로 바꾸고 같은 조건에서 재측정했다. min 110ms → 4.17ms, p95 567ms → 37ms. 요청당 BCrypt라는 바닥이 통째로 사라졌다.

0. 지난 편의 숙제

부하테스트 편에서 p95가 567ms까지 오른 걸 파고들었더니, 범인은 요청마다 도는 BCrypt였다. 앱 로직의 문제가 아니었다.

HTTP Basic 인증은 무상태다. 세션이 없으니 요청마다 비밀번호를 다시 검증하고, 비밀번호는 BCrypt로 해싱돼 있는데 BCrypt는 의도적으로 느리다(브루트포스 방어). 측정에서 그대로 드러났다. 최소 응답시간 110ms. 이 바닥이 순수한 BCrypt 비용이었다.

그때 글은 “실서비스는 토큰/세션으로 이걸 없앤다”로 끝났다. 이번엔 그 문장을 실제 코드로 옮긴다. 병목을 지목만 하고 끝내면 반쪽이다.

1. 왜 토큰이 BCrypt를 없애는가

핵심은 비밀번호 검증을 언제 하느냐다.

  • HTTP Basic: 매 요청에 id:pw가 실려 온다. 그러니 서버는 요청마다 저장된 해시와 BCrypt로 대조한다. 요청 100번이면 BCrypt 100번.
  • JWT: 로그인 때 딱 한 번 BCrypt로 검증하고, 성공하면 서명된 토큰을 발급한다. 이후 요청은 그 토큰의 서명만 검증한다. 서명 검증(HMAC-SHA256)은 BCrypt와 비교하면 거의 공짜다. 마이크로초 단위.

BCrypt가 느린 건 의도된 방어 기능이다. 느려야 무차별 대입 공격이 어렵다. BCrypt 자체를 빠르게 만들면 그 방어를 깎는 셈이니, 돌리는 횟수를 줄여야 한다. 로그인 1회로 몰아넣는 게 정답이다.

2. 구현: Spring Security OAuth2 Resource Server

Spring Security의 정석대로 갔다. 자체 발급/검증이라 외부 IdP 없이 대칭키(HS256)를 쓴다.

  • 로그인(POST /api/v1/auth/login): AuthenticationManager로 자격증명을 검증(여기서 BCrypt 1회)하고, 성공하면 subject에 userId, roles 클레임에 권한을 담아 서명된 JWT를 발급.
  • 검증: oauth2ResourceServer(jwt)가 요청마다 토큰 서명만 확인. 세션은 STATELESS.

미묘하게 신경 쓴 게 두 가지 있다.

(1) principal이 계속 userId를 가리켜야 한다. 이 프로젝트는 주문 소유권 검증principal.getName()(=userId)으로 한다. 그래서 JWT의 subject에 userId를 넣어, 인증 방식을 바꿔도 Long.parseLong(principal.getName())이 그대로 동작하게 했다. 소유권 검증 코드는 한 줄도 안 건드렸다.

(2) roles 클레임과 권한 접두사를 맞물리기. 로그인 시 getAuthorities()가 이미 ROLE_USER를 주니 그대로 클레임에 넣고, 검증 측 컨버터는 접두사를 빈 문자열로 둬서 클레임 값이 그대로 authority가 되게 했다. 이래야 hasRole("USER")가 맞아떨어진다. 여기가 어긋나면 인증은 되는데 인가에서 403이 난다. 흔한 삽질 포인트다.

시크릿은 하드코딩 키를 제거했던 것과 같은 결로, HS256 최소 길이(32바이트) 미만이면 기동을 막았다(fail-fast).

3. 통제된 재측정: 변수는 인증 하나

이제 중요한 부분이다. 개선을 주장하려면 변수를 하나만 바꿔야 한다.

그래서 지난 측정과 똑같은 조건으로 맞췄다. 같은 노트북, 비슷한 머신 부하(load avg ~6), 같은 50 VU, 같은 주문→승인 시나리오. 바꾼 건 인증 방식 HTTP Basic→JWT 하나다. k6 스크립트도 setup()에서 한 번 로그인해 토큰을 받고, 이후엔 그 Bearer 토큰을 재사용하게 고쳤다.

결과는 이렇다.

지표HTTP Basic + BCryptJWT개선
min110ms (BCrypt 바닥)4.17ms~26배
p95567.84ms37.09ms~15배
p99712.27ms58.47ms~12배
오류율0%0%동일
✓ 'p(95)<1500' p(95)=37.09ms
✓ 'p(99)<3000' p(99)=58.47ms
✓ 'rate<0.01' rate=0.00%
http_req_duration: min=4.17ms med=17.53ms max=145.11ms
http_reqs: 4071 (50 req/s), 오류율 0%

요청당 110ms의 BCrypt 바닥이 통째로 사라졌다. min이 110ms→4.17ms로 떨어진 게 그 증거다. 이 바닥이 순수 BCrypt 비용이었으니까.

개선폭이 유독 큰 건, 한 번의 체크아웃이 주문 + 승인으로 인증을 두 번 타기 때문이다. Basic일 땐 BCrypt를 두 번 돌렸는데, JWT에선 둘 다 서명 검증으로 바뀌었다.

4. 무엇을 배웠나

이번 작업은 새 기능이라기보단 “측정 → 병목 지목 → 개선 → 재측정”의 한 사이클을 닫은 것이다.

  • 앞 절에서 “min 110ms가 이상하다 → BCrypt다”로 병목을 지목했고,
  • 이번 절에서 그걸 제거하고 같은 조건에서 재측정해 효과를 수치로 확인했다.

성능 개선에서 제일 흔한 함정이 “이것저것 바꾸고 빨라졌다”다. 그러면 뭐가 효과였는지 모른다. 변수를 인증 하나로 묶어두니 37ms라는 결과가 온전히 JWT 덕이라고 말할 수 있다. 카카오페이의 “2.5배 개선기”가 개선을 하나씩 넣고 전후를 재는 것도 같은 이유다.

물론 이게 끝은 아니다. JWT 검증도 공짜가 아니고(서명 계산·클레임 파싱), 실서비스라면 토큰 만료·갱신(refresh)·폐기(블랙리스트)까지 설계해야 한다. 하지만 “요청당 재해싱”이라는 가장 큰 덩어리는 들어냈고, 그게 수치로 남았다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 재측정은 실 MySQL·Redis 위에서 k6로 실행했다.

Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.