문제 상황
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLDelete(
sql =
"""
UPDATE caregiver
SET name = null,
phone_number = null,
gender = null,
street_address = null,
detail_address = null,
birth_date = null,
profile_image_url = null,
caregiver_certificate_number = null,
social_worker_certificate_number = null,
nursing_care_certificate_number = null,
is_deleted = true,
delete_date = now()
WHERE caregiver_id = ?
""")
@SQLRestriction("is_deleted = false")
public class Caregiver extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "caregiver_id")
private Long id;
private String name;
...
}
이렇게 요양보호사(회원) 엔티티를 정의하였다.
요양보호사가 탈퇴하는 경우, caregiverRepository.delete(caregiver) 로 요양보호사를 지우되, 실제로 나가는 쿼리는 엔티티에 작성한 UPDATE 쿼리가 대신 나가는 방식이다.
문제는 요양보호사에 대한 회원 탈퇴를 진행하면서 발생했다.
@Transactional
public void deleteCaregiver() {
Caregiver loggedInCaregiver = authUtil.getLoggedInCaregiver();
updateChatRoomsAsCaregiverLeft(loggedInCaregiver);
deleteCaregiverData(loggedInCaregiver);
caregiverRepository.delete(loggedInCaregiver);
}
회원 탈퇴로직은 위와 같이 작성했다.
로그인한 요양보호사를 조회한 뒤, 서비스 정책에 따라 채팅방에서는 탈퇴한 요양보호사로 보여주기 위해서 채팅방 상태를 수정하고,
나머지 연관 데이터를 모두 삭제하고 최종적으로 요양보호사는 삭제하는 (soft delete) 방법이다.
그런데 테스트 과정에서 요양보호사 탈퇴시 아래와 같은 에러가 발생하는 문제를 마주하였다.
java.lang.IllegalStateException: org.hibernate.TransientObjectException: persistent instance references an unsaved transient instance of 'com.becareful.becarefulserver.domain.caregiver.domain.Caregiver' (save the transient instance before flushing)
내가 알던 이 에러 메세지는 영속성 컨텍스트에 올리지 않은 객체를 영속성 컨텍스트에 올린 객체처럼 다루고자 할 때 발생하는 에러였다.
entityManager 의 persist 메서드를 사용하지 않고 JPA 스펙을 활용하여 SQL 을 실행하려고 한다거나 등등..
그런데 지금은 새로운 객체를 생성한 것도 아니고, 기존의 객체만을 활용하고 있는 상태인데 이 문제가 발생하는 이유를 알 수 없어서 낯설었다.
만약 연관관계가 문제라면 참조 무결성 에러가 났으면 났지, 영속성 객체 관련된 에러가 발생하니 당황스러웠다.
문제 해결 과정
현재 회원 탈퇴 테스트 코드는 연관된 데이터가 잘 지워지는지 검증하는 로직까지는 작성된 상태였고, 모든 테스트가 통과하고 있었기 때문에 연관 데이터 삭제와 관련된 부분은 문제가 아니라고 생각했다.
아직 테스트를 작성하지 않은 로직은 채팅 관련 로직이었기 때문에, 채팅쪽 로직을 위주로 테스트해보았다.
테스트를 하다보니 채팅이 없는 요양보호사는 탈퇴가 성공적으로 되지만, 채팅이 하나라도 있는 요양보호사는 탈퇴가 되지 않는 것을 확인했다.
채팅과 관련된 엔티티 중 요양보호사와 직접적인 연관관계를 맺고 있는 엔티티는 caregiver chat read status 엔티티였다.
사실 채팅 로직을 내가 쓰지 않았다보니, 처음에는 이 엔티티는 지우면 안되는 줄 알고 남겨두면서 문제를 해결하기 위해 고민했다.
나는 soft delete 구현을 할 때 이렇게 @SQLDelete 어노테이션을 사용해서 구현한다는 정도로만 알고 있었고, 이 어노테이션의 동작 과정은 깊게 이해하고 있지는 않았다.
그래서 막연하게 update 쿼리고 디비에도 데이터가 남아있으니 당연히 영속성 컨텍스트에도 객체가 남아있을 것이라고 여겼다.

그런데 공식문서를 읽어보니 "엔티티를 데이터베이스에서 삭제할 때" 하이버네이트가 생성하는 SQL을 커스텀 SQL 로 대체하는 어노테이션이라고 한다.
사실 soft delete 를 수행하는 입장에서는 해당 row 를 수정하는지, 물리적으로 삭제하는지 알 필요가 없기는 하다.
이건 삭제 정책과 관련된 문제이니, 상위 계층에서는 그냥 삭제하는 명령만 내리고 (어떤 정책인지는 몰라도) 삭제가 되었을 것으로 기대만하면 되기 때문이다.
그런 의미에서 repository 의 delete 를 호출하여 엔티티를 지우고, 실제 나가는 쿼리가 soft delete 용 update 쿼리인 것이 의도에 맞는 쿼리라고 생각해서 이렇게 작성했었는데 이 에러를 보면서 문득 그런 궁금증이 들었다.
엔티티 매니저 입장에서는 엔티티를 삭제하는 요청을 받았으니 영속성 컨텍스트에서는 이 엔티티를 삭제하려고 하지 않을까?
그런데 실제 데이터베이스에는 soft delete 상태로 남아있고, 실제 쿼리도 update 쿼리로 나간다면 영속성 컨텍스트에는 이 엔티티가 어떻게 남아있는 걸까?
내 예상으로는 엔티티 매니저는 해당 엔티티를 removed 상태로 마킹하고, flush 할 때 removed 상태인 엔티티에 대해 SQLDelete 어노테이션으로 명시한 쿼리가 나가면서 영속성 컨텍스트에서 해당 엔티티를 제거하는 것이 예상되었다.
@Transactional
public void deleteCaregiver() {
Caregiver loggedInCaregiver = authUtil.getLoggedInCaregiver();
updateChatRoomsAsCaregiverLeft(loggedInCaregiver);
deleteCaregiverData(loggedInCaregiver);
caregiverRepository.delete(loggedInCaregiver);
}
이제 이 로직에서 채팅 관련 로직을 상세하게 보면
private void updateChatRoomsAsCaregiverLeft(Caregiver caregiver) {
ChatRoomActiveStatusUpdatedChatResponse chatResponse =
ChatRoomActiveStatusUpdatedChatResponse.of(ChatRoomActiveStatus.요양보호사탈퇴);
caregiverChatReadStatusRepository.findAllByCaregiver(caregiver).stream()
.map(CaregiverChatReadStatus::getChatRoom)
.filter(chatRoom -> chatRoom.getChatRoomActiveStatus() == ChatRoomActiveStatus.채팅가능)
.forEach(chatRoom -> {
chatRoom.caregiverLeave();
messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoom.getId(), chatResponse);
});
}
이렇게 로직이 작성되어 있는 상태이다.
caregiver chat read status 를 순회하면서 채팅방의 상태를 탈퇴한 상태로 바꿔준다.
이 과정에서 caregiver chat read status 엔티티가 모두 영속성 컨텍스트에 올라오는데
caregiver 를 삭제하고나서 flush를 하면 엔티티 매니저 입장에서는 영속성 컨텍스트에서 caregiver 는 지워서 더 이상 엔티티 객체가 없는데 caregiver chat read status 는 여전히 caregiver 객체를 가지고 있는 문제가 발생한다.
그래서 TransientObjectException 예외가 발생한 것이다.
내가 생각한 이 문제를 해결하는 방법은 2가지였다.
1. caregiver 를 지우기 전에 영속성 컨텍스트를 비운다. (영속성 컨텍스트에 남아있는 caregiver chat read status 가 문제이므로)
2. caregiver chat read status 를 삭제해도 된다면 지우고나서 caregiver 를 삭제한다.
최종적으로는 2번을 적용하여 해결하였으나, 1번 방법으로도 해결이 되는지 궁금해서 테스트해보았다.
private void deleteCaregiverData(Caregiver caregiver) {
careerRepository.findByCaregiver(caregiver).ifPresent(career -> {
careerDetailRepository.deleteAllByCareer(career);
careerRepository.delete(career);
});
workApplicationRepository.findByCaregiver(caregiver).ifPresent(workApplication -> {
applicationRepository.deleteByWorkApplication(workApplication);
workApplicationRepository.delete(workApplication);
});
completedMatchingRepository.deleteByCaregiver(caregiver);
//caregiverChatReadStatusRepository.deleteAllByCaregiver(caregiver);
em.flush();
em.clear();
}
이렇게 연관관계 데이터를 지우는 대신, 그냥 flush, clear 를 해서 영속성 컨텍스트 내용을 디비에 반영하고 컨텍스트를 비워주었다.

이렇게 해도 테스트가 성공한다.
이번 에러를 해결하면서 SQLDelete 어노테이션의 동작 과정을 구체적으로 상기할 수 있는 기회가 되었다.
'WEB(BE) > Spring & Spring Boot' 카테고리의 다른 글
| [Redis] READONLY You can't write against a read only replica. 에러 해결기 (feat. 중국) (0) | 2025.08.06 |
|---|---|
| JAVA 값 객체의 동등 비교 ('==' 과 equals() 의 차이) (0) | 2025.03.08 |
| [Swagger] Failed to load API definition (403, 500, NoSuchMethodError) (3) | 2025.01.22 |
| [Spring Boot] application.yml 데이터베이스 연결 정보 입력 (0) | 2025.01.06 |
| [Spring Boot] profile 개념과 profile 분리 (0) | 2025.01.04 |