Spring Model/03. 연관관계 처리

05. 연관 관계 관리 - fetch 속성

  • -

이번 시간에는 관계를 나타내는 애너테이선들이 공통적으로 가지는 fetch 속성을 살펴보자.

 

관계에 따른 로딩 시점 결정

 

관계 설정과 fetch 속성

fetch는 DB에서 연관관계 엔티티 실제로 조회해서 가져오는 것 즉 select 시점을 결정하는 속성으로 FetchType이라는 enum 타입으로 선언되어있다.

fetch 속성 내용 기본 적용
FetchType.EAGER
(열렬한)
즉시 로딩
 - 엔티티 조회 시 join을 이용해 연관 관계 엔티티까지 한번에 조회, 영속화
 - 연관된 엔티티가 하나인 경우(가져올게 적다)
 - @ManyToOne, @OneToOne
FetchType.LAZY
(게으른)
지연 로딩
 - 엔티티 조회 시 대상 엔티티만 조회,  연관 관계 엔티티는 조회하지 않음
 - 연관 관계 엔티티는 필요한(사용하는) 시점에 추가로 검색
 - 연관된 엔티티가 여럿인 경우(가져올게 많다)
 - @OneToMany, @ManyToMany
 - @ElementCollection

 

서로의 입장 차이!

 

동작 확인

현재 Post, Reply, Attachment, Member간에 설정된 관계에 의한 기본 fetch 타입은 다음과 같다.

엔티티 간의 관계와 기본 Fetch 형태

위 객체 그래프에 참조해서 다음의 테스트가 진행할 때 언제 조회가 일어날지 생각해보자.

@Autowired
PostRepository prepo;

@Autowired
MemberRepository mrepo;

@Autowired
AttachmentRepository arepo;

@Test
public void getEagerTest(){
  // select 1: post와 eager 관계인 attachment, member 조회
  Post post = prepo.findById(1L).get();
  Assertions.assertEquals(post.getContent(), "content_001");
  Assertions.assertEquals(post.getAttachment().getFile(), "test_file.png");
  // select 2: member는 이미 영속 상태이기 때문에 별도로 조회되지 않는다.
  Member writer = mrepo.findById(post.getWriter().getMno()).get();
  Assertions.assertEquals(writer.getEmail().getEmailId(), "hong");
}
  1. 특정 pno에 해당하는 Post를 조회한다. -> 이때 fetch 속성이 EAGER인 Member와 Attachment까지 조회한다.
    • Attachment의 모든 내용을 사용할 수 있다.
    • Member에서는 email 등은 바로 사용가능하며 replies, nicknames은 아직 조회가 안된 상태이다.
  2. Post와 연관된 Member를 조회해본다. 하지만 이미 대상 Member는 영속화 되어있기 때문에 추가적인 조회는 일어나지 않는다.
더보기
Hibernate: 
    select
        p1_0.pno,  p1_0.content,  p1_0.create_time,  p1_0.title,  p1_0.update_time,
        
        a1_0.ano,  a1_0.create_time,  a1_0.file,  a1_0.update_time,       
        
        w1_0.mno,        w1_0.create_time,    w1_0.email_domain,  w1_0.email_id,
        w1_0.gender,     w1_0.id,             w1_0.name,  w1_0.pass,  
        w1_1.paid_date,  w1_1.payment_method, w1_0.update_time
        case 
            when w1_1.mno is not null 
                then 1 
            when w1_0.mno is not null 
                then 0 
        end,                    // writer에서 조회되지 않은 것은?
        
    from        post p1_0    
    left join    attachment a1_0    on p1_0.pno=a1_0.pno 
    left join   (member w1_0 left join    paid_member w1_1   on w1_0.mno=w1_1.mno) 
        on w1_0.mno=p1_0.writer 
    where       p1_0.pno=?

 

다음은 LAZY한 특성에 대해서 조회해보자.

@Test
public void getLazyTest() {
  // select 1: member를 조회해보지만 post, reply, nickname에 관한 정보는 조회하지 않는다.
  Member writer = mrepo.findById(1L).get();
  List<Post> posts = writer.getPosts();
  List<String> nicknames = writer.getNicknames();
  log.debug("여기까지 실행된 쿼리는??");
  // select 2: writer의 nickname을 사용하면 비로서 조회가 이뤄진다.
  Assertions.assertIterableEquals(nicknames, List.of("율도국왕", "홍서방"));
  // select 3: post나 reply도 마찬가지로 사용할 때 그때 조회된다.
  Assertions.assertEquals(posts.size(), 1);
}
  1. 특정 ID의 Member를 조회한다.
    • fetch 속성이 LAZY한 연관 엔티티인 post와 reply 그리고 @ElementCollection인 nickname을 조회하지 않는다.
  2. writer의 nickname을 사용하는 순간 nickname을 조회한다.
  3. post의 크기를 출력하기 위해서 post entity를 조회한다. 이때 EAGER한 attachment를 조회한다.
    • post의 writer도 EAGER이지만 이미 영속 상태이므로 조회하지 않는다.
더보기
Hibernate: 
    select      . . .                  // nickname은 제외
    from        member m1_0 
    left join   paid_member m1_1 on m1_0.mno=m1_1.mno 
    where       m1_0.mno=?

23:04:37 [DEBUG] [c.d.b.r.FetchTest.getLazyTest.45] 여기까지 실행된 쿼리는??

Hibernate: 
    select      n1_0.member_mno, n1_0.nicknames 
    from        member_nicknames n1_0 
    where       n1_0.member_mno=?

Hibernate: 
    select      ...
    from        post p1_0 
    left join   attachment a1_0 on p1_0.pno=a1_0.pno 
    where       p1_0.writer=?

 

적용 원칙은? Case By Case

LAZY를 사용하면 연관관계 엔티티의 데이터가 필요할 때까지 기다렸다가 조회한다. 따라서 개별 쿼리는 가볍지만 상황에 따라 여러번 조회 하게 된다. 이는 네트워크 비용의 증가를 초래해서 성능상 문제가 될 수 있다. 반면 EAGER를 적용하면 join을 이용해서 한번에 연관 엔티티들을 조회한다. 따라서 추가적인 네트워크 비용은 발생하지 않지만 개별 쿼리가 무거워진다.

따라서 주로 함께 사용되는 데이터는 EAGER로 한방에 가져오고, 따로 사용되는 데이터는 LAZY하게 처리하는 것이 바람직하다.

그럼 @OneToMany나 @ManyToOne에 기본적으로 설정된 fetch 타입은 타당할까?

  • 사용자 정보 조회: Member에 대한 정보가 조회되는데 이 와중에 Member가 작성한 게시글이나 댓글을 목록은 불필요하다. 다행히 Post와 Reply는 LAZY하게 조회되기 때문에 바로 조회되지는 않고 사용할 때 다시 조회된다.  하지만 그 회원의 활동 지수를 함께 표현하고 싶다면 EAGER로 처리해할 수도 있다.
  • 게시글 조회: 게시글을 조회하는 경우 대부분 하단에 댓글들도 같이 조회된다. Post에서 Reply는 LAZY하기 때문에 그냥 뒀다가는 select를 두번 해야 하는 상황이 발생한다. 이때는 Post에서 Reply에 대한 관계 설정에서 LAZY 대신 EAGER로 설정하는 것이 좋을 수도 있다. 다만 링크를 통해 댓글을 조회하는 방식이라면 LAZY로 그냥 두는 것이 좋을 수도 하다.
프로필에 작성한 글 정보는 필요 없다.(Member:Post = 1:N) 본문과 함께 댓글을 확인한다.(Post:Reply = 1:N)

이처럼 FetchType에 대한 판단은 절대적일 수 없다. 관계가 가지는 기본 FetchType과 정 반대일 가능성도 충분한다. 기본 FetchType은 일반적으로 그런 경우가 많다는 것이지 꼭 그대로 써야 하는 것은 아니다. 프로젝트가 처한 상황 즉 비지니스 로직에 따라서 다르다.

엔티티간의 관계를 설정하다 보면 어떤 FetchType을 적용해야 할지 정확히 판단이 서지 않을 경우도 있다. 이럴 때는 일단 한번에 몽땅 조회해 버리는 EAGER 보다는 가볍게 자신만 조회하는 LAZY로 선언해서 써보는 것이 좋다. 그리고 프로젝트를 진행하면서 연관 관계 엔티티가 함께 사용되는 경우가 많이 발생한다면 그때 EAGER로 수정하는 것이 권장된다. 이런 작업이 쿼리를 바꾸는 거라면 손이 많이 가겠지만 겨우 fetch 속성 하나만 바꿔주면 자동으로 쿼리가 변경되니 크게 힘들지도 않다.

그리고 Entity에서 LAZY하게 설정했다고 하더라도 "실제로 어떻게 조회할 것인가?"는 그때 그때의 조회 쿼리에서 세밀하게 조절해서 사용할 수 있으니까 크게 걱정할 필요는 없다. 아직 우리는 조회 쿼리를 작성한 적이 없다.

FetcyType 변경 및 동작 확인

여기서는 모든 FetchType을 일단 LAZY로 변경하고 다시 test를 했을 때 select가 어떻게 변경되는지 살펴보자. 즉 모든 @XXToOne에 fetchType=FetcyType.LAZY로 설정해준다.

// Attachment
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post")
Post post;

// Post
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "writer")
private Member writer;

@OneToOne(mappedBy = "post",fetch = FetchType.LAZY)
private Attachment attachment;
    
// Reply
@ManyToOne(fetch =  FetchType.LAZY)
@JoinColumn(name = "post")
private Post post;   

@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name="writer")
private Member writer;

테스트 결과를 살펴보면 join이 한 건도 발생하지 않고 있고 필요할 때마다 계속 select가 일어나고 있는 것을 알 수 있다. 물론 이상태가 최선이라는 것은 아니다!!

 

지연로딩 대상의 상태

 

Post의 member나 replies는 어떤 상태?

이제까지 EAGER와 LAZY에 대한 이야기를 했는데 생각해보면 Lazy로 관계를 설정한 엔티티의 상태가 사실 좀 이상하다.

예를 들어 Post를 조회했을 때 Post의 writer는 지연로딩이므로 조회하지 않는다. 그럼 post.getWriter() 결과는 null일까? null인 상태라면 nickname을 가져올 때 NullPointerException이 발생해야 정상인데 어떻게 조회가 가능할까? 마찬가지로 replies 역시 지연로딩인데 언제 List가 구성되고 데이터가 조회됐을까?

Post만 조회했는데 Reply와 Member의 상태는?


그 비밀은 역시나 Proxy에 있다.(AOP에서 만났었죠? 구면이네요~)

느닷없이 Proxy가 튀어나왔는데 다음의 테스트를 실행해보면 Proxy의 정체에 대해 명확히 알 수 있다.

@Test
public void proxyTest() {
    Post post = prepo.findById(1L).get();     // 연관 Entity는 모두 LAZY하게 처리
    
    Member writer = post.getWriter();
    Assertions.assertInstanceOf(HibernateProxy.class, writer);
    log.debug("writer.class: {}", writer.getClass().getName());
    
    List<Reply> replies = post.getReplies();
    Assertions.assertInstanceOf(PersistentBag.class, replies);
    log.debug("replies.class: {}", replies.getClass().getName());
}

 

Hibernate: 
    select      ... 
    from        post p1_0 
    where       p1_0.pno=?

Hibernate: #attachment는 왜 조회되고 있을까?
    select      ... 
    from        attachment a1_0 
    where       a1_0.pno=?

[DEBUG] [c.d.b.r.FetchTest.proxyTest.61] writer.class: Member$HibernateProxy$LTl5xxbR
[DEBUG] [c.d.b.r.FetchTest.proxyTest.64] replies.class: PersistentBag

테스트는 잘 통과하는 것을 알 수 있다. 실제 연관관계를 구성하는 객체들은 엔티티가 아니라 Proxy이거나 PersistentBag 타입의 객체였다.

그런데 또 신기한 것은 어디서도 Attachment관련 내용을 사용하지 않는데 조회되고 있다는 점이다. 이것에 대한 궁금증도 일단 가슴에 새겨두자.

 

proxy와 collection wrapper


JPA는 지연로딩해야 하는 대상을 다음의 상황에 따라서 처리한다. prepo의 findById(1L)이 호출됐을 때의 동작과 연결해서 살펴보자.

  • 대상 Entity가 이미 영속상태라면 Proxy를 생성하지 않고 실제 Entity를 사용한다.
  • 대상 Entity가 비영속 상태인 경우는 Proxy를 구성해서 Entity를 대체한다. 이 Proxy는 대상 엔티티의 @ID를 갖는다. 관계의 소유자인 Post 테이블만 조회해도 writer의 id는 가져올 수 있으므로 이 값을 이용해서 Proxy를 구성한다.
  • 대상이 Collection인 경우는 Collection Wrapper(PersistentBag<E> 또는 PersistentSet<E>)로 대체된다. 이 Collection은 Owner(관계 비소유자)의 @ID를 가지며 이것을 이용해 연관관계의 조회를 진행한다.

LAZY 상태인 연관관계 엔티티의 구성


Proxy의 동작을 좀 더 살펴보자. 이해를 위해서 post.writer.name()을 호출한다고 생각해보자. post.writer는 Proxy이다.

  1. Proxy는 실제 엔티티에 대한 @ID와 실제 엔티티 레퍼런스를 할당받을 target이라는 필드가 존재한다. 물론 처음에 target은 null인 상태이다.
  2. writer의 name을 조회한다면 실제 엔티티를 활용해야 하는데 아직 없는 상황이므로 가지고 있는 @ID에 해당하는 값을 조회해서 엔티티 생성하고 영속화한다.
  3. 영속화된 엔티티의 레퍼런스는 Proxy의 target 필드에 할당된다.
  4. 정보를 요청받은 Proxy는  target인 엔티티를 활용해서 요청 받은 값을 반환한다.

Proxy의 동작 방식

PersistentBag은 물론 Proxy는 아니지만 Proxy와 유사하게 동작한다고 보면 된다.

 

Attachment는 왜 proxy가 아닐까?

Post가 가지고 있는 Attachment 역시 관계를 맺을 때 LAZY로 설정했기 때문에 Proxy를 구성하는게 맞다. 하지만 처음에 Post를 조회했을 때 Attachment가 즉시 조회되는 것을 볼 수 있다. 즉 처음부터 P.C에서 Attachment를 관리하기 때문에 Proxy를 구성할 필요가 없다. Member나 Attachment 모두 LAZY인데 왜 다를까?

Post 테이블에 Member는 있지만 Attachment가 없다.!

Attachment가 Proxy가 아닌 이유는 Proxy를 구성할 수 없기 때문이다. Proxy는 나중에 진짜 엔티티를 조회하기 위해 @ID에 해당하는 값을 가져야 한다. Post와 Member 사이에서 관계의 주인은 Post이므로, Post에는 Member의 @ID에 해당하는 값이 있어서 이를 이용해 Proxy를 구성할 수 있다. 그러나 Post와 Attachment 사이에서 관계의 주인은 Attachment이다. 이 경우, Post를 조회해서는 Attachment의 @ID에 해당하는 값을 알 수 없기 때문에 Proxy를 생성할 수 없다. 결과적으로 처음부터 엔티티를 구성하게 된다.

따라서 Post와 Attachment처럼 @OneToOne 관계에서 관계 비소유자가 조회할 때는 FetchType을 Eager로 설정하는 것도 고려할 만하다. 사용 여부와 무관하게 언제나 2번의 쿼리가 발생하기 때문이다.

 

findById vs getReferenceById

이전 시간에 살펴봤던 두 메서드의 차이도 fetch 관점에서 다시 한번 살펴보자. 

  findById() getReferenceById()
메서드 반환 타입 실제 Entity P.C에 Entity가 없을 경우는 Entity를 상속한 Proxy
 - 내부의 Target에 실제 Entity 저장

P.C에 이미 Entity가 있을 경우는 실제 Entity 반환
실제 DB 조회 시점 메서드 호출 시점(EAGER) Enttiy의 내용 확인 시(LAZY)
없은 값 조회 시 null 반환 target이 null인 Proxy 반환
 - 내용 확인 시 EntityNotFoundException

 

Entity 관계 정리

 

비지니스로직을 고려한 FetchType 설정

이제 Post가 조회될 때 비지니스 로직을 고려하여 FetchType을 다시 설정해보자.

  • Attachment는 @OneToOne이고 어차피 Proxy가 안만들어지는 상황이므로 EAGER로 하자.
  • 글 작성자의 정보는 언제나 보여지기 때문에 Member에 대한 조회도 EAGER로 하자.
  • Reply도 언제나 같이 표현되므로 EAGER가 적합할 수 있지만 댓글 수가 많아지면 성능의 문제가 생길 수 있으므로 나중에 페이징을 고려해서 LAZY로 하자.

Post에서 Member와 Attachment를 가져오는 방식을 EAGER로 처리해주자!

전술하였듯이 FetchType의 결정은 프로젝트의 상황, 데이터의 양에 따라 성능 이슈를 낳을 수 있기 때문에 신중히 결정해야 한다. 위의 설정은 단지 일반적인 예임을 명심하고 실무에서는 각 프로젝트의 상황에 따라 결정하자.

@Entity
public class Post extends BaseEnttiy {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long pno;

    @ManyToOne(fetch = FetchType.EAGER)                    // 게시글의 작성자는 거의 필요
    @JoinColumn(name="writer")
    @ToString.Exclude
    private Member writer;

    @OneToOne(mappedBy = "post", fetch = FetchType.EAGER)  // 언제나 조회됨
    @ToString.Exclude
    private Attachment attachment;
    . . .
}

 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.