자동화 slecs

자동화 봇의 중복 청구를 원자적으로 처리

목차

운영 자동화 시스템을 만들다 보면, 매일 반복되는 작업들을 배치 처리하는 일일 봇을 필요로 한다. 이번 작업은 그 봇이 여러 카테고리의 청구를 처리할 때 발생하는 중복 문제를 정면으로 마주쳤다. categoryId 추가, 인터리브 클러스터 선택, 원자적 중복 제거—세 가지 개선을 한 번에 적용했다.

왜 자동화 봇에 카테고리가 필요했나

초기엔 단순했다. 매일 밤 실행되는 봇이 모든 청구를 한 덩어리로 처리했다. 하지만 서비스가 커지면서 청구 유형이 늘었고, 각 유형마다 다른 로직, 다른 우선순위, 다른 검증 규칙이 필요해졌다. 예를 들어 구독 갱신 청구와 일회성 거래 청구는 처리 방식이 근본적으로 다르다.

여기서 중요한 건 단순한 분류가 아니라는 점이다. 카테고리를 도입한다는 건 청구 흐름 자체를 재설계하는 것과 같다. 각 카테고리별로 독립적인 검증과 기록을 남겨야 했고, 감사 로그도 복잡해졌다. 그러다 보니 같은 청구가 여러 카테고리 경로를 동시에 타거나, 재시도 로직에서 중복으로 처리되는 시나리오가 생겼다.

인터리브 클러스터 선택으로 부하 분산

카테고리를 나누니 이번엔 또 다른 문제가 생겼다. 한 번에 수백 건의 청구를 처리해야 하는데, 한 대의 워커로는 느리다. 그래서 여러 클러스터 워커에 작업을 분산하기로 했다.

처음엔 단순했다. 카테고리 A의 청구 50건은 워커 1로, 카테고리 B의 청구 50건은 워커 2로 보낸다. 하지만 시간이 지나며 실제 카테고리별 작업량이 편차가 생겼다. 어떤 날은 카테고리 A가 대부분이고, 어떤 날은 카테고리 B가 많다. 그러면 한 워커는 바쁘고 다른 워커는 놀게 된다.

인터리브 선택이란 이렇게 한다:

카테고리별 청구 목록:
  A: [1, 2, 3, 4, 5]
  B: [6, 7, 8]
  C: [9, 10]

라운드 로빈으로 배분 (interleaved):
  워커 1: [1, 7, 9]      (A, B, C 각각 차례대로)
  워커 2: [2, 8, 10]     (A, B, C 각각 차례대로)
  워커 3: [3, 6]         (A, B)
  워커 4: [4]            (A)
  워커 5: [5]            (A)

이렇게 하면 한 워커가 한 카테고리의 모든 일을 하는 게 아니라, 여러 카테고리의 작업을 고르게 나눠 받는다. 결과적으로 워커들의 실행 시간이 더 균등해진다. 또한 한 카테고리가 오래 걸리더라도 전체 파이프라인이 블록되지 않는다.

중복 청구의 원자성 문제

하지만 여기서 새로운 함정이 있었다. 이미 처리된 청구를 기록하는 방식이었는데, 여러 워커가 동시에 실행되니 다음 같은 시나리오가 생겼다:

  1. 워커 A가 청구 #100을 읽음 → 아직 처리 안 됨 (DB 조회)
  2. 동시에 워커 B가 같은 청구 #100을 읽음 → 역시 아직 처리 안 됨
  3. 워커 A가 청구 #100을 처리하고 "처리됨" 마크
  4. 워커 B도 청구 #100을 처리하고 "처리됨" 마크
  5. 청구 #100이 두 번 처리됨 ← 버그

이걸 경쟁 조건(race condition)이라고 한다. 분산 시스템에서 매우 흔한 버그다. 금전 거래를 다루는 영역에선 이 버그 하나가 신뢰도를 갈아먹는다. 사용자가 같은 청구를 두 번 맞게 되거나, 반대로 한 건이 세 번 처리되면 정산에 문제가 생긴다.

원자적 중복 제거(atomic dedupe)는 이렇게 해결한다:

단계 문제 있는 방식 원자적 방식
1단계 DB 조회 후 검증 DB 수준에서 "처리 중" 상태로 원자적 변경
2단계 비즈니스 로직 실행 비즈니스 로직 실행
3단계 처리 완료 기록 처리 완료로 상태 변경
동시성 중간에 다른 워커 개입 가능 한 번에 하나의 워커만 상태 변경 가능
# 원자적 처리 예시 (의사 코드)
def process_claim_atomically(claim_id):
    # 이 부분이 트랜잭션으로 묶여서
    # "조회 + 상태 변경"이 분리 불가능하게 처리됨
    with atomic_transaction():
        claim = db.fetch_for_update(claim_id)  # Lock 획득
        if claim.status != 'pending':
            return  # 이미 다른 워커가 처리 중

        claim.status = 'processing'
        db.commit()

    # 실제 처리는 lock 밖에서 (다른 애들이 다음 건을 처리하도록)
    process_claim(claim)

    # 완료 표시
    with atomic_transaction():
        claim.status = 'completed'
        db.commit()

이렇게 하면 워커들이 아무리 동시에 달려들어도, 각 청구는 정확히 한 번만 처리된다.

팀 입장에서 배운 것

이 작업을 하면서 느낀 것들:

  • 자동화 시스템일수록 중복 처리가 치명적이다. 사람이 개입하는 프로세스라면 "어? 뭔가 이상한데?" 하고 눈치챌 수도 있지만, 완전 자동화된 시스템은 실수를 조용히 반복한다. 감시 없인 위험하다.

  • 카테고리를 추가할 때는 중복 제거 전략부터 생각하자. 나중에 추가하려니 코드가 훨씬 복잡해진다. 초기 설계 단계에서 "어떻게 같은 항목이 여러 경로를 탈 수 있을까"를 고민했으면 훨씬 나았을 것 같다.

  • 분산 시스템에서 "읽고 나중에 처리하겠다"는 위험한 패턴이다. 아무리 간단한 시스템이어도, 시간이 벌어질수록 중복 처리 가능성이 커진다. 가능하면 원자적으로 상태를 예약해 두는 게 낫다.

이번 개선으로 일일 봇의 신뢰도가 확실히 올라갔다. 중복 청구 감지 알람도 많이 줄었고, 정산 불일치 문제도 없어졌다. 자동화가 할 일이 정확히 정의되고, 그 과정이 견고할 때 비로소 팀이 믿고 맡길 수 있는 시스템이 된다는 걸 다시 한 번 깨달았다.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.