Spring Boot 개발 중 학습이 필요한 내용을 정리하고,
트러블 슈팅 과정을 기록하는 포스팅입니다.
이번 포스팅은 프로젝트에 사용되는 REST API
개발 중, Delete API
를 개발하면서 겪은 문제와 이를 해결하는 과정을 담았습니다.
문제 상황은 심플합니다. 기본적인 Create API
를 우선 개발한 후, Delete API
개발에 착수 했습니다. Create API
를 작성하면서 기본적인 ERD 구조
를 설계 했고, JPA Entity
구조에 대한 코드를 짜놨기 때문에 쉽게 쉽게 개발할 줄 알았습니다. 하지만, 모든 개발 과정이 그렇듯 생각만큼 쉽게 쉽게 되는 경우는 매우 드뭅니다,,ㅠㅠ
위 ERD
는 개발에 사용되는 ERD
의 일부입니다. 보시는 것과 같이 테이블끼리 연관 관계가 복잡하게 얽혀있는 것을 확인할 수 있습니다.
가장 처음으로 개발에 시작한 Delete API
는, 위 ERD
에서 가장 위에 위치한 video_space Entity
에 대한 Delete API
입니다.
중요한 것은, video_space Entity
가 삭제 될 때, One to Many 연관 관계
를 이루고 있는 video_space_participant Entity
, video Entity
그리고 이와 또 연관 관계가 있는 individual_video Entity
까지 삭제돼야 합니다.
연관된 개념으로 JPA 영속성 전이
(Cascade
)가 있습니다.
영속성 전이
란 특정 Entity
영속성 상태 변경이 일어날 때, 연관된 Entity
의 영속성 상태 또한 변경되는 것을 말합니다.
아래의 VideoSpace Entity
의 Java
코드를 통해 확인해보겠습니다.
@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
옵션을 줬습니다.
// 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 Entity
의 delete by videoSpaceId
서비스 계층 코드입니다.
간단하게 주석별로 로직을 설명하자면 아래와 같습니다.
(1)
Access Token
을 활용해서 로그인된 유저 정보를select
합니다.
(2)videoSpaceId
를 활용해서 삭제할VideoSpace Entity
를Get
합니다.
(3)VideoSpace
의Host
인 경우에만 삭제 가능하도록 예외 처리합니다.
(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
단에서는 양방향 연관관계 설정을 해놨기 때문에 무엇이 문제인지 조차 발견하기까지 많이 많이 돌아서 온 것 같습니다.
결과적으로 해당 문제는 JPA 영속성 컨텍스트 이해 부족
, JPA 연관 관계 매핑과 지연 로딩에 대한 이해 부족
, 무지성으로 과거 코드 재활용
등 전반적인 모든 부분에 있어서 JPA 이해도와 활용 경험이 부족한 탓에 발생한 문제 였습니다.
위의 개념들은 JPA
를 활용함에 있어서 기본적인 개념이라고 생각하며 분명 학습을 여러번 했어서 이미 이해하고 있었다고 생각했습니다.
때문에 학습 과정에서 연관 관계 매핑, 영속성 컨텍스트 등에 의해서 영속 상태에 있는 Entity
들을 양방향으로 관리해줘야하며, 연관 관계 편의 메소드 등을 활용할 수 있음을 개념적으로는 알고 있었습니다.
그러나 실제로 코드를 쳐보면서 부딪쳐본 결과, 제가 제대로 이해하지 못하고 있었고, 하나 하나의 개념들이 엉성하게 연결돼 있었음을 다시 한번 느꼈습니다.
문제의 근본적인 원인은 (1)번 getByAccessToken 메소드
에서 발생했었습니다. 해당 메소드는 내부적으로 email
을 통해서 User Entity
를 find
합니다. 이때 fetch join
을 통해서 One to Many
관계로 연결된 video_space_participant Entity
를 join
해서 get
합니다. 즉 delete 메소드
의 transaction
에서 video_space_participant Entity
는 영속 상태
가 됩니다.
Video Space Entity
를 삭제하기 위해 delete 메소드
를 사용할 때, transaction
끝나는 맨 마지막에 flush
를 하면서 자식 Entity
인 video_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
들이 영속화 되는 경우에는 연관 관계를 끊어주는 것이 필요합니다.
DB
에서 삭제는 Hard Delete
와 Soft Delete
로 나뉩니다.
Hard Delete
는 실제로 데이터를 삭제하는 것이지만,
Soft Delete
는 삭제 여부를 나타내는 칼럼을 추가해서 삭제 여부를 표현하는 방식입니다.
해당 내용은 이 포스팅을 참고해주세요!
실제 운영 환경에서는 데이터를 실제로 삭제하기 보다는 Soft Delete
를 하는 것이 일반적이라고 합니다. 추후에 삭제된 데이터를 필요로 하는 경우가 있으며, 삭제된 데이터를 복구하는 것은 힘들기 때문에 Soft Delete
적용하는 것이 바람직합니다.
실제로 삭제시 삭제할 Entity Select와 각각의 연관 관계 매핑된 Entity들 하나하나마다 Delete 쿼리가 발생하여 수많은 삭제 쿼리가 발생합니다. Bulk Update, Delete를 활용하여 발생하는 쿼리의 수를 줄여야 합니다.
해당 문제가 발생한 원인을 다시 한번 살펴 보면,
양방향 연관 관계의 장점만 보고 사용하다가 큰코 다쳤습니다. 양방향 연관 관계를 활용하면 관리의 어려움이 있음을 직접 체감할 수 있었고, 실제로 Delete
하는 과정에서도 n+1 문제
가 계속 발생함을 목격했습니다.
성능 뿐만 아니라 유지보수 측면에서도 코드의 복잡성을 높이기 때문에 양방향 연관 관계를 채택할 것인가 아닌가에 대한 기준에 대해 다시 한번 생각해봐야겠습니다.
JPA
강의도 듣고 관련 책도 읽으면서 이해했다고 생각했던 영속성과 지연 로딩에 관한 내용을 머리로만, 개념적으로만 이해했었음을 느꼈습니다. 실제로 코드에 개념을 적절하게 대입할 수 있었으면 해당 문제는 빠르게 해결했을 것입니다.
지연 로딩이 발생하는 시점과 이 시점에서 영속화 된다는 컨셉에 대해 다시 한번 이해하고 받아들일 수 있었습니다. 이를 통해서 앞서서 별 생각 없이 작성했던 Create API
관련 코드들도 성능 개선을 시킬 수 있을 것입니다.
저 같은 경우에는, 결국 fetch join
을 활용한 코드를 활용하지 않았으면, 애초에 발생하지 않았을 문제였습니다... 하지만 언젠가는 개념의 부재로 인해 터질 문제이긴 했습니다.
코드의 재사용성을 높이는 것은 OOP
에서 핵심적인 요소입니다. 하지만 해당 코드의 동작 원리와 과정을 무시하고 input
과 output
만을 고려하면서 재사용하는 것은 정말 위험함을 한번더 느꼈습니다.
https://parkhyeokjin.github.io/jpa/2019/11/06/JPA-chap8.html
도움많이 되었습니다. 감사합니다!