글 목록

알림이 조용해진 게 안정화인 줄 알았다

배포와 큐 포화로 후속 처리가 조용히 사라지던 비동기 리스너 구조를 아웃박스 원장과 폴러로 바꿨다. at-least-once가 낳는 중복은 멱등 선점으로 막았다.

주재범7분 읽기

POV: 배포가 잦았던 주에 알림이 눈에 띄게 조용하다. 좋은 신호일까. 커밋 뒤에 이어져야 하는 처리들이 있다. 알림 발송과 감사 기록, 상대 조직에 세워야 하는 데이터 사본이다. 이걸 비동기 리스너에 태우면 코드는 편해진다. 대신 커밋은 됐는데 후속 처리는 없던 일이 되는 구멍이 생긴다. 우리는 그 구멍의 크기를 실측한 뒤에야 구조를 바꿨고 바꾼 뒤에도 두 번의 사고를 더 겪고서야 규칙이 완성됐다.

한 줄 요약: 배포와 큐 포화로 이벤트가 유실되던 비동기 리스너 구조를 아웃박스 원장과 폴러로 바꿨다. 방향은 at-least-once로 확정하고 유실 대신 감수하기로 한 중복은 멱등 선점으로 막았다. 즉시 처리는 최적화일 뿐이고 보장은 원장과 폴러의 몫이다.

배경

우리 솔루션은 배출·운반·처리 사업자 사이의 주문과 배차, 계약, 정산을 다루는 B2B 물류 플랫폼이다. 주문 상태가 바뀌면 후속 처리가 따라붙는다. 담당자에게 알림톡이 나가고 감사 이력이 남고 상대 조직 화면에 설 사본이 만들어진다. 상태 변경과 후속 처리는 논리적으로 한 몸이지만 물리적으로는 트랜잭션 안에 다 넣을 수 없다. 외부 발송이 트랜잭션을 물고 늘어지기 때문이다.

기존 구조는 커밋 후 비동기 리스너였다. 커밋이 끝나면 인메모리 실행기 큐에 후속 처리를 던지는 방식이다. 구현이 쉽고 응답도 빠르다.

문제를 느낀 계기

"가끔 알림이 안 온다"는 제보가 반복되어 유실 경로를 전수조사했고 세 갈래가 나왔다.

  • 큐 포화: 실행기 큐 용량을 넘는 이벤트는 조용히 버려졌다. 한 트랜잭션에서 1,000건을 발행하는 실측에서 325건만 전달됐다. 그렇다, 675건이 로그 한 줄 없이 사라졌다.
  • 배포 = 유실: graceful 종료가 없어 배포할 때마다 실행기 큐가 통째로 폐기됐다. 배포가 잦은 주에는 알림이 눈에 띄게 조용해졌는데 우리는 한동안 그걸 안정화로 읽었다. 귀환한 폭격기의 총알구멍만 보고 장갑 위치를 정하던 그 오류다. 격추된 비행기는 조사장에 없고 유실된 이벤트는 로그에 없다.
  • 낳는 쪽의 비대: 일괄 배차 한 요청이 이벤트 1,300건을 낳고 있었다. 몸통이 빈 리스너 17개까지 이벤트마다 스레드와 트랜잭션을 태웠다. 유실 이전에 애초에 흘릴 물이 너무 많았다. 큐가 넘칠 각이었다.

제약

  • 알림과 사본, 감사는 유실 불가다. 그런데 전달 보장을 올리면 재전달이 생기고 되돌릴 수 없는 외부 발송인 알림톡은 두 번 나가면 안 된다. 보장과 중복은 맞교환 관계다.
  • 요청 스레드의 응답 시간을 희생할 수 없다.
  • 수백 곳의 발행 지점을 한 번에 고칠 수 없다. 점진 전환이어야 한다.

선택지

1. 동기 발행. 트랜잭션 안이나 직후 요청 스레드에서 후속 처리까지 끝낸다. 장점: 유실이 없다. 단점: 외부 시스템 장애가 본 트랜잭션과 응답 시간을 물고 늘어진다.

2. 메시지 브로커 도입. 장점: 전달 보장을 인프라가 진다. 단점: 운영 부담이 늘고 커밋과 발행 사이의 이중 쓰기 문제는 브로커가 있어도 그대로 남는다.

3. 아웃박스 원장 + 폴러. 장점: 상태 변경과 이벤트 기록이 같은 DB 트랜잭션으로 커밋된다. 커밋됐으면 이벤트도 반드시 있다. 단점: 원장 테이블과 폴러 운영이 생기고 재전달로 인한 중복에 대응해야 한다.

결정

3을 택하고 팀 규칙을 한 문장으로 못 박았다. 커밋 뒤 후속 처리는 아웃박스 원장을 거친다. 즉시 처리는 최적화일 뿐이고 보장은 원장과 폴러의 몫이다. 새 비동기 리스너 추가는 아키텍처 테스트로 막았다. 구현은 네 축이다.

원장이 보장하고 즉시경로는 거든다. 도메인 상태와 이벤트 행이 한 트랜잭션에 커밋되고 커밋 직후 즉시경로인 실행기가 빠르게 처리한다. 실행기가 흘린 몫은 폴러가 원장을 읽어 수렴시킨다. 도입 근거였던 1,000건 실측은 회귀 테스트로 정식화했다. 즉시경로가 용량 초과로 흘려도 최종 1,000/1,000 전달을 기계가 증명한다.

방향은 at-least-once로 확정하고 중복은 멱등 선점으로 막는다. 재전달은 이벤트 단위 재실행이라 알림이 두 번 나갈 수 있다. 발송 전에 전달 단위의 자리를 원자적으로 선점하고 이미 선점된 자리는 접는다.

// 되돌릴 수 없는 외부 발송의 멱등 가드. (topic, claimKey) 유니크 행을 원자적으로 선점한다.
// 판정은 예외 대신 삽입된 행 수로 하고, 새 트랜잭션에서 즉시 커밋해
// 본 처리가 롤백돼도 선점 이력은 남게 한다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean claimDelivery(String topic, String claimKey) {
    int inserted = repository.insertIgnore(topic, claimKey);
    return inserted == 1;   // 0 이면 이미 발송된 전달이라 접는다
}

선점 축의 설계가 핵심이다. 주문 알림은 주문당 일생 1회가 축이다. 문서 재업로드 알림은 이미지×관점이 축이라 재업로드는 정당하게 다시 나가고 같은 전달의 재실행만 접힌다. 중복 차단은 무엇이 같은 전달인지 정의하는 일이었다.

흘릴 물 자체를 줄였다. 빈 리스너 17개를 삭제했다. 묶음 작업의 이벤트는 건별 발사 대신 트랜잭션당 상수 회수로 접고 감사 기록은 청크 묶음 기록으로 바꿨다. 요청 1건이 DB 왕복 3,900회에 6초를 돌던 발행 경로가 상수 수준으로 내려왔다.

소비자의 동시성은 DB 락으로 직렬화했다. 같은 상대의 묶음이 연속 발행되면 소비자가 병렬로 돌며 같은 계약을 동시에 get-or-create 했고 뒤의 것들은 유니크 충돌로 롤백돼 백오프 재시도로만 살아났다. 상대 단위 advisory lock으로 소비자를 직렬화했다. 락을 쥔 커넥션과 본체 트랜잭션은 분리해 본체 커밋 뒤에 락이 풀리는 순서를 강제했다. 같은 트랜잭션에 두면 락 해제가 커밋보다 앞서서 다음 대기자가 커밋 전 세계를 읽는다.

두 번의 사고, 그리고 판정 규칙

전환 뒤에도 사고가 두 번 났다. 계획은 완벽했다. 커밋이 끝났는가를 판정하는 방법만 빼면. 두 사고의 공통 원인이 그 판정이었다.

  • 즉시경로가 산 트랜잭션을 커밋 완료로 오판해 커밋 전에 소비자를 돌렸다. 소비자는 커밋 전 상태를 읽고도 예외 없이 끝나 처리 완료 도장을 받았다. at-least-once는 예외 없는 성공을 구제하지 못한다. 즉시경로를 커밋이 증명된 국면으로 한정하고 커밋 전 데이터가 안 보이는 상황은 침묵 통과 대신 재시도 신호로 던지게 바꿨다.
  • 수리에 쓴 판정 신호인 영속성 컨텍스트의 트랜잭션 합류 여부가 특정 설정 조합에서 뜻이 달라져 같은 오판이 재발했다. 판정 근거를 프레임워크의 간접 신호에서 커밋 콜백 프레임 안인지라는 직접 증거로 교체하고서야 닫혔다.
// 즉시경로 제출은 커밋이 증명된 국면에서만. 그 외에는 원장만 기록하고 폴러가 집는다.
switch (resolvePublishPhase()) {
    case AFTER_COMMIT, NO_TRANSACTION -> { recordLedger(event); submitImmediate(event); }
    case IN_TRANSACTION              -> registerAfterCommit(() -> submitImmediate(event));
    case UNCERTAIN                   -> recordLedgerDetached(event);  // 유예 후 폴러가 전달
}

결과와 한계

  • 얻은 것: 1,000건 프로브 전량 전달. graceful 종료와 폴러 재전달로 배포 중 유실 0. 발행 경로의 왕복과 스레드 부하 급감.
  • 잃은 것: 원장 테이블과 폴러라는 운영물. 즉시경로가 못 태운 이벤트는 폴러 주기만큼 늦는다. 전달 축마다 멱등 키를 설계해야 하는 비용.
  • 되돌릴 조건: 이벤트가 시스템 경계를 넘어 외부로 나가야 하는 시점이 오면 그때 브로커를 원장 뒤에 단다. 원장은 그대로 발행 소스가 된다.

남긴 교훈은 규칙 문장보다 사고 쪽에 있다. 전달 보장은 원장에서 깨지지 않았고 판정에서 깨졌다. 커밋됐는가처럼 당연해 보이는 술어일수록 프레임워크의 간접 신호 대신 직접 증거로 판정해야 한다.

이 글 공유
LinkedInThreadsX