사전 중복 처리
문제

중복 처리

gabury1고친 사람 github-actions[bot]

중복 처리는 한 번만 일어나야 할 일이 두 번 반영되는 고장입니다. 결제 한 번에 돈이 두 번 빠지거나 주문 한 번에 주문서가 두 장 생기는 식입니다. 대개 응답을 못 받아 다시 보낸 요청이 원인입니다. 「중복 처리」라는 이름은 이런 겹침을 걸러 내는 일을 가리킬 때도 쓰입니다.

쉽고 빠른 이해

중복 처리는 같은 일이 두 번 처리되는 고장입니다. 결제 버튼을 한 번 눌렀습니다. 그런데 카드에서 돈이 두 번 빠집니다.

이 고장에 이름이 따로 붙은 까닭은 누가 실수하지 않아도 생기기 때문입니다. 응답이 오다가 사라지면 보낸 쪽은 일이 됐는지 모릅니다. 그래서 한 번 더 보냅니다. 받는 쪽은 이미 한 일을 또 합니다.

어떻게 생기나:

  1. 받는 쪽이 일을 끝냅니다
  2. 끝냈다는 응답이 보낸 쪽에 닿기 전에 사라집니다
  3. 보낸 쪽이 실패로 알고 같은 일을 다시 보냅니다

무엇이 나빠지나 — 조회처럼 두 번 해도 같은 일이면 탈이 없습니다. 돈을 빼거나 행을 더하는 일이면 결과가 한 번 더 쌓입니다. 막으려면 받는 쪽이 같은 일을 알아보는 장치를 따로 둬야 합니다.

상세

중복 처리는 같은 요청이나 메시지나 작업이 받는 쪽에서 두 번 이상 반영되는 고장입니다. 사용자는 주문을 한 번 했습니다. 그런데 주문 목록에는 같은 주문이 두 건 남습니다.

「중복 처리」라는 이름은 겹친 것을 걸러 내는 일을 가리킬 때도 쓰입니다. 「중복 처리 로직을 넣었다」는 말이 그런 쓰임입니다. 이 편은 고장 쪽 뜻으로 씁니다. 걸러 내는 일에는 중복 제거라는 이름이 따로 있습니다.

응답이 사라진 요청

이 고장은 대부분 한 장면에서 시작합니다. 요청은 서버에 닿아 처리됐습니다. 그 응답만 돌아오는 길에 사라집니다.

클라이언트가 서버에 결제를 요청합니다. 서버는 잔액에서 3000원을 뺍니다. 그런데 네트워크가 끊겨 응답이 클라이언트에 닿지 않습니다.

클라이언트는 응답을 무한정 기다리지 않습니다. 기다리는 시간에 상한을 둡니다. 이 상한을 타임아웃이라고 부릅니다. 상한이 없으면 응답 없는 요청 하나가 클라이언트를 영영 붙잡아 둡니다.

sequenceDiagram
    participant 클라이언트
    participant 서버
    클라이언트->>서버: 3000원 결제 요청
    Note over 서버: 잔액에서 3000원을 뺀다
    서버--x 클라이언트: 응답이 도중에 사라진다
    Note over 클라이언트: 타임아웃에 걸려 실패로 본다
    클라이언트->>서버: 같은 결제 요청을 다시 보낸다
    Note over 서버: 잔액에서 3000원을 또 뺀다
    서버-->>클라이언트: 결제 완료

타임아웃에 걸린 클라이언트는 세 경우를 가려낼 수 없습니다. 요청이 서버에 아예 안 닿았을 수 있습니다. 서버가 아직 처리하는 중일 수도 있습니다. 처리는 끝났고 응답만 사라졌을 수도 있습니다.

클라이언트가 볼 수 있는 것은 「응답이 안 왔다」 하나뿐입니다. 그래서 흔히 같은 요청을 같은 내용으로 다시 보냅니다. 이것을 재시도라고 부릅니다. 셋째 경우였다면 그림의 아래쪽 절반처럼 서버는 같은 결제를 한 번 더 합니다.

메시지 큐와 배치에서 생기는 중복

요청과 응답이 아니어도 같은 모양이 나옵니다. 이 소절은 메시지를 나르는 큐와 모아서 도는 작업에서 같은 틈이 어떻게 생기는지 봅니다.

메시지 큐는 보내는 쪽이 넣은 메시지를 받는 쪽이 꺼내 가도록 맡아 두는 중간 저장소입니다. 큐가 사이에 있으면 보내는 쪽은 받는 쪽이 바쁘거나 꺼져 있어도 메시지를 넣어 두고 제 일로 돌아갈 수 있습니다.

큐는 받는 쪽이 메시지를 꺼내 간 것만으로는 지우지 않습니다. 받는 쪽은 메시지를 처리한 뒤 「다 했다」는 확인 응답을 큐에 보냅니다. 큐는 이 확인을 받아야 메시지를 지웁니다. 받는 쪽이 처리 도중에 죽어도 메시지가 사라지지 않게 하려는 것입니다.

받는 쪽이 처리를 끝내고 확인을 보내기 직전에 죽었다고 합시다. 큐는 확인을 못 받았으니 그 메시지를 다른 받는 쪽에 다시 줍니다. 같은 메시지가 두 번 처리됩니다.

이런 큐는 메시지를 잃지 않는 쪽을 고른 것입니다. 빠뜨리지는 않되 두 번 갈 수는 있다는 약속을 최소 한 번 전달이라고 부릅니다. 이 약속 아래에서는 중복이 언제든 올 수 있다고 보고 받는 쪽을 짭니다.

데이터를 모아 두었다가 한 번에 처리하는 배치 처리도 같습니다. 행을 절반쯤 쓰다 실패한 작업을 처음부터 다시 돌리면 앞에서 쓴 절반이 한 번 더 들어갑니다.

두 번 처리되는 조건

중복 처리는 아래 넷이 모두 설 때 일어납니다. 하나라도 빠지면 일어나지 않거나 일어나도 결과에 흔적이 안 남습니다.

  1. 보낸 쪽이 결과를 모른 채 같은 일을 다시 보냅니다
  2. 첫 시도가 받는 쪽에서 이미 반영됐습니다
  3. 받는 쪽이 두 번째를 첫 번째와 같은 일로 알아보지 못합니다
  4. 그 일을 두 번 하면 결과가 한 번 한 것과 달라집니다

첫째가 빠지면 중복은 안 생깁니다. 대신 서버에 닿지 못한 요청은 다시 오지 않고 사라집니다. 둘째가 빠지면 재시도가 처음이자 유일한 처리가 되니 탈이 없습니다.

셋째와 넷째는 받는 쪽의 사정입니다. 같은 일이 다시 왔을 때 받는 쪽이 어느 길로 가는지가 이 둘에 달려 있습니다.

flowchart TD
    A["같은 일이 한 번 더 온다"] --> B{"받는 쪽이 처리한 적 있는 일로 알아보나"}
    B -->|알아본다| C["지난 결과를 돌려주고 끝난다"]
    B -->|못 알아본다| D{"두 번 해도 결과가 같은 일인가"}
    D -->|같다| E["다시 해도 흔적이 안 남는다"]
    D -->|다르다| F["중복 처리"]

두 갈림 가운데 어느 한쪽에서만 막혀도 고장은 안 납니다. 둘 다 뚫려 맨 아래 칸에 닿을 때만 중복 처리가 됩니다.

넷째 조건의 반대를 멱등성이라고 부릅니다. 멱등성은 같은 일을 여러 번 해도 결과가 한 번 한 것과 같은 성질입니다. 「잔액을 7000원으로 바꿔라」는 몇 번 해도 7000원입니다. 「잔액에서 3000원을 빼라」는 할 때마다 줄어듭니다.

코드에서 나타나는 꼴

이 소절은 같은 결제가 두 번 도는 코드를 봅니다. 앞의 것은 조건 넷이 다 선 코드입니다. 뒤의 것은 셋째 조건을 깬 코드입니다.

잔액에서 3000원을 빼는 결제가 재시도로 한 번 더 돌면 이렇게 됩니다.

Python
balance = 10000
balance -= 3000   # 7000  첫 시도
balance -= 3000   # 4000  재시도

셋째 줄은 둘째 줄과 똑같은 요청입니다. 그런데 잔액은 한 번 더 줄었습니다. 받는 쪽이 둘을 같은 일로 알아볼 단서가 코드 어디에도 없기 때문입니다.

단서를 주려면 보내는 쪽이 요청마다 고유한 번호를 붙입니다. 다시 보낼 때는 처음 붙인 번호를 바꾸지 않습니다. 이 번호를 멱등 키라고 부릅니다.

Python
balance = 10000
done = {}   # 처리한 번호 → 그때 결과

def pay(key, amount):
    global balance
    if key in done:
        return done[key]
    balance -= amount
    done[key] = balance
    return balance

pay("A1", 3000)   # 7000  첫 시도
pay("A1", 3000)   # 7000  재시도

두 번째 호출은 번호 A1 을 이미 본 번호로 알아봅니다. 그래서 잔액을 건드리지 않고 지난번 결과를 돌려줍니다. 셋째 조건이 깨졌으니 중복 처리도 안 일어납니다.

이 코드가 제대로 막으려면 번호를 적는 일과 잔액을 빼는 일이 함께 반영돼야 합니다. 둘 사이에서 서버가 죽으면 잔액만 줄고 번호는 안 남습니다. 그래서 둘을 한 트랜잭션으로 묶습니다. 트랜잭션은 여러 작업을 전부 반영하거나 전부 없던 일로 만드는 단위입니다.

고장을 막는 수단

막는 수단은 셋째 조건을 깨는 쪽과 넷째 조건을 깨는 쪽으로 갈립니다. 둘 다 받는 쪽에서 합니다. 보내는 쪽은 첫 시도가 반영됐는지 모르기 때문입니다.

수단 무엇을 하나 깨는 조건
멱등 키 같은 번호가 다시 오면 지난 결과를 돌려준다 셋째
중복 제거 이미 받은 메시지 번호를 적어 두고 다시 오면 버린다 셋째
유니크 제약 같은 번호의 두 번째 행을 데이터베이스가 거절한다 셋째
더하지 않고 덮어쓰기 「더해라」 대신 「이 값으로 만들어라」로 짠다 넷째

마지막 줄은 배치 처리에서 흔히 씁니다. 날짜 하나를 쓰는 작업이라면 그 날짜의 행을 비우고 새로 채웁니다. 몇 번을 다시 돌려도 그 날짜에는 한 벌만 남습니다.

첫째 조건을 깨는 길도 있습니다. 결과를 모르면 다시 보내지 않는 것입니다. 이 약속을 최대 한 번 전달이라고 부릅니다. 중복은 사라지지만 응답이 사라진 일은 한 번도 처리되지 않은 채 남을 수 있습니다.

그래서 흔히 전달은 최소 한 번으로 두고 받는 쪽이 중복을 걸러 냅니다. 이렇게 해서 결과를 한 번 처리한 것과 같게 맞추는 것을 정확히 한 번 전달이라고 부릅니다. 메시지가 네트워크에서 딱 한 번 오가는 것은 아닙니다. 여러 번 와도 한 번 반영되게 만든 것입니다.

중복 처리와 헷갈리는 이웃

재시도가 부르는 고장이 여럿이라 이름이 나란히 불립니다. 무엇이 잘못되는지를 기준으로 셋을 가르면 이렇습니다.

고장 무엇이 잘못되나
중복 처리 한 번 할 일의 결과가 두 번 쌓인다
재시도 폭풍 다시 보낸 요청이 몰려 받는 쪽이 버티지 못한다
메시지 유실 보낸 일이 한 번도 처리되지 않는다

재시도 폭풍은 요청의 양이 문제입니다. 요청 하나하나가 멱등해도 폭풍은 일어납니다. 중복 처리는 요청이 둘뿐이어도 일어납니다.

메시지 유실은 중복 처리의 반대편입니다. 다시 보내지 않으면 유실이 생깁니다. 다시 보내면 중복이 생길 틈이 열립니다. 두 고장 사이에서 어느 쪽을 받아들일지 고르는 것이 앞에서 본 전달 약속입니다.

데이터를 테이블에 옮겨 쌓는 작업에서 같은 행이 두 번 들어간 결과는 따로 중복 적재라고 부르기도 합니다. 중복 처리가 테이블에 남긴 흔적입니다.

관련 항목

중복 처리를 불러들이는 재시도와 재실행

재시도 · 타임아웃 · 지수 백오프 · 배치 처리 · 백필 · 체크포인트

중복 처리를 막는 성질과 장치

멱등성 · 멱등 키 · 중복 제거 · 유니크 제약 · 유니크 인덱스 · UPSERT · 트랜잭션 · 트랜잭셔널 아웃박스

중복 처리의 여지를 정하는 전달 약속

최소 한 번 전달 · 최대 한 번 전달 · 정확히 한 번 전달 · 확인 응답 · 가시성 타임아웃

중복 처리가 자주 나는 시스템

메시지 큐 · 작업 큐 · 분산 시스템 · 데이터 파이프라인 · Amazon SQS · Apache Kafka

중복 처리와 나란히 불리는 장애

재시도 폭풍 · 메시지 유실 · 중복 적재 · 부분 실패 · 경쟁 상태 · 독약 메시지

다른 이름: duplicate processing · 이중 처리 · 중복 실행