JPA FetchType.LAZY으로 인한 발생했던 문제점

김동훈·2023년 4월 19일
1

reDuck

목록 보기
2/5

보통 Entity들의 연관관계가 ~ToOne 의 관계를 가진다면, fetch타입을 Lazy로 두어 매핑하는 경우가 많습니다. 최근 진행중인 프로젝트에서, post(게시글)를 조회하는 기능을 구현하다가 FetchTyp.LAZY와 관련되어 발생한 문제상황이 있었습니다. 이에 관하여 조금 적어보겠습니다.


마주한 문제상황

  1. User - Post에서 fetch Lazy로 인한 jackson initializer 문제 발생
  2. User - Post에서 fetch Lazy로 인한 n+1 문제 발생

JPA Entity 연관관계

  • FetchType fetch() default EAGER (즉시로딩)
    - 다대일(M:1)관계 : @ManyToOne
    - 일대일(1:1)관계 : @OneToOne
  • FetchType fetch() default LAZY (지연로딩)
    - 일대다(1:1)관계 : @OneToMany
    - 다대다(N:M)관계 : @ManyToMany

프로젝트의 Entity 연관관계


Post(게시글) <-> Comment(댓글) 1:N 관계
User(사용자) <- Comment(댓글) 1:N 관계
User(사용자) <- Post(게시글) 1:N 관계

[문제상황 1] User - Post에서 fetch Lazy로 인한 jackson initializer 문제 발생

public class Post extends BaseEntity {
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
    
    private String postTitle;
    
    @Column(columnDefinition = "TEXT")
    private String postContent;

    @Column(unique = true)
    private String postOriginId;

    @OneToMany(mappedBy = "post")
    List<Comment> comments= new ArrayList(); //양방향 매핑 순환참조 문제 발생.

@Transactional
    public List<PostOutDto> getPosts() {
        System.out.println("getPosts() 전체 게시글 조회 메서드 ============");
        List<Post> all = postRepository.findAll();
        List<PostOutDto> dtos = new ArrayList<>();
        for (Post p : all) {
            PostOutDto build = PostOutDto.builder()
                    .postContent(p.getPostContent())
                    .postTitle(p.getPostTitle())
                    .postOriginId(p.getPostOriginId())
                    .user(p.getUser()).build();
            dtos.add(build);
        }

        return dtos;
    }

@GetMapping("/post")
    public ResponseEntity<List<PostOutDto>> getPosts() {
        List<PostOutDto> posts = postService.getPosts();
        System.out.println("컨트롤러단. 프록시 조회 =======================");
        posts.get(0).getUser().getUserId();
        posts.get(1).getUser().getUserId();
        posts.get(2).getUser().getUserId();
        posts.get(3).getUser().getUserId();

        System.out.println("컨트롤러단. responseEntity 내려줄 때  ==========================");
        return new ResponseEntity( posts, HttpStatus.OK);

    }

먼저 FetchType.EAGER로 설정한 경우 생성되는 쿼리에 대해서 확인해보겠습니다.


FetchType.EAGER 설정 한경우, getPosts()메소드에서 user에 대한 조회 쿼리문이 나가는 것을 볼 수 있습니다. EAGER로 하면 jackson initializer문제가 생기지는 않습니다.
하지만 FetchType.EAGER에는 단점이 있습니다.

  • EAGER로 연관관계를 맺은 다른 Entity도 즉시로딩되어 필요하지 않는 데이터도 조회된다.
  • Entity 조회시, N+1문제가 발생한다.

따라서 보통 LAZY로 연관관계를 갖게 합니다. 그럼 EAGER가 아닌 LAZY로 바꾸어 생성되는 쿼리문을 확인해보겠습니다.
N+1문제는 문제상황2 와 관련되어 있기 때문에 뒤쪽에서 언급하도록 하겠습니다.

getPosts() 전체 게시글 조회 메서드 ============
Hibernate: 
    select
        post0_.id as id1_1_,
        post0_.created_at as created_2_1_,
        post0_.updated_at as updated_3_1_,
        post0_.post_content as post_con4_1_,
        post0_.post_origin_id as post_ori5_1_,
        post0_.post_title as post_tit6_1_,
        post0_.user_id as user_id7_1_ 
    from
        post post0_
컨트롤러단. 프록시 조회 =======================
Hibernate: 
    select
        user0_.id as id1_2_0_,
        user0_.created_at as created_2_2_0_,
        user0_.updated_at as updated_3_2_0_,
        user0_.name as name4_2_0_,
        user0_.password as password5_2_0_,
        user0_.user_id as user_id6_2_0_ 
    from
        user user0_ 
    where
        user0_.id=?
Hibernate: 
    select
        user0_.id as id1_2_0_,
        user0_.created_at as created_2_2_0_,
        user0_.updated_at as updated_3_2_0_,
        user0_.name as name4_2_0_,
        user0_.password as password5_2_0_,
        user0_.user_id as user_id6_2_0_ 
    from
        user user0_ 
    where
        user0_.id=?
컨트롤러단. responseEntity 내려줄 때  ==========================

FetchType.LAZY는 엔티티를 프록시 객체를 통해 조회하게 됩니다.
다시 말하면, 위와 같은 관계에서 Post 엔티티를 조회 할 때 Post엔티티에는 User엔티티가 아닌 User엔티티를 상속받은 Proxy오브젝트가 있게됩니다. 이후, 실제로 User엔티티를 사용하게 될 때 다시 select 쿼리문을 날려 User엔티티를 조회하게 됩니다.

그럼 LAZY로 설정하면 어디서 문제가 생기는걸까??

바로 controller단에서 client에 response를 내려주는 시점입니다

위에서 LAZY는 프록시 오브젝트를 통해 user엔티티에 접근하게 된다고 했습니다.
따라서, json 형태로 직렬화를 해야하는데, User는 실제 Object가 아닌 프록시이기 때문에 불가능하다는 것입니다.

그럼 FetchType.LAZY를 유지하며 해결할 수 있는 방법은 무엇이 있을까요?

[문제상황 1]에 대한 해결방법 1 : application.yml 설정

application.yml파일에 jackson.serialization.fail-on-empty-beans = false 속성을 추가해주는 것 입니다. 실제로 false 속성을 추가해준뒤의 쿼리문은 다음과 같습니다.

getPosts() 전체 게시글 조회 메서드 ============
Hibernate: 
    select
        post0_.id as id1_1_,
        post0_.created_at as created_2_1_,
        post0_.updated_at as updated_3_1_,
        post0_.post_content as post_con4_1_,
        post0_.post_origin_id as post_ori5_1_,
        post0_.post_title as post_tit6_1_,
        post0_.user_id as user_id7_1_ 
    from
        post post0_
컨트롤러단. 프록시 조회 =======================
Hibernate: 
    select
        user0_.id as id1_2_0_,
        user0_.created_at as created_2_2_0_,
        user0_.updated_at as updated_3_2_0_,
        user0_.name as name4_2_0_,
        user0_.password as password5_2_0_,
        user0_.user_id as user_id6_2_0_ 
    from
        user user0_ 
    where
        user0_.id=?
Hibernate: 
    select
        user0_.id as id1_2_0_,
        user0_.created_at as created_2_2_0_,
        user0_.updated_at as updated_3_2_0_,
        user0_.name as name4_2_0_,
        user0_.password as password5_2_0_,
        user0_.user_id as user_id6_2_0_ 
    from
        user user0_ 
    where
        user0_.id=?
컨트롤러단. responseEntity 내려줄 때  ==========================

[문제상황 1]에 대한 해결방법 2 : @JsonIgnore 사용

@JsonIgnore은 해당 어노테이션이 달린 값을 json으로 직렬화 시키지 않겠다 라는 의미가 있습니다. 하지만 게시글의 정보들을 조회할 때 작성자의 이름,프로필 이미지 등 user에 대한 정보는 반드시 필요하므로 @JsonIgnore로 처리해서는 안됩니다!!
사실,,JsonIgnore 사용해보았지만, 왜인지는 모르겠지만 해결이 안되었따,,,ㅎㅎ

[문제상황 1]에 대한 해결방법 3 : DTO 활용.

실제로 제 프로젝트에서는 dto를 사용하는 방안으로 해결하였습니다.
Dto 사용을 지향해야 한다는 글도 많이 보았고, 이번 글에서 다루는 문제들을 해결해보며 dto사용의 중요성을 느껴가고 있는데요, DTO를 사용 해야하는 이유 에 대한 것은 참고 글을 보시면 좋을 것 같습니다.

그럼 제가 dto를 사용해서 해결한 방법은 다음처럼 구현해보았습니다.

public class PostOutDto {
    private String postTitle;
    private UserDto userDto;
    private String postContent;
    private String postOriginId;
}

public class UserDto {
    private String password;
    private String userId;
    private String name;
}

    public List<PostOutDto> getPosts() {
        System.out.println("전체 게시글 조회 ============");
        List<Post> all = postRepository.findAll();
        List<PostOutDto> dtos = new ArrayList<>();
        for (Post p : all) {
            UserDto build1 = UserDto.builder()
                    .password(p.getUser().getPassword())
                    .name(p.getUser().getName())
                    .userId(p.getUser().getUserId())
                    .build();
                    
            PostOutDto build = PostOutDto.builder()
                    .postContent(p.getPostContent())
                    .postTitle(p.getPostTitle())
                    .postOriginId(p.getPostOriginId())
                    .userDto(build1).build();
            dtos.add(build);
        }
        return dtos;
    }

PostOutDto에서 User -> UserDto 로 바꿔주었습니다.
그 결과 Controller단의 입장에서는 Lazy하게 조회되던 User정보를 즉시 로딩된 것처럼 보이게 됩니다.
그럼 PostOutDto에는 User정보가 proxy오브젝트가 아닌 UserDto로 존재하기 때문에, Controller단에서 response를 내려줄 때 직렬화가 가능하게 되겠죠??

컨트롤러  ==========================
전체 게시글 조회 ============
Hibernate: 
  select
      post0_.id as id1_1_,
      post0_.created_at as created_2_1_,
      post0_.updated_at as updated_3_1_,
      post0_.post_content as post_con4_1_,
      post0_.post_origin_id as post_ori5_1_,
      post0_.post_title as post_tit6_1_,
      post0_.user_id as user_id7_1_ 
  from
      post post0_
Hibernate: 
  select
      user0_.id as id1_2_0_,
      user0_.created_at as created_2_2_0_,
      user0_.updated_at as updated_3_2_0_,
      user0_.name as name4_2_0_,
      user0_.password as password5_2_0_,
      user0_.user_id as user_id6_2_0_ 
  from
      user user0_ 
  where
      user0_.id=?
Hibernate: 
  select
      user0_.id as id1_2_0_,
      user0_.created_at as created_2_2_0_,
      user0_.updated_at as updated_3_2_0_,
      user0_.name as name4_2_0_,
      user0_.password as password5_2_0_,
      user0_.user_id as user_id6_2_0_ 
  from
      user user0_ 
  where
      user0_.id=?
리턴문  ==========================

LazyInitializer문제는 해결된 것 처럼 보입니다. 하지만 위 쿼리문을 자세히 보시면 N+1 문제가 있음을 볼 수 있습니다.
이것이 바로 문제상황2에서의 FetchType.LAZY를 사용했을 때의 N+1문제입니다!


[문제상황 2] User - Post에서 fetch Lazy로 인한 n+1 문제 발생

N+1문제란?
엔티티를 조회할 때 1개의 쿼리만 나가야하지만 연관되어있는 엔티티들 때문에 추가적으로 N개의 쿼리가 발생하는 문제입니다.

위의 N+1문제는 FetchType.LAZY로 인해 User는 프록시 객체이므로, for문에서 user에 대한 정보를 가져올 때 쿼리가 추가적으로 생성되는 문제입니다.

그럼 프록시객체여서 문제가 되면 EAGER로 하면 되지 않을까??

EAGER는 앞서 말했던 것과 마찬가지로 N+1문제가 발생하게 됩니다. 그럼 실제 EAGER로 설정하고 쿼리문을 확인해 보겠습니다.

컨트롤러  ==========================
전체 게시글 조회 ============
Hibernate: 
  select
      post0_.id as id1_1_,
      post0_.created_at as created_2_1_,
      post0_.updated_at as updated_3_1_,
      post0_.post_content as post_con4_1_,
      post0_.post_origin_id as post_ori5_1_,
      post0_.post_title as post_tit6_1_,
      post0_.user_id as user_id7_1_ 
  from
      post post0_
Hibernate: 
  select
      user0_.id as id1_2_0_,
      user0_.created_at as created_2_2_0_,
      user0_.updated_at as updated_3_2_0_,
      user0_.name as name4_2_0_,
      user0_.password as password5_2_0_,
      user0_.user_id as user_id6_2_0_ 
  from
      user user0_ 
  where
      user0_.id=?
Hibernate: 
  select
      user0_.id as id1_2_0_,
      user0_.created_at as created_2_2_0_,
      user0_.updated_at as updated_3_2_0_,
      user0_.name as name4_2_0_,
      user0_.password as password5_2_0_,
      user0_.user_id as user_id6_2_0_ 
  from
      user user0_ 
  where
      user0_.id=?
for문 user 조회 ===============
리턴문  ==========================

위의 쿼리문을 보시면, post엔티티를 조회할 때 N+1문제가 발생하는 것을 알 수 있습니다.
위 쿼리문은 총 2명의 user가 게시글을 작성하였기 때문에 post조회하는 쿼리문 (1)개에 관련된 user조회하는 쿼리문 (2)개로 총 3(1+N)개의 쿼리문이 발생했습니다.
만약 더 많은 user가 게시글을 작성했다면 그만큼 추가적으로 N개의 쿼리가 발생했을겁니다...

그럼 LAZY를 유지하며 해결하는 방법은 바로

[문제상황 2]에 대한 해결방법 : fetch join사용.

Repository인터페이스에서 fetch join을 사용한 jpql로 조회하는 것 입니다.

  @Query("select p from Post p join fetch p.user")
  @Override
  List<Post> findAll();

Query를 보면 post를 조회하는데, post와 연관관계가 맺어진 user를 fetch join한다는 뜻 이겠죠. join fetch는 지연로딩이 걸려있는 연관관계에 대해서 모두 즉시 로딩해주게 됩니다.

컨트롤러  ==========================
전체 게시글 조회 ============
Hibernate: 
  select
      post0_.id as id1_1_0_,
      user1_.id as id1_2_1_,
      post0_.created_at as created_2_1_0_,
      post0_.updated_at as updated_3_1_0_,
      post0_.post_content as post_con4_1_0_,
      post0_.post_origin_id as post_ori5_1_0_,
      post0_.post_title as post_tit6_1_0_,
      post0_.user_id as user_id7_1_0_,
      user1_.created_at as created_2_2_1_,
      user1_.updated_at as updated_3_2_1_,
      user1_.name as name4_2_1_,
      user1_.password as password5_2_1_,
      user1_.user_id as user_id6_2_1_ 
  from
      post post0_ 
  inner join
      user user1_ 
          on post0_.user_id=user1_.id
for문 user 조회 ===============
리턴문  ==========================

join문을 보시면 inner join으로 post와 user의 관계를 확인하고 한번에 조회하고 있습니다. 따라서 post조회하는 쿼리인 (1)개만 발생하게 됩니다.

결론

  • EGAER => LAZY 사용을 하자!
  • Entity의 직접적인 사용보다는 DTO를 사용하자!

사실 위 Entity관계에서 마주한 문제상황이 1개 더 있었습니다. 게시글을 조회하는 과정에서, post-comment간 양방향 순환참조를 하게 되는 문제입니다. 이 문제또한 DTO를 사용하면 해결되는 문제인데, 이는 다음 글에서 한번 작성해보려고 합니다!

감사합니다!

profile
董訓은 영어로 mentor

0개의 댓글