1편에서는 @Transactional 안에서 Admin 서버로 직접 API를 호출하던 구조를 Transactional Outbox 패턴으로 바꾸고, AFTER_COMMIT 즉시 발송과 30초 폴링 재시도, 그리고 SKIP LOCKED 병렬 조회까지 구현했다. 마지막에 두 가지 문제가 여전히 남아 있었다. 하나는 인스턴스 수와 TPS에 따라 BATCH_SIZE를 매번 손봐야 한다는 것이고, 다른 하나는 폴링 스케줄러가 여전히 트랜잭션 내부에서 API를 호출한다는 것이었다.
2편은 이 두 문제를 이어받아, 1편에서 만든 폴링 구조(V1)를 V2와 V3로 다시 손본 과정의 기록이다.
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의 즉시 발송 경로에서 이 문제를 트랜잭션 밖으로 빼는 데 성공했지만, 폴링 재시도 경로에는 여전히 트랜잭션이 남아 있었다. 이번엔 이 폴링 쪽을 손봐야 했다.
가장 직관적인 해결책은 즉시 발송 경로에서 했던 것처럼, 폴링 경로에서도 외부 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라는 공용 공간에 올려두고 모든 인스턴스가 거기서 알아서 하나씩 집어가게 하면, 인스턴스가 몇 대든 배치 사이즈를 신경 쓸 필요가 없어진다.
핵심 아이디어는 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 단계에서는 분산락으로 한 인스턴스만 적재한다. 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으로 병렬 소비한다. 래치가 풀리면 모든 인스턴스가 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편 말미에 남겼던 두 숙제, 배치 사이즈 수동 조정과 폴링 경로의 트랜잭션도 함께 정리됐다.
1편을 마치며 "이 두 가지 문제점에 대해서는 추가로 고민해봐야겠다"고 적었는데, 그 고민을 따라온 것이 이번 2편이었다.
이번 개선에서 가장 인상 깊었던 건 V2에서 V3로 넘어가는 발상의 전환이었다. V2 단계에서 나는 "복구 스케줄러의 timeout을 몇 초로 잡아야 할까"를 고민하고 있었다. 그건 주어진 구조 안에서 더 나은 파라미터를 찾는 질문이었다. 하지만 한 발 물러서서 "애초에 이 복구 스케줄러가 왜 필요하지?"라고 물었을 때, 비로소 진짜 원인이 보였다. PROCESSING 상태를 DB에 커밋한다는 결정이었다. 그 결정을 바꾸자, 그 위에 쌓아 올렸던 복구 로직과 timeout 고민이 한꺼번에 사라졌다.
돌이켜보면 1편의 BATCH_SIZE 튜닝도, 2편 V2의 timeout 튜닝도 결국 같은 종류의 고민이었다. 주어진 구조를 그대로 둔 채 "숫자를 얼마로 맞출까"를 묻는 것. 문제를 만났을 때 "이걸 어떻게 막을까"를 먼저 떠올리기 쉽고, 그래서 보호 장치를 덧대거나 매직 넘버를 조정하는 방향으로 가게 된다. 하지만 때로는 "이 문제가 왜 생기는 구조지"를 묻고 그 원인을 제거하는 편이 훨씬 단순하고 단단한 답을 준다는 걸 이번에 배웠다. 좋은 설계는 더 정교한 방어막을 쌓는 게 아니라, 방어할 필요 자체를 없애는 쪽에 가깝다는 생각이 들었다.