[강의] Spring Data 트랜잭션

Jerry·2025년 8월 29일

트랜잭션 기본 개념

TCL (Transaction Control Language)

트랜잭션의 개념

한번에 수행되어야 할 DB 명령어의 논리적 작업 단위(LUW, Logical Units of Work)를 말한다.
하나의 트랜잭션으로 이루어진 작업들은 반드시 한꺼번에 완료가 되어야 하며, 그렇지 않은 경우에는 한번에 취소 되어야 한다. 데이터의 무결성과 신뢰성을 유지를 위해 활용된다.
TCL은 트랜잭션을 관리 하기 위한 언어로 아래 4개의 명령어를 갖는다.
START TRANSACTION(트랜잭션 시작, 생략 가능), COMMIT(트랜잭션 종료 및 저장), ROLLBACK(트랜잭션 취소), SAVEPOINT(임시 저장)

병행성 문제 (Concurrency Issue)

데이터베이스는 실시간으로 다수의 사용자가 접근이 가능함으로 병행성의 문제가 발생 가능하다.
병행성 문제란? 특정 데이터를 여러 사용자가 동시에 조회/수정할 경우, 데이터가 의도와 다르게 처리될 수 있는 상황
예를 들어, 은행 계좌에서 여러 프로세스가 동시에 출금을 시도하는 상황에서 병행성 문제가 해결되지 않은 상태라면, 잔고가 충분하지 않더라도 두 개 이상의 출금 요청이 동시에 성공할 수 있으며, 그 결과 잔액이 2번 출금 되고 계좌 잔고가 음수로 떨어지거나 데이터 무결성이 깨질 수 있다.

트랜잭션을 통한 병행성 문제 해결

트랜잭션은 보통 2가지 방법을 위해 활용되는데, 첫번째 활용은 업무 단위를 만들어 업무를 실패하는 경우 이를 모두 Rollback(되돌리기)하는 시점으로 활용 될 수 있으며, 두번째 활용은 처리 시간 단위로 Lock을 걸고, 다른 요청이 해당 업무(데이터)의 접근을 불가능 하도록 보호 할 수 있다. (트랜잭션의 원자성 유지)

ACID 특성

ACID(원자성, 일관성, 고립성, 지속성)는 데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질을 가리키는 약어이다. 짐 그레이는 1970년대 말에 신뢰할 수 있는 트랜잭션 시스템의 이러한 특성들을 정의 하였으며 이는 점차 연구되어 DB의 핵심 기능으로 유지되고 있다.

  1. 원자성(Atomicity)
    트랜잭션과 관련된 작업들이 부분적으로 실행되다가 중단되지 않는 것을 보장하는 능력이다. (다수의 명령을 업무처리 단위를 보장)
  2. 정합성(Consistency)
    트랜잭션 처리 전과 처리 후 데이터 모순이 없는 상태를 유지하는 것을 의미, 만일 모순 발견 시 되돌릴 수 있다. (무결성, Rollback)
  3. 독립성(Isolation)
    트랜잭션 수행 시 다른 트랜잭션의 연산 작업이 끼어들지 못하도록 보장하는 것을 의미 이것은 트랜잭션 밖에 있는 어떤 연산도 중간 단계의 데이터를 볼 수 없음을 의미 (트랜잭션 Locking 단위)
  4. 지속성(Durability)
    성공적으로 수행된 트랜잭션은 영원히 반영되어야 함을 의미한다. 시스템 문제, DB 일관성 체크 등을 하더라도 유지되어야 함을 의미

TCL (Transcation Control Language)

COMMIT

모든 작업을 정상적으로 처리하겠다고 확정하는 명령으로 트랜잭션의 처리 과정을 물리 데이터베이스에 반영하여, 변경된 내용을 모두 영구 저장한다.
TRANSACTION은 INSERT, UPDATE, DELETE의 복합 명령으로 구성될 수 있다.

ROLLBACK

작업 중 문제가 발생했을 때, 트랜잭션의 처리 과정에서 발생한 변경 사항을 취소하고, 트랜잭션 과정을 종료, 트랜잭션 인한 하나의 묶음 처리가 시작되기 이전의 상태로 되돌리는 명령, 마지막 COMMIT 까지로 되돌려 진다.

트랜잭션의 완료

트랜잭션은 아래와 같이 5개의 상태를 가지며, COMMIT 명령과, ROLLBACK 명령을 통해 완료 또는 철회 상태로 변경된다.

Spring의 트랜잭션 처리 방식

@Transactional 어노태이션 (선언적 트랜잭션)

@Transactional는 Spring에서 선언적 트랜잭션 관리를 지원하는 대표 어노테이션이다.
AOP 기반으로 메서드 실행 전후에 트랜잭션을 시작하고, 정상 종료 시 commit, 예외 발생 시 rollback 처리
어노테이션 속성 값을 통해 읽기 전용이나 트랜잭션 전파 수준을 관리 할 수 있다.

@Transactional 어노태이션 상세 정리

@Transactional(
	propagation = Propagation.REQUIRES_NEW, // 항상 새로운 트랜잭션
	isolation = Isolation.READ_COMMITTED , // 커밋된 데이터만 읽음
	timeout = 10, // 10초 초과 시 롤백
	readOnly = true, // 읽기 전용
	rollbackFor = {IOException.class, Exception.class}, // 지정 예외 롤백
	noRollbackFor = {BusinessWarningException.class} // 지정 예외는 커밋
)

public void createUser() {}
속성기본값설명
propagationPropagation.REQUIRED트랜잭션 전파 방식 설정
옵션: REQUIRED, REQUIRES_NEW, MANDATORY, SUPPORTS, NOT_SUPPORTED, NEVER, NESTED
isolationIsolation.DEFAULT트랜잭션 격리 수준 (DB 기본값 따름, 보통 READ_COMMITTED)
옵션: DEFAULT, READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE
timeout-1 (제한 없음)트랜잭션 제한 시간(초).
설정한 시간 초과 시 롤백 발생
readOnlyfalse읽기 전용 여부.
조회 전용 트랜잭션일 때 더티 체킹·플러시 최소화로 최적화 가능
rollbackFor없음
(기본: RuntimeException, Error)
지정한 예외 발생 시 롤백 수행.
Checked 예외도 롤백하려면 직접 지정 필요
rollbackForClassName없음클래스 이름(String)으로 롤백 예외 지정
noRollbackFor없음지정한 예외 발생 시 롤백하지 않음
noRollbackForClassName없음클래스 이름(String)으로 롤백 제외 예외 지정
transactionManager기본 트랜잭션 매니저여러 개의 트랜잭션 매니저가 등록된 경우 특정 매니저를 지정할 때 사용
구분설정 예시설명
롤백 옵션
(rollbackFor)
@Transactional(rollbackFor = Exception.class)기본적으로 Unchecked Exception(RuntimeException, Error)만 롤백.
일반 Checked Exception은 롤백하지 않음.
DB 예외를 포함한 대부분의 예외는 Checked이므로 rollbackFor 옵션 지정 필요.
격리 수준
(isolation)
@Transactional(isolation = Isolation.DEFAULT) → DB 기본값
@Transactional(isolation = Isolation.READ_COMMITTED) → Level1, Dirty Read 방지
@Transactional(isolation = Isolation.REPEATABLE_READ) → Level2, Non-Repeatable Read 방지
@Transactional(isolation = Isolation.SERIALIZABLE) → Level3, Phantom Read 방지 (가장 엄격, 성능 저하)
DB 트랜잭션 격리 수준 설정.
격리 수준이 높아질수록 동시성 이슈 감소, 대신 성능 저하.
실무에서는 보통 READ_COMMITTED ~ REPEATABLE_READ 수준 권장.
전파 수준
(propagation)
@Transactional(propagation = Propagation.REQUIRED) → 기본값, 부모 TX 재사용, 없으면 새로 생성
@Transactional(propagation = Propagation.REQUIRES_NEW) → 항상 새 트랜잭션 생성
@Transactional(propagation = Propagation.SUPPORTS) → 부모 TX 있으면 참여, 없으면 비트랜잭션
@Transactional(propagation = Propagation.NESTED) → 항상 트랜잭션 실행, 내부에 Savepoint 설정
트랜잭션을 어떻게 전파할지 정의.
일반적으로 REQUIRED 사용.
읽기 전용
(readOnly)
@Transactional(readOnly = true)읽기 전용 트랜잭션.
Hibernate는 flush 최소화, DB는 select 최적화 가능.
격리 수준이 설정되어 있어도 락을 잡지 않음 → SELECT 성능 향상.
타임아웃
(timeout)
@Transactional(timeout = 30)트랜잭션 최대 수행 시간을 초 단위로 설정.
설정 시간 초과 시 롤백 발생.
지나치게 오래 걸리는 서비스 요청을 방지 가능.

TransactionTemplate 활용 (프로그래밍 방식)

Spring에서 제공하는 프로그래밍 방식 트랜잭션 관리 유틸리티 클래스
내부적으로 트랜잭션 매니저를 사용하며, 템플릿 메서드 패턴으로 트랜잭션 경계(시작/커밋/롤백)를 보장
사용자가 직접 세밀하게 트랜잭션을 관리 할 때 사용되는 방법이나 자주 활용되진 않는다.

@Service
	public class UserService {

	private final PlatformTransactionManager transactionManager;

	public void createUser(User user) {
		TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
		try {
			userRepository.save(user);
			emailService.sendWelcomeMail(user);
			transactionManager.commit(status);
		} catch (Exception e) {
			transactionManager.rollback(status);
			throw e;
		}
	}
}

선언적 트랜잭션과 프로그래밍 방식 트랜잭션 비교

구분선언적 트랜잭션 (Declarative)프로그래밍 방식 트랜잭션 (Programmatic)
관리 방식@Transactional 애노테이션 또는 XML 설정 기반
스프링이 자동 관리
TransactionTemplate, PlatformTransactionManager 등을 코드에서 직접 호출
구현 방식AOP 프록시가 메서드 실행 전/후에 트랜잭션 시작 → 커밋/롤백 자동 처리개발자가 begin → commit/rollback 흐름을 직접 작성
코드 복잡도비즈니스 로직에만 집중 가능 → 코드 간결트랜잭션 처리 코드가 섞여 복잡하고 중복 증가
제어 수준단순/일괄 트랜잭션 제어에 적합
(주로 Service 계층에서 사용)
조건부 롤백, 부분 커밋 등 세밀한 제어 가능
주요 장점표준화, 유지보수 용이
애노테이션 선언만으로 적용
복잡한 트랜잭션 시나리오를 유연하게 처리 가능
주요 단점특수 조건·부분 제어에는 한계코드 중복·가독성 저하
대표 기술@Transactional(readOnly, rollbackFor, propagation, isolation)TransactionTemplate.execute(), PlatformTransactionManager
권장 사용일반적인 CRUD, 서비스 계층 비즈니스 로직예외적 상황 제어, 조건부 롤백, 부분 커밋 필요 시

Spring AOP와 트랜잭션

프록시 기반 트랜잭션 처리

프록시 기반 트랜잭션 처리는 스프링에서 AOP를 활용하여 트랜잭션 경계를 자동으로 관리하는 방식이다.
대상 객체의 메서드 호출 시 프록시가 개입해 트랜잭션 시작, 커밋, 롤백을 제어한다.
개발자는 비즈니스 로직에만 집중할 수 있고 트랜잭션 관리 로직은 프록시에 의해 투명하게 처리된다.

AOP(Aspect Oriented Programming)의 동작 원리

AOP는 핵심 로직과 부가 기능(로깅, 보안, 트랜잭션 등)을 분리하여 모듈화하는 기법이다. 프록시 객체가 메서드 호출 전·후 또는 예외 발생 시에 지정된 공통 기능을 삽입한다. 이를 통해 중복 코드를 제거하고 관심사의 분리를 달성한다.

트랜잭션 프록시의 역할

트랜잭션 프록시는 실제 객체 앞단에서 동작하며 메서드 호출 시 트랜잭션 경계를 설정한다.
호출이 정상 완료되면 커밋을 수행하고, 예외 발생 시 롤백을 처리한다.이를 통해 개발자는 트랜잭션 관리
코드를 작성하지 않고도 일관성 있는 데이터 처리를 보장받는다.

트랜잭션 매니저 개념

PlatformTransactionManager의 역할

트랜잭션 매니저(Transaction Manager)는 데이터베이스와 애플리케이션 사이에서 트랜잭션을 제어하는 핵심 컴포넌트이다. 트랜잭션 시작, 커밋, 롤백 같은 동작을 표준화된 방식으로 제공한다.
DB 종류나 기술(JDBC, JPA 등)에 따라 구현체가 달라지지만 동일한 인터페이스로 관리된다.

트랜잭션 매니저 구현체

Spring은 데이터 접근 기술에 맞춰 다양한 트랜잭션 매니저 구현체를 제공한다.
대표적으로 DataSourceTransactionManager(JDBC), JpaTransactionManager(JPA/Hibernate), HibernateTransactionManager(Hibernate), JtaTransactionManager(분산 트랜잭션) 등이 있다.

JpaTransactionManager

JpaTransactionManager는 JPA 표준을 따르는 트랜잭션 매니저로, Spring에서 JPA 기반 데이터 접근 시 사용된다.
EntityManager를 통해 영속성 컨텍스트와 DB 트랜잭션을 일관되게 관리한다.
트랜잭션 시작, 커밋, 롤백을 자동으로 처리하여 개발자가 비즈니스 로직에만 집중할 수 있게 한다.

트랜잭션 전파와 격리 수준

트랜잭션 전파(Transaction Propagation)

트랜잭션 전파는 메서드 호출 시 기존 트랜잭션을 이어받을지, 새로운 트랜잭션을 생성할지 결정하는 동작 방식이다. 스프링은 REQUIRED, REQUIRES_NEW, NESTED 등 다양한 전파 옵션을 제공한다. 이를 통해 복잡한 계층 구조에서도 트랜잭션 경계를 유연하게 제어할 수 있다.

트랜잭션 전파 상세 옵션

@Transactional(propagation = Propagation.REQUIRES_NEW )

DB Lock의 개념

Lock은 트랜잭션(또는 세션)이 사용하는 자원에 대해 상호배제(Mutual Exclusion)를 보장하는 기능이다.
이는 하나의 트랜잭션이 특정 데이터 항목에 대해 잠금(Lock)을 설정하면, 해당 잠금이 해제되기 전까지 다른 트랜잭션은 해당 자원에 접근할 수 없도록 막는다. MySQL에서는 주로 세션 단위로 Lock을 설정하며, 세션이 비정상적으로 종료될 경우 해당 잠금이 해제되지 않으면 다른 트랜잭션은 해당 자원에 접근하지 못하는 문제가 발생할 수 있다.

Deadlock의 개념

Deadlock은 두 개 이상의 트랜잭션이 서로가 보유한 자원을 기다리며 무한 대기 상태에 빠지는 현상이다.
각 트랜잭션이 잠금(Lock)을 해제하지 않고 상대의 자원을 요청할 때 발생한다.
DBMS는 이를 감지하면 일반적으로 한 쪽 트랜잭션을 강제 종료(Rollback)시켜 교착 상태를 해소한다.

공유락(Shared Lock)과 배타락(Exclusive Lock)

공유락(Shared Lock) : 데이터를 읽을 수는 있지만, 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 하는 읽기 전용 잠금이다. (SELECT ... LOCK IN SHARE MODE)
배타락(Exclusive Lock) : 데이터를 읽고 수정할 수 있으며, 다른 트랜잭션의 읽기와 쓰기 모두 차단하는 쓰기 전용 잠금이다. INSERT문과 UPDATE문은 자동으로 배타락을 걸어준다. (SELECT ... FOR UPDATE)

Isolation Level(격리 수준, ANSI SQL 표준)

Isolation Level은 트랜잭션 간 데이터 접근을 어느 정도까지 격리할지를 정의하여, 동시성 문제를 방지하는 트랜잭션 속성이다. DB의 기본 격리 수준은 REPEATABLE READ이며, 이를 포함해 총 4단계(READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE)를 지원한다.
Isolation Level의 강도에 따른 정합성과 성능은 상충 관계임으로 적절한 구문으로 Level을 설정해야 한다.
→ LOCK과 Isolation은 상호 보완적으로 함께 사용되어야 데이터 무결성과 동시성을 유지할 수 있다.

격리 수준설명발생 가능한 문제특징/주의사항
READ UNCOMMITTED
(읽기 미보장)
아직 커밋되지 않은 데이터도 다른 트랜잭션에서 읽을 수 있음- Dirty Read
- Non-Repeatable Read
- Phantom Read
가장 낮은 격리 수준.
성능은 가장 좋지만 무결성 보장이 거의 없음.
READ COMMITTED
(커밋된 읽기)
커밋된 데이터만 읽음 (미커밋 데이터는 읽지 않음)- Non-Repeatable Read
- Phantom Read
대부분 DBMS 기본값
(Oracle, PostgreSQL 등).
성능과 일관성의 절충.
REPEATABLE READ
(반복 가능 읽기)
동일 트랜잭션 내에서 같은 쿼리 결과는 항상 동일- Phantom Read (새로운 행 삽입은 막지 못함)MySQL InnoDB 기본값.
Dirty Read, Non-Repeatable Read 방지.
SERIALIZABLE
(직렬화)
트랜잭션을 직렬 실행한 것과 같은 효과 → 완전한 일관성 보장없음 (모든 문제 방지)가장 높은 격리 수준.
무결성 보장은 확실하지만 동시성 성능 저하 심함.

트랜잭션 격리 수준에서 발생할 수 있는 현상들

Dirty Read (더티 리드)

정의

다른 트랜잭션에서 아직 커밋하지 않은 데이터를 읽는 현상

SQL 시나리오

-- Session A
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
-- 아직 COMMIT 안 함

-- Session B
BEGIN;
SELECT balance FROM account WHERE id = 1;
-- 결과: 200원 (커밋되지 않은 값 읽음)
COMMIT;

-- Session A
ROLLBACK;
-- Session B가 읽은 값은 존재하지 않음 (데이터 무결성 깨짐)

👉 발생 가능: READ UNCOMMITTED
👉 방지: READ COMMITTED 이상

Non-Repeatable Read (반복 불가능 읽기)

정의

같은 트랜잭션 내에서 같은 조건으로 두 번 조회했는데 값이 달라지는 현상

SQL 시나리오

-- Session A
BEGIN;
SELECT balance FROM account WHERE id = 1;
-- 결과: 100원

-- Session B
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;

-- 다시 Session A
SELECT balance FROM account WHERE id = 1;
-- 결과: 200원 (처음과 다름)
COMMIT;

👉 발생 가능: READ COMMITTED
👉 방지: REPEATABLE READ 이상

Phantom Read (팬텀 리드)

정의

같은 트랜잭션 내에서 같은 조건으로 조회했는데 행(row) 수 자체가 달라지는 현상

SQL 시나리오

-- Session A
BEGIN;
SELECT * FROM orders WHERE price > 100;
-- 결과: 2건

-- Session B
BEGIN;
INSERT INTO orders (id, price) VALUES (3, 200);
COMMIT;

-- 다시 Session A
SELECT * FROM orders WHERE price > 100;
-- 결과: 3건 (새로운 행이 "팬텀"처럼 나타남)
COMMIT;

👉 발생 가능: REPEATABLE READ
👉 방지: SERIALIZABLE

요약

현상설명발생 격리 수준방지 격리 수준
Dirty Read커밋 전 데이터를 다른 트랜잭션이 읽음READ UNCOMMITTEDREAD COMMITTED 이상
Non-Repeatable Read같은 조건의 조회 결과가 달라짐READ COMMITTEDREPEATABLE READ 이상
Phantom Read같은 조건의 조회에서 행 수가 달라짐REPEATABLE READSERIALIZABLE

트랜잭션 예외 처리와 롤백

트랜잭션 기본 롤백 규칙

스프링의 @Transactional은 기본적으로 런타임 예외(언체크 예외, RuntimeException 및 그 하위 클래스)와 에러(Error) 발생 시 롤백한다. 반면 체크 예외(Exception) 발생 시에는 기본적으로 롤백하지 않고 커밋한다.

rollbackFor / noRollbackFor 설정

rollbackFor: 지정한 예외가 발생하면 롤백을 강제로 수행한다. (체크 예외도 롤백 가능)

@Transactional(rollbackFor = Exception.class)
public void saveData() { ... }

noRollbackFor: 지정한 예외는 발생하더라도 롤백하지 않고 커밋한다.

@Transactional(noRollbackFor = IllegalArgumentException.class)
public void updateData() { ... }

실무 적용 가이드

트랜잭션 경계 설정

트랜잭션 시작 위치 결정

  • @Transactional이 붙은 스프링 빈의 public 메서드를 외부에서 호출할 때 AOP 프록시가 트랜잭션을 시작한다.
  • 같은 클래스 내부의 자기 호출은 프록시를 거치지 않으므로 트랜잭션이 적용되지 않는다.
  • JDK 동적 프록시는 인터페이스 기반, CGLIB 프록시는 클래스 기반이며 final 메서드에는 적용되지 않는다.
@Service
public class PaymentService {

	// 트랜잭션 시작: 컨트롤러 등 외부에서 호출될 때 프록시가 경계를 연다
	@Transactional
	public void pay(PaymentCommand cmd) {
		// 비즈니스 로직
		// 내부에서 helper()를 호출해도 프록시를 거치지 않아 새 전파 옵션이 적용되지 않는다
		helper(cmd);
	}

	// self-invocation: 프록시 미적용
	@Transactional(propagation = Propagation.REQUIRES_NEW)
	public void helper(PaymentCommand cmd) { /* ... */ }
}

서비스 계층의 트랜잭션 처리

  • 서비스 레이어를 경계의 기본 단위로 삼아 한 작업 단위를 하나의 트랜잭션으로 묶는다.
  • 읽기/쓰기 분리를 적용하여 조회 전용은 readOnly = true로 최적화하고, 변경 작업만 쓰기 트랜잭션으로 처리한다.
  • 전파, 격리, 타임아웃은 필요한 곳에서만 최소한으로 상향한다.
@Service
public class OrderService {

	// 조회 전용 경계
	@Transactional(readOnly = true)
	public OrderView getOrder(Long id) {
		return /* 조회 로직 */;
	}

	// 쓰기 경계
	@Transactional(timeout = 5, isolation = Isolation.READ_COMMITTED )
	public void placeOrder(OrderCommand cmd) {
		inventoryService.reserve(cmd.getProductId(), cmd.getQty()); // REQUIRED
		outboxService.saveOutbox(cmd); // REQUIRES_NEW로 분리 가능 시나리오도 존재
		// 여기서 호출하면 DB 락 유지 시간이 늘어난다 → 분리 권장
	}
}

트랜잭션 경계 설정 모범 사례

  • 짧고 작은 경계를 유지하며 네트워크 호출, 파일 I/O, 메시지 발행 대기 등은 트랜잭션 밖으로 분리한다.
  • 서비스에서만 선언하고 리포지토리에는 중복 선언하지 않는다. 경계가 흩어지면 전파 규칙을 추적하기 어렵다.
  • 읽기/쓰기 분리 기본값을 팀 표준으로 정한다. 기본은 @Transactional(readOnly = true)와 하고, 쓰기가 필요한 메서드에만 쓰기 트랜잭션을 부여한다.
  • 전파/격리는 최소한으로 사용하고 문서화한다. REQUIRES_NEW, NESTED, SERIALIZABLE은 부작용이 크므로 꼭 필요한 경우에만 사용한다
@Service
public class RefundService {

	// 기본 규칙: RuntimeException/Errors → 롤백, Checked Exception → 커밋
	// 체크 예외에도 롤백이 필요하면 rollbackFor로 명시한다
	@Transactional(rollbackFor = {IOException.class, Exception.class})
	public void refund(RefundCommand cmd) throws IOException {
		// 로직...
		if (/* 외부 IO 실패 */) {
			throw new IOException("외부 환불 API 실패"); // 체크 예외지만 롤백된다
		}
	}

	// 특정 예외는 커밋 강제
	@Transactional(noRollbackFor = IllegalArgumentException.class)
	public void adjust(AdjustCommand cmd) {
		// 유효성 문제는 롤백하지 않고 보정 처리 후 커밋
		if (/* 경미한 파라미터 오류 */) throw new IllegalArgumentException("보정 가능 오류");
	}
}

readOnly 속성 활용

  • @Transactional(readOnly = true)는 영속성 컨텍스트의 쓰기 관련 작업과 더티체킹을 최소화하고, 드라이버/DB에 읽기 전용 힌트를 전달하여 불필요한 플러시를 억제하는 최적화이다.
  • JPA에서 플러시가 지연되거나 수동 모드로 전환되어 쓰기 비용이 줄어들며, 읽기 전용 조회의 처리량이 향상
  • 읽기 전용 경계에서 엔티티를 변경하면 변경 내용이 커밋되지 않거나 무시될 수 있으므로, 쓰기 작업은 분리 권장
@Service
public class PostQueryService {

	// 조회 전용: 더티체킹/플러시 최소화
	@Transactional(readOnly = true)
	public PostView findOne(Long id) {
		// 순수 조회 로직만 유지
		return /* 조회 */;
	}
}

트랜잭션 시간 최소화

  • 트랜잭션은 락과 자원을 점유하므로 가능한 짧게 유지하는 것이 성능과 동시성 측면에서 유리하다.
  • 원격 호출, 파일 I/O, 대기 시간이 긴 연산을 트랜잭션 밖으로 분리, 트랜잭션에는 최소한의 DB 변경 작업만 배치한다.
  • 선조회→계산→최소 변경의 순서로 구성하고, 배치 쓰기는 벌크 API와 배치 크기 튜닝으로 커밋 단위를 최적화한다.
@Service
@RequiredArgsConstructor
public class OrderOrchestrator {
	private final OrderService orderService;
	private final PaymentGateway paymentGateway;

	public void place(OrderCommand cmd) {
		// 트랜잭션 밖: 외부 결제 준비, 서명 생성 등 장기 연산
		String token = paymentGateway.prepare(cmd);

		// 트랜잭션 안: 최소 변경만 수행
		orderService.place(cmd, token);
		
        // 트랜잭션 밖: 후속 알림/웹훅 대기 등
		paymentGateway.confirm(cmd);
	}
}

@Service
public class OrderService {
	@Transactional(timeout = 5, isolation = Isolation.READ_COMMITTED)
	public void place(OrderCommand cmd, String token) {
		// 재고 차감, 주문 레코드 저장 등 최소 변경
	}
}

불필요한 트랜잭션 제거

  • 쓰기가 없는 경로나 캐시·조회 전용 경로에 트랜잭션을 부여하면 락/커넥션 점유만 늘어나므로 제거 권장
  • 트랜잭션 선언은 서비스 계층에서만 관리하고, 리포지토리·헬퍼 레이어에는 중복 선언을 피한다
  • 외부로부터 트랜잭션이 전파되면 안 되는 읽기 작업에는 NOT_SUPPORTED로 아예 트랜잭션 참여를 금지한다
@Service
public class CatalogService {

	// 상위 트랜잭션이 있더라도 참여하지 않음 → 커넥션/락 점유 최소화
	@Transactional(propagation = Propagation.NOT_SUPPORTED)
	public List<ProductView> hotItems() {
		// 캐시 우선 조회 또는 단순 읽기
		return /* 조회 */;
	}
}

// 잘못된 예: 리포지토리에 트랜잭션을 붙이면 경계가 흩어져 전파/락 추적이 어려워진다
@Repository
public class BadRepository {
	// @Transactional <-- 제거 권장
	public void save(...) {}
}

흔한 실수와 해결방안

내부 메소드 호출 문제

  • 같은 클래스 내부에서 this.inner()로 @Transactional을 호출하면 프록시를 거치지 않아 트랜잭션이 시작되지 않는다.
  • 전파·격리·타임아웃·readOnly 설정이 적용되지 않고, 더티체킹/플러시 타이밍이 어긋난다.
  • 구조를 분리해 별도 빈으로 옮기거나, 최소한 AopContext.currentProxy()로 프록시를 통해 호출한다.
@Service
@RequiredArgsConstructor
public class OrderFacade {
	private final OrderService orderService; // 비즈 로직
    private final AuditService auditService; // 분리된 빈

	@Transactional
	public void place(OrderCommand cmd) {
		orderService.core(cmd); // REQUIRED
		auditService.writeLog(cmd); // REQUIRES_NEW 정상 적용
	}
}

@Service
public class AuditService {
	@Transactional(propagation = Propagation.REQUIRES_NEW)
	public void writeLog(OrderCommand cmd) { /* ... */ }
}

트랜잭션 전파 실수

  • 기본값 REQUIRED의 의미를 오해하거나, REQUIRES_NEW를 남용해 예상치 못한 부분 커밋·롤백이 발생한다.
  • 내부 실패가 바깥 트랜잭션까지 전파되어 전체 롤백되거나, 반대로 REQUIRES_NEW로 일부가 먼저 커밋되어 불일치
  • 기본은 REQUIRED를 사용하고, 외부 영향 없이 반드시 기록되어야 하는 부수 작업(감사 로그, 아웃박스)만
    REQUIRES_NEW로 제한한다. NESTED는 드라이버/데이터소스 지원 여부를 확인한다.
@Service
public class OutboxService {
	// 반드시 남겨야 하는 이벤트 기록에만 REQUIRES_NEW 사용
	@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
	public void save(OutboxEvent e) { /* insert ... */ }
}

@Service
@RequiredArgsConstructor
public class CheckoutService {
	private final OrderRepository orders;
	private final OutboxService outbox;

	@Transactional // REQUIRED
	public void checkout(Cart c) {
		orders.save(...); // 본거래
		outbox.save(...); // 부분 커밋 허용 의도적 사용
	}
}

긴 트랜잭션 처리

  • 네트워크 호출, 외부 API 대기, 대용량 파일 처리 등을 트랜잭션 내부에 포함시켜 락 보유 시간이 길어지고
    동시성 저하와 데드락 위험이 커진다.
  • 커넥션 고갈, 타임아웃, 대기열 증가, 교착 상태 등이 발생한다.
  • 트랜잭션 경계를 짧게 유지한다. 외부 연동은 트랜잭션 밖으로 분리하거나 비동기 패턴을 사용한다.
@Service
@RequiredArgsConstructor
public class OrderOrchestrator {
	private final OrderService orderService;
	private final PaymentGateway paymentGateway;

	public void place(OrderCommand cmd) {
		String token = paymentGateway.prepare(cmd); // 트랜잭션 밖
		orderService.place(cmd, token); // 최소 변경만 트랜잭션 안
		paymentGateway.confirm(cmd); // 트랜잭션 밖
	}
}

@Service
public class OrderService {
	@Transactional(timeout = 5, isolation = Isolation.READ_COMMITTED)
	public void place(OrderCommand cmd, String token) {
		// 재고 차감·주문 저장 등 DB 변경 최소화
    }
}

예외 처리 누락

  • @Transactional의 기본 규칙(언체크 예외/에러만 롤백)을 모르거나, 체크 예외를 잡아먹으면서도 롤백 설정을 하지 않아 데이터가 커밋된다.
  • 외부 API 실패(체크 예외) 후에도 데이터가 커밋되거나, try-catch 후 예외를 삼켜 일관성이 깨진다.
  • 예외 정책을 코드로 고정한다. 체크 예외에도 롤백이 필요하면 rollbackFor를 명시하고, 잡은 예외는 도메인 런타임 예외로 래핑해 다시 던진다.
@Service
public class RefundService {

	// 체크 예외도 롤백되게 명시
	@Transactional(rollbackFor = {IOException.class, Exception.class})
	public void refund(RefundCommand cmd) throws IOException {
		// 외부 환불 API 호출 실패 시 IOException 발생 → 롤백
		callExternal();
	}

	// 예외를 삼키지 말고 런타임으로 래핑
	@Transactional
	public void adjust(AdjustCommand cmd) {
		try {
			callPartner();
		} catch (PartnerCheckedException e) {
			throw new BusinessException("조정 실패", e); // 언체크 → 롤백
		} 
	}
}
profile
Backend engineer

0개의 댓글