Spring Boot

[Spring] @Transactional의 동작 방식 & 중첩이 일어났을 때 어떻게 될까?

HEY__ 2024. 8. 6. 12:18
728x90

이 글은 공부를 하면서 알게 된 내용들을 기록하는 글입니다. 오류나 고쳐야 할 사항들이 있다면 지적 부탁드립니다!

 

Spring을 이용해서 개발하다보면, 그중 DB를 연결해서 개발하다 보면 `@Transactional` 어노테이션을 많이 사용하게 된다.

리팩토링을 진행하던 중 `@Transactional`과 관련된 예외를 몇 번 만나게 되었고, 이를 해결하면서 @Transactional 어노테이션에 대한 궁금증이 들기 시작했다.

이번 포스팅에서는 그 궁금증들을 타파해보자!

 

 

✅ `Transaction`의 필요성

`@Transactional` 어노테이션에 대해 이야기하기 전에, Transaction에 대해 이야기해 보자.

(Transaction에는 다양한 종류들이 있는데, 그중 Database Transaction을 이야기한다.)

 

Database Transaction은 `데이터베이스에서 수행되는 여러 작업을 하나의 논리적 단위로 묶는 것`으로 정의할 수 있다.

Transaction은 왜 필요할까?

 

송금하는 과정을 생각해 보자. 

 

A의 계좌에는 5만원, B의 계좌에는 0원이 있다.

이때, A가 B에게 5만원을 송금하려고 할 때, 밑의 4단계를 거칠 것이다.

1) A의 계좌 조회

2) A의 계좌에서 5만원 차감

3) B의 계좌 조회

4) B의 계좌에 5만원 추가

 

그런데, 만일 4번 과정이 완료되기 이전에 Exception이 발생하면 어떻게 될까?

A의 계좌에서는 5만원이 차감되어 잔고가 0원이 되었지만, B의 계좌에 돈이 추가되지 않아 B의 잔고도 0인 상황이 될 것이다.

한 마디로 5만원이 증발한 것이다. 🥲

 

이러한 문제점을 해결하기 위해 `여러 작업을 하나의 논리적 단위로 묶어서 실행`한다. 

이게 무슨 이야기인고 하니,

1번부터 4번까지의 작업(여러 작업)을 하나로 묶어 문제없이 모두 성공하면 commit을, 중간에 문제가 발생했을 때 모든 작업을 롤백(rollback)한다.

이렇게 하면 모든 작업이 수행되거나 or 모두 수행되지 않기 때문에 데이터 일관성을 유지할 수 있다.

 


✅ @Transactional의 동작 방식

`@Transactional` 어노테이션을 사용하면서 궁금했던 점 중 하나는, '@Transactional 어노테이션만 붙였을 뿐인데, 어떻게 트랜잭션을 지킬 수 있는 것일까?'였다.

 

🔥 JBDC Transaction; 프로그래밍 방식 트랜잭션 관리

먼저 `@Transactional`가 없었을 때(Spring boot가 없었을 때) transaction을 어떻게 관리했는지 알아보자.

Java에서 Transaction을 적용하기 위해서는 `Connection`을 직접 가져와 `try-catch`문을 통해 처리해야 한다.

public void sendMoney() throws SQLException {
    Connection connection = dataSource.getConnection();

    try (connection) {
        connection.setAutoCommit(false);
        // A의 계좌 조회
        // A의 계좌에서 5만원 차감
        // B의 계좌 조회
        // B의 계좌에 5만원 추가
        connection.commit();
    } catch (SQLException e) {
        connection.rollback();
    }
}

 

1) `dataSource.getConnection()`을 통해 데이터베이스와 연결

2) `. setAutoCommit(false)`를 통해 자동으로 commit이 일어나지 않게끔 설정함으로써, transaction을 할 수 있게끔 한다.

3) 송금 과정을 `try-catch`문으로 감싸서 예외가 발생했을 때에는 catch문의 `. rollback()`을 통해 롤백을 하게 하고, 예외가 발생하지 않았을 때에는 `. commit()`이 이루어지게끔 한다.

 

 

그렇다면 Spring에서도 프로그래밍적 방식으로 transaction을 관리할 수 있을까?

Spring Framework(Spring boot 아님!)`TransactionTemplate`을 이용하는 프로그래밍적 방식을 통해 transaction을 관리할 수 있다.

이 방법 역시 잘 사용하지 않는 방식이다.

 

이러한 프로그래밍 방식은 밑과 같은 단점이 있다.

1) try-catch문을 계속 적용해야 하기 때문에 코드 작성에 실수할 여지(휴먼 에러)가 발생할 수 있다

2) 중복 코드가 많아진다

3) 또한 서비스 레이어에 비지니스 로직이 아닌 코드가 담긴다 (try-catch문, .commit, rollback과 같은 코드)

4) 특정 기술에 종속적인 코드가 된다 (Connection에 대한 명세가 변한다면 모든 코드를 변경해야 한다)

 

 

🔥 @Transactional; 선언적 방식

Spring Boot에서는 transaction을 `@Transactional` 어노테이션을 통해 설정할 수 있다.

밑의 코드는 어노테이션을 통해 트랜잭션을 적용한 코드이다.

JDBC를 이용해 transaction을 관리한 코드와는 달리, 코드가 깔끔하고 비지니스 로직에만 집중할 수 있는 환경이 된 것을 알 수 있다.

@Transactional
public void sendMoney() {
    // A의 계좌 조회
    // A의 계좌에서 5만원 차감
    // B의 계좌 조회
    // B의 계좌에 5만원 추가
}

 

 

 

 

🔥 @Transactional의 동작 방식

그럼 다시 질문으로 돌아와서, @Transactional 어노테이션을 붙이기만 했는데 transaction이 적용된 거라면, JDBC에서 transaction을 적용하는 코드가 어디에선가 적용되고 있다는 것일 텐데... 어떻게 이루어지고 있는 걸까?

 

이 궁금증의 정답은 `Spring AOP`, `Proxy 패턴`과 관련이 있다.

 

Spring AOP는 Proxy 패턴을 바탕으로 동작한다.

Proxy 패턴이란 대상 원본 객체를 대리하는 객체(Proxy)가 다른 처리함으로 서 로직의 흐름을 제어하는 행동 패턴이다. 

 

Spring에서 Proxy 객체를 생성하는 방법에는 1) JDK Proxy,  2) CGLib Proxy 이렇게 두 개가 있는데,

Spring Boot에서는 주로 CGLib Proxy를 사용한다.

 

Spring Boot에서는 Bean 생성 시, @Transactional 어노테이션이 있으면 Proxy 객체가 Bean으로 등록되는데,

이 Proxy 객체는 @Transactional이 붙은 원본 클래스(인터페이스)를 상속받아 생성한다.

 

Proxy 객체는 @Transactional이 붙은 `public 메서드`에 대해 내부적으로 transaction을 적용하는데,

밑과 같은 작업을 하게 된다.

메서드 실행 전에 `doBegin()`을 통해 JDBC connection을 가지고 오며, auto commit 설정
메서드가 정상 실행 이후라면 `commit()` 처리, 예외 발생 히 `rollback()` 처리

 

 

⚠️ 하나 주의할 점!!  Spring AOP는 Proxy 패턴을 사용하여 원본의 클래스/인터페이스를 상속받는다.

`private` 메서드의 경우 자식 클래스(Proxy)에서 호출할 수 없기 때문에, `private` 메서드에 @Transactional 어노테이션을 붙여도 transaction이 적용되지 않는다.

 

 

 

AOP에 대해 제대로 모르다 보니 Transactional에 대해 공부하는 데에도 한계가 있는 것 같다!

AOP에 대해 따로 공부를 하고, 다시 공부해 봐야겠다.

 


@Transactional의 중첩

🔥 중첩 시 Transaction의 단위는 어떻게 되는가?

서비스를 리팩토링하면서 서비스 계층에 Facade pattern을 적용하면서, @Transactional 어노테이션이 중첩되는 경우가 발생했다.

서비스 계층 코드를 작성하면서 DB에 변경이 일어나야 하는 메서드에만 @Transactional 어노테이션을 작성했는데,

상위 계층에서 하위 계층의 코드를 호출하다 보니 자연스레 어노테이션이 중첩이 된 것이다.

 

이런 경우에는 Spring Boot에서는 어떤 단위로 transaction을 적용할까?

단순하게 생각해 보면 제일 상위 계층에 적용되어 있는 @Transactional 어노테이션을 기준으로 transaction이 적용되어야 할 것 같은데...

 

 

이는 @Transactional 어노테이션의 `propagation` 옵션에 따라 달라진다.

 

1. REQUIRED (디폴트)

propagation에 아무런 옵션을 주지 않았을 때 적용되는 디폴트 값으로서,

부모 transaction이 있는 경우, 새로운 transaction을 만들지 않고 부모 transaction에 적용한다.

부모 transaction이 없는 경우에 자신의 transaction을 만들어 적용한다.

 

2. SUPPORTS

부모 transaction이 있는 경우 새로운 transaction을 만들지 않고 부모 transaction에 적용한다.

부모 transaction이 없는 경우, transaction을 `적용하지 않는다`.

 

3. MANDATORY

부모 transaction이 있는 경우, 새로운 transaction을 만들지 않고 부모 transaction에 적용한다.

부모 transaction이 없는 경우, `예외가 발생`한다.

 

4. REQUIRES_NEW

부모 transaction의 존재 여부와 상관없이, 새로운 transaction을 만들어 적용한다.

기존에 실행하던 부모 transaction이 있는 경우 → 부모 transaction은 자식 transaction이 끝날 때까지 대기한다.

 

자식 transaction과 부모 transaction의 `rollback 여부는 서로 독립적`이다.

자식 transaction이 rollback 되더라도, 부모 transaction은 rollback 되지 않는다.

자식 transaction이 정상적으로 종료되고 부모 transaction이 rollback 되어도, 자식 transaction은 rollback 되지 않는다.

 

5. NOT_SUPPORTED

부모 transaction이 있는 경우, 부모 transaction을 `일시중지`한다.

트랜잭션을 적용하지 않은 상태에서 비지니스 로직을 수행하고 메서드가 종료된 후, 부모 transaction을 `재개`한다.

 

부모 transaction이 없는 경우 transaction을 `적용하지 않는다`.

 

6. NEVER

트랜잭션을 `절대 적용하고 싶지 않을 때` 사용한다.

부모 transaction이 있는 경우 예외가 발생하고, 부모 transaction이 없는 경우 transaction을 적용하지 않는다.

 

7. NESTED

부모 transaction의 `존재 여부에 상관없이, 새로운 transaction을 만들어 적용`한다.

기존에 실행하던 부모 transaction이 있는 경우 → 부모 transaction은 자식 transaction이 끝날 때까지 대기한다.

 

여기까지는 `REQUIRES_NEW`와 같지만, 부모 transaction과 자식 transaction의 rollback이 서로 `종속적`이라는 차이가 있다.

자식 transaction은 부모 transacion과 함께 commit 된다.

자식 transaction이 rollback 되는 경우, `부모 transacion도 같이 rollback`된다.

자식 transaction이 정상적으로 종료되었다고 해도, 부모 transaction이 rollback 된다면 자식 `transaction도 같이 rollback`된다.

 

 

🔥 @Transactional의 propagation 옵션 사용 시 주의할 점

@Service
public class Account{
    @Transactional
    public void pay(Long userId){
        // 비지니스 로직
        sendMoney();
    }

    @Transactional(propagation = Propagation.NESTED)
    public void sendMoney(){
        // 비지니스 로직
    }
}

 

예를 들어 위와 같은 코드가 있다고 가정해 보자. 

`pay()`에서 `sendMoney()`를 호출하고 있고, `sendMoney()`에서는 propagation 옵션을 NESTED로 줌으로써,

부모 transaction의 존재 여부와 상관없이 transaction을 적용하게끔 했다.

 

위 코드를 실행하면 Transaction의 개수가 총 몇 개가 될까?

우리는 2개를 기대하고 propagation에 옵션을 주었겠지만, 실제로는 1개의 transaction만 생성된다.

 

그 이유가 뭘까?

위에서 Spring Boot는 @Transactional 어노테이션이 붙은 클래스에 대해 Proxy 객체를 만들어 적용한다.

 

출처: https://blogshine.tistory.com/291

 

Proxy에서 Transaction을 연 시점부터 닫는 시점까지, 그 기간 동안 실행된 Service의 메서드들은 동일한 Transaction 범위 안에서 수행된다.

위의 플로우 차트를 보자.

UserService가 개발자가 직접 작성한 서비스 클래스이고, 해당 클래스 내에 @Transactional 어노테이션이 붙은 `invoice()`와 `createPdf()`  메서드가 있다.

 

이때, UserService에서 `invoice()`가 `createPdf()`를 호출한다고 했을 때, UserService Proxy 객체 안에서 transaction 처리가 이루어진다.

따라서 같은 클래스 내에서 각 메서드에 @Transactional이 붙어도 실제로는 하나의 transaction으로  처리된다.

 

 

그럼 transaction을 각자 처리하고 싶으면 어떻게 해야 할까?

간단하다. 클래스를 분리하면 된다!

 

클래스를 분리하게 되면 Proxy 객체도 따로 생성될 것이고, 각자 Transaction을 취급할 수 있게 되기 때문이다!

 

 


✅  참고 자료 & 링크

-  Transactional 동작 원리

https://blogshine.tistory.com/291

- Transaction 동작 원리, JDBC 기본에 충실한 과정

https://jeong-pro.tistory.com/228

- [테코톡] 리차드의 @Transactional

https://www.youtube.com/watch?v=taAp_u83MwA

- @Transactional은 언제써야할까?

https://medium.com/@hgbaek1128/transactional%EC%9D%80-%EC%96%B8%EC%A0%9C%EC%8D%A8%EC%95%BC%ED%95%A0%EA%B9%8C-bd7aac4ad17f\

- @Transactional이 중첩되면 무슨 일이 일어날까?

https://velog.io/@fastdodge7/JPA-Transactional%EC%9D%B4-%EC%A4%91%EC%B2%A9%EB%90%98%EB%A9%B4-%EB%AC%B4%EC%8A%A8-%EC%9D%BC%EC%9D%B4-%EC%9D%BC%EC%96%B4%EB%82%A0%EA%B9%8C

 

[JPA] @Transactional이 중첩되면 무슨 일이 일어날까?

스프링 JPA를 사용하던 도중 한가지 궁금한 점이 생겼다.@Transactional 어노테이션이 붙어있는 메서드에서 @Transactional이 들어간 다른 메서드를 호출하면 어떻게 될까?예를 들면, 아래와 같은 상황이

velog.io

 

 

 

 

 

 

 

 

 

728x90