📕 목차
1. Soft Delete 반영
2. 동일성이 깨진다?
3. 고찰
1. Soft Delete 반영
📌 Annotation
@Entity
@Getter
@Table(name = "user")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicInsert
@SQLRestriction("deleted_at IS NULL")
@SQLDelete(sql = "UPDATE user SET deleted_at = NOW() WHERE id = ?")
public class User extends DateAuditable {
...
@ColumnDefault("NULL")
private LocalDateTime deletedAt;
...
}
- @SQLDelete: delete 쿼리가 호출되어야 하는 시점에 sql에 정의해둔 다른 Query를 실행시킬 수 있다.
- @SQLRestriction: Entity를 search할 때, 해당 조건이 WHERE 절에 추가된다.
- findById()를 사용하면 "SELECT * FROM user WHERE deleted_at IS NULL" 쿼리가 나간다.
여기까진 그저 참 편하게 만들어놨네..정도로 끝났는데, 테스트 케이스를 작성하다가 재밌는 걸 발견하게 됐다.
2. 동일성이 깨진다?
📌 Soft Delete Query는 언제 수행되는가?
@Test
@DisplayName("soft delete는 쿼리는 언제 실행되는가?")
@Transactional
public void softDeleteTest() {
// given
User savedUser = userService.createUser(user);
// when
userService.deleteUser(savedUser);
// then
System.out.println("after delete: savedUser = " + savedUser);
}
당연한 이야기지만 위의 테스트로는 UPDATE 쿼리를 확인할 수 없다.
지연 쓰기로 인해서 softDeleteTest()가 끝나야지 쿼리가 나가는 것을 확인할 수 있다는 것을 직관적으로 알 수 있다.
(flush가 발생해야 한다는 의미)
Delete 쿼리를 Update로 수정했으니, SELETE 절로 해당 Entity를 조회한다 하더라도 1차 캐시에 반영되어 있을 것이 아닌가?
그럼 무슨 수로 저걸 강제로 commit 시키지?
그런데 @SQLDelete에 작성한 sql은 Java Application이 아닌, DB까지 가야 업데이트가 가능할 텐데 1차 캐시에 반영이 될 수 있나?
라는 생각을 하고 일단 SELECT 절을 실행해봤다.
@Test
@DisplayName("soft delete는 쿼리는 언제 실행되는가?")
@Transactional
public void softDeleteTest() {
// given
User savedUser = userService.createUser(user);
System.out.println("before delete: savedUser = " + savedUser);
// when
userService.deleteUser(savedUser);
User deletedUser = userService.readUser(savedUser.getId()).orElse(null);
// then
System.out.println("after delete: deletedUser = " + deletedUser);
}
어이없게도 Update는 실행도 안 되고 있고, Select 쿼리마저 실행되지 않고 있다.
그보다 userService.readUser(userId)의 결과가 null이라는 부분이 도저히 이해가 안 갔었다..대체 왜??
분명히 INSERT 쿼리가 수행되었고, savedUser는 영속화가 되어있을 텐데 탐색에 실패한다는 건 이상한 일이다.
여기서 굉장히 기이한 현상이 발생하고 있다.
@Test
@DisplayName("soft delete는 쿼리는 언제 실행되는가?")
@Transactional
public void softDeleteTest() {
// given
User savedUser = userService.createUser(user);
Long userId = savedUser.getId();
System.out.println("before delete: savedUser = " + savedUser);
// when
userService.deleteUser(savedUser);
User deletedUser = userService.readUser(userId).orElse(null);
System.out.println("is deleted? = " + userService.isExistUser(userId);
// then
System.out.println("after delete: deletedUser = " + deletedUser);
}
그래서 exists 메서드를 실행해보니 갑자기 UPDATE 쿼리가 날아가고, 아까는 반응하지도 않던 SELECT 쿼리가 수행되었다.
당연히 delete_at 필드가 수정되었으니 Entity는 탐색 조건에 의해 null을 반환하게 된다.
📌 동일성
savedUser는 분명히 영속화가 되었던 상태였고, 그렇다면 해당 user를 강제로 갖오면 둘은 당연히 영속성 컨텍스트에 의해 동일성을 충족해야만 한다고 생각했다.
@Test
@DisplayName("[명제] em.createNativeQuery를 사용해도 영속성 컨텍스트에 저장된 엔티티를 조회할 수 있다.")
@Transactional
public void findByEntityMangerUsingNativeQuery() {
// given
User savedUser = userService.createUser(user);
Long userId = savedUser.getId();
// when
Object foundUser = em.createNativeQuery("SELECT * FROM user WHERE id = ?", User.class)
.setParameter(1, userId)
.getSingleResulte();
// then
assertNotNull("foundUser는 nll이 아니어야 한다.", foundUser);
assertEquals("동일성, 동등성 보장에 성공해야 한다.", savedUser, foundUser);
System.out.println("foundUser = " + foundUser);
}
일반적인 방법으로는 @SQLRestriction 설정으로 인해 soft delete 처리된 entity 정보를 조회할 수 없어서 nativeQuery를 사용했다.
nativeQuery를 사용하더라도 영속성 컨텍스트를 사용하므로 1차 캐시의 정보를 가져오는 것을 알 수 있다.
따라서 동일성 보장이 성공한다는 전제가 성립한다.
@Test
@DisplayName("동일성이 보장되는가?")
@Transactional
public void softDeleteTest() {
// given
User savedUser = userService.createUser(user);
Long userId = savedUser.getId();
System.out.println("before delete: savedUser = " + savedUser);
// when
userService.deleteUser(savedUser);
Object deletedUser = em.createNativeQuery("SELECT * FROM user WHERE id = ?", User.class)
.setParameter(1, userId)
.getSingleResulte();
// then
System.out.println("after delete: deletedUser = " + deletedUser);
System.out.println("동일성 검증 : " + (savedUser == deletedUser));
}
그런데 softDelete 처리된 entity를 불러오면 동일성이 깨지는 것을 알 수 있다.
어떻게 pk가 같은 두 entity가 동일성 보장에 실패할 수 있을까 싶지만, savedUser에는 여전히 deletedAt 필드가 갱신되지 않았음을 알 수 있다.
즉, savedUser는 언젠가부터 비영속화된 상태임을 의미한다.
3. 고찰
📌 왜 이런 현상이 발생했을까?
처음에는 'JPA가 이상 현상을 처리하지 못 한 건 아닐까?'라는 생각에 Contributor가 된 내 자신을 상상했지만, 조금 더 고민을 해보니 다른 이유에서 비롯했다고 생각한다.
처음의 가정은 Delete가 아닌 Update문이니까 영속성이 유지될 것이라고 생각했지만, 여기서 발생한 Update는 단순히 수정이 아니다.
삭제의 목적을 갖는 수정이 된다.
영속화된 savedUser를 softDelete 처리하고, readUser()를 했을 때 쿼리는 하나도 나가지 않았음에도 반환값이 null이었던 이유는 무엇이었을까?
나는 그저 deletedAt 필드가 수정되는 것이라 여겼지만, JPA 입장에서는 해당 캐시는 제거되어야 함이 마땅한 것이었다.
아마 이 시점에 savedUser를 특수하게 처리하고 Update 쿼리를 지연쓰기 등록해놓았을 것이라 생각한다.
(바로 비영속화를 시켰다면 SELECT 쿼리가 나갔어야 하는 게 정상이다.)
그런데 어쩐 이유에선지 exists 메서드를 호출하니 직접 DB를 확인해봐야 한다고 판단한 것 같다.
급하게 지연 쓰기에 있던 Update 쿼리가 날아가고, 후다닥 조회를 시작하지만 당연히 조회할 수 없는 데이터가 된다.
(이 시점에는 분명히 savedUser가 비영속화 되어 있었을 것이다.)
그래서 nativeQuery로 가져온 deletedUser가 당당하게 영속성 컨텍스트에 이름을 올렸을 것이고, savedUser는 비영속화 상태이기 때문에 둘의 동일성이 깨졌다고 판단한다.
📌 알아서 어따 쓰는데..
개인적으로 호기심이 발동해서 이것저것 쿼리를 날려보긴 했지만, 일반적으로 삭제 연산을 수행한 entity를 다시 참조할 일은 거의 없다. (이미 떠난 인연을 애써 붙잡으려 하는 꼴)
하지만 admin api를 개발할 때는 이 점을 분명히 알아둘 필요가 있을 것 같다.
soft delete 처리가 된 entity와 그렇지 않은 entity를 모두 가져와서 로직을 수행하다보면, 이 이슈..한 번쯤 다시 보게 되지 않을까? 라는 생각이 든다.