Spring boot + Jpa 매직

카일·2020년 9월 10일
1

Spring

목록 보기
4/4
post-thumbnail

상황

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

  • 게시물과 회원은 N : 1 양방향 관계입니다.(fetch 타입은 Lazy입니다)
  • 한 명의 회원은 여러 게시물을 작성할 수 있습니다.
  • 특정 회원의 정보와, 작성한 게시물을 함께 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이 닫힙니다. (스프링에서 @Transaction 이라는 어노테이션에 대해서, AOP를 적용하여 메소드 시작 전후로 트랜잭션을 닫아줍니다!)

그렇다면 이상한 점은 Controller 계층은 분명 트랜잭션과 영속성 컨텍스트가 닫혀있는 상황입니다. 이런 상황에서 ResponseDto에서 Post 엔티티를 초기화하고 있습니다. 왜 예외가 터지지 않았을까요?

해결과 OSIV

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

OSIV - Open Session In View

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

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

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

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

참고 - 추가로 아래와 같은 내용이 궁금하신 분이 계실 것 같습니다. 관련 링크를 함께 걸어 두었습니다!

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

결론

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

간단하게 정리한 내용이라 부족한 부분이 많습니다. 부족한 부분이나 잘못된 내용이 있다면 댓글로 알려주시면 정말 감사하겠습니다!

0개의 댓글