티스토리 뷰

개요
Transaction Propagation을 REQUIRES_NEW로 사용하여 처리하는 시스템에서 TPS가 높아지면 실패율이 증가하는 현상이 있었고, 원인 분석을 통해 DeadLock 문제를 발견하여 해결하였습니다.
이 글에서는 DeadLock이 발생했던 이유와 해결 방법 그리고 개선 효과에 대해 정리하여 공유합니다.
DeadLock 발생을 확인할 수 있는 증상들
Connection Pool의 상태 변화
Connection Pool의 상태 변화를 모니터링하여 이상 징후를 감지할 수 있었습니다. 수십 초나 되는 시간 동안 Connection Pool의 상태가 변함없이 똑같은 상황이고, HikariPool의 Connection is not available 에러도 볼 수 있습니다. 정상적인 상황에서는 작업이 처리됨에 따라 Waiting 수가 줄어들어야 합니다.

작업이 처리되지 않는 구간
아래 도표는 일정한 TPS 요청이 들어올 때 시간에 따라 처리된 요청의 수를 나타낸 표입니다. 빨간색 구간에서는 아무 처리도 하지 못하는 모습을 확인할 수 있습니다. 정상적인 상황에서는 요청이 일정하게 처리되어야 합니다.

문제 원인 찾기
Connection Pool이 고갈된 것 같다는 판단을 하였고, 어떤 이유로 Connection이 부족해졌는지 파악하기 위해 트랜잭션을 분석하였습니다. 그 결과 일부 method에서 Propagation.REQUIRES_NEW를 사용하고 있음을 확인할 수 있었습니다.
REQUIRES_NEW 동작 방식
Spring Transaction Propagation에서는 여러 가지 전략을 사용할 수 있습니다. 그중 REQUIRES_NEW는 기존 트랜잭션과 독립적으로 커밋/롤백을 수행할 수 있고, 기존 트랜잭션 또한 새로운 트랜잭션의 롤백 여부와는 상관없이 처리될 수 있습니다.
원인 발견
REQUIRES_NEW의 DeadLock 발생 가능성
REQUIRES_NEW에서는 독립적인 새로운 트랜잭션을 시작하기 위해 DB Connection이 추가로 필요합니다.
아래 그림과 같이 동시에 요청이 오면 빨간색 트랜잭션(이후 메인 트랜잭션이라 지칭)이 시작되고, 그다음 파란색 트랜잭션(이후 서브 트랜잭션이라 지칭)이 시작되어야 하지만 Connection Pool의 DB Connection이 모두 사용 중이라 더는 처리되지 못하고 대기하게 됩니다.

DB Connection Timeout
시스템에서는 Connection Timeout 시간을 30초로 사용하고 있었습니다. 따라서 30초 동안 Connection을 획득하지 못하면 예외가 발생하여 기존에 실행 중인 트랜잭션을 롤백하고 Connection을 반납하게 됩니다.
상황 정리
1. Connection이 부족하다고 판단하였고, 트랜잭션을 확인해보니 REQUIRES_NEW를 사용하고 있었다.
2. REQUIRES_NEW는 Connection을 추가로 사용하기 때문에 DeadLock이 발생하였다.
3. DeadLock 때문에 30초(DB Connection Timeout)동안 아무런 작업을 하지 못하는 상황이 발생하였다.
4. DB Connection Timeout이 발생하여 일부 스레드는 가지고있던 Connection을 반납한다.
5. 반납한 Connection 덕분에 일부 작업이 처리되지만, 다시 DeadLock이 재발하여 30초 동안 작업을 수행하지 못한다.
문제 해결
DeadLock이 발생하지 않도록 근본 원인을 제거할 필요가 있었습니다. 근본 원인을 제거하고자 다음과 같은 방법들을 시도해보았습니다.
문제 해결 시도 방법
1. Async 처리
2. Connection Pool 분리
3. 동시 스레드 수 제어
1. Async 처리
비동기 이벤트를 발행하여 트랜잭션을 별도 스레드에서 처리할 수 있습니다. 하나의 요청이 처리되기 위해 동시에 2개의 Connection이 필요했던 문제가 해결되어 DeadLock이 발생하지 않을 것으로 판단했습니다.

하지만 서브 트랜잭션은 동기적으로 처리되어야 했습니다. 기존 시스템은 서브 트랜잭션에서 저장한 Entity를 메인 트랜잭션에서 조회하는 경우가 있었습니다. 비동기로 처리하면 메인 트랜잭션에서 Entity를 조회하는 시점에 서브 트랜잭션이 커밋되지 않았다면 조회할 수 없다는 문제가 있었습니다.

2. Connection Pool 분리
서브 트랜잭션 전용 Connection Pool을 만들면 교착 상태가 발생하지 않을 것으로 판단하였습니다.
전용 Connection Pool Size가 최소 1개 이상이면 모든 요청 중 적어도 하나의 요청은 DB Connection을 모두 획득해 작업을 진행할 수 있기 때문입니다.

하지만 이 방법은 DataSource가 2개로 늘어남에 따라 모니터링 등 관리해야 할 지점이 증가하게 된다는 문제점이 있었습니다.
3. 동시 스레드 수 제어
마지막으로 동시 스레드 수를 제어하는 방법입니다. Connection Pool Size가 N개일 때, 동시 스레드 수를 N-1개로 제한하면 어느 한 스레드는 반드시 Connection을 2개 얻을 수 있습니다. 문제가 발생했던 시스템은 멀티스레딩을 이용하여 작업을 처리하고 있었고, 이때 동시 스레드 수를 Connection Pool Size보다 작게 유지하는 방법을 사용하였습니다.

동시 스레드 수 선정 시 유의점
동시 스레드 수를 N-1로 선택한다면, 특정 상황에서는 단 하나의 스레드만 작업을 처리할 수 있습니다. DeadLock이 발생하지는 않지만, 처리율이 떨어질 수 있기 때문에 적당한 수를 선정해야 합니다. 팀에서는 Pool Size의 80% 수준으로 적용을 한 뒤, 모니터링을 통해 조금씩 조정해 나가는 방향을 선택하였습니다.
적용 결과
테스트
K6를 이용하여 기존 방식과 개선 방식의 TPS 별 실패율을 측정하였습니다.
안정적으로 처리할 수 있는 TPS를 5 TPS에서 30 TPS로 6배 개선할 수 있었습니다.

긴 내용을 끝까지 읽어주셔서 감사합니다.
참고 자료
'트러블 슈팅' 카테고리의 다른 글
| [트러블슈팅]Offset을 사용한 조회에서 성능이 떨어지는 이슈 (0) | 2024.11.03 |
|---|---|
| [트러블 슈팅] 잘못된 Comparable을 구현한 클래스의 HashSet, HashMap 사용 시 중복 삽입이 되는 문제 (0) | 2024.05.09 |
- Total
- Today
- Yesterday
- 타임머신
- 상범빌딩
- 7511
- 벨만포드
- 중위 표기식 후위 표기식으로 변환
- 소셜네트워킹어플리케이션
- 벨만-포드
- 어린왕자 C++
- 백준
- 1004
- C++
- 6539
- 골목길C++
- tea time
- 6018
- 골목길
- 11657
- 1738
- 1918
- 스택
- 후위 표기식
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |