회사에서 Outbox 패턴으로 이벤트를 처리하는 배치 모듈을 만들고 있었는데,Lock wait timeout exceeded; try restarting transaction (SQL Error: 1205) 에러를 만났다...

SELECT ... FOR UPDATE SKIP LOCKED로 조회이후 채팅방이 생성되면 최상단으로 올라와야한다는 요구사항이 추가되었고, 이를 위해 채팅방 생성 시 알림을 보내야 했고, 채팅 모듈에서도 Outbox 이벤트를 발행하도록 구조를 변경했다.
변경된 흐름
1. 주문 생성 -> 주문 생성 Outbox 이벤트 INSERT
2. 배치 모듈이 Outbox 이벤트를 SELECT ... FOR UPDATE SKIP LOCKED로 조회
3. 조회한 이벤트를 처리하면서 채팅 모듈(외부 시스템)으로 REST API 호출
-> 채팅방 생성 요청
4. 채팅 모듈은 채팅방 생성 후 채팅방 생성 Outbox 이벤트 INSERT

그리고 변경된 흐름 4번에서 새로운 Outbox 이벤트를 INSERT 시점에 실패하는 것을 확인했다.
처음에는 FOR UPDATE SKIP LOCKED를 사용하고 있고, 배치 모듈에서 @Scheduled로 처리해 던일 워커로 동작하고 있어서 왜 락 경합이 발생할까라고 생각했다.
이놈의 문제 원인은 락 경합이 아니라, 락을 너무 오래 잡고 있어서였다...
Dispacther 클래스(Outbox 이벤트 처리하는 클래스)에서 이벤트를 처리하는 트랜잭션 범위 안에서 채팅 모듈에 REST API를 요청하고(이때 SKIP LOCKED로 락 점유), 채팅 모듈에서 Outbox 이벤트 테이블에 INSERT를 하려고해서 문제가 된 것이다...
FOR UPDATE SKIP LOCKED로 조회해 락 획득그리고 채팅 모듈에서는 채팅방 생성 후 새로운 Outbox 이벤트를 INSERT를 시도한다.
하지만 1번에서 트랜잭션이 아직 커밋이 되지 않았기 때문에 INSERT 쿼리는 락을 기다리게 되고, lock_wait_timeout을 초과하면서 Lock wait timeout exceeded (SQL Error: 1205)에러가 발생한 것이다.
다시 정리해보면
락이 서로 충돌해서 발생한 문제가 아니라, 락을 잡은 상태에서 외부 API 호출하고 트랜잭션이 지나치게 길어진 문제였다.
문제 원인을 다시 정리해보면 락을 잡은 트랜잭션 안에서 외부 시스템을 호출한 것이다.
배치 모듈에서 FOR UPDATE SKIP LOCKED로 조회한 트랜잭션에서 채팅 모듈에 REST API를 호출해 DB 락 점유 시간이 길어져 문제가 된 것 이기 때문에 DB 락이 필요한 구간과 채팅 모듈에 REST API호출하는 구간을 분리하기로 했다.
// OutboxEventDispatch 클래스
@Transactional
public void dispatch() {
// 1. 아웃박스 이벤트 조회
List<OutboxEventEntity> events = outboxEventReader.readRetryableEvents();
if (events.isEmpty()) {
return;
}
// 2. 각 이벤트를 대상 시스템으로 발행
for (OutboxEventEntity event : events) {
try {
OutboxEventProcessor publisher = outboxEventProcessorRegistry.resolve(event.getType());
publisher.publish(event);
event.markAsSent();
} catch (Exception e) {
handleEventFailure(event, e);
}
}
}
기존 Outbox 이벤트 상태는 PENDING, SENT, FAILED, DEAD_LETTER 밖에 없었고, PROCESSING 상태를 추가했다.
그 이유는 외부 시스템을 호출하는 동안 다른 배치가 같은 이벤트를 다시 조회하는 것을 방지하기 위함이다.
따라서 SELECT ... FOR UPDATE SKIP LOCKED로 이벤트를 조회하고 해당 이벤트를 바로 PROCESSING 상태로 변경하고 바로 트랜잭션을 커밋하면 SELECT ... FOR UPDATE SKIP LOCKED로 조회할 때 PENDING, FAILED 상태만 조회해 중복 처리 없이 트랜잭션 분리 가능하기 때문이다. (아직 배치 모듈은 @Scheduled 기반으로 단일 워커라 중복 처리 문제가 직접적으로 발생하지는 않지만, 향후 스케일 아웃 가능성을 고려해.. 안전한 방향으로 생각해봤다)
@Transactional
public List<OutboxEventEntity> fetchAndMarkProcessing() {
List<OutboxEventEntity> events = outboxEventReader.readRetryableEvents();
events.forEach(OutboxEventEntity::markAsProcessing);
return events;
}
즉, PROCESSING 상태는 락 대신 이벤트를 선점하기 위한 논리적인 장치인 것이다.
그리고 dispatch 메서드의 @Transactional 어노테이션을 제거해 트랜잭션 내에서 외부 시스템을 호출되지 않도록 수정했다.
따라서, 네트워크 지연, 타임아웃, 외부시스템장애 등에 대해서 DB 락에는 영향을 안받게 된다.
public void dispatch() {
// 1. 아웃박스 이벤트 조회 후 PROCESSING 상태 변경 후 바로 커밋
List<OutboxEventEntity> events = transactionService.fetchAndMarkProcessing();
if (events.isEmpty()) {
return;
}
// 2. 각 이벤트를 대상 시스템으로 발행
for (OutboxEventEntity event : events) {
try {
OutboxEventProcessor publisher = outboxEventProcessorRegistry.resolve(event.getType());
publisher.publish(event);
transactionService.markAsSent(event.getId());
} catch (Exception e) {
transactionService.markAsFailedOrDead(event.getId(), e);
}
}
}
변경된 코드의 흐름은 아래와 같다.
FOR UPDATE SKIP LOCKED로 조회PROCESSING 상태로 변경 후 즉시 커밋SENT / FAILED / DEAD_LETTER 상태로 변경public class OutboxEventDispatcher {
private final OutboxEventTransactionService transactionService; // 중요 !!!
private final OutboxEventProcessorRegistry outboxEventProcessorRegistry;
public void dispatch() {
// 1. 아웃박스 이벤트 조회 후 PROCESSING 상태 변경 후 바로 커밋
List<OutboxEventEntity> events = transactionService.fetchAndMarkProcessing();
if (events.isEmpty()) {
return;
}
// 2. 각 이벤트를 대상 시스템으로 발행
for (OutboxEventEntity event : events) {
try {
OutboxEventProcessor publisher = outboxEventProcessorRegistry.resolve(event.getType());
publisher.publish(event);
transactionService.markAsSent(event.getId());
} catch (Exception e) {
transactionService.markAsFailedOrDead(event.getId(), e);
}
}
}
}
Outbox 패턴을 처음 적용할 때, FOR UPDATE SKIP LOCKED로 조회하면 락 경합을 없앨 수 있을 것 같다고 생각했는데 락 경합 문제 뿐 아니라 트랜잭션 안에 외부 시스템을 호출하게 되면 트랜잭션이 길어져 문제가 될 수 있다는 것을 알게 되었다....
따라서 트랜잭션 범위를 잘못 잡으면 장애로 이어질 수 있다는 것을 알게 되었다....!!!