[JPA] 영속성 컨텍스트와 OSIV(Open Session In View)

2022. 5. 24. 16:03 Spring Data/Spring Data JPA

개요

JPA를 사용하면서 개발하다보면 간혹 LazyInitializationException 이라는 예외를 접하게 될 것이다. 이번에 포스팅할 OSIV는 해당 예외와 밀접한 관련이 있다. 또한 OSIV의 활성화 여부에 따라 JPA를 사용해서 개발하는 애플리케이션 성능에 큰 영향을 미치게 된다. 이번 기회에 OSIV에 대한 개념을 이해해보자.

1. 영속성 컨텍스트

먼저 선행되어야 할 지식은 JPA의 영속성 컨텍스트이다. 영속성 컨텍스트는 JPA에서 가장 중요한 개념중 하나로, 쉽게 말해 눈에 보이지 않으며 Entity를 영구히 저장하는 환경이라는 뜻이다.

하나의 트랜잭션 안에서 이루어지는 데이터들은 영속화되어 데이터를 조회할때 1차캐시라는 공간에서 가져오게 된다. 아래 예제들을 보면서 이해해보자.

📝예제 1) 하나의 트랜잭션에서 실행되는 경우

먼저 아래 예제와 같은 Member와 Team이 존재한다.

@Entity
@NoArgsConstructor
@Getter @Setter
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;
}


@Entity
@NoArgsConstructor
@Getter @Setter
public class Team {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
}

다소 억지스러운 예제지만 아래 Controller에서 saveAndFind() 메소드를 실행했을때 어떤 쿼리가 실행되는지 예측해보자.

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @GetMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Team saveAndFind() {
        return memberService.FindAndSave();
    }
}
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final TeamRepository teamRepository;

    @Transactional
    public Team FindAndSave() {
        // (1)
        Team team = new Team();
        team.setName("Team");
        teamRepository.save(team);

        // (2)
        Member member = new Member();
        member.setName("userA");
        member.setTeam(team);
        memberRepository.save(member);

        // (3)
        Member findMember = memberRepository.findById(2L).orElseThrow();
        // (4)
        return findMember.getTeam();

    }
}
  • (1) : Team을 Save하는 Insert 쿼리가 실행된다.
  • (2) : Member를 Save하는 Insert 쿼리가 실행된다.
  • (3) : Member를 조회하는 Select 쿼리가 실행된다.
  • (4): Lazy 로딩으로 Member를 조회할때는 Team을 Select하는 쿼리가 실행되지 않지만 실제로 Team을 조회하는 시점인 (4)에서는 Team을 조회하는 Select 쿼리가 실행된다.

실제 실행된 쿼리는 다음과 같다.

 insert 
 into
     team
     (name, id) 
  values
     (?, ?)

 insert 
 into
     member
     (name, team_id, id) 
  values
     (?, ?, ?)

예상대로라면 Select쿼리도 두 번 실행되어야 하는데 Team과 Member를 생성하는 Insert 쿼리만 실행되었다.

그 이유는 Team과 Member를 저장하고 조회하는 로직이 모두 하나의 트랜잭션 안에서 진행되었기 때문에 조회하는 시점에 DB에 Select 쿼리를 날려서 조회하는것이 아닌 영속성 컨텍스트(1차 캐시)에서 조회했기 때문이다.

(트랜잭션이 종료되면 1차 캐시도 함께 소멸 된다. 실제로는 하나의 트랜잭션 안에서 저장과 조회과 함께 일어나는 경우는 매우 드물기 때문에 1차 캐시로 얻을 수 있는 성능은 매우 미비하다.)


📝예제 2) 서로 다른 트랜잭션에서 실행되는 경우

예제1과 마찬가지로 실행되는 쿼리를 예상해보자. 예제1과 다르게 저장과 조회가 서로 다른 트랜잭션에서 일어나기 때문에 아래 예상대로 쿼리가 실행될 것이다.

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @GetMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Team save() {
        memberService.save();
        return memberService.findById();
    }
}
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final TeamRepository teamRepository;

    @Transactional
    public void save() {
        // (1)
        Team team = new Team();
        team.setName("Team");
        teamRepository.save(team);

        // (2)
        Member member = new Member();
        member.setName("userA");
        member.setTeam(team);
        memberRepository.save(member);
    }

    public Team findById() {
        // (3)
        Member findMember = memberRepository.findById(2L).orElseThrow();
        // (4)
        return findMember.getTeam();
    }
}
  • (1) : Team을 Save하는 Insert 쿼리가 실행된다.
  • (2) : Member를 Save하는 Insert 쿼리가 실행된다.
  • (3) : Member를 조회하는 Select 쿼리가 실행된다.
  • (4): Lazy 로딩으로 Member를 조회할때는 Team을 Select하는 쿼리가 실행되지 않지만 실제로 Team을 조회하는 시점인 (4)에서는 Team을 조회하는 Select 쿼리가 실행된다.

실제 실행된 쿼리는 다음과 같다.

    insert 
    into
        team
        (name, id) 
    values
        (?, ?)

    insert 
    into
        member
        (name, team_id, id) 
    values
        (?, ?, ?)

FindAndSave 메소드가 종료되면서 트랜잭션이 커밋되었기 때문에 1차 캐시도 소멸되어야 한다. 따라서 INSERT 쿼리 이후 Select 쿼리가 각각 실행되어야 하는데 실제 실행 결과를 보니 예제1과 마찬가지로 Insert만 실행되고 Select쿼리는 생략되었다. 

 

정확히는 Team이라는 객체가 @Transactional이 적용된 FindAndSave 를 벗어남과 동시에 detached(비영속) 상태가 되기 때문에 lazyLoading 대기 상태이던 Team 이라는 필드에 접근하면 LazyInitializationException이 발생해야 한다.

2. OSIV(Open Session In View)

바로 위 예제에서 LazyInitializationException이 발생하지 않은 이유가 바로 OSIV가 활성화 되어있기 때문이다.

Open Session In View란 뷰 렌더링 시점에 영속성 컨텍스트가 존재하지 않기 때문에 Detached 객체의 프록시를 초기화할 수 없다면 영속성 컨텍스트를 오픈된 채로 뷰 렌더링 시점까지 유지하자는 것 입니다. 즉, 작업 단위를 요청 시작 시점부터 뷰 렌더링 완료 시점까지로 확장하는 것 입니다.
https://kingbbode.tistory.com/27

쉽게 말해서 영속성 컨텍스트(1차 캐시)를 트랜잭션 종료 시점이 아닌 뷰 렌더링 종료 시점까지 유지시키는 것이다. 개념으로만 보면 항상 OSIV를 활성화 시키는것이 유리하다고 생각할 수 있지만 이는 성능적인 문제를 야기한다.

 

기본적으로 트랜잭션이 종료됨과 동시에 Connection pool에 DB 커넥션을 반환해야 하는데 OSIV가 활성화되어 있으면 뷰 렌더링이 종료되는 시점까지 커넥션을 유지하게 된다.

 

그렇기 때문에 실시간 트래픽이 중요한 대규모 서비스에서는 커넥션이 부족해지는 현상이 발생할 수 있다.

 

우리가 OSIV를 직접 활성화 시킨적이 없는데도 불구하고 LazyInitializationException이 발생하지 않는 이유는 Springboot에서 Default로 OSIV를 활성화 상태로 설정하기 때문이다.

 

application.yml

spring:
  jpa:
    open-in-view: false

OSIV를 비활성화 하고 예제 2번을 다시한번 실행해보자.

실행되는 쿼리는 다음과 같다.

    insert 
    into
        team
        (name, id) 
    values
        (?, ?)


    insert 
    into
        member
        (name, team_id, id) 
    values
        (?, ?, ?)


    select
        member0_.id as id1_6_0_,
        member0_.name as name2_6_0_,
        member0_.team_id as team_id3_6_0_ 
    from
        member member0_ 
    where
        member0_.id=?   

쿼리 실행 결과를 보면 Member와 Team을 Insert하는 쿼리가 각각 실행되고, Member를 조회하는 Select 쿼리가 실행되 이후에 Fetch 전략이 Lazy로 설정되어있는 Team 객체를 조회하는 순간에 아래와 같은 LazyInitializationException이 발생하게 된다.

"org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: could not initialize proxy [com.flab.shoeauction.controller.Team#1] ~~~~~ 생략

3. 해결 방법

먼저 성능을 위해 항상 OSIV를 OFF해야 하는것은 아니다. 커넥션이 많이 필요한 로직과 그렇지 않은 로직에 따라서 trade off를 비교한 후 알맞은 솔루션을 선택해야 한다.

OSIV 비활성화 - 만약 OSIV를 비활성화 한다면 LazyLoding을 포기해야할까??

그렇지 않다. OSIV를 비활성화 한다는 것 자체가 성능적인 부분을 개선하기 위함인데 Fetch 전략을 EAGER로 변경하면 또다른 성능 문제가 발생하게 된다.

가장 간단한 해결책은 트랜잭션 안에서 미리 로딩을 해놓거나, Fetch Join또는 @EntityGraph를 사용해서 Member조회 시점에 Team도 함께 조회하는 것이다. 

 

하지만 LazyInitializationException을 방지하기 위해 트랜잭션안에서 모든 로직을 처리하도록 구현한다면 코드 자체도 지저분해질 뿐만 아니라 유지보수성 또한 떨어지게 된다.

 

따라서 기존 Service 로직을 그대로 둔 상태에서 상황에 맞는 API 조회용 Service를 추가로 만들어서 그 로직을 읽기전용 트랜잭션으로 설정한 후 따로 관리하는 방법을 많이 사용한다고 한다.

4. 정리

sping.jpa.open-in-view : true

  • 장점
    • 뷰 렌더링 종료시까지 영속성 컨텍스트가 유지
    • : 지연 로딩을 하나의 트랜잭션 안에서 처리하지 않아도 되기 때문에 코드 구현에 있어서 상대적으로 편하고, 관리해야 할 코드도 줄어듬
  • 단점
    • DB 커넥션을 뷰 렌더링 종료시까지 유지
    • : 상황에 따라 커넥션 부족 문제가 발생할 수 있음

sping.jpa.open-in-view : false

  • 장점
    • DB 커넥션 리소스의 효율적인 사용
      : 트랜잭션을 종료할 때 영속성 컨텍스트를 닫으면서 DB 커넥션을 반환
  • 단점
    • 모든 지연 로딩을 트랜잭션 안에서 처리
    • : 이 문제를 해결하기 위해 추가적인 Service Layer를 관리/생성해야함. 코드의 구현량 증가

 

출처 : https://1-7171771.tistory.com/150?category=885255