#transaction, #jpa

Open Session In View

상황

이번 포스팅은 Spring boot와 JPA를 활용하여 개인 프로젝트를 개발 중 JPA의 예상치 못한 동작을 발견하게 되어 이를 공유하고자 작성하였다. 읽고 계신 분들도 상황을 보며 어떤 점이 이상한 것인지 예상해 보시고 아래의 답을 보면 좋을 것 같다. 문제 상황은 아래와 같다.

  • 게시물과 회원은 N : 1 양방향 관계.
  • 한 명의 회원은 여러 게시물을 작성 가능.
  • 특정 회원의 정보와, 작성한 게시물을 함께 JSON으로 반환하는 상황.

레이어별 코드.

  • Domain
@Entity
@Getter
public class Member {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "member")
    private List<Post> posts = new ArrayList<>();
}

@Entity
@Getter
public class Post {
    @Id
    @GeneratedValue
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;
}
  • Controller & Service
@RestController
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;

    @GetMapping("/api/members/{id}")
    public ResponseEntity<MemberResponse> getMemberWithPosts(@PathVariable Long id) {
        Member member = memberService.findPostsByMemberId(id);

        return ResponseEntity.ok(MemberResponse.of(member));
    }
}

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

    @Transactional(readOnly = true)
    public Member findPostsByMemberId(Long id) {
        Member findMember = memberRepository.findById(id)
            .orElseThrow(IllegalArgumentException::new);

        return findMember;
    }
}
  • ResponseDto
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class MemberResponse {
    private Long id;
    private String name;
    private List<PostResponse> posts;

    public static MemberResponse of(Member member) {
        return new MemberResponse(
            member.getId(),
            member.getName(),
            PostResponse.of(member.getPosts())
        );
    }
}

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class PostResponse {
    private Long id;
    private String content;

    public static List<PostResponse> of(List<Post> posts) {
        return posts.stream()
            .map(post -> new PostResponse(post.getId(), post.getContent()))
            .collect(Collectors.toList());
    }
}

위의 코드는 회원의 아이디로 회원을 찾는 요청 API인데, 혹시 어떤 점이 이상한지 찾았는가? 사실 위의 코드는 어떤 매직에 의해 동작하는 것이며, 그 매직이 없었다면 동작하지 않아야 할 코드이다.

구체적인 문제

지연 로딩된 객체를 초기화하는 것은 영속성 컨텍스트의 도움을 받아서 이루어진다. 즉 영속성 컨텍스트가 열려 있어야 가능한 작업인 것이다. 하지만 Service 계층의 코드를 보면, Service 계층이 종료되는 시점에 Transaction이 닫힌다.

그렇다면 Controller 계층은 아래와 같은 상황이다.

  • 컨트롤러 계층은 트랜잭션과 영속성 컨텍스트가 닫혀있다.
  • 영속성 컨텍스트가 닫혀있으면 지연 로딩된 객체를 초기화 할 수 없다.
  • 그런데 MemberResponse.of 메서드는 컨트롤러에서 호출이 된다.
  • 함수 내부에서는 getPost를 통해 Post를 초기화한다.

즉, 영속성 컨텍스트가 열려있지 않은 Controller 계층에서 어떻게 객체를 초기화할 수 있을까?

해결과 OSIV

사실 위의 문제는 실제로 트랜잭션과 영속성 컨텍스트가 닫힌 상태이기 때문에 예외가 터지는 것이 맞다. 그런데도 예외가 발생하지 않는 이유는 Spring boot에서 OSIV 라는 설정을 자동으로 true로 설정하기 때문이다.

OSIV - Open Session In View

그렇다면 OSIV는 무엇일까? 단어에서 유추할 수 있듯이 View 단에서 Session(영속성 컨텍스트)을 열거냐 라는 의미이다. OSIV가 OFF 되어 있는 설정에서, Spring에 기본으로 설정된 영속성 컨텍스트의 지속 기간은 Transaction의 범위와 동일하다. 아래의 그림과 같이 트랜잭션이 시작되며 영속성 컨텍스트가 열리고 트랜잭션이 끝나는 시점에 영속성 컨텍스트는 닫히는 것이다. 이 경우 Controller에서는 영속성 컨텍스트가 닫혀 있는 상태이기 때문에, 준영속 상태인 Post가 정상적으로 초기화될 수 없다.

스크린샷 2020-09-11 오전 12 11 08

※ 출처: 자바 ORM 표준 JPA 프로그래밍

하지만 Spring boot가 자동설정으로 OSIV를 true로 변경하여 제공할 때 그림은 아래와 같다. 이 경우에도 트랜잭션은 Service 계층 이후에 사라진다. 하지만 영속성 컨텍스트는 컨트롤러 계층까지 열려있음을 볼 수 있다. 이러한 옵션이 자동으로 적용되어 있기 때문에 Controller에서도 프록시 객체를 초기화 할 수 있었던 것이다.

스크린샷 2020-09-11 오전 12 12 05

※ 출처: https://www.slideshare.net/sungjaepark121/ss-71171382

참고 - 추가로 아래와 같은 내용이 궁금하신 분은 아래의 링크를 참고하면 좋을 것 같다.

  • 영속성 컨텍스트를 통해 데이터베이스를 조회하지 않나? 그렇다면 영속성 컨텍스트가 열려있어도 트랜잭션이 닫혀 있으면 조회가 불가능하지 않을까? -> 여기
  • Session이라는 단어는 Jpa의 구현체인 하이버네이트에서 영속성 컨텍스트를 지칭하는 말이다. 하이버네이트에서 지원하는 기존의 OSIV 방식과, 스프링에서 제공하는 OSIV 방식은 조금 상이한데 링크를 들어가면 차이에 관해서 확인할 수 있다. -> 여기

결론

스프링 부트의 자동 설정은 없어선 안 될 만큼 다양한 설정들을 자동으로 수행한다. 하지만 이러한 자동 설정에 대해서 제대로 이해하지 못하고 사용한다면 버그가 생겼을 때 잡기가 너무 힘들어 진다고 생각한다. 모든 자동 설정을 공부할 수는 없지만, 자신이 알고 있는 로우 레벨의 기술을 스프링이 어떻게 추상화시켰고, 어떤 식의 자동 설정을 지원하는지 등을 알아보는 것은 많은 도움이 될 것 같다.