개발 slecs

쿠폰 복호화

목차

복호화 로직에서 순차 fallback이 필요해지는 순간은 대체로 암호화 키가 교체될 때다. 기존 키로 만들어진 쿠폰이 DB에 쌓여 있는 상태에서 새 키가 도입되면, 디코더는 두 버전을 모두 소화할 수 있어야 한다. 이 부분을 놓치면 특정 시점 이전에 발급된 쿠폰들이 갑자기 복호화 실패를 내뱉는다. 사용자한테는 "쿠폰이 유효하지 않습니다" 같은 메시지로 보이고, 왜 그런지 추적하려면 로그부터 뒤져야 한다.

zlgoon 쪽 쿠폰 복호화 버그가 그 케이스였다. 건드린 파일은 내부 클래스 2개. 하나는 디코더 fallback 순서 처리, 다른 하나는 콜백 응답에 couponNo를 담을 때 URL-safe base64를 쓰지 않아 발생하는 문제였다.

URL-safe encoding을 빠뜨리면 생기는 일

base64 스펙에는 두 맛이 있다. 표준 base64는 +, /, =를 사용하고, URL-safe 변형은 +-, /_로 대체하고 패딩을 생략하거나 퍼센트 인코딩한다. couponNo처럼 외부 콜백 URL 파라미터로 실려 가는 값이 표준 base64 문자열이면, +가 공백으로, /가 경로 구분자로 깨질 수 있다. 파트너 시스템이 URL 파싱을 엄격하게 하면 값 자체를 잘라버리거나 400을 내뱉기도 한다.

코드 수준 수정은 한 줄이지만, 이게 오래 버텨온 이유는 단순하다. couponNo에 +/가 포함되는 케이스가 확률적으로 낮아서, 대부분의 흐름에서는 아무 일도 안 생긴다. 단위 테스트에 해당 문자를 명시적으로 넣어두지 않으면 잡기 어렵다.

// 콜백 파라미터에 쓰는 잘못된 방식
val encoded = Base64.getEncoder().encodeToString(couponNoBytes)

// URL 안전한 방식
val encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(couponNoBytes)

복호화 쪽도 대칭적으로 맞춰줘야 한다. 인코딩만 URL-safe로 바꾸고 디코딩을 표준 디코더로 그대로 두면 역방향에서 터진다.

순차 fallback 디코더 설계

디코더 fallback은 "새 키로 먼저 시도하고, 실패하면 이전 키로 재시도" 하는 패턴이다. 쿠폰 값에 키 버전을 박아두지 않은 구조라면 직접 순서대로 돌리는 수밖에 없다.

fun decrypt(encoded: String): String {
    val decoders = listOf(currentKeyDecoder, legacyKeyDecoder)
    for (decoder in decoders) {
        runCatching { decoder.decode(encoded) }
            .onSuccess { return it }
            .onFailure { /* 다음 decoder로 진행 */ }
    }
    throw CouponDecryptionException("모든 디코더 실패: $encoded")
}

예외를 그냥 삼키는 게 찝찝하면, 실패한 시도마다 warn 로그를 남겨두면 나중에 추적이 편하다. 어떤 키로 성공했는지도 알 수 있으면 키 교체 완료 시점을 판단하는 데 도움이 된다.

이 접근의 트레이드오프는 명확하다:

항목 순차 fallback 키 버전 태깅
구현 복잡도 낮음 중간
런타임 비용 실패 시 N회 시도 1회
기존 데이터 호환 즉시 마이그레이션 필요
키 교체 추적 로그 기반 명시적

쿠폰 복호화 빈도가 낮고 키가 자주 바뀌지 않는 상황이라면 순차 fallback으로 충분하다. 복호화가 hot path거나 디코더가 3개 이상으로 늘어날 것 같으면, 쿠폰 값 앞에 버전 prefix를 붙여두는 구조를 처음부터 가져가는 게 낫다.

기존 코드는 첫 번째 디코더가 예외를 던지면 그대로 상위로 올라가는 구조였다. 두 번째 키로 재시도할 경로가 없었다. 에러 자체는 발생하되, 특정 발급 시점의 쿠폰에서만 재현되다 보니 레포트가 뜸하게 들어왔고, 특정 패턴의 쿠폰에서만 터지는 것처럼 오해하기 쉬운 상태였다.

결제·쿠폰 도메인에서 이런 버그 다루는 방식

금융·결제 관련 코드에서 "아마 맞겠지"는 없다. 쿠폰 복호화 실패는 대부분 사용자한테 조용히 에러로 드러나거나, 쿠폰이 무효 처리되는 형태로 나타난다. 사용자는 쿠폰이 왜 안 되냐고 문의하고 CS가 들어오고, 그제서야 로그를 뒤지게 된다. 한 번 신뢰가 깨지면 "버그였는데 고쳤다"로 회복이 잘 안 된다.

수정 후 직접 버그 케이스를 재현해서 정상 동작을 확인했다. couponNo에 +/가 포함된 케이스를 의도적으로 만들어 콜백 파라미터가 제대로 전달되는지 봤고, 관련 화면에서 숫자도 cross-check했다.

버그 수정 커밋 전에 체크하는 것들:

  • 같은 로직이 다른 클래스나 경로에도 있는지 - 중복 코드는 같은 버그를 공유한다
  • 수정이 기존 정상 케이스를 망가뜨리지 않는지 - 최소한 손으로라도 재현해본다
  • 실패 경로에서 예외 메시지가 디버깅에 충분히 유용한지

이번에는 두 수정이 내부 클래스 2개 안에 묶여 있었지만, 논리적으로는 fallback 로직 수정과 encoding 수정이 독립적이다. 가능하면 커밋을 쪼개는 게 나중에 어느 변경에서 뭐가 바뀌었는지 추적하기 쉽다. 커밋 메시지도 "무엇을 바꿨다"보다 "왜 바꿨는지"를 담는 쪽이 3개월 후에 이 커밋을 다시 열었을 때 맥락이 살아 있다.

엣지 케이스를 꼼꼼히 짚는 게 처음엔 느린 것 같지만, 같은 버그로 다시 돌아오는 비용이 항상 더 크다. 외부 콜백처럼 로컬에서 재현 환경을 만들기 귀찮은 경로일수록 이번 한 번 제대로 검증해두는 게 낫다.

댓글 0

첫 댓글 달아줘.