본문 바로가기

Article

서비스 계층에 @Transactional 어노테이션을 붙이는 것에 대한 고찰

@Transactional 어노테이션

스프링은 AOP를 통해 트랜잭션 시작과 종료를 간편하게 할 수 있도록 지원한다. 덕분에 @Transactional 어노테이션을 통해 비즈니스 로직에 트랜잭션 관련 로직이 섞이지 않을 수 있다. 트랜잭션 처리 로직은 프록시 객체가 가져가고, 서비스 계층에는 순수한 비즈니스 로직만 남길 수 있게 해준다.

 

@Transactional을 적용했을 때 동작 과정은 다음과 같다.

  1. 트랜잭션을 시작하기 위해서는 먼저 커넥션이 필요하다.
  2. 커넥션을 획득했으면, 해당 커넥션을 통해 auto commit 속성을 false로 설정한다. 즉, 트랜잭션이 시작되었고, 이후 진행되는 로직들은 DB에 바로 반영되지 않고 커밋 시점에 반영된다.
  3. 이후 서비스 계층 내에서 DB에 접근하는 메서드가 실행되면 해당 쿼리가 JPA의 쓰기 지연 SQL 저장소에 저장된다.
  4. 트랜잭션이 커밋되면, 플러시가 일어나면서 쓰기 지연 SQL 저장소의 쿼리들이 데이터베이스에 실행된다.

만약 @Transactional을 적용하지 않으면?

항상 서비스 계층에 @Transactional을 붙여주곤 했는데, 만약 붙이지 않으면 어떻게 될까?

 

JpaRepository의 기본 구현체는 SimpleJpaRepository이다. 그 내부를 보면 @Transactional(readOnly=true)가 기본이다. 그리고 엔티티 수정 메서드(save, delete, …)에는 @Transactional(readOnly=false)로 설정되어 있다.

 

	@Transactional
	@Override
	public <S extends T> S save(S entity) {
		//...
	}
	
	@Transactional
	@Override
	public void deleteById(ID id) {
		//...
	}

 

즉, JpaRepository에 미리 선언된 메서드를 호출하면, 서비스 계층에 @Transactional이 없어도 트랜잭션이 제대로 동작한다. 그리고 메서드의 성격에 따라 readOnly 설정 값이 다르다.

* 참고: readOnly 설정
readOnly 설정을 true로 설정하면, JPA는 자동으로 플러시를 하지 않으며, 사용자가 직접 플러시를 호출해야 한다. 즉, 트랜잭션 커밋 시 자동으로 플러시가 호출되지 않는다. 따라서 조회만 하는 메서드에서 readOnly 설정을 true로 설정하면, 플러시가 호출되지 않기 때문에 실수로 엔티티를 수정했더라도 DB에 반영되지 않을 수 있다. 그리고 JPA 내부에서 해당 엔티티는 조회용으로만 사용될 것임을 인지하고 더티체킹을 위한 스냅샷을 생성하지 않아 성능 상 이점이 있다.

 

대신 이 경우, 서비스 계층에서 JpaRepository의 메서드를 2개 이상 호출한다면, 각각의 작업들은 독립적인 트랜잭션에서 수행된다. 즉, 첫 번째 메서드 호출이 성공하고 두 번째 메서드 호출이 실패할 경우, 첫 번째 메서드의 변경 사항은 롤백되지 않는다.

 

반면, 서비스 계층에 @Transactional이 붙어있으면 다음과 같이 동작한다.

// 서비스 계층
@Transactional
public void save() {
    repository.save(...); 
}
  • 서비스 계층의 save() 메서드가 호출될 때 트랜잭션이 시작된다. ("JpaTransactionManager : Creating new transaction ...")
  • 그리고 JPA의 기본 메서드인 save() 메서드에도 @Transactional이 붙어있는데, 이때는 새로운 트랜잭션을 시작하는 것이 아니라, 기존 트랜잭션에 참여하게 된다. (즉, 내부 트랜잭션이 외부 트랜잭션에 참여한다. 따라서 두 트랜잭션은 같은 데이터베이스 커넥션을 사용한다.) ("JpaTransactionManager : Participating in existing transaction")

그러면 서비스 계층에 굳이 @Transactional을 붙여야 하는 이유는?

  • 우선 스프링 컨테이너의 기본 설정은 트랜잭션의 범위와 영속성 컨텍스트의 범위가 같다. 즉, 트랜잭션이 시작될 때 영속성 컨텍스트가 생성되고, 트랜잭션이 끝날 때 영속성 컨텍스트가 종료된다. 만약 서비스 계층에 @Transactional을 붙이지 않으면, JPA 기본 메서드를 호출할 때 트랜잭션이 시작된다. 즉, 서비스 계층에서 JPA 메서드를 호출할 때마다 그 각각들이 개별 트랜잭션과 영속성 컨텍스트를 갖는다. 따라서 영속성 컨텍스트의 1차 캐시를 통한 이점을 얻을 수 없다. 서비스 계층에서 엔티티를 여러 번 조회할 때, 영속성 컨텍스트의 1차 캐시에 해당 엔티티가 있으면 재사용한다. 즉, DB 조회 횟수를 줄여 성능상 이점이 있다. 그러나 각 메서드마다 각기 다른 영속성 컨텍스트를 가지면 1차 캐시를 통한 이점을 누릴 수 없다.
  • 그리고 리포지토리 메서드가 호출될 때마다 트랜잭션을 열고 닫으면, 원자성이 보장되어야 하는 작업에서 각기 다른 트랜잭션이 수행될 수 있다.
  • 서비스 계층에서 지연로딩을 하면 예외가 발생한다. 만약 게시글과 연관된 회원 엔티티를 지연로딩으로 설정했다고 가정하자. 그러면 JPA 메서드를 통해 게시글 엔티티를 조회해왔을 때, 회원 엔티티는 프록시 객체이다. 그리고 서비스 계층에서 회원 엔티티에 접근하면? 이때 서비스 계층은 영속성 컨텍스트의 밖이므로, 조회된 게시글 엔티티는 준영속 상태가 된다(영속이었다가 준영속으로 됨). 따라서 이때 지연로딩이 일어나지 않고 LazyInitializationException 예외가 발생한다. 즉, 준영속 상태에서 지연로딩을 사용하면 예외가 발생한다.
  • 결론적으로, 핵심 비즈니스 로직을 담고 있는 서비스 계층에서는 대부분의 로직이 원자성이 보장되어야 한다. 따라서 서비스 계층에서 트랜잭션이 시작되고 종료되어야 한다. 그리고 서비스 계층에서 1차 캐시, 지연로딩 등 영속성 컨텍스트를 통한 성능상 이점을 누리기 위해서는, 서비스 계층에서 트랜잭션이 시작되며 영속성 컨텍스트가 살아있어야 한다.

@Transactional 없이 지연 로딩하기: OSIV

OSIV 옵션을 켜면, 트랜잭션 없이도 지연 로딩이 가능하다. 즉, 트랜잭션 밖에서도 영속성 컨텍스트가 살아있기 때문에 엔티티 조회가 가능하다(수정은 불가). 따라서 OSIV 옵션을 켜면, 서비스 계층에 @Transactional이 없어도 영속성 컨텍스트가 유지되므로 지연로딩이 가능하다.

 

그러나 OSIV 옵션을 켜두면 영속성 컨텍스트의 생명주기가 길어지고, 그동안 데이터베이스 커넥션을 오래 잡고 있게 된다. 따라서 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 부족해서 대기해야 할 수 있으므로, OSIV를 끄는 것이 좋다.


Reference

 

'Article' 카테고리의 다른 글

좋아요 동시성 제어 과정  (1) 2024.08.20
MySQL의 갭 락과 넥스트 키 락에 대해  (0) 2024.08.01