[Spring Boot] - @Transactional

정원준·2025년 1월 2일

Back-End

목록 보기
8/9

해당 포스팅에선 트랜잭션에 대해 작성해보고자 한다.

Transaction

트랜잭션의 정의는 다음과 같다.

데이터베이스의 상태를 변화시키기 해서 수행하는 작업의 단위

데이터베이스의 상태를 변화시키기 위해선 데이터베이스에 접근해야 하지 않겠는가?
접근할 때 사용하는 질의어(SQL)는 다음과 같다.

  • INSERT ( 추가 )
  • SELECT ( 조회 )
  • UPDATE ( 수정 )
  • DELETE ( 삭제 )

작업의 단위라 함은 질의어 하나가 아닌 원하는 목표를 이루기 위한 질의어의 묶음이라고 생각할 수 있겠다.
예를 들어, 상품 목록을 제어하는 상황을 가정해보자.
A 품목에 대하여 1개를 빼는 연산을 진행시키고( UPDATE ) 그 후 A 품목에 대하여 수정이 잘 이루어졌는지 다시 확인( SELECT )하고자 한다.
이 질의어는 2개가 사용되었지만, 작업 단위는 하나의 트랜잭션이다.

특징

특징은 크게 4가지로 분류된다.

원자성 (Atomicity)

트랜잭션이 데이터베이스에 모두 반영되던가, 아니면 전혀 반영되지 않아야 한다

일관성 (Consistency)

트랜잭션의 작업 처리 결과는 항상 일관성이 있어야 한다

독립성 (Isolation)

둘 이상의 트랜잭션이 동시에 실행되고 있을 때 어떤 하나의 트랜잭션이라도, 다른 트랜잭션의 연산에 끼어들 수 없다

지속성 (Durability)

트랜잭션이 성공적으로 완료됐을 때, 결과는 영구적으로 반영되어야 한다

@Transactional

스프링 부트에서 @Transactional을 사용할 때 다음과 같은 작업을 수행할 수 있다.

  • 트랜잭션 관리 자동화
  • 트랜잭션 전파 (Propagation)
  • 격리 수준 (Isolation Level)
  • 트랜잭션 롤백 제어
  • 읽기 전용 트랜잭션
  • 트랜잭션 시간 제한

하나하나 자세히 알아보기 전에 어노테이션을 어떻게 사용하는지, 적용되는 우선순위는 어떻게 되는지, 먼저 알아보자.

동작원리

@Transactional은 Spring AOP를 통해 구현되어있다.
동작은 다음과 같다.

  1. 클래스, 메소드에 @Transactional이 선언되면 해당 클래스에 트랜잭션이 적용된 프록시 객체 생성
  2. 프록시 객체는 @Transactional이 포함된 메서드가 호출될 경우, 트랜잭션을 시작하고 Commit or Rollback을 수행
  3. CheckedException이거나 예외가 없을 때는 Commit
  4. UncheckedException이 발생하면 Rollback

사용법

사용법은 간단하다. 클래스나 메서드 위에서 @Transactional 을 선언하면 끝이다.
클래스에서 @Transactional을 붙이게 되면 클래스에 내포된 모든 메서드에 적용되고, 메서드에 선언하게 되면 해당 메서드에만 적용된다.

우선순위

@Transactional이 적용되는 우선 순위는 다음과 같다.

  1. 클래스 내의 메서드
  2. 클래스
  3. 인터페이스 내의 메서드
  4. 인터페이스

사실상 인터페이스에선 @Transactional을 사용할 것 같지는 않다.

@Transactional 모드

두 가지 모드가 존재하는데, Proxy Mode와 AspectJ Mode로 나뉘어진다. (Proxy Mode가 Default로 설정)
Proxy Mode는 다음과 같은 때 다음 상황을 주의해야한다.

  • 반드시 public 메서드에 적용
    - Protected, Private Method에서는 에러 발생 x 동작 x
    - Non-Public 메서드에 적용하고 싶으면 AspectJ Mode
  • @Transactional이 적용되지 않은 메서드에서 @Transactional이 적용된 Public Method를 호출할 경우, 트랜잭션이 동작하지 않는다.

그렇다면 이제 본격적으로 해당하는 작업에 대해 알아보자.

트랜잭션 관리 자동화

이건 뭐 사실 별 거 없다.
길게 설명하자면 다음과 같이 설명할 수 있다.

데이터베이스 작업이 성공적으로 수행되면 자동으로 커밋되고, 예외가 발생하면 자동으로 롤백된다

그렇다면 여기서 의문이 생긴다.

엥? 그럼 그냥 우리가 JPA메소드 사용해서 save()하는 거랑 뭐가 달라요?

예를 들어 설명하자면, 여러 데이터베이스 작업(INSERT, UPDATE, DELETE 등)을 하나의 트랜잭션으로 묶어야 할 때 사용된다.
하지만 트랜잭션을 최소한으로 구분하는 것이 좋은 프로그래밍이라고도 하니,, 상황에 맞게 사용하면 되지 않나 싶다!

트랜잭션 전파 (Propagation)

@Transactional은 트랜잭션 전파 속성을 설정하여 메서드 호출 간 트랜잭션의 동작 방식을 제어할 수 있다.
이제부터 머리가 좀 아파지기 시작한다.
전파에 대해 설명하려면 트랜잭션에 대해 자세히 알아야한다.

  • 트랜잭션은 하나의 Connection을 가져와 사용하다가 닫는 사이에서 발생한다.
  • 트랜잭션의 시작과 종료는 Connection 객체를 통해 이뤄진다.
  • 하나의 트랜잭션이 시작 후 commit() 또는 rollback() 호출될 때 까지가 하나의 트랜잭션이다.
  • 해당 작업을 트랜잭션의 경계설정이라고 한다.

그래 오케이. 트랜잭션이 어떻게 스프링에서 작동하는 지 알겠어. 그래서 전파 속성이 뭔데?

@Transactional을 사용하면 여러 트랜잭션을 묶어서 커다란 하나의 트랜잭션 경계를 만들 수 있다.

프로젝트를 진행하다보면 기존의 트랜잭션이 진행중일 때, 추가적인 트랜잭션이 필요한 경우가 있다.
이 때, 이미 트랜잭션이 진행중일 때 추가 트랜잭션 진행을 어떻게 할지 결정하는 것이 전파 속성(Propagation)이다.
속성에 따라, 기존의 트랜잭션에 참여하거나, 별도의 트랜잭션으로 진행하는 등 선택이 가능하다.

물리 트랜잭션

위에 말했듯이 트랜잭션은 커넥션 객체를 통해 처리한다.
따라서 1개의 트랜잭션을 사용한다는 것은 하나의 커넥션 객체를 사용한다는 것이고, 실제 데이터베이스의 트랜잭션을 사용한다는 것을 물리 트랜잭션이라고 한다.

논리 트랜잭션

논리 트랜잭션은 Spring의 PlatformTransactionManager 인터페이스를 기반으로 구현된다.
이렇게만 설명하면 논리 트랜잭션이 이해가 잘 가지 않을 것이다.
논리 트랜잭션을 간단하게 설명하면 스프링이 트랜잭션 매니저를 통해 트랜잭션을 관리하는 단위이다.
여기서 정말 중요한 특징은 다음과 같다.

  • 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
  • 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.

이에 대한 자세한 내용은 전파 속성에 대해 설명하면서 다루도록 하겠다.

전파 속성

전파 속성은 총 7가지가 존재한다. 가장 많이 쓰이는 속성은 REQUIRED와 REQUIRES_NEW이다.

REQUIRED

REQUIRED는 스프링이 제공하는 DEFAULT 전파 속성으로 2개의 논리 트랜잭션을 묶어 1개의 물리 트랜잭션을 사용하는 것이다. 쉽게 말해, 현재 실행 중인 트랜잭션에 참여하는 것이다.
이렇게만 설명하면 이해하기 어려울테니 코드를 보자

@Service
public class TransactionService {

    @Transactional(propagation = Propagation.REQUIRED)
    public void transactionA() {
    	// 논리 트랜재션 1로서 물리 트랜잭션을 직접 관리
        System.out.println("Transaction A started");

        transactionB(); // 호출

        System.out.println("Transaction A ended");
        // 물리 트랜잭션 커밋
    }

	// propagation = Propagation.REQUIRED
    @Transactional()
    public void transactionB() {
        // 논리 트랜잭션 2로서 물리 트랜잭션 1에 참여
        System.out.println("Transaction B started");

        // 비즈니스 로직 수행
        System.out.println("Transaction B ended");
        // 논리 트랜잭션 2 커밋
    }
}

코드를 보면 알겠지만 REQUIRED를 사용하면 transactionB()가 커밋이여야 transactionA() 또한 커밋이 되는 것이다.

REQUIRES_NEW

REQUIRES_NEW는 논리 트랜잭션이 완전히 독립적으로 분리된다. 즉, 현재 실행 중인 트랜잭션을 중단하고 새로운 트랜잭션을 생성하는 것이다.
코드로 보자

@Service
public class TransactionExample {

	// propagation = Propagation.REQUIRED
    @Transactional()
    public void parentTransaction() {
        // 물리 트랜잭션 1 시작
        System.out.println("Parent Transaction started");

        childTransaction(); // 호출

        System.out.println("Parent Transaction ends");
        // 물리 트랜잭션 1 커밋
    }

	// childTransaction이 호출되면서 물리 트랜잭션 2가 새로 생성
    // 논리 트랜잭션(Child)은 물리 트랜잭션 2를 완전히 독립적으로 관리하며,
    // Parent와 Child는 서로 영향을 주지 않는다.
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void childTransaction() {
        // 물리 트랜잭션 2 시작
        System.out.println("Child Transaction started");

        // 비즈니스 로직 수행
        System.out.println("Child Transaction ends");
        // 물리 트랜잭션 2 커밋
    }
}

여기선 좀 다르다. childTransaction()이 커밋이 되지 않더라도 parentTransaction()은 커밋이 진행된다.
그럼 이제 나머지 속성에 대해서도 빠르게 설명하겠다.

SUPPORTS

  • 트랜잭션이 존재하면 그 트랜잭션에 참여한다. ( REQUIRED와 동일 )
  • 트랜잭션이 없으면 트랜잭션 없이 실행한다. ( 즉 트랜잭션의 유무에 민감하지 않다. )
  • 트랜잭션 경계가 필요 없는 읽기 전용 작업이나 유연한 트랜잭션 관리를 위해 사용한다.
  • ex) 로그 기록, 데이터 조회와 같은 트랜잭션 필요 여부가 선택적인 작업
// SUPPORTS 속성
@Transactional(propagation = Propagation.SUPPORTS)
public void someMethod() {
    // 트랜잭션이 있으면 참여, 없으면 비트랜잭션 상태로 실행
}

NOT_SUPPORTED

  • 트랜잭션이 존재하면 일시 중단(suspend)하고, 트랜잭션 없이 실행한다.
  • 트랜잭션을 강제적으로 사용하지 않도록 보장한다.
  • 트랜잭션이 필요하지 않은 작업에서 사용한다.
  • 트랜잭션 경계로 인해 성능 저하가 발생할 수 있는 작업에 적합하다.
  • ex) 대규모 파일 처리, 비즈니스 로직과 독립된 백그라운드 작업
// NOT_SUPPORTED 속성
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void someMethod() {
    // 트랜잭션을 중단하고 트랜잭션 없이 실행
}

MANDATORY

  • 반드시 기존 트랜잭션이 존재해야 한다.
  • 트랜잭션이 없으면 예외(IllegalTransactionStateException)를 발생시킨다.
  • 트랜잭션 경계를 명확히 요구하며, 트랜잭션 없이 실행되지 않도록 강제성을 부여한다.
  • 트랜잭션이 필수적인 데이터 작업에 적합하다.
  • ex) 항상 트랜잭션 내에서 실행되어야 하는 보조 작업
// MANDATORY 속성
@Transactional(propagation = Propagation.MANDATORY)
public void someMethod() {
    // 트랜잭션이 없으면 예외 발생
}

NEVER

  • 트랜잭션이 없어야 한다.
  • 트랜잭션이 존재하면 예외(IllegalTransactionStateException)를 발생시킨다.
  • 트랜잭션이 필요 없는 환경에서 트랜잭션 사용을 엄격히 방지한다.
  • 비트랜잭션 상태에서 실행이 보장되어야 하는 작업에 적합하다.
  • ex) 트랜잭션으로 인해 발생할 수 있는 부작용을 방지해야 하는 작업
// NEVER 속성
@Transactional(propagation = Propagation.NEVER)
public void someMethod() {
    // 트랜잭션이 존재하면 예외 발생
}

NESTED

  • 트랜잭션이 존재하면 해당 트랜잭션 내에 중첩된 트랜잭션을 시작한다.
  • 트랜잭션이 없으면 새 트랜잭션을 생성한다.
  • 중첩 트랜잭션은 부모 트랜잭션의 커밋/롤백 여부에 영향을 받는다.
  • SAVEPOINT를 사용하여 부분 롤백이 가능하다.
  • ex) 큰 트랜잭션 내에서 부분적으로 롤백 가능한 작업이 필요한 경우
@Transactional(propagation = Propagation.NESTED)
public void someMethod() {
    // 중첩된 트랜잭션 실행 (부모 트랜잭션의 영향을 받음)
}

중첩된 트랜잭션이라는 말이 잘 이해가 가지 않았다. 알아보니 새로운 트랜잭션을 시작하게 되는 것인데, 차이가 있다면 savepoint를 사용한다는 것이다.
그렇다면 REQUIRES_NEW와 무슨 차이가 있겠는가 싶어서 정리한 표는 다음과 같다.

savepoint가 설정되는 지점해당 메서드가 호출되는 지점이다.
여기까지 전파 속성에 대해 알아보았는데, 여기서 궁금한 점이 하나 있었다.
물리 트랜잭션 하나에서 여러개의 논리 트랜잭션이 발생했을 때, 커밋이 전부 롤백되는가에 대해 알아보았다. 코드로 설명하는 게 편하겠다고 생각이 든다.

@Transactional(propagation = Propagation.REQUIRED)
public void parentTransaction() {
    // 물리 트랜잭션 시작
    childTransactionA(); // 논리 트랜잭션 A
    childTransactionB(); // 논리 트랜잭션 B
    // 물리 트랜잭션 커밋 또는 롤백
}

@Transactional(propagation = Propagation.REQUIRED)
public void childTransactionA() {
    // 논리 트랜잭션 A 실행
    // 데이터 업데이트 또는 저장
    System.out.println("Child Transaction A completed");
}

@Transactional(propagation = Propagation.REQUIRED)
public void childTransactionB() {
    // 논리 트랜잭션 B 실행
    // 예외 발생으로 롤백
    throw new RuntimeException("Child Transaction B failed");
}

위와 같은 상황일 때, 논리 트랜잭션 A는 커밋될 것인가? 롤백될 것인가?
정답은 롤백이 진행된다.
"논리 트랜잭션 A가 커밋됐다" 라는 표현보다는 논리 트랜잭션 A의 작업이 물리 트랜잭션 내에서 기록됐다" 라고 표현하는 게 맞다고 생각한다.

트랜잭션 격리 수준 (Isolation Level)

격리 수준에 대한 정의는 다음과 같다.

데이터베이스의 트랜잭션 간 상호 간섭을 제어하는 방법을 정의하는 설정

이게 왜 필요한가..에 대한 답변은 다음과 같다.
예를 들어 금전 서비스를 진행한다고 해보자.
A에서 B에게 송금할 때, 또는 뭐 A 혼자 계좌에 돈을 입금할 때, 가장 중요한 건 이 아니겠는가?
해당 금액은 다른 무엇보다도 데이터베이스가 일관성이 있어야하고 데이터 무결성이 보장되어야 한다.
SQL 표준에 정의된 격리 수준은 총 4가지가 있다.
격리 수준에 따라 발생할 수 있는 문제는 다음과 같다.

  • Dirty Read
    - 한 트랜잭션이 커밋되지 않은 데이터를 읽는 문제
    - 읽은 데이터가 이후 롤백되면 잘못된 데이터를 읽게 되는 문제
  • Non-repeatable Read
    - 한 트랜잭션이 동일한 데이터를 여러 번 읽을 때, 다른 트랜잭션에 의해 데이터가 변경되어 결과가 달라지는 문제
  • Phantom Read
    - 한 트랜잭션이 특정 조건으로 데이터를 조회할 때, 다른 트랜잭션에 의해 새로운 데이터가 추가되거나 삭제되어 조회 결과가 달라지는 문제

그럼 이제 격리 수준을 하나씩 설명해보고자 한다.

READ UNCOMMITTED

  • 트랜잭션이 커밋되지 않은 변경사항도 다른 트랜잭션에서 읽을 수 있다.
  • 가장 낮은 격리 수준으로, 동시성은 높지만 데이터 무결성을 보장하지 못한다.
  • 발생할 수 있는 문제는 Dirty Read , Non-repeatable Read , Phantom Read가 있다.
  • 읽기/쓰기 성능이 가장 좋다.
  • ex) 데이터 일관성이 중요하지 않은 환경에서 사용

READ COMMITTED

  • 커밋된 데이터만 읽을 수 있다.
  • 트랜잭션 동안 다른 트랜잭션이 커밋되지 않은 변경사항은 읽을 수 없다.
  • 발생할 수 있는 문제는 Non-repeatable Read , Phantom Read가 있다.
  • Dirty Read 문제를 방지하지만 성능 저하가 크지 않다.
  • 대부분의 애플리케이션에서 기본적으로 사용

REPEATABLE READ

  • 트랜잭션이 같은 데이터를 여러 번 읽을 때 항상 동일한 결과를 보장한다.
  • 트랜잭션이 완료될 때까지 읽은 데이터가 다른 트랜잭션에 의해 수정되지 않는다.
  • 발생할 수 있는 문제는 Phantom Read가 있다.
  • 데이터 읽기의 일관성을 보장한다.
  • ex) 재고 관리 시스템처럼 동일한 데이터를 여러 번 읽어야 하는 경우

SERIALIZABLE

  • 가장 높은 격리 수준으로, 모든 트랜잭션을 직렬화하여 실행한다.
  • 트랜잭션 간의 완전한 격리를 보장한다.
  • 격리 수준에 따라 발생할 수 있는 모든 문제를 방지한다.
  • 데이터 무결성과 일관성을 완벽히 보장한다.
  • 대폭적인 성능 저하와 동시성 처리 능력 감소한다.
  • ex) 은행 계좌 잔액 확인처럼 데이터 무결성이 매우 중요한 경우

이것들을 표로 정리해보면 다음과 같다.

격리 수준도 생각할 게 참 많다..

트랜잭션 롤백 제어

@Transactional을 사용하게 되면 트랜잭션을 롤백할지 말지 커스터마이징 할 수 있다.
rollbackFor와 noRollbackFor 속성 등을 사용해서 가능하게 되는데, 이건 코드들로만 보고 넘어가도 될 것 같다.

rollbackFor

@Transactional(rollbackFor = {IOException.class})
public void someMethod() throws IOException {
    // IOException 발생 시 롤백
    throw new IOException("An IO error occurred");
}

noRollbackFor

@Transactional(noRollbackFor = {IllegalArgumentException.class})
public void someMethod() {
    // IllegalArgumentException 발생 시 롤백하지 않음
    throw new IllegalArgumentException("Invalid argument provided");
}

rollbackForClassName

@Transactional(rollbackForClassName = {"CustomException"})
public void someMethod() {
    // "CustomException" 클래스 이름에 해당하는 예외 발생 시 롤백
    throw new CustomException("Custom exception occurred");
}

noRollbackForClassName

@Transactional(noRollbackForClassName = {"CustomException"})
public void someMethod() {
    // "CustomException" 클래스 이름에 해당하는 예외 발생 시 롤백하지 않음
    throw new CustomException("Custom exception occurred");
}

읽기 전용 트랜잭션

읽기 전용 트랜잭션은 데이터베이스에서 데이터를 조회만 수행하고, 변경 작업(INSERT, UPDATE, DELETE 등)을 하지 않도록 제한하는 트랜잭션이다.
readOnly 속성을 통해 설정할 수 있다.
예시 코드만 보고 넘어가겠다.

@Transactional(readOnly = true)
public List<MyEntity> fetchEntities() {
    return myEntityRepository.findAll();
}

트랜잭션 시간 제한

트랜잭션 시간 제한(Transaction Timeout)은 트랜잭션이 최대 실행될 수 있는 시간을 설정하여, 해당 시간이 초과되면 트랜잭션을 자동으로 롤백하는 기능이다.
장기 실행 작업이나 무한 대기 상태로 인해 시스템 리소스가 소모되는 것을 방지하기 위해 사용된다.

동작 방식

동작 방식은 어렵지 않다.
지정된 시간(초 단위)을 초과하면 트랜잭션이 강제로 롤백됩니다.
따로 어려울 게 없으므로 이것도 코드만 보고 끝내자.. 이제,,

@Transactional(timeout = 5)
public void performTransactionalOperation() {
    // 이 트랜잭션은 최대 5초 동안만 실행
    // 5초를 초과하면 트랜잭션 롤백
}

마무리

value값도 설정해서 사용할 수 있는데,, 이건 알아보니 여러 데이터 소스나 다양한 트랜잭션 관리 방식을 사용할 때 사용한다고 한다. 이건.. 언젠가 실무에서 공부하게 될 때 그 때 다시 정리해보도록 하겠다..
포스팅 길이가 미친 것 같지만,, 하나의 포스팅에서 정리할 수 있었다는 것에 만족하련다,,
꽤 의미있는 공부가 된 것 같다!




참고문헌

https://imiyoungman.tistory.com/9
https://mangkyu.tistory.com/269

2개의 댓글

comment-user-thumbnail
2025년 1월 5일

트랜잭션 타임아웃에 대해서 찾아봐야지 하고 있었는데, 되게 간단하게 설정할 수 있다는걸 알게 되었네요

1개의 답글