REQUIRES_NEW 가 커넥션 풀을 말린다 — 요청 하나가 커넥션 둘을 쥘 때
로그만은 남기려고 저장 메서드를 별도 트랜잭션으로 뺐어요. 그런데 트래픽이 오르자 CPU 도 DB 도 한가한데 앱이 멈춰요. 요청 하나가 커넥션 둘을 쥐면서 생기는 교착과 피하는 법을 다뤄요.
바깥 작업이 실패해도 로그는 남기고 싶어서, 로그 저장 메서드를 별도 트랜잭션으로 분리했어요. 전파 설정을 새 트랜잭션으로 바꾸는 한 줄이면 됐어요.
기능은 의도대로 동작했어요. 그런데 트래픽이 오르니 앱이 통째로 멈춰요. CPU 도 낮고 DB 도 한가한데 아무 요청도 처리되지 않아요.
스레드 덤프를 보니 전부 커넥션 풀에서 대기 중이에요. DB 가 느린 게 아니라, 우리 앱이 스스로 만든 교착이었어요.
커넥션을 하나 쥔 채로 커넥션을 하나 더 달라고 요청하고 있었던 거죠. 그리고 아무도 쥔 것을 놓지 않으니 영원히 안 풀립니다.
왜 그런가
새 트랜잭션으로 전파한다는 건 바깥 트랜잭션을 잠시 보류하고 별도의 트랜잭션을 연다는 뜻이에요. 별도의 트랜잭션이니 별도의 커넥션이 필요해요.
여기서 문제가 생겨요. 바깥 트랜잭션의 커넥션은 반납되지 않아요. 보류 상태로 계속 쥐고 있어요. 그러니까 요청 하나가 커넥션 두 개를 동시에 점유해요.
풀 크기가 10이라고 해 볼게요. 요청 10개가 동시에 들어와 각자 바깥 트랜잭션으로 커넥션을 하나씩 잡으면 풀이 빕니다. 그 상태에서 10개가 전부 안쪽 트랜잭션을 위해 커넥션을 하나씩 더 요청해요. 줄 게 없어요. 그리고 아무도 자기 커넥션을 놓지 않아요 — 안쪽이 끝나야 바깥이 끝나는데 안쪽이 시작을 못 하니까요.
전형적인 자원 교착이에요. 외부 요인이 하나도 없어요. DB 도 멀쩡하고 네트워크도 멀쩡한데 앱이 스스로 멈춰요.
무서운 건 트래픽에 비례해 갑자기 온다는 점이에요. 동시 요청이 풀 크기보다 적으면 절대 안 나타나요. 개발과 스테이징에서 멀쩡하다가, 동시 요청이 풀 크기를 넘는 순간 전면 정지로 갑니다. 서서히 느려지는 게 아니라 계단식으로 죽어요.
코드로 보면
요청 하나가 커넥션 둘을 쥔다:
@Transactional
void order() {
save(order); // 커넥션 #1 점유 중
logger.write(); // REQUIRES_NEW → 커넥션 #2 요청
} // #1 은 안 놓는다
동시 요청 10 + 풀 크기 10 = 전원 대기, 아무도 진행 못 함
· 1) 애초에 트랜잭션 밖으로 뺀다 (제일 좋다)
order(); // 트랜잭션 종료·커밋
logger.write(); // 그다음에 별도로
· 2) 꼭 안에서 남겨야 하면 아웃박스로
같은 트랜잭션에 로그 행을 넣고 워커가 옮긴다
· 3) 정 써야 하면 풀 크기를 최대 중첩 깊이만큼 확보한다
pool_size >= 동시요청 × 중첩깊이 (권장하지 않는다)✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
이 문제는 새 트랜잭션 전파만의 문제가 아니에요. "커넥션을 쥔 채 커넥션을 더 요청하는" 모든 구조가 같은 위험을 가져요.
여러 데이터소스를 쓰는 경우가 대표적이에요. 주 DB 트랜잭션 안에서 다른 DB 를 조회하면 커넥션 두 개를 동시에 쥐어요. 읽기 복제본을 별도 데이터소스로 잡아 두고 쓰기 트랜잭션 안에서 읽으면 같은 상황이 되고요.
병렬 처리도 위험해요. 트랜잭션 안에서 여러 작업을 병렬로 돌리면서 각자 DB 를 쓰면, 요청 하나가 커넥션을 여러 개 잡아요. 스레드 풀 크기와 커넥션 풀 크기가 맞지 않으면 그 자체로 교착 조건이 됩니다.
그래서 원칙은 이거예요. 커넥션 획득은 중첩되지 않게 한다. 하나를 쓰고 놓고, 그다음에 다음 것을 잡는다.
새 트랜잭션이 정말 필요한 경우도 있어요. 감사 로그처럼 바깥이 실패해도 반드시 남아야 하는 기록이요. 그런데 대부분은 아웃박스로 더 잘 풀려요. 같은 트랜잭션에 기록해 두고 워커가 옮기면, 원자성도 지켜지고 커넥션 중첩도 없어요.
저장점을 쓰는 전파 방식은 또 달라요. 새 커넥션을 안 쓰고 같은 트랜잭션 안에 저장점을 만들어요. 커넥션 중첩이 없으니 이 교착은 안 생겨요. 대신 진짜 독립된 트랜잭션이 아니라서, 바깥이 롤백되면 같이 사라져요. 요구사항이 "바깥과 무관하게 남아야 한다"면 저장점으로는 안 됩니다.
풀 크기를 늘려 해결하려는 유혹이 있는데, 앞에서 본 대로 풀을 키우면 DB 쪽에서 다른 문제가 생겨요. 그리고 중첩 깊이가 예측 불가능하면 얼마나 키워야 하는지도 알 수 없어요. 구조를 고치는 게 맞습니다.
처방
- 트랜잭션 안에서 새 트랜잭션을 열지 마세요. 이 한 줄이 처방의 대부분이에요.
- 부수적인 기록은 트랜잭션 밖으로 빼세요. 순서를 바꾸는 것만으로 해결되는 경우가 많아요.
- 바깥이 실패해도 남아야 하는 기록은 아웃박스로 하세요. 같은 트랜잭션에 넣고 워커가 옮기면 원자성과 독립성을 동시에 얻어요.
- 저장점 방식으로 충분한지 확인하세요. 부분 롤백만 필요하다면 새 커넥션이 필요 없어요.
- 커넥션 획득이 중첩되는 지점을 코드에서 찾아 두세요. 여러 데이터소스, 병렬 처리, 새 트랜잭션 전파가 후보예요. 아키텍처 테스트로 금지 규칙을 만들 수 있습니다.
- 풀 대기 시간을 지표로 보세요. 대기가 급증하면 이 문제의 전조예요. 획득 타임아웃도 짧게 잡아 두면, 교착이 전면 정지 대신 일부 실패로 나타나 원인을 찾기 쉬워집니다.
- 부하 테스트를 풀 크기보다 큰 동시성으로 돌리세요. 이 문제는 그 임계값을 넘어야만 나타납니다.
지금 확인해 보세요
교착인지 느린 건지 구분한다:
· 앱 — 풀 대기가 쌓이는가
hikaricp_connections_pending 이 0 위에서 안 내려오면 의심
hikaricp_connections_active 가 최대치에 붙어 있는지 같이 본다
· DB 는 정말 한가한가 (한가한데 앱이 멈췄으면 자기 교착이다)
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
active 가 적은데 앱이 멈췄다면 DB 문제가 아니다
· 스레드 덤프를 두 번 떠서 비교한다
같은 지점에 그대로 멈춰 있으면 교착
조금씩 움직이면 그냥 느린 것
· 임계값 재현 — 풀 크기보다 큰 동시성으로 부하를 준다
풀 10 이면 동시 20 으로. 그 아래에서는 절대 안 나온다핵심 한 줄커넥션을 쥔 채 커넥션을 더 달라고 하면, 언젠가 아무도 못 받는다.