@Transactional이 트랜잭션을 처리하는 방식
JDBC 기준으로 정리했습니다. Spring은 @Transactional이 붙은 빈을 프록시로 감싸고, 프록시가 메서드 앞뒤에서 트랜잭션을 처리합니다.
- 이미 진행 중인 트랜잭션이 있으면 기본 전파 방식인
REQUIRED는 새 트랜잭션을 만들지 않고 참여합니다. - 같은 클래스 안에서
this로 호출하면 프록시를 거치지 않아 트랜잭션이 적용되지 않습니다.
트랜잭션을 정리하며 생긴 질문
Q1. 트랜잭션이 유지되는 동안 같은 커넥션을 쓰는 이유는 무엇인가?
TransactionSynchronizationManager가 커넥션을 스레드별 저장소(ThreadLocal)에 DataSource별로 보관하기 때문입니다. 전처리에서 커넥션을 저장소에 넣고, JdbcTemplate은 SQL을 실행할 때 저장소에서 커넥션을 먼저 찾습니다. 후처리에서 저장소에서 커넥션을 빼고 커넥션 풀에 반환합니다.
그래서 같은 스레드에서 같은 DataSource로 실행한 SQL만 커넥션을 공유합니다. @Async처럼 다른 스레드에서 실행하면 공유하지 않습니다.
Q2. readOnly 같은 트랜잭션 힌트는 언제 실행되는가?
트랜잭션을 시작하는 전처리에서, autocommit을 끄기 직전에 SET SESSION TRANSACTION READ ONLY로 실행됩니다. 커밋한 뒤에는 SET SESSION TRANSACTION READ WRITE로 해제됩니다.
기존 트랜잭션에 참여하면 전처리가 실행되지 않아 힌트도 다시 적용되지 않습니다. 쓰기 트랜잭션 안에서 부른 readOnly = true 메서드가 읽기 전용이 되지 않는 이유입니다.
Q3. 트랜잭션 시작과 커밋은 어느 시점에 실행되는가?
가장 바깥의 @Transactional 메서드를 기준으로 시작하고 커밋합니다. JDBC에는 트랜잭션을 시작하는 SQL이 따로 없고, autocommit을 끄는 것이 시작입니다.
| 시점 | 동작 | MySQL에 실행되는 SQL |
|---|---|---|
@Transactional 호출 | 트랜잭션 시작 | SET autocommit=0 |
| SQL 실행 | 같은 커넥션으로 실행 | SELECT, UPDATE 등 |
| 메서드 종료 | 커밋 | COMMIT |
RuntimeException이나 Error 발생 | 롤백 | ROLLBACK |
- checked 예외로 끝나면 기본 설정에서는 롤백하지 않고 커밋합니다.
- 커밋한 뒤에는
AFTER_COMMIT리스너를 실행하고, autocommit을 다시 켠 다음(SET autocommit=1) 커넥션을 반환합니다.
Q4. 스레드에 바인딩된 커넥션을 다른 스레드에 빌려주지 않는 이유는 무엇인가?
커넥션 풀과 Spring이 각자 기록을 관리합니다. 커넥션 풀은 어떤 커넥션을 빌려줬는지 기록하고, Spring은 스레드가 어떤 커넥션을 빌렸는지 기록합니다.
HikariCP는 커넥션마다 상태값을 둡니다
| 상태 | 의미 |
|---|---|
STATE_NOT_IN_USE | 사용 가능한 커넥션입니다. 빌려줄 수 있습니다. |
STATE_IN_USE | 빌려준 커넥션입니다. 다른 스레드에 주지 않습니다. |
getConnection()은 상태를STATE_NOT_IN_USE에서STATE_IN_USE로 바꾸는 데 성공한 커넥션만 돌려줍니다.- 커넥션의
close()를 호출하면 DB 연결을 끊지 않고 상태만STATE_NOT_IN_USE로 되돌립니다. 이것이 커넥션 풀에 반환하는 동작입니다.
Spring은 스레드마다 바인딩 기록을 둡니다
Q1에서 본 스레드별 저장소가 바인딩 기록입니다. 다른 스레드는 이 기록을 볼 수 없습니다.
- SQL이 끝나도 기록에 있는 커넥션이면
close()를 호출하지 않습니다. - 후처리에서 기록을 지운 뒤
close()를 호출합니다.
그래서 트랜잭션이 끝날 때까지 커넥션은 STATE_IN_USE로 남고, 다른 스레드는 다른 유휴 커넥션을 빌리거나 기다립니다.
@Transactional과 DB 트랜잭션의 범위
@Transactional은 Spring이 메서드 하나를 하나의 작업처럼 처리하도록 돕는 기능이라고 생각합니다. 그래서 @Transactional이 붙은 메서드의 실행 범위와 DB 트랜잭션의 라이프사이클이 같다고 보기는 어렵습니다.
@Transactional이 붙은 메서드에서 Spring과 MySQL InnoDB가 하는 일을 시점별로 나누면 다음과 같습니다.
| 시점 | Spring | MySQL InnoDB |
|---|---|---|
| 메서드 시작 | 커넥션을 얻고 autocommit을 끈다 | 세션에 트랜잭션이 열린 상태가 된다 |
| 첫 SELECT | 없음 | 일관된 읽기에 쓸 스냅샷을 만든다 |
| 첫 쓰기나 잠금 읽기 | 없음 | 트랜잭션 ID를 발급한다 |
| 메서드 종료 | 커밋하고 커넥션을 반환한다 | 트랜잭션을 끝낸다 |
@Transactional이 붙었지만 메서드 안에서 쿼리가 실행되지 않으면, DB는 스냅샷을 만들지 않고 트랜잭션 ID도 발급하지 않습니다. 반면 Spring은 메서드가 끝날 때까지 커넥션을 점유하고, SET autocommit=0과 COMMIT 같은 SQL도 실행합니다. 메서드 안에서 외부 API를 호출하면 그 시간 동안에도 커넥션을 점유합니다.
조회 메서드에 @Transactional(readOnly = true)가 필요한가
@Transactional(readOnly=true), 정말 필요할까는 관례대로 붙이던 @Transactional(readOnly = true)가 실제로 이득이 있는지 확인한 글입니다. 원문의 흐름은 다음과 같습니다.
- 카카오페이 글을 보고
Propagation.SUPPORTS를 적용하려다가, 카카오페이는 DB를 나눠 쓰는 환경이라 전제가 다르다고 판단했습니다. - 설정값을 조회하는
getValue()를 3번 호출하고 MySQL general query log로 SQL을 확인했습니다. 호출마다 SQL 6개가 실행돼 총 18개였고, 애노테이션을 지우자 SELECT 3개만 남았습니다. - 단순 조회인
getValue()에서는 애노테이션을 지우고, 여러 건을 읽는search()에는 유지했습니다.
원문과 카카오페이 글을 Spring, Hibernate, MySQL 공식 문서와 Spring 소스에 대조했습니다. 원문의 실측은 재현하지 않았습니다.
애노테이션을 지우면 달라지는 것
원문 실측 A에서 getValue()를 호출할 때마다 실행된 SQL입니다. SELECT 앞뒤의 SQL이 앞의 Q2, Q3에서 본 전처리와 후처리입니다.
SET SESSION TRANSACTION READ ONLY
SET autocommit=0
select ... from admin_config_values where config_key='KEY_1'
COMMIT
SET autocommit=1
SET SESSION TRANSACTION READ WRITE
- 원문: 호출당 6개, 그중 SELECT를 뺀 5개가 추가 SQL
- 카카오페이: SELECT 1개에 추가 SQL 6개
카카오페이는 부하 테스트에서 트랜잭션을 없애자 select 처리량이 약 2~3배, API 성능이 약 2배 좋아졌다고 측정했습니다. 원문은 SQL 개수만 확인했고 응답 시간은 측정하지 않았습니다.
선언 방식마다 트랜잭션을 만드는 방식이 다릅니다.
| 선언 | 기본 동작 |
|---|---|
@Transactional | 기존 트랜잭션에 참여하거나 새로 만듭니다. |
@Transactional(readOnly = true) | 같은 방식으로 참여하거나 새로 만들고, 읽기 전용 속성을 지정합니다. |
@Transactional(propagation = SUPPORTS) | 기존 트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 실행합니다. 리소스를 공유하는 동기화 범위는 만듭니다. |
| 애노테이션 없음 | 트랜잭션을 만들지 않습니다. 호출한 쪽이 만든 트랜잭션은 이용합니다. |
원문은 SUPPORTS와 애노테이션 없음이 “완전히 동일한 결과를 낸다”고 썼지만, 표처럼 SUPPORTS는 동기화 범위를 만든다는 점이 다릅니다. getValue()에는 SUPPORTS까지 쓸 필요가 없었습니다.
원문 실측 C처럼 readOnly = true 트랜잭션 안에서 애노테이션 없는 getValue()를 3번 호출하면, 바깥 트랜잭션에 참여해 SET과 COMMIT이 1번만 실행됩니다. 그래서 원문 실측 A와 B는 트랜잭션을 새로 만드는 경우와 만들지 않는 경우의 비교입니다.
원문은 readOnly = true를 붙이면 dirty checking과 flush를 건너뛴다고 썼습니다. 두 효과는 적용 조건이 다릅니다.
- 자동 flush는 읽기 전용 트랜잭션이면 항상 막습니다.
- dirty checking과 스냅샷은 트랜잭션을 위해 EntityManager를 새로 만든 경우에만 건너뜁니다.
- 쓰기를 막아 주지는 않습니다. Spring은
readOnly를 힌트로 설명합니다.
Repository 메서드는 종류에 따라 기본 트랜잭션 설정이 다릅니다.
| Repository 메서드 | 별도 설정이 없을 때 |
|---|---|
findById(), findAll() 같은 기본 제공 메서드 | 읽기 전용 트랜잭션을 만듭니다. |
findByConfigKey() 같은 파생 쿼리 | 트랜잭션 설정이 없습니다. |
@Query 메서드 | 트랜잭션 설정이 없습니다. |
원문은 파생 쿼리만 확인하고 “Repository가 트랜잭션을 다시 연다”는 설명 전체가 틀렸다고 결론 냈습니다. 기본 제공 메서드를 호출하면 서비스에서 애노테이션을 지워도 트랜잭션이 만들어집니다.
트랜잭션을 유지할 조건
원문은 여러 건을 읽는다는 이유로 search()에 애노테이션을 유지했습니다. 조회 건수와 함께 다음 조건을 확인해야 합니다.
여러 조회를 같은 시점의 값으로 묶어야 할 때
원문은 설정값 3개를 getValue()로 하나씩 조회합니다. 조회 사이에 관리자가 설정을 바꾸면 변경 전 값과 변경 후 값을 섞어 읽을 수 있습니다. 세 값이 하나의 설정 묶음이라면 호출하는 쪽에 트랜잭션을 둡니다.
readOnly = true만으로 같은 시점의 값을 읽지는 않습니다. MySQL InnoDB의 일반 SELECT는 격리 수준에 따라 다릅니다.
| 격리 수준 | 같은 트랜잭션 안의 일반 SELECT |
|---|---|
REPEATABLE READ | 첫 번째 읽기에서 정한 스냅샷을 이후 읽기도 씁니다. |
READ COMMITTED | 읽을 때마다 새 스냅샷을 씁니다. |
지연 로딩이 필요할 때
조회 후 Session이 닫히면 아직 로딩하지 않은 연관 데이터에 접근할 수 없습니다.
읽기용 DataSource를 골라야 할 때
원문은 카카오페이가 readOnly = true를 “읽기 복제본으로 보내라”는 라우팅 신호로 쓴다고 설명했습니다. 카카오페이 글은 커넥션 풀만 나눴고, 조회용 DataSource도 master DB를 쓴다고 적고 있습니다.
원문의 두 메서드
getValue(): 단일 값을 반환하고, 지연 로딩과 DataSource 라우팅이 필요 없습니다. 세 값의 일관성을 호출하는 쪽에서 책임진다면 애노테이션을 지워도 됩니다.search(): 구현이 공개되지 않아 판단하지 못했습니다. 여러 쿼리가 하나의 결과를 만들어야 하는지, 영속성 컨텍스트가 얼마나 오래 필요한지로 판단합니다.
@TransactionalEventListener는 트랜잭션이 없으면 실행되지 않는다
@TransactionalEventListener는 왜 조용히 무시될까에 나온 사례입니다. 저장은 됐는데 검색 색인에서 빠진 문서가 있었습니다. 색인 리스너는 호출되지 않았고, 예외와 로그도 없었습니다. 같은 저장 메서드가 두 경로에서 호출됐습니다.
- 일반 저장 경로:
@Transactional안에서 호출해서 리스너가 실행됐습니다. - 운영자 콘솔 경로: 트랜잭션 없이 호출해서 리스너가 실행되지 않았습니다.
원문에는 코드가 없어서 설명을 아래 코드로 옮겼습니다.
// 저장 후 이벤트를 발행한다. @Transactional 없음
@Component
public class DocumentWriter {
public void save(Document doc) {
documentRepository.save(doc);
publisher.publishEvent(new DocumentSavedEvent(doc.getId()));
}
}
// 일반 저장 경로
@Service
public class DocumentService {
@Transactional
public void edit(Document doc) { documentWriter.save(doc); }
}
// 운영자 콘솔 경로
@Service
public class AdminConsoleService {
public void changeStatus(Document doc) { documentWriter.save(doc); }
}
@Component
public class DocumentIndexListener {
@TransactionalEventListener
public void on(DocumentSavedEvent event) { indexer.reindex(event.documentId()); }
}
동작 방식
리스너는 publishEvent() 시점에 트랜잭션이 있는지 확인합니다.
public void onApplicationEvent(ApplicationEvent event) {
if (TransactionalApplicationListenerSynchronization.register(event, this, this.callbacks)) {
logger.debug("Registered transaction synchronization for " + event);
}
else if (isDefaultExecution()) { // fallbackExecution = true
processEvent(event);
}
else {
logger.debug("No transaction is active - skipping " + event);
}
}spring-tx/.../TransactionalApplicationListenerMethodAdapter.java
트랜잭션이 없으면 DEBUG 로그만 남기고 건너뜁니다. flex 원문은 이 로그를 trace 수준이라고 썼지만, 소스에서는 DEBUG입니다. 앞 주제의 SUPPORTS로 동기화 범위만 만든 경우에도 실제 트랜잭션이 없으므로 건너뜁니다.
등록된 리스너는 phase에 따라 실행됩니다.
| phase | 실행 시점 |
|---|---|
AFTER_COMMIT (기본값) | 커밋에 성공한 뒤 |
AFTER_ROLLBACK | 롤백한 뒤 |
AFTER_COMPLETION | 커밋이나 롤백이 끝난 뒤 |
BEFORE_COMMIT | 커밋 직전, 트랜잭션 안 |
Spring 이벤트는 큐에 쌓이지 않습니다. publishEvent()를 호출한 스레드에서 리스너 메서드를 순서대로 직접 호출합니다.
publishEvent() 시점 | @EventListener | @TransactionalEventListener | fallbackExecution = true 추가 |
|---|---|---|---|
| 트랜잭션 있음 | 바로 실행, 발행한 트랜잭션 안 | 커밋 후 실행 | 커밋 후 실행 |
| 트랜잭션 없음 | 바로 실행 | 실행하지 않음 | 바로 실행 |
조건에 따라 달라지는 결과
트랜잭션이 없는 경로에서 발행할 때
리스너를 @EventListener로 바꾸면 운영자 콘솔 경로에서도 실행됩니다. 대신 일반 저장 경로에서는 edit()의 트랜잭션 안에서 실행되므로 색인에 실패하면 문서 저장까지 롤백될 수 있습니다. flex 원문은 fallbackExecution = true를 선택했습니다.
fallbackExecution = true에서 트랜잭션 없이 발행하면 리스너는 publishEvent() 줄에서 바로 실행됩니다. 나중에 누군가 publishEvent() 아래에 문서 상태를 바꾸는 코드를 추가하면, 운영자 콘솔 경로의 리스너는 바뀌기 전 상태를 읽습니다.
flex 원문은 리스너가 하는 일로 선택을 나눴습니다.
| 리스너가 하는 일 | 선택 |
|---|---|
| 여러 번 실행해도 결과가 같고 커밋 상태를 읽지 않는다 (색인) | fallbackExecution = true를 쓰고, publishEvent()를 메서드 마지막 줄에 두고, 그 전제를 주석으로 남긴다 |
| 커밋된 결과를 읽거나 외부에 확정 신호를 보낸다 | 발행 직전에 트랜잭션이 있는지 확인하고, 없으면 예외를 던진다 |
트랜잭션 없이 발행하는 경로는 다음 경우에 생깁니다.
- 같은 클래스 안에서
this.save()로 호출해서 프록시를 거치지 않을 때 - 스케줄러, 배치, 관리자 API에서
@Transactional없이 서비스를 호출할 때
리스너에서 예외가 날 때
| 리스너 | 발행한 트랜잭션 | 예외가 publishEvent()를 호출한 코드로 전달되는지 |
|---|---|---|
@EventListener (트랜잭션 안) | 롤백될 수 있습니다. | 리스너 안에서 잡지 않으면 전달됩니다. |
AFTER_COMMIT | 영향이 없습니다. | 전달되지 않습니다. ERROR 로그만 남습니다. |
BEFORE_COMMIT | 롤백됩니다. | 전달됩니다. |
fallbackExecution = true, 트랜잭션 없음 | 트랜잭션이 없습니다. | 전달됩니다. |
flex 원문은 @EventListener에서 예외를 잡아도 트랜잭션이 rollback-only로 표시된다고 설명했습니다. 이 표시는 예외가 @Transactional 메서드 밖으로 나올 때 붙습니다.
indexer.reindex()에@Transactional이 없으면 리스너에서 예외를 잡는 것으로 끝나고,edit()은 정상 커밋됩니다.indexer.reindex()에@Transactional이 있으면 리스너에서 예외를 잡아도edit()이 커밋할 때UnexpectedRollbackException이 납니다.
AFTER_COMMIT 리스너가 실패해도 요청은 정상 응답합니다. 실패를 알려면 리스너 안에서 예외를 처리하거나 TransactionSynchronization.afterCompletion threw exception 로그를 모니터링해야 합니다.
리스너에서 DB에 쓸 때
AFTER_COMMIT 리스너는 커밋한 뒤, 커넥션을 반환하기 전에 실행됩니다(Q3). 이때 기본 전파 방식으로 저장하면 이미 커밋된 트랜잭션에 참여해 변경 내용이 커밋되지 않습니다. 리스너에서 DB에 쓰려면 @Transactional(propagation = REQUIRES_NEW)로 새 트랜잭션을 만들어야 합니다.
리스너를 비동기로 실행할 때
Spring 트랜잭션은 스레드에 묶여 있습니다. @Async 리스너는 다른 스레드에서 실행되므로 발행한 트랜잭션을 볼 수 없습니다.
| 조합 | 실행 시점과 스레드 | 주의할 점 |
|---|---|---|
@EventListener + @Async | 발행 즉시, 다른 스레드 | 커밋 전에 실행돼 저장한 데이터를 찾지 못하거나, 롤백된 작업의 후속 처리가 실행될 수 있습니다. |
@TransactionalEventListener + @Async | 커밋 후, 다른 스레드 | 리스너의 실패와 유실에 직접 대비해야 합니다. |
동작 라이프사이클
커밋과 AFTER_COMMIT 리스너 실행 사이에 이벤트는 스레드의 메모리에만 있습니다. 이 사이에 서버가 SIGKILL로 종료되면 문서는 저장됐는데 색인은 실행되지 않습니다.
서버가 종료될 때는 리스너 종류에 따라 결과가 다릅니다.
| 리스너 | 정상 종료 (SIGTERM) | SIGKILL |
|---|---|---|
@EventListener | 웹 서버가 요청 완료를 기다리면 리스너도 함께 끝납니다. | 발행한 트랜잭션과 함께 롤백됩니다. |
@EventListener + @Async | 기본 설정에서는 큐에 남은 작업을 취소합니다. | 큐에 있던 이벤트가 유실됩니다. |
@TransactionalEventListener | 웹 서버가 요청 완료를 기다리면 리스너도 함께 끝납니다. | 커밋과 리스너 사이에 종료되면 유실됩니다. |
@TransactionalEventListener + @Async | 기본 설정에서는 큐에 남은 작업을 취소합니다. | 큐에 있던 이벤트가 유실됩니다. |
이벤트가 유실돼도 되는지에 따라 대비 방법을 고릅니다.
- 유실돼도 되는 이벤트: 캐시 삭제나 통계처럼 다시 계산할 수 있는 작업은
@TransactionalEventListener와@Async를 그대로 쓰고,ThreadPoolTaskExecutor의waitForTasksToCompleteOnShutdown을true로 두어 정상 종료 때 큐를 처리하게 합니다. - 반드시 처리해야 하는 이벤트:
BEFORE_COMMIT리스너에서 outbox 테이블에 처리할 작업을 저장합니다. 문서 저장과 outbox 저장이 함께 커밋되고, 별도 스케줄러가 outbox의 작업을 처리합니다. 같은 작업이 두 번 처리될 수 있으므로 처리 로직은 멱등해야 합니다. - 다른 서비스가 받아야 하는 이벤트: outbox와 Kafka나 RabbitMQ로 설계합니다. Spring 이벤트는 같은 JVM 안에서만 전달됩니다.