[업무일지] 재실행 한 번에 두 배가 된 회수 대상
어느 셀러의 회수 대상이 실제의 2배로 잡히고, 그 금액으로 알림톡까지 두 번 나갔습니다. 새벽 배치가 만든 대상을, 같은 날 운영자 수기 재실행이 그대로 다시 INSERT한 게 원인이었습니다. 재실행이 멱등하지 않았거든요. 가드를 배치 경로가 아니라 모든 진입 경로가 지나는 공용 코어에 넣었고, 가드값과 DB 기본값을 상수 하나로 묶었습니다.
Java 21, Spring Boot 3.x, JPA, Redis 분산락 환경입니다. 쇼핑몰 정산금을 회수하기 위한 대상 레코드를 배치와 어드민이 만듭니다. 이 글의 클래스명과 컬럼값은 예시로 바꿨습니다.
회수 대상이 2배로 잡히고 알림까지 두 번 나갔다
어느 날 한 셀러의 회수 대상 금액이 실제의 2배로 잡혔습니다. 게다가 그 2배로 부풀려진 금액이 그대로 셀러에게 알림톡으로 나가서, 단순한 내부 계산 오류가 아니라 셀러가 직접 보는 숫자까지 틀어진 상황이 됐고요.
추적해보니 원인은 단순했습니다. 하나만 있어야 할 회수 대상이 같은 (userId, channelId)로 그날 두 개 만들어져 있었거든요.
경위는 이랬습니다. 새벽 배치가 이 대상을 정상적으로 만들었습니다. 그런데 크롤 성공과 실패를 가르는 판정이 어긋나는 바람에, 정상 생성된 이 건이 어드민의 ‘크롤 실패목록’에 잘못 떴습니다.
운영자는 실패한 줄 알고 낮에 재크롤을 눌렀습니다. 그랬더니 회수 대상이 새로 하나 더 들어갔죠. 두 배가 됐고, 합산된 금액으로 알림톡이 다시 나갔습니다.
크롤 판정이 왜 어긋났는지는 이 글의 주제가 아니라 따로 고쳤습니다. 여기서는 재실행이 왜 눌렸는지까지만 보면 됩니다. 중요한 건 그 재실행이 대상을 하나 더 만들었다는 겁니다.
재실행이 멱등하지 않았다
뿌리는 재실행이 멱등하지 않다는 거였습니다.
회수 대상을 만드는 코어는 대략 이런 모양이었습니다.
1
2
3
public Result run(Long userId, Long channelId) {
// 크롤 -> 정산금 계산 -> 회수 대상 INSERT
}
이 코어에는 “오늘 이미 만들었는지”를 확인하는 단계가 없었습니다. 부르면 부르는 대로 크롤하고, 정산금을 계산하고, 새 대상을 하나 더 넣었죠. 그게 다였습니다.
그리고 이 코어를 타는 경로가 하나가 아니었습니다.
- 새벽 배치 (이벤트로 트리거)
- 어드민 단건 재실행
- 어드민 벌크 재실행
이벤트 소비는 기본이 at-least-once입니다. 같은 이벤트가 두 번 소비될 수 있죠. 거기에 어드민 수기 재실행까지 더하면, 같은 날 같은 대상에 run()이 두 번 도는 경로가 여럿이었습니다. 어느 경로로 두 번 돌든 결과는 똑같이 대상이 2배가 되는 것이었습니다.
가드를 배치 경로가 아니라 공용 코어에 넣었다
처음 설계는 “야간 배치 전용 진입점을 따로 만들고 거기에만 가드를 건다”였습니다. 어드민 재실행은 운영자가 일부러 누르는 거니 가드 대상이 아니라고 봤고요.
그런데 실제 인시던트가 정확히 그 어드민 재실행이었습니다. 오전에 배치가 정상적으로 만들어 둔 대상을, 오후에 운영자가 실패목록만 보고 재실행을 누르면서 그대로 하나 더 만들어 버린 케이스였죠. 배치 경로에만 가드를 걸면 정작 이번 인시던트는 못 막습니다. 문제를 일으킨 게 바로 그 어드민 재실행 경로였으니까요. 설계가 틀렸던 거죠.
그래서 가드를 배치 전용 진입점이 아니라 공용 코어 run()의 맨 앞(분산락 임계구역 안)으로 옮겼습니다. 배치든 어드민 단건이든 벌크든, 이 코어를 타는 모든 경로가 같은 가드를 지나게요.
1
2
3
4
5
6
7
public Result run(Long userId, Long channelId) {
// 당일 배치 생성분이 이미 있으면 재크롤·재생성을 통째로 skip
if (targetRepository.existsTodayBatchCreatedTarget(userId, channelId)) {
return Result.skippedDuplicateTarget();
}
// ... 크롤 -> INSERT
}
진입 경로와 가드의 위치를 그림으로 보면 이렇습니다.
flowchart TD
P1["새벽 배치<br/>이벤트 at-least-once"] --> CORE["공용 코어 run()"]
P2["어드민 단건 재실행"] --> CORE
P3["어드민 벌크 재실행"] --> CORE
CORE --> G{"당일 배치<br/>생성분 있나?"}
G -->|있음| SKIP["skip<br/>재크롤·INSERT·알림 안 함"]
G -->|없음| INS["크롤 → INSERT"]
classDef fix fill:#2e7d32,color:#fff,stroke:#1b5e20
class SKIP fix
자동 코드리뷰는 이걸 “out of scope”라고 경고했습니다. 이슈 스펙은 배치 경로만 가드하라고 했으니까요. 그래도 인시던트 재발 방지가 먼저라고 판단해서 스펙을 벗어난 쪽으로 갔습니다. 대신 그 판단과 근거를 리뷰에 남겼습니다.
가드에서 유저·어드민 수기 생성분은 제외했습니다. 중도상환이나 역입금처럼 사람이 그 자리에서 의도를 갖고 만드는 대상은 자동으로 매일 도는 배치 생성분하고 성격이 다르고, 가드가 그것까지 붙잡으면 정상 처리를 막게 되거든요. 그래서 가드는 생성 경로가 배치인 대상만 봅니다.
skip할 때는 조용히 넘기지 않고 센티널 결과를 돌려줬습니다. 어드민 단건이면 “이미 있어서 건너뜀”을 응답에 보여주고, 벌크면 그 결과로 알림톡 중복 발송을 막았습니다. 인시던트 때 2차 피해였던 “알림톡 2배 발송”을 이걸로 끊었죠.
DB 기본값과 가드값을 상수 하나로 묶었다
가드는 생성 경로가 배치인 대상만 오늘 날짜로 조회합니다. 그런데 이 ‘배치’를 나타내는 문자열이 두 군데에 있었습니다. 가드가 비교하는 값, 그리고 대상을 INSERT할 때 DB가 넣는 기본값(@ColumnDefault)입니다.
둘이 어긋나면 가드가 조용히 무력화됩니다. 예를 들어 DB 기본값 문자열이 어느 리팩토링에서 슬쩍 바뀌었는데 가드 쪽 비교값은 옛 문자열 그대로라면, 가드는 매번 “그런 대상 없음”으로 판정해서 정작 막아야 할 중복을 하나도 못 막게 됩니다. 그런데 이 상태가 티가 안 납니다. 컴파일도 되고요. 테스트도 그 조합을 콕 짚지 않으면 그냥 넘어갑니다. 조용히 뚫리는 거죠.
그래서 문자열을 상수 하나로 올리고, 가드도 @ColumnDefault도 그 상수를 참조하게 했습니다.
1
2
3
4
5
public static final String CREATED_BY_BATCH = "BATCH";
@Column(name = "created_by")
@ColumnDefault("'" + CREATED_BY_BATCH + "'")
private String createdBy;
이제 값을 바꾸려면 상수 한 곳만 고치면 되고, 가드값과 DB 기본값이 따로 놀 수가 없습니다.
맺음말
바꾼 뒤로 같은 대상이 하루에 두 번 만들어지는 일은 사라졌습니다. 이제 운영자가 실패목록을 잘못 보고 재실행을 누르든 이벤트가 중복으로 소비되든, 같은 코어를 두 번째로 지나는 순간 당일 생성분 가드에 걸려서 크롤도 INSERT도 알림톡도 다시 일어나지 않습니다. 조용히 skip되죠.
포기한 것과 한계가 남았습니다. 우선 run()은 트랜잭션으로 안 묶여 있습니다. 대상을 여러 개 만드는 도중에 크래시가 나면 일부만 만들어진 채로 남습니다. 그 상태에서 재소비가 들어오면 가드가 “이미 있음”으로 보고 나머지까지 통째로 skip하죠. 남은 걸 자동으로 채워주지 않습니다. 지금 당일 복구 수단은 만들다 만 걸 수동으로 지우고 다시 실행하는 것뿐입니다. 대상 하나 단위로 더 촘촘한 멱등을 넣는 건 후속으로 미뤘습니다.
집계 라벨도 부정확한 게 하나 있습니다. 벌크 재실행에서 중복 skip이 별도 항목이 아니라 락 획득 실패로 뭉뚱그려져 집계됩니다. 총합이나 돈에는 영향이 없고 라벨만 부정확한 거라, 급하지 않아 그대로 뒀습니다. 정확히 구분하려면 결과 종류를 하나 더 나눠야 하는데, 그건 필요해지면 다루기로 했습니다.