기술

REQUIRES_NEW를 썼더니 자기 자신과 데드락이 났습니다

2026.08.137분 읽기

외식 프랜차이즈 ERP를 운영하며 겪은 일입니다. 관리자와 점주가 간헐적으로 로그아웃된다는 보고가 이어졌고, 운영 로그로 세어 보니 하루 3~6건이 상시로 발생하고 있었습니다. 매번 재현은 안 되는데 당한 사람은 분명히 있는, 전형적인 간헐 장애였습니다.

스택트레이스를 추적하니 토큰 갱신(refresh) API가 특정 경로에서 500을 내고 있었습니다. 프론트는 갱신 실패를 받으면 세션을 정리하고 로그인 화면으로 보내도록 되어 있으니, 사용자 입장에서는 어느 순간 갑자기 튕기는 것으로 보였습니다.

이상한 점은 응답 시간이었습니다. 실패한 요청은 하나같이 약 50초를 기다린 끝에 죽었습니다. 발생은 간헐적인데 실패까지의 지연은 일정하다 — 이 조합은 락 타임아웃의 냄새입니다. 실제로 예외도 비관적 락 예외, Lock wait timeout이었습니다.


50초의 정체 — 잠근 쪽도 기다린 쪽도 우리

이 서비스의 refresh 토큰에는 회전과 재사용 탐지가 붙어 있습니다. 이미 폐기된 토큰이 다시 제출되면 탈취로 간주하고, 그 토큰 가족(family) 전체를 폐기하는 보안 장치입니다. 문제는 이 폐기 로직의 트랜잭션 구성이었습니다.

refresh()가 현재 토큰 행을 FOR UPDATE로 잠급니다. 같은 family에 속한 행입니다.

재사용 탐지가 발동하면 가족 폐기(revokeFamily)를 REQUIRES_NEW로 호출합니다.

자식 트랜잭션이 family 전체를 UPDATE하다가 부모가 쥔 행 락에 막힙니다.

부모는 자식이 끝나길, 자식은 부모의 락이 풀리길 기다립니다. 50초 락 타임아웃 후 예외, 500, 강제 로그아웃.

다운로드.png

처음에는 다른 요청과의 경합을 의심했습니다. 하지만 파보니 동시 요청은 필요하지 않았습니다. 단일 refresh 호출 하나만으로 재현되는, 코드 경로에 박제된 구조적 데드락이었습니다. 보통의 데드락이 두 트랜잭션의 타이밍 문제라 재현이 어려운 것과 정반대입니다.


REQUIRES_NEW는 새 트랜잭션이 아니라 별도 커넥션입니다

이 사건의 핵심 오해는 어노테이션 이름에 있습니다. REQUIRES_NEW라고 하면 '새 트랜잭션을 시작한다' 정도로 읽히지만, 실제로 일어나는 일은 커넥션 풀에서 새 커넥션을 꺼내 완전히 독립된 DB 세션으로 붙는 것입니다.

DB 입장에서 부모와 자식은 남남입니다. 같은 스레드에서 호출했다고 해서 락을 공유하거나 이어받지 않습니다. 부모가 FOR UPDATE로 잠근 행은 자식에게도 그냥 남이 잠근 행이고, 자식은 그 앞에서 얌전히 대기합니다. 그런데 부모는 자식 메서드가 리턴해야 다음으로 진행할 수 있으니, 서로가 서로를 — 사실상 자기 자신을 — 기다리는 원이 완성됩니다.

원래 설계 의도 자체는 정당했습니다. 가족 폐기는 보안 이벤트라 본 흐름이 롤백돼도 반드시 남아야 하고, 그래서 REQUIRES_NEW를 선택한 것입니다. 의도는 맞았지만, 부모가 쥔 락과 겹치는 행을 자식 커넥션이 건드리는 순간 자기충돌이 됩니다.

다운로드 (2).png


수정 — 의도는 보존하고 데드락만 제거

첫 번째 수정은 revokeFamily의 REQUIRES_NEW를 제거하고 호출자와 같은 트랜잭션으로 합치는 것이었습니다. 락 주체가 하나가 되니 데드락이 성립할 여지 자체가 사라집니다.

남는 숙제는 원래 의도였습니다. '폐기는 롤백돼도 남아야 한다'를 어떻게 보존하느냐입니다. 답은 noRollbackFor였습니다. 재사용 탐지 시 던지는 예외 타입을 noRollbackFor로 지정하면, 예외가 올라가도 트랜잭션은 롤백되지 않고 폐기 UPDATE는 그대로 커밋됩니다. REQUIRES_NEW 없이도 같은 보장을 얻을 수 있습니다.


락 예외를 500에서 401로 — 실패의 등급 설계

두 번째 수정은 실패의 등급이었습니다. 혹시 남을 수 있는 락 예외를 try/catch로 잡아 500 대신 401로 내리도록 바꿨습니다. 같은 실패라도 500과 401은 사용자 피해가 전혀 다릅니다. 500은 50초 멈춘 뒤 정체불명 오류로 튕기는 경험이고, 401은 즉시 재로그인으로 이어지는 경험입니다.

적용 후 50초씩 걸리던 실패는 사라졌습니다. 다만 401 강등은 원인을 가릴 수 있어, 해당 경로의 발생률을 카운트와 로그로 계속 지켜보고 있습니다. 그리고 이 수정은 어디까지나 '50초+500'을 없앤 것입니다. 재사용 탐지 경로에 애초에 왜 진입하는가 — 회전 응답 유실 같은 후보 — 는 별도 후속 과제로 분리해 기록했습니다. 증상 제거와 원인 규명을 한 덩어리로 섞으면 후속이 증발하기 때문입니다.

덧붙이면, 간헐 로그아웃이라는 증상을 만난 것이 이번이 처음은 아니었습니다. 같은 증상이 refresh 토큰 미사용과 외부 4xx 오인 매핑에서 나온 적도 있습니다.

다운로드 (1).png


같은 함정을 피하기 위한 체크리스트

REQUIRES_NEW는 별도 커넥션입니다. 부모가 FOR UPDATE로 잠근 행을 자식이 건드리면 동시 요청 없이도 자기 데드락이 납니다.

간헐 장애인데 실패까지의 지연이 일정하다면(이번에는 50초) 락 타임아웃부터 의심합니다.

롤백돼도 남아야 하는 쓰기는 REQUIRES_NEW 말고도 달성 수단이 있습니다. 같은 트랜잭션 + noRollbackFor, 또는 커밋 후 이벤트입니다.

락 예외를 그대로 500으로 흘리지 않습니다. 사용자 행동으로 이어지는 상태 코드(여기서는 401)로 강등할 수 있는지 봅니다.

증상 제거와 원인 규명을 분리해 기록합니다. 섞으면 후속 과제가 사라집니다.

REQUIRES_NEW 자체가 나쁜 도구는 아닙니다. 감사 로그처럼 락 경합이 없는 쓰기에는 여전히 유효합니다. 금지해야 할 것은 어노테이션이 아니라, 부모가 잠근 행을 자식 커넥션이 건드리는 조합입니다. 락과 트랜잭션 경계를 함께 쓰는 코드라면, '지금 이 문장은 어느 커넥션으로 DB에 붙어 있는가'를 먼저 그려보는 편이 50초짜리 수업료보다 쌉니다.

#운영의기술#트랜잭션#REQUIRES_NEW#데드락#스프링#락타임아웃#간헐장애#토큰갱신