늦게 도착한 이벤트를 버리고 있었다: 순서를 사는 대신 병합을 순서에 무관하게 만들었다
목차
같은 활동 로그로 만든 온라인 컨텍스트와 오프라인 재계산이 순서가 뒤집힌 조건에서 20명 전원 갈라졌습니다(일치율 0%). 병합 규칙이 “현재보다 순번이 크지 않은 이벤트는 버린다”였기 때문에, 늦게 도착한 이벤트는 로그에는 남고 컨텍스트에서는 영구히 사라졌습니다. 사용자별 순서 보장을 사는 대신 병합 자체를 순서에 무관하게 바꿔 100%로 맞췄습니다. 같은 실험에서 목록 길이로 세던 창 집계는 60%만 맞았고 재는 도구도 같은 잘림을 물려받고 있었습니다.
결론부터
- 사용자별 순서 보장을 전제로 늦은 이벤트를 버리던 병합 규칙이 온라인 컨텍스트를 오염시켰습니다.
- 적용된
seq집합에서 최신 N개를 다시 고르는 방식으로 바꾸자 온라인·오프라인 일치율이 0%에서 100%가 됐습니다. - 목록 길이로 추정하던 창 집계는 60%만 맞아 이벤트 유형별 카운터로 교체했습니다.
온라인과 오프라인이 달라지는 조건
홈 추천은 사용자의 최근 활동(본 상품, 담은 상품)을 Redis에 컨텍스트로 모아 둡니다. 같은 활동 로그로 오프라인에서 다시 계산한 값과 온라인 컨텍스트가 같아야 추천을 재현하고 검증할 수 있습니다. 이 글은 둘이 어디서 갈라지는지 재고 원인을 둘로 나눠 하나씩 고친 과정을 다룹니다.
활동마다 클라이언트가 발생 시점에 순번(seq)을 붙입니다. 컨텍스트는 최근 20개(max-items)만 담고 7일 TTL로 만료됩니다. 측정은 합성 로그로 로컬에서 했습니다.
어디서 갈라지는지 조건을 나눠 쟀다
| 조건 | 컨텍스트 일치율 | 무엇이 일어났나 |
|---|---|---|
| 기준 | 100% | |
| 순서 역전 주입 | 0.0% | 20명 전원, 항목 영구 유실 |
| 인프로세스 전달 | 33.3% | 6명 중 4명이 항목을 잃음 |
| Kafka 전달 | 100% | |
| 활동이 20개를 넘음 | 목록 100%, 창 집계 60% |
중복은 입력 단계(409 응답)와 재배달(컨슈머 오프셋 되감기) 양쪽에서 막혔습니다. 남은 문제는 순서와 집계였습니다.
인프로세스 전달은 브로커 없이 앱 안의 비동기 스레드 풀로 이벤트를 적용하는 방식입니다. 브로커 없이 뜨는 장점이 있어 기본값 후보였습니다. 이 결과로 기본값을 Kafka로 되돌렸습니다.
유실의 원인은 둘이었다
인프로세스 전달의 33.3%에는 원인 후보가 둘 있었습니다.
- 적용 순서 역전: 스레드 풀 안에서 낮은 순번이 나중에 적용되면 병합 규칙이 그것을 버린다. 설계대로의 동작이다.
- 읽고 고치고 쓰는 사이의 덮어쓰기: 높은 순번을 쓴 뒤에, 그보다 먼저 낮은 순번 상태를 읽어 둔 스레드가 자기 결과로 덮어쓴다. 결함이다.
둘은 증상이 같아서(항목 하나가 사라지고 목록이 밀림) 한 번의 측정으로 갈라지지 않았습니다. 그래서 2번부터 없앴습니다. 컨텍스트 갱신을 Java의 “읽기, 비교, 쓰기”에서 Redis Lua 스크립트 한 번으로 바꿔 읽기부터 쓰기까지를 원자적으로 만들었습니다. 측정 전에 판정 기준을 적었습니다. 두 전달 방식 모두 100%가 되면 원자화로 충분하고 인프로세스가 100%가 아니면 남은 원인은 순서다.
| 조건 | 원자화 전 | 원자화 후 |
|---|---|---|
| 인프로세스 전달 | 33.3% | 83.3% |
| Kafka 전달 | 100% | 100% |
덮어쓰기는 사라졌고 유실은 남았습니다. 남은 원인은 순서였습니다.
순서를 살 것인가, 순서가 필요 없게 만들 것인가
| 선택지 | 얻는 것 | 잃는 것 |
|---|---|---|
| 사용자별 직렬 처리 보장 | 순서가 지켜진다 | 큐와 샤딩 키가 늘고 대기 상한으로 지불하던 지연을 다시 지불한다 |
| 지금 규칙을 두고 한계를 문서화 | 코드 변경 없음 | 원인이 남는다. 순서 역전 조건 0%가 그대로다 |
| 병합을 순서에 무관하게 | 전송이 순서를 보장할 필요가 없어진다 | 컨텍스트의 의미가 바뀐다 |
마지막을 골랐습니다. 병합은 이제 “적용된 순번의 집합에서 큰 것 20개”를 유지합니다. 들어온 이벤트의 순번이 낮아도 버리지 않고 그 자리에 끼워 넣고 같은 순번이 이미 있으면 아무것도 하지 않습니다. 집합이 같으면 도착 순서가 달라도 결과가 같습니다.
이렇게 바꾸면 따라오는 성질이 있습니다.
- 재배달이 자연스럽게 멱등이 됩니다. 같은 순번은 두 번 쓰이지 않습니다.
- 온라인 컨텍스트가 로그의 함수가 됩니다. 같은 로그를 다시 흘리면 같은 값이 나오므로 오프라인 재계산과 대조하는 검증 방법이 정의상 성립합니다.
- 인프로세스 전달이 다시 후보가 됩니다.
판단의 근거는 순번의 뜻에 있었습니다. 순번은 클라이언트가 발생 시점에 붙입니다. 늦게 도착한 이벤트는 우리가 늦게 알게 된 것이지 일어나지 않은 일이 아닙니다. 버리는 규칙은 “언제 알았는가”를 “무엇이 일어났는가”보다 앞세우는 선택이었습니다.
| 조건 | 처음 | 원자화 후 | 순서 무관 병합 후 |
|---|---|---|---|
| 순서 역전 주입 | 0.0% | 측정 안 함 | 100.0% |
| 인프로세스 전달 | 33.3% | 83.3% | 100.0% |
| Kafka 전달 | 100% | 100% | 100.0% |
대가는 지연이나 저장량이 아니라 의미로 나타났습니다. 컨텍스트는 “최근에 적용된 것”에서 “최근에 일어난 것”이 됐고 그래서 지나간 시점의 항목이 뒤늦게 끼어들 수 있습니다. 20개를 넘을 때 버리는 대상도 “가장 먼저 적용된 것”에서 “가장 낮은 순번”으로 바뀌었습니다. 값의 모양이 달라져 기존 키와 호환되지 않으므로 키에 판 번호를 붙였습니다(ctx:{userId}:v2). 7일 TTL의 캐시라 옮기지 않고 새 키로 시작했습니다.
목록 길이로 세던 집계
같은 실험에서 한 가지가 더 드러났습니다. 활동이 30건인 사용자에서 목록은 100% 맞는데 “최근에 상의를 몇 번 봤나” 같은 창 집계는 60%만 맞았습니다. 집계를 20개로 잘린 목록의 길이로 세고 있었기 때문입니다. 그릇이 하나라서 잘림과 집계가 같이 망가졌습니다.
오프라인 대조 도구도 목록 길이로 창을 세고 있었습니다. 재는 쪽과 재지는 쪽이 같은 잘림을 물려받은 것입니다. 앱만 고쳤다면 좋아진 것을 볼 수 없었을 것입니다.
컨텍스트에 활동 유형별 카운터를 함께 담게 했습니다. 목록과 달리 잘리지 않습니다. 별도 Redis 키로 두면 목록과 카운터 중 하나만 쓰이고 다른 하나가 실패하는 순간이 생기므로 같은 값 안에서 같은 Lua 스크립트로 갱신합니다. 중복 제거가 병합 전에 걸리므로 재배달은 두 번 세지 않고 늦게 온 이벤트는 한 번 셉니다. 집계도 순서에 무관해졌습니다.
대조 도구는 유형별 카운터를 비교하도록 바꿨고 같은 조건을 새 도구로 전부 다시 쟀습니다. 창 집계 일치율은 60%에서 100%가 됐습니다. 정의가 바뀐 열의 옛 수치와 새 수치를 같은 표에 섞지 않으려고 이전 리포트의 창 집계 값은 목록 길이 기준이었다고 밝혀 두었습니다.
이 결정이 말하지 않는 것
- 창은 슬라이딩 창이 아니라 TTL 경계입니다. “최근 7일”이 아니라 “활동이 이어진 최근 7일”이고 활동이 끊기면 창이 초기화됩니다.
- 순번을 믿을 수 없게 되면(클라이언트가 임의 값을 보내는 경우) 이 규칙이 흔들립니다. 지금은 인증된 사용자가 자기 컨텍스트만 바꾸므로 영향이 자기 안에 머뭅니다.
- 집계는 적용된 이벤트를 셉니다. 소비가 못 따라온 구간은 목록과 똑같이 빠집니다. 집계가 더 정확해진 것이 아니라 잘림에 강해진 것입니다.
- 합성 로그와 로컬 단일 장비의 결과입니다.
참고
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.