Skip to Content
← 블로그
개편기

까먹지 말자"로는 세 번째도 까먹습니다

캐시 갱신을 각 결제 흐름의 선택지로 남겨두었더니, 결제 수단을 붙일 때마다 같은 장애가 났습니다

QR 결제 중계 플랫폼에는 가맹점에 직접 제공하는 자체 모바일 POS(이하 mPOS)가 있습니다. 일반 POS와 결제 흐름은 같은데 한 가지가 다릅니다.

  • 일반 POS: 거래 상태 조회 → 서버 → RDB
  • mPOS: 거래 상태 조회 → 서버 → Redis

빠른 응답을 위해 mPOS만 캐시를 봅니다. 그래서 mPOS가 거래 상태를 제대로 보려면 결제가 끝날 때마다 누군가 그 캐시를 갱신해야 합니다.

그 "누군가"가 각 결제 흐름의 개발자였습니다.

간편결제사 B를 붙였을 때 한 번 잊었습니다. 운영에서 mPOS로 테스트하다 발견했습니다. 은행 QR 채널을 붙였을 때 또 잊었습니다. 역시 운영에서 발견했습니다.

두 번째 장애를 고칠 때 저는 공통 장치를 만들어 뒀습니다. 상태 갱신 이벤트를 발행하는 게이트웨이와, 그걸 받아 Redis를 갱신하는 리스너였습니다.

세 번째 장애를 조사하다 알게 된 사실은 이것이었습니다. 그 공통 장치를 쓰고 있는 흐름은 여전히 하나뿐이었습니다.

공통 함수를 만드는 것은 해결이 아니었습니다

코드를 훑으니 Redis를 갱신하는 방식이 세 가지 공존하고 있었습니다.

결제 흐름갱신 방식
중계 게이트웨이 콜백콜백 서비스에서 캐시 저장소를 직접 호출
간편결제사 B공통 게이트웨이 그리고 전용 이벤트·리스너 (같은 일을 하는 리스너가 둘)
은행 QR 채널없음

세 번째 줄이 이번 장애였습니다. 하지만 문제는 세 번째 줄이 아니라 표 자체였습니다.

공통 장치는 존재했습니다. 문서에도 적혀 있었습니다. 그런데 그것을 호출하는 일이 선택지로 남아 있었습니다. 새 결제 수단을 붙이는 사람이 그 존재를 알아야 하고, 알아도 기억해야 하고, 기억해도 빠뜨리지 않아야 했습니다.

그래서 이번에 네 번째 이벤트를 추가하는 선택지는 처음부터 지웠습니다. 그건 다음 결제사에서 네 번째 장애를 예약하는 일이니까요.

손에 최신 상태를 들고 있으면서 낡은 캐시를 믿었습니다

진단을 하다가 더 이상한 걸 발견했습니다. 문제가 실제로 드러나는 지점은 쓰기가 아니라 읽기였습니다.

mPOS 폴링 코드는 이렇게 생겼습니다.

Payment payment = repository.findByTransactionNo(txNo);   // ← DB를 읽는다
Optional<TransactionState> cached = redis.find(txNo);

if (cached.isPresent()) {
    if (isWaiting(cached.get())) {
        return "아직 대기 중";                              // ← DB를 버린다
    }
    ...
}

매 폴링마다 DB에서 원장을 읽고 있었습니다. 그러고는 캐시가 "진행중"이라고 하면, 방금 읽은 DB가 "완료"라고 해도 대기 응답을 돌려주고 끝냈습니다.

캐시 키가 아예 없는 경우에는 DB로 폴백하는 코드가 있었습니다. 없는 것은 대비했는데, 있지만 틀린 것은 대비하지 않았습니다.

여기에 연산자 우선순위 버그도 하나 숨어 있었습니다.

return COMPLETED.equals(state) && (APPROVED_1.equals(stage)) || APPROVED_2.equals(stage);

(A && B) || C로 파싱됩니다. 거래 상태와 무관하게 stage가 APPROVED_2이기만 하면 승인으로 판정했습니다. 나중에 리뷰가 하나를 더 찾아냈는데, 그 대기 분기는 만료 판정까지 건너뛰고 있었습니다. 한 줄의 early return이 세 가지를 동시에 잘못하고 있었던 셈입니다.

1️⃣ 전이와 발행을 분리할 수 없게 만들었습니다

방향은 정해졌습니다. "공통 함수를 부르자"가 아니라 "부르지 않는 것이 불가능하게" 입니다.

Before — 전이·저장·발행이 각각 따로

After — 셋을 한 몸으로

퍼널은 특별할 게 없는 클래스입니다. 상태를 바꾸고, 저장하고, 이벤트를 발행합니다. 요점은 호출자가 그중 하나만 골라 할 수 없다는 것입니다.

설계하면서 가장 신경 쓴 계약은 셋이었고, 공교롭게 셋 다 어겼을 때 아무 소리도 나지 않는 종류였습니다.

하나. 전이가 거부되면 아무것도 발행하지 않습니다. 조건부 UPDATE가 0행을 반환하면 이미 끝난 거래라는 뜻입니다. 거기서 이벤트를 발행하면 뒤늦게 도착한 결과가 올바른 종결 캐시를 덮어씁니다.

둘. 벌크 UPDATE 뒤에는 엔티티에서 상태를 읽지 않습니다. JPA 벌크 연산은 영속성 컨텍스트를 갱신하지 않으므로 그 뒤에 읽은 엔티티는 낡았을 수 있습니다. 그래서 이벤트에 실을 상태는 전이 목표값에서 가져오고, 원장에서는 벌크가 건드리지 않는 필드만 읽습니다.

셋. 활성 트랜잭션이 없으면 이벤트는 증발합니다. @TransactionalEventListener(AFTER_COMMIT)은 트랜잭션 밖에서 발행된 이벤트를 예외도 로그도 없이 버립니다.

셋째가 특히 위험했습니다. 검색하면 fallbackExecution = true라는 옵션이 바로 나오는데, 그건 함정입니다. 의미가 "커밋 전에 실행"으로 바뀌어서, 롤백된 값이 캐시에 남는 정반대 사고가 됩니다. 그래서 트랜잭션 활성 여부를 확인해 동기 갱신으로 폴백하는 쪽을 골랐습니다.

이벤트가 엔티티를 들고 다니고 있었습니다

기존 이벤트는 원장 엔티티 참조를 그대로 실어 보내고 있었습니다. 리스너는 @Async라 나중에 깨어나 상태를 읽습니다.

문제는 그 사이 같은 인스턴스가 다시 전이된다는 것입니다. 승인 직후 망취소가 걸리는 경로가 실제로 있습니다. 그러면 리스너는 발행된 전이가 아니라 그 이후 전이의 값을 캐시에 씁니다.

처음에는 지연 로딩 예외를 걱정했는데, 확인해 보니 그건 문제가 아니었고(해당 연관은 즉시 로딩이었습니다) 걱정하지 않았던 경합이 진짜 문제였습니다. 이벤트를 불변 스냅샷으로 바꿨습니다.

2️⃣ 까먹으면 장애가 아니라 빌드가 깨지게

퍼널을 만들어도 호출을 안 하면 그만입니다. 그래서 우회를 컴파일 타임에 막기로 했습니다.

ArchUnit 규칙 두 개를 세웠습니다. 원장 엔티티의 상태 전이 메서드를 직접 부르는 것, 그리고 상태를 바꾸는 벌크 UPDATE를 직접 부르는 것. 후자를 빼면 안 됩니다 — 이 코드베이스에는 전이 방식이 두 갈래라 엔티티 쪽만 막으면 벌크로 우회됩니다.

규칙을 어디에 두느냐가 이 작업의 전부였습니다.

처음엔 공통 모듈에 두려 했습니다. 그런데 의존 방향이 api 모듈 → 공통 모듈이라, 공통 모듈의 테스트 클래스패스에는 api 모듈 클래스가 없습니다. 규칙이 정작 장애가 났던 모듈을 한 줄도 스캔하지 못한 채 초록불이 됩니다.

규칙 본체는 테스트 픽스처로 빼고, 모듈마다 자기 패키지를 스캔하는 얇은 실행 테스트를 두는 구조로 바꿨습니다.

그리고 제 규칙은 아무것도 막지 못하고 있었습니다

계획서에 "일부러 위반을 심어보고 실패하는지 확인한다" 는 단계를 넣어뒀습니다. 규칙을 추가하고 초록불을 보고 안심하는 게 가장 흔한 자기기만이라고 생각해서였습니다.

그 단계가 잡은 것은 제 코드였습니다.

// 제가 계획서에 적어둔 것
events.add(SimpleConditionEvent.violated(call, call.getDescription()));

ArchUnit의 noClasses().should(condition)은 내부적으로 조건을 NeverCondition으로 감싸고 넘어온 이벤트를 전부 반전시킵니다. "위반을 찾았다"를 violated(...)로 넣으면 반전을 거쳐 "만족"이 됩니다.

실제 위반이 있어도 규칙이 조용히 통과합니다.

작업자가 라이브러리 jar를 디컴파일해 이걸 확인하고 satisfied(...)로 고쳤습니다. 저도 직접 재현했습니다 — 편입이 끝난 서비스에 전이 호출 한 줄을 심고 러너를 돌려 실패하는 것을 보고, 되돌린 뒤 통과하는 것까지 확인했습니다.

이 개편 전체가 막으려던 실패 형태가 "조용히 아무것도 하지 않는 것" 이었습니다. 그리고 그걸 막으려고 쓴 가드가 정확히 그 방식으로 고장나 있었습니다.

그 검증 단계가 없었다면 규칙을 추가하고, 초록불을 보고, 문서에 "이제 우회는 빌드 실패로 막힙니다"라고 적고 끝냈을 겁니다. 아무도 몰랐을 거고요.

가드는 "지금 통과한다"가 아니라 "위반하면 실패한다"로 검증해야 합니다.

부수 소득도 있었습니다. 화이트리스트를 추측으로 채우지 않고 빈 목록으로 한 번 돌려 실제 위반 리포트를 받았는데, 잔여 전이 지점이 29곳이었습니다. 제가 추측으로 적어둔 21곳보다 많았습니다. 그리고 그 목록이 그대로 다음 단계의 작업 목록이 됐습니다.

3️⃣ 캐시가 결제를 인질로 잡고 있었습니다

캐시 자체도 들여다봤습니다. 두 가지가 나왔습니다.

하나는 TTL 누수. 저장이 이렇게 돼 있었습니다.

opsForHash().putAll(key, hashMap);
expire(key, Duration.ofSeconds(TTL));   // ← 별개 명령

두 명령 사이에 끊기면 TTL이 없는 키가 남습니다. volatile-* eviction 정책에서는 회수 대상도 아닙니다. 목표 규모에서는 무한히 쌓입니다. MULTI/EXEC로 묶었습니다.

다른 하나가 더 큰 문제였습니다. 저장이 실패하면 예외를 던지고, 파사드가 그걸 잡지 않고, 호출부는 @Transactional입니다. 이어 붙이면:

Redis가 죽으면 QR 발급 자체가 롤백됩니다. mPOS뿐 아니라 전 가맹점이.

읽기는 fail-open으로 설계해 놓고(캐시 미스면 DB 폴백) 쓰기는 fail-closed였습니다. 세 파일에 나뉘어 있어서 한 파일만 봐서는 보이지 않습니다. 저장소만 보면 "예외를 던지는 리포지토리", 파사드만 보면 "그냥 위임", 호출부만 보면 "트랜잭션 안의 캐시 저장"입니다. 셋을 이어야 보입니다.

캐시가 장애 시 결제를 막으면 그건 캐시가 아니라 의존성입니다. fail-open으로 바꿨습니다.

그리고 저는 그 원칙을 세 곳 중 한 곳에만 적용했습니다

전체 리뷰가 잡은 것입니다.

Redis에 쓰는 지점은 셋이었습니다. 저장, 폴링의 캐시 복구, 퍼널의 동기 폴백. 저는 저장 한 곳만 고쳤습니다.

폴링의 캐시 복구가 부르는 갱신 메서드는 여전히 예외를 던졌고, 아무도 잡지 않았습니다. Redis 쓰기만 막히는 상황에서 어떻게 되는지 따라가 보면:

  1. 리스너 쓰기도 전부 실패 → 완료된 거래의 캐시가 전부 낡음
  2. 모든 mPOS 완료 폴링이 복구를 시도 → 예외 → 트랜잭션 롤백
  3. 단말 수신 기록이 날아가고, 폴링 카운트도 증가하지 않음
  4. mPOS는 승인 대신 서버 에러를 받고, 다음 폴링도 똑같이 반복

이 개편 이전에는 폴링이 Redis에 쓰지 않았으므로 쓰기 장애가 mPOS에 아무 영향이 없었습니다. 제가 만든 회귀였고, 원래 장애(무한 대기)보다 나빴습니다. 무응답을 고치려다 서버 에러를 만든 셈입니다.

태스크마다 붙인 리뷰 아홉 번이 이걸 못 잡았습니다. 각 리뷰는 자기 태스크의 diff만 보니까요. 원칙의 일관성은 전체를 봐야 보입니다.

4️⃣ 단조성은 "변하지 않음"이 아니었습니다

캐시 쓰기에 가드를 하나 넣었습니다. 비동기 리스너는 순서 보장이 없으므로, 늦게 도착한 "진행중" 이벤트가 승인 결과를 되감지 못하게 막는 것입니다.

진리표를 그리다 함정을 만났습니다.

캐시 현재새 상태허용?
진행중종결✅ 정상 승인
종결진행중가드의 본체
종결종결여기
키 없음❌ (키 생성은 발급의 책임)

세 번째 줄입니다. "단조 증가"라고 하면 종결에 도달한 뒤엔 아무것도 못 바꿀 것 같지만, 승인된 거래가 망취소되면 종결에서 종결로 바뀌어야 합니다. 이걸 막으면 취소된 거래가 mPOS 화면에 영원히 승인으로 남습니다.

단조성은 "변하지 않음"이 아니라 "뒤로 가지 않음" 입니다. 종결에서 종결은 앞으로 가는 것이고, 종결에서 진행중만 뒤로 가는 것입니다.

관련해서 제 판단이 한 번 뒤집혔습니다. 폴링의 자가치유는 캐시가 비종결일 때만 복구합니다. 그래서 캐시가 종결인데 값이 틀린 경우(순서 역전으로 실패가 승인 뒤에 도착한 경우)는 복구되지 않습니다. 저는 "술어를 넓히면 2줄이면 되는 개선"이라고 판단했는데, 최종 리뷰가 하지 말라고 했고 근거가 저보다 정확했습니다.

현재 술어는 단조적입니다. 복구가 캐시를 오직 진행중 → 종결 방향으로만 옮기므로 동시 쓰기와 경쟁할 수 없습니다. 넓히면 폴링 스레드가 종결↔종결 순서 경쟁의 참가자가 되는데, 폴링 스레드는 스냅샷에서 씁니다.

T1: 폴링이 DB=승인 읽음
T2: 망취소가 DB=실패 커밋
T3: 망취소 리스너가 캐시=실패 기록
T4: 폴링이 T1의 낡은 값으로 캐시를 승인으로 되돌림   ← 새로운 역전

지금 코드로는 만들 수 없는 역전을 새로 만드는 거래였습니다. 읽기 측의 드문 낡음을 쓰기 측의 새 경합과 맞바꾸는 셈이죠. 진짜 해법은 술어를 넓히는 게 아니라 원장에 단조 증가 버전 컬럼을 두고 원자적으로 비교하는 것이고, 그건 다음 단계로 미뤘습니다. 대신 백로그에 이 반론을 적어뒀습니다 — 버전 컬럼 없이 누가 "개선"하지 않도록.

마치며 — 남은 것과 배운 것

이번 범위는 두 채널(은행 QR, 앱 결제)의 완료 전이 다섯 곳입니다. 나머지 29곳은 화이트리스트에 올라가 있고, 그 목록이 비는 시점이 다음 단계의 착수 조건입니다.

의도적으로 하지 않은 것도 있습니다. 캐시 히트 시 DB 조회를 생략하는 fast path는 매력적이지만, 이번 자가치유와 동시에 성립하지 않습니다. DB를 읽지 않으면 낡은 캐시를 감지할 방법이 없어서, 한 번의 쓰기 누락이 자기교정되는 대신 조용히 틀린 답이 됩니다. 쓰기가 100% 보장된 뒤에야 켤 수 있고, 그 기계적 증거가 바로 "화이트리스트가 비었는가"입니다.

작업하면서 남은 것들.

공통 장치를 만드는 것과 그것을 쓰게 만드는 것은 다른 문제입니다. 두 번째 장애 때 저는 전자만 했습니다. 문서에도 적었고 코드도 있었는데, 호출이 선택지로 남아 있는 한 아무 의미가 없었습니다.

가드는 통과가 아니라 실패로 검증해야 합니다. 제가 쓴 규칙이 실제 위반을 조용히 통과시키고 있었고, 그걸 잡은 건 "일부러 어겨보는" 단계 하나였습니다. 그 단계를 넣은 이유가 그 단계에 의해 증명된 셈입니다.

원칙은 세운 곳이 아니라 안 세운 곳에서 깨집니다. fail-open을 세 곳 중 한 곳에만 적용해 놓고 저는 그걸 "고쳤다"고 적었습니다. 부분 리뷰는 이런 걸 구조적으로 볼 수 없습니다.

그리고 하나 더. 이번에 고친 것 중 버그를 지키고 있던 테스트가 있었습니다.

assertThat(strategy(FAILED).stage(PAY)).isEqualTo(APPROVED_STAGE);

실패인데 기대값이 승인 스테이지입니다. 그리고 초록이었습니다 — 구현과 일치했으니까요. 이 줄이 있는 한 누가 매핑을 고치면 테스트가 깨지고, "테스트가 깨졌으니 내가 틀렸나" 하고 되돌리게 됩니다. 기대를 명세가 아니라 구현에서 베껴 오면 그 테스트는 영원히 초록이고 영원히 무의미합니다.