Transactional 어노테이션

요약

트랜잭션이 무엇인지 알아본다.
@Transactional의 Propagation 옵션을 알아본다.
@Transactional의 롤백 기준을 알아본다.
@Transactional 적용 실패하는 경우를 알아본다.

트랜잭션?

모두 반드시 성공해야 하는 연속된 작업들을 트랜잭션이라 한다.
만약 작업들 중 하나만 실패해도 모든 작업들이 어플리케이션의 상태를 변경시키지 않은 상태로 돌려놔야 한다.

만약 하나의 지하철 노선에 등록할 때 구간도 같이 저장해야 한다고 가정하자.
이때 INSERT 문이 두 번 사용될 것이다.
개발자는 이를 하나의 작업 단위로 인식해서 하나의 트랜잭션으로 관리할 수 있다.
노선을 등록하는데 성공했더라도 구간이 저장안되면 노선도 저장되서는 안되기 때문이다.

트랜잭션 진행 중에 문제가 생길 경우 그 작업을 진행하기 이전으로 DB를 롤백시킨다.

JDBC의 트랜잭션

JDBC는 statement를 트랜잭션에서 진행할 수 있게한다.
JDBC의 한 Connection의 수행마다 auto-commit한다.
즉 모든 statement를 하나의 트랜잭션이라 생각하고 수행 이후 자동으로 반영한다.

만약 여러 statement를 하나의 트랜잭션에서 진행하고 싶다면 connection의 autoCommit을 꺼놓고 트랜잭션을 종료하고 싶을 때 명시적으로 커밋해주면 된다.

1
2
3
connection.setAutoCommit(false); // auto-commit 꺼놓음.
// ... 작업들...
connection.commit(); //명시적으로 커밋.

스프링의 트랜잭션

스프링에서는 여러가지 트랜잭션 관리 기능을 제공한다.

명시적 트랜잭션

트랜잭션의 범위를 자세하게 정하고 싶을 땐 명시적 트랜잭션을 사용한다.

Transaction Template는 개발자가 직접 트랜잭션 범위를 명시적으로 결정할 수 있다.

선언적 트랜잭션 @Transactional

클래스나 메서드에 @Transactional 어노테이션을 붙이면 글로벌 트랜잭션을 정해줄 수 있다.

1
2
3
4
@Transactional
public void deleteById(final Long id) {
lineRepository.deleteById(id);
}

이렇게 메서드 위에 두면 메서드 실행 중 예외가 발생하면 롤백한다.

1
2
3
4
5
6
7
8
@Service
@Transactional
public class LineService {

public void deleteById(final Long id) {
lineRepository.deleteById(id);
}
}

클래스 위에 두면 클래스에 해당하는 메서드들을 @Transactional을 붙인 셈이 된다.

@Transactional의 여러가지 속성

트랜잭션에는 여러가지 속성을 설정해줄 수 있다. 예시코드를 통해 이해해보자.

일단 하나의 서비스에서 방을 DB에 저장하고 예외를 일으키는 메서드를 트랜잭션으로 처리하고자 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class TransactionalService {

private final RoomRepository roomRepository;

public TransactionalService(RoomRepository roomRepository) {
this.roomRepository = roomRepository;
}

@Transactional
public void throwSomething() {
roomRepository.createRoom(Room.fromPlainPassword("other", "password"));
throw new IllegalArgumentException();
}
}


그리고 다른 서비스에서 아까 만든 서비스를 주입받아 예외를 만드는 메서드를 호출하는 메서드를 트랜잭션 처리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class OuterService {

private final TransactionalService transactionalService;
private final RoomRepository roomRepository;

public OuterService(TransactionalService transactionalService, RoomRepository roomRepository) {
this.transactionalService = transactionalService;
this.roomRepository = roomRepository;
}

@Transactional
public void doSomething() {
roomRepository.createRoom(Room.fromPlainPassword("name", "password"));

try {
transactionalService.throwSomething();
} catch (IllegalArgumentException e) {
System.out.println("catched!!");
}
System.out.println("done!");
}
}

이제 OuterService의 메서드를 실행하기 위한 Runner 클래스를 만들었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class Runner implements ApplicationRunner {

private final OuterService outerService;

public Runner(OuterService outerService) {
this.outerService = outerService;
}

@Override
public void run(ApplicationArguments args) throws Exception {
outerService.doSomething();
}
}

이제 어플리케이션을 실행하면 외부 트랜잭션 메서드가 실행될 것이다.

1. 전파 옵션

트랜잭션 수행 중 다른 트랜잭션을 호출하는 상황에서 어떻게 처리하는지 다루는 옵션.

  • REQUIRED : 트랜잭션 수행 중 다른 트랜잭션 호출되면 먼저 수행되던 거에 합쳐서 수행. propagation 기본 설정값.

    1
    2
    @Transactional
    public void throwSomething() {

    다른 설정을 안하면 REQUIRED 옵션으로 설정된다.
    이 상태로 실행시키면 DB에는 아무 일도 일어나지 않는다.

    1
    Caused by: org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only

    UnexpectedRollbackException은 외부 트랜잭션이 내부 트랜잭션이 롤백됐음을 예상하지 못했다는 의미다.

    외부 트랜잭션은 내부에서 다른 트랜잭션의 성공 여부 상관없이 커밋하려 한다.
    중간에 내부 트랜잭션이 실패해서 롤백됐으면 이를 외부 트랜잭션에게 알려줘야 해서 생긴 예외다.

    REQUIRED 옵션은 내부 트랜잭션과 외부 트랜잭션의 범위가 논리적으로는 분리되지만 물리적으로는 각 스코프가 같은 물리적 트랜잭션을 갖게 된다. 그래서 외부 트랜잭션은 내부의 트랜잭션의 rollback-only에 따라 영향을 받는다.

  • SUPPORTS : 만약 트랜잭션이 진행되고 있는 상태에서 호출 시 해당 트랜잭션에 참여, 아닐 경우 트랜잭션 하지 않고 진행.

    1
    2
    3
    4
    //외부 메서드가 트랜잭션이 아니면
    public void doSomething() { ... }
    @Transactional(propagation = Propagation.SUPPORTS)
    public void throwSomething() { ... }

    이렇게 트랜잭션을 변경하고 실행하면 어떻게 될까?

    그 결과 두 메서드의 DB 업데이트가 모두 이뤄진다.(예외를 일으켜도 DB 업데이트가 이뤄졌다.)
    사실 두 메서드 모두 트랜잭션 처리 되지 않았다.

  • REQUIRED_NEW : 이미 진행 중인 트랜잭션이 있어도 새로운 트랜잭션 생성해서 진행. 서로 물리적으로 독립된 트랜잭션을 생성.

    1
    2
    3
    4
    @Trasactional
    public void doSomething() { ... }
    @Transactional(propagation = Propagation.REQUIRED_NEW)
    public void throwSomething() { ... }

    이렇게 트랜잭션을 변경하고 실행하면 외부 트랜잭션만 커밋된다.
    내부 트랜잭션은 따로 트랜잭션으로 생성되서 예외 탐지하고 롤백됐고 외부 트랜잭션은 내부 트랜잭션의 실패 여부와 상관없이 트랜잭션을 진행해서 커밋했다.

  • NESTED : 이미 진행 중인 트랜잭션의 중첩된 트랜잭션을 생성. 물리적으로는 하나의 트랜잭션인데 저장점을 추가하는 방식. 중첩된 트랜잭션은 외부 트랜잭션에 영향을 끼치지 못하지만 내부 트랜잭션은 외부 트랜잭션의 롤백에 영향 받는다.

    1
    2
    3
    4
    @Trasactional
    public void doSomething() { ... }
    @Transactional(propagation = Propagation.NESTED)
    public void throwSomething() { ... }

    이러면 내부 트랜잭션은 롤백되지만 외부 트랜잭션은 잘 실행된다.

    반면 내부 트랜잭션은 예외를 던지지 않는데 외부 트랜잭션에서 예외가 발생한 경우는 어떨까?
    모든 트랜잭션이 롤백된다! 내부가 성공해도 외부가 롤백되면 모두 롤백된다.

2. 롤백 조건 옵션

트랜잭션이 롤백되는 조건을 다룬다.
선언적 트랜잭션은 기본적으로 unchecked exception와 Error가 발생하면 롤백한다.
Checked exception은 개발자가 반드시 처리한 예외이니 예상하지 못한 예외인 unchecked exception과 Error가 발생하면 롤백한다.

@Transactional 어노테이션의 속성을 통해 롤백조건을 다르게 할 수 있다.
관련 속성은 다음과 같다.

  • rollbackFor : 추가로 롤백되어야 할 예외를 추가할 수 있다.
  • rollbackForClassName : 추가로 롤백되어야 할 예외 클래스의 이름을 배열로 받아 추가할 수 있다.
  • noRollbackFor : 롤백하지 않으려는 예외를 정할 수 있다.
  • noRollbackForClassName : 롤백하지 않으려는 예외 클래스으 이름을 배열로 받아 정할 수 있다.

3. readOnly

데이터를 수정하지 않는 트랜잭션을 데이터를 수정하려고 하는 경우를 막고 싶을 수 있다.
이럴 때 사용되는게 readOnly 속성이다.

readOnly는 읽기 전용일 경우 true로 세팅해주면 읽기 위한 트랜잭션임을 암시한다.
다만 readOnly가 붙은 트랜잭션에서 데이터를 수정하려고 할 때 반드시 실패한다는 의미는 아니다.
트랜잭션 매니저에 따라 readOnly를 지원하지 않는 경우 readOnly 설정은 무시된다.

4. 그 외 속성들

트랜잭션의 격리 수준을 결정하는 isolation이나 트랜잭션의 제한 시간을 정하는 timeout 속성들도 존재한다.

@Transation과 AOP

선언적 트랜잭션은 AOP 프록시를 통해 활성화 된다.
프록시로 구현되다보니 트랜잭션에 수행하는 프록시 객체와 진짜 객체가 서로 다를 수 있다.
간략하게 표현하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class OuterService {

private final TransactionalService transactionalService;
private final RoomRepository roomRepository;

public OuterService(TransactionalService transactionalService, RoomRepository roomRepository) {
this.transactionalService = transactionalService;
this.roomRepository = roomRepository;
}

@Transactional
public void doSomething() {
roomRepository.createRoom(Room.fromPlainPassword("name", "password"));
transactionalService.throwSomething();
System.out.println("done! with throw");
}
}

위에서 예시로 사용했던 서비스는 사실 AOP 프록시를 사용하면 다음과 같이 된다.
외부에서 doSomething 메서드를 호출 -> (프록시)트랜잭션 시작! -> (진짜) doSomething 수행! -> (프록시)트랜잭션 끝!

1
2
3
4
5
6
7
8
9
10
public class 프록시객체 {

private final 진짜객체;

public void 트랜잭션_붙은_메서드() {
트랜잭션_시작;
진짜객체.트랜잭션_붙은_메서드();
트랜잭션_끝;
}
}

그런데 다음과 같은 경우는 어떻게 될까??

트랜잭션이 적용되지 않은 public 메서드를 통해서 트랜잭션 붙은 메서드를 호출해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class OuterService {

private final RoomRepository roomRepository;

public OuterService(RoomRepository roomRepository) {
this.roomRepository = roomRepository;
}

public void execute() {
doSomething();
}

@Transactional(readOnly = true)
private void doSomething() {
roomRepository.createRoom(Room.fromPlainPassword("name", "password"));
throw new IllegalArgumentException();
}
}

이렇게 하면 doSomething은 롤백될까? 답은 아니다!!!!

왜냐면 프록시 객체는 execute 메서드를 실행시킬 때 내부에서 어떤 메서드를 부르는지 모른다. 그래서 트랜잭션을 적용시켜주지 못한다.

그래서 private 메서드는 @Transactional을 붙이는 건 의미 없다. 트랜잭션 처리 해줄 프록시 객체가 해당 메서드에 접근할 수 없기 때문이다.

그리고 public이 붙었더라도 트랜잭션이 붙지 않은 메서드를 통해서 호출하면 트랜잭션이 적용되지 않는다!!!

Share