개발 회고록

Spring JPA 개발 회고록

쪼멘탈 2022. 11. 30. 16:09
반응형
Spring JPA 개발 회고록

넘블에서 팀 프로젝트를 진행하면서 JPA를 쓰면서 겪었던 일화들(이론과 실전은 다르다는 것을 깨달았다.) 백엔드 첫 협업이기도 하고 Spring JPA를 예전에 써보고 오랜만에 다시 써보면서 User와 Board 엔티티를 만들고 조회를 하면서 겪었던 일이다. JPA를 쓰면 누구나 한 번쯤은 만나보는 순환 참조와 N + 1 문제를 겪어보고 이를 해결하는 내용이다.

 

Spring MVC를 만들고 Service까지 단위 테스트를 진행했는데 여기까지는 문제가 발생하지 않아 개발이 잘 되어가는 줄 알고 진행하던 중 Controller test에서  순환 참조 문제가 발생한다는 것을 알게 되었다.

 

양방향 연관관계에서 User와 Board를 만들고 Board를 작성하면 User안 BoardList안에 추가하는 코드를 작성했는데 이 코드가 Board 조회 시 순환 참조를 만들어 오류가 발생하게 되었다.

 

Board 조회 -> User 조회 ->  Board 조회 -> User 조회...

User Entity

@Entity
@Getter
@Table(name = "tb_user")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    @Column(name = "user_email", nullable = false, length = 20)
    private String email;

    private String password;
	
    @OneToMany(mappedBy = "user", cascade = CascadeType.MERGE, orphanRemoval = true)
    private List<Board> boards = new ArrayList<>();
    ...
}

Board Entity

@Entity
@Getter
@Table(name = "tb_board")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id", callSuper = false)
public class Board extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id")
    private Long id;

    @ManyToOne(cascade = CascadeType.PERSIST, targetEntity = User.class, fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id",updatable = false) // 읽기 전용 insertable = false
    private User user;
    ....
}

해결방법

Dto를 사용하여 필요한 데이터만 선택적으로 노출하도록 변경하여 조회 시 순환참조가 되어 무한루프를 도는 부분을 해결했다. 

 

기본 Entity에서 User객체를 받았던 부분을 User객체가 아닌 User Entity안에 있는 nickname만을 사용했었지만 Dto를 사용하여 Entity의 모든 정보를 보여주는 것은 보안에 문제가 되어 필요한 정보들만을 선택적으로 넘겨주기 때문에 좋은 방법이라고 생각했다. 또 게시글을 작성과 조회에 Dto를 나눠(AddBoardRequest, GetBoardResponse) 사용하여 불필요한 정보를 제거했고 Dto를 사용하여 @valid를 사용하여 요청에 맞는 유효성을 확인할 수 있었다. 추천하진 않지만 추가적인 순환 참조를 방지하는 다른 방법으로는 @JsonManagedReference & @JsonBackReference 어노테이션을 사용하여 양방향을 순환 참조를 해결하는 방법과 @JsonIgnore를 사용하여 양방향 관계를 가지고 있는 두 엔티티 중 하나의 엔티티의 참조 필드에 직렬화를 제외시키는 방법이 있다. 

 

변화 후 GetBoardResponse 코드의 일부

@Getter
@RequiredArgsConstructor
public class GetBoardResponse {

    private String content;

    private List<Image> image;

    private String nickname;
    ...
}

DTO를 사용하여 순환 참조를 막았고 이런 방식을 사용하여 얻는 이점은 아래 블로그 글에서 자세히 나와있어서 참고하기 좋다.

https://tecoble.techcourse.co.kr/post/2020-08-31-dto-vs-entity/

 

요청과 응답으로 엔티티(Entity) 대신 DTO를 사용하자

tecoble.techcourse.co.kr

 

하지만 아직 N + 1문제가 남아있다.

N + 1 문제는 쿼리 요청을 단 한 번 했음에도 불구하고 연관관계 엔티티로 인해 데이터 개수(N) 만큼 추가 쿼리 요청이 발생하는 문제이다.

나의 프로젝트의 경우 게시글 조회(1번) + User 조회(N번) = 총 N+1번의 쿼리 발생 쿼리가 어마 무시하게 나가는 것을 확인할 수 있다.

 

Hibernate: 
    select
        board0_.board_id as board_id1_3_,
        board0_.create_by as create_b2_3_,
        board0_.modify_by as modify_b3_3_,
        board0_.animal_type_id as animal_t7_3_,
        board0_.content as content4_3_,
        board0_.like_count as like_cou5_3_,
        board0_.user_id as user_id8_3_,
        board0_.view_count as view_cou6_3_ 
    from
        tb_board board0_ 
    where
        board0_.board_id=?
Hibernate: 
    select
        user0_.user_id as user_id1_9_0_,
        user0_.create_by as create_b2_9_0_,
        user0_.modify_by as modify_b3_9_0_,
        user0_.deleted as deleted4_9_0_,
        user0_.deleted_date as deleted_5_9_0_,
        user0_.user_email as user_ema6_9_0_,
        user0_.user_nickname as user_nic7_9_0_,
        user0_.password as password8_9_0_,
        user0_.profile as profile9_9_0_,
        user0_.role as role10_9_0_ 
    from
        tb_user user0_ 
    where
        user0_.user_id=?
Hibernate: 
    select
        comments0_.board_id as board_id5_6_0_,
        comments0_.comment_id as comment_1_6_0_,
        comments0_.comment_id as comment_1_6_1_,
        comments0_.create_by as create_b2_6_1_,
        comments0_.modify_by as modify_b3_6_1_,
        comments0_.board_id as board_id5_6_1_,
        comments0_.content as content4_6_1_,
        comments0_.user_id as user_id6_6_1_ 
    from
        tb_comment comments0_ 
    where
        comments0_.board_id=?

아직 완성되지 않은 게시글(이미지, 태크, 등)이 존재하지 않는 상태에서도 1개의 게시글을 조회하는데 상당히 많은 양의 쿼리가 발생하는 것을 볼 수 있다. 

N + 1 문제 해결방법은 @BatchSize, @EntityGraph 등등이 존재한다.

@BatchSize 어노테이션은 @OneToMany 컬럼에 추가로 선언해주어 Size를 보통 1000 이하로 입력하여 선언한 컬럼에 연관된 데이터를 한 번에 같이 불러온다.

application.yml파일 안에 BatchSize를 선언하여 사용할 수 도 있다.

spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 1000

나는 이 문제를 해결하기 위해서 사용한 방법은 @EntityGraph 어노테이션을 사용했다. 기존 Repository에서 사용한 코드에서 

Optional<Board> findById(Long boardId);
@EntityGraph(attributePaths = {"user", "comments"}, type = EntityGraph.EntityGraphType.LOAD)

를 사용하여 user와 comment부분을 join후 하나의 쿼리에 담아서 불러오는 것을 확인할 수 있다.

Hibernate: 
    select
        board0_.board_id as board_id1_2_0_,
        board0_.create_by as create_b2_2_0_,
        board0_.modify_by as modify_b3_2_0_,
        board0_.address_id as address_7_2_0_,
        board0_.animal_id as animal_i8_2_0_,
        board0_.content as content4_2_0_,
        board0_.like_count as like_cou5_2_0_,
        board0_.user_id as user_id9_2_0_,
        board0_.view_count as view_cou6_2_0_,
        comments1_.board_id as board_id5_5_1_,
        comments1_.comment_id as comment_1_5_1_,
        comments1_.comment_id as comment_1_5_2_,
        comments1_.create_by as create_b2_5_2_,
        comments1_.modify_by as modify_b3_5_2_,
        comments1_.board_id as board_id5_5_2_,
        comments1_.content as content4_5_2_,
        comments1_.user_id as user_id6_5_2_,
        user2_.user_id as user_id1_8_3_,
        user2_.create_by as create_b2_8_3_,
        user2_.modify_by as modify_b3_8_3_,
        user2_.deleted as deleted4_8_3_,
        user2_.deleted_date as deleted_5_8_3_,
        user2_.user_email as user_ema6_8_3_,
        user2_.user_nickname as user_nic7_8_3_,
        user2_.password as password8_8_3_,
        user2_.profile as profile9_8_3_,
        user2_.role as role10_8_3_,
        address3_.address_id as address_1_0_4_,
        address3_.create_by as create_b2_0_4_,
        address3_.modify_by as modify_b3_0_4_,
        address3_.address_name as address_4_0_4_,
        address3_.region_depth_1 as region_d5_0_4_,
        address3_.region_depth_2 as region_d6_0_4_,
        address3_.user_id as user_id7_0_4_ 
    from
        tb_board board0_ 
    left outer join
        tb_comment comments1_ 
            on board0_.board_id=comments1_.board_id 
    left outer join
        tb_user user2_ 
            on board0_.user_id=user2_.user_id 
    left outer join
        tb_address address3_ 
            on user2_.user_id=address3_.user_id 
    where
        board0_.board_id=?

하지만 이런 쿼리를 보면서 쿼리의 개수는 3개에서 1개로 줄었지만 너무 많은 조인이 사용된 거 같아 성능이 의심스러웠다. 이 부분은 더 공부하고 실제 부하 테스트를 진행해 봐야겠다.

 

반응형