@Transactional을 쓸 상황을 최대한 줄이자
출처: 카카오페이 기술블로그 — JPA Transactional 조금 더 이해하기
카카오페이 온라인 결제팀이 DBA의 위험 알림에서 출발해
@Transactional사용 컨벤션 전체를 다시 정한 사례다. 원문을 읽고 정리한 내용과 내 생각을 함께 적는다.
문제
DBA의 위험 알림으로 MySQL 지표에서 set_option 쿼리가 전체 QPS 14K 중 상당한 비중을 차지하고 있었다.
set_option은 SET ... 계열 명령을 세는 카운터다. 조회 쿼리도 아니고 데이터를 바꾸는 쿼리도 아닌 것이 지표의 상당 부분을 먹고 있었다.
분석
@Transactional(readOnly = true)가 붙은 단건 조회 메서드를 실행하면 MySQL general log에 쿼리가 여섯 개 더 찍힌다. 실제로 필요한 것은 SELECT 하나뿐인데도 그렇다.
SET autocommit=0
SET SESSION TRANSACTION READ ONLY
SELECT ... ← 실제로 필요한 것은 이것뿐
commit
SET SESSION TRANSACTION READ WRITE
SET autocommit=1
원인은 readOnly = true에 propagation이 지정되어 있지 않다는 점이었다. 기본값 REQUIRED는 상위 트랜잭션이 없으면 새로 만들고 그 과정에서 DB에 실제 트랜잭션 사용을 요청한다. 단건 조회 API 하나가 DB를 일곱 번 왕복하는 셈이다.
그러면 질문이 달라진다. 트랜잭션은 언제 진짜 필요한가.
원문의 답은 여러 데이터를 update할 때뿐이고 다음 세 가지를 필요 없는 경우로 정리한다.
| 경우 | 근거 |
|---|---|
| 조회만 필요 | ACID 중 Durability가 필요 없다 |
| 하나의 row만 update | 단일 문장의 원자성은 DB가 보장한다 |
| 동시성 제어만 필요 | @Transactional만으로는 완벽한 제어가 안 된다 (MySQL의 phantom read). optimistic lock이나 도메인 구조 재검토가 맞다 |
세 번째가 특히 결이 다르다. 앞의 둘은 “필요 없다”인데 이건 “부족하다”이다. 동시성은 애초에 이 도구의 일이 아니라는 것.
여기에 함정이 하나 있다. 어노테이션을 떼는 것만으로는 트랜잭션이 사라지지 않는다. SimpleJpaRepository가 클래스 레벨에 @Transactional(readOnly = true)를 달고 있어서 findById, save, delete 같은 기본 메서드는 자기 트랜잭션을 연다. 오히려 서비스에서 어노테이션을 떼면 리포지토리 호출마다 트랜잭션이 하나씩 생겨 개수가 늘어난다.
해결
그렇게 정리된 컨벤션이 다섯 가지다.
1. 단건 요청에서 Transactional 제거 — 조회뿐 아니라 단건 update/insert도 대상이다.
2. @Transactional(readOnly=true) 대신 커스텀 어노테이션
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
annotation class ReadOnlyTransactional
핵심은 SUPPORTS다. 상위 트랜잭션이 있으면 참여하고 없으면 만들지 않는다. 트랜잭션을 만들지 않는 후보는 SUPPORTS, NEVER, NOT_SUPPORTED 셋인데 NEVER는 의도치 않은 예외를, NOT_SUPPORTED는 상위 트랜잭션 일시정지 후 추가 커넥션을 유발한다는 이유로 탈락했다.
readOnly = true를 그대로 둔 이유가 흥미롭다. 이 팀은 CQRS로 조회용 DataSource를 분리해 두었는데, 라우팅은 readOnly 플래그를 보고 결정된다. Spring은 SUPPORTS로 실제 트랜잭션을 열지 않아도 이 플래그를 스레드에 바인딩한다. readOnly DataSource는 보되 트랜잭션은 수행하지 않는 상태를 만든 것이다.
3. 조회가 빈번한 엔티티의 findById override — Querydsl로 다시 구현하면 SimpleJpaRepository를 거치지 않아 기본 트랜잭션에서 자유로워진다. 다만 표준 동작에서 벗어나므로 가장 조회가 많은 두 개 테이블에만 적용했다.
4. 클래스 레벨 @Transactional 제거 — 전부 메서드 레벨로 내렸다. 클래스 레벨은 propagation이 REQUIRED로 고정되어 컨벤션에 구멍을 낸다.
5. 커스텀 어노테이션과 PR 컨벤션으로 규율화 — 비즈니스 로직에서 @Transactional(readOnly=true)를 직접 쓰지 못하게 막았다.
검토했다가 접은 방법도 있다. DB config에서 autocommit을 끄면 SET autocommit=0/1 두 개는 줄일 수 있다. 하지만 그러면 모든 접근에 트랜잭션을 강제해야 하고 commit과 set session 계열 네 개는 여전히 남는다. 트랜잭션을 없애려는 방향과 정면으로 충돌하는 선택이라 뺐다.
결과
select처리량 약 2~3배 증가- API 응답 성능 약 2배 개선
절대 수치와 테스트 조건은 공개되지 않아 그대로 대입하기는 어렵다.
주의할 점이 하나 있다. 이 결론은 카카오페이의 조건 위에서 나왔다. 서비스 간 트랜잭션을 Saga 패턴이 받아주기 때문에 각 서비스가 트랜잭션 경계를 좁힐 수 있고, CQRS로 DataSource를 분리해 뒀기 때문에 readOnly 플래그가 값어치를 갖는다. 그리고 부가 쿼리 여섯 개는 MySQL Connector/J의 동작이라 PostgreSQL에서는 체감이 훨씬 작다.
보상 메커니즘이 없는 조직이 결론만 가져가면 트랜잭션을 쪼갠 만큼 되돌릴 방법이 사라진다. 커밋은 정상적으로 끝나므로 에러 로그도 남지 않는다.
인상 깊었던 점
지금까지 나는 관행적으로 DB 커넥션이 필요한 곳에는 트랜잭셔널 어노테이션이 필수로 존재한다고 생각했다. 이 글을 보고 그게 아니라 필요에 따라 없앨 수도 있다는 걸 확인했다.
커넥션과 트랜잭션이 별개라는 점도 같이 정리됐다. 커넥션은 쿼리를 날리는 순간 잡히고 @Transactional이 결정하는 것은 그 위에서 트랜잭션을 열지 말지뿐이다. 어노테이션이 없으면 autocommit 모드로 동작할 뿐 커넥션이 없는 것이 아니다.
그리고 이 글에서 가져갈 것은 결론보다 방법론에 가깝다. DBA 알림에서 시작해 지표를 확인하고, 가설을 세우고, 성능 테스트로 검증하고, 제약사항을 정의한 뒤 후보를 소거하고, 마지막에 컨벤션으로 굳혔다. 결론은 그 조건에서 나온 답이고 조건이 다르면 답도 달라진다.