🚨 개인 프로젝트 진행 중 오류가 발생했다. 오류자체는 간단해서 바로 해결은 했지만...
의문이 되는 부분이 있어서 이것 저것 테스트해보다가 save()와 flush에 대해 좀 더 이해하게 되었다.
역시나 기본 개념이 제일 중요하다는 걸 다시한번 깨달았다.🤣
아직 김영한님의 DB 2편을 듣지 못했는데, DB와 트랜잭션관련하여 지식이 부족한 것 같으니 빨리 완강해봐야겠다.
우선 상황을 복기해보자면...
TransientPropertyValueException: object references an unsaved transient instance 예외발생
결론부터 말하면 원인자체는 간단했다. 외래키를 넣어야 하는데, 해당 데이터가 없어서 발생한 것이었다.
오류가 발생한 것은 ShopRepositoryTest였고, 새로운 Shop의 생성을 검증하는 테스트를 작성하고 있었다.
관련된 엔티티는 Shop과 Owner로 @ManyToOne 단방향 관계이다.
각 엔티티의 주된 부분은 아래와 같다.
가게사장(owner)은 가게(shop)를 여러개 만들 수 있다라는 가정하에 Shop을 기준으로 @ManyToOne을 지정했다. (양방향관계로 하려다가 비교를 위해, 우선 단방향으로 작업해 본 후 양방향관계로 리팩토링할 생각이다.)
이후 ShopRepositoryTest에 아래처럼 작성하고 테스트를 진행했다. 그리고 캡쳐에는 없지만 createOwner()와 createShop()메소드도 같은 클래스 내에 생성해 놨다.
'아이 이정도 테스트는 이제 껌이지!!!' 라고 생각했지만,,, 실패 😱
당연하게 테스트가 성공할 것이라고 생각했다...하지만...😥 이정도도 실패하다니, JPA와 테스트코드를 좀 더 열심히 공부해야겠다..
아무튼 테스트를 실행하니까 findAll() 부분에서
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : my.reservetable.shop.domain.Shop.owner -> my.reservetable.owner.domain.Owner
위와 같이 TransientPropertyValueException 예외가 발생했다.
🐛TransientPropertyValueException 는 언제 발생하는 걸까?
먼저 이 예외에 대해서 알아보면 TransientPropertyValueException은 연관된 엔티티 중 하나가 영속 상태가 아닐 때 발생하는 예외라고 한다.
보통 한 엔티티(A)가 다른 엔티티(B)의 외래키를 가지고 있는 경우, 엔티티(A)를 데이터베이스에 저장하려고 할 때, 외래키에 대응하는 엔티티가 아직 데이터베이스에 저장되지 않은 상태에 발생한다.
즉 내 테스트의 경우, shop을 데이터베이스에 저장하려고 하는데, owner가 아직 저장되지 않아서 발생한 것이었다.
Shop엔티티가 Owner 엔티티를 참조하는데, 진짜 멍청하게도 Owner를 save하지 않은 것이다....😑;
그래서 아래처럼 ownerRepository.save(owner);를 추가하여 간단히 해결하였다.
그렇다면... 나는 왜 save를 안 넣어도 되겠지..라는 생각을 했던 걸까?😅
처음 혼돈의 시작은 그놈의 @Transactional 때문이었다.
repository 테스트를 진행하기 전에 엔티티(도메인) 테스트를 먼저 작성했었다. 엔티티 테스트에서는 당연히 save로직 없이 영속성상태의 엔티티의 생성, 변경을 검증했기에 @Transactional이 필요 없다.
그 후 repository 테스트로 넘어와서 @Transactional 을 자동으로 적용하는 @DataJpaTest를 선언하였고, @Transactional이 있으니 영속성 상태 객체가 트랜잭션에 의해 알아서 저장되겠지...라는 생각으로 save 부분을 빼먹었던 것 같다;
그저 owner객체만 생성해 놓고 영속성 상태로 관리될 거야~라고 생각한 거다🤣🤣🤣🤣
📢 영속성상태
🤨첫 번째 의문!! findById로 조회했을 때 왜 ownerId가 조회될 수 있는 거지!?
그리고 ownerRepository.save(owner)를 추가하여 해결하면서또 다른 의문이 생겼다.기존의 owner의 save가 없던 상태에서 테스트를 실행했을 때, 로그에 shop insert문이 발생한 것 때문이었다.
만약 로그의 insert쿼리가 정상적으로 동작한 것이라면..?
라는 가정하에 shop은 저장은 되었고, 트랜잭션이 끝나는 시점에 롤백이 될 테니, 그전에 findById로 해당 shop을 찾으면 어떻게 될까..? 라면서 테스트를 진행해 봤다.
shop의 insert문이 발생할 때, 외래키인 ownerId에 바인딩되는 파라미터는 분명 null인 것을 로그로 확인했다. 저장된 shop의 ownerId는 null일 테니 findShopOwner도 null로 나올 것이라 예상했다.
그러나 조회해 온 shop의 ownerId는 처음 shop을 생성할 때 넣으려던 ownerId값인 test001이 찍히는 것이다. 이게 무슨...?!
🙋♀️그 이유는 영속성과 관련이 있었다!!!
shop 엔티티를 저장하려고 할 때, owner 엔티티는 save호출을 하지 않아서 비영속상태이다. 하지만 owner가 비영속 상태임에도 불구하고, shop의 save가 호출되는 순간!
(createShop()을 통해 owner객체를 포함하고 있는) shop객체가 DB에 저장되기 전에 먼저 영속성 컨텍스트에 저장이 된다.
때문에 1차 캐시에 저장된 shop에는 test001이라는 owner의 정보가 있을 것이고, findById는 실제 데이터베이스가 아닌 1차 캐시에서 조회한 것이기 때문에 owner 정보가 null이 아닌 test001로 찍힌 것이다.
🎯정리하면 owner 자체는 비영속상태가 맞지만, owner정보를 가진 shop이 영속상태이므로, findById로 조회 시 1차캐시의 shop을 조회하기 때문이었다.
📢 Flush과정과 시점
🤨두 번째 의문!! 왜 에러가 shop의 save에서 발생하지 않고 findAll에서 발생한 거지?!
그렇다면 분명 owner는 비영속상태인데, 왜 shop의 save에서 TransientPropertyValueException 예외가 발생하지 않았던 걸까? owner가 save되지 않아서 shop도 save되지 않아야 하는 거 아냐?..그래서 오류 난 거 아니었나...?
이상했다.
분명히 owner가 save되지 않아서 shop이 save될 때 TransientPropertyValueException 예외가 발생한 것이라고 생각했기 때문에, 당연히 shop은 정상적으로 insert가 불가능하여 insert문도 발생하지 말았어야 했다.
그러나 로그에는 insert문이 수행된 것으로 찍혔고, 이후 로직인 findAll()에서 예외가 발생하였다.
(트랜잭션 커밋을.. insert문을 날리면서 하는건가..?라는 의문까지 생기게 되면서 잠깐 멘붕이 왔다.😥)
🙋♀️이유의 답은 전부 flush에 있었다.
나를 헷갈리게 했던 로그에 찍힌 insert문은 DB에 커밋되는 쿼리문이 아닌 JPA의 flush과정에서 발생한 로그였다.
flush과정에서 영속성 컨텍스트의 상태가 DB와 동기화된다. 즉 변경된 엔티티에 대한 sql쿼리들이 데이터베이스에 전송은 되지만, 실제로 커밋은 이루어지지 않는다.
이때, JPA에서 sql문을 데이터베이스에 전송하면서 sql 쿼리가 로그에 찍힌다.
이후 검증과정을 통해 엔티티 간의 관계와 제약조건을 검증하면서 TransientPropertyValueException 예외가 발생하게 되는 것이다.
나는 테스트 클래스에 @DataJpaTest어노테이션으로 인해 @Transactional이 적용되지만, JPA의 save()에도 @Transactional이 있기 때문에, save 자체로도 하나의 트랜잭션 단위라고 생각했던 것 같다. 그래서 save에서 flush, commit까지 한다고 잘못 생각하고 있었다. 이를 계기로 save메소드를 직접 찾아봤는데, persist 또는 merge만을 진행하고 commit은 전혀 하지 않는 것을 알 수 있었다.
정리하면 save()를 호출해도 실제 DB에 저장되는 것이 아니라 persist를 수행한 상태, 즉 SQL문을 생성하고 쓰기지연 저장소에 저장해 놓는 것이다. 그래서 TransientPropertyValueException예외가 shop의 save에서 발생하지 않은 것이다!!!
오호! 그러면 sql문이 찍히는 flush과정은 언제 발생한 것일까? 궁금해졌다.
알고 보니 flush발생 시점과 findAll()에서 에러가 발생하는 이유가 관련이 있었다.
먼저 flush의 발생 시점부터 알아야 한다. flush는 다음과 같은 상황에서 발생한다.
- 트랜잭션의 커밋 : 트랜잭션이 커밋되는 시점에 flush가 자동적으로 발생한다.
- JPQL 쿼리 실행 : 쿼리 결과가 최신 상태를 반영하도록 하기 위해 JPQL 또는 Criteria 쿼리를 실행하기 전에 flush가 발생한다.
- 명시적인 flush 호출 : EntityManager.flush()메소드를 호출하여 flush를 직접 발생시킬 수 있다.
그렇다면 내 테스트코드에서는 언제 flush가 발생한 것인가. 바로 두 번째 케이스로 인해 flush가 발생했다.
예외가 발생했던 findAll() 메소드는 내부적으로 실행될 때, Spring Data JPA가 JPQL이나 SQL 같은 쿼리 언어로 변환되어 데이터베이스에서 데이터를 조회하는 작업을 수행한다고 한다. (그렇다고 findAll 자체가 JPQL쿼리르 사용한다라고 단정 짓기보다는 내부적으로 구현된 Hibernate와 같은 JPA 프로바이더의 작동 방식에 다를 수 있다.)
때문에 findAll()메소드가 실행되면서 flush가 동작하였고, 이때 로그에 insert문이 찍히면서 데이터베이스에 전송이 된 것이다. (앞에서 말했지만 실제 커밋되는 것은 아니다.)
마지막으로 owner의 save가 없는 상태로 findAll()부분을 주석처리해도 flush가 발생하는지 테스트해보았는데 결과는 에러 없이 정상 통과였다. 만약 테스트 코드가 아니었다면,@Transactional에 의해서 발생한 flush로 에러가 났을 것이다. @Transactional은 메소드가 실행되면서 시작된 트랜잭션이 메소드 종료 후 커밋한다. 그렇기에 전체 메소드가 끝나는 시점에 flush가 발생했을 것이다. 하지만 지금은 테스트 코드에서의 @Transactional이기 때문에 테스트 종료 후 커밋이 아닌 롤백을 하므로 flush는 발생하지 않는 것이다.
그리고 findAll을 주석처리한 상태로 findById()를 진행해도 에러가 발생하지 않았는데, 이유는 findById()와 같이 단일 엔티티 조회는 영속성컨텍스트와 데이터베이스를 동기화할 필요가 없기 때문에 flush가 동작하지 않는다고 한다. 수정, 삽입 등의 변경사항은 데이터베이스와 동기화가 필요하지만, 조회는 특정 엔티티가 DB에 존재하는지를 확인하기 위해 단순히 DB에 쿼리를 날려서 결과를 가져오기만 하면 되기 때문이다. 그러나 예외도 존재하는데, JPQL 또는 Criteria를 이용해 직접 쿼리를 작성해서 실행한다면 flush가 발생할 수도 있다고 한다. 이에 반해 findAll은 새로 생성된 엔티티, 변경된 엔티티 등의 변경사항이 있는 경우 쿼리 결과에 영향을 줄 수 있어서 내부적으로 JPQL식 쿼리를 발생시키는 것이다.
🎯정리
- TransientPropertyValueException 예외는 엔티티간의 관계와 제약조건을 검증하면서 발생하는 예외이다.
- save를 하지 않은 owner는 비영속 상태지만, owner객체를 품고 있는 shop이 save되어 1차캐시에 저장되기 때문에 1차캐시를 통해 ownerId를 조회할 수 있었던 것이다.
- flush과정에서 영속성컨텍스트와 DB를 동기화된다. 이때 쓰기지연저장소에 있던 SQL문이 DB로 전달되면서 로그에 쿼리문이 찍히지만, 로그가 찍혔다고 해서 실제로 DB에 커밋된 것은 아니다.
- Spring Data JPA의 save()메소드는 영속성컨텍스트에 저장하는 persist와 더티체크(변경내용감지)의 merge만을 수행한다. 실제 커밋은 진행하지 않는다.
'스프링' 카테고리의 다른 글
[Spring]같은 클래스 내의 다른 메소드에서 @Transactional이 있는 메소드를 호출하면 트랜잭션이 처리 될까? (0) | 2024.03.12 |
---|