[Spring DB] 스프링과 트랜잭션, 트랜잭션 전파

스프링의 트랜잭션

 

스프링을 사용하면 @Transactional 어노테이션을 사용하여 선언적 트랜잭션 관리를 하게 된다. 이렇게 선언적 트랜잭션 관리 방식을 사용하게 되면 프록시 방식의 AOP가 동작한다.

 

그 전에 앞서 공부했던 스프링의 db 연동과정부터 차례로 살펴보면 다음과 같은 흐름을 가진다.

 

 

0. 트랜잭션을 시작하기 위해서는 db 커넥션이 필요하다.
1. 스프링 컨테이너에 등록된 트랜잭션 매니저datasource라는 객체를 통해서 스프링이 관리하는 커넥션 풀에서 커넥션을 획득한다.
2. 트랜잭션 매니저트랜잭션 컨텍스트를 통해 생성된 커넥션을 트랜잭션 동기화 매니저에 보관한다. 트랜잭션 컨텍스트는 트랜잭션 매니저와 트랜잭션 동기화 매니저 사이에서 트랜잭션의 작업을 관리한다.
3. 트랜잭션 동기화 매니저는 ThreadLocal을 통해서 스레드마다 각각의 커넥션을 사용하도록 한다.
4. Repository에서 DB 커넥션이 필요하면 데이터베이스 동기화 매니저에서 가져다가 사용한다.
5. 비즈니스 로직이 끝나고, 트랜잭션이 종료되면 획득한 커넥션을 통해 DB에 커밋/롤백한다.
6. 트랜잭션 동기화 매니저의 커넥션을 정리한다.

 

이렇게 트랜잭션 매니저가 커넥션 획득과 반납을 담당한다면, 트랜잭션의 시작과 종료을 관리하는 객체는 '트랜잭션 프록시'이다.

 

트랜잭션 프록시는 트랜잭션을 적용할 클래스에 직접 접근하지 않고, 해당 클래스의 프록시를 만들어서 스프링 컨테이너에 등록한다. 이렇게 하는 이유는 일반적인 프록시 사용 이유와 비슷한데, 접근 권한과 함께 부가 기능을 활용할 수 있기 때문이다.

 

 

 

트랜잭션 프록시를 도입하기 이전에는 스프링 컨테이너에 등록된 Transaction Manager를 획득하고, 획득한 Transaction Manager에서 직접 트랜잭션을 얻고 commit 또는 rollback하는 코드를 삽입했다.

 

 

트랜잭션 프록시를 도입한 이후에는 트랜잭션을 처리하는 객체와 비즈니스를 위한 서비스를 분리할 수 있다. 

 

 

다양한 트랜잭션 매니저

 

- PlatformTransactionManager: 모든 트랜잭션 매니저의 상위 인터페이스이다. 트랜잭션 관리를 위한 기본 인터페이스를 제공한다.

- ChainedTransactionManager: 여러개의 다른 트랜잭션 매니저를 연결해서 사용하기 위하여 사용하는 클래스이다.

- AbstractPlatformTransactionManager: 트랜잭션 관리자를 만들기 위한 추상 클래스이다. 이를 JpaTransactionManager, DatasourceTransactionManager, JtaTransactionManager 등으로 구현하여 JPA, JDBC, JTA와 같은 다양한 데이터 액세스 기술을 지원한다.

 

 

예외 발생과 트랜잭션

 

 이전에 정리(https://eckrin.tistory.com/144)한 적 있듯이 예외는 체크 예외와 언체크 예외로 구분된다. 그런데 트랜잭션 진행 중 예외가 발생했다면 그것의 종류에 따라 처리 방법(커밋/롤백)이 달라지게 된다.

 

- 체크 예외: RuntimeException을 제외한 Exception 하위 단계의 예외들을 말하며, 비즈니스적 의미가 있을 경우 사용된다. 체크 예외 발생시 진행중인 트랜잭션은 커밋된다. 체크 예외 발생시에도 롤백하고 싶다면 @Transactional 어노테이션에 'rollbackFor = ' 옵션을 설정해주면 된다.
- 언체크 예외: RuntimeException의 하위 Exception들을 말하며, 시스템 오류나 네트워크 오류 등 복구 불가능한 예외들을 말한다. 언체크 예외 발생시 진행중인 트랜잭션은 롤백된다.

 

 나는 일반적으로 서비스 로직 개발 시에 발생할 수 있는 오류(유저가 존재하지 않는다거나, 입력 양식에 오류가 있다던가)들에 대해서 언체크 예외를 사용했는데(RuntimeException을 상속받아 커스텀 예외를 만들어 사용), 비즈니스적인 오류에 대해서는 체크 예외를 사용해야 한다길래 고민을 조금 해보았다.

 

 

 

 

내 결론은

거래 서비스를 가정하자. 만약 유저가 존재하지 않아서 예외를 터트리고 비즈니스 로직을 종료해야 하는 상황이라면 기존에 해왔던 것처럼 런타임에러를 발생시키면 될 것이다. 하지만 만약 해당 예외가 발생했을 경우 진행중인 로직을 유지하고 예외를 처리하겠다고 마음먹으면 체크 예외를 발생시키고 잡아서 처리해주면 된다. 간단한 예시를 들자면 다음과 같다.

 

@Transactional
fun service1() {
  //service logic
  ... ?: throw CustomUncheckedException("유저가 존재하지 않습니다")
}  

@Transactional
fun service2() {
  //service logic
  if(문제 발생시) {
    order.payStatus = PayEnumType.STANDBY
    throw CustomCheckedException("유저가 존재하지 않습니다. 거래 정보를 대기상태로 변경합니다.")
  }
}

 

첫 번째 케이스는 유저가 존재하지 않으면 에러를 터트리고 서비스 로직을 종료시킨다. 따라서 클라이언트 쪽으로는 JSON등을 이용하여 커스텀된 오류 메세지가 전달될 것이고, 트랜잭션은 rollback되는 것이 맞다.

두 번째 케이스는 유저가 존재하지 않으면 에러를 터트리되, 거래 서비스를 종료하는 것이 아니라 대기상태로 전환하는 것을 목표로 한다. 이 경우 생성된 order정보가 db에 남으며, 고객은 해당 서비스를 이어서 작성하는 등 db의 정보를 가져다 사용할 수 있다.

 

정리하자면 개발자가 서비스 로직을 어떻게 처리할지 방법에 따라서 체크 예외나 언체크 예외를 상황에 맞게 가져다 사용하는 방법을 사용할 수 있다는 것.

 

 

 

 

트랜잭션 관리 (HikariPool)

 

 스프링은 DB와의 연결을 관리하기 위하여 히카리(Hikari)라는 커넥션 풀을 사용한다. DB와 연결하는 작업은 적지 않은 자원을 사용하므로 일정량의 jdbc 커넥션을 connection들을 미리 생성해두고, 어떤 공간(pool)에 담아두고 꺼내 사용하게 된다. db요청이 들어오면 쓰레드는 hikari에 커넥션을 요청하고, hikari는 커넥션 풀에서 만들어진 커넥션을 꺼내 프록시의 형태로 제공하는 식이다.

 

set log visible

 

트랜잭션을 요청해보면 이렇게 Hikari가 conn0이라는 커넥션을 제공하는데, 실제 객체 그대로 제공하는 것이 아니라 HikariProxyConnection@1626288305와 같이 프록시 형태로 실제 커넥션을 감싸서 제공하는 것을 로그로 확인할 수 있다.

 

 

위 히카리 프록시 커넥션과 동일한 물리 커넥션을 사용하지만, 프록시 객체의 주소가 다르므로 둘은 다른 커넥션이며, 생성된 트랜잭션도 다르다고 할 수 있다.

 

 

 

 

트랜잭션 전파 - REQUIRED

 

 그렇다면 하나의 트랜잭션 실행 중 다른 트랜잭션이 실행하면 어떻게 동작할까? 트랜잭션 전파 옵션에 따라 다르지만, 기본 옵션인 REQUIRED 기준으로 설명하면, 이미 진행중인 트랜잭션(외부 트랜잭션)이 존재하는 상황에 트랜잭션이 추가로 동시 수행될 경우(내부 트랜잭션) 모두 하나의 물리 트랜잭션으로 묶어서 관리한다. 이를 진행중인 트랜잭션에 참여(participate)한다라고 표현하기도 하며, 새로운 커넥션을 생성하지 않고 기존의 트랜잭션을 사용한다는 것을 의미한다. 즉 맨 처음 시작된 논리 트랜잭션 커밋 이전의 논리 트랜잭션 커밋도 모두 물리적으로는 무시된다.

 

진행중인 트랜잭션에 참여 (별다른 물리적 동작은 X)

 

 

 

두번째 트랜잭션이 시작할때도 트랜잭션 매니저를 통해서 새로운 트랜잭션 시작을 요청하지만, 트랜잭션 매니저는 이미 동작하는 트랜잭션이 존재함을 확인한 뒤 신규 트랜잭션을 반환하지 않는다.

 

결국 처음 시작한 논리 트랜잭션(=외부 트랜잭션)과 이후 시작한 논리 트랜잭션(내부 트랜잭션)은 모두 물리적으로 하나의 트랜잭션으로 묶여있기에, 두 트랜잭션 진행 중 커넥션을 요청할 경우 트랜잭션 동기화 매니저에서 동일한 커넥션을 획득하게 된다. 한마디로 외부 트랜잭션의 시작과 종료만이 트랜잭션의 물리적인 변화를 초래하며, 내부 트랜잭션들의 시작과 종료는 별도의 물리적 동작으로 연결되지 않는다.

 

내부 트랜잭션 롤백시 트랜잭션 동기화 매니저에 rollbackOnly=true 옵션을 설정(마킹)한다.

 

 물리 트랜잭션 내에 존재하는 여러개의 트랜잭션들은 논리 트랜잭션이라고 하며, 물리 트랜잭션이 커밋되기 위해서는 내부의 모든 논리 트랜잭션이 커밋되어야 한다. 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션도 롤백된다. 내부 트랜잭션이 커밋되고 외부 트랜잭션이 롤백되면 문제가 발생하지 않지만, 내부 트랜잭션이 롤백되면 트랜잭션 동기화 매니저에 rollbackOnly = true옵션이 설정되는데 이 상태에서 외부 트랜잭션 커밋을 시도하면 UnexpectedRollbackException이 발생하며 외부 트랜잭션도 롤백되었음을 알려준다.

 

 

 

 

트랜잭션 전파 - REQUIRES_NEW

 

 앞서 설명한 동작은 트랜잭션이 REQUIRED방식으로 동작할 경우에 수행된다. 하지만 내부 트랜잭션 수행시 REQUIRES_NEW 옵션을 주어 실행하게 되면 내부 트랜잭션 실행시 기존 트랜잭션을 무시하고 새로운 물리 트랜잭션을 시작하게 되어, 외부와 내부 트랜잭션이 별도의 물리 트랜잭션을 사용한다.(= 다른 데이터베이스 커넥션을 사용한다)

 

DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); //REQUIRES_NEW -> 기존 트랜잭션을 무시하고 새 트랜잭션 생성
TransactionStatus inner = txManager.getTransaction(definition);

 

 

위 첫 번째 로그는 외부 트랜잭션 실행시 나오는 로그이고, 밑의 로그는 내부 트랜잭션 실행시 나오는 로그이다. REQUIRED옵션과 다르게 두 트랜잭션이 실행된 db커넥션 이름도 다르다.(conn0, conn1). 즉 내부 트랜잭션 실행시 별도의 물리적 커넥션을 얻어와 별도의 트랜잭션을 만들었음을 알 수 있다.

 

내부/외부 트랜잭션이 별도의 커넥션을 가지는 것을 볼 수 있다.

 

REQUIRES_NEW와 같은 방법은 각각의 트랜잭션을 물리적으로 별도로 처리할 수 있다는 장점을 갖지만, 하나의 요청 로직으로 인해 여러개의 db커넥션이 사용될 수 있기 때문에 물리적 트랜잭션이 빠르게 반환되지 않는다면 트랜잭션 동기화 매니저에 존재하는 커넥션들이 빠르게 고갈될 수 있다는 문제가 있다. 따라서 일반적으로 REQUIRED옵션을 기본 옵션으로 많이 사용한다.

 

 

 

트랜잭션 전파 심화

 

 다음과 같은 상황을 생각하자. 회원가입 로직을 로그로 남기기 위하여, db에 로그 테이블을 만들고 저장하는 과정을 서비스에서 한꺼번에 진행한다. 이때 당연하게도 로그를 작성하는 동안 문제가 발생했다고 비즈니스 로직이 롤백되어서는 안된다. 

 

//AuthService.java
@Transactional
public void signIn(String username) {
    Member member = new Member(username);
    Log logMessage = new Log(username);

    memberRepository.save(member);
    logRepository.save(logMessage);
}
//LogRepository.java
@Transactional
public void save(Log logMessage) {
    em.persist(logMessage);
}
//MemberRepository.java
@Transactional
public void save(Member member) {
    em.persist(member);
}

 

 

다시 말해 로그를 작성하는 로직에서 예외가 터지면, 그냥 아래 코드처럼 그걸 받아서 정상 흐름으로 변환시켜주고, 회원가입 프로세스에서 예외가 터지면 정상적으로 트랜잭션이 롤백되어야 한다.

이렇게 하면 문제를 해결할 수 있을까?

@Transactional
public void signIn(String username) {
    Member member = new Member(username);
    Log logMessage = new Log(username);

    memberRepository.save(member);
    try {
        logRepository.save(logMessage);
    } catch(RuntimeException e) {
        //에러를 받아 정상로직으로 변환
    }
}

 

 

앞서 얘기했듯, Service와 Repository 각각의 함수에 @Transactional을 걸어두고, Service 안의 메소드에서 Repository의 메소드를 호출하는 식으로 동작하면, Repository의 로직에서 에러가 발생하여 롤백되는 순간 트랜잭션 동기화 매니저의 해당 커넥션에는 rollbackOnly=true옵션이 설정되어 버린다.

 

 즉, Service 코드에서 예외가 터지지 않는다고 해도 Repository에서 발생한 예외로 인해서 이미 물리 트랜잭션의 롤백은 확정되어 버린 것이다. 결국 에러를 처리했다고 생각하고 물리 트랜잭션을 커밋하는 순간 아래 그림처럼 UnexpectedRollbackException이 터지며 물리 트랜잭션이 롤백된다.

 

논리 트랜잭션 실패로 인한 rollbackOnly = true 옵션 설정 -> 물리 트랜잭션 롤백

 

LogRepository라도 상황이 다르지 않을 것이다. 로그를 남기는 과정에서 예외가 터지면 MemberService의 트랜잭션까지 롤백되게 되는데, 이럴때 REQUIRES_NEW 옵션을 이용할 수 있다.

 

 @Transaction(propagation = REQUIRES_NEW)옵션을 적용한다면 각각의 내부 트랜잭션이 각각 새로운 커넥션을 할당받아 별도의 물리 트랜잭션으로 동작할 것이므로 예외가 발생한 LogRepository의 트랜잭션을 제외한 나머지 트랜잭션은 롤백되지 않을 것이다.

 

'[ Backend ] > Spring DB, JPA' 카테고리의 다른 글

[Spring Data JPA] Page, Slice  (0) 2023.09.24
[JPA] N+1 문제  (0) 2023.07.11
[JPA] 쓰기 지연으로 인한 서비스 오류 경험  (0) 2023.06.21
[JPA] 연관관계 설정 고민  (0) 2023.05.07
[JPA] JDBC  (0) 2023.02.05