20230220_트랜잭션
이번에 발생했었던 이유 사항에 대해서 정리해보자 계속해서 HikariCP 에서 deadlock으로 인해서 timeout 에러가 발생하고 있었다
우선 문제는 명확했다 우리 회사는 MSA 구조로 되어있는 터에 수 많은 컴포넌트들이 하나의 디비를 바라보고 있는 상황이다 물론 디비의 상세 구조를 내가 알고 있는 것은 아니라서 확실하게는 모르겠지만 수 많은 컴포넌트들이 하나의 디비를 바라보고 Connection pool 을 잡고 있어서 Connection pool이 가득찼던 이슈 사항이 있었다. 그래서 이걸 해소하기 위해서 개발계에서 많이 사용되는 컴포넌트가 아닌 사용량이 적어도 괜찮은 컴포넌트의 커넥션 풀을 줄이자는 이야기가 나왔다 물론 해당 작업은 본인이 진행하지는 않았지만 일정 컴포넌트의 Hickri의 connection pool size을 2개로 줄였다 해당 설정과 관련해서 수정한 config은 이하와 같다
일단 2개로 넣어둔 상황에서 지속해서 Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection 요 에러가 수우우우우우우도 없이 발생하고 있었다 그래서 자세한 로그를 확인해본 결과, 해당 에러는 HikariCP 에서 connection timeout이 발생했다고 말해주고 있었고, 해당 connection timeout은 hikari에서 connection pool을 찾는 도중에 deadlock이 발생하면서 나온 에러로 확인되었다
그럼 해당 에러를 재현해보자 해당 에러를 재현하기는 쉬웠다 하나의 api가 있는데 요것은 req부터 res까지 필요한 시간은 0.7초? 정도면 수행되는 api 였다 그런데 요 0.7초 내에 해당 api를 또 다시 호출하게되면 (같은 row에 접근하는 케이스가 아니더라도 괜찮았다) connection pool을 기다리다가 hikari에서 connection pool을 기다리는 기본 타임아웃 시간 초인 30초를 넘어가면서 타임아웃이 발생하게 되는 그런 상황이였다
해당 메소드의 내부 로직을 봐보면 이러하다 메인 메소드에서 데이터를 저장 >> @Async에서 데이터를 저장 >> @Transactional(propagation=REQUIRES_NEW) 에서 findById.ifPresent{it.카운트++} (근데 이게 저장까지 되는게 신기하네) >> 추가로 전에 작성한 findById에는 @Lock(LockModeType.PESSIMISTIC_WRITE)이 달려있었다 그래서 이 메소드들의 의심점을 가지고 무엇이 문제인지 확인해보았다
가장 처음으로 의심을 가져보려고 했던 것은 해당 컴포넌트에게 특별하게 길게 실행되는 쿼리가 있는가 였다 그야 당연히 connection pool size를 줄인 것은 좀 되었고 해당 컴포넌트에 개발건은 몇 개월동안 없었는데 왜 이제와서..? 갑자기 커넥션 풀을 잡지 못한다고 그러는걸까? 이에 대해 생각해본 답변은 그야 갑자기 요 컴포넌트에서 배치성으로 길게 돌거나 대용량 데이터를 밀어넣는 곳이 있을까 싶어서 내부 코드를 봤지만 따로 그런건 없었다
그럼 이제는 정말 로직적으로 이슈가 있다는 것에 의심을 가지기 시작했고 처음으로 의심을 가졌던 것은 @Async 메소드 부분이였다 사실 따로 @Async였기 떄문에 처음으로 의심을 가진 것은 아니구, 메인 메소드에서 save가 일어나는 부분 다음으로 관련된 메소드였기 떄문에 의심을 해보았다 실제로 저장하는 메소드가 @Async 내부에 존재하지는 않았지만 비동기 처리가 되게 되면 별도의 스레드가 하나 더 생기게 되고 비동기 처리로 인해서 생긴 스레드에서 connection pool을 요구하게 된다 그래서 connection pool을 얻기위해서 기다릴 것이라고 생각했었다 요걸 확인해보기 위해서 해당 @Async문을 삭제하고 다시 테스트를 진행해 봤지만? 결과는 같았다 곰곰히 생각해보면 비동기 메소드에서 connection pool을 얻기 위해서 hikariCP에서의 대기열인 blocking queue에 들어갔다고 한들, 원래의 메소드가 30초가 걸리지 않고 connection pool을 뱉어낼텐데 요건 문제가 아니겠거니 싶었다 간단하게 chatGPT에게 blocking queue에 대해서 물어보니 답변은 이러했었다 A blocking queue is a queue that blocks when you try to dequeue from it and the queue is empty, or when you try to enqueue items to it and the queue is full. A blocking queue uses locking and signaling mechanisms to provide a thread-safe, synchronized way for producer and consumer threads to communicate and synchronize with each other.
그럼 그 다음을 문제는 @Transactional(propagation=REQUIRES_NEW) 였는데 요거겠거니 했었다 @Transactional 애노테이션에 대해서는 한번 공부를 하긴 했었는데 간단하게 다시 한 번 정리해보자 @Transactional 이라는 애노테이션에서 주의해서 봐야하는 속성은 두 가지가 있다. 그 두 속성은 이러하다 > isolation, propagation 우선 isolation은, 격리 수준이라고 해당 트랜잭션의 격리 수준을 설정하는 설정이다 해당 속성에 들어갈 수 있는 값들은 레벨 낮은 순서부터 이러하다
lv0 - READ_UNCOMMITTED : 커밋되지 않은 데이터에 대한 읽기 허용
요것의 문제는 Dirty Read가 발생할 수 있다는 점이 위험 사항이다. 여기서 dirty read란 다른 트랜잭션에서 어떤 데이터를 수정했지만 커밋하지 않은 상태에서 해당 데이터를 읽는 것이다. 만약에 dirty read가 발생하게 되면 수정하고 있는, 해당 데이터를 건들고 있는 다른 트랜잭션의 작업을 기다리지 않고 그 값을 사용해버리는 문제가 생긴다는 것이다
lv1 - READ_COMMITTED : 트랜잭션에서 커밋이 확정된 데이터만을 읽도록 허용
요렇게만해도 lv0에서 발생할 수 있는 dirty read을 피하는 것이 가능하다
lv2 - REPEATABLE_READ : 트랜잭션이 어떠한 데이터를 읽을 때, 항상 해당 데이터를 데이터베이스에서 항상 바라보고 조회하는 것이 아니라 데이터베이스에서 제공하는 스냅샷을 기준으로 조회를 해준다 따라서 커밋이 되었다고 해서 바로 커밋된 데이터가 조회되는 것이 아니라 일정 시점의 스냅샷을 조회해서 내려준다
lv3 - SERIALIZABLE : 트랜잭션의 가장 높은 독립성을 제공하는 속성이다
lv1(READ_COMMITTED), lv2(REPEATABLE_READ) 요 2가지 레벨에서 발생할 수 있는 이슈인 phantom read를 방지하는 것이 가능하다
phantom read : 2개의 트랜잭션에서 하나에 자원에 접근한다고 생각했을 때, 하나의 트랜잭션에서 동일한 조회 쿼리를 날렸을 때 첫 번쨰 조회쿼리와 두 번쨰 조회쿼리가 다른 경우
해당 격리수준으로 설정해두면, 단순 조회하는 쿼리을 실행해두 shared lock이 걸리게 됨으로 다른 트랜잭션에서 수정하거나 삭제하는 것이 불가능하다
shared lock : 읽기 잠금으로 동시에 읽는 것은 가능해도 수정하는 것은 불가능한 잠금 레벨이다
exclusive lock : 쓰기 잠금으로 해당 자원을 수정할 때, 해당 트랜잭션의 종료 전까지 다른 트랜잭션에서 수정이나 삭제는 당연하게 제한되지만 조회마저도 제한되는 그러한 잠금 레벨이라고 볼 수 있다
여기서 포인트는 해당 isolation lv은 디폴트가 >> 각 데이터베이스의 isolation lv에 따라서 설정이 된다 회사에서 사용하고 있는 오라클을 기준으로 보면 READ_COMMITTED 이구 MySQL은 REPEATABLE_READ이다
다음으로는 해당 트랜잭션의 전이 레벨이다. propagation 이라고도 불리운다 트랜잭션이 시작되고 해당 트랜잭션을 어떻게까지 사용할 것인가? 에 대한 속성으로 정의할 수 있으려나
REQUIRED(default) : 상위 메소드의 트랜잭션에 묶여서 실행되고 상위 메소드에서 트랜잭션이 종료되면 신규로 생성
REQUIRES_NEW : 새로운 트랜잭션에서 해당 메소드가 실행되도록 설정
MANDATORY : 상위 메소드에 트랜잭션이 없다면 에러를 던지고, 상위 메소드에서 트랜잭션이 있다면 조용하게 들어가서 실행 >> 종속적으로 사용해야하는 경우에 사용하는 듯
SUPPORTS : 상위에 트랜잭션이 있으면 들어가서 진행하지만 만약에 없다면 그냥 없는대로 실행
NOT_SUPPORTED : 트랜잭션을 사용하지 않도록 강제한다 > 만약에 상위에 트랜잭션이 있다면 대기시킨다
NEVER : 트랜잭션을 사용하지 않도록 강제한다 > 만약에 상위에 트랜잭션이 있다면 에러를 던진다
나머지 속성들도 readOnly, 롤백관련, timeout 관련이 있지만 이름 그대로 인것으로 보이니 자세한게 필요할 때 보쟈
우선 propagation 속성에서 REQUIRES_NEW 이라는 속성은 기존에 진행되던 트랜잭션 이외에 새로운 트랜잭션을 생성해서 진행하겠다는 의미이다 따라서 해당 애노테이션이 달려있는 메소드 내부에서는 새로운 트랜잭션이 실행되고 소스코드에서 해당 메소드 내부에서는 findById.ifPresent{ it.카운트++ }가 되어있었는데 여기 메소드에서 새로히 connection pool을 얻고 디비를 조회하고 수정을 하고 update 쿼리를 날리는 로직이다. 그러면 결국은 여기에서 새로히 connection pool을 요청하게 되는 결과이고, 하나의 큰 메소드에서 총 2개의 connection pool을 요한 다는 것을 확신할 수 있었다
그래서 요걸 어떻게 해결할 것인가에 대해서 고민을 해보았다 우선은 REQUIRES_NEW가 들어간 부분에서 이슈가 있었기 떄문에 요걸 지우고 테스트를 해보니 잘 되었었지만 본인이 만든 코드도 아니고 해당 코드에 이유가 있어서 그렇게 애노테이션을 달아두었을 것이기 때문에 소스 코드를 수정하는 것은 불가능했다 그렇기 떄문에 적어도 4개의 conenciton pool이 필요하다고 판단했고 맨 처음에 설정해두었던 connnection pool의 사이즈를 2개에서 4개로 늘렸고 4개로 늘려둔 상황에서 REQUIRES_NEW을 통한 테스트도 정상적으로 통과하게 되었다
정말 분석하는데 트랜잭션에 대한 개념도 다시 한번 고민하게 되는 시간이였고, 맨 처음에 REQUIRES_NEW 을 유지하면서 교착상태를 해결하기 위해서 isolation level을 건드려보려고 했었지만 해당 방법을 사용하는 것은 위험한 방법이라고 들었다 그래도 이김에 isolation level에 대해서 배울 수 있는 시간이였다
원래는 dead lock을 방지하기 위해서 @Lock을 통해서 진행해보려고 까지 생각했었다. 다시 생각해보면 @Lock까지 갈 필요도 없는 문제였는데 단순히 내가 @Lock의 lockMode에 대해서 자세하게 알지 못했기 때문에 그렇게까지 생각이 진행되었던 것 같다 이 귀중한 기회를 날리지말고 공부할 수 있는 시간으로써 @Transactional, @Lock에 대해서 정리 다시 한 번 해보도록 하자
Last updated
Was this helpful?