inblog logo
|
vlogue
    System/NetworkDBJava & Spring BootJava & JPA & Hibernate

    Transactional, REQUIRES_NEW가 만든 보이지 않는 데드락

    가비지 컬렉터에 수집될 개발자가 할 수 있는 어이없는 발상
    김주혁's avatar
    김주혁
    Sep 17, 2026
    Transactional, REQUIRES_NEW가 만든 보이지 않는 데드락
    Contents
    psql 두 개로 재현하기
     
    SELECT ... FOR UPDATE로 잠근 행을 같은 메서드 안에서 @Transactional(propagation = REQUIRES_NEW)로 다시 저장하면, Application의 특정 기능들을 영영 죽일 수 있습니다.
    바깥 트랜잭션은 안쪽 호출이 끝나기를 기다리고, 안쪽 트랜잭션은 바깥쪽의 행 잠금이 풀리기를 기다리기 때문입니다.
     
    다음은 실제 propagation을 생각없이 남발하다 생긴 문제인데,
     
    주문을 수락할 일이 발생한 경우에,
    • 주문 관련 API가 타임아웃(10초) 안에 응답하지 못하여 503 에러가 발생했습니다.
    • 데이터 베이스 Transaction 조회 결과 pg_stat_activity에서 한 세션이 idle in transaction 상태로 멈춰 있었습니다. 마지막 SQL은 ... FOR NO KEY UPDATE 조회였습니다.
    • outbox_event에 대한 UPDATE와 장바구니 잠금 조회가 그 세션을 기다렸고, 뒤이은 요청들은 PESSIMISTIC_WRITE으로 잠긴 Lock을 대기하는 문제가 발생했습니다.
    • 같은 시각 애플리케이션 로그에는 "주문이 PAID라 복구를 건너뜀" 로그가 1ms 차이로 찍혀 있었고,
    • DB의 idle_in_transaction_session_timeout, lock_timeout, statement_timeout이 모두 0(무제한)이어서 아무것도 이 대기를 끊을 수 없었는데
    • 하필 서버 재배포(종료) 시점과 겹쳐 스케줄러 종료 대기 시간 초과, JDBC 연결 오류, 게이트웨이 503까지 쭉 이어진 이후로 특정 기능이 마비되는 현상이 발생했습니다.
     

    예제로 살펴보면,

     
    주문 취소 후 재고를 복구하는 Outbox 이벤트를 주기적으로 처리하는 스케줄러가 있고, 대기 중인 이벤트를 FOR UPDATE SKIP LOCKED로 잠그고, 주문 상태에 따라 처리한 뒤 이벤트 상태를 저장합니다.
    queryDsl .selectFrom(qOutbox) .where(/* aggregateType, eventType, PENDING, processAt 조건 */) .orderBy(qOutbox.createdAt.asc()) .setLockMode(LockModeType.PESSIMISTIC_WRITE) .limit(limit) .fetch();
    아웃박스 이벤트는 이미 잠긴 outbox 행을 만나면 건너뛰지 않고 기다리는 방식이었는데,
     
    상위 PESSIMISTIC_WRITE를 호출한 Transaction 내부에선 호출한 쪽의 트랜잭션과 무관하게 "반드시 남겨야 하는" 기록용으로 만든 메서드라는 느낌으로, REQUIRES_NEW를 사용하여 아웃박스를 저장하고 있었습니다.
    @Transactional(propagation = Propagation.REQUIRES_NEW) public void save(OutboxEvent event) { repository.save(event); }
     
    이제 어떤 일이 벌어질까요? 다음과 같은 예제 코드가 있다고 치면,
    @Service @RequiredArgsConstructor public class StockRestoreScheduler { private final OutboxEventRepository outboxRepository; private final OutboxWriter outboxWriter; private final OrderReader orderReader; private final StockService stockService; @Scheduled(fixedDelay = 5_000) @Transactional(timeout = 30) public void recover() { List<OutboxEvent> events = outboxRepository.findPendingForUpdate(100); // 행 잠금 획득 for (OutboxEvent event : events) { handle(event); } } void handle(OutboxEvent event) { try { Order order = orderReader.getById(event.getOrderId()); if (order.getStatus() == OrderStatus.PAID) { event.markPublished(); outboxWriter.save(event); // (1) REQUIRES_NEW return; } stockService.restore(order); event.markPublished(); outboxWriter.save(event); // (2) REQUIRES_NEW } catch (Exception e) { event.markFailed(e.getMessage()); outboxWriter.save(event); // (3) REQUIRES_NEW } } }
    겉보기에는 상태 기록만큼은 확실히 남기자는 방어적인 코드처럼 보입니다만, (1)~(3)이 모두 바깥 트랜잭션이 이미 잠근 바로 그 행을 다른 트랜잭션에서 UPDATE하는 문제가 발생하게 됩니다.
     

    원인

     
    진짜 원인은 무엇이었을까요?
    REQUIRES_NEW는 바깥 트랜잭션을 일시 중단할 뿐 커밋하지 않습니다. 중단된 트랜잭션의 DB 커넥션은 열린 채로 잠금을 그대로 쥐고 있고, 안쪽 트랜잭션은 커넥션 풀에서 다른 커넥션을 빌려 새 DB 세션에서 실행됩니다.
    그림으로 보면 이런 느낌…
    그림으로 보면 이런 느낌…
    1. 바깥 트랜잭션(세션 A)이 FOR UPDATE로 outbox 행을 잠급니다.
    1. outboxWriter.save() 호출 → 세션 A는 중단되고, 새 트랜잭션(세션 B)이 시작됩니다.
    1. 세션 B가 같은 행을 UPDATE하려다 세션 A의 잠금을 기다립니다.
    1. 세션 A는 Java 스레드가 save()에서 돌아오기를 하염없이 기다립니다.. 그런데 그 스레드는 세션 B의 응답을 기다리고 있습니다.
     
    고리의 세 화살표 중 DB가 볼 수 있는 것은 B → A 하나뿐입니다. A → 스레드 → B 구간은 JVM 안에 있어 Database는 알 수 없습니다.
    대기의 흐름 상 절반은 DB 안에, 나머지 절반은 Java 호출 스택에 있습니다. 그래서 다음 장치들이 모두 동작하지 않습니다.
    • PostgreSQL 데드락 감지: DB가 보기에 세션 A는 잠금을 기다리는 게 아니라 클라이언트의 다음 명령을 기다리는 idle in transaction입니다. 잠금 대기 그래프에 사이클이 없으므로 데드락으로 판정하지 않습니다.
    • 바깥 메서드의 @Transactional(timeout = 30): Spring의 트랜잭션 타임아웃은 그 트랜잭션에서 실행되는 쿼리에 적용됩니다. 독립적으로 시작된 REQUIRES_NEW 트랜잭션에는 이어지지 않습니다.
    • DB 타임아웃: 모두 0이었으므로 세션 B의 UPDATE는 무기한 대기 상태에 머무르게 됩니다.
    실패 경로 (3)도 같은 패턴이라는 점도 중요합니다. lock_timeout을 걸어 세션 B를 실패시키더라도, catch 블록이 다시 REQUIRES_NEW로 같은 행을 저장하려 하므로 똑같이 블락됩니다.
    서버 종료 시점과 겹친 것도 우연에 가깝습니다. 스레드가 영원히 끝나지 않으니 graceful shutdown이 스케줄러 종료를 기다리다 시간 초과됐고, 그 과정의 연결 오류가 더 눈에 띄었던 것 뿐이라고 생각 됩니다. 종료가 자기 대기를 만든 것이 아니라, 자기 대기가 종료를 방해한 쪽에 가깝습니다.
     

    psql 두 개로 재현하기

    Java 없이도 DB 쪽 절반은 쉽게 재현됩니다. 터미널 두 개를 열고 같은 순서로 실행해 보자.
    -- 준비 CREATE TABLE outbox_event ( id bigserial PRIMARY KEY, status text NOT NULL ); INSERT INTO outbox_event (status) VALUES ('PENDING');
    -- 세션 A: 바깥 트랜잭션 BEGIN; SELECT * FROM outbox_event WHERE status = 'PENDING' FOR UPDATE SKIP LOCKED; -- 여기서 멈춘다. 다음 명령을 보내지 않는다 (= save() 호출 중인 Java 스레드)
    -- 세션 B: REQUIRES_NEW 트랜잭션 BEGIN; UPDATE outbox_event SET status = 'PUBLISHED' WHERE id = 1; -- 대기 시작. 세션 A가 커밋/롤백하기 전까지 끝나지 않는다
    실제 애플리케이션에서는 세션 A에 COMMIT을 보낼 주체가 세션 B의 결과를 기다리는 바로 그 스레드입니다. 그래서 누구도 고리를 끊을 수 없습니다. 세 번째 터미널에서 다음 절의 진단 쿼리를 실행하면 운영에서 본 모습과 같은 결과가 나옵니다.
     

    어떻게 디버깅할까요?

    같은 증상이 보이면 쿼리 로그보다 누가 누구를 막고 있는지부터 확인하는 것이 좋습니다(경험담).
    SELECT pid, state, xact_start, state_change, now() - state_change AS idle_for, pg_blocking_pids(pid) AS blocked_by, left(query, 60) AS last_query FROM pg_stat_activity WHERE datname = current_database() AND (state = 'idle in transaction' OR cardinality(pg_blocking_pids(pid)) > 0) ORDER BY xact_start; -- 위 재현 상태에서의 결과... pid | state | idle_for | blocked_by | last_query ------+---------------------+----------+------------+----------------------------------- 4101 | idle in transaction | 00:03:12 | {} | SELECT * FROM outbox_event ... FOR UPDATE 4102 | active | 00:03:12 | {4101} | UPDATE outbox_event SET status = ...
    • idle in transaction인데 다른 세션의 blocked_by에 등장하는 pid가 범인 후보입니.
    • query 컬럼은 마지막으로 실행한 SQL일 뿐입니다. 그 세션의 Java 스레드가 지금 무엇을 하는지는 보여 주지 않습니다. 이번 경우 마지막 SQL은 장바구니 조회였지만 실제로는 save() 호출 안에서 멈춰 있었습니다.
    • xact_start와 state_change를 애플리케이션 로그 시각과 맞춰 보면 어느 코드 경로인지 좁힐 수 있습니다. 실제 장애에서는 idle 전환 시각과 "PAID라 건너뜀" 로그가 1ms 차이였습니다.
    Java 쪽 절반은 스레드 덤프(jstack <pid> 또는 /actuator/threaddump)로 확인합니다. 스케줄러 스레드가 아래처럼 OutboxWriter.save 안에서 소켓 읽기를 기다리고, 그 아래 스택에 StockRestoreScheduler.recover가 있다면 확정으로 볼 수 있습니다.
    DB 세션과 Java 스레드를 직접 연결할 방법이 없으면 이런 추론에 기대야 합니다. 최선의 선택은, 잘 코드를 짜는 거겠지만, AI도 개발자도 알 수 없는 순간도 분명히 존재합니다.
     

    어떻게 수정할까요?

    해결은 단순한데, 행을 잠근 트랜잭션에서 상태도 함께 바꾸고 함께 커밋하거나 롤백하면 됩니다. 기존 save()는 다른 곳에서 독립 기록 용도로 쓰고 있을 수 있으니 의미를 바꾸지 않고, 현재 트랜잭션 전용 메서드를 추가했습니다.
    @Component @RequiredArgsConstructor public class OutboxWriter { private final OutboxEventRepository repository; /** 호출자와 독립적으로 커밋해야 하는 기록 전용. 잠근 행에는 절대 쓰지 않는다. */ @Transactional(propagation = Propagation.REQUIRES_NEW) public void save(OutboxEvent event) { repository.save(event); } /** 잠금을 획득한 트랜잭션 안에서만 호출한다. 트랜잭션이 없으면 예외. */ @Transactional(propagation = Propagation.MANDATORY) public void saveInCurrentTransaction(OutboxEvent event) { repository.save(event); } } void handle(OutboxEvent event) { try { Order order = orderReader.getById(event.getOrderId()); if (order.getStatus() == OrderStatus.PAID) { event.markPublished(); outboxWriter.saveInCurrentTransaction(event); return; } stockService.restore(order); event.markPublished(); outboxWriter.saveInCurrentTransaction(event); } catch (Exception e) { event.markFailed(e.getMessage()); outboxWriter.saveInCurrentTransaction(event); } }
     

    체크포인트

    세 경로를 모두 같은 트랜잭션으로 묶으면 실패 기록에 함정이 하나 생깁니다. stockService.restore()가 @Transactional(REQUIRED) 프록시를 거쳐 RuntimeException을 던지면, 바깥 트랜잭션이 rollback-only로 표시됩니다.
    그러면 catch에서 저장한 FAILED 상태도 함께 롤백되고, 커밋 시점에UnexpectedRollbackException이 발생하게 됩니다.
    실패 기록까지 확실히 남겨야 한다면 다음 중 하나를 고릅시다.
    • 이벤트 하나마다 짧은 트랜잭션을 연다 (TransactionTemplate으로 "잠금 → 처리 → 상태 저장 → 커밋"을 한 건씩). 한 건의 실패가 다른 건에 번지지 않습니다.
    • 처리 부분만 세이브포인트(Propagation.NESTED)로 감싸 실패 시 그 부분만 되돌리고, 실패 상태는 바깥 트랜잭션에서 저장합니다.
    • 롤백 후 잠금이 풀린 다음에 별도 트랜잭션으로 실패를 기록합니다. 이때는 같은 행을 다시 잠그고 상태를 확인해야 합니다.
    어느 쪽이든 원칙은 같은데, 잠금을 쥔 트랜잭션이 살아 있는 동안 그 행을 다른 트랜잭션에서 쓰지 않는다는 점 입니다.
     
    별개로, DB 타임아웃이 모두 0인 것은 별개로 고쳐야 할 문제입니다. 애플리케이션 계정 단위로 걸어 두면 다른 작업(마이그레이션, 배치 계정 등)에 영향을 주지 않습니다.
    -- 예시 ALTER ROLE app_user SET idle_in_transaction_session_timeout = '60s'; ALTER ROLE app_user SET lock_timeout = '5s'; ALTER ROLE app_user SET statement_timeout = '30s';
    값은 서비스 특성에 맞게 정하면 됩니다. 그리고 이런 설정은 오히려 또 다른 엣지를 발생시킬 수 있는데
    • idle_in_transaction_session_timeout이 바깥 세션 A를 끊으면 잠금이 풀리고, 기다리던 세션 B의 PUBLISHED가 독립적으로 커밋됩니다. 바깥 트랜잭션의 다른 변경(재고 복구 등)은 롤백되는데 이벤트만 "처리 완료"로 남을 수 있습니다.
    • lock_timeout은 세션 B를 실패시키지만, 앞서 본 것처럼 catch 경로가 같은 패턴이면 다시 막힙니다.
    TCP keepalive나 커넥션 풀의 maxLifetime도 같은 범주입니다. 죽은 커넥션을 정리해 주지만 코드 결함을 대신 해결하지 않습니다.
     

    정리하면

    • REQUIRES_NEW는 "독립적인 커밋"을 주지만, 대가로 다른 DB 세션을 씁니다. 바깥이 잠근 행을 그 세션에서 건드리면 스스로를 기다리게 됩니다. 가장 기본적인 지식인데, 까먹으면 참사가 발생합니다.
    • 이 문제는 DB와 Java에 반씩 발을 걸치고 있어서 DB 데드락 감지도, 바깥 @Transactional(timeout)도 풀어 주지 못합니다. DB에서는 idle in transaction으로만 보이기 때문에 디버깅이 끔찍합니다.
    • 진단은 pg_stat_activity + pg_blocking_pids()로 차단 관계를 먼저 보고, 스레드 덤프로 Java 쪽 절반을 확인하는게 좋습니다.
    • 해결은 잠금과 상태 저장을 같은 트랜잭션에 두는 것입니다. MANDATORY로 그 전제를 강제할 수 있습니다.
    • DB 타임아웃은 반드시 걸어 두되, 원인 해결이 아니라 피해 제한 장치로 여기는게 맞습니다.
     
    안다고 다 개발할 때 참고할 수 있는게 아니니, AI와 함께 코드 검토는 신중하게..
    Share article
    Contents
    psql 두 개로 재현하기

    vlogue

    RSS·Powered by Inblog