Transactional Outbox 개선기 (2) — 커넥션 점유와 복구 로직 걷어내기

희운·2026년 6월 24일

1편에서는 @Transactional 안에서 Admin 서버로 직접 API를 호출하던 구조를 Transactional Outbox 패턴으로 바꾸고, AFTER_COMMIT 즉시 발송과 30초 폴링 재시도, 그리고 SKIP LOCKED 병렬 조회까지 구현했다. 마지막에 두 가지 문제가 여전히 남아 있었다. 하나는 인스턴스 수와 TPS에 따라 BATCH_SIZE를 매번 손봐야 한다는 것이고, 다른 하나는 폴링 스케줄러가 여전히 트랜잭션 내부에서 API를 호출한다는 것이었다.

2편은 이 두 문제를 이어받아, 1편에서 만든 폴링 구조(V1)를 V2와 V3로 다시 손본 과정의 기록이다.

무엇이 문제였나 (V1 복기)

1편에서 만든 폴링 스케줄러를 V1이라 부르자. 구조를 다시 떠올려보면, process() 메서드가 하나의 트랜잭션 안에서 조회 → 외부 API 호출 → 삭제를 모두 처리한다.

@Transactional
public void process() {
    // 1. Outbox 조회 (SKIP LOCKED)
    List<OutboxEvent> events = outboxRepository.findByCreatedAtBeforeWithSkipLocked(threshold);
    // 2. 각 이벤트마다 Admin API 호출
    ProcessingResult result = processAllEvents(events);
    // 3. 성공한 이벤트 삭제
    deleteProcessedEvents(result.toDelete());
}

1편 마지막에서 "단일 스레드로 호출되지만 이 역시 트랜잭션 내부에서 API 호출을 한다"고 적었던 바로 그 부분이다. 이게 왜 문제인지, 이번엔 숫자로 따져봤다.

트랜잭션이 열려 있는 동안 스케줄러 스레드는 DB 커넥션을 쥐고 있다. 그런데 그 트랜잭션 한가운데에 외부 API 호출이 들어가 있으니, Admin 서버가 느려지거나 죽으면 응답(혹은 timeout)을 기다리는 내내 커넥션을 붙잡게 된다. 문제는 이 점유 시간이 조회한 이벤트 수에 비례한다는 점이다.

1편의 가정대로 미션 기록이 초당 1건씩 쌓이고, 외부 API timeout을 3초로 잡았다고 하자. 최악의 경우 한 번의 process()에서 30건을 처리하므로, 3초(timeout) × 30건, 즉 최대 90초 동안 커넥션 하나를 붙잡는다. 물론 이건 모든 호출이 timeout까지 가는 최악의 가정이다.

여기에 더 고약한 문제가 겹친다. 스케줄링 주기는 30초인데 한 번 처리에 최대 90초가 걸린다면, 이전 실행이 끝나기 전에 다음 실행이 시작된다.

0초   →  90초 : 커넥션 1번 점유
30초  → 120초 : 커넥션 2번 점유 (SKIP LOCKED로 다른 레코드 처리)
60초  → 150초 : 커넥션 3번 점유
...

SKIP LOCKED 덕분에 각 실행이 서로 다른 레코드를 잡으니 중복 처리는 없지만, 그 대신 커넥션 점유가 겹겹이 쌓인다. Admin 장애가 길어질수록 풀려나지 않는 커넥션이 늘어나고, 결국 커넥션 풀 고갈로 이어진다. 스케줄러 하나의 장애가 PeakTime 서비스 전체의 DB 커넥션을 마르게 할 수 있는 구조였던 것이다.

원인은 1편에서부터 일관되게 같았다. DB 커넥션을 쥔 채로 외부 API를 기다린다는 것. 1편에서는 @TransactionalEventListener의 즉시 발송 경로에서 이 문제를 트랜잭션 밖으로 빼는 데 성공했지만, 폴링 재시도 경로에는 여전히 트랜잭션이 남아 있었다. 이번엔 이 폴링 쪽을 손봐야 했다.

V2: 트랜잭션 밖으로 API 호출을 꺼내다

가장 직관적인 해결책은 즉시 발송 경로에서 했던 것처럼, 폴링 경로에서도 외부 API 호출을 트랜잭션 밖으로 빼는 것이었다. 그래서 하나였던 트랜잭션을 둘로 쪼갰다.

Tx1: 이벤트 조회 + 상태를 PROCESSING으로 변경 → 커밋 (커넥션 반환)
     ─ 외부 API 호출 (트랜잭션 없음, DB 커넥션 잡지 않음) ─
Tx2: 성공 시 이벤트 삭제 / 실패 시 상태를 READY로 복구

핵심은 가운데 외부 API 호출 구간이다. 이 시점에는 트랜잭션이 닫혀 있으므로, Admin 서버가 아무리 느려도 DB 커넥션을 점유하지 않는다. V1의 가장 큰 문제였던 커넥션 장기 점유는 이렇게 해결됐다.

그런데 이 구조는 새로운 문제를 데려왔다. 외부 API를 호출하기 전에 이벤트를 PROCESSING 상태로 커밋해버린다는 점이다. 만약 Tx1을 커밋한 직후 외부 API를 호출하기도 전에 서버가 다운되거나, 외부 API는 성공했는데 Tx2(삭제)가 실패한다면 어떻게 될까? 두 경우 모두 이벤트가 PROCESSING 상태에 갇혀버린다. 누가 처리 중인지 알 수 없고, 스스로 READY로 돌아오지도 못한다. 이미 커밋된 상태라 롤백으로 되돌릴 수도 없다.

그래서 갇힌 이벤트를 되살릴 복구 스케줄러(ProcessingRecoveryScheduler)가 필요해졌다. 이 복구 로직은 이벤트가 일정 시간(예: 90초) 이상 PROCESSING 상태에 머물러 있으면 멈춘 것으로 간주하고 READY로 되돌리는 방식으로 동작한다.

여기서 마음에 걸리는 지점이 있었다. 이 복구 로직은 결국 "얼마나 오래 머물렀으면 죽은 것으로 볼 것인가"라는 timeout 기준에 의존한다. 이 임계값이 너무 짧으면 정상 처리 중인 이벤트를 멋대로 되살려 중복 호출이 발생하고(1편에서 멱등성으로 고민했던 바로 그 중복 요청이다), 너무 길면 실제로 멈춘 이벤트가 한참 방치된다. 적절한 값을 찾는 건 결국 추측과 튜닝의 영역이었다.

정리하면 V2는 커넥션 점유는 해결했지만, 그 대가로 PROCESSING이라는 중간 상태와 그것을 감시하는 복구 스케줄러라는 상태 관리 복잡도를 떠안았다. 문제를 옮겨놓은 것에 가까웠다.

구분V1 (1편 폴링 구조)V2
커넥션 점유외부 API 포함 전체 점유조회/삭제 시에만 점유
트랜잭션1개2개로 분리
복구 로직불필요필요 (timeout 기준)
상태 관리단순 (조회/삭제)PROCESSING 상태 추가

왜 복구 스케줄러가 필요했을까

복구 스케줄러의 timeout 임계값을 몇 초로 잡을지 고민하다가, 문득 질문이 바뀌었다. 복구 스케줄러가 왜 필요했지? PROCESSING 상태를 DB에 커밋했기 때문이다. 한번 커밋해버리니 롤백으로 되돌릴 수 없고, 그래서 갇힌 데이터를 누군가 따로 되살려야 했다.

그럼 PROCESSING을 DB에 커밋하지 않으면 되지 않을까? 처리할 이벤트 목록을 DB가 아닌 다른 곳에 잠깐 들고 있을 수만 있다면, "처리 중"이라는 상태를 영속화할 필요도, 그것을 감시할 복구 스케줄러도 필요 없어진다. 그 "다른 곳"으로 Redis를 떠올렸다.

그리고 이 발상은 1편에서 남긴 첫 번째 숙제와도 맞닿아 있었다. 1편에서는 SKIP LOCKED로 여러 인스턴스가 레코드를 나눠 가졌지만, 그러려면 인스턴스 수와 TPS에 맞춰 BATCH_SIZE를 매번 계산해 박아넣어야 했다. 만약 처리할 목록을 Redis라는 공용 공간에 올려두고 모든 인스턴스가 거기서 알아서 하나씩 집어가게 하면, 인스턴스가 몇 대든 배치 사이즈를 신경 쓸 필요가 없어진다.

Redis SET을 중간 큐로

핵심 아이디어는 DB와 외부 API 호출을 완전히 분리하고, 그 사이에 Redis SET을 중간 큐로 두는 것이다. 처리 흐름을 적재(Produce)와 소비(Consume) 두 단계로 나눴다.

폴러는 이 두 단계를 순서대로 호출하는 얇은 진입점이다. 한 인스턴스가 Redis SET에 이벤트를 장전하고, 그다음 모든 인스턴스가 그 SET을 함께 비운다.

@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxPoller {

    private final OutboxProducer producer;
    private final OutboxConsumer consumer;

    @Scheduled(fixedDelay = 3_000)
    public void poll() {
        // 1. 장전 (Producer면 장전, 아니면 대기)
        producer.loadEventsToRedisWithSync();

        // 2. 소비 (모든 인스턴스가 동시에 병렬 처리)
        consumer.consumeAll();
    }
}

Produce — 분산락으로 한 인스턴스만 적재

먼저 Produce 단계에서는 분산락으로 한 인스턴스만 적재한다. 1편에서 다중 인스턴스 환경의 중복 발행을 막을 때, 분산 락을 한 번 후보로 올렸다가 "한 대만 일하게 되어 병렬성을 못 살린다"는 이유로 SKIP LOCKED를 택했었다. 이번에는 분산 락을 조회 단계에만 국한해서 쓴다. 락을 획득한 대표 인스턴스 하나만 Outbox 테이블을 조회해 SADD로 Redis SET에 적재하고, 나머지는 락 대기 상태로 빠진다.

여기서 한 가지 풀어야 할 문제가 있었다. 락을 얻지 못한 인스턴스를 단순히 대기시키기만 하면, 그 인스턴스는 적재가 끝나기도 전에 다음 소비 단계로 넘어가 텅 빈 SET을 보게 될 수 있다. 그래서 락(RLock)에 더해 카운트다운 래치(RCountDownLatch)를 함께 사용했다.

public boolean loadEventsToRedisWithSync() {
    RLock lock = redissonClient.getLock(PRODUCER_LOCK_KEY);
    RCountDownLatch latch = redissonClient.getCountDownLatch(PRODUCER_LATCH_KEY);

    boolean isProducer;
    try {
        isProducer = lock.tryLock(0, 30, TimeUnit.SECONDS);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return false;
    }

    if (isProducer) {
        // 락을 잡은 인스턴스: Producer로서 Redis SET에 장전
        boolean success = false;
        try {
            latch.trySetCount(1);
            doLoadEventsToRedis();
            success = true;
        } catch (Exception e) {
            log.error("Redis 장전 실패", e);
        } finally {
            latch.countDown();   // 장전 완료를 알림
            lock.unlock();
        }
        return success;
    } else {
        // 락을 못 잡은 인스턴스: 장전이 끝날 때까지 대기
        try {
            latch.await(30, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return false;
    }
}

동작을 정리하면 이렇다. tryLock(0, ...)은 대기 시간을 0으로 줘서, 락을 즉시 못 잡으면 기다리지 않고 바로 실패를 반환한다. 그래서 가장 먼저 도착한 한 인스턴스만 isProducer == true가 되어 Producer가 되고, 나머지는 곧장 else 분기로 빠진다.

여기서 일반적인 락과 다른 점이 드러난다. 보통 락은 "못 잡았으면 잡힐 때까지 기다렸다가 임계 영역에 들어간다." 즉 모두가 결국 같은 작업(적재)을 차례로 수행한다. 하지만 여기서는 락을 못 잡은 인스턴스가 임계 영역(적재)에 끝내 들어가지 않는다. 적재는 오직 Producer 한 대만 하고, 나머지는 그 작업이 끝나기를 기다리기만 한다. 락을 "누가 임계 영역에 들어가느냐"가 아니라 "누가 Producer 역할을 맡느냐"를 정하는 용도로 쓴 셈이다.

그 "기다리기"를 담당하는 게 카운트다운 래치다. Producer는 적재를 시작하기 전에 래치 카운트를 1로 세팅하고, 적재가 끝나면(finally) countDown()으로 0으로 만든다. 락을 못 잡은 인스턴스들은 latch.await()에서 이 카운트가 0이 될 때까지 멈춰 있다가, Producer의 적재가 끝나는 순간 한꺼번에 풀려난다. 덕분에 모든 인스턴스가 "SET이 채워진 시점"에 맞춰 소비 단계로 진입하게 된다. 락이 "한 명만 적재하게" 만든다면, 래치는 "나머지가 그 적재를 기다리게" 만드는 셈이다.

조회를 한 인스턴스로 몰아도 병렬성이 죽지 않는 이유는, 무거운 작업인 외부 API 호출을 조회와 분리했기 때문이다. DB 조회는 짧고, 정작 오래 걸리는 API 호출은 뒤이은 Consume 단계에서 모든 인스턴스가 나눠서 한다.

여기서 Redis 자료구조로 SET을 고른 이유가 있다. SET은 같은 값을 중복으로 담지 않으므로, 혹시 같은 이벤트가 두 번 적재되려 해도 SADD가 멱등하게 동작해 중복 장전이 자연스럽게 방지된다. 분산 락으로 1차 방어, SET의 멱등성으로 2차 방어를 한 셈이다.

Consume — 모든 인스턴스가 SPOP으로 병렬 소비

다음으로 Consume 단계에서는 모든 인스턴스가 SPOP으로 병렬 소비한다. 래치가 풀리면 모든 인스턴스가 Redis SET에서 이벤트를 하나씩 꺼낸다. 이때 사용하는 SPOP은 SET에서 임의의 원소를 꺼내면서 동시에 제거하는 원자적 연산이다. 덕분에 여러 인스턴스가 동시에 SPOP을 호출해도 같은 이벤트를 두 번 꺼내가는 일이 없다. 1편에서 SKIP LOCKED로 얻었던 "서로 겹치지 않게 나눠 가져간다"는 성질을, 이번엔 Redis가 대신 보장해주는 것이다.

각 인스턴스는 꺼낸 이벤트로 Admin API를 호출한다. 이 호출 과정에는 트랜잭션도, DB 커넥션도 전혀 관여하지 않는다. Admin 서버가 아무리 느려도 DB 커넥션 풀에는 영향이 없다.

이 구조가 가져다준 변화는 세 가지다.

첫째, 외부 API 호출 시 DB 커넥션을 전혀 잡지 않는다. 1편부터 이어진 커넥션 장기 점유 문제가 구조적으로 사라졌다. 폴링 경로에 남아 있던 마지막 트랜잭션까지 걷어낸 것이다. 1편의 두 번째 숙제가 여기서 해결됐다.

둘째, 복구 스케줄러가 필요 없다. "처리 중" 상태를 DB에 커밋하지 않기 때문이다. 이벤트는 Redis SET에 있거나(아직 처리 전), SPOP으로 꺼내져 처리되거나 둘 중 하나다. V2에서 가장 거슬렸던 timeout 임계값 고민이 문제의 근원과 함께 통째로 사라졌다.

셋째, 인스턴스 수와 무관하게 자동으로 분산 처리된다. SPOP이 원자적으로 분배해주므로, 인스턴스를 늘리면 늘린 만큼 병렬로 소비한다. 1편에서 BATCH_SIZE를 인스턴스 수와 TPS로 계산해 박아야 했던 부담이 사라졌다. 1편의 첫 번째 숙제가 여기서 해결됐다.

세 버전 비교

버전커넥션 점유트랜잭션복구 로직분산 처리
V1외부 API 포함 전체 점유1개불필요SKIP LOCKED
V2조회/삭제 시에만 점유2개 분리필요SKIP LOCKED
V3삭제 시에만 개별 점유없음불필요SPOP 자동 분배

표를 보면 V1에서 V3로 가면서 점유, 트랜잭션, 복구 로직이 차례로 줄어든다. 특히 V3에 와서는 트랜잭션과 복구 로직이 모두 사라졌다. 복잡도를 더해 문제를 막는 게 아니라, 문제의 원인 자체를 구조에서 걷어낸 결과다. 그리고 그 과정에서 1편 말미에 남겼던 두 숙제, 배치 사이즈 수동 조정과 폴링 경로의 트랜잭션도 함께 정리됐다.

번외: Redis 없이도 될까

여기까지가 프로젝트 기간 동안의 개선이었다. V3는 만족스러웠지만, 프로젝트가 끝나고 나서도 마음 한구석에 걸리는 게 하나 있었다. Redis라는 인프라가 추가됐다는 점이다.

V3의 구조를 다시 보면, Redis는 세 가지 역할을 맡고 있다. 분산 락과 래치로 Producer를 조율하고, SET으로 이벤트를 중복 없이 나눠준다. 잘 동작하지만, 이 말은 곧 Outbox 재시도라는 하나의 기능을 위해 Redisson 클라이언트, 락, 래치, SET이라는 부품이 함께 굴러가야 한다는 뜻이기도 하다. 만약 프로젝트에 Redis가 없었다면? 이 기능 하나 때문에 Redis를 새로 들이는 게 맞았을까?

그래서 프로젝트가 끝난 뒤, 추가 인프라 없이 RDB만으로 같은 조건을 충족할 수 있는지 따로 고민해봤다. 충족해야 할 조건은 지금까지의 여정에서 이미 정리되어 있었다.

  1. 여러 인스턴스가 병렬로 재시도를 나눠 처리할 것
  2. 같은 이벤트를 중복으로 폴링하지 않을 것
  3. 외부 API 호출 시 DB 커넥션을 점유하지 않을 것
  4. 인스턴스가 죽어도 별도의 복구 스케줄러 없이 이어받을 것

V3는 이 넷을 Redis로 풀었다. 이번엔 같은 문제를 DB만으로 다시 푸는 것이다.

문제를 다시 쪼개보기

조건 1과 2, 즉 "중복 없이 나눠 갖기"는 사실 1편에서 이미 답을 알고 있다. SELECT FOR UPDATE SKIP LOCKED다. 각 인스턴스가 다른 인스턴스가 락을 잡은 행은 건너뛰고 남은 이벤트만 가져가므로, 별도 조율 없이 이벤트가 인스턴스별로 분배된다.

문제는 조건 3과의 조합이다. 커넥션을 점유하지 않으려면 API 호출 전에 트랜잭션을 커밋해야 하는데, SKIP LOCKED의 행 락은 트랜잭션이 열려 있는 동안만 유효하다. 커밋하는 순간 락이 풀리고, 그 뒤에 이어지는 API 호출 구간은 무방비가 된다. 이 시점에 다른 인스턴스의 스케줄러가 돌면, 아직 전송 중인 이벤트를 그대로 다시 집어간다.

정리하면 이렇다.

Tx: SKIP LOCKED로 조회 → 커밋 (락 해제)
    ─ 외부 API 호출 (락 없음, 무방비 구간) ─   ← 여기서 다른 인스턴스가 같은 이벤트를 재조회할 수 있다
Tx: 성공 시 완료 마킹

V2가 이 무방비 구간을 PROCESSING이라는 상태로 보호하려다가 복구 스케줄러를 떠안았다는 걸 이미 봤다. 상태로 보호하면, 그 상태에 갇힌 이벤트를 되살릴 누군가가 필요해진다. 그래서 이번엔 상태가 아니라 시간으로 보호하기로 했다.

시간으로 보호하기 — 다음 조회 가능 시각

아이디어는 단순하다. Outbox 테이블에 next_poll_at(다음 조회 가능 시각) 컬럼을 하나 둔다. 폴링 쿼리는 이 시각이 지난 이벤트만 조회한다.

그리고 이벤트를 조회할 때, 같은 트랜잭션 안에서 이 시각을 미래로 미루는 UPDATE까지 함께 실행하고 커밋한다.

Tx: SKIP LOCKED로 조회
    + next_poll_at을 200초 뒤로 UPDATE
    → 커밋 (락은 풀리지만, 시각은 이미 미래로 밀려 있음)
    ─ 외부 API 호출 (트랜잭션 없음, DB 커넥션 없음) ─
Tx: 성공 시 COMPLETED로 마킹 / 실패 시 그대로 둠 (200초 뒤 자동 재조회)

커밋으로 행 락이 풀려도, 이 이벤트는 200초 동안 어떤 인스턴스의 조회 조건에도 걸리지 않는다. 락이 사라진 자리를 시각 조건이 이어받는 것이다. 락은 트랜잭션과 함께 죽지만, 커밋된 시각 값은 트랜잭션이 끝나도 남는다는 성질을 이용한 셈이다.

코드로는 조회 로직이 이렇게 바뀐다.

@Transactional
public List<OutboxEvent> fetchAndReserve() {
    // 1. 미완료 상태이면서 next_poll_at이 지난 이벤트를 SKIP LOCKED로 조회
    List<OutboxEvent> events =
            outboxRepository.findPendingWithSkipLocked(LocalDateTime.now(), BATCH_SIZE);

    // 2. 같은 트랜잭션에서 next_poll_at을 뒤로 미룸
    events.forEach(e -> e.postponeNextPollAt(Duration.ofSeconds(200)));

    return events;  // 커밋 → 락 해제, 그러나 시각은 이미 미래
}

호출부는 이 메서드가 커밋된 뒤에 외부 API를 호출한다. 트랜잭션도, 커넥션도 관여하지 않는다.

public void process() {
    List<OutboxEvent> events = fetchAndReserve();   // Tx1: 조회 + 시각 미루기
    ProcessingResult result = sendAll(events);       // 트랜잭션 밖 API 호출
    markAsCompleted(result.toComplete());            // Tx2: 성공분 COMPLETED 마킹
}

성공한 이벤트는 삭제하지 않고 COMPLETED로 마킹만 한다. 폴링 쿼리가 미완료 상태만 조회하므로 완료된 이벤트는 자연히 재시도 대상에서 빠지고, 대신 전송 이력이 테이블에 그대로 남는다. 오래된 완료 데이터의 정리는 재시도 흐름과 무관한 별도의 관심사로 분리할 수 있다.

이 패턴은 사실 낯선 발명이 아니다. AWS SQS의 visibility timeout이 정확히 이 방식으로 동작한다. 메시지를 꺼내가면 일정 시간 동안 다른 소비자에게 보이지 않게 감추고, 그 시간 안에 삭제(ack)하지 않으면 다시 보이게 하는 것. 그걸 컬럼 하나로 RDB 위에 옮겨온 셈이다. SQS의 삭제가 하는 ack 역할을 여기서는 COMPLETED 마킹이 맡는다.

200초는 어떻게 나온 숫자인가

미뤄두는 시간은 "한 배치의 전송이 아직 진행 중인데 다시 조회되는 일"이 없도록 잡아야 한다. 최악의 경우를 계산하면, 배치 사이즈 30건이 전부 timeout(5초)까지 간다고 했을 때 30건 × 5초 = 150초다. 여기에 여유를 더해 200초로 잡았다. 전송이 150초 안에 끝나는 한, 200초짜리 보호막이 걷히기 전에 성공분은 COMPLETED로 마킹되고 실패분만 미완료로 남아 다음 조회에 걸린다.

인스턴스가 죽으면?

이 구조의 진가는 장애 시나리오에서 드러난다. 어떤 인스턴스가 이벤트를 조회해 시각을 미뤄놓고, API를 호출하던 도중 죽었다고 하자.

V2였다면 이벤트가 PROCESSING에 갇혀 복구 스케줄러가 와서 되살려줘야 했다. 하지만 이 구조에서는 아무도 아무것도 할 필요가 없다. 200초가 지나면 next_poll_at 조건이 저절로 풀리고, 이벤트는 다시 조회 대상이 된다. 어느 인스턴스든 다음 폴링에서 자연스럽게 이어받는다. 복구가 별도의 로직이 아니라, 조회 조건 자체에 내장되어 있는 것이다.

물론 공짜는 아니다. 죽기 직전에 API 호출이 이미 성공했는데 완료 마킹만 못 한 경우라면, 200초 뒤 같은 이벤트가 한 번 더 전송된다. at-least-once의 숙명이다. 이 재전송은 1편에서 마련해둔 동아리 서버의 멱등성 키가 걸러낸다. 결국 "중복은 발생할 수 있되, 수신 측에서 무해하게 만든다"는 원칙이 여기서도 마지막 안전망 역할을 한다.

V3와 V4, 무엇을 주고받았나

구분V3 (Redis)V4 (RDB only)
추가 인프라Redis (락 + 래치 + SET)없음 (컬럼 하나 추가)
분배 방식SPOP 원자적 분배SKIP LOCKED
API 호출 중 보호SET에서 이미 제거됨next_poll_at 시각 조건
커넥션 점유삭제 시에만조회/완료 마킹 시에만 (짧게)
복구 로직불필요불필요 (조회 조건에 내장)
인스턴스 다운 시SPOP된 이벤트 유실 가능200초 뒤 자동 재조회 (at-least-once)
배치 사이즈신경 쓸 필요 없음여전히 존재 (보호 시간 산정에 관여)

흥미로운 건 마지막 두 줄이다. V3에서는 SPOP으로 꺼낸 직후 인스턴스가 죽으면 그 이벤트는 SET에도 없고 처리도 안 된, 유실에 가까운 상태가 된다(엄밀히는 Outbox 테이블에 남아 있어 다음 장전 때 되살아나지만, 그 사이의 공백이 있다). V4는 시각 기반이라 이 공백이 200초로 명확히 정의되고, 그 뒤엔 반드시 재조회된다. 대신 V4는 BATCH_SIZE가 보호 시간 계산에 다시 등장한다. 어느 쪽이 우월하다기보다, Redis를 들일 수 있는 환경이냐에 따라 답이 갈리는 트레이드오프였다.

그런데 200초도 결국 매직 넘버 아닌가

여기서 스스로에게 되물을 수밖에 없었다. 이 글 내내 나는 "숫자를 튜닝하는 대신 구조를 바꿔 원인을 없애자"고 말해왔다. 그런데 V4는 200초라는 시간 기준을 다시 들고 왔다. V2의 timeout 임계값을 그렇게 못마땅해했으면서, 이건 뭐가 다른가?

다른 점은 임계값이 틀렸을 때 무슨 일이 벌어지는가다.

V2의 timeout은 틀리면 두 방향 모두 사고였다. 너무 짧으면 정상 처리 중인 이벤트를 되살려 중복 호출이 나고, 너무 길면 갇힌 이벤트가 방치된다. 그리고 그 사고를 수습하려고 복구 스케줄러라는 별도의 장치가 필요했다.

V4의 200초는 틀려도 실패 모드가 하나뿐이다. 너무 짧으면? 전송 중인 이벤트가 재조회되어 중복 전송이 난다 — 멱등성 키가 걸러낸다. 너무 길면? 죽은 인스턴스의 이벤트가 조금 늦게 재처리된다 — 어차피 재시도 경로이므로 지연일 뿐 유실이 아니다. 어느 쪽으로 틀려도 시스템이 망가지지 않고, 별도의 감시 장치도 필요 없다. 같은 "시간 기준"이라도, 그 기준이 정확해야만 안전한 구조와 대충 맞아도 안전한 구조는 다르다. 매직 넘버를 없애지 못한다면, 적어도 매직 넘버가 틀려도 되는 구조를 만들 수는 있는 것이다.

정리하며

이번 시도는 프로젝트의 요구사항 때문이 아니라 순전히 궁금해서 한 것이었다. Redis로 잘 풀어놓고도 "이 인프라가 정말 필요했나"를 다시 물은 건데, 결과적으로 컬럼 하나와 쿼리 조건 하나로 같은 조건을 전부 충족할 수 있었다.

돌이켜보면 V2에서 V3로 갈 때는 "상태를 커밋하지 말자"는 발상이 열쇠였고, 이번 V4는 "락이 못 지키는 구간을 시간이 지키게 하자"는 발상이 열쇠였다. 도구는 달랐지만, 둘 다 문제의 구간을 정확히 짚고 그 구간에 맞는 최소한의 장치를 고르는 과정이었다. 조회 시점의 분배는 SKIP LOCKED가, 커밋 이후 무방비 구간은 시각 조건이, 인스턴스 다운 시의 재처리는 그 시각 조건의 만료가, 마지막 중복은 멱등성 키가 맡는다. 구간마다 필요한 장치가 무엇인지 하나씩 대응시켜보는 경험이었고, 덕분에 "분산 환경에서 일감을 중복 없이 나눠 갖는다"는 문제를 Redis 버전과 RDB 버전 두 갈래로 모두 손에 쥐게 됐다.

profile
기록하는 공간

0개의 댓글