Notice
Recent Posts
Recent Comments
Link
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

DOing

[팀 프로젝트2] ActionPlan의 연관 관계 이슈 총 정리 본문

항해99

[팀 프로젝트2] ActionPlan의 연관 관계 이슈 총 정리

mangdo 2021. 7. 15. 20:12

이번 ActionPlan 팀프로젝트에서 제가 가장 고민했던 부분은 바로 '연관관계'였습니다.

 

 제가 이전 개인 프로젝트에서 구현한 게시글과 댓글 CRUD는 사실 아무런 연관관계가 있지 않았습니다.

 댓글에 직접 게시글 Id값을 넣고, 게시글 Id값으로 조회를 하였습니다. 이런 설계에 아쉬움을 느끼고 있었고 이번 팀프로젝트에서 연관관계 설정에 대해 팀원들과 고민하며 프로젝트에 적용시켰습니다. 그 과정에서 생겼던 이슈와 수정 과정, 제 생각의 변화에 대해 정리해보려고 합니다.  

 


 

Plan과 Reply 사이의 관계

1차 시도) 양방향

실제 프로젝트 관련 PR 보러가기 : Github PR 링크

 

- Plan Entity

@Entity
public class Plan extends Timestamped {

    @Id @GeneratedValue
    private Long planId;

    @OneToMany(mappedBy = "plan")
    private List<Reply> replyList = new ArrayList<>();
    
	(생략)
}

- Reply Entity

@Entity
public class Reply extends Timestamped {

    @Id @GeneratedValue
    private Long replyId;
    
    @ManyToOne
    @JoinColumn(name="planId")
    private Plan plan;

	(생략)

}

Q. 양방향을 선택한 이유?

: 현재 대부분의 로직에서 Plan을 조회할때 Reply를 같이 조회해야했기 때문입니다.

 물론 단방향 매핑만으로도 이미 연관관계가 매핑되는 것을 알고있습니다. 하지만 외래 키가 있는 있는 곳을 주인으로 정하는 것이 좋기때문에 우선 Reply에 @ManyToOne을 넣고나서, Plan에서 Reply를 조회하고 싶으니 Plan에 @OneToMany를 추가적으로 넣어야겠다는 생각을 하였습니다.

 당시 저는 plan.getReply()로 다루는 것이 객체지향적이다!라고 생각하여 양방향 매핑을 선택하었습니다.

 

Q. 양방향을 하게되면?

1) mapped by로 연관관계의 주인을 설정해야합니다.

  일단 가장 먼저 명심해야할 것은 객체와 DB의 테이블이 관계를 맺는 것에는 차이가 있다는 것입니다. 이에 대한 자세한 내용은 추후에 포스팅을 하려고 합니다. 지금은 결론만 말하면 만약 객체가 양방향 연관관계를 맺고 싶다면, 객체의 두 관계중 하나를 연관관계의 주인으로 지정해야합니다. 연관관계의 주인만이 외래 키를 관리하고 주인이 아닌쪽은 읽기만 가능합니다.  외래 키가 있는 있는 곳을 주인으로 정하는 것이 좋기때문에 Reply를 연관관계의 주인으로 설정하였습니다.

 

2) 양방향매핑시 주의할점 - 무한루프

 게시글을 .toString() 으로 출력하는데 만약 댓글에도 .toString()이 있다면, 무한루프에 빠지게됩니다. 마찬가지로 Json을 반환하는 Controller에서 양방향 관계를 가지고 있는 Entity자체를 반환하게 되면 무한루프에 빠지게 됩니다. 저희 팀프로젝트에서는 각각 ReplyResponseDto, PlanResponseDto를 만들고 PlanResponseDto에 List<ReplyResponseDto>를 넣어 구현하였습니다.

 

2차 시도) 양방향 + EAGER

실제 프로젝트 관련 commit 보러가기 : Github Commit 링크

@Entity
public class Plan extends Timestamped {

    @Id @GeneratedValue
    private Long planId;

    @OneToMany(mappedBy = "plan", fetch = FetchType.EAGER)
    private List<Reply> replyList = new ArrayList<>();
    
	(생략)
}

Q. EAGER를 선택한 이유?

: 현재 대부분의 로직에서 Plan은 항상 Reply를 조회하기 때문에 두번의 쿼리보다는 한 쿼리를 사용하여 조회하는 것이 이득이라고 생각하여 수정하였습니다.

 @OneToMany의 fetch 디폴트 값은 LAZY입니다. 때문에 Plan이 Reply를 초기에 불러오는 것이 아닌 사용할 때 불러오게됩니다. 쿼리로그들을 확인하며 이와 같은 사항을 발견했고 두번의 쿼리로 조회하는 것이 아닌 Join을 사용하여 한쿼리로 조회하고 싶어 EAGER를 선택하게 되었습니다.

 

🐛 예상하지 못한 쿼리 발생

실제 프로젝트 관련 이슈 보러가기 : Github Issue 링크

select
        plan0_.plan_id as plan_id1_0_0_,
        plan0_.created_at as created_2_0_0_,
        plan0_.modified_at as modified3_0_0_,
        plan0_.content as content4_0_0_,
        plan0_.plan_password as plan_pas5_0_0_,
        plan0_.plan_writer as plan_wri6_0_0_,
        plan0_.success as success7_0_0_,
        plan0_.title as title8_0_0_,
        replylist1_.plan_id as plan_id7_1_1_,
        replylist1_.reply_id as reply_id1_1_1_,
        replylist1_.reply_id as reply_id1_1_2_,
        replylist1_.created_at as created_2_1_2_,
        replylist1_.modified_at as modified3_1_2_,
        replylist1_.plan_id as plan_id7_1_2_,
        replylist1_.reply_content as reply_co4_1_2_,
        replylist1_.reply_password as reply_pa5_1_2_,
        replylist1_.reply_writer as reply_wr6_1_2_ 
    from
        plan plan0_ 
    left outer join
        reply replylist1_ 
            on plan0_.plan_id=replylist1_.plan_id 
    where
        plan0_.plan_id=?


insert 
    into
        reply
        (created_at, modified_at, plan_id, reply_content, reply_password, reply_writer, reply_id) 
    values
        (?, ?, ?, ?, ?, ?, ?)

  양방향 매핑에 EAGER를 사용하던 중 예상치 못한 쿼리로그가 찍히는 것을 확인하였습니다. 왜 이런 쿼리가 생기는지 고민해보니 이제껏 저는 대부분의 비지니스 로직에서 Plan을 조회할때 Reply이 함께 필요하다고 판단하여 Plan을 가져올 때 Reply를 Join으로 함께 조회해야한다고 생각했습니다.

 하지만 Plan을 삭제하기 위해서 Plan을 간단히 조회하는 순간까지도 Reply를 조회하는 상황을 발견하게 되었습니다. 이에 FetchType에 대해 추가적으로 공부를 해보고 팀원들과 토의를 통해 다음과 같은 EAGER의 문제점을 알게되었습니다.

  1. 즉시 로딩을 사용하면 예상하지 못한 SQL이 발생한다.
    : 이 부분이 현재 저희 프로젝트에서 생긴 이슈였으며 이 이유로 FetchType.EAGER -> FetchType.LAZY로 변경하였습니다.
  2. 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.

 이와 같은 이유로 실무에서와 같이 테이블이 복잡하게 얽혀있는 경우에는 지연 로딩을 사용해야 한다는 사실도 깨닫게 되었습니다.

 

관련 포스팅 : 2021.07.14 - [spring] - [JPA] Fetch - 즉시 로딩, 지연 로딩


3차 시도) 단방향

실제 프로젝트 관련 PR 보러가기 : Github PR 링크

- Plan Entity

@Entity
public class Plan extends Timestamped {

    @Id @GeneratedValue
    private Long planId;

    // @OneToMany(mappedBy = "plan") :삭제
    // private List<Reply> replyList = new ArrayList<>();
    
	(생략)
}

- Reply Entity

@Entity
public class Reply extends Timestamped {

    @Id @GeneratedValue
    private Long replyId;
    
    @ManyToOne
    @JoinColumn(name="planId")
    private Plan plan;

	(생략)

}

 

Q. 단방향으로 선택한 이유?

: @ManyToOne 단방향만으로도 연관관계가 매핑되었고, 충분히 조회가 가능했기때문입니다.

  처음 양방향을 고려했던 이유는 비즈니스로직상 대부분이 Plan을 조회할때 Reply를 조회한다고 판단했기때문입니다. 하지만 실제 구현을 해보니 그렇지 않다는 것을 깨달았습니다. 또한 양방향으로 설계하는 것이 객체 입장에서는 부담이되고 신경쓸 요소들이 많아진다는 것도 알게되었습니다. 이와 같은 이유들로 팀 회의를 통해 양방향에서 단방향으로 수정하였습니다.

 

4차 시도) 단방향 + LAZY

실제 프로젝트 관련 PR 보러가기 : Github PR 링크

@Entity
public class Reply extends Timestamped {

    @Id @GeneratedValue
    private Long replyId;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="planId")
    private Plan plan;

	(생략)

}

Q. LAZY를 선택한 이유?

: Reply를 조회할때 항상 Plan을 같이 조회해야할 필요가 없었기때문입니다.

FetchType을 EAGER로 해놓았을 때 문제점은 위에서 자세히 설명했기에 생략하겠습니다. 쿼리로그를 분석하다보니 또 예상치 못한 쿼리가 나가는 것을 발견하였고 @ManyToOne의 fetch 디폴트 값인 fetch = FetchType.EAGER로 작동하는 것이 문제였다는 것을 알게되었습니다. 때문에 이를 LAZY로 수정하였습니다.

 


🐛 Reply를 가지고 있는 Plan을 삭제 불가능한 에러발생

실제 프로젝트 관련 이슈 보러가기 : Github Issue 링크

 

에러 상황 설명

Reply를 가지고 있는 Plan은 삭제가 불가능했습니다. 삭제를 시도할때 다음과 같은 에러가 발생하였습니다. 

Cannot delete or update a parent row: a foreign key constraint fails
(ActionPlan.reply, CONSTRAINT FKmpvvomy1h7prxymrjqanyyvbs FOREIGN KEY (plan_id) REFERENCES plan (plan_id))

 

해결 시도 1 - cascade)

@Entity
public class Reply extends Timestamped {

    @Id @GeneratedValue
    private Long replyId;
    
    @manytoone(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
    @joincolumn(name = "planId")
    private Plan plan;
    
	(생략)

}

  DB 관점에서 on delete cascade를 생각하며 시도했지만, 직접 쿼리로그를 확인해보고 더 공부해본 결과 JPA의 casecade는 연관관계와 아무런 관계가 없다는 것을 알게되었습니다.

관련 포스팅 : 2021.07.15 - [spring] - [JPA] 연속성 전이(CASCADE)

  cascade에 대해서대해 다른 팀원들과도 토의해본 결과는 다음과 같습니다.

  1. 연관관계가 하나 + 라이프사이클을 동일하게 만들고 싶을 때 사용하자
  2. 연관관계가 여러개가 얽혀있다면 쓰지말자.
  3. 이해를 못하겠으면 그냥 쓰지말자. 예상치 못한 결과를 일으킬 수 있다.

 

해결 시도 2 - repository를 이용해서 삭제)

실제 프로젝트 관련 PR 보러가기 : Github PR 링크

    @Transactional
    public void deletePlan(Long id, DeleteRequestDto requestDto) {
        Plan plan = planRepository.findById(id).orElseThrow(
                () -> new ApiRequestException("해당 Plan 글이 없습니다. id = " + id));

        // 바말번호가 일치할때만 삭제가능
        if (!plan.getPlanPassword().equals(requestDto.getPassword())) {
            throw new ApiRequestException("비밀번호가 틀렸습니다.");
        }

        // 해당 plan와 연결되어있는 reply 먼저 삭제
        replyRepository.deleteAllByPlan(plan);
        // plan 삭제
        planRepository.deleteById(id);
    }

 

+) 여담이지만, 현업에서는 게시글을 삭제했다고 DB에서 실제로 삭제하는 것이 아닌, 숨김처리를 한다고 합니다. 데이터는 유지하고 사용자에게 보여주지 않는 방식을 사용한다고 합니다. 3년정도 저장해놓는다고 했나... 다른 팀원께서 말씀해주신 내용이라 저도 잘 모르겠....


마무리

  어찌보면 간단할 수 있는 게시글 - 댓글의 CRUD 구현일 수 있습니다. 하지만 이번 프로젝트를 통해서 팀원들 뿐만아니라 다른 여러 팀들과도 토의를 하며 "어떻게 하면 더 좋은 설계를 할 수 있는지?"를 고민해봤습니다. 이 과정에서는 Git의 Pull Request, Issue와 게더를 적극적으로 활용했습니다.

  여러 사람들의 의견을 들으면서 이번 기회에 목표했던 연관관계에 대해 깊이 공부해볼 수 있었습니다. 이 과정에서 단방향vs양방향 / OneToMany vs ManyToOne / 지연로딩 vs 즉시로딩 / cascade에 대해서 공부하고 이를 실제 프로젝트에 적용하였습니다.

 

프로젝트 Github 링크 : https://github.com/ActionPlan23