포스트

[업무일지] 배치 재실행 한 번에 두 배가 된 회수 대상

어느 셀러의 회수 대상이 실제의 2배로 잡히고, 그 금액으로 알림톡까지 두 번 나갔습니다. 새벽 배치가 만든 대상을, 같은 날 운영자 수기 재실행이 그대로 다시 INSERT한 게 원인이었습니다. 재실행이 멱등하지 않았거든요. 가드를 배치 경로가 아니라 모든 진입 경로가 지나는 공용 코어에 넣었고, 가드값과 엔티티의 기본값 선언을 상수 하나로 묶었습니다.

Java 17, Spring Boot 3.x, JPA, Redis 분산락 환경입니다. 쇼핑몰 정산금을 회수하기 위한 대상 레코드를 배치와 어드민이 만듭니다. 이 글의 클래스명과 컬럼값은 예시로 바꿨습니다.

회수 대상이 2배로 잡히고 알림까지 두 번 나갔다

어느 날 한 셀러의 회수 대상 금액이 실제의 2배로 잡혔는데, 그 부풀려진 금액이 그대로 셀러에게 알림톡으로 나가는 바람에 내부 계산 오류에서 끝나지 않고 셀러가 직접 보는 숫자까지 틀어진 상황이 됐습니다.

추적해보니 원인은 단순했습니다. 하루 한 벌이어야 할 회수 대상이 같은 (userId, channelId)로 그날 한 벌 더 만들어져 있었거든요.

경위는 이랬습니다. 새벽 배치가 이 대상을 정상적으로 만들었습니다. 그런데 크롤 성공과 실패를 가르는 판정이 어긋나는 바람에, 정상 생성된 이 건이 어드민의 ‘크롤 실패목록’에 잘못 떴습니다.

운영자는 실패한 줄 알고 오전 9시쯤 재크롤을 눌렀습니다. 그랬더니 회수 대상이 한 벌 더 들어갔죠. 두 배가 됐고, 합산된 금액으로 알림톡이 다시 나갔습니다.

크롤 판정이 어긋난 원인은 따로 고쳤습니다. 중요한 건 그 재실행이 대상을 한 벌 더 만들었다는 겁니다.

재실행이 멱등하지 않았다

회수 대상을 만드는 코어는 대략 이런 모양이었습니다.

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배 발송”을 이걸로 끊었죠.

가드값과 엔티티 기본값을 상수 하나로 묶었다

가드는 생성 경로가 배치인 대상만 오늘 날짜로 조회합니다. 그런데 이 ‘배치’를 나타내는 문자열이 여러 군데에 흩어져 있었습니다. 가드가 비교하는 값, 엔티티의 @ColumnDefault, 그리고 운영 테이블에 실제로 걸린 DB 기본값입니다. 엔티티가 @DynamicInsert 라서, INSERT 때 이 컬럼을 비워 두면 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;

다만 이 상수가 묶어 주는 건 가드와 엔티티 선언까지입니다. @ColumnDefault 는 스키마를 생성할 때만 쓰이는 값이라 테스트 스키마에만 반영되고, 운영 테이블의 기본값은 마이그레이션 밖의 기존 DDL 에 따로 있습니다. 누가 상수만 바꾸면 테스트 스키마는 따라와서 테스트는 통과하는데, 운영 DB 는 옛 값을 넣으니 운영에서만 가드가 비게 됩니다. 그쪽까지 같은 값인지는 여전히 사람이 맞춰야 합니다.

걸지 않은 유니크 제약과 트랜잭션 없는 run()

DB 유니크 제약은 설계 단계에서 ‘필요하면 걸 후보’로만 적어 두고 이번엔 걸지 않았습니다. 대상이 미회수 건마다 한 줄씩 생기는 구조라, 건다면 어느 미회수 건에서 나온 행인지와 날짜를 키로 구분할 수 있어야 합니다. 테이블도 마이그레이션 밖에 있어서 제약 하나 거는 것도 따로 챙길 일이고요.

run()은 트랜잭션으로 안 묶여 있습니다. 대상을 여러 개 만드는 도중에 크래시가 나면 일부만 만들어진 채로 남습니다. 그 상태에서 재소비가 들어오면 가드가 “이미 있음”으로 보고 나머지까지 통째로 skip하죠. 남은 걸 자동으로 채워주지 않습니다. 지금 당일 복구 수단은 만들다 만 걸 수동으로 지우고 다시 실행하는 것뿐이고, 대상 하나 단위로 더 촘촘한 멱등을 넣는 건 후속으로 미뤘습니다.

집계 라벨도 부정확한 게 하나 있습니다. 벌크 재실행에서 중복 skip이 별도 항목이 아니라 락 획득 실패로 뭉뚱그려져 집계됩니다. 총합이나 돈에는 영향이 없고 라벨만 부정확한 거라, 급하지 않아 그대로 뒀습니다. 정확히 구분하려면 결과 종류를 하나 더 나눠야 하는데, 그건 필요해지면 다루기로 했습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.