지금 일련의 서비스 흐름을 정리하자면 아래와 같다.
orders 테이블에 주문 정보를 저장한다.outbox_events 테이블에 해당 주문 정보에 대한 이벤트도 저장한다.OutboxDispatcher가 outbox_events 테이블을 폴링해서 외부 시스템을 전송한다.
이렇게 할 수밖에 없던 이유는 “주문 정보를 저장하는 것” 과, “이벤트를 외부 시스템에 전송하는 것” 을 한 번에 묶어서 원자적으로 처리할 수 없었기 때문이었다. 근데 여기서 만약 outbox_events 테이블이 무진장 커질 수도 있다는 우려도 있었다. 그럼 OutboxDispatcher의 폴링 성능에도 문제가 생기지 않을까?
아래는 OutboxRepository의 findAndLockPending() 메서드의 일부다.
public List<OutboxEvent> findAndLockPending(int limit, String workerId) {
String sql = """
SELECT id, event_type, aggregate_type, aggregate_id, payload, status,
retry_count, next_run_at, last_error, created_at
FROM outbox_events
WHERE status = 'PENDING'
AND (next_run_at IS NULL OR next_run_at <= CURRENT_TIMESTAMP)
ORDER BY id
LIMIT ?
FOR UPDATE SKIP LOCKED
""";
...
이 쿼리가 바로 OutboxDispatcher 폴링의 핵심이다. 요약하자면, PENDING 상태인 이벤트 중에서 실행 예정 시각이 임박했거나 혹은 즉시 실행해야 하는 것들 중에서 현재는 BATCH_SIZE를 50으로 설정했기 때문에 50개만 가져온다는 내용이다.
생각해보니 외부 결제 파트너사의 서버에 장애가 나서 우리 쪽에서 이벤트 전송이 계속 밀리거나, 혹은 이후에 복구되면서 next_run_at이 미래로 날아가버린 이벤트들이 무지막지하게 쌓일 수 있었다. 그리고 다시 정상적으로 복구된다면 그 시점부터는 폴링해야 하는 이벤트들이 쌓이기 시작하겠지?
바로 이런 상황에 대해 성능 개선의 여지가 있어 보였다. 왜냐하면 그 정상적으로 복구된 시점으로부터 생성되기 시작한 이벤트들은 id를 기준으로 outbox_events의 끝자락에 몰려 있을 것이 뻔했기 때문이다.
성능 향상을 한눈에 보기 위해 극단적으로 생각해봤다. 100만 건의 이벤트를 폴링하는데 계속 장애가 지속돼서 이벤트 전송이 다 밀리고, 마지막 50개만 처리해야 하는 상황을 가정했다. 폴링 조건을 다시 생각해보면, status로 PENDING만 솎아 내고, next_run_at로 범위를 좁히고, 그 중에서 id를 기준으로 50개만 가져온다. 따라서 1차적으로는 인덱스를 추가하기 전에 성능을 측정하고, 이후에 아래 복합 인덱스를 추가한 다음에 성능을 측정했다.
CREATE INDEX idx_outbox_pending_pick
ON outbox_events (status, next_run_at, id);
100만 건을 생성하고, 마지막 50개만 즉시 폴링해야 하는 환경을 세팅하고, 아래 쿼리를 통해 성능을 측정했다.
EXPLAIN ANALYZE
SELECT id
FROM outbox_events
WHERE status='PENDING'
AND (next_run_at IS NULL OR next_run_at <= CURRENT_TIMESTAMP)
ORDER BY id
LIMIT 50;
/*
'-> Limit: 50 row(s)
(cost=4.58 rows=2) (actual time=321..321 rows=50 loops=1)\n
-> Filter: ((outbox_events.`status` = \'PENDING\')
and ((outbox_events.next_run_at is null)
or (outbox_events.next_run_at <= <cache>(now()))))
(cost=4.58 rows=2) (actual time=321..321 rows=50 loops=1)\n
-> Index scan on outbox_events using PRIMARY
(cost=4.58 rows=50)
(actual time=0.0596..135 rows=1e+6 loops=1)\n'
*/
/*
'-> Limit: 50 row(s)
(cost=4.58 rows=2) (actual time=359..359 rows=50 loops=1)\n
-> Filter: ((outbox_events.`status` = \'PENDING\')
and ((outbox_events.next_run_at is null)
or (outbox_events.next_run_at <= <cache>(now()))))
(cost=4.58 rows=2) (actual time=359..359 rows=50 loops=1)\n
-> Index scan on outbox_events using PRIMARY
(cost=4.58 rows=50)
(actual time=0.493..176 rows=1e+6 loops=1)\n'
*/
보다시피 실제 실행 시간은 약 321ms, 359ms가 나왔고, 스캔한 row 수는 1e + 6으로 그냥 풀 테이블 스캔했다. 평균 시간은 대략 340ms 정도 걸렸다고 하자.
인덱스 추가 후 결과는 아래와 같았다.
EXPLAIN ANALYZE
SELECT id
FROM outbox_events
WHERE status='PENDING'
AND (next_run_at IS NULL OR next_run_at <= CURRENT_TIMESTAMP)
ORDER BY id
LIMIT 50;
/*
'-> Limit: 50 row(s)
(cost=11.6 rows=50)
(actual time=0.0684..0.0729 rows=50 loops=1)\n
-> Sort: outbox_events.id, limit input to 50 row(s) per chunk
(cost=11.6 rows=50) (actual time=0.0677..0.0698 rows=50 loops=1)\n
-> Filter: ((outbox_events.`status` = \'PENDING\')
and ((outbox_events.next_run_at is null)
or (outbox_events.next_run_at <= <cache>(now()))))
(cost=11.6 rows=50) (actual time=0.0232..0.0508 rows=50 loops=1)\n
-> Index range scan on outbox_events using idx_outbox_pending_pick over
(status = \'PENDING\' AND NULL <= next_run_at <= \'2026-01-30 23:20:34\')
(cost=11.6 rows=50)
(actual time=0.0209..0.0401 rows=50 loops=1)\n'
*/
/*
'-> Limit: 50 row(s)
(cost=11.6 rows=50)
(actual time=0.104..0.108 rows=50 loops=1)\n
-> Sort: outbox_events.id, limit input to 50 row(s) per chunk
(cost=11.6 rows=50)
(actual time=0.102..0.104 rows=50 loops=1)\n
-> Filter: ((outbox_events.`status` = \'PENDING\')
and ((outbox_events.next_run_at is null)
or (outbox_events.next_run_at <= <cache>(now()))))
(cost=11.6 rows=50)
(actual time=0.0451..0.0793 rows=50 loops=1)\n
-> Index range scan on outbox_events using idx_outbox_pending_pick over
(status = \'PENDING\' AND NULL <= next_run_at <= \'2026-01-30 23:20:45\')
(cost=11.6 rows=50)
(actual time=0.0417..0.0678 rows=50 loops=1)\n'
*/
실제 실행 시간은 약 0.0729ms, 0.108ms가 나왔다. 조건에 맞는 인덱스 구간만 빠르게 읽어 왔으니까 어찌 보면 당연하지만 그래도 충격적이다… 평균적으로 걸린 시간은 약 0.09ms다.
인덱스 하나 추가했다고 340ms에서 0.09ms? 거의 3700배??? 성능이 개선되었다. 분명 outbox_events 테이블이 작을 때는 아무 차이를 느끼지 못 했겠지만, Outbox는 구조상 이렇게 충분히 데이터가 쌓이기 쉽기 때문에 규모가 커지만 폴링 성능이 무조건 문제가 되는 것 같다. 특히 이런 극단적인 상황에서는 인덱스 없이 LIMIT를 사용하면 쿼리가 매우 비싸지는 것을 체감했다. 따라서 Outbox 폴링 쿼리는 서비스 초반부터 인덱스 설계를 해두는 것이 바람직하다고 판단했다.