실기동: 승인됐다고 답했는데 DB에는 결제가 없었다
목차
개요
그 시점 테스트 200여 개가 전부 초록불인 상태에서 서버를 실제로 띄워봤더니, 승인됐다고 응답을 보낸 결제가 DB에는 없었습니다. 단위 테스트는 내가 상상한 경계만 검사하고, 상상하지 못한 경계는 띄워봐야 나옵니다.
이 글은 그렇게 실기동과 전수 감사로 드러난 여섯 가지를 다룹니다. 이벤트는 두 번 인코딩되고 있었고, 배치는 로직만 있고 부르는 사람이 없었고, 만들어둔 암호화 금고에는 정작 아무것도 안 들어가 있었고, 접근 차단 목록은 서버를 재시작하면 사라졌고, 이스케이프 함수는 안 통하는 자리에 쓰여 있었습니다.
| 상황 | 다룬 것 |
|---|---|
| 상황 1 | 승인됐다고 답했는데 DB엔 결제가 없었다 |
| 상황 2 | 프로세스 밖 소비자를 붙이니 이중 인코딩이 드러났다 |
| 상황 3 | 아무도 부르지 않는 배치들. javadoc은 스케줄러가 있다고 믿었다 |
| 상황 4 | 암호화 금고를 만들어놓고 아무것도 안 넣었다 |
| 상황 5 | 재시작하면 접근 차단 목록이 사라졌다 |
| 상황 6 | 만들어둔 이스케이프 함수를 안 통하는 자리에 썼다 |
개인 프로젝트로 만든 결제 시스템 pay의 개발 기록입니다. 실무 운영 경험이 아닙니다.
”승인됐습니다”라고 답했지만 DB엔 없었다: 실기동이 드러낸 결제 확정 버그
응답은 PAID인데 DB엔 PENDING_PAYMENT가 남아 있었습니다. 결제 승인이 실제로는 DB에 확정되지 않는 버그가, 200개 넘는 테스트가 전부 초록불인 채로 실기동에서 드러났습니다.
0. 구매확정이 자꾸 막혔다
에스크로를 붙이고 실기동으로 흐름을 눌러봤습니다. 주문 생성 → 결제 승인 → 구매확정. 그런데 구매확정이 자꾸 막혔습니다.
POST /api/v1/orders/{orderNo}/confirm-purchase→ 409 "결제 완료 주문만 구매확정할 수 있습니다."이상했습니다. 바로 전에 결제 승인 응답은 분명 이랬으니까.
{ "orderNo": "...", "orderStatus": "PAID", "paymentStatus": "DONE", "message": "승인 완료" }PAID라고 응답했는데 구매확정은 “PAID가 아니다”라며 막습니다. 앞뒤가 안 맞아서 DB를 직접 열어봤습니다.
SELECT status FROM orders WHERE order_no='...'; -- PENDING_PAYMENT (?!)SELECT status FROM payments WHERE order_no='...'; -- IN_PROGRESS (?!)응답은 PAID/DONE인데 DB는 PENDING_PAYMENT/IN_PROGRESS. 결제 승인이 실제로는 DB에 확정되지 않고 있었습니다. 결제 시스템에서 이건 최악의 종류입니다. 됐다고 답해놓고 실제론 안 된 상태니까.
1. 이상한 단서: 트랜잭션은 커밋되는데 UPDATE가 없다
먼저 상태머신을 의심했습니다. 그런데 PAID → PENDING_PAYMENT는 상태 전이표상 불가능한 전입니다. PAID였다가 되돌아간 게 아니었습니다. 애초에 PAID가 저장된 적이 없다는 뜻이었습니다.
응답의 orderStatus는 서비스가 메모리에 들고 있는 order.getStatus()로 만듭니다. 그건 PAID. 그런데 DB엔 반영이 안 됐습니다. 그럼 트랜잭션이 롤백됐나? 아니었습니다. 트랜잭션 디버그 로그를 켜봤습니다.
Creating new transaction [CheckoutService.confirm]Participating in existing transaction (...)Initiating transaction commitCommitting JPA transaction on EntityManager [...]트랜잭션은 정상적으로 생성되고 커밋됐습니다. 그런데 SQL 로그는 이랬습니다.
insert into payments (...) ← startApproval: IN_PROGRESSinsert into payment_history (...) ← 이력insert into payment_history (...) ← approve()가 남긴 두 번째 이력update stock ... ← 재고 차감(명시 쿼리)...(update payments set status=DONE → 없음!)(update orders set status=PAID → 없음!)INSERT는 나가는데 상태를 바꾸는 UPDATE만 안 나갑니다. 트랜잭션이 커밋되는데도 dirty-checking이 만들어야 할 UPDATE가 없었습니다.
2. 왜 INSERT는 되고 UPDATE는 안 될까
여기서 실마리가 잡혔습니다. 이 프로젝트의 엔티티는 @GeneratedValue(strategy = IDENTITY)를 씁니다. IDENTITY 전략은 persist() 시점에 즉시 INSERT를 실행합니다(DB가 생성한 ID를 바로 받아와야 하니까). 그래서 신규 엔티티의 INSERT는 flush 여부와 무관하게 즉시 나갑니다. 반면 기존 엔티티의 상태 변경(UPDATE)은 커밋 시점의 flush에 의존합니다. 그 flush가 안 일어나면 UPDATE도 없습니다.
문제는 이 트랜잭션의 영속성 컨텍스트가 커밋 때 변경분을 flush하지 않는다는 것이었습니다. IDENTITY 즉시 INSERT가 이 문제를 가려서, 주문 행은 있으니 저장은 되는 줄 착각하게 만들었습니다.
정정(두 번 고쳤다): 처음엔 “OSIV를 꺼서 그렇다”고 이해했는데 부정확했습니다. 그래서 “readOnly 조회가 FlushMode를 MANUAL로 바꿔서”라고 고쳤는데, 그것도 틀렸습니다.
나중에 네 경우를 실제로 재현해 확정했습니다(
ReadOnlyFlushSemanticsTest).
경우 결과 read-write + managed 커밋 때 flush된다 안에 readOnly=true조회를 껴도여전히 flush됩니다, 참여하면 readOnly는 무시된다 독립 readOnly트랜잭션MANUAL이고 그 안의 변경은 안 남는다detached 엔티티 dirty check 대상이 아니다 즉
saveAndFlush가 실제로 막는 건 마지막 하나입니다. 조회와 변경 사이에 트랜잭션 경계가 있어 엔티티가 detached면 dirty check가 아예 동작하지 않습니다. “readOnly 조회 탓”은 재현으로 반증됐습니다.
3. 고치기: 애그리거트를 명시적으로 영속
해법은 dirty-checking의 자동 flush에 의존하지 않고, 상태를 바꾼 애그리거트를 명시적으로 저장하는 것.
// 상태 전이(approve/abort/markUnknown)를 명시적으로 영속합니다.paymentRepository.saveAndFlush(payment);처음엔 save()로 했는데, 여기서 재밌는 걸 발견했습니다. save()로 되는 곳과 안 되는 곳이 갈렸습니다.
findByOrderNo로 불러온(detached) 엔티티(주문·에스크로 홀드)는save()(=merge)로 확정됐습니다.initiate로 새로 만들어 persist한 managed 엔티티(결제)는save()가 merge no-op이 되어 확정되지 않았습니다.
이 트랜잭션에서 자동 flush를 신뢰할 수 없다는 게 드러난 셈입니다. 그래서 모든 애그리거트 상태 확정 지점을 saveAndFlush로 통일했습니다. merge 여부와 무관하게 flush를 강제합니다. 가끔 되고 가끔 안 되는 저장을 결제 시스템에 둘 수는 없습니다.
같은 버그가 confirm뿐 아니라 취소·복구 배치·에스크로·가상계좌·구독에도 다 있었습니다. 불러와서 상태만 바꾸는 모든 곳. 전수 조사해서 한꺼번에 고쳤습니다.
고친 뒤 실기동으로 다시 확인했습니다.
승인 → 주문 PAID, 결제 DONE, 에스크로 RELEASED전액취소 → 주문 CANCELED, 결제 CANCELED(잔액 0), 에스크로 REFUNDED부분취소 → 주문 PAID, 결제 PARTIAL(잔액 7천), 에스크로 HELD 유지이제 응답과 DB가 일치합니다.
4. 왜 여태 안 잡혔나: 테스트의 사각지대
제일 뼈아픈 질문. 테스트가 200개가 넘는데 왜 이걸 못 잡았지?
답은 테스트의 성격에 있었습니다. 이 프로젝트의 단위 테스트는 리포지토리를 목(mock)으로 둡니다.
OrderRepository orderRepository = mock(OrderRepository.class);// order.markPaid() 후 상태가 PAID인지 "메모리에서" 검증assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID); // 통과!이 테스트는 “서비스 로직이 order를 PAID로 바꿨는가”를 검증합니다. 그건 맞았습니다. 하지만 그게 실제 DB에 반영됐는지는 목이라서 검증할 수가 없습니다. dirty-checking flush 같은 영속성 계층의 동작은 진짜 DB를 써야만 드러납니다.
그래서 이 버그는 단위 테스트의 사각지대에 정확히 숨어 있었습니다. 부하테스트(k6)도 HTTP 200만 봤지 DB 상태는 안 봤습니다. 에스크로가 “주문이 PAID여야 한다”는 조건을 실제로 요구하면서, 실기동에서 처음 터진 것입니다.
목 기반 단위 테스트는 로직을 검증할 뿐 영속까지 검증하지 못합니다. 상태 전이가 DB에 남는지는 실 DB 통합 테스트로만 확인됩니다. 그래서 재발 방지로 각 수정 지점에 verify(repo).saveAndFlush(...) 단언을 넣어, 최소한 “명시 저장을 호출한다”는 계약을 고정했습니다. 그리고 기능은 끝까지 눌러봐야 합니다. “API가 200을 준다”와 “DB에 올바르게 남는다”는 다른 얘기입니다. E2E 실기동으로 실제 상태를 확인하지 않았다면, 이 버그는 운영에서 “결제됐다는데 주문이 없어요” 문의로 터졌을 것입니다.
”프로세스 밖 소비자”라는 약속을 실제로 지키기: 붙여보니 드러난 이중 인코딩
외부화해둔 Kafka 이벤트를 구독하는 소비자를 드디어 만들었습니다. 그리고 붙이자마자 알았습니다. JSON이어야 할 와이어에 base64가 흐르고 있었습니다.
0. 구독자 없는 외부화는 약속일 뿐
Kafka 외부화 편에서 결제 이벤트에 @Externalized를 달아 Kafka로 내보냈습니다. 목적은 분명했습니다. 프로세스 밖 소비자(분석, 별도 서비스, 다른 팀)가 이벤트 모델을 고치지 않고 구독할 수 있게 하는 것.
그런데 돌아보니 정작 구독하는 프로세스 밖 소비자가 하나도 없었습니다. 토픽에 이벤트가 실리는 것까진 확인했지만, “다른 프로세스가 실제로 받아서 쓸 수 있다”는 건 여전히 약속이었지 실증이 아니었습니다. FDS(이상거래 탐지) 엔진도, 대사(내 결제 기록과 PG가 보낸 정산 파일을 맞춰보는 작업) 엔진도 만들어두고 안 연결된 상태였다가 연결하면서 완성됐습니다. 외부화도 소비자가 있어야 완성입니다.
그래서 만들었습니다. 이번에도 실제로 붙여보니 몰랐던 게 드러났습니다.
1. 소비자 앱: 메인과 완전히 분리
소비자는 “정산 알림” 데모 워커입니다. payment.confirmed/payment.canceled를 구독해 구조화 로그를 남기는 경량 앱. 설계에서 지킨 분리 원칙은 세 가지입니다.
(1) 빌드부터 분리. consumer-app/은 자체 settings.gradle을 가진 독립 Gradle 프로젝트입니다. 루트 멀티모듈에 include하지 않아서 메인의 빌드·테스트·CI가 이 앱의 존재조차 모릅니다. 실행만 wrapper를 공유합니다(./gradlew -p consumer-app bootRun). “별도 서비스”라면 빌드 생명주기부터 별도여야 합니다.
(2) 타입도 분리. 소비자는 메인 앱의 이벤트 클래스를 import하지 않습니다. 값을 String으로 받아 Jackson readTree로 파싱합니다. producer가 붙이는 타입 헤더(__TypeId__: com.beomsu.pay...PaymentConfirmedEvent)에 기대면 소비자가 발행자의 내부 클래스명에 결합되고, 그 순간 “프로세스 밖”의 의미가 사라집니다. 계약으로 삼을 것은 JSON 스키마입니다.
(3) 실패도 분리. 파싱 안 되는 메시지(포이즌)는 warn 찍고 건너뛰게 했습니다.
이건 나중에 뒤집었습니다. 파티션이 멈추는 걸 막으려던 건 맞는데, 선택지를 둘로만 봤습니다, 던지거나(멈춤) 버리거나(유실). 그 사이에 DLT(Dead Letter Topic, 실패한 메시지를 버리지 않고 따로 격리해두는 토픽)가 있습니다. 건너뛰면 오프셋까지 커밋되니 그 이벤트는 되돌릴 방법 없이 사라집니다.
더 이상한 건, 본체는 이미 알림 실패를 DeadLetter 테이블로 격리하고 어드민에서 재처리하고 있었다는 점입니다. 같은 프로젝트 안에서 한쪽은 격리하고 한쪽은 버리고 있었습니다. 지금은 예외를 던지고 DefaultErrorHandler가 2회 재시도 후 payment.confirmed.DLT로 보냅니다. 원 토픽·파티션·오프셋·예외가 헤더로 같이 실립니다. outbox 재발행은 at-least-once라 중복 수신이 가능하다는 것, 그래서 실소비자는 orderNo/paymentId 기반 멱등 처리가 필수라는 것도 코드에 명시했습니다.
2. 붙여보니: 와이어에 base64가 흐르고 있었다
두 앱을 나란히 띄우고 결제를 일으켰습니다. 소비자가 이벤트를 받긴 받았습니다.
[정산알림] 결제 완료 수신 orderNo= amount=0 ... ← 빈 값?!파싱이 전부 빈 값입니다. 토픽을 직접 덤프했습니다.
"eyJvcmRlck5vIjoiMDFLV1c2OFpFMldNOVNFQVY5SEFONjdHSlEiLCJwYXltZW50SWQiOjEsImFtb3VudCI6MTAwMDAsIn..."와이어에 흐르는 건 base64 문자열이었습니다. 디코드하니 그 안에 정확한 JSON이 들어 있었습니다. 이중 인코딩입니다.
원인은 직렬화기 궁합이었습니다. Modulith 외부화는 이벤트를 이미 JSON byte[]로 직렬화해서 KafkaTemplate에 넘깁니다. 그런데 producer의 value-serializer가
JsonSerializer였습니다. Jackson은 byte[]를 받으면 base64 문자열로 JSON 인코딩합니다. 이미 JSON인 걸 한 번 더 감싼 것입니다.수정은 한 줄.
value-serializer: ByteArraySerializer. 이미 직렬화된 byte[]를 그대로 와이어에 싣습니다.
고치고 다시 돌렸습니다.
[정산알림] 결제 완료 수신 orderNo=01KWW6HR... amount=10000 partition=0 offset=1[정산알림] 결제 취소 수신 orderNo=01KWW6HR... cancelAmount=10000 fullyCanceled=true별도 프로세스가 깨끗한 JSON을 받아 모든 필드를 제대로 파싱합니다. “프로세스 밖 소비자”가 약속에서 동작하는 사실이 됐습니다.
3. 왜 이 버그는 지금까지 숨어 있었나
곱씹어볼 지점입니다. 이 이중 인코딩은 외부화를 만든 시점부터 있었습니다. 그런데 아무 테스트도 못 잡았습니다. 발행 측 설정 테스트는 “토픽으로 나간다”까지만 봤고, 와이어 포맷을 읽는 쪽이 없으니 포맷이 틀렸는지 알 길이 없었습니다.
이 시리즈에서 반복된 패턴이 또 나왔습니다.
결제 확정 버그는 실기동 E2E가, outbox 데드락은 스파이크 실측이, 이번 이중 인코딩은 실제 소비자를 붙이는 것이 드러냈습니다. 소비하는 쪽이 생기기 전까지 만드는 쪽은 자기가 옳다고 믿습니다. 계약의 검증은 언제나 반대편 끝에서 옵니다.
Kafka를 붙일 계획이 없더라도, 이벤트를 외부화한다면 더미라도 좋으니 진짜로 읽는 소비자를 하나 두는 게 좋습니다. 그게 와이어 계약의 테스트입니다.
아무도 부르지 않는 배치들: javadoc은 스케줄러가 있다고 믿었다
죽은 이벤트 다음은 죽은 배치였습니다. 같은 감사에서, 복구·만료·dunning 배치가 로직만 완성된 채 부르는 스케줄러 없이 잠들어 있다는 게 드러났습니다. 심지어 javadoc은 있지도 않은 스케줄러가 돈다고 적고 있었습니다.
0. javadoc이 거짓말을 하고 있었다
전수 감사에서 나온 또 하나의 항목입니다.
[5] 핵심 배치 스케줄러 부재: @Scheduled는 2개뿐. 미확정 결제 복구·구독 dunning·주문/VA 만료 배치가 로직만 있고 부르는 스케줄러가 없음.
특히 미확정 복구가 흥미로웠습니다. PaymentRecoveryService의 javadoc엔 이렇게 적혀 있었습니다.
/** * ... 스케줄러가 {@code payment.recovery.enabled=true}일 때 이 로직을 주기 실행한다(운영). ... */그런데 그 스케줄러가 없었습니다. grep해보니 @Scheduled도, payment.recovery.enabled 프로퍼티도 실재하지 않았습니다. 문서는 “스케줄러가 돈다”고 믿고 있는데 실제로는 어드민이 수동으로 트리거하는 것뿐이었습니다. 의도와 구현이 갈라진, 죽은 이벤트와 같은 종류의 갭입니다.
1. 로직은 다 있었다, 부르는 사람만 없었다
확인해보니 배치 로직은 전부 완성돼 있었습니다.
| 배치 | 로직 | 스케줄러 |
|---|---|---|
| 미확정(UNKNOWN) 결제 복구 | recoverUnknownPayments() 있음 | 없음 (javadoc은 있다고 주장) |
| 가상계좌 만료 | expireOverdue(now) 있음 | 없음 |
| 구독 정기결제(dunning) | runBillingCycle(today) 있음 | 없음 |
| 주문 만료 | Order.markExpired() 있음 | 없음 (스캔 서비스도 없음) |
가장 심각한 건 주문 만료였습니다. Order.markExpired()(PENDING_PAYMENT → EXPIRED)는 있는데, “만료 배치가 이 값으로 스캔한다”는 주석의 그 배치가 존재하지 않았습니다.
결제 안 하고 방치된 PENDING_PAYMENT 주문이 영원히 만료되지 않습니다. 30분 유효시간이 지나도 상태가 그대로라 재고 선점(있었다면)이나 통계가 계속 오염됩니다. 만료 로직은 있는데 아무도 실행하지 않으니, 사실상 없는 기능이었습니다.
2. 스케줄러를 다는 것도 그냥 붙이면 안 된다
배치 4개에 스케줄러를 달았습니다. 이 프로젝트엔 스케줄러를 켜고 끄는 원칙이 이미 있습니다. 보상 태스크·에스크로 자동 릴리스처럼 프로퍼티 게이트(기본 off)입니다.
@Configuration @EnableScheduling@ConditionalOnProperty(name = "app.order.expiry.enabled", havingValue = "true")class OrderExpirySchedulingConfig {} // 프로퍼티 off면 스케줄링 인프라 자체가 안 뜹니다기본을 off로 두는 이유는 테스트·부트에 부작용이 없게 하기 위해서입니다. 여기서 신경 쓴 게 하나 있습니다. 여러 @EnableScheduling이 공존해도 되나?
이미 order(보상)·escrow(자동 릴리스)에 각각
@EnableScheduling게이트가 있고, 여기에 복구·만료·dunning·outbox까지 더하면@EnableScheduling이 여러 개가 됩니다. 괜찮을까? 괜찮습니다.@EnableScheduling은ScheduledAnnotationBeanPostProcessor를 고정된 빈 이름으로 등록하는데, Spring이 같은 이름의 중복 등록을 dedup합니다. 게이트를 몇 개를 켜도 스케줄링 처리기는 한 번만 뜨고, 각 프로퍼티를 켠 스케줄러만 독립적으로 돕니다.
새로 만든 건 주문 만료 스캔 서비스 하나뿐입니다(나머지는 기존 로직에 스케줄러만 달았습니다).
public int expireOverdue(Instant now) { // PENDING_PAYMENT + 만료시각 경과 주문을 스캔 → markExpired + saveAndFlush // 한 건 실패가 배치를 안 멈추게 per-item try/catch}예의 saveAndFlush와 한 건 실패 격리는 이제 반사적으로 들어갑니다.
실기동으로 확인했습니다. 주문을 만들고 만료시각을 과거로 backdate한 뒤 스케줄러를 켜니,
주문 → PENDING_PAYMENT(만료시각 과거로 조작)→ 8초 뒤 → EXPIRED로그: "주문 만료 배치 완료 count=1"드디어 만료돼야 할 주문이 만료됩니다.
3. 무한히 자라던 테이블
마지막은 눈에 안 보이던 문제, outbox 테이블의 무한 성장입니다.
이 시스템은 Modulith의 Event Publication Registry를 outbox로 씁니다(event_publication 테이블). 이벤트를 발행하면 여기 기록되고, 리스너가 처리하면 완료 표시됩니다.
완료된 이벤트를 지우거나 아카이브하는 게 없었습니다. 게다가
republish-outstanding-events-on-restart: true라 재기동 때 미완료분을 재발행합니다. 그래서 이 테이블은 매 결제마다 커지기만 했습니다. 몇 달이면 수백만 행이 쌓여 조회·재발행이 느려지고, 결국 성능 문제가 됩니다.
Modulith가 이걸 위한 API를 줍니다. CompletedEventPublications.deletePublicationsOlderThan(Duration)으로 완료된 지 N일 지난 이벤트를 지우는 것. 주기 정리 스케줄러를 붙였습니다(기본 7일 보존).
참고로 Modulith 1.3엔
completion-mode로DELETE(완료 즉시 삭제)나ARCHIVE(별도 테이블로 이동) 모드도 있습니다. 감사 이력을 잠깐이라도 남기고 싶어서 “완료 유지 + 주기 purge”를 택했습니다. outbox의 목적은 유실 방지지 영구 보관이 아닙니다. 이 선택은 나중에 뒤집혔습니다. 완료 이벤트가 안 지워져 부하 실험 여섯 번 만에 15만 행이 됐고, 완료 처리 쿼리가 그 전체를 풀스캔했습니다. 지금은ARCHIVE입니다(측정이 틀렸던 편).
금고를 만들어놓고 아무것도 안 넣었다
감사가 짚은 다음 간극은 더 민감한 곳, 암호화였습니다. AES-256-GCM 금고까지 완비해놓고 어느 컬럼에도 적용하지 않아, 계좌번호와 빌링키가 DB에 평문으로 누워 있었습니다.
0. 금고는 있는데 비어 있었다
필드 암호화를 만들고, envelope로 키 로테이션까지 정성껏 확장했습니다. AES-256-GCM, DEK/KEK(데이터를 암호화한 키를 다시 마스터키로 감싸는 이중 키 구조), 블라인드 인덱스, JPA 컨버터까지 민감 데이터를 잠글 준비는 다 돼 있었습니다.
그런데 감사가 이렇게 짚었습니다.
[4] 필드 암호화 인프라 전부 미사용:
@Convert가 어느 엔티티 컬럼에도 적용 안 됨. 가상계좌 계좌번호·빌링키가 평문.
인프라만 완비하고 채우진 않은 셈입니다. 계좌번호도, 카드 토큰인 빌링키도 평문 그대로였습니다. FDS 엔진처럼, 대사 엔진처럼, 만들고 안 연결한 또 하나였습니다.
1. 그냥 붙이면 되는 게 아니었다
컬럼에 @Convert만 붙이면 될 줄 알았는데, 필드마다 접근 패턴이 달라서 그게 안 됐습니다.
계좌번호는 쉬웠습니다. 값으로 조회할 일이 없습니다(주문번호로 가상계좌를 찾지, 계좌번호로 찾지 않습니다). 그냥 암호화하면 끝.
@Convert(converter = EncryptedStringConverter.class)@Column(length = 255) // 암호문이 길어서 확장private String accountNumber;문제는 빌링키였습니다. 코드를 봤습니다.
Optional<BillingKey> findByBillingKey(String billingKey); // 값으로 조회@Column(nullable = false, unique = true, length = 200) // 유니크private String billingKey;빌링키는 유니크이고, 값으로 조회됩니다. 여기에 암호화를 붙이면 문제가 생깁니다.
envelope 암호화는 매번 새 DEK와 IV를 씁니다(그래서 안전합니다. 같은 값도 매번 다른 암호문이 나옵니다). 그런데 그게 바로 문제입니다. 같은 빌링키가 저장할 때마다 다른 암호문이 되니, (1)
WHERE billing_key = '암호화된값'조회가 절대 안 맞고, (2) 유니크 제약이 무의미해집니다(다른 암호문이라 절대 충돌하지 않습니다). 보안을 위한 암호화가 조회와 유니크를 깨뜨립니다.
암호화하면 못 찾고, 못 찾으면 결제를 못 합니다. 결제 시스템에서 민감 필드를 다룰 때 항상 나오는 벽입니다.
2. 블라인드 인덱스가 존재하는 이유
해법은 결정적 인덱스를 따로 두는 것입니다. 암호문이 못 하는 일(같은 입력=같은 값)을 대신해주는 컬럼입니다.
billing_key env:v1:... ← envelope 암호문 (비결정적, 안전)billing_key_index a3f9c2... ← HMAC-SHA256 (결정적, 유니크·조회용)블라인드 인덱스는 빌링키를 secret으로 HMAC 해싱한 값입니다. 같은 빌링키는 항상 같은 인덱스가 나옵니다. 덕분에 두 가지가 풀립니다.
- 유니크를 인덱스 컬럼으로 옮깁니다(같은 빌링키 → 같은 인덱스 → 충돌 감지).
- 조회를 인덱스로 합니다:
findByBillingKey(raw)→raw를 해싱 →findByBillingKeyIndex(hash).
public Optional<BillingKey> findByBillingKey(String raw) { return repository.findByBillingKeyIndex(indexer.index(raw)); // 원문을 해싱해 조회}빌링키 자체는 여전히 암호문으로 잠겨 있고(유출돼도 못 읽는다), 조회·유니크는 인덱스가 대신합니다. HMAC은 일방향이라 인덱스만 봐선 빌링키를 역산 못 하고, secret이 없으면 인덱스를 만들 수도 없습니다. 블라인드 인덱스 클래스를 애초에 만든 이유가 이것이었는데, 실제로 써보고서야 왜 필요한지 체감했습니다.
그래서 필드마다 전략이 다릅니다.
| 필드 | 조회? | 전략 |
|---|---|---|
| 계좌번호 | 안 함 | 단순 암호화 |
| 구독의 빌링키 참조 | 안 함 (userId로 조회) | 단순 암호화 |
| 빌링키(원본) | 함 (유니크·값 조회) | 암호화 + 블라인드 인덱스 |
3. 이미 있던 평문은 어쩌나
마지막 현실 문제는 마이그레이션 전에 저장된 평문 행들입니다. 그걸 복호화하려 하면 env: 형식이 아니라 예외가 납니다.
그래서 하위호환 한 줄을 넣었습니다.
public String decrypt(String ciphertext) { if (!ciphertext.startsWith("env:")) return ciphertext; // 레거시 평문 → 그대로 ... // env: 형식만 복호화}env:로 시작하지 않으면 마이그레이션 전 평문으로 보고 그대로 돌려줍니다. 새로 저장되는 값은 항상 암호화되고, 옛 평문은 읽기만 됩니다. 한 번에 다 재암호화하지 않고 점진적으로 넘어가는 방식입니다.
그런데 이 분기에 끝이 없었습니다. 남겨두면 “암호화를 우회하는 형식”이 영구히 허용되고, 손상되거나 바뀐 값도 조용히 평문으로 받아들입니다. 몇 개 남았는지도 안 셌습니다.
뒤에서 비밀번호 이관에는 “남은 레거시 해시 수” 게이지를 만들어놓고(7편), 여기엔 같은 걸 안 했습니다. 지금은 평문으로 읽힌 횟수를 세고, 이관 완료를 선언하면(fail-on-legacy-plaintext) 평문을 통과시키지 않고 실패시킵니다. 끝낼 수 없는 이관이라면 최소한 끝을 판정할 수 있어야 합니다.
재시작하면 블랙리스트가 사라진다: 보안 상태의 수명
저장된 데이터를 잠갔으니 다음은 살아 움직이는 보안 상태입니다. 차단·검증에 쓰이는 상태가 얼마나 오래, 어디까지 사는지를 감사가 짚었고, 덤으로 문서의 거짓말까지 나왔습니다.
0. 감사가 짚은 셋
전수 감사의 보안 관련 항목 셋을 이번에 마감했습니다. 성격은 다른데 관통하는 주제가 하나 있습니다. 보안 상태가 얼마나 오래, 어디까지 사는가.
1. 조용한 기본값: 웹훅 시크릿
웹훅 서명 검증은 HMAC 시크릿으로 “이 웹훅이 진짜 PG가 보낸 것인지”를 확인합니다.
짚어둘 게 있습니다. 실제 토스페이먼츠 일반 결제 웹훅에는 서명 헤더가 없습니다. 공식 안내는 paymentKey로 조회 API를 다시 호출해 검증하는 방식입니다. 이 프로젝트의 HMAC은 자체 모델링한 PG 계약이고, 실제 토스 연동이라면 조회 재검증이 맞습니다. 그런데 그 시크릿이 이렇게 주입되고 있었습니다.
@Value("${payment.webhook.secret:test-webhook-secret}") // ← 기본값test-webhook-secret이라는 약한 기본값을 조용히 쓰고 있었습니다. 운영에서 환경변수를 깜빡 안 넣으면? 앱은 아무 경고 없이 뜨고, 공개된 문자열로 서명을 검증합니다. 공격자가 그 문자열로 서명을 위조해 가짜 웹훅을 보낼 수 있습니다.
이상한 건 JWT 키는 이미 fail-fast였다는 점입니다(미설정이면 기동 실패). 웹훅만 빠져 있었습니다. 일관성을 맞췄습니다.
public WebhookSignatureVerifier(@Value("${payment.webhook.secret}") String secret) { // 미설정·약한 키면 기동 거부 — JWT 키와 같은 결}실기동으로 확인했습니다. 빈 시크릿으로 띄우니 앱이 뜨지 않습니다.
Caused by: IllegalStateException: payment.webhook.secret 미설정 — 웹훅 서명 시크릿을 환경변수/시크릿으로 주입해야 합니다.조용히 약한 값으로 도는 것보다 시끄럽게 죽는 게 보안에선 낫습니다. 잘못된 설정으로 프로덕션이 뜨는 걸 배포 시점에 막습니다.
2. 재시작하면 사라지는 블랙리스트
FDS 사후 심사에서, 사기로 판정된 카드를 블랙리스트에 넣어 이후 결제를 차단하게 만들었습니다. 그런데 그 블랙리스트가 이랬습니다.
private final Set<String> cardBlacklist = ConcurrentHashMap.newKeySet(); // in-memory메모리에만 있었습니다.
앱을 재시작하면(배포, 장애 복구) 블랙리스트가 통째로 사라집니다. 어제 사기로 차단한 카드가 오늘 배포 후엔 다시 통과합니다. 보안 상태가 프로세스 수명만큼만 사는 것입니다. 결제 차단 같은 상태는 프로세스보다 오래 살아야 합니다.
여기서 판단이 하나 있었습니다. 블랙리스트를 어디에 저장하나. 새 테이블을 만들 수도 있지만, 진실 원천은 이미 DB에 있었습니다. 사기로 거부한 심사 리뷰(REJECTED)가 fraud_reviews에 남아 있습니다.
그래서 별도 저장소 대신 기동 시 REJECTED 리뷰에서 블랙리스트를 재구축하기로 했습니다. 인메모리 Set은 “캐시”로 두고, 진실 원천은 DB의 심사 이력으로 삼습니다.
@EventListener(ApplicationReadyEvent.class)void reload() { fraudReviewRepository.findByStatus(REJECTED) .forEach(r -> fraudService.blacklistCard(r.getCardKey())); // 기동 시 재적재}이제 재시작해도 어제 차단한 카드는 그대로 막힙니다. “상태를 어디에 두느냐”보다 “진실 원천이 무엇이냐”를 먼저 물으니, 새 테이블 없이 풀렸습니다.
그런데 재시작만 해결했다
외부 리뷰가 짚었습니다. 서버가 여러 대면 여전히 안 막힙니다.
A 에서 새 카드 차단 → A 의 Set 에만 추가B·C 는 모른다 → 계속 통과기동 시 재적재는 재시작을 견디게 할 뿐이고, 런타임에 추가한 차단은 그 인스턴스에만 남습니다.
바로 아래에서 velocity 카운터가 같은 이유로 무력해진다고 적어놓고, 차단 목록에는 같은 질문을 안 했습니다.
Redis 공유 집합으로 옮겼습니다. 진실 원천은 여전히 DB의 심사 이력이고, Redis는 인스턴스들이 같은 답을
보게 하는 자리입니다. 로컬 Set은 Redis가 죽었을 때만 쓰는 마지막 사본으로 내렸습니다.
여기서는 fail-open 하지 않습니다. velocity는 “이상 급증 신호”라 못 세면 통과시키는 게 맞지만,
차단은 이미 부정으로 확정된 카드입니다. 통과시키는 쪽이 더 비쌉니다.
3. 한 프로세스만 세는 velocity
FDS velocity 룰은 “1분에 카드로 몇 번 시도했나”를 세서 이상 패턴을 잡습니다. 그런데 그 카운터가 인메모리였습니다.
서버가 여러 대면(실서비스는 당연히 그렇다) 각 인스턴스가 자기가 받은 요청만 셉니다. 공격자가 1분에 100번을 쳐도 10대에 분산되면 각각 10번밖에 못 봐서 velocity 룰이 안 걸립니다. 인메모리 카운터는 다중 인스턴스에서 사실상 무력합니다.
rate limiter나 토큰 저장소와 같은 문제였고, 해법도 같습니다. Redis 공유 카운터.
// velocity:{cardKey}:{분} 을 모든 인스턴스가 공유해 INCRLong n = redis.opsForValue().increment("velocity:" + key + ":" + epochMinute);기존 인메모리 구현은 폴백·테스트용으로 남기고 Redis 구현을 기본으로 뒀습니다(@Primary). Redis가 죽으면 fail-open(velocity 통과 + 경고)입니다. 사기 탐지는 보조 방어층이라, 그 층의 Redis 장애가 결제 전면 중단으로 번지면 안 됩니다.
실기동으로 결제를 일으키니 Redis에 velocity 키가 실제로 찍혔습니다.
velocity:card:pk-98E5E60F...:29723098이제 여러 대가 하나의 카운터를 공유합니다.
여기에도 빈틈이 하나 있었습니다. INCR과 EXPIRE를 나눠 불렀습니다. 그 사이에 프로세스가 죽으면
TTL 없는 키가 영구히 남습니다. 카드마다 분마다 키가 생기므로 그런 키가 쌓이면 Redis가 계속 커집니다.
local n = redis.call('INCR', KEYS[1])if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1])endreturn n한 스크립트로 합쳤습니다. “보통은 둘 다 성공한다”에 기대는 코드는 언젠가 그 틈으로 샙니다.
전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 웹훅 fail-fast·블랙리스트 재적재·Redis velocity를 실기동으로 검증했습니다.
만들어둔 이스케이프 함수를 안 통하는 자리에 썼다
대사 근거를 모아 보여주는 어드민 화면을 만들다 보안 리뷰에 두 번 걸렸습니다(10편). 둘 다 유효했습니다.
첫 번째는 타임라인 행을 innerHTML 문자열 조립으로 그린 것. 확인해보니 실제 악용 경로가 있었습니다. 분쟁 사유는 차지백 웹훅에서 오고, 강제취소 사유는 사람이 타이핑합니다. 저장형 XSS입니다.
고치려고 보니 이 콘솔에 이스케이프 함수가 아예 없었습니다. innerHTML이 14곳에서 쓰이는데 기존 렌더러도 서버 값을 그대로 넣고 있었습니다. 내가 인스턴스를 하나 추가한 거지 문제를 만든 건 아니었습니다. 그래서 기존 것까지 같이 고쳤습니다.
두 번째는 그 esc()를 잘못된 자리에 쓴 것.
onclick="pickCause('" + esc(cause) + "')"안전해 보이지만 아닙니다. HTML 파서가 JS 파싱보다 먼저 '를 '로 되돌립니다. esc()는 HTML 텍스트용이지 속성 안의 JS용이 아닙니다.
지금은 값이 enum이라 안 뚫립니다. 그래도 고쳤습니다. *“지금은 enum이니까”*에 기대는 안전은 다음 사람이 모르기 때문입니다. createElement + addEventListener로 바꿔 값을 문자열이 아니라 변수로 넘기면 그 층 자체가 사라집니다.
남는 생각
여섯 건의 공통점이 있습니다. 전부 테스트는 통과하고 있었습니다.
단위 테스트는 내가 상상한 경계를 검사합니다. 상상하지 못한 경계는 실제로 띄워야만 나왔습니다. 프로세스가 재시작되는 것, 이벤트가 직렬화를 두 번 거치는 것, @Scheduled 가 있어도 @EnableScheduling 이 없는 것이 그랬습니다.
그래서 이후로는 기능을 만들 때마다 한 번은 진짜로 돌려보는 것을 절차에 넣었습니다.
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.