시스템 간 연동 (EAI) · 비동기 큐 연동과 재처리 · 이론
'정확히 한 번'은 거짓말이다
한 줄 요약
"정확히 한 번" 은 전달 계층이 만들어 주는 것이 아니라 최소 한 번 전달 + 수신측의 중복 제거로 만드는 것이며, 뒤쪽 절반은 우리 몫이다.
왜 큐를 쓰는가
동기 연동은 상대가 살아 있어야 성립한다. 비동기는 그 전제를 없앤다.
- 시간 분리 — 상대가 지금 없어도 나중에 처리된다
- 부하 흡수 — 초당 1만 건이 몰려도 큐에 쌓아 두고 초당 1천 건씩 처리
- 장애 격리 — 수신측 장애가 송신측으로 번지지 않는다
- 재처리 — 실패한 메시지를 다시 넣을 수 있다
대신 얻는 대가가 있다. 복잡도와 중복이다.
"응답을 바로 못 받는다"는 것은 결과 통지 채널을 따로 설계해야 한다는 뜻이고,
"재시도한다"는 것은 같은 메시지가 두 번 처리될 수 있다는 뜻이다.
전달 보장의 세 등급
| 등급 | 뜻 | 현실 |
| --- | --- | --- |
| at-most-once | 최대 한 번. 유실 가능 | 로그·메트릭처럼 잃어도 되는 것 |
| at-least-once | 최소 한 번. 중복 가능 | 대부분의 실무 메시징 |
| exactly-once | 정확히 한 번 | 조건부로만 성립 |
여기서 반드시 이해할 것.
> "정확히 한 번" = "적어도 한 번 전달" + "수신측의 중복 제거"
즉 exactly-once 는 전달 계층이 마법으로 만들어 주는 것이 아니라
수신측이 중복을 걸러 내서 결과적으로 그렇게 보이게 하는 것이다.
브로커가 "exactly-once 지원"이라고 광고해도, 그건 특정 조건(같은 클러스터,
트랜잭션 API 사용) 안에서의 이야기다. 외부 시스템에 쓰기를 하는 순간 깨진다.
그러므로 소비자는 언제나 중복을 가정하고 짜야 한다. 예외가 아니라 기본값이다.
순서 보장의 범위
또 하나 자주 오해되는 것.
> 순서는 파티션(또는 큐) 안에서만 보장된다. 토픽 전체의 전역 순서는 보장되지 않는다.
그래서 "같은 주문번호의 메시지는 순서대로 처리돼야 한다"는 요구가 있으면,
주문번호를 파티션 키로 써야 한다. 그러면 같은 주문의 메시지는 같은 파티션에
들어가고 순서가 유지된다.
이걸 모르고 라운드로빈으로 분배하면, 취소 메시지가 주문 메시지보다 먼저
처리되는 일이 생긴다. 그리고 그건 하루에 한두 건씩만 발생해서
원인을 찾기가 아주 어렵다.
소비자 설계의 표준 형태
1. 메시지 수신2. 파싱 및 형식 검증 → 실패: 즉시 error/DLQ (재시도해도 똑같다)3. 멱등 확인 (이미 처리했나) → 이미 처리: 아무것도 안 하고 ack4. 업무 처리 (DB 트랜잭션)5. 처리 이력 기록 ← 4와 같은 트랜잭션 안에서6. ack (큐에서 제거)5번을 4번과 같은 트랜잭션에 넣는 것이 핵심이다.
따로 하면 "업무는 처리했는데 이력은 못 남긴" 상태가 생기고,
그 상태에서 재시도하면 중복 처리가 된다.
그리고 6번을 4번보다 먼저 하면 안 된다. 먼저 ack 하고 처리하다 죽으면
메시지가 사라진다. 이게 at-most-once 가 되는 지점이다.
파싱 실패는 재시도하지 마라
2번을 따로 둔 이유가 있다. JSON 이 깨졌거나 필수 필드가 없는 메시지는
100번 재시도해도 100번 실패한다. 그런데 재시도 로직에 걸리면
그 메시지가 큐 맨 앞에서 계속 실패하며 뒤의 정상 메시지를 막는다.
이걸 독약 메시지(poison message)라고 한다.
그래서 오류를 두 종류로 나눈다.
- 영구 오류(형식 오류, 필수값 누락, 존재하지 않는 코드) → 즉시 DLQ
- 일시 오류(DB 커넥션 실패, 상대 시스템 5xx, 타임아웃) → 재시도
이 구분이 없으면 큐가 막히고, 막힌 큐는 곧 전체 업무 정지다.
큐 적체(lag)는 가장 중요한 메트릭
비동기 연동의 건강 상태를 하나의 숫자로 보라면 소비자 지연(consumer lag) 이다.
"쌓인 메시지 수" 또는 "가장 오래된 미처리 메시지의 나이".
- 평소 0 근처 → 정상
- 계속 증가 → 소비자가 못 따라감 (성능 문제 또는 소비자 다운)
- 갑자기 급증 → 생산 측 급증 또는 소비자 장애
- 줄어들지 않는 일정 값 → 독약 메시지로 막혀 있을 가능성
경보는 절대값이 아니라 추세와 지속 시간으로 건다.
"lag 1000 초과"보다 "lag 이 10분 연속 증가"가 좋은 조건이다.
배치성 유입이 있는 시스템에서는 순간 lag 이 큰 게 정상이기 때문이다.
파일 큐도 큐다
브로커(Kafka, RabbitMQ)가 없는 SI 현장도 많다.
그럴 때 디렉터리 기반 큐를 쓴다. 의외로 견고하다.
/data/if/inbox/ ← 도착/data/if/processing/ ← 처리 중 (원자적 mv 로 이동 = 잠금)/data/if/done/ ← 성공/data/if/error/ ← 실패 (DLQ 역할)핵심은 mv 가 같은 파일시스템 안에서 원자적이라는 것이다.inbox → processing 이동에 성공한 프로세스만 그 메시지를 갖는다.
소비자를 여러 개 띄워도 중복 처리가 안 된다.
주의할 점 둘.
1. 다른 파일시스템 사이의 mv 는 복사+삭제라 원자적이지 않다.
같은 마운트 안에서 움직여야 한다.
2. 송신측이 파일을 다 쓰기 전에 소비자가 집어 갈 수 있다.
그래서 임시 이름으로 쓰고 완료 후 이름을 바꾸거나,
완료 플래그 파일(.ok) 을 함께 만드는 규약을 쓴다.
재처리 절차는 미리 만든다
장애가 나면 반드시 재처리를 하게 된다. 그때 필요한 것.
- 무엇이 실패했는가 — error/ 의 메시지와 사유
- 왜 실패했는가 — 사유별 분류 (형식/업무/시스템)
- 고칠 수 있는가 — 데이터 수정 후 재투입 vs 원천 재전송 요청
- 재처리해도 안전한가 — 멱등성이 보장되는가
- 누가 승인하는가 — 금액이 오가는 인터페이스는 승인이 필요할 수 있다
이 절차를 오픈 전에 문서로 만들고 리허설해야 한다.
장애 당일에 만들면 그날 밤을 새운다.
현장에서 만나는 모습
큐를 도입한 프로젝트에서 실제로 터지는 것은 큐 자체가 아니라 소비자 쪽 가정이다.
가장 흔한 것은 중복이다. 네트워크가 한 번 끊겼다가 붙으면 브로커는 아직 확인응답을 못 받은 메시지를 다시 보낸다. 이건 고장이 아니라 규격대로 동작한 것인데, 소비자가 그걸 몰랐으면 같은 주문이 두 건 적재된다. 그리고 이 사고는 대개 월말 정산에서 금액이 안 맞아서 발견된다 — 사고가 난 지 3주 뒤다.
두 번째는 순서다. 큐 전체가 순서를 지켜 준다고 믿고 짜면, 파티션이나 소비자를 늘리는 순간 깨진다. 순서가 보장되는 범위는 대개 파티션 하나 안쪽이므로, 같은 주문의 메시지가 같은 파티션으로 가도록 키를 잡아야 한다.
세 번째는 적체다. 큐는 부하를 흡수해 주기 때문에 소비자가 느려도 송신측은 아무 이상을 못 느낀다. 그래서 적체(lag)를 감시하지 않으면, 몇 시간 밀린 것을 "오늘 데이터가 안 보이는데요" 라는 문의로 알게 된다.