본문 바로가기

코딩/미완

[JPA Exception] TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing 과 여러 가지 문제들

 

결론적으로 말하면 엔티티를 저장할 때 모든 연관관계의 엔티티는 영속상태여야한다는 원칙을 몰라서 생긴 일이었다.

 

#
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : pnu.problemsolver.myorder.domain.Cake.store -> pnu.problemsolver.myorder.domain.Store; nested exception is java.lang.IllegalStateException: org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : pnu.problemsolver.myorder.domain.Cake.store -> pnu.problemsolver.myorder.domain.Store

 

프로젝트를 진행하던 중 위와 같은 예외가 발생했다.

 

문제가 된 엔티티는 Cake, Store이다. 이를 살펴보자 아래와 같이 Cake는 Store와 연관관계가 있다. Store.cake는 없다.

단방향 관계로 설정했다

@Entity
public class Cake {

    @Id
    @GeneratedValue(generator = "uuid2")
    @GenericGenerator(name = "uuid2", strategy = "uuid2")
    @Column(columnDefinition = "BINARY(16)")
    private UUID uuid;

    @ManyToOne(fetch = FetchType.LAZY)
    private Store store;

#

아래의 테스트 코드 실행중 계속 TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing 예외가 발생했다. cake.store가 아직 저장되지 않았으니 저장하라는 소리임. 하지만 난 @ManyToOne에 optional=false로 지정하지 않았고 FK는 null가능이다. SQL로 FK가 null인 상태로 넣으면 잘 들어감... 왜 자꾸 예외가 발생할까?...

문제는 Entity와 DTO를 변환하는데 있었다. 아래 코드를 보자.

(처음에는 ModelMapper를 사용하다가 reflection을 사용해서 느린 점과 완벽하게 알지 못하는 기술은 사용하지 말자는 철학아래 DTO-Entity변환메소드를 손으로 직접 작성하게 되었다. 어지간히 귀찮은 일이 아님... 다음번 프로젝트에나 플젝이 끝나고 시간이 남으면 어노테이션 기반 MapStruct로 다 리팩토링 할거다..)

아래의 코드를 이해할 필요는 없다. 다만 말하고 싶은 것은 storeUUID를 설정하지 않으면 제목과 같은 예외가 발생한다는 것이다.

@Test
    public void editStoreMenuTest() throws Exception {

        CakeDTO cakeDTO1 = CakeDTO.builder()
                .name("케이크1")
                .minPrice(1000)
//                .storeUUID(savedStoreDTO.getUuid()) //TODO : 왜 store를 설정하지 않으면 예외발생?
                .build();
        System.out.println(cakeDTO1);

        CakeDTO saved1 = cakeService.save(cakeDTO1);

        CakeDTO cakeDTO2 = CakeDTO.builder()
                .name("케이크2")
                .minPrice(2000)
//                .storeUUID(savedStoreDTO.getUuid())
                .build();
        CakeDTO saved2 = cakeService.save(cakeDTO2);

        StoreDTO storeDTO = StoreDTO.builder()
                .name("초기화")
                .description("초기화")
                .build();
        StoreDTO savedStoreDTO = storeService.save(storeDTO);
    }

#

위의 코드에서 cakeService에서 DTO객체를 넣어서 저장했다. CakeService.save()는

1. DTO->Entity로 변환.

2. cakeRepository.save(eneity)를 수행할 뿐이다.


#

아래의 코드가 CakeDTO to Cake이다.(toEntity()는 Cake엔티티의 메소드로 정의했다. 즉 Cake.toEntity()이다.)

보면 store()에서 Store객체를 생성해서 넣어주고 있다. cakeDTO.getStoreUUID()는 null값이다. 즉 cake.store=null이 아니라 cake.store.uuid=null인 상황이 된 것임..

CakeDTO에 Store store가 아닌 UUID storeUUID 필드를 만든 잘못이다.. 김영한님의 ORM책을 읽으면서 알게 된 것인데 객체지향적으로 만들기 위해서는 storeUUID 이런 식이 아니라 store객체를 DTO의 필드로 사용해야했다.. 

그래서 아래 주석으로 되어 있는 store(null)로 진행하니 테스트를 잘 통과했다. 

결국 엔티티의 cake.store==null인 것과 cake.store.getUUID()==null인 것은 하이버네이트에서 다르게 받아들인다. 그리고 하이버네이트에서는 cake.store=null인 상황을 요구한다. 

cake.store.getUUID()=null 이면 Store와 연관관계가 있다고 생각하고 store에서 PK=null를 찾는 것 같다... 그래서 Store를 저장하라는 메시지를 출력한듯..찾을 수 있을리가...null 체크 해주면 좋을텐뎀

public static Cake toEntity(CakeDTO cakeDTO) {
        Cake cake = Cake.builder()
                .uuid(cakeDTO.getUuid())
                .store(Store.builder().uuid(cakeDTO.getStoreUUID()).build())
//                .store(null) //null로 지정하니까 잘 통과함!
                .filePath(cakeDTO.getFilePath())
                .option(cakeDTO.getOption())
                .name(cakeDTO.getName())
                .description(cakeDTO.getDescription())
                .minPrice(cakeDTO.getMinPrice())
                .build();
        
        System.out.println("Cake.toEntity : " + cake);

        return cake;
    }

#
아래와 같이 수정해서 해결할 수 있었다. 진짜 이럴 때 마다 코틀린 쓰고싶다... 근데 이제 자바에 익숙해지려하는데 또 코틀린을...모르겠다.. 자바도 세이프콜(?.) 엘비스(?:) 도입좀... 

public static Cake toEntity(CakeDTO cakeDTO) {
   UUID storeUUID = cakeDTO.getStoreUUID();
   
   Cake cake = Cake.builder()
         .uuid(cakeDTO.getUuid())
         .store(storeUUID == null ? null : Store.builder().uuid(storeUUID).build())//이렇게 해야한다..
         .filePath(cakeDTO.getFilePath())
         .option(cakeDTO.getOption())
         .name(cakeDTO.getName())
         .description(cakeDTO.getDescription())
         .minPrice(cakeDTO.getMinPrice())
         .build();
   
   System.out.println("Cake.toEntity : " + cake);
   
   return cake;
}

이렇게 실수하기 쉬운 부분 정리 끝!

 


만약 @ManyToOne에 optional=false로 null을 허용하지 않는 상황이라면 toEntity()에서 dto.storeUUID==null이면 throw new Exception을 하는게 맞는 것 같다. 이때는 잠깐 테스트 한다고 optional 설정을 안했는데 서비스에서는 모든 FK에 optional=false로 설정할 예정이라서 toEntity()의 코드를 아래와 같이 수정했다.

public static Cake toEntity(CakeDTO cakeDTO) {
		UUID storeUUID = cakeDTO.getStoreUUID();
		if (storeUUID == null) {
			throw new NullPointerException("store must not be null!");
		}
		
		Cake cake = Cake.builder()
				.uuid(cakeDTO.getUuid())
//				.store(storeUUID == null ? null : Store.builder().uuid(storeUUID).build())//이렇게 해야한다..
				.store(Store.builder().uuid(storeUUID).build())//이렇게 해야한다..
				.filePath(cakeDTO.getFilePath())
				.option(cakeDTO.getOption())
				.name(cakeDTO.getName())
				.description(cakeDTO.getDescription())
				.minPrice(cakeDTO.getMinPrice())
				.build();
		
		
		return cake;
	}

근데 위와 같이 코드를 짜면 Store가 영속상태가 아닐 수도 있잖아....

하지만 아래 코드에서 예외가 발생하지 않는다. 26번 줄 이후 컨텍스트가 종료되고 33번 줄에서는 다시 새로운 컨텍스트인데... 예외가 안난다.

 

 

예외만 안나고 DB에 저장은 잘못된거 아닌가 싶어서 조회해 봤는데 이것도 아니다.

spring data jpa가 알아서 해주는게 많아서 그럴까? spring data jpa에서 엔티티 매니저를 어떻게 처리하는지 알아봐야겠다.