[JPA] 읽기 전용 트랜잭션의 필요성

Ceing·2024년 7월 13일

JPA

목록 보기
4/15
post-thumbnail

개요

  • Spring Boot + JPA 프로젝트를 진행하면서 @Transactional(readOnly = true)를 하던 적지 않던 문제 없이 잘 조회가 되는 것을 확인할 수 있었다.

  • 즉 조회 시에 왜 트랜잭션이 필요한지 알아보도록 하자

사전 지식

  • 트랜잭션 개념 : DB의 상태를 변경시키기 위한 여러 작업의 단위들의 결합
  • 트랜잭션 구현 : 수동 커밋 모드로 설정하여 수동으로 커밋 & 롤백을 제어
    ➤ 스프링에선 @Transactional을 통해 스프링 AOP가 @Transactional을 지정한 클래스의 프록시 객체를 생성해서 해당 프록시 객체가 실제 객체보다 먼저 요청받아서 앞단에서 트랜잭션 처리를 해주는 것임


읽기 전용(readOnly) 트랜잭션?

  • 일반적인 트랜잭션은 여러 작업의 단위(쿼리)들을 하나로 묶어서 수행함에 따라 지정된 범위의 CRUD를 묶어서 커밋 혹은 롤백을 할 수 있음

  • 하지만 트랜잭션을 읽기 전용으로 설정하면 오직 "조회" 작업만 가능함에 따라 다양한 성능 최적화 가능

  • 그런데 사실 조회만 할 경우에는 트랜잭션이 필요하지 않다고 생각될 수 있는데 한번 JDBC 코드부터 시작해서 살펴보자



1. 구현 예제 : 순수 JDBC

1. 트랜잭션 적용 X

public class MemberService{
	private static final MemberRepository memberRepository = new MemberRepository();
	public void findMemberAndPrint(Long id){
    	//커넥션 시작
    	Connection con = getConnection(); //DriverManager.getConnection(URL , USERNAME , PASSWORD);
        con.setReadOnly(true); //핵심
        
        //비즈니스 로직
        Member findMember = memberRepository.findById(id , con);
        System.out.println("Member = "+findMember);
        
        //커넥션 종료
        con.setReadOnly(false); //핵심
        close(); //con.close();
    }

}
  • 다른 라이브러리의 도움 없이 순수 JDBC로만 작성하였음

  • findMemberAndPrint()은 이름에서 알 수 있듯이 회원 정보를 리포지토리에서 id를 통해 조회해서 출력하는 로직

  • getConnection()은 커넥션을 DriverManager로부터 받고, close()는 커넥션을 종료하는 로직

  • 핵심은 con.setReadOnly(true)를 통해 커넥션을 읽기 전용으로 만들고 종료 시점에 con.setReadOnly(false)를 통해 다시 원상복구 시켜놓음으로써 Connection Pool을 사용하는 경우 추후 커넥션 요청 시 안심하고 커넥션을 받을 수 있다.
    ➤ 커넥션을 DriverManager로부터 생성할 경우는 커넥션 반납되는 즉시 폐기되기 때문에 굳이 true로 되돌려놓지 않아도 되긴 함

  • 위와 같이 단순 조회일 경우 위의 코드와 같이 트랜잭션을 적용하지 않았으므로 트랜잭션의 시작을 알리는 수동 커밋 모드(con.setAutoCommit(false)) 및 커밋 및 롤백 로직을 작성하지 않았음



2. 트랜잭션 적용 O

public class MemberService{
	private static final MemberRepository memberRepository = new MemberRepository();
	public void findMemberAndUpdate(Long id){
    	
        Connection con = null;
        
        try{
        	//커넥션 시작
    		con = getConnection(); //DriverManager.getConnection(URL , USERNAME , PASSWORD);
            con.setAutoCommit(false);
	        con.setReadOnly(true); 
          
	        //비즈니스 로직
    	    Member findMember = memberRepository.findById(id , con);
        	System.out.println("Member = "+findMember);
            
            //성공 시 커밋
            con.commit();
        }catch(SQLException e){
        	System.out.println(e.getMessage());
            //문제 시 롤백
			con.rollback();
            
            
        }finally{
	        //커넥션 종료
    	    con.setReadOnly(false); 
        	close(); //con.setAutoCommit(true); & con.close();
	    }

}
  • 트랜잭션을 적용하였으므로 수동 커밋 모드를 활성화하였고, 성공 시 커밋 , 실패 시 롤백을 하였음.

  • 커넥션을 종료할 땐 위에서 말했다시피 다시 원상복구 해주는 작업이 필요함에 따라 커넥션을con.setReadOnly(false) 뿐만 아니라 con.setAutoCommit(true)로 다시 디폴트값인 자동 커밋 모드로 변환해주었음


조회 시 트랜잭션 유무 차이

  • JDBC만 쓸 경우에는 단순 조회 시에는 그냥 트랜잭션 시작 안 하고 con.setReadOnly(true)만 해도 되지만, 복잡한 조회 작업이나 여러 단계의 작업을 포함할 땐 con.setReadOnly(true)con.setAutoCommit(true)로 트랜잭션 시작을 함으로써 데이터 일관성을 보장하고 오류 시 롤백을 할 수도 있음

  • 하지만 JPA를 쓸 경우 단순 조회를 하더라도 트랜잭션이 있는 상태에서 읽기 전용으로 하는 게 무조건 좋다는 사실을 알 수 있을 것임. 다음의 예제를 살펴보자


2. 구현 예제 : Spring Boot + JPA

1. 트랜잭션 O

@Service
@RequiredArgsConstructor
public class MemberService{
  private final MemberRepository memberRepository;

  
  @Transactional(readOnly = true)
  public void findMemberAndPrint(Long id){
    Member findMember = memberRepository.findById(id);
    System.out.println(findMember.getTeam().getName());
  }
  
}
  
  • Member과 Team 엔티티는 서로 양방향 연관관계 및 지연 로딩으로 설정되어있음

  • memberRepository.findById()로 Member을 조회 후 객체 그래프 탐색을 통해 Team의 이름을 조회하고자 함

  • 이때 @Transactional(readOnly = true) 트랜잭션을 읽기 전용으로 지정


1. 트랜잭션 O : 결과

  • 위의 코드와 같이 읽기전용 트랜잭션이 걸린 findMemberAndPrint()를

  • 하지만 만약 트랜잭션을 적용하지 않고 Member에 대한 Team을 조회해보겠다.



2. 트랜잭션 X

@Service
@RequiredArgsConstructor
public class MemberService{
  private final MemberRepository memberRepository;

  
  //@Transactional(readOnly = true)
  public void findMemberAndPrint(Long id){
    Member findMember = memberRepository.findById(id);
    System.out.println(findMember.getTeam().getName());
  }
  
}
  • 트랜잭션을 적용하지 않고 결과를 한 번 확인해보자


2. 트랜잭션 X : 결과

  • 1.트랜잭션 O 와는 다르게 LazyInitializationException이 뜨는 것을 알 수 있다.

  • 왜 이런 문제가 발생할까?



LazyInitializationException 이유

  • 기본적으로 JPA의 영속성 컨텍스트는 트랜잭션의 생명주기와 일치함(트랜잭션 시작=> 영속성 컨텍스트 주기 시작 , 트랜잭션 종료 => 영속성 컨텍스트 주기 종료)

  • 이에 따라 비즈니스 로직의 수행 여부에 따라 커밋 or 롤백을 하기 위해 일반적으로 @Transacitonal을 서비스에 검으로써 서비스에서 트랜잭션을 시작하는 것

  • 즉 위의 결과와 같이 @Transactional을 서비스 계층에 걸지 않으면 영속성 컨텍스트의 생명 주기가 서비스 계층까지 닿지 못하여 서비스 계층에서 조회 시점에 엔티티는 준영속 상태로 반환되는 것

  • 따라서 준영속 상태에서의 엔티티는 영속성 컨텍스트에서 관리되지 않아서 지연 로딩이 불가능함에 따라 Member에서 Team 객체를 조회하는 것이 불가능함에 따라 LazyInitializationException이 발생한 것


3. 실용적인 예제 : 추가적인 문제

  • 위의 코드와 같이 서비스 계층에서 print문으로 회원을 출력해줄 일은 없다. 즉 실용적인 관점에서도 한번 살펴보자

  • 일반적으로는 서비스에서findMember()을 통해 회원을 조회하는 기능까지만 구현하고, 컨트롤러에서 해당 메서드를 호출하는 형태로 구현한다.

  • 한번 구현해보자


서비스

@Service
@RequiredArgsConstructor
public class MemberService{
  private final MemberRepository memberRepository;

  
  @Transactional(readOnly = true)
  public void findMember(Long id){
    return memberRepository.findById(id);
    
  }
  
}
  

컨트롤러

@Controller
@RequiredArgsConstructor
public class MemberController{
	private final MemberService memberService;
    
    @GetMapping("members/{id}/edit")
    public String editMemberForm(@PathVariable("id") Long id , Model model){
    	model.addAttribute("member" , memberService.findMember(id));
        return "member";
    }
    
}

뷰(Thymeleaf)

<!-- 코드 생략-->
<form th:object="${member}" th:action method="post">
  <!-- 코드 생략-->
  	<label for="name" th:text="|팀명 : |">팀명 : </label>
	<input th:field="*{team.name}" id="name">
</form>
<!-- 코드 생략-->
  • 해당 예제는 회원 수정 폼에 접근하기 위한 로직이므로 조회된 회원을 모델로 뷰에 넘겨서 수정을 위해 기존에 사용자가 입력했던 값을 "유지"해줌에 따라 회원이 속한 팀 이름을 출력해고자 한다.

  • 하지만 여기서도 LazyInitializationException이 발생

  • 아무리 서비스에서 읽기 전용 트랜잭션을 잘 걸고 있더라도 컨트롤러에서 조회된 시점에서 이미 영속성 컨텍스트의 주기를 벗어났기 때문에 준영속 엔티티가 되므로 지연 로딩이 되지 않는다

  • 그렇다면 지연로딩으로 연관관계가 설정된 경우 준영속 엔티티에 대한 조회 방법은 없을까? 이때 두 가지 방법이 있다.



준영속 상태의 엔티티 지연로딩 해결 방법

  1. 필요한 엔티티를 조회하기 전에 미리 로딩

  2. OSIV


1. 필요한 엔티티 조회하기 전에 미리 로딩

총 3가지 방법 존재

1. 즉시 로딩(Eager Loading) : 몽땅 연관관계에 대한 객체들을 조회하므로 필연적으로 N + 1문제 발생 , 쓰지 말자

2. 페치 조인(fetch join) : N+1문제가 발생하진 않지만, 각 화면에 맞춘 리포지토리의 메서드가 증가할 수 잇음 >> 이거 추천하긴 함

3. 강제 초기화 : 영속성 컨텍스트가 살아있을 때 강제로 초기화하여 반환하는 건데 결국 프록시를 초기화하는 역할을 서비스가 담당하다보니 뷰가 필요한 엔티티에 따라 서비스의 로직을 변경해야해서 치명적인 프레젠테이션 계층이 서비스 계층 침범하게 됨

결국 셋 다 문제가 있는데 이는 결국 준영속 상태의 엔티티를 조회하려고 하기 때문에 발생 , 이를 보완하는 게 OSIV임

2. OSIV

개요

  • 위의 모든 문제는 결국 엔티티가 클라이언트 계층에서 준영속 상태이기 때문에 발생하는 것

  • 그럼 엔티티를 모든 계층에 걸쳐서 영속 상태로 만들 수는 없을까?

  • 가능하다 ! 영속성 컨텍스트를 컨트롤러 및 뷰와 같은 프레젠테이션 계층까지 열어두는 것을 OSIV라고 함


개념

  • Open Session In View의 약자로서 이때 세션은 자바에서의 커넥션과 같은 개념으로 뷰까지 영속성 컨텍스트를 열어둔다는 의미

  • 영속성 컨텍스트가 살아있으면 엔티티는 영속 상태로 유지가 돼서 지연 로딩을 어느 계층에서든지 사용이 가능

  • 이것은 과거의 요청 당 트랜잭션 방식의 OSIV가 있고, 스프링 프레임워크가 제공하는 비즈니스 계층까지 트랜잭션을 유지하고 상위 프레젠테이션 계층에선 영속성 컨텍스트만 유지하게 하는 OSIV가 있는데 후자를 스프링 OSIV라고 하겠다.


요청 당 트랜잭션 OSIV

  • 요청 들어오자마자(WAS 내부로 요청이 진입한 순간) 서블릿 필터 혹은 스프링 인터셉터에서 영속성 컨텍스트 만듦과 동시에 트랜잭션 시작하고 응답 나갈 때 트랜잭션과 영속성 컨텍스트 함께 종료

  • 트랜잭션 생명주기 = 영속성 컨텍스트 생명주기 = 요청,응답의 생명주기

  • 프레젠테이션 계층(컨트롤러 , 뷰)에서 엔티티 무작위로 변경 가능함에 따라 문제 많음


스프링 OSIV

  1. 클라이언트 요청이 들어오면 필터 & 인터셉터에서 영속성 컨텍스트 시작, 트랜잭션은 시작 X

  2. 서비스 계층에서 트랜잭션 시작하면 앞서 생성해 둔 영속성 컨텍스트에서 트랜잭션 시작

  3. 비즈니스 로직 실행 후 서비스 계층 종료 시 트랜잭션 커밋하면서 영속성 컨텍스트 플러시(트랜잭션만 종료하고 영속성 컨텍스트는 유지)

  4. 클라이언트 응답이 나갈 때 영속성 컨텍스트 종료

  • 영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이루어져야하므로 트랜잭션 없이 엔티티 변경하고 플러시 하면 TransactionRequiredException 발생

  • 하지만 변경하지 않고 단순 조회는 트랜잭션 없이도 영속성 컨텍스트가 살아있기 때문에 어느 계층에서든지 자유롭게 조회가 가능함에 따라 지연로딩도 적극적으로 써도 됨

  • 이에 따라 @Transactional이 없는 범위에서도 영속성 컨텍스트가 있기에 지연로딩을 통한 조회가 자유롭게 가능한 것 , 즉 프레젠테이션 계층에서도 트랜잭션 없이 읽기(조회) 가능

  • 하지만 변경 감지(Dirty Checking) 기능은 트랜잭션 안에서 이루어져야 하므로 변경이 일어나지 않고 변경을 시도하면 앞서 말했듯이 예외가 발생

  • 이에 따라 OSIV를 켜놓음으로써 트랜잭션 없어도 영속성 컨텍스트가 동작하여 지연로딩을 통한 조회가 가능하단 사실을 알 수 있음

  • Spring Boot 사용 시 디폴트로 applicaton.properties에서 spring.jpa.open-in-view 옵션을 true로 주었기 때문에 OSIV 옵션은 기본값으로 TRUE로 설정돼있음



결론 및 내 생각

  • 조회 시에 읽기 전용 트랜잭션(@Transactional(readOnly = true))을 쓰든 트랜잭션 없이 조회하든 둘 다 영속성 컨텍스트를 flush를 하지 않음으로써 변경 감지(Dirty Checking)를 통한 스냅샷 비교를 하지 않아 기능을 성능이 향상됨

  • 물론 트랜잭션 아예 없으면 트랜잭션 시작 및 커밋 롤백 과정조차 사라져서 약간의 성능 향상을 기대할 수 있겠지만, 그 정도는 굉장히 미비할 것으로 보임
    ➤ 조회용 트랜잭션 설정만으로도 충분한 성능 향상을 일으킴

  • 따라서 단순 조회만 하는 서비스 계층이고 연관관계가 지연로딩으로 설정되어있으면 OSIV를 켜놓고 @Transactional 없이 조회해도 되지만, 동시성 이슈를 고려하지 않고 단순 조회만 하려고 서비스 계층을 두는 경우는 거의 없고, 확장성과 유지보수를 생각하면 그냥 성능 차이도 그렇게 많지도 않고 서비스 단에 @Transactional을 통해 트랜잭션을 거는 게 좋다고 생각



※ 보충 : @Transactional(readOnly = true) 동작 원리

설명

  • 우리는 CRUD 중 Read(조회) 시 트랜잭션을 읽기 전용으로 만들어 성능 면에서 많은 장점을 가져갈 수 있었음
  • 트랜잭션을 읽기 전용으로 만들면 오직 해당 트랜잭션은 조회만 가능하게끔 하여 JPA에서는 변경 감지를 비롯한 각종 영속성 컨텍스트의 특성들을 배제할 수 있기 때문
  • @Transactional(readOnly = true)의 동작 원리를 보자
  1. 우선 스프링 AOP가 메서드 호출을 가로채고 트랜잭션 시작

  2. 트랜잭션 매니저(PlatformTransactionManager)는 con.setReadOnly(true)를 통해 커넥션 연결을 읽기 모드로 설정
    연결
    ➤ 읽기 전용으로 하면서 DB의 쓰기 작업 방지하고 최적화 수행

  3. EntityManager의 FlushMode를 MANUAL로 설정하여 쓰기 작업 방지
    ➤ EntityManager가 트랜잭션이 유지되는 동안 변경 감지(Dirty Checking)를 일으키지 않게 하여 쓰기 작업(저장 , 수정 , 삭제)이 불가능하게 함
    ※ JPA는 저장 또한 변경 감지를 통해 영속성 컨텍스트의 1차 캐시에 저장된 객체가 없는 것을 확인 후 저장 쿼리를 만들어주는 것

결론

  • 즉 1번을 통해 DB의 쓰기 작업을 불가능하게 하고 2번을 통해 JPA(영속성 컨텍스트)의 쓰기 작업을 불가능하게 하는 것임
  • readOnly로 둘 경우 DB에선 쓰기 기능을 막아놓고 JPA 사용 시에는 변경 감지를 사용하지 않아도 됨
profile
이유에 대해 끊임없이 생각하고 고민하는 개발자

0개의 댓글