
Outbox로 비즈니스 트랜잭션과 메일 발송을 분리하고, 발송 과정은 선점 → 발송 → 정리로 분리하여 SMTP 발송 중 DB 커넥션을 점유하지 않도록 설계했습니다.
또한 attempt_count와 dead 상태를 통해 반복 실패한 메일을 격리하여 무한 재시도를 방지했습니다.
예약 확정 또는 취소 트랜잭션에서 메일 발송 정보를 JSON으로 만들어 email_outbox 테이블에 INSERT합니다.
예약/취소와 동일한 트랜잭션으로 커밋되므로, 비즈니스 트랜잭션이 롤백되면 아웃박스 데이터도 함께 롤백됩니다.
초기 상태는 next_attempt_at = 현재 시각, attempt_count = 0, dead = false로 설정합니다.
워커가 1초마다 email_outbox를 조회하여 아래와 같은 조건을 만족하는 메일을 최대 20건씩 가져옵니다.
next_attempt_at <= 현재 시각
AND dead = false
선점된 행은 next_attempt_at이 미래 시각으로 변경되어 있으므로 다음 폴링 대상에서 제외됩니다.
선점 - 짧은 트랜잭션
조회한 행의 next_attempt_at을 현재 시각 + 60초로 변경하고 즉시 커밋합니다.
→ 발송 중인 메일을 다른 워커가 가져가지 못하도록 60초의 선점 윈도우를 확보하고, DB 커넥션을 즉시 반납합니다.
SMTP 발송 - 트랜잭션 밖
DB 트랜잭션과 커넥션을 유지하지 않은 상태에서 실제 SMTP 서버로 메일을 발송합니다.
→ SMTP 응답을 기다리는 동안 DB 커넥션이 장시간 점유되지 않습니다.
정리 - 짧은 트랜잭션
발송 결과에 따라 아웃박스 상태를 정리합니다.
성공 → 아웃박스 행 삭제
실패 → attempt_count + 1
attempt_count < 3 → 그대로 유지하여 60초 후 재시도attempt_count >= 3 → dead = true로 변경하여 격리( 재시도 중단 )SELECT ... FOR UPDATE는 조회한 행에 배타적 잠금(X Lock)을 걸고, 트랜잭션이 커밋되거나 롤백될 때까지 유지합니다.
따라서, 다른 트랜잭션은 해당 행을 수정/삭제하거나 FOR UPDATE로 잠그기 위해 대기하게 됩니다. 즉, 읽은 값을 수정하는 사이에 다른 트랜잭션이 개입하지 못하도록 보장합니다.
반면, FOR UPDATE SKIP LOCKED는 이미 다른 트랜잭션이 잠근 행을 기다리지 않고 건너뜁니다. 잠기지 않은 행만 조회하고 해당 행에는 현재 트랜잭션이 잠금을 획득합니다.
SELECT *
FROM email_outbox
WHERE next_attempt_at <= :now
AND dead = false
ORDER BY next_attempt_at
LIMIT 20
FOR UPDATE SKIP LOCKED
여러 워커가 동시에 실행하더라도 이미 다른 워커가 선점한 메일은 건너뛰고, 각 워커가 서로 다른 메일을 선점합니다. 따라서 워커 간 불필요한 락 대기를 줄이면서 메일 발송 작업을 병렬로 처리할 수 있습니다.
현재 구조에서의 실효성
현재는 단일 워커로 동작하기 때문에 동시에 같은 행을 선점하려는 상황이 없어 SKIP LOCKED가 실제로 행을 건너뛸 일은 없습니다.
현재 성능을 위해 필요한 기능이라기보다는, 향후 워커를 여러 대로 확장하더라도 별도의 구조 변경 없이 중복 선점을 방지할 수 있도록 미리 적용한 것입니다. 단일 워커에서는 사실상 FOR UPDATE와 동작 차이가 없지만, 향후 수평 확장을 고려하면 낮은 비용으로 동시성을 확보할 수 있습니다.
FOR UPDATE SKIP LOCKED의 특징과 주의점
결과 집합의 일관성이 보장되지 않습니다.
일반 조회에서는 일관된 결과를 보장하지 않아 부적합하지만, 저희 Outbox는 조회 자체가 목적이 아니라 처리할 메일을 중복 없이 선점하는 것이 목적이기 때문에 SKIP LOCKED가 적합하다고 판단했습니다.
처리 순서가 엄격하게 보장되지 않습니다.
행을 시간순으로 정렬하더라도 앞선 행이 다른 워커에 의해 잠겨 있으면 건너뛰기 때문에 순차 처리는 보장되지 않아 확인/취소 메일의 순서가 뒤바뀔 수 있지만, 두 메일이 동시에 Outbox에 존재하는 경우가 드물고 각 메일에 상태 확정 시각을 포함해 순서와 관계없이 상태를 명확히 파악할 수 있도록 했습니다.
락은 트랜잭션 범위이므로 짧게 유지해야 함
FOR UPDATE로 획득한 락은 트랜잭션이 커밋되거나 롤백될 때까지 유지됩니다. 따라서 우리 구조에서는 메일을 선점하는 트랜잭션과 실제 SMTP 발송을 분리하고, 선점 정보만 기록한 뒤 즉시 커밋하도록 구성했습니다.
만약 SMTP 발송까지 같은 트랜잭션에서 수행한다면, 메일을 발송하는 동안 DB 커넥션과 락을 계속 점유하게 되므로, 이는 앞서 개선한 DB 커넥션 장기 점유 문제로 이어집니다.
적절한 인덱스가 필요함
조건에 맞는 행을 효율적으로 찾지 못하면 많은 행을 스캔해야 하므로 불필요한 DB 부하가 발생할 수 있습니다. 따라서 우리 구조에서는 next_attempt_at 인덱스를 사용해 발송 대상 후보를 효율적으로 찾도록 했습니다.