이번 포스팅에서는 몇 시간의 삽질(?)끝에 문제 원인을 찾았을 때 머리가 띵할 정도로 내가 미웠던,
영속성 컨텍스트 그리고 JPQL 그리고 영속, 비영속에 관한 이야기를 하려고 한다.
나의 문제 해결 과정을 퀴즈처럼 나열하며 포스팅 할 것이기에
"나는 JPA, 영속성 컨텍스트, JPQL에 자신있어"라는 사람이라면 바로 문제를 알아차릴 수도 있다.
하지만, 나는 문제를 해결하며 "아.. 내가 아직 JPA, 영속성 컨텍스트, JPQL를 잘 알고 있는것이 아니구나"라고 느끼게 되었다.
자 그럼 퀴즈 시작해보자.
문제 상황
문제 상황을 설명하려면 일단 코드를 보는 편이 좋을 것 같다.
StudyRoom 삭제 관련 메서드
@Transactional
public void delete(Long studyRoomId, UUID userId) {
StudyRoom studyRoom = studyRoomRepository.findByIdAndUserId(studyRoomId, userId)
.orElseThrow(() -> new GlobalException(ErrorCode.NOT_FOUND, "StudyRoom Not Found"));
deleteStudyRoomLogic(studyRoom, studyRoomId);
}
private void deleteStudyRoomLogic(StudyRoom studyRoom, Long studyRoomId) {
imageRepository.deleteAllByStudyRoomId(studyRoomId);
tagRepository.deleteAllByStudyRoomId(studyRoomId);
optionInfoRepository.deleteAllByStudyRoomId(studyRoomId);
typeInfoRepository.deleteAllByStudyRoomId(studyRoomId);
reserveTypeRepository.deleteAllByStudyRoomId(studyRoomId);
dayOffRepository.deleteAllByStudyRoomId(studyRoomId);
likeRepository.deleteAllByStudyRoomId(studyRoomId);
bookmarkRepository.deleteAllByStudyRoomId(studyRoomId);
List<StudyRoomReview> reviews = reviewRepository.findByStudyRoomId(studyRoomId);
List<Long> reviewIds = reviews.stream()
.map(StudyRoomReview::getId)
.toList();
reviewReplyRepository.deleteAllByStudyRoomReviewIdIn(reviewIds);
reviewImageRepository.deleteAllByStudyRoomReviewIdIn(reviewIds);
reviewRepository.deleteAllByReviewIds(reviewIds);
qnaRepository.deleteAllByStudyRoomId(studyRoomId);
studyRoomRepository.delete(studyRoom);
}
각 Repository `delete` 관련 내부 동작
@Modifying
@Query("delete from StudyRoomImage s where s.studyRoom.id = ?1")
void deleteAllByStudyRoomId(Long studyRoomId);
@Modifying
@Query("update StudyRoomTag s set s.deletedAt = CURRENT_TIMESTAMP where s.studyRoom.id = ?1")
void deleteAllByStudyRoomId(Long studyRoomId);
@Modifying
@Query("delete from StudyRoomReviewImage s where s.studyRoomReview.id in (?1)")
void deleteAllByStudyRoomReviewIdIn(List<Long> studyRoomReviewIds);
@Modifying
@Query("update StudyRoomReview s set s.deletedAt = CURRENT_TIMESTAMP where s.id in (?1)")
void deleteAllByReviewIds(List<Long> reviewIds);
정확히 아래 코드에서
studyRoomRepository.delete(studyRoom);
아래 오류가 발생했다.
org.hibernate.TransientObjectException: persistent instance references an unsaved transient instance of 'com.jj.swm.domain.studyroom.entity.StudyRoom
추가 정보
- 위에서 삭제하는 엔티티는 전부 StudyRoom과의 관계의 주인이다.(일 : 다에서 "다")
- 즉, `StudyRoom`을 외래키로 갖는다.
- 스터디 룸 이용후기 이미지, 이용후기 답글 제외
- 어느 엔티티 관계에서도 `Cascade`가 설정된 곳은 없다.
- 전부 `Lazy Loading`이다.
- 스터디 룸과 양방향으로 되어있는 엔티티는 `StudyRoomTag`와 `StudyRoomOption`이다.
- 불러오진 않는다.
- 스터디 룸 ID에 따른 스터디 룸 이용후기를 조회해 스터디 룸 이용후기 이미지/답글을 먼저 삭제한다.
- `deleteXXX...`를 제외한 모든 메서드는 `Query Method`이다.
잠시 화면을 멈춰놓고 생각해보시면 됩니다.
현재 정보를 토대로 원인을 찾을 수 있는 분은 아래부터는 확인하지 않으셔도 됩니다!
뭐가 문제일까?
양방향 관계가 있긴 하지만, 전부 `Lazy Loading`이므로, 문제가 되지 않는다..
또한, Cascade 옵션을 달아놓은 것도 없다.
그리고 `delete()` 이전에 오류가 터진 것도 아니다.
이것도 저것도 문제가 없어보인다.
근데 왜.. 안될까?
내가 실험해본 것들이 너무 많아서 전부 다 설명할 순 없지만,
문제 해결을 위해 굵직하게 시도해본 것들에 관해 소개하려 한다.
(물론, StudyRoom 삭제 시에도 동일하게 @Modifying을 사용하면 삭제가 된다. 하지만, 나는 명확한 이유가 궁금했다.)
1. @Modifying에 옵션 추가하기
처음에는 나는 일단 StudyRoom의 `delete` API가 작동되길 원했다.
그리고, `StudyRoom`은 단순 조회만 했는데 삭제가 안된다??
십중팔구 중간 단계에서 영속성 컨텍스트에 관한 문제가 있다는 생각이 들었다.
그래서, 모든 `deleteXXX..`에 대해 아래처럼 `clearAutomatically = true` 옵션을 적용했다.
@Modifying(clearAutomatically = true)
@Query("delete from StudyRoomImage s where s.studyRoom.id = ?1")
void deleteAllByStudyRoomId(Long studyRoomId);
- `clearAutomatically` : 쿼리 실행 후 영속성 컨텍스트를 비우는 옵션
즉, 쿼리 실행 후 영속성 컨텍스트를 전부 비워버린다.
결과는 당연하게도(?) 성공..
우리는 이로써 확실한 걸 하나 알게 되었다.
"영속성 컨텍스트와 관련된 문제가 있다는 것"
2. 정확히 원인이 되는 엔티티가 무엇인지 찾기
앞서, 말한 것처럼 현재 작업에서 `StudyRoom`은 단순 조회 후 `delete`로 삭제하는 작업밖에 수행하지 않는다.
그런데도 문제가 생겼다. 이 말은 즉, 조회 -> 삭제 과정 사이의 작업에서 뭔가 잘못된 것이라는 생각이 들었다.
그래서, 코드에서 의심가는 것들을 하나하나씩 계속 주석처리하면서 테스트 해보았다.
그러다가 문제가 되는 부분을 찾아냈다. 그때의 주석 처리된 코드는 아래와 같다.
private void deleteStudyRoomLogic(StudyRoom studyRoom, Long studyRoomId) {
imageRepository.deleteAllByStudyRoomId(studyRoomId);
tagRepository.deleteAllByStudyRoomId(studyRoomId);
optionInfoRepository.deleteAllByStudyRoomId(studyRoomId);
typeInfoRepository.deleteAllByStudyRoomId(studyRoomId);
reserveTypeRepository.deleteAllByStudyRoomId(studyRoomId);
dayOffRepository.deleteAllByStudyRoomId(studyRoomId);
likeRepository.deleteAllByStudyRoomId(studyRoomId);
bookmarkRepository.deleteAllByStudyRoomId(studyRoomId);
// List<StudyRoomReview> reviews = reviewRepository.findByStudyRoomId(studyRoomId);
// List<Long> reviewIds = reviews.stream()
// .map(StudyRoomReview::getId)
// .toList();
// reviewReplyRepository.deleteAllByStudyRoomReviewIdIn(reviewIds);
// reviewImageRepository.deleteAllByStudyRoomReviewIdIn(reviewIds);
// reviewRepository.deleteAllByReviewIds(reviewIds);
qnaRepository.deleteAllByStudyRoomId(studyRoomId);
studyRoomRepository.delete(studyRoom);
}
어..? 이용후기(StudyRoomReview)와 관련된 부분을 주석처리하면 된다..!
이로써 우리는 하나를 더 알아냈다.
"스터디 룸 이용후기 때문에 위 오류가 발생했다."
자, 여기서 잠깐 멈추고 한번 혼자 생각을 해보자.
단서는 아래 2가지 이다.
- 영속성 컨텍스트와 관련된 문제가 있다.
- 문제가 되는 부분은 `StudyRoomReview` 이다.
혼자서 생각해보고 아직 이유를 찾지 못했다면 아래 글을 계속 보길 바랍니다.
문제가 발생했던 이유
해당 단서를 가지고 곰곰이 생각하며 코드를 다시 한 번 읽어보니..
아..
오류의 원인을 깨달았다.
문제의 원인
우리는 아까의 내용을 통해 `StudyRoomReview`가 원인이라는 걸 알아냈다.
현재 코드를 보면 `studyRoomId`를 통해 `StudyRoomReview`의 목록을 읽어온다.
private void deleteStudyRoomLogic(StudyRoom studyRoom, Long studyRoomId) {
imageRepository.deleteAllByStudyRoomId(studyRoomId);
tagRepository.deleteAllByStudyRoomId(studyRoomId);
optionInfoRepository.deleteAllByStudyRoomId(studyRoomId);
typeInfoRepository.deleteAllByStudyRoomId(studyRoomId);
reserveTypeRepository.deleteAllByStudyRoomId(studyRoomId);
dayOffRepository.deleteAllByStudyRoomId(studyRoomId);
likeRepository.deleteAllByStudyRoomId(studyRoomId);
bookmarkRepository.deleteAllByStudyRoomId(studyRoomId);
List<StudyRoomReview> reviews = reviewRepository.findByStudyRoomId(studyRoomId);
List<Long> reviewIds = reviews.stream()
.map(StudyRoomReview::getId)
.toList();
reviewReplyRepository.deleteAllByStudyRoomReviewIdIn(reviewIds);
reviewImageRepository.deleteAllByStudyRoomReviewIdIn(reviewIds);
reviewRepository.deleteAllByReviewIds(reviewIds);
qnaRepository.deleteAllByStudyRoomId(studyRoomId);
studyRoomRepository.delete(studyRoom);
}
아래부터는 퀴즈의 정답이다.
"`findByStudyRoomId` 작업에서 영속성 컨텍스트에서는 어떤 일이 일어날까?"
- 그렇다. 영속성 컨텍스트에 `StudyRoomReview`들을 저장한다.
"그 이후에는?"
- `deleteAllByReviewIds` 메소드를 통해 `StudyRoomReview`들을 삭제한다.(`@Modifying`으로)
"그럼 어떻게 될까?"
- `@Modifying` 또는 `JPQL`로 특정 작업을 수행하면 어떻게 되는가?
- 영속성 컨텍스트를 거치지 않고 바로 `DB`에 접근한다.
- 즉, 위의 상황에서는 트랜잭션 내에서 `StudyRoomReview`들은 삭제된다.
"그럼 영속성 컨텍스트에 남아있는 `StudyRoomReview`들은?"
- 그대로 남아있다.
"어떤 상태로?"
- 아직 `StudyRoom`을 외래 키로 참조하고 있는채로
그럼? 이 상태에서 `StudyRoom`을 `delete()`로 삭제하면?
- JPA 영속성 컨텍스트는 `StudyRoomReview`와 `StudyRoom` 간의 관계를 추적하고 있다.
- 이 상태에서 `StudyRoom` 엔티티를 삭제하려고 하면 JPA는 연관된 엔티티(`StudyRoomReview`등)가 영속성 컨텍스트에 존재하는지 확인한다.
- 그런데, 앞에서 설명했던 것처럼 영속성 컨텍스트에는 `StudyRoomReview`가 남아있다.
- 그래서, JPA는 `StudyRoom` 삭제 전 `StudyRoomReview`를 조회하거나 관계를 검증하려고 시도한다.
- 하지만, `StudyRoomReview`는 이미 삭제되었다.
- `Hibernate`는 이 상태를 처리할 수 없다고 판단해 오류를 발생시키고 롤백한다.
"persistent instance references an unsaved transient instance"
"영속 인스턴스가 비영속 인스턴스를 참조하고 있다."
이것이 퀴즈의 정답이자 사건의 전말이다.
그럼 왜? 아래와 같이 `StudyRoom`을 주체로 한 오류 로그가 발생했는가?
org.hibernate.TransientObjectException: persistent instance references an unsaved transient instance of 'com.jj.swm.domain.studyroom.entity.StudyRoom
`Hibernate`는 엔티티 간 관계를 바탕으로 작업을 수행한다.
그렇기에 이 과정에서 발생하는 오류는 상위 엔티티(`StudyRoom`)의 작업 중 발생한 것으로 간주된다.
즉,
- `StudyRoomReview`는 `StudyRoom`에 의존적이므로, JPA는 `StudyRoom` 엔티티를 삭제할 때,
관련된 하위 엔티티가 적절히 처리되지 않았음을 문제로 간주한다. - 결과적으로, `Hibernate`는 `StudyRoom` 삭제 시점에서 문제가 발생했다고 판단해 메시지에서 `StudyRoom`을 언급하는 것이다.
이해를 위한 관련 사진
마무리하며
지금까지 내가 `StudyWithMe`를 진행하며 겪은(?) 영속성 컨텍스트 관련 트러블 슈팅을 설명했다.
솔직히.. JPA, 영속성 컨텍스트, JPQL에 관해 정확히 알고 있었다면 금방 해결할 수 있는 문제라고 생각이 든다.
여태까지는 `JPQL`로 맘껏 코딩해도 문제가 없는 경우가 많았는데
이번 경험으로 `JPQL`을 사용할 때 조심해야 한다는 것을 깨달았다.
최종 수정 코드는 다음과 같다.
private void deleteStudyRoomLogic(Long studyRoomId) {
imageRepository.deleteAllByStudyRoomId(studyRoomId);
tagRepository.deleteAllByStudyRoomId(studyRoomId);
optionInfoRepository.deleteAllByStudyRoomId(studyRoomId);
typeInfoRepository.deleteAllByStudyRoomId(studyRoomId);
reserveTypeRepository.deleteAllByStudyRoomId(studyRoomId);
dayOffRepository.deleteAllByStudyRoomId(studyRoomId);
likeRepository.deleteAllByStudyRoomId(studyRoomId);
bookmarkRepository.deleteAllByStudyRoomId(studyRoomId);
List<StudyRoomReview> reviews = reviewRepository.findByStudyRoomId(studyRoomId);
List<Long> reviewIds = reviews.stream()
.map(StudyRoomReview::getId)
.toList();
reviewReplyRepository.deleteAllByStudyRoomReviewIdIn(reviewIds);
reviewImageRepository.deleteAllByStudyRoomReviewIdIn(reviewIds);
reviewRepository.deleteAllByReviewIds(reviewIds);
qnaRepository.deleteAllByStudyRoomId(studyRoomId);
studyRoomRepository.deleteByIdWithJpql(studyRoomId);
}
`StudyRoom` 삭제 시에도 `JPQL`로 삭제하므로, 영속성 컨텍스트 정보 불일치 상황을 방지했다.
그리고 이번 트러블 슈팅을 통해 다시 한 번 되새기게 된다.
"JPQL은 영속성 컨텍스트 보다 데이터베이스와 우선적으로 통신한다."
또한, 다시 한 번 다짐하게 된다.
- `@Modifying` 사용 시 코드 작성에 주의하자.
- `JPQL` 사용 시 영속성 컨텍스트를 고려하자.
마지막으로, 주의해야 할 점이 한 가지 존재한다.
JPQL은 영속성 컨텍스트에 도움을 받지 않기 때문에 확실하게 코드를 검토하지 않으면,
나와 같은 "삭제 작업"에서 잘못 활용 시 `고아 객체`가 발생할 수 있다.
'프로젝트 > StudyWithMe' 카테고리의 다른 글
[StudyWithMe] Async Thread Pool과 CompletableFuture (0) | 2025.01.12 |
---|---|
[StudyWithMe] 스터디 윗 미 dev 무중단 배포 (0) | 2024.12.24 |
[StudyWithMe] 프로젝트에서의 쿼리 고민 일기(계속 추가 예정) (0) | 2024.12.10 |
[StudyWithMe] 멀티 스레드에서 트랜잭션 작업 간 정보 불일치 문제 (0) | 2024.12.08 |
[StudyWithMe] 페이징 시 Offset 방식 대신 Cursor 방식 적용(약 111배 성능 향상) (0) | 2024.12.07 |