| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 데이터베이스
- mysql
- CloudNative
- Kotlin
- Java
- 성능 최적화
- 백엔드개발
- RDBMS
- DevOps
- grafana
- NIO
- GitOps
- kafka
- 성능최적화
- Kubernetes
- helm
- jvm
- netty
- redis
- spring boot
- monitoring
- selector
- 백엔드
- 동시성제어
- prometheus
- docker
- webflux
- JPA
- 트랜잭션
- SpringBoot
- Today
- Total
유성
@Transactional 파라미터 해부: 소스 코드로 보는 8가지 속성 본문
앞서 한차례 트랜잭션 범위에 대해서 글을 쓴 적이 있다.
https://youseong.tistory.com/71
예외 발생 시 롤백 방지하기: Spring에서 독립적인 트랜잭션 설정 방법
프로젝트에서 repository.save() 메서드를 사용했음에도 불구하고 데이터가 커밋되지 않는 문제를 겪은 적이 있습니다. 이 글에서는 문제의 원인과 해결 방법을 공유합니다. 트랜잭션 처리 방식문
youseong.tistory.com
이번 글에서는 @Transactional 의 파라미터들을 뜯어보며 어떤 기능들이 있는지 확인해 본다.
단순히 "붙이면 된다"를 넘어 이를 어떻게 활용할 수 있는지 하나하나씩 뜯어보자.
(트랜잭션 의존성: spirng-tx:7.0.3)
1. 어떤 트랜잭션 매니저를 쓸 것인가? (Value, TransactionManager)
먼저 소스 코드를 보면 동일한 역할을 하는 두 개의 속성이 정의되어 있다.
@AliasFor("transactionManager")
String value() default "";
@AliasFor("value")
String transactionManager() default "";
@AliasFor로 연결되어 결과적으로 동일하게 동작하는 필드이다.
언제 필요한가: 하이브리드 인프라 환경
단일 DB를 쓰거나 Read-only Slave 분기 처리를 넘어, 아예 물리적으로 다른 데이터베이스를 연동해야 하는 경우가 있다.
예를 들어 다음과 같은 보안 정책이 있는 시스템을 가정해 보자.
- 메인 도메인 DB: 퍼블릭 클라우드에서 관리
- 인증용 DB: 사내망(On-premise) 내부에 존재하며, 특정 보안망으로만 접근 가능
물론 인증 전용 API가 따로 존재하겠지만, 여기서는 하나의 서비스에서 두 개의 DB에 직접 쿼리를 날려야 한다고 가정한다.
이때 우리는 각 DB에 대응하는 TransactionManager 빈을 두 개 정의하고, 상황에 맞게 주입받아 사용해야 한다.
아래와 같이 사용하여 필요한 트랜잭션 매니저를 불러와 사용할 수 있다.
// 사용 예시
@Transactional("cloudTransactionManager")
public void saveOrder() { ... }
@Transactional(transactionManager = "authTransactionManager")
public void authenticate() { ... }
그러나 필자는 이 방식을 절대 권장하지 않는다.
- 코드의 복잡도: 하나의 클래스 내에서 다수의 DB를 호출하는 구조는 복잡성이 너무 커진다.
- 클래스 분리: 만약 클래스를 분리한다면 @EnableJpaRepositories 같은 기능을 사용하는 것이 더 효율적이다.
따라서 클래스마다 매니저 이름을 적기보다, @EnableJpaRepositories 같은 설정을 활용해 패키지 단위로 트랜잭션 매니저를 강제하는 방식을 사용하는 것이 바람직하다.
2. 트랜잭션 라벨링 (Label)
트랜잭션을 라벨링 할 수 있으며, 복수개의 라벨링이 가능하다.
String[] label() default {};
라벨은 코드를 설명하는 데 사용될 수도 있고, 트랜잭션 모니터링에 대한 라벨링으로 사용될 수 있다.
@Transactional(label = "batch-processing")
public void saveOperationBatch() { ... }
3. 트랜잭션 전파: 이미 실행 중인 트랜잭션이 있다면? (Propagation)
트랜잭션 전파는 "이미 실행 중인 트랜잭션이 있을 때, 새로 만난 @Transactional을 어떻게 처리할까?"에 대한 규칙을 정의한다.
Propagation propagation() default Propagation.REQUIRED;
우리는 이미 전파 전략을 사용하고 있었다.
Spring Data JPA를 써본 경험이 있다면, 별다른 설정 없이도 이 전파를 경험하고 있다.
흔히 사용하는 JpaRepository의 내부 구현체인 SimpleJpaRepository를 직접 열어보면 그 증거가 나온다.
@Repository
@Transactional(readOnly = true) // 클래스 레벨 기본값
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
@Override
@Transactional // 쓰기 메서드에서 오버라이딩
public <S extends T> S save(S entity) {
Assert.notNull(entity, ENTITY_MUST_NOT_BE_NULL);
// ... 생략
}
}
보통 서비스 로직에서 @Transactional을 선언하고 내부에서 repository.save()를 호출하곤 한다.
이때 서비스의 트랜잭션과 레포지토리의 트랜잭션이 중복 실행되는 셈인데, 만약 어느 한 부분에서 예외가 발생하면 어떻게 될까?
지금 머릿속으로 생각한 결과가, 해당 기본 설정인 Propagation.REQUIRED의 동작 방식인 것이다.
Propagation 설정값의 종류
전파 전략은 총 7가지가 존재하며 REQUIRES_NEW 정도만 눈여겨볼 필요가 있다고 생각한다.
- REQUIRED (기본값): 기존 트랜잭션이 있으면 합류하고, 없으면 새로 만든다.
- SUPPORTS: 기존 트랜잭션이 있으면 합류하지만, 없으면 트랜잭션 없이 실행한다.
- MANDATORY: 기존 트랜잭션이 반드시 있어야 한다. 없으면 예외를 던진다.
- REQUIRES_NEW: 기존 트랜잭션과 상관없이 무조건 새로운 트랜잭션을 시작한다. (기존 TX는 보류)
- NOT_SUPPORTED: 트랜잭션 없이 실행한다. 기존 트랜잭션이 있다면 잠시 보류시킨다.
- NEVER: 트랜잭션이 없어야 한다. 기존 트랜잭션이 있으면 예외를 던진다.
- NESTED: 기존 트랜잭션 내부에 중첩 트랜잭션을 만든다. (SAVEPOINT 활용) 부모가 롤백되면 함께 롤백되지만, 자신이 롤백되어도 부모는 살릴 수 있다. (환경에 따라 지원 여부 확인 필요)
사실 위 7가지를 다 외울 필요는 없다. 실제로 유의미하게 사용되는 것은 다음 2가지로 압축된다.
- REQUIRED: 거의 모든 비즈니스 로직에 사용된다. 자원을 효율적으로 공유하며 원자성을 보장한다.
- REQUIRES_NEW: 독립적인 처리가 필요할 때. 로그 저장, 알림 발송 이력, 감사 등에 사용된다. 부모 로직이 실패하더라도 "이 시도를 했다"는 기록은 남아야 하기 때문이다.
이외의 설정들은 시스템의 엄격한 제약 사항을 걸거나, 최적화를 하는 방식으로 프레임워크나 라이브러리 설계 단계에서 사용될 것이다.
4. 트랜잭션 격리 수준 (Isolation)
격리 수준은 데이터 정합성과 시스템 성능 사이의 트레이드오프를 결정하는 핵심 설정이다.
여러 트랜잭션이 동시에 같은 데이터에 접근할 때, 이를 얼마나 엄격하게 격리할지를 정의한다.
Isolation isolation() default Isolation.DEFAULT;
기본값: Isolation.DEFAULT의 진실
여기서 DEFAULT는 스프링 자체의 기본 설정을 적용하겠다는 뜻이 아니다.
현재 애플리케이션과 연결된 데이터베이스(DB)의 기본 격리 수준을 그대로 따르겠다는 의미이다.
따라서 사용하는 DB가 무엇인지에 따라 실제 동작이 달라진다.
- MySQL: REPEATABLE READ
- PostgreSQL: READ COMMITTED
격리 수준에 대한 내용은 너무 방대하기에 로우 레벨에서 어떻게 구현되었는지 알고 싶다면 아래 글을 참고하자.
데이터베이스 격리 수준과 동시성 제어의 실체
REPEATABLE READ는 어떻게 구현되는가?부제: MySQL InnoDB의 MVCC와 Undo Log 해부 트랜잭션 격리 수준은 데이터베이스의 동시성 처리 성능과 데이터 무결성 사이의 트레이드오프를 결정하는 핵심 설정이다
youseong.tistory.com
Isolation 설정 값 종류
- DEFAULT: 연결된 DB의 격리 수준을 그대로 수용한다.
- READ_UNCOMMITTED: 커밋되지 않은 데이터도 읽을 수 있다.
- READ_COMMITTED: 커밋된 데이터만 읽는다.
- REPEATABLE_READ: 트랜잭션이 시작된 시점 이전의 버전만 읽는다.
- SERIALIZABLE: 범위락을 걸어 명령어들을 직렬화한다.
5. 트랜잭션 타임아웃 (timeout)
트랜잭션이 시작된 후 지정된 시간 내에 완료하지 못하면, 시스템을 보호하기 위해 강제로 롤백을 수행하는 설정이다.
int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
// 스프링 5.3부터 추가
String timeoutString() default "";
기본적으로 초 단위로 설정하며, 아무 값도 넣지 않으면 이 또한 DB나 트랜잭션 시스템의 기본 타임아웃 설정을 따른다.
Javadoc을 보면 주의사항을 하나 알려주는데, 이미 진행 중인 트랜잭션에 합류하는 경우 내가 설정한 timeout 값이 무시된다는 것이다.
만약 특정 로직에 엄격한 타임아웃을 걸고 싶다면, 해당 메서드가 트랜잭션의 시작점이 되도록 설계하거나 REQUIRES_NEW를 고려해야 한다.
성능과 가용성을 동시에 잡기 위해 보통 다음과 같은 이중 전략을 사용한다.
- 전역 설정(YAML): default-timeout을 전역 설정하여, 모든 트랜잭션이 무한 대기에 빠져 스레드 풀 고갈을 방지한다.
- 개별 설정(Annotation): 대량의 데이터를 처리하는 배치성 로직이나 외부 API 연동이 포함된 특수 케이스의 경우, 전역 설정보다 넉넉한 시간을 명시적으로 부여하여 유연하게 대처한다.
설정의 외부화를 위해 문자열 버전의 타임아웃이 스프링 5.3부터 추가되었다.
@Transactional(timeoutString = "${my.app.transaction.timeout:30}")
public void processTask() { ... }
6. 읽기 전용 트랜잭션 (readOnly)
readOnly 속성은 해당 트랜잭션 내에서 데이터 변경 작업이 일어날 것인지 아닌지를 설정한다.
boolean readOnly() default false;
단순한 힌드 이상의 가치가 있다.
Javadoc에 따르면, 이 설정은 실제 트랜잭션 하위 시스템에 전달되는 '힌트'이다.
즉, 이 설정을 한다고 해서 반드시 쓰기 작업이 금지되는 것은 아니지만 이를 통해 얻을 수 있는 성능적 이득은 매우 크다.
각 환경별 성능 이점을 살펴보자.
1. JPA/Hibernate 계층에서의 최적화 (더티 체킹 생략)
JPA는 영속성 컨텍스트를 통해 엔티티를 관리한다.
트랜잭션 내에서 엔티티를 조회하면 초기 상태를 복사하여 메모리(스냅샷)에 들고 있다가, 트랜잭션이 끝날 때 현재 상태와 비교해 변경된 부분만 UPDATE 쿼리를 수행한다.
이때 readOnly = true 를 설정하면 하이버네이트는 스냅샷을 찍지 않는다. 결과적으로 메모리 점유율이 줄어들고, 스냅샷 비교 연산이 생략되어 전체적인 응답 속도가 향상된다.
2. DB 계층에서의 트랜잭션 관리 오버헤드 감소
DB 엔진은 읽기 전용 트랜잭션임을 인지하면 다음과 같은 최적화를 수행한다. (DB에 따라 다름)
- 트랜잭션 ID 할당을 생략하거나 간소화하여 관리 비용을 줄인다.
- 데이터 변경이 없으므로 롤백 세그먼트나 Undo 로그 관리를 훨씬 가볍게 가져가 시스템 리소스를 보존한다.
3. 인프라 계층 Read-Replica의 Slave DB 라우팅
대규모 서비스에서 DB 부하를 분산하기 위해 가장 많이 사용하는 전략이다.
readOnly 설정을 보고 스프링의 RoutingDataSource가 자동으로 쿼리를 Slave DB로 보낼 수 있다.
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected @Nullable Object determineCurrentLookupKey() {
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
return isReadOnly ? "slave" : "master";
}
}
이렇게 "master"와 "slave"를 분류해 두면, 쓰기 작업이 집중되는 Master DB의 부하를 획기적으로 낮추어 전체 시스템의 가용성을 높이는 핵심 전략이 된다.
실제로 SImpleJpaRepository의 소스 코드를 보면 클래스 레벨에 @Transactional(readOnly = true)가 선언되어 있다.
이는 조회가 주를 이루는 DB 작업에서 리소스 낭비를 최소화하겠다는 설계자의 의도가 담긴 것이다.
필자는 모든 서비스 레이어에서 클래스 범위에 readOnly = true를 기본으로 설정하고,
데이터 변경이 발생하는 특정 메서드에서만 선별적으로 readOnly = false를 적용하는 것이 가장 견고하고 효율적인 설계라고 생각한다.
7. 롤백 대상 지정 (rollbackFor)
트랜잭션 실행 중 예외가 발생했을 때, 이를 '무시하고 커밋할 것인가' 아니면 '원점으로 되돌릴 것인가'를 결정하는 기준이 된다.
Class<? extends Throwable>[] rollbackFor() default {};
String[] rollbackForClassName() default {};
구현체를 확인해 보면 스프링 트랜잭션의 기본 롤백 전략은 DefaultTransactionAttribute의 rollbackOn 메서드에 명확히 정의되어 있다.
@Override
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
기본 설정을 보면 RuntimeException과 Error에 대해서 예외 발생 시 롤백을 수행하는데 의도는 다음과 같다.
- Unchecked Exception: 시스템상 발생해서는 안 될 "예상치 못한 사고"로 간주하여 즉시 롤백한다.
- Checked Exception: 비즈니스 로직상 "복구가 가능한 대안적 결과"로 간주한다. 즉 에러라기보다 하나의 비즈니스 응답으로 보아 리소스를 정상적으로 마무리하는 것이 기본값이다.
보통 rollbackFor를 재정의 하는 경우는 드물지만, 로직이 매우 중요해 Checked Exception을 정의하고 예외가 발생한 경우
롤백이 꼭 수행되어야 한다면 명시적으로 이를 지정할 필요가 있다.
@Transactional(rollbackFor = OnceTransferLimitExceededException.class)
public void sendMoney() { ... }
여기서는 OnceTransferLimitExceededException라는 Checked Exception이 발생한 경우 롤백을 수행하겠다는 의미이다.
문자열 기반 설정 (rollbackForClassName)
클래스 타입이 아닌 문자열로도 대상을 지정할 수 있는데,
이는 모듈 간의 물리적 결합도를 낮추고 설정값으로 관리할 수 있다는 장점이 있다.
@Transactional(rollbackForClassName = "java.io.IOException")
public void invoke() { ... }
하지만 필자는 로직상 가장 중요한 분기점이 되는 롤백 설정에 클래스 이름을 직접 적는 방식은 권장하지 않는다.
- 타입 안정성 부재: 패키지 경로를 포함한 이름을 적어야 하는데, 사소한 오타가 발생해도 컴파일러가 잡아주지 못한다.
- 조용한 실패: 이름 매칭에 실패하면 정작 예외가 터졌을 때 롤백이 되지 않는 문제가 발생할 수 있다.
차라리 결합도를 최대한 높여 예상하지 못한 문제가 발생하지 못하도록 설정하는 것이 더 견고한 설계라고 생각된다.
8. 롤백 예외 지정 (noRollbackFor)
특정 예외가 발생하더라도 트랜잭션을 롤백하지 않고, 커밋을 수행하도록 지정하는 설정이다.
Class<? extends Throwable>[] noRollbackFor() default {};
String[] noRollbackForClassName() default {};
위에서 RuntimeException이 발생하면 무조건 롤백을 수행하는 것을 보았다.
하지만 비즈니스 로직상 비정상적인 상황이지만, 지금까지의 작업을 저장해야 하는 경우가 있을 때 사용해 볼 수 있다.
그런 경우 해당 파라미터를 활용하여 롤백 수행을 막을 수 있다.
'Spring Data' 카테고리의 다른 글
| Spring Boot에서 MySQL Master-Slave (Replication) 효율적인 연결 (0) | 2026.01.22 |
|---|---|
| JPA의 진짜 설계 의도: 왜 N+1 문제를 방치하였나? (4) | 2025.07.26 |
| [JPA] fetchJoin 과 Paging 처리의 한계 및 해결 방안 (0) | 2025.03.12 |
| RDBMS 격리 수준 (0) | 2024.11.25 |
| 예외 발생 시 롤백 방지하기: Spring에서 독립적인 트랜잭션 설정 방법 (3) | 2024.09.07 |