Skip to content
hello tis
Go back

[26.09.29] 학습: 트랜잭션 유무별 조회와 이벤트 리스너 동작 확인하기

@Transactional이 트랜잭션을 처리하는 방식

JDBC 기준으로 정리했습니다. Spring은 @Transactional이 붙은 빈을 프록시로 감싸고, 프록시가 메서드 앞뒤에서 트랜잭션을 처리합니다.

실행되는 메서드가 프록시를 호출하면 전처리에서 커넥션을 얻어 스레드에 바인딩하고, 실제 메서드의 SQL 1과 SQL 2가 같은 커넥션으로 실행된 뒤 후처리에서 커밋하고 커넥션을 반환하는 순서

트랜잭션을 정리하며 생긴 질문

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

Q4. 스레드에 바인딩된 커넥션을 다른 스레드에 빌려주지 않는 이유는 무엇인가?

커넥션 풀과 Spring이 각자 기록을 관리합니다. 커넥션 풀은 어떤 커넥션을 빌려줬는지 기록하고, Spring은 스레드가 어떤 커넥션을 빌렸는지 기록합니다.

HikariCP는 커넥션마다 상태값을 둡니다

상태의미
STATE_NOT_IN_USE사용 가능한 커넥션입니다. 빌려줄 수 있습니다.
STATE_IN_USE빌려준 커넥션입니다. 다른 스레드에 주지 않습니다.

Spring은 스레드마다 바인딩 기록을 둡니다

Q1에서 본 스레드별 저장소가 바인딩 기록입니다. 다른 스레드는 이 기록을 볼 수 없습니다.

그래서 트랜잭션이 끝날 때까지 커넥션은 STATE_IN_USE로 남고, 다른 스레드는 다른 유휴 커넥션을 빌리거나 기다립니다.

@Transactional과 DB 트랜잭션의 범위

@Transactional은 Spring이 메서드 하나를 하나의 작업처럼 처리하도록 돕는 기능이라고 생각합니다. 그래서 @Transactional이 붙은 메서드의 실행 범위와 DB 트랜잭션의 라이프사이클이 같다고 보기는 어렵습니다.

@Transactional이 붙은 메서드에서 Spring과 MySQL InnoDB가 하는 일을 시점별로 나누면 다음과 같습니다.

시점SpringMySQL InnoDB
메서드 시작커넥션을 얻고 autocommit을 끈다세션에 트랜잭션이 열린 상태가 된다
첫 SELECT없음일관된 읽기에 쓸 스냅샷을 만든다
첫 쓰기나 잠금 읽기없음트랜잭션 ID를 발급한다
메서드 종료커밋하고 커넥션을 반환한다트랜잭션을 끝낸다

@Transactional이 붙었지만 메서드 안에서 쿼리가 실행되지 않으면, DB는 스냅샷을 만들지 않고 트랜잭션 ID도 발급하지 않습니다. 반면 Spring은 메서드가 끝날 때까지 커넥션을 점유하고, SET autocommit=0과 COMMIT 같은 SQL도 실행합니다. 메서드 안에서 외부 API를 호출하면 그 시간 동안에도 커넥션을 점유합니다.

조회 메서드에 @Transactional(readOnly = true)가 필요한가

@Transactional(readOnly=true), 정말 필요할까는 관례대로 붙이던 @Transactional(readOnly = true)가 실제로 이득이 있는지 확인한 글입니다. 원문의 흐름은 다음과 같습니다.

원문과 카카오페이 글을 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

카카오페이는 부하 테스트에서 트랜잭션을 없애자 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를 건너뛴다고 썼습니다. 두 효과는 적용 조건이 다릅니다.

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를 쓴다고 적고 있습니다.

원문의 두 메서드

@TransactionalEventListener는 트랜잭션이 없으면 실행되지 않는다

@TransactionalEventListener는 왜 조용히 무시될까에 나온 사례입니다. 저장은 됐는데 검색 색인에서 빠진 문서가 있었습니다. 색인 리스너는 호출되지 않았고, 예외와 로그도 없었습니다. 같은 저장 메서드가 두 경로에서 호출됐습니다.

원문에는 코드가 없어서 설명을 아래 코드로 옮겼습니다.

// 저장 후 이벤트를 발행한다. @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()); }
}

동작 방식

@Transactional 메서드에서 publishEvent를 호출하면 리스너가 트랜잭션 동기화 목록에 등록되고, 프록시의 후처리에서 커밋한 뒤 @TransactionalEventListener가 실행되는 순서

리스너는 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@TransactionalEventListenerfallbackExecution = true 추가
트랜잭션 있음바로 실행, 발행한 트랜잭션 안커밋 후 실행커밋 후 실행
트랜잭션 없음바로 실행실행하지 않음바로 실행

조건에 따라 달라지는 결과

트랜잭션이 없는 경로에서 발행할 때

리스너를 @EventListener로 바꾸면 운영자 콘솔 경로에서도 실행됩니다. 대신 일반 저장 경로에서는 edit()의 트랜잭션 안에서 실행되므로 색인에 실패하면 문서 저장까지 롤백될 수 있습니다. flex 원문은 fallbackExecution = true를 선택했습니다.

fallbackExecution = true에서 트랜잭션 없이 발행하면 리스너는 publishEvent() 줄에서 바로 실행됩니다. 나중에 누군가 publishEvent() 아래에 문서 상태를 바꾸는 코드를 추가하면, 운영자 콘솔 경로의 리스너는 바뀌기 전 상태를 읽습니다.

flex 원문은 리스너가 하는 일로 선택을 나눴습니다.

리스너가 하는 일선택
여러 번 실행해도 결과가 같고 커밋 상태를 읽지 않는다 (색인)fallbackExecution = true를 쓰고, publishEvent()를 메서드 마지막 줄에 두고, 그 전제를 주석으로 남긴다
커밋된 결과를 읽거나 외부에 확정 신호를 보낸다발행 직전에 트랜잭션이 있는지 확인하고, 없으면 예외를 던진다

트랜잭션 없이 발행하는 경로는 다음 경우에 생깁니다.

리스너에서 예외가 날 때

리스너발행한 트랜잭션예외가 publishEvent()를 호출한 코드로 전달되는지
@EventListener (트랜잭션 안)롤백될 수 있습니다.리스너 안에서 잡지 않으면 전달됩니다.
AFTER_COMMIT영향이 없습니다.전달되지 않습니다. ERROR 로그만 남습니다.
BEFORE_COMMIT롤백됩니다.전달됩니다.
fallbackExecution = true, 트랜잭션 없음트랜잭션이 없습니다.전달됩니다.

flex 원문은 @EventListener에서 예외를 잡아도 트랜잭션이 rollback-only로 표시된다고 설명했습니다. 이 표시는 예외가 @Transactional 메서드 밖으로 나올 때 붙습니다.

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기본 설정에서는 큐에 남은 작업을 취소합니다.큐에 있던 이벤트가 유실됩니다.

이벤트가 유실돼도 되는지에 따라 대비 방법을 고릅니다.


Share this post:

Previous Post
[26.09.30] 로그 수집 방식을 최상·평균·최악의 경우로 나눠 비교하기
Next Post
[26.09.28] 챌린지 API의 QA 대응을 위한 테스트 도구와 수정 자동화 구성하기