그 외 공부/트러블 슈팅

JPA 엔티티 조회 시 수정 내용이 바로 반영되지 않는 문제 해결

SeongOnion 2022. 7. 28. 23:25
728x90

문제 상황

엔티티의 속성을 수정, 저장 후 수정사항이 반영되었는지 확인하기 위해 재조회할 때 변경 내용이 반영되지 않은 채로 데이터가 조회되는 상황이 발생했다.

 

보다 구체적을 설명하면, 엔티티를 Soft delete하고 난 후 Repository를 통해 다시 해당 객체를 조회했을 때, 조회가 되지 않도록 @Where 처리를 해놓았으나, 실제론 해당 데이터가 여전히 조회됐다.

// Book 엔티티

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Where(clause = "state != 'DELETED'")
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private Integer price;

    @Enumerated(EnumType.STRING)
    private State state;

    @Builder
    public Book (String title, int price) {
        this.title = title;
        this.price = price;
        this.state = State.ALIVE;
    }

    public void delete() {
        this.state = State.DELETED;
    }
}

Soft Delete 여부는 State라는 Enum 타입의 필드값을 통해 정의하였으며, 엔티티에 Where 어노태이션을 사용하여 SELECT 시 삭제되지 않은 데이터만 가져올 수 있도록 하였다.

 

삭제 작업은 Book 인스턴스의 delete 메서드를 호출해 진행되며, 해당 메서드는 인스턴스의 state 필드값을 삭제 상태로 변경해준다.

테스트 코드는 아래와 같이 매우 심플했다.

@Test
@DisplayName("Soft Delete 이후 데이터가 조회되지 않는다.")
void selectNullAfterSoftDeleted() {
    // given
    Book book = Book.builder().price(1000).title("삭제할 책").build();
    Book savedBook = bookRepository.save(book);

    // when
    savedBook.delete();

    // then
    assertThat(bookRepository.findById(savedBook.getId())).isEmpty();
}

Book 인스턴스를 생성해 저장해주고, 해당 인스턴스를 삭제해준 후 Id값을 통해 다시 조회하였을 때 해당 값이 조회되지 않을 것이라고 예상했다.

테스트 결과, 데이터는 Empty하지 않았다.

 

원인 파악

이 문제는 스프링의 엔티티 매니저 및 영속성 컨텍스트에 대한 이해가 부족해 발생한 문제였다.

 

스프링에서 특정 엔티티가 하나의 트랜잭션 내에서 처음 생성되거나 조회될 때, 해당 엔티티는 고유 식별자와 함께(엔티티에서 @Id로 선언된 값) 영속성 컨텍스트에 저장된다.

 

이후의 로직에서 해당 엔티티를 필요로 하는 상황이 발생하면 실제 DB에 다시 접근해 값을 읽어오는 것이 아닌 영속성 컨텍스트의 1차 캐시에서 값을 가져오게 된다.

@Test
void selectMultipleTest() {
    Book book = Book.builder().price(1000).title("책").build();
    Book savedBook = bookRepository.save(book);

    bookRepository.findById(savedBook.getId());
    bookRepository.findById(savedBook.getId());
    bookRepository.findById(savedBook.getId());
    bookRepository.findById(savedBook.getId());
    bookRepository.findById(savedBook.getId());
    bookRepository.findById(savedBook.getId());
    bookRepository.findById(savedBook.getId());
    bookRepository.findById(savedBook.getId());
    bookRepository.findById(savedBook.getId());
    bookRepository.findById(savedBook.getId());

}

예컨대, 다음과 같이 Book 인스턴스를 저장 후, 조회 메서드를 10번 태우더라도 실제 쿼리는 인스턴스 저장 시 발생하는 INSERT 쿼리 한 번만 나갈 뿐, SELECT 쿼리는 단 한 번도 나가지 않는다.

 

즉, 첫 INSERT 시 Book 인스턴스가 영속성 컨텍스트에 저장되고 10번 조회를 하더라도 동일한 Key(Id 값)를 가진 인스턴스가 영속성 컨텍스트에 존재하기 때문에 DB를 찍지 않고 기존 값을 그대로 가져오는 것이다.

 

나의 경우도 selectNullAfterSoftDeleted 테스트 코드의 SQL 로그를 살펴보면 INSERT 쿼리 이외에 UPDATESELECT 쿼리가 날아가지 않는다.

즉, 마지막 라인의 assertThat(bookRepository.findById(savedBook.getId())).isEmpty();에서의 findById는 DB에 직접 접근해 데이터를 조회하지 않고 영속성 컨텍스트 내에서 Book 인스턴스를 가져오게 되는 것이다.

 

그러니 추가적인 SELECT 쿼리가 나가지 않으니 당연히 엔티티에 @Where 로 정의한 조건도 먹히지 않는 것이다.

 

그렇다면 UPDATE 쿼리는 왜 안나가지?

JPA는 엔티티 객체의 수정 사항을 실제 DB에 반영하기 위해 변경 감지(Dirty Checking)를 사용한다.

 

하나의 트랜잭션으로 묶인 작업 내에서 모든 작업을 마무리한 후 Commit하는 시점에 영속성 컨텍스트에 저장된 객체의 상태를 DB와 대조해 변경 사항을 자동으로 업데이트한다.

 

이것이 엔티티 객체를 수정한 후 별도의 save 메서드를 호출해주지 않아도 변경 사항이 제대로 반영되는 이유이다.

 

테스트 코드의 경우, 기본적으로 각 테스트들을 실행한 후 해당 내용을 Commit하는 것이 아니라 모두 Rollback 해버리기 때문에, 업데이트 쿼리가 나가질 않는 것이다.

 

이에 대한 증거로, 테스트의 Rollback 옵션을 false로 두고 테스트를 다시 돌려보면 UPDATE 쿼리가 나가는 것을 확인할 수 있다.

@Test
@Rollback(value = false) // Rollback 옵션을 false로 설정
@DisplayName("Soft Delete 이후 데이터가 조회되지 않는다.")
void selectNullAfterSoftDeleted() {
    // given
    Book book = Book.builder().price(1000).title("삭제할 책").build();
    Book savedBook = bookRepository.save(book);

    // when
    savedBook.delete();

    // then
    assertThat(bookRepository.findById(savedBook.getId())).isEmpty();
}

 

해결

결국엔, 수정사항이 실제 DB에 잘 반영되었는지 확인하기 위해서는 bookRepository가 Book 객체를 조회할 때, 해당 값을 1차 캐시가 아닌 실제 DB에서 읽도록 만들어주어야 한다.

 

이를 위한 해결법은 그냥 1차 캐시를 관리하는 엔티티 매니저를 날려버리는 것이다.

 

TestEntityManager를 주입받아, 작업을 마친 후 clear 해버리면 1차 캐시가 없어지게 되므로, bookRepository가 엔티티 조회 시 추가적인 SELECT 문을 날리게 된다.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class BookRepositoryTest {

    @Autowired
    BookRepository bookRepository;

    @Autowired
    TestEntityManager testEntityManager; // 인텔리제이에서 autowire가 불가하다고 에러가 떠도 무시해도 된다

    @Test
    @DisplayName("Soft Delete 이후 데이터가 조회되지 않는다.")
    void selectNullAfterSoftDeleted() {
        // given
        Book book = Book.builder().price(1000).title("삭제할 책").build();
        Book savedBook = bookRepository.save(book);

        // when
        savedBook.delete();

        // Rollback = false 옵션을 주지 않는 이상 여전히 Commit이 나가지 않기 때문에 직접 flush 해주어야 update 쿼리가 나간다.
        testEntityManager.flush();

        // 엔티티 매니저 클리어
        testEntityManager.clear();

        // then
        assertThat(bookRepository.findById(savedBook.getId())).isEmpty();
    }

우리가 예상한대로 INSERTUPDATESELECT 순으로 쿼리가 날아가는 것을 확인할 수 있다.

 

결론

테스트 코드에서 엔티티 UPDATE 후, DB상에서의 수정사항을 바로 확인하고 싶다면 EntityManager를 날려주자.

 

물론, 테스트 코드가 아닌 실제 소스 코드에서 EntityManager를 직접 주입받아 임의로 clear 해주는 것은 매우 좋지 못하다.

 

위와 같은 해결책은 반드시 테스트 코드에서만 사용할 수 있도록 하고, 실제 코드에선 변경 사항을 DB에 직접 접근해 확인하기보다는 객체의 변경을 확인하여 체크하도록 하자.