Spring Boot + JPA 프로젝트를 진행하면서 @Transactional(readOnly = true)를 하던 적지 않던 문제 없이 잘 조회가 되는 것을 확인할 수 있었다.
즉 조회 시에 왜 트랜잭션이 필요한지 알아보도록 하자
일반적인 트랜잭션은 여러 작업의 단위(쿼리)들을 하나로 묶어서 수행함에 따라 지정된 범위의 CRUD를 묶어서 커밋 혹은 롤백을 할 수 있음
하지만 트랜잭션을 읽기 전용으로 설정하면 오직 "조회" 작업만 가능함에 따라 다양한 성능 최적화 가능
그런데 사실 조회만 할 경우에는 트랜잭션이 필요하지 않다고 생각될 수 있는데 한번 JDBC 코드부터 시작해서 살펴보자
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)) 및 커밋 및 롤백 로직을 작성하지 않았음
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를 쓸 경우 단순 조회를 하더라도 트랜잭션이 있는 상태에서 읽기 전용으로 하는 게 무조건 좋다는 사실을 알 수 있을 것임. 다음의 예제를 살펴보자
@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) 트랜잭션을 읽기 전용으로 지정
위의 코드와 같이 읽기전용 트랜잭션이 걸린 findMemberAndPrint()를
하지만 만약 트랜잭션을 적용하지 않고 Member에 대한 Team을 조회해보겠다.
@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());
}
}

1.트랜잭션 O 와는 다르게 LazyInitializationException이 뜨는 것을 알 수 있다.
왜 이런 문제가 발생할까?
기본적으로 JPA의 영속성 컨텍스트는 트랜잭션의 생명주기와 일치함(트랜잭션 시작=> 영속성 컨텍스트 주기 시작 , 트랜잭션 종료 => 영속성 컨텍스트 주기 종료)
이에 따라 비즈니스 로직의 수행 여부에 따라 커밋 or 롤백을 하기 위해 일반적으로 @Transacitonal을 서비스에 검으로써 서비스에서 트랜잭션을 시작하는 것
즉 위의 결과와 같이 @Transactional을 서비스 계층에 걸지 않으면 영속성 컨텍스트의 생명 주기가 서비스 계층까지 닿지 못하여 서비스 계층에서 조회 시점에 엔티티는 준영속 상태로 반환되는 것
따라서 준영속 상태에서의 엔티티는 영속성 컨텍스트에서 관리되지 않아서 지연 로딩이 불가능함에 따라 Member에서 Team 객체를 조회하는 것이 불가능함에 따라 LazyInitializationException이 발생한 것
위의 코드와 같이 서비스 계층에서 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";
}
}
<!-- 코드 생략-->
<form th:object="${member}" th:action method="post">
<!-- 코드 생략-->
<label for="name" th:text="|팀명 : |">팀명 : </label>
<input th:field="*{team.name}" id="name">
</form>
<!-- 코드 생략-->
해당 예제는 회원 수정 폼에 접근하기 위한 로직이므로 조회된 회원을 모델로 뷰에 넘겨서 수정을 위해 기존에 사용자가 입력했던 값을 "유지"해줌에 따라 회원이 속한 팀 이름을 출력해고자 한다.
하지만 여기서도 LazyInitializationException이 발생
아무리 서비스에서 읽기 전용 트랜잭션을 잘 걸고 있더라도 컨트롤러에서 조회된 시점에서 이미 영속성 컨텍스트의 주기를 벗어났기 때문에 준영속 엔티티가 되므로 지연 로딩이 되지 않는다
그렇다면 지연로딩으로 연관관계가 설정된 경우 준영속 엔티티에 대한 조회 방법은 없을까? 이때 두 가지 방법이 있다.
필요한 엔티티를 조회하기 전에 미리 로딩
OSIV
총 3가지 방법 존재
1. 즉시 로딩(Eager Loading) : 몽땅 연관관계에 대한 객체들을 조회하므로 필연적으로 N + 1문제 발생 , 쓰지 말자
2. 페치 조인(fetch join) : N+1문제가 발생하진 않지만, 각 화면에 맞춘 리포지토리의 메서드가 증가할 수 잇음 >> 이거 추천하긴 함
3. 강제 초기화 : 영속성 컨텍스트가 살아있을 때 강제로 초기화하여 반환하는 건데 결국 프록시를 초기화하는 역할을 서비스가 담당하다보니 뷰가 필요한 엔티티에 따라 서비스의 로직을 변경해야해서 치명적인 프레젠테이션 계층이 서비스 계층 침범하게 됨
결국 셋 다 문제가 있는데 이는 결국 준영속 상태의 엔티티를 조회하려고 하기 때문에 발생 , 이를 보완하는 게 OSIV임
위의 모든 문제는 결국 엔티티가 클라이언트 계층에서 준영속 상태이기 때문에 발생하는 것
그럼 엔티티를 모든 계층에 걸쳐서 영속 상태로 만들 수는 없을까?
가능하다 ! 영속성 컨텍스트를 컨트롤러 및 뷰와 같은 프레젠테이션 계층까지 열어두는 것을 OSIV라고 함
Open Session In View의 약자로서 이때 세션은 자바에서의 커넥션과 같은 개념으로 뷰까지 영속성 컨텍스트를 열어둔다는 의미
영속성 컨텍스트가 살아있으면 엔티티는 영속 상태로 유지가 돼서 지연 로딩을 어느 계층에서든지 사용이 가능
이것은 과거의 요청 당 트랜잭션 방식의 OSIV가 있고, 스프링 프레임워크가 제공하는 비즈니스 계층까지 트랜잭션을 유지하고 상위 프레젠테이션 계층에선 영속성 컨텍스트만 유지하게 하는 OSIV가 있는데 후자를 스프링 OSIV라고 하겠다.

요청 들어오자마자(WAS 내부로 요청이 진입한 순간) 서블릿 필터 혹은 스프링 인터셉터에서 영속성 컨텍스트 만듦과 동시에 트랜잭션 시작하고 응답 나갈 때 트랜잭션과 영속성 컨텍스트 함께 종료
트랜잭션 생명주기 = 영속성 컨텍스트 생명주기 = 요청,응답의 생명주기
프레젠테이션 계층(컨트롤러 , 뷰)에서 엔티티 무작위로 변경 가능함에 따라 문제 많음

클라이언트 요청이 들어오면 필터 & 인터셉터에서 영속성 컨텍스트 시작, 트랜잭션은 시작 X
서비스 계층에서 트랜잭션 시작하면 앞서 생성해 둔 영속성 컨텍스트에서 트랜잭션 시작
비즈니스 로직 실행 후 서비스 계층 종료 시 트랜잭션 커밋하면서 영속성 컨텍스트 플러시(트랜잭션만 종료하고 영속성 컨텍스트는 유지)
클라이언트 응답이 나갈 때 영속성 컨텍스트 종료
영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이루어져야하므로 트랜잭션 없이 엔티티 변경하고 플러시 하면 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을 통해 트랜잭션을 거는 게 좋다고 생각
우선 스프링 AOP가 메서드 호출을 가로채고 트랜잭션 시작
트랜잭션 매니저(PlatformTransactionManager)는 con.setReadOnly(true)를 통해 커넥션 연결을 읽기 모드로 설정
연결
➤ 읽기 전용으로 하면서 DB의 쓰기 작업 방지하고 최적화 수행
EntityManager의 FlushMode를 MANUAL로 설정하여 쓰기 작업 방지
➤ EntityManager가 트랜잭션이 유지되는 동안 변경 감지(Dirty Checking)를 일으키지 않게 하여 쓰기 작업(저장 , 수정 , 삭제)이 불가능하게 함
※ JPA는 저장 또한 변경 감지를 통해 영속성 컨텍스트의 1차 캐시에 저장된 객체가 없는 것을 확인 후 저장 쿼리를 만들어주는 것