HikariCP 커넥션 Deadlock 이슈 해결하기

🔥Log·2025년 6월 3일
0

스프링

목록 보기
22/22

⚠️ 문제 발생


🤔 Pending...

회사에서 열심히 개발하던 중, QA환경에 배포된 기능에서 이상한 증상을 발견했다.
어떤 서버 API를 여러번 사용하면, 이후에 들어오는 모든 API 요청들이 Pending 상태가 되는 것이였다. 🤔

그래서, 문제가 되는 API를 분석해보았다.

🧐 무슨 API지?

문제가 됐던 API는 엄청난 로직이 담긴 API가 아닌, 평범한 조회 API였다.
또, 목록 조회하는 것도 아닌 데이터의 PK를 사용하여 한 건의 데이터를 조회하는 API였고, 이는 절대 성능 이슈 또는 잘못 작성된 쿼리의 문제가 아니라고 생각이 됐다.

또, Pending됐던 API들에서 사용된 쿼리들이 결국에는 Connection Timeout에러가 발생하는 것을 보고, 정확한 원인은 모르겠지만, 어디선가 꼬여버려서 Deadlock이 발생한 것 같다고 직감적으로 생각됐다.

🧐 JPA, MyBatis

현재 개발중인 프로젝트는 초기에는 MyBatis를 DB 접근 기술로 사용하였고, 점진적으로 JPA로 전환하는 과정에 있었다. 그래서, 프로젝트에는 MyBatis와 JPA가 혼재되어 설정이 되어 있는데 문제가 됐던 API는 하나의 API에서 JPA와 MyBatis가 같이 사용되고 있었다.

결론부터 얘기하자면, 이렇게 JPA와 MyBatis를 하나의 기능에서 사용한 것이 DB 커넥션에 대해서 Deadlock을 유발한 것인데, 자세한 내용을 알아보자 🔥



💻 프로젝트의 설정


본격적으로 커넥션 Deadlock에 대해서 이야기하기 전에 프로젝트가 어떻게 구성되어 있는지 간단히 이야기해보겠다.

DB 관련 설정

프로젝트는 Spring boot 서버이며, HikariCP를 커넥션 풀 구현체로 사용하고 있었다.
QA 환경에서 최대 Pool 사이즈는 2로 작게 설정되어 있었다.

💡 이렇게 작은 사이즈로 커넥션 수를 설정한 이유는 개인적인 취향(?)이 녹아있는 것인데, Production 환경을 제외한 환경에서는 의도적으로 작은 스펙으로 환경을 구성해서, 시스템을 일부러 Fragile하게 만들어서 다양한 이슈를 빠르게 찾기 위함이다.

정리하자면 아래와 같이 구성되어 있는 것이다.

- 프레임워크: Spring boot 3.x
- 커넥션 풀: HikariCP (최대 Pool size: 2)
- DB 접근: JPA, MyBatis (각각의 트랜잭션 매니저가 세팅되어 있음)

자, 이제 본격적으로 실제로 겪은 커넥션 Deadlock에 대해서 하나씩 알아보자.



☠️ 커넥션 Deadlock


1) 문제가 됐던 API의 동작

문제가 됐던 API는 대략 위와 같은 방식으로 동작한다.
여기서 핵심은 JPA로 조회한 데이터와 MyBatis로 조회한 데이터를 조합하여, 최종 응답값을 클라이언트에 전달하는 부분이다.

JPA와 MyBatis를 같이 사용한 적은 처음이라서, 이 둘을 같이 사용했을 때 어떤 문제가 있을지 몰랐는데 아래와 같이 동작하는 것으로 확인하였다.

2) JPA와 MyBatis를 같이 사용할 때의 동작

  1. 하나의 @Transactional로 감싸진 메서드에서 JPA로 먼저 데이터를 조회하고, 그 다음 MyBatis로 데이터를 조회할 때에는 JPA에서 사용한 커넥션을 사용하지 않고, 별도의 커넥션을 커넥션 풀에서 꺼내서 사용한다. → 하나의 트랜잭션에서 2개의 커넥션이 사용된다.
  2. 하나의 @Transactional로 감싸진 메서드에서 MyBatis로 먼저 데이터를 조회하고, 그 다음 JPA로 데이터를 조회할 때에는 MyBatis에서 사용한 커넥션을 JPA에서 사용한다. → 하나의 커넥션에서 1개의 커넥션이 사용된다.

1번을 보면, 트랜잭션은 최대한 Atomic하게 동작해야하는 것이 이상적인데, 하나의 트랜잭션에서 2개의 커넥션이 사용된다는 것 부터가 일단 뭔가 구리구리한 냄새가 난다 🤔

3) 1 트랜잭션, 2커넥션 = Deadlock

하나의 트랜잭션에서 2개 이상의 커넥션을 사용한다면, 어떤 일이 벌어질까?

'그냥 2개 갖다 쓰면 되는 거 아니야?'라고 생각할 수도 있지만, 커넥션에 대한 Deadlock이 2000% 발생하게 된다.

왜 그런지 살펴보자.
먼저 이해를 쉽게 하기 위해서 아래와 같은 가정을 해보겠다.

- 커넥션 풀의 최대 커넥션 수는 2로 설정되어 있다.
- 'API X' 하나를 처리하기 위해서는 2개의 커넥션이 필요하다.
- 'API X'는 하나의 트랜잭션으로 처리된다.
- 'API X'에 2개의 요청이 들어올 것인데, 이 두 요청은 정확히 동일한 순간에 들어온다.

4) Deadlock이 발생하는 과정

그림이 좀 복잡하지만, 한 트랜잭션을 처리하기 위해서 커넥션이 2개가 필요한 기능에, 동시에 2개의 요청이 들어오면 위와 같은 상황이 발생한다.

각 요청은 최초에 필요한 커넥션은 획득할 수 있지만, 두번째로 필요한 커넥션은 서로 반환하지 않고 있는 커넥션 때문에 커넥션풀에 잔여 커넥션이 없어서, 커넥션 획득을 위한 대기 시간이 끝나면 커넥션을 획득하지 못하고 처리에 실패하게 된다.

내가 겪은 문제도 이와 정확히 동일한 상황이었는데, 그렇다면 어떻게 이 문제를 해결할 수 있을까?



🚀 해결 및 개선 방법


1) JPA와 MyBatis를 같이 쓰지 않기

애초에 내가 겪은 문제는 JPA와 MyBatis를 같이 사용하면서 발생한 문제이고, 이 두 기술을 같이 사용하는 상황에서는 커넥션 Deadlock이 발생할 위험이 매우 높으므로 JPA와 MyBatis를 애초에 같이 쓰지 않고 하나의 기술만을 사용하면, 위와 같은 문제를 만날 확률을 매우 낮출 수 있다.

2) JPA와 MyBatis는 명확하게 별도의 트랜잭션을 통해서 처리하기

하지만, 인생은 실전이고 단 시간에 레거시를 걷어내기 어려운 상황이라면, JPA와 MyBatis를 같이 사용해야하는데, 이럴 경우엔 JPA와 MyBatis를 절!대! 하나의 트랜잭션에 묶어서 사용하지 않도록 개발하는 것이 필수적이다.

3) 하나의 트랜잭션을 최대한 Atomic하게 구성하는 원칙을 갖기

사실 커넥션 Deadlock 이슈는 JPA와 MyBatis를 같이 쓰지 않아도 충분히 발생할 수 있는 문제이므로, 최대한 하나의 트랜잭션을 Atomic하게 구성하는 습관 또는 원칙을 갖는 것이 바람직할 것이다.

4) 강조

다시 한 번 강조하자면, 어떠한 기술을 사용하든 상관없이 하나의 트랜잭션에서 2개 이상의 커넥션을 사용하게 될 경우 커넥션 Deadlock은 무!조!건! 발생할 것이므로, 이러한 트랜잭션 사용을 절대 지양하면 사실 모든 문제는 해결된다 ㅎㅎ



🙏 참고


팀 내에서 해당 이슈에 대해서 알아보던 중, 배민에서 겪은 동일한 문제를 다룬 좋은 블로그 글이 있었다.
배민 땡큐! 🙏

0개의 댓글