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 쪽에서 다른 문제가 생겨요. 그리고 중첩 깊이가 예측 불가능하면 얼마나 키워야 하는지도 알 수 없어요. 구조를 고치는 게 맞습니다.

처방

  1. 트랜잭션 안에서 새 트랜잭션을 열지 마세요. 이 한 줄이 처방의 대부분이에요.
  2. 부수적인 기록은 트랜잭션 밖으로 빼세요. 순서를 바꾸는 것만으로 해결되는 경우가 많아요.
  3. 바깥이 실패해도 남아야 하는 기록은 아웃박스로 하세요. 같은 트랜잭션에 넣고 워커가 옮기면 원자성과 독립성을 동시에 얻어요.
  4. 저장점 방식으로 충분한지 확인하세요. 부분 롤백만 필요하다면 새 커넥션이 필요 없어요.
  5. 커넥션 획득이 중첩되는 지점을 코드에서 찾아 두세요. 여러 데이터소스, 병렬 처리, 새 트랜잭션 전파가 후보예요. 아키텍처 테스트로 금지 규칙을 만들 수 있습니다.
  6. 풀 대기 시간을 지표로 보세요. 대기가 급증하면 이 문제의 전조예요. 획득 타임아웃도 짧게 잡아 두면, 교착이 전면 정지 대신 일부 실패로 나타나 원인을 찾기 쉬워집니다.
  7. 부하 테스트를 풀 크기보다 큰 동시성으로 돌리세요. 이 문제는 그 임계값을 넘어야만 나타납니다.

지금 확인해 보세요

교착인지 느린 건지 구분한다:

코드
· 앱 — 풀 대기가 쌓이는가
 hikaricp_connections_pending 이 0 위에서 안 내려오면 의심
 hikaricp_connections_active 가 최대치에 붙어 있는지 같이 본다

· DB 는 정말 한가한가 (한가한데 앱이 멈췄으면 자기 교착이다)
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
 active 가 적은데 앱이 멈췄다면 DB 문제가 아니다

· 스레드 덤프를 두 번 떠서 비교한다
 같은 지점에 그대로 멈춰 있으면 교착
 조금씩 움직이면 그냥 느린 것

· 임계값 재현 — 풀 크기보다 큰 동시성으로 부하를 준다
 풀 10 이면 동시 20 으로. 그 아래에서는 절대 안 나온다

핵심 한 줄커넥션을 쥔 채 커넥션을 더 달라고 하면, 언젠가 아무도 못 받는다.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

트랜잭션·락·DB 운영 글 21편 모두 보기 →