[Spring boot] JPA Delete is not Working, 영속성와 연관 관계를 고려했는가.

Byuk_mm·2022년 10월 25일
6

Spring Boot Development

목록 보기
12/13
post-thumbnail

Spring Boot 개발 중 학습이 필요한 내용을 정리하고,
트러블 슈팅 과정을 기록하는 포스팅입니다.




✅ Background

이번 포스팅은 프로젝트에 사용되는 REST API 개발 중, Delete API를 개발하면서 겪은 문제와 이를 해결하는 과정을 담았습니다.

문제 상황은 심플합니다. 기본적인 Create API를 우선 개발한 후, Delete API 개발에 착수 했습니다. Create API를 작성하면서 기본적인 ERD 구조를 설계 했고, JPA Entity 구조에 대한 코드를 짜놨기 때문에 쉽게 쉽게 개발할 줄 알았습니다. 하지만, 모든 개발 과정이 그렇듯 생각만큼 쉽게 쉽게 되는 경우는 매우 드뭅니다,,ㅠㅠ


📌 활용 테이블 구조

ERD는 개발에 사용되는 ERD의 일부입니다. 보시는 것과 같이 테이블끼리 연관 관계가 복잡하게 얽혀있는 것을 확인할 수 있습니다.




✅ Delete API

가장 처음으로 개발에 시작한 Delete API는, 위 ERD에서 가장 위에 위치한 video_space Entity에 대한 Delete API 입니다.
중요한 것은, video_space Entity가 삭제 될 때, One to Many 연관 관계를 이루고 있는 video_space_participant Entity, video Entity 그리고 이와 또 연관 관계가 있는 individual_video Entity까지 삭제돼야 합니다.


📌 영속성 전이(Cascade)

연관된 개념으로 JPA 영속성 전이(Cascade)가 있습니다.
영속성 전이란 특정 Entity 영속성 상태 변경이 일어날 때, 연관된 Entity의 영속성 상태 또한 변경되는 것을 말합니다.

아래의 VideoSpace EntityJava 코드를 통해 확인해보겠습니다.


@Entity
@Table(name = "video_space")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class VideoSpace extends BaseTime {

	// .. 중략

    @OneToMany(mappedBy = "videoSpace", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    private List<VideoSpaceParticipant> videoSpaceParticipants = new ArrayList<>();

    @OneToMany(mappedBy = "videoSpace", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Video> videos = new ArrayList<>();
    
    // .. 중략

영속성 전이 옵션을 cascade 파라미터를 통해 확인할 수 있습니다.
cascade 옵션은 ALL, PERSIST, MERGE, REMOVE, REFRESH, DETACH 등이 있습니다. 앞서 말한 것처럼 저는 Video Space가 삭제될 때, 연관된 모든 연관 관계 Entity들이 전부 삭제되는 것을 원하기 때문에 ALL 옵션을 줬습니다.


📌 Video Space Entity Delete


// videoSpace deleteById
public void delete(Long videoSpaceId) {

    // (1) user get by access token
    User user = userService.getByAccessToken();

    // (2) video space get by id
    VideoSpace videoSpace = videoSpaceFindService.findById(videoSpaceId);

    // (3) 자신이 host인 경우만 삭제 가능, throw new
    if (!videoSpace.getHostEmail().equals(user.getEmail()))
        throw new VideoSpaceHostedAccessRequiredException();

    // (4) space delete
    videoSpaceRepository.deleteById(videoSpaceId);

}

위 코드는 Video Space Entitydelete by videoSpaceId 서비스 계층 코드입니다.
간단하게 주석별로 로직을 설명하자면 아래와 같습니다.

(1) Access Token을 활용해서 로그인된 유저 정보를 select합니다.
(2) videoSpaceId를 활용해서 삭제할 VideoSpace EntityGet합니다.
(3) VideoSpaceHost인 경우에만 삭제 가능하도록 예외 처리합니다.
(4) VideoSpace를 삭제합니다.

위의 코드를 짜고 "와~ 역시 JPA 코드 한줄로 슥삭~" 이라며 JPA에 대한 찬양과 함께 코드를 실행시켰습니다. 테스트용 데이터를 조금만 넣어둔 상태이며, 아래의 log를 통해 발생한 쿼리를 살펴보겠습니다.

Hibernate: select distinct user0_.user_id as user_id1_7_0_, videospace1_.video_space_participant_id as video_sp1_10_1_, user0_.created_at as created_2_7_0_, user0_.update_at as update_a3_7_0_, user0_.email as email4_7_0_, user0_.webex_access_token as webex_ac5_7_0_, user0_.zoom_access_token as zoom_acc6_7_0_, user0_.last_access_individual_video_id as last_acc7_7_0_, user0_.name as name8_7_0_, user0_.picture as picture9_7_0_, user0_.role as role10_7_0_, videospace1_.created_at as created_2_10_1_, videospace1_.update_at as update_a3_10_1_, videospace1_.user_id as user_id4_10_1_, videospace1_.video_space_id as video_sp5_10_1_, videospace1_.user_id as user_id4_10_0__, videospace1_.video_space_participant_id as video_sp1_10_0__ from user user0_ left outer join video_space_participant videospace1_ on user0_.user_id=videospace1_.user_id where user0_.email=?
Hibernate: select videospace0_.video_space_id as video_sp1_9_0_, videospace0_.created_at as created_2_9_0_, videospace0_.update_at as update_a3_9_0_, videospace0_.description as descript4_9_0_, videospace0_.host_email as host_ema5_9_0_, videospace0_.is_individual_video_space as is_indiv6_9_0_, videospace0_.name as name7_9_0_ from video_space videospace0_ where videospace0_.video_space_id in (?, ?, ?)
Hibernate: select videospace0_.video_space_id as video_sp5_10_1_, videospace0_.video_space_participant_id as video_sp1_10_1_, videospace0_.video_space_participant_id as video_sp1_10_0_, videospace0_.created_at as created_2_10_0_, videospace0_.update_at as update_a3_10_0_, videospace0_.user_id as user_id4_10_0_, videospace0_.video_space_id as video_sp5_10_0_ from video_space_participant videospace0_ where videospace0_.video_space_id in (?, ?, ?)
Hibernate: select individual0_.video_space_participant_id as video_sp7_2_1_, individual0_.individual_video_id as individu1_2_1_, individual0_.individual_video_id as individu1_2_0_, individual0_.created_at as created_2_2_0_, individual0_.update_at as update_a3_2_0_, individual0_.last_access_time as last_acc4_2_0_, individual0_.progress_rate as progress5_2_0_, individual0_.video_id as video_id6_2_0_, individual0_.video_space_participant_id as video_sp7_2_0_ from individual_video individual0_ where individual0_.video_space_participant_id in (?, ?, ?, ?)
Hibernate: select videos0_.video_space_id as video_sp8_8_1_, videos0_.video_id as video_id1_8_1_, videos0_.video_id as video_id1_8_0_, videos0_.created_at as created_2_8_0_, videos0_.update_at as update_a3_8_0_, videos0_.description as descript4_8_0_, videos0_.is_uploaded as is_uploa5_8_0_, videos0_.title as title6_8_0_, videos0_.uploader_id as uploader7_8_0_, videos0_.video_space_id as video_sp8_8_0_ from video videos0_ where videos0_.video_space_id in (?, ?, ?)
Hibernate: select individual0_.video_id as video_id6_2_1_, individual0_.individual_video_id as individu1_2_1_, individual0_.individual_video_id as individu1_2_0_, individual0_.created_at as created_2_2_0_, individual0_.update_at as update_a3_2_0_, individual0_.last_access_time as last_acc4_2_0_, individual0_.progress_rate as progress5_2_0_, individual0_.video_id as video_id6_2_0_, individual0_.video_space_participant_id as video_sp7_2_0_ from individual_video individual0_ where individual0_.video_id in (?, ?, ?)

delete 쿼리가 쫘르륵 발생하는 것을 기대했지만, 발생한건 오직 select 쿼리 뿐이었습니다.
이때부터 해당 문제를 해결하기 위해서 이것저것 많은 시행착오를 거쳤습니다. 4개의 테이블이 서로 연관관계에 얽혀있고, JPA 단에서는 양방향 연관관계 설정을 해놨기 때문에 무엇이 문제인지 조차 발견하기까지 많이 많이 돌아서 온 것 같습니다.




✅ Solution

결과적으로 해당 문제는 JPA 영속성 컨텍스트 이해 부족, JPA 연관 관계 매핑과 지연 로딩에 대한 이해 부족, 무지성으로 과거 코드 재활용전반적인 모든 부분에 있어서 JPA 이해도와 활용 경험이 부족한 탓에 발생한 문제 였습니다.

위의 개념들은 JPA를 활용함에 있어서 기본적인 개념이라고 생각하며 분명 학습을 여러번 했어서 이미 이해하고 있었다고 생각했습니다.
때문에 학습 과정에서 연관 관계 매핑, 영속성 컨텍스트 등에 의해서 영속 상태에 있는 Entity들을 양방향으로 관리해줘야하며, 연관 관계 편의 메소드 등을 활용할 수 있음을 개념적으로는 알고 있었습니다.

그러나 실제로 코드를 쳐보면서 부딪쳐본 결과, 제가 제대로 이해하지 못하고 있었고, 하나 하나의 개념들이 엉성하게 연결돼 있었음을 다시 한번 느꼈습니다.

문제의 근본적인 원인은 (1)번 getByAccessToken 메소드에서 발생했었습니다. 해당 메소드는 내부적으로 email을 통해서 User Entityfind합니다. 이때 fetch join을 통해서 One to Many 관계로 연결된 video_space_participant Entityjoin해서 get 합니다. 즉 delete 메소드transaction에서 video_space_participant Entity영속 상태가 됩니다.

Video Space Entity를 삭제하기 위해 delete 메소드를 사용할 때, transaction 끝나는 맨 마지막에 flush를 하면서 자식 Entityvideo_space_participant Entity, video Entity를 삭제하는 쿼리를 날릴 것 입니다. 그런데 이 때, 앞선 과정으로 인해 User Entity에는 video_space_participant Entity가 객체들로 존재하게 됩니다. 즉 계속해서 영속 상태로 존재하게 되기 때문에 삭제 쿼리가 날라가지 않는 것입니다.


📌 삭제 연관 관계 편의 메소드 작성

해당 문제를 해결하기 위해서는 연관 관계를 끊어 주는 것이 핵심입니다.
다음과 같은 삭제 연관 관계 편의 메소드를 만들어서 연관 관계를 끊어줄 수 있습니다.

// VideoSpace 연관 관계 삭제 편의 메소드
public void delete() {

    // ManyToOne, videoSpaecParticipant와 연관 관계 끊기
    for (VideoSpaceParticipant videoSpaceParticipant : videoSpaceParticipants) {

		// user entitiy에서 연관 관계 삭제.
        videoSpaceParticipant.getUser().getVideoSpaceParticipants().remove(videoSpaceParticipant); // test
        videoSpaceParticipant.deleteMapping(); // user = null, videoSpace = null
    }

    // One to Many 연관 관계 끊기.
    videoSpaceParticipants.clear();

    // ManyToOne, video와 연관 관계 끊기
    for (Video video : videos) {
        video.deleteMapping();	// videoSpace = null
    }

    // One to Many 연관 관계 끊기.
    videos.clear(); 
}

이렇게 모든 연관 관계를 끊어주게 되면 연관 관계 매핑 옵션의 nullabe = false에 의해 영속화된 Entity들이 모두 removed 상태가 되며, 삭제 처리가 됩니다.

Hibernate: delete from individual_video where individual_video_id=?
Hibernate: delete from video_space_participant where video_space_participant_id=?
Hibernate: delete from individual_video where individual_video_id=?
Hibernate: delete from video_space_participant where video_space_participant_id=?
Hibernate: delete from video where video_id=?
Hibernate: delete from video_space where video_space_id=?

그렇다면 위와 같이 delete 쿼리가 정상적으로 발생하는 것을 알 수 있습니다. 양방향 연관 관계에서 연결된 Entity마다 객체를 관리해줘야함을 알고 있었음에도, 해당 부분을 신경쓰지 않아서 해결하는데 시간이 오래 걸렸습니다.

이 말은 즉, 개념을 알고 있었더라도 적용시키지 못했으며, 실제로는 제대로 알고 있지 못했다고 생각됩니다.


📌 영속성과 지연 로딩의 관점에서,

그렇다면 반드시 삭제시 연관 관계 편의 메소드를 작성해야 할까요? 제가 직면한 문제에서는 그렇지 않습니다. 지연 로딩에 의해서 Entity를 불러오면 연관된 Entity들은 실제로 참조되기 전까지 프록시 객체로 존재합니다. 즉 애초에 영속화 시키지 않는다는 말입니다.

앞서 말했듯 이 문제의 근본적인 원인은 fetch join으로 인해 삭제 처리해야할 Entity가 영속화된 것이 문제였습니다. 때문에 (1)번 getByAccessToken 메소드fetch join을 사용하지 않고 지연 로딩을 이용한 find 메소드를 사용하여 User Entity만 영속화한다면 해당 삭제 연관 관계 편의 메소드 없이도 삭제 처리가 될 것입니다.

하지만 삭제 비즈니스 로직 상, 연관된 Entity들이 영속화 되는 경우에는 연관 관계를 끊어주는 것이 필요합니다.


📌 더 나아가기 위해..

1. Soft Delete 적용.

DB에서 삭제는 Hard DeleteSoft Delete로 나뉩니다.
Hard Delete는 실제로 데이터를 삭제하는 것이지만,
Soft Delete는 삭제 여부를 나타내는 칼럼을 추가해서 삭제 여부를 표현하는 방식입니다.
해당 내용은 이 포스팅을 참고해주세요!

실제 운영 환경에서는 데이터를 실제로 삭제하기 보다는 Soft Delete를 하는 것이 일반적이라고 합니다. 추후에 삭제된 데이터를 필요로 하는 경우가 있으며, 삭제된 데이터를 복구하는 것은 힘들기 때문에 Soft Delete 적용하는 것이 바람직합니다.

2. Bulk Delete 적용.

실제로 삭제시 삭제할 Entity Select와 각각의 연관 관계 매핑된 Entity들 하나하나마다 Delete 쿼리가 발생하여 수많은 삭제 쿼리가 발생합니다. Bulk Update, Delete를 활용하여 발생하는 쿼리의 수를 줄여야 합니다.




✅ 느낀점

해당 문제가 발생한 원인을 다시 한번 살펴 보면,

1. 무지성으로 양방향 연관 관계를 채택해서 사용했습니다.

양방향 연관 관계의 장점만 보고 사용하다가 큰코 다쳤습니다. 양방향 연관 관계를 활용하면 관리의 어려움이 있음을 직접 체감할 수 있었고, 실제로 Delete하는 과정에서도 n+1 문제가 계속 발생함을 목격했습니다.
성능 뿐만 아니라 유지보수 측면에서도 코드의 복잡성을 높이기 때문에 양방향 연관 관계를 채택할 것인가 아닌가에 대한 기준에 대해 다시 한번 생각해봐야겠습니다.

2. JPA 영속성과 지연 로딩에 관한 정확한 이해가 아직도 부족했습니다.

JPA 강의도 듣고 관련 책도 읽으면서 이해했다고 생각했던 영속성과 지연 로딩에 관한 내용을 머리로만, 개념적으로만 이해했었음을 느꼈습니다. 실제로 코드에 개념을 적절하게 대입할 수 있었으면 해당 문제는 빠르게 해결했을 것입니다.
지연 로딩이 발생하는 시점과 이 시점에서 영속화 된다는 컨셉에 대해 다시 한번 이해하고 받아들일 수 있었습니다. 이를 통해서 앞서서 별 생각 없이 작성했던 Create API 관련 코드들도 성능 개선을 시킬 수 있을 것입니다.

3. 작성해놓은 코드를 무분별하게 재사용했습니다.

저 같은 경우에는, 결국 fetch join을 활용한 코드를 활용하지 않았으면, 애초에 발생하지 않았을 문제였습니다... 하지만 언젠가는 개념의 부재로 인해 터질 문제이긴 했습니다.
코드의 재사용성을 높이는 것은 OOP에서 핵심적인 요소입니다. 하지만 해당 코드의 동작 원리와 과정을 무시하고 inputoutput 만을 고려하면서 재사용하는 것은 정말 위험함을 한번더 느꼈습니다.




✅ 참고

https://velog.io/@kyle/%EB%A7%88%EC%BC%93-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%84%A4%EA%B3%84-1.-%EC%9C%A0%EC%9D%98%EC%A0%90

https://velog.io/@neptunes032/JPA-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8%EB%9E%80

https://parkhyeokjin.github.io/jpa/2019/11/06/JPA-chap8.html

profile
어디야 벽벽 / 블로그 이전 -> byuk.dev

1개의 댓글

comment-user-thumbnail
2023년 4월 26일

도움많이 되었습니다. 감사합니다!

답글 달기