포스트

스트리밍 시스템 2부 — 과금 이벤트를 정확히 한 번 반영하기

한 번의 클릭은 어떻게 한 번의 과금이 되는가

애즈 플랫폼의 과금 이벤트에는 분명한 요구사항이 있습니다. 같은 이벤트가 두 번 과금되어서도 안 되고, 누락되어서도 안 됩니다. 매일 정산하는 시점에는 그날의 과금 대상 이벤트가 정확히 한 번씩 반영됐음을 보장할 수 있어야 합니다.

제가 과금 처리 시스템을 직접 구현한 것은 아닙니다. 다만 지금까지 여러 시스템을 개발하면서 최소 한 번이나 최대 한 번 처리를 고려해왔기 때문에, 두 조건을 동시에 만족시키는 방법이 궁금했습니다. 실패하면 다시 시도하는 방식과 중복 실행을 피하는 방식은 익숙했지만, 중복도 누락도 허용하지 않는 시스템이 실무에서 어떻게 동작하는지는 쉽게 그려지지 않았습니다.

《Streaming Systems》를 읽게 된 출발점은 이 질문이었습니다.

매일 정산할 때, 과금 이벤트가 빠짐없이 정확히 한 번 반영됐다는 것을 어떻게 보장할 수 있을까?

광고 클릭 한 번을 받아 비용을 계산하는 코드는 단순합니다. 하지만 그 코드가 실행되는 서버와 네트워크는 언제든 실패할 수 있습니다. 비용은 반영됐는데 응답이 오지 않았다면, 같은 이벤트를 다시 처리해도 될까요?

1부에서는 이벤트를 어떤 시간 윈도우에 묶고, 언제 어떤 형태의 결과를 내보낼지 살펴봤습니다. 마지막에는 시간 정책만으로 외부 원장에 과금이 한 번씩 반영됐다고 보장할 수 없다는 질문이 남았습니다.

1부와 연결해서 생각하면, 끝없이 들어오는 스트림에서도 어느 범위까지는 입력과 계산이 끝났다고 판단할 수 있어야 정산이 가능합니다. 여기서 한 걸음 더 나아가, 그 범위의 계산이 장애와 재시도를 거친 뒤에도 원장에 정확히 한 번 남도록 만드는 과정이 궁금했습니다.

이번에는 그 보장에 어떤 원리가 필요한지 풀어보려고 합니다. 여기서 2부는 블로그 연재 순서입니다. 책에서는 5장 Exactly-Once and Side Effects가 이 주제를 다룹니다. 책의 목차에서도 입력, 출력, 부수 효과를 함께 다루는 구성을 확인할 수 있습니다.

아래 흐름은 이 질문을 따라가기 위해 단순화한 과금 시스템 예시입니다. 책에서 읽은 원리를 적용해보는 사고 실험이며, 제가 직접 구현한 과금 시스템이나 실제 운영 장애를 재현한 사례는 아닙니다.

출발점 — 무엇을 한 번으로 셀 것인가

1부처럼 클릭당 100원을 과금하는 CPC 광고를 가정하겠습니다.

1
2
3
4
5
클릭 이벤트 → 메시지 로그 → 처리기 → 과금 원장

event_id = click-42
campaign_id = A
amount = 100원

과금 원장은 어떤 클릭 때문에 얼마의 비용이 발생했는지 남기는 영속적인 기록입니다. 이 글에서 원하는 결과는 명확합니다.

과금 대상으로 받아들인 click-42가 원장에 100원의 비용을 정확히 한 번 남긴다.

이때 같은 클릭을 재전송해도 event_id는 유지된다고 가정합니다. 재시도할 때마다 새 ID를 만들면 수신자는 새 클릭인지 재전송인지 구별하기 어렵습니다. 반대로 같은 사용자가 실제로 두 번 클릭했다면 서로 다른 ID가 필요합니다.

먼저 하나의 이벤트를 받고 진행 위치를 저장하는 과정부터 생각해보겠습니다.

1. 첫 번째 문제 — 처리됐는지 모르는 이벤트를 어떻게 할까

문제: 성공한 작업도 실패처럼 보일 수 있다

처리기가 이벤트를 읽고 원장에 100원을 기록했습니다. 그런데 메시지 처리 완료를 알리기 직전에 종료됐습니다.

1
2
3
4
5
6
7
click-42 읽기
    ↓
원장에 100원 반영 성공
    ↓
프로세스 종료
    ↓
처리 완료 기록은 남지 않음

다시 실행된 처리기 입장에서는 완료 기록이 없습니다. 그렇다고 원장 반영이 실패했다고 단정할 수도 없습니다. 타임아웃도 마찬가지입니다. 응답을 받지 못했다는 사실만으로 상대방이 작업을 수행했는지는 알 수 없습니다.

해결: 완료 기록의 순서에 따라 보장이 달라진다

단순한 순차 소비자에서 처리 전에 진행 위치를 영속적으로 저장하고, 실패한 이벤트를 재시도하지 않는다고 해보겠습니다.

1
2
3
이 이벤트까지 완료했다고 기록
    ↓
원장에 비용 반영

두 단계 사이에서 종료되면 비용은 반영되지 않았지만 복구 후에는 다음 이벤트로 넘어갑니다. 이것이 최대 한 번(At-Most-Once) 방식의 예입니다. 이 경로에서는 같은 입력을 다시 실행하지 않는 대신 유실을 허용합니다. 생산자가 이미 중복 발행한 별도 레코드까지 제거해준다는 뜻은 아닙니다.

반대로 실제 처리가 성공한 뒤에 완료를 기록할 수 있습니다.

1
2
3
원장에 비용 반영
    ↓
이 이벤트까지 완료했다고 기록

이번에는 둘 사이에서 종료되면 같은 입력을 다시 읽습니다. 성공 여부가 불확실하면 재시도해서 결국 반영되도록 하는 최소 한 번(At-Least-Once) 방식입니다.

물론 재시도할 원본이 보존되고, 장애에서 복구할 수 있어야 합니다. 재시도 횟수를 다 쓴 뒤 이벤트를 버린다면 그 이벤트의 과금까지 보장했다고 말할 수 없습니다.

방식단순 소비자의 구현 예장애가 끼어들면
최대 한 번완료 기록 후 처리, 재시도하지 않음반영되지 않은 이벤트를 건너뛸 수 있음
최소 한 번처리 후 완료 기록, 미완료 입력 재시도이미 반영된 이벤트를 다시 처리할 수 있음
정확히 한 번재시도와 함께 중복 효과를 막는 장치 구성실행이 반복돼도 관찰하는 결과는 한 번 반영

제약: 재시도만으로는 과금이 정확해지지 않는다

최소 한 번 방식은 유실을 막는 출발점이지만, 다음 연산을 그대로 재실행하면 문제가 됩니다.

1
2
3
4
campaign_cost += 100

첫 실행: 0원 → 100원
재시도: 100원 → 200원

여기까지 오면 질문이 바뀝니다.

다시 실행할 수밖에 없다면, 이미 수행한 계산을 어떻게 다뤄야 할까?

2. 두 번째 문제 — 재처리하면서 집계도 다시 더하면 어떨까

문제: 입력 위치와 계산 상태가 어긋난다

이번에는 원장에 쓰기 전, 스트리밍 엔진 안에서 캠페인별 비용을 합산한다고 해보겠습니다. 한 입력 파티션의 이벤트는 모두 100원이고, offset 1부터 시작한다고 단순화하겠습니다.

1
2
3
offset 1~10 처리 완료
캠페인 A의 누적 비용 = 1,000원
다음에 읽을 offset = 11

이후 offset 11까지 계산해 1,100원이 됐지만, 저장된 입력 위치는 여전히 11이라면 어떻게 될까요? 1,100원을 복구한 뒤 11번을 다시 읽으면 1,200원이 됩니다.

반대로 다음 입력 위치만 12로 저장하고 비용을 1,000원으로 복구하면, 11번의 100원이 빠집니다.

입력 위치와 집계 상태를 각각 저장했다는 사실만으로는 충분하지 않습니다. 두 값이 같은 처리 경계를 가리켜야 합니다.

해결: 입력 위치와 상태를 하나의 복구 지점으로 묶는다

Flink의 체크포인트를 이해할 때 핵심은 어디까지 읽었는지와 그 결과 상태를 함께 복구한다는 점입니다. Flink는 입력에 체크포인트 배리어를 흘려보내고 연산자 상태를 일관된 경계에서 스냅샷으로 남깁니다. 아래는 배리어 정렬을 사용하는 방식의 개념 설명입니다. Flink의 장애 복구 문서

1
2
3
4
5
6
7
8
9
10
11
12
13
14
완료된 체크포인트 C
  다음 입력 offset = 11
  누적 비용 = 1,000원

11번 처리 → 1,100원
12번 처리 → 1,200원
장애 발생

C로 복구
  다음 입력 offset = 11
  누적 비용 = 1,000원

11번 재처리 → 1,100원
12번 재처리 → 1,200원

11번과 12번을 계산하는 코드는 두 번 실행됐습니다. 하지만 실패한 실행의 상태를 버리고 함께 되돌렸으므로, 복구된 집계에는 각각 한 번의 효과만 남습니다.

입력이 여러 개여도 같은 원리가 필요합니다. 한 입력은 배리어 이후 이벤트까지 집계하고, 다른 입력은 배리어 이전 상태를 저장하면 복구 경계가 어긋납니다. 배리어 정렬은 여러 입력을 이 경계에 맞추는 방법입니다.

따라서 Flink의 Exactly-Once를 사용자 함수의 호출 횟수로 해석하면 안 됩니다. 기본적으로는 Flink가 관리하는 상태에 입력의 효과가 한 번 남는다는 의미입니다. 복구 가능한 입력과 올바른 체크포인트 구성이 전제됩니다. Exactly Once Guarantees

Apache Beam을 볼 때도 실행과 결과를 나눠 생각해야 합니다. Beam은 파이프라인을 표현하는 모델과 SDK를 제공하고, 실제 실행은 Flink 같은 Runner가 담당합니다. Beam의 실행 모델에서는 번들 일부가 실패하면 이미 실행한 요소까지 다시 실행할 수 있습니다. 실패한 번들의 출력과 관리되는 상태 변경은 버리지만, 사용자 함수에서 호출한 외부 API의 효과까지 자동으로 취소되는 것은 아닙니다. 보장의 범위는 Runner와 I/O 구성을 함께 확인해야 합니다. Beam 실행 모델

제약: 체크포인트 밖의 효과는 되돌아오지 않는다

앞의 복구가 가능한 이유는 1,200원이라는 중간 상태를 1,000원으로 되돌릴 수 있기 때문입니다. 그런데 11번을 처리하면서 외부 과금 API도 호출했다면 어떻게 될까요?

1
2
Flink 내부 상태: 체크포인트의 1,000원으로 복구
외부 과금 원장: 이미 기록한 100원이 남아 있음

내부 상태를 복구한 뒤 API를 다시 호출하면 외부에는 중복 비용이 남을 수 있습니다.

체크포인트에는 운영 비용도 있습니다. 더 자주 저장하면 보통 복구 시 다시 읽을 범위가 줄지만 저장 작업이 늘어납니다. 상태가 커지거나 입력이 밀리면 체크포인트 완료도 늦어질 수 있습니다. 원본 로그가 복구 지점보다 먼저 삭제되면 필요한 재처리가 불가능합니다.

결국 다음 질문은 엔진 내부의 정확성을 외부 저장소까지 어떻게 이어갈 것인가입니다.

3. 세 번째 문제 — 원장에는 이미 돈이 반영됐다

문제: 처리 완료와 외부 쓰기가 서로 다른 곳에 있다

외부 DB에 비용을 쓰는 작업과 스트리밍 엔진의 체크포인트는 기본적으로 별개의 작업입니다. 각각 성공해도 둘 사이에 장애가 끼어들 수 있습니다.

이 경계를 연결하는 대표적인 방법은 두 가지입니다. 외부 쓰기의 확정을 체크포인트와 조율하거나, 외부 저장소가 같은 과금 요청을 반복해서 받아도 한 번만 반영하게 만드는 것입니다.

해결 A: 외부 쓰기의 확정을 체크포인트와 조율한다

트랜잭션을 지원하는 Sink에서는 결과를 먼저 준비하고, 체크포인트 완료에 맞춰 확정하는 방식을 사용할 수 있습니다.

1
2
3
4
5
6
7
외부 트랜잭션에 결과 쓰기
    ↓
커밋 준비 및 복구에 필요한 트랜잭션 정보 저장
    ↓
체크포인트 완료
    ↓
외부 트랜잭션 커밋

완료되지 않은 체크포인트에 속한 쓰기는 복구 과정에서 폐기하고 재처리할 수 있습니다. 완료된 체크포인트의 커밋이 중단됐다면, 저장한 정보로 해당 커밋을 마무리할 수 있어야 합니다. 이미 성공한 커밋의 재요청도 안전해야 합니다.

이것이 체크포인트와 연계한 2단계 커밋을 이해하는 핵심입니다. 외부 DB에 단순히 BEGIN과 COMMIT을 붙이는 것만으로는 입력 위치와의 관계가 생기지 않습니다. Flink의 End-to-End Exactly-Once 설명

이 방식은 Sink와 저장소의 지원이 필요합니다. 결과 공개가 체크포인트 완료를 기다릴 수 있고, 트랜잭션 타임아웃에는 장애 복구 시간도 고려해야 합니다. 외부 독자도 커밋된 결과를 읽는 방식으로 구성해야 합니다. 위 문서는 원리 설명을 위한 자료이며, 실제 API와 지원 범위는 사용하는 커넥터 버전에서 확인해야 합니다.

해결 B: 같은 과금 요청을 여러 번 받아도 결과가 같게 만든다

과금 원장을 직접 설계한다면 이벤트별 멱등성을 두는 방법도 생각할 수 있습니다. 멱등성(Idempotency)은 같은 작업을 반복해도 결과가 한 번 수행했을 때와 같아지는 성질입니다.

cost += 100은 멱등하지 않습니다. 대신 과금 원장의 event_id에 유일성 제약을 두고, 해당 이벤트가 처음 기록될 때만 비용을 반영할 수 있습니다.

주의할 점은 중복 확인과 과금을 별개로 처리하지 않는 것입니다.

1
2
3
4
5
이미 처리한 ID인지 조회
    ↓
비용 반영
    ↓
처리한 ID 저장

이 구조에서는 두 워커가 동시에 조회를 통과할 수 있습니다. 비용 반영 후 ID 저장 전에 종료되는 문제도 남습니다.

다음은 같은 PostgreSQL DB 안에 원장과 캠페인 합계가 있다는 가정의 예시입니다. billing_ledger.event_id에는 PRIMARY KEY가 있고, 원장의 campaign_id는 미리 생성된 캠페인 행을 참조하는 외래 키라고 하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BEGIN;

WITH inserted AS (
    INSERT INTO billing_ledger (event_id, campaign_id, amount)
    VALUES ('click-42', 'A', 100)
    ON CONFLICT (event_id) DO NOTHING
    RETURNING campaign_id, amount
)
UPDATE campaign_cost AS cost
SET amount = cost.amount + inserted.amount
FROM inserted
WHERE cost.campaign_id = inserted.campaign_id;

COMMIT;

처음이면 원장 행을 삽입하고 그 행의 금액만 합계에 더합니다. 재시도에서는 충돌한 행을 삽입하지 않으므로 RETURNING 결과가 없고 합계도 변하지 않습니다. 충돌 처리와 반환 행의 의미는 PostgreSQL INSERT 문서에 설명돼 있습니다.

원장 삽입과 합계 갱신은 같은 트랜잭션에서 성공하거나 실패합니다. 처리기는 이 커밋이 성공한 뒤 입력 처리를 완료합니다. 커밋 응답을 잃어버렸다면 같은 ID로 재시도합니다.

장애 시점DB에 남은 결과같은 ID로 재시도한 결과
커밋 전 종료, 트랜잭션 롤백원장과 비용 모두 미반영처음으로 100원 반영
커밋 성공 후 응답 유실원장과 비용 모두 반영추가 반영 없음
커밋 성공 후 입력 완료 기록 실패원장과 비용 모두 반영추가 반영 없음

이 설계에서는 입력을 최소 한 번 전달하더라도, 원장에서 관찰하는 과금 효과를 정확히 한 번으로 만들 수 있습니다. 원장과 합계가 서로 다른 DB에 있다면 위 트랜잭션의 보장은 그대로 적용되지 않습니다.

제약: ID와 보존 기간, 외부 시스템도 계약의 일부다

여기에는 중요한 가정이 있습니다. 같은 event_id는 항상 같은 캠페인과 금액을 뜻해야 합니다. 같은 ID로 100원과 120원이 들어왔다면 단순 중복으로 넘길 문제가 아닙니다. 실제 구현에서는 기존 내용과의 불일치를 검출하고 별도로 처리해야 합니다.

재처리하면서 현재 CPC를 다시 조회하는 경우도 생각해볼 수 있습니다. 최초에는 100원이던 단가가 다음 실행에서는 120원일 수 있습니다. 이벤트에 확정된 과금 금액이나 적용할 가격 버전을 남겨 재현할 수 있게 해야 합니다. 한 번 저장됐다는 사실만으로 금액 자체의 정확성이 보장되지는 않습니다.

중복 판별 기록의 수명도 중요합니다. 예를 들어 처리한 ID를 7일 뒤 삭제하면서 30일 전 이벤트를 다시 투입할 수 있다면, 오래된 클릭이 새 과금으로 보일 수 있습니다. 스트리밍 상태의 TTL만으로 보호할지, 원장의 유일성 기록을 더 오래 유지할지, 오래된 재처리를 별도 정산 경로로 보낼지 정해야 합니다.

입력 로그의 offset과 비즈니스 이벤트 ID도 구분해야 합니다. 같은 클릭이 생산자 재시도로 서로 다른 offset에 두 번 기록될 수 있습니다. 엔진이 두 레코드를 각각 정확히 한 번 처리해도 클릭 기준으로는 두 번입니다. 이런 중복은 안정적인 이벤트 ID와 도메인 규칙으로 다뤄야 합니다.

마지막으로 원장 기록 뒤 별도의 결제 API까지 호출한다면 보장 경계가 다시 생깁니다. 원장과 같은 트랜잭션에 전송할 요청을 저장하는 Outbox를 둘 수 있지만, 전송 자체는 반복될 수 있습니다. 수신 측도 과금 키로 중복을 막아야 합니다. 상대가 멱등 키나 처리 결과 조회를 지원하지 않으면, 응답을 잃은 요청을 무조건 재시도하는 것으로 정확히 한 번을 보장할 수 없습니다.

1부의 윈도우로 돌아가면

입수, 계산, 과금 확정은 서로 다른 경계다

1부에서는 무한한 스트림을 윈도우로 나누고, 워터마크와 트리거를 이용해 결과를 내보냈습니다. 이 흐름을 보면 특정 시점 이전의 이벤트가 모두 입수되고 계산됐다고 이해하기 쉽습니다. 하지만 실제 정산에 연결하려면 세 가지 경계를 구분해야 합니다.

경계답하려는 질문사용하는 근거
입력의 시간 경계어느 시각까지 발생한 이벤트가 도착했다고 볼 것인가?워터마크, 입력 소스의 완료 조건, 지연 정책
계산의 복구 경계어느 입력 위치까지의 계산 상태를 장애 후 복구할 수 있는가?완료된 체크포인트의 입력 위치와 상태
외부 결과의 확정 경계어떤 과금 내역이 원장에 영속적으로 반영됐는가?Sink 커밋 완료, 원장의 트랜잭션과 유일성 제약

일반적인 워터마크는 입력 완전성에 대한 추정입니다. 워터마크가 자정을 넘었다고 해서 전날 클릭이 더는 도착하지 않는다고 무조건 보장할 수는 없습니다. 그 강도는 입력 소스가 제공하는 조건에 달려 있습니다. Beam의 워터마크 설명

체크포인트의 경계는 이벤트 시간의 자정과도 다릅니다. 같은 체크포인트에 전날의 지연 클릭과 오늘의 클릭이 함께 들어갈 수 있습니다. 체크포인트는 입력 위치에 대응하는 상태를 복구하도록 하며, 특정 날짜의 모든 이벤트가 수집됐음을 증명하지는 않습니다.

또한 윈도우 결과가 나왔어도 외부 쓰기가 진행 중일 수 있습니다. 트랜잭션 Sink에서는 체크포인트 완료가 외부 커밋을 진행할 근거가 되지만, 정산 결과를 읽는 쪽에서는 필요한 커밋이 완료됐는지도 확인해야 합니다.

따라서 워터마크만 보고 정산을 확정하거나, 체크포인트가 성공했다는 이유로 해당 날짜의 원장이 완성됐다고 판단하면 경계를 하나 건너뛰게 됩니다.

결과를 한 번 저장하는 것과 올바르게 해석하는 것

누적 모드의 같은 윈도우에서 아래 두 결과가 나왔다고 해보겠습니다.

1
2
첫 결과: 클릭 100건 → 누적 비용 10,000원
지연 클릭 반영: 클릭 105건 → 누적 비용 10,500원

두 결과를 각각 정확히 한 번씩 더하면 20,500원이 됩니다. 전송과 저장에 중복이 없어도 누적 결과의 의미를 잘못 해석하면 과금은 틀립니다.

윈도우 합계를 저장한다면 캠페인과 윈도우를 키로 최신 누적값을 반영하고, 오래된 결과가 늦게 도착해 최신 값을 덮지 않도록 버전도 관리해야 합니다. 차액을 과금한다면 이전에 확정한 값과 이번 변경의 ID를 함께 관리해야 합니다. 앞의 이벤트별 원장 예시는 클릭마다 비용을 남겨 이 문제를 분리한 설계입니다.

또한 지연 허용 범위를 넘겨 제외된 클릭이나, 입력 로그에 도달하기 전에 유실된 클릭은 체크포인트가 복구해주지 않습니다. 받아들인 이벤트의 중복 반영을 막는 것과 모든 과금 대상이 빠짐없이 들어왔는지는 별도로 확인해야 합니다.

실무에 적용해본다면 — 하루치 정산의 확정 절차

세 경계를 연결하는 구체적인 예시를 생각해보겠습니다. 다음은 제품의 기본 기능을 나열한 것이 아니라, 앞에서 살펴본 원리로 구성해본 정산 설계입니다. 과금은 이벤트별 멱등 원장에 기록하고, 윈도우 집계는 현황을 보여주는 용도로 사용한다고 가정합니다.

정산 대상은 서울 시간으로 10월 4일에 발생한 클릭입니다. 스트림에는 10월 5일의 이벤트도 계속 들어오지만, 정산에서는 전날 이벤트만 구분합니다.

먼저 정산 실행에 아래와 같은 정보를 남길 수 있습니다.

1
2
3
4
5
정산 실행 ID: settlement-2026-10-04-v1
이벤트 시간 범위: [10월 4일 00:00, 10월 5일 00:00)
원본 입력 경계: 파티션별 포함할 마지막 offset
과금 규칙: 이번 정산에 적용할 가격·정책 버전
진행 상태: 수집 확인 → 처리 확인 → 대사 → 확정

파티션별 offset은 이번 실행이 검사할 원본 범위를 고정합니다. 이 값만으로 전날 이벤트가 모두 수집됐다고 증명할 수는 없습니다. 각 생산자가 전날 데이터를 모두 영속 로그에 기록했다는 신뢰할 수 있는 완료 신호를 제공한다면, 그 신호가 보장하는 범위를 포함하도록 입력 경계를 잡을 수 있습니다. 그런 신호가 없다면 마감 정책에 따른 정산이라는 사실과 이후 지연 이벤트를 처리할 경로가 필요합니다.

그다음에는 고정한 입력 경계까지 처리됐는지 확인합니다. 이때 이벤트별 원장 쓰기를 사용하는 예시에서는 해당 범위의 비동기 쓰기까지 완료돼야 하고, 실패한 쓰기를 건너뛴 채 처리 완료로 기록해서는 안 됩니다. 윈도우 집계 결과를 과금 입력으로 사용한다면 해당 윈도우의 필요한 출력까지 발생하고 커밋됐는지도 추가로 확인해야 합니다. 입력을 읽은 것과 윈도우의 결과를 출력한 것은 다르기 때문입니다.

그 범위의 과금 대상 ID와 원장을 대사해 누락분을 재처리합니다. 앞에서 만든 유일성 제약은 여기서도 유용합니다. 재처리에 이미 반영된 클릭이 섞여 있어도 같은 ID로 요청하면 추가 과금을 막을 수 있습니다.

1
2
3
4
5
실시간 처리: click-42의 100원 커밋 성공, 응답 유실
재시도:     click-42는 이미 존재 → 추가 반영 없음
정산 대사:  click-43이 누락됨을 발견
복구 처리:  click-42, click-43 재투입
원장 결과: click-42 100원, click-43 100원

이 과정에서 click-42를 처리하는 코드는 여러 번 실행됩니다. 정산이 확인하는 조건은 각 대상 ID의 과금 내역이 하나씩 남았다는 것입니다. 재시도는 누락을 메우고, 멱등성은 그 재시도가 중복 비용을 만들지 않게 합니다.

마지막으로 대사한 결과를 정산 실행 ID에 연결해 고정하고 확정합니다. 대사 직후 다른 쓰기가 끼어들어 합계가 바뀌지 않도록, 확정 대상의 불변 스냅샷이나 트랜잭션으로 보호된 마감 절차가 필요합니다. 같은 정산 실행을 재시도해도 정산 내역이 중복 생성되지 않도록 정산 실행 ID에도 유일성을 적용할 수 있습니다.

이후 도착한 이벤트는 확정된 정산을 조용히 바꾸는 대신 별도의 조정 내역으로 연결합니다. 원장 전체를 잠그거나 스트림을 멈추지 않아도, 특정 정산이 어떤 입력과 결과를 기준으로 확정됐는지 남기는 방식입니다.

정산 완료를 선언하기 위한 조건

처음 궁금했던 일일 정산 문제에 이 원리를 적용해보겠습니다. 원장에 중복을 막는 제약이 있다는 사실만으로는 정산을 확정하기 어렵습니다. 아직 처리하지 못한 이벤트가 남아 있을 수도 있기 때문입니다.

설계 예시로, 정산 대상 원본을 확정할 수 있고 각 이벤트의 ID와 과금 금액을 재현할 수 있다고 가정하겠습니다. 그러면 다음 조건을 확인하는 방식으로 정산을 구성할 수 있습니다.

확인할 조건확인하는 이유
해당 날짜의 과금 대상과 입력 마감 기준이 정해져 있음무엇을 빠짐없이 처리해야 하는지 기준이 필요함
확정한 입력 범위까지 처리가 끝나고 실패·보류 건이 해소됨재시도 예정이라는 사실만으로 정산 완료를 선언할 수 없음
대상 이벤트 ID마다 원장에 과금 내역이 하나씩 존재함중복 방지와 함께 누락 여부도 확인해야 함
대상 밖의 과금 내역이 없고 각 금액이 일치함건수만 같아도 서로 다른 이벤트나 잘못된 금액일 수 있음

이처럼 원본과 원장을 비교하는 대사는 앞에서 살펴본 재시도와 멱등성이 의도대로 동작했는지 확인하는 절차입니다. 차이가 발견되면 원인을 확인하고 같은 이벤트 ID로 누락분을 재처리한 뒤 다시 대사할 수 있습니다. 중복 방지가 있기 때문에 이미 반영된 이벤트가 함께 재투입돼도 추가 과금을 막을 수 있습니다.

다만 대사 기준인 원본 자체가 불완전하면 원장과 일치하더라도 전체 클릭의 완전성을 증명할 수 없습니다. 입력을 어디까지 신뢰할 수 있는지, 어떤 수집 완료 신호로 마감을 판단할지도 정해야 합니다. 처리기의 Exactly-Once 설정만으로 이 기준이 만들어지지는 않습니다.

여기서 1부의 시간 문제가 다시 연결됩니다. 이벤트가 무한히 늦을 수 있다면 정해진 시각에 그날의 모든 이벤트가 도착했다고 단정할 수 없습니다. 정산을 확정하려면 입력 완료를 확인할 수 있는 조건이 필요하고, 그렇지 않다면 지연 허용 범위와 마감 후 조정 정책을 명시해야 합니다. 조건이 충족되지 않았을 때 정산을 보류할지도 이 설계에 포함됩니다.

환불이나 정책 변경 역시 최초 과금의 재시도와 구별해야 합니다. 원래 과금 ID에 연결된 별도의 조정 내역으로 기록하면 변경의 이유를 남길 수 있습니다.

정확히 한 번을 읽는 기준

Beam과 Flink를 공부하면서 Exactly-Once라는 표현을 볼 때 확인할 기준이 생겼습니다.

단계해결한 문제남는 조건
완료되지 않은 입력 재시도장애로 인한 유실중복 실행의 효과를 막아야 함
입력 위치와 상태를 함께 복구재처리로 집계가 어긋나는 문제외부 쓰기는 별도 보호가 필요함
Sink의 커밋 조율 또는 원장의 멱등성외부 결과의 중복 반영ID, 보존 기간, 수신 측 지원이 맞아야 함

최소 한 번과 최대 한 번을 고려하며 개발할 때는 주로 실패한 요청을 다시 실행할지에 집중했습니다. 이번에 이해하고 싶었던 것은 그다음이었습니다. 다시 실행하는 과정이 있더라도, 매일 정산할 때는 각 과금 대상에 대응하는 비용이 빠짐없이 하나씩 남아 있어야 합니다.

그래서 이제는 Exactly-Once라는 이름을 보면 입력의 범위부터 원장까지 경계를 따라가보려고 합니다. 무엇을 같은 이벤트로 판단하는지, 장애가 나면 무엇을 되돌리는지, 되돌릴 수 없는 효과는 어디에서 중복을 막는지 확인하는 것입니다.

1부에서 윈도우가 어떤 이벤트를 같은 계산에 넣을지 정했다면, 이번 글에서 살펴본 복구와 커밋은 그 계산이 반복돼도 과금 결과를 어떻게 한 번으로 유지할지 정합니다. 이 두 조건이 함께 맞아야 계산된 비용을 신뢰할 수 있습니다.

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