@Transactional 어노테이션 🔫 정리

도비·2023년 10월 15일
3

Spring Boot

목록 보기
3/13
post-thumbnail

Transaction

@Transactional 어노테이션을 클래스, 메서드에 붙이기만 하면 Transaction이 보장된 메서드가 실행된다. 코드에 트랜잭션의 시작과 종료를 호출하는 코드를 작성할 필요가 없다. spring-data-jpa와 함께 사용할 경우 데이터베이스의 트랜잭션 시작과 커밋(또는 롤백)이 알아서 된다.
이렇게 간편하게 어노테이션을 통해서 Transaction(트랜잭션)을 보장한다. 이 때, Transaction(트랜잭션)은 데이터베이스의 상태를 변화시키기 해서 수행하는 작업들을 쪼갤 수 없는 단위의 작업으로 나누고, 그 작업들을 논리적으로 묶은 최소 단위로 묶은 것이다.

Transaction은 ACID라는 특징을 가지고 있는데, 정리해보자면 다음과 같다.

  • 원자성(Atomicity)
    트랙잭션의 연산은 DB에 모두 반영되던지, 모두 반영되지 않아야 한다. 하나라도 실패한다면 앞서 성공한 것들을 원상 복구 시켜야한다.

  • 일관성(Consistency)
    트랜잭션의 작업 처리 결과는 항상 일관성 있어야 한다.

  • 독립성(Isolation)
    어떤 트랜잭션도 다른 트랜잭션의 작업에 끼어들 수 없다.

  • 지속성(Durability)
    트랜잭션이 완료된다면, 결과는 영구적이어야 한다.

그럼 이 때, 트랜잭션이 원자성을 지키는 방법이 뭘까?

트랜잭션은 로직을 수행하고 모든 로직이 성공적으로 수행되었을 경우에는 모든 결과를 DB에 일괄적으로 commit하고 하나라도 실패하면 모든 작업을 원상 복구(rollback) 시킨다.

코드로 보면 어떻게 보장하면 될까?

try {
// 한번에 처리되어야 하는 로직
	connection.commit();	
} catch(Exception e) {
	connection.rollback();
	throw new RemittanceException();
}

이렇게 Transaction에 관한 처리를 해줄 수 있지만, 아래의 문제가 있다.

  • Transaction의 원자성을 보장해주기 위한 코드가 비지니스 로직과 함께 존재한다. 따라서, 개발자는 핵심 비지니스 로직에 집중할 수 없다.
  • Transaction에 관한 connection 관련 코드와 try-catch 코드가 중복된다. Transaction을 처리해주어야하는 곳이 여러 곳이라면 해당 try-catch를 반복적으로 작성해줘야 한다.

그래서 Spring은 AOP를 제공해준다.

AOP가 뭔데요?

AOP는 Aspect Oriented Programming, 관점지향 프로그래밍을 말한다.
관점지향 프로그래밍이란, 횡단 관심사에 집중하는 것을 말한다.

위와 같이 계좌 이체, 입출금, 이자계산이라는 기능이 있고, 세 기능은 로깅, 트랜잭션, 보안에 대한 처리를 해주어야 한다고 가정하자. 비지니스 로직을 제외하고 공통적으로 가지는 부가 기능을 따로 분리하여 관리하는 것이 관점 지향 프로그래밍이다.

Spring에서 사용하는 @Transactional 어노테이션도 AOP의 개념이 적용되어 있다. 트랜잭션을 적용해야 하는 메서드에 위의 try-catch 처럼 작성해주지 않고, @Transactional 어노테이션만 작성하면 된다.

@Service
public class PostService {

    @Transactional
    public Long addPost(Long boardId, NewPostRequest newPostRequest, AuthInfo authInfo) {
        // 새로운 게시글을 등록하는 비즈니스 로직만을 작성하면 됨
    }

    @Transactional
    public void updatePost(Long postId, PostUpdateRequest postUpdateRequest, AuthInfo authInfo) {
        // 게시글을 수정하는 비즈니스 로직만을 작성하면 됨
    }
}

AOP 개념이 적용된 Transaction 처리에서는 아래와 같은 장점이 있다.
1. 메서드에는 서비스의 비지니스 로직만을 작성하고, 비지니스 로직에 집중된 코드를 작성할 수 있다.
2. 반복적인 Trancaction 처리에 대해 @Transactional 어노테이션을 통해 중복된 트랜잭션 처리 코드를 제거할 수 있다.

그래서 AOP로 @Transactional이 어떻게 작동하는걸까?

Transactional과 Proxy

Proxy

스프링은 기본적으로 트랜잭션을 Proxy를 통해서 처리한다.

실제로 PostService의 addPost 메서드를 요청하면, 동작 전의 코드로는 위와 같이 보인다.

실제로는(런타임에서는) 아래와 같이 동작한다.

스프링이 (어플리케이션이 동작할 때) Transaction 전후 처리를 하는 Proxy를 동적으로 생성한다.

@Transactional어노테이션이 선언된 클래스나 메서드(Target)에 대해 Target을 참조하는 Proxy를 스프링이 생성한다. Target의 트랜잭션 처리가 필요한 요청은 Proxy에게 요청되고 Proxy가 Transation 전후 처리를 한다.

이렇게 Transactional의 작동을 알아봤는데, 어노테이션 내부의 조건들을 살펴보자.

@Transactional 옵션

1. isolation

isolation 옵션을 트랜잭션에서 일관성없는 데이터를 어떻게 허용할지에 대한 허용 수준을 정할수 있는 옵션이다. 아래와 같은 형태로 설정을 해주면 된다.

@Service
public class PostService { 
	@Transactional(isolation=Isolation.DEFAULT) 
    public void addPost() throws Exception { 
    	// 비지니스 로직 구현
    } 
}
1) DEFAULT
기본적인 격리 수준이고, 기존 DB의 Isolation Level을 따르게 된다.

2) READ_UNCOMMITED (Level 0)
커밋되지 않는 데이터에 대한 읽기를 허용하는 방식이다. 이렇게 설정하면 Dirty Read라는 문제가 발생할 수 있다. 
예를 들어 사용자 A가 1이라는 데이터를 2로 변경한다고 했을때, 사용자 B가 아직 완료되지 않은 (Uncommitted or Dirty) 데이터를 읽을 수 있는데, 만약 사용자가 A 가 수행한 데이터가 정상적으로 커밋되지 않아 롤백될 경우 사용자 B가 읽은 데이터는 잘못된 데이터가 되는 것이다.

3) READ_COMMITED (Level 1)
커밋된 데이터에 대해 읽기를 허용하는 방식이다. 즉, 특정 사용자가 데이터를 변경하는 동안 다른 사용자는 해당 데이터에 접근이 불가하다.
이렇게 하면 Dirty Read 문제는 방지할 수 있으나, Non-Repeatable Read 문제가 생긴다.
예를들어 사용자 A 가 1번 데이터를 조회하고 있을 때 사용자 B가 1번 데이터를 수정하고 커밋을 해버리면, 사용자 A가 같은 트랜잭션에서 다시 1번 데이터를 조회한다면 수정된 데이터가 조회되어 버려서 반복해서 데이터를 읽을 수 없는 경우를 의미한다.

4) REPEATABLE_READ (Level 2)
동일 필드에 대해 다중으로 접근할 때 동일한 결과를 보장하는 방식을 의미한다. 즉, 트랜잭션이 완료될 때까지 SELECT 문장이 사용되는 모든 데이터들에 대해 Shared Lock이 걸려 다른 사용자가 그 데이터에 대한 접근이 불가능해진다.
그러므로 어떤 트랜잭션이 수행될 때 다른 트랜잭션이 앞선 트랜잭션이 사용 중인 데이터에 대해 갱신하거나 삭제하는게 불가능하기에 트랜잭션 내에서 여러번 데이터를 접근한다해도 데이터의 일관성을 보장할 수 있다.
이렇게 하면 Non-Repeatable Read 문제는 방지할 수 있으나, Phantom Read 문제가 생긴다.
예를들어 사용자 A 가 특정 범위의 데이터 [1,2,3,4] 를 2번 읽는 트랜잭션이 수행된다고 가정하자. 두 번째 읽기전에 사용자 B가 수행한 트랜잭션이 [5,6,7,8] 이라는 데이터를 추가해 버리면 첫번째 쿼리와 두번째 쿼리의 결과가 달라진다.
즉, 한 트랜잭션에서 일정한 범위의 레코드를 두번 이상 읽을 때, 데이터가 불일치하는 문제가 바로 Phantom Read 문제이다.

5) SERIALIZABLE (Level 3)
해당 설정은 데이터의 일관성과 동시성을 유지하기 위해 MVCC(Multi Version Concurrency Control) 을 사용 하지 않는다. (MVCC 는 다중 사용자 데이터 베이스 성능 을 위한 기술로 데이터를 조회할때 LOCK 을 사용하지 않고 데이터의 버전을 관리하여 데이터 일관성과 동시성을 높이는 기술을 의미)
그리고 트랜잭션이 완료될 때 까지 SELECT 문장이 사용하는 모든 데이터에 Shared Lock 을 걸어 다른 사용자가 해당 영역에 있는 모든 데이터에 대한 수정과 입력이 불가능하게 만들어 Phantom Read를 방지한다.
설명만 보기에는 SERIALIZABLE 속성을 써야하겠지만 이렇게 격리수준이 높아지면 성능저하의 우려가 있으니 상황에 따라 적절한 속성을 사용해줘야한다.

2. propagation

propagation 옵션은 트랜잭션이 동작할 때 다른 트랜잭션이 호출되면 어떻게 처리할지를 정하는 옵션이다. 즉 피호출 트랜잭션에서 호출한 트랜잭션을 그대로 사용할지 새로 생성할지를 정하는 옵션이라고 생각하면된다.
설정방법은 아래와 같이 적용해주면 된다.

@Service
public class PostService { 
	@Transactional(propagation=Propagation.REQUIRED)
    public void addPost() throws Exception { 
    	// 비지니스 로직 구현
    } 
}
1) REQUIRED
디폴트인 속성이고, 부모 트랜잭션 내에서 실행하게 하고 만약 부모 트랜잭션이 없으면 새로운 트랜잭션을 생성하게 하는 설정이다.

2) SUPPORTS
이미 시작된 트랜잭션이 있으면 그 트랜잭션에 참여하여 처리하게 하고, 없으면 트랜잭션없이 진행하게 하는 설정이다.

3) REQUIRES_NEW
부모 트랜잭션이 있어도 그냥 새롭게 트랜잭션을 생성하게 하는 설정이다.

4) MANDATORY
REQUIRED 처럼 이미 시작한 트랜잭션에 참여하지만, 없으면 새로 생성하는게 아니라 예외를 발생시킨다. 보통 독립적으로 트랜잭션이 진행되면 안되는 경우에 해당 옵션을 사용한다.

5) REQUIRES_NEW
항상 새로운 트랜잭션으로 작업을 수행한다. 만악에 진행중인 트랜잭션이 있으면 트랜잭션을 잠시 보류시킨다.

6) NOT_SUPPORTED
트랜잭션 없이 작업을 수행한다. 만약 진행중인 트랜잭션이 있으면 트랜잭션을 잠시 보류시킨다.

7) NEVER
트랜잭션을 사용하지 않게 강제한다. 이미 진행중인 트랜잭션이 있다면 Exception을 발생시키고 트랜잭션이 진행중이지 않을때 작업을 수행한다.

8) NESTED
이미 진행중인 트랜잭션이 있으면 중첩된 트랜잭션을 실행하고 없으면 REQUIRED와 동일하게 새로운 트랜잭션을 생성하여 진행한다. 중첩 트랜잭션을 말 그대로 트랜잭션 안에 트랜잭션을 만드는 것이다. 트랜잭션안의 트랜잭션이 커밋되거나 롤백되어도, 바깥의 트랜잭션에게 영향을 주지 않는다.

3.noRollbackFor, rollbackFor

특정 예외 발생 시, rollback을 하거나 하지않게 하는 옵션이다.
사용방법은 아래와 같다.

@Service
public class PostService { 
	@Transactional(noRollvackFor=CustomException.class) 
    public void addPost() throws Exception { 
    	// 비지니스 로직 구현
    } 
}

선언적 트랜잭션에서는 런타임 예외가 발생하면 롤백을 하도록한다. 반면 체크해준 예외가 발생하면 커밋을 한다. 스프링에서는 Data Acces 기술의 예외가 런터임 예외로 전환되어 던져지기 때문에 런타임 예외만 롤백 대상이 되지만 기본동작을 바꿀 수 있는데 그 옵션이 바로 rollbackFor 속성이다.
그리고 반대로 특정 예외 발생시 Rollback 처리되지 않게 하는 옵션이 noRollbackFor 속성이다.

4. timeout

지정한 시간 내에 메서드 수행이 완료되지 않으면 rollback 하게 하는 옵션이다. -1 이 default 값이고 이는 no timeout을 의미한다.
사용 방법은 아래와 같다.

@Service
public class PostService { 
	@Transactional(timeout=10)
    public void addPost() throws Exception { 
    	// 비지니스 로직 구현
    } 
}

5. readOnly

트랜잭션을 읽기전용으로 설정하는 옵션이다. true로 설정하면 insert, update, delete 실행할 때 예외가 발생한다. 기본 값은 false이다.
사용방법은 아래와 같다.

@Service
public class PostService {
	@Transactional(readOnly =true)
    public List<Post> findAllPosts() throws Exception { 
    	// 전체 post 검색 로직 구현 
      } 
}

보통 get 이나 find 같은 이름의 메서드 앞에 이런식으로 설정이 되어있다.

이렇게 @Transactional의 작동방식과 옵션을 알아봤다! 기술의 사용에는 이유와 이해가 동반되어야 한다는 것을 느끼고 간다. 다음에는 DI와 관련된 Transaction 코드들을 뜯어보고 싶다!

참고 자료

https://brunch.co.kr/@anonymdevoo/44
https://velog.io/@byeongju/Spring-Transaction%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B8%B0%EB%B3%B8-%EA%B0%9C%EB%85%90
https://devkingdom.tistory.com/287

profile
하루에 한 걸음씩

1개의 댓글