[JPA] Not-null property references a transient value - transient instance must be saved before current operation

식빵·2023년 8월 26일
0

JPA 이론 및 실습

목록 보기
16/17

오늘 공부하면서 생겼던 에러와 그 해결법을 기록합니다.
글의 목차는 다음과 같습니다.

  1. 현재상황 : 제가 사용했던 테이블과 엔티티 클래스의 모습을 보고,
  2. 에러분석 : 에러가 난 코드를 분석하고
  3. 해결법 : 해결법을 알아보겠습니다.

📌 현재상황


사용했던 테이블 설계

제가 테스트용으로 만든 테이블은 위와 같습니다.
한명의 사용자가 다수의 블로그를 갖는 비즈니스 로직을 위한 설계입니다.


User.java

@Entity
@SequenceGenerator(
	name = "blogIdSequence",
	schema = "blog",
	sequenceName = "blog_id_seq",
	allocationSize = 1
)
@Getter
@ToString
@NoArgsConstructor
public class Blog {

	@Id @GeneratedValue(generator = "blogIdSequence")
	private Long blogId;
	
	@Column(nullable = false)
	private String blogName;
	
	@CreationTimestamp
	@Column(nullable = false)
	private LocalDate blogCreateDate;
	
	@ManyToOne(fetch = FetchType.LAZY, optional = false)
	@JoinColumn(name = "user_id")
	@ToString.Exclude
	private User user;
	
	@Builder(builderMethodName = "newBlogBuilder")
	public Blog(String blogName, LocalDate blogCreateDate) {
		this.blogName = blogName;
		this.blogCreateDate = blogCreateDate;
	}
	
	public void decideOwner(User user) {
		this.user = user;
	}
}
  • decideOwner 메소드를 통해서 blog 자신을 소유한 주인(User) 를 지정하도록 했습니다.

Blog.java

@Entity
@Table(schema = "blog", name = "users")
@SequenceGenerator(
	name="userIdSequence",
	schema = "blog",
	sequenceName = "users_id_seq",
	allocationSize = 1
)
@Getter
@ToString
@NoArgsConstructor
public class User {
	@Id
	@GeneratedValue(generator = "userIdSequence")
	private Long userId;
	
	@Column(nullable = false)
	private String nickname;
	
	@Column(nullable = false)
	private String email;
	
	@Column(nullable = false, length = 30)
	private String phoneNumber;
	
	@Column(columnDefinition = "smallint check ((age > 0) AND (age <= 140))",
			nullable = false)
	private Short age;

	@OneToMany(mappedBy = "user")
	@ToString.Exclude
	private List<Blog> blogList = new ArrayList<>();
	
	@Builder(builderMethodName = "newUserBuilder")
	public User(String nickname, String email, String phoneNumber, Short age) {
		this.nickname = nickname;
		this.email = email;
		this.phoneNumber = phoneNumber;
		this.age = age;
	}
}



📌 에러분석

에러를 일으키는 코드를 보겠습니다.

User newUser = User.newUserBuilder()
	.nickname("Alicia")
	.email("alicia@velog.io")
	.phoneNumber("010-2222-2222")
	.age((short) 31)
	.build();

Blog newBlog = Blog.newBlogBuilder()
	.blogName("Alicia Blog")
	.blogCreateDate(LocalDate.now()).build();

newBlog.decideOwner(newUser);

// 참고: em 은 EntityManager 인스턴스입니다!
em.persist(newBlog); // !!!!!!!에러 발생!!!!!!!!!

em.persist(newUser);

이러면 em.persist(newBlog); 실행하면 아래와 같은 에러가 납니다.

Caused by: java.lang.IllegalStateException:
	org.hibernate.TransientPropertyValueException: 
    	Not-null property references a transient value 
        	- transient instance must be saved before current operation :
            	coding.toast.blog.entity.Blog.user -> coding.toast.blog.entity.User

대충 해석해보자면 이렇습니다.

Blog 인스턴스의 user 필드(=연관관계 필드)는 Not-Null 제약이 있는데,
현재 코드 작성자께서는 해당 필드에 Transient 인스턴스,
Persistence Context (이후 PC 라고 부르겠습니다) 에 저장되지도
않은 User 인스턴스를 Blog.user 필드에 세팅한 상태에서
em.persist(blog) 를 시도해서 에러가 난 겁니다.


이해가 안되시나요? 좀 더 풀어 얘기해보겠습니다.
PC 를 일종의 추상적인 DB 환경이라고 가정하고 다시 생각해보죠.


User 테이블은 부모 테이블이고, Blog 테이블은 user_id 를 받는 자식 테이블입니다.
그에 더해서 Blog 테이블은 user_id 컬럼에 not null 제약이 걸린 상태입니다.

그런데 Blog 를 먼저 insert (= em.persist(blog) ) 하고,
User 를 insert (= em.persist(user) ) 를 하는 게 과연 맞을까요?

아니겠죠! 부모 테이블의 record 가 먼저 생기고,
recordid 를 참조하는 자식 테이블의 record 가 생기는 게 맞는 흐름입니다.


이와 마찬가지로 PC 에 엔티티를 영속 상태로 변경하는 순서는
다음과 같은 순서로 해주는 게 맞습니다!

  1. persist → user : user 가 영속상태가 됩니다.
  2. persist → blog : user 를 참조한 blog 가 영속상태가 됩니다.



📌 해결법

제가 생각하는 방법은 2가지입니다.

방법1: persist 순서 변경

newBlog.decideOwner(newUser);
em.persist(newUser);
em.persist(newBlog);

앞서 설명한 것을 생각하면 됩니다. 단순히 순서를 변경한 겁니다.



방법2: Cascade.PERSIST 세팅

그런데 아래처럼 2개의 메소드를 부르는 게 귀찮을 수 있습니다.

em.persist(newUser);
em.persist(newBlog);

그냥 자식 테이블을 담당하는 엔티티 (= blog)가 persist 할때,
자신이 포함하고 있는 부모 테이블 엔티티 ( = user) 도 자동으로 persist
할 방법이 없을까요? 그냥 em.persist(newBlog); 만 호출하면 끝나게 말입니다.

다행히 방법이 있습니다!

public class Blog {

	@ManyToOne(fetch = FetchType.LAZY, 
    		   optional = false, 
               cascade = CascadeType.PERSIST) // Blog persist + User persist
	@JoinColumn(name = "user_id")
	@ToString.Exclude
	private User user;
}

cascade = CascadeType.PERSIST 속성을 추가하면 끝입니다!
이러면 엔티티 클래스가 갖는 연관관계 엔티티도 같이 persist 가 됩니다.
엄청 편리하죠.



추천하지 않는 방법

기존 Blog 코드에서 optional=false 를 줘서 어떻게 보면 이 사단이 난 거긴 합니다.
그렇다고 이걸 주석 처리해서 해결하면 좋을까요?

public class Blog {
	
	@ManyToOne(fetch = FetchType.LAZY/*, optional = false*/)
	@JoinColumn(name = "user_id")
	@ToString.Exclude
	private User user;

    // 나머지 모두 생략...
}

개인적으로 매우 추천하지 않는 방법입니다.
이렇게 하고 기존에 에러가 나던 코드가 정상 동작은 할겁니다.
하지만 과연 우리가 원하는 방식대로 동작할까요?


한번 기존 에러가 나던 코드를 다시 실행해보고,
쿼리가 어떻게 나가는지 파악해보죠.

em.persist(blog)
em.persist(user)

실제 실행되는 쿼리

Hibernate: 
    insert 
    into
        blog.blog (blog_create_date,blog_name,user_id,blog_id) 
    values
        (?,?,?,?)
Hibernate: 
    insert 
    into
        blog.users (age,email,nickname,phone_number,user_id) 
    values
        (?,?,?,?,?)
Hibernate: 
    update blog.blog 
    set
        blog_create_date=?,
        blog_name=?,
        user_id=? 
    where
        blog_id=?
  • blog 를 먼저 Persistence Context 에 저장합니다.
  • 하지만 현재 User 는 Persistence Context 에 저장되지 않은 상태이고,
    해당 인스턴스는 Transient 상태이기 때문에 PC
    이걸 어떻게 처리해야 되는지 이해를 못합니다.
  • 결국 처음 em.persist(blog) 를 호출했을 때 Persistence Context 의 SQL 저장소에 쌓이는 SQL 는 insert 구문에서 user_id 부분은 null 을 넣어줘버립니다.
  • 추후에 em.persist(user) 를 하면 PCuser 엔티티를 관리하게 됩니다. user insert 쿼리도 SQL 저장소에 쌓습니다.
  • 하지만 마지막에 em.persist(blog) 에서 사용했던 user 를 뒤늦게 알아낸 Persistence ContextSQL 저장소에 update 구문을 추가해줍니다.

이러면 뭐가 문제일까요?

  1. 분명 em.persist 를 2번 호출하면 insert 구문이 2번만 나가면 끝이어야 하는데,
    이상하게 update 가 나가면 상당히 혼란스럽습니다.

  2. 그리고 성능이슈도 발생하게 되죠.



⭐ 참고링크

https://www.baeldung.com/hibernate-not-null-error

profile
백엔드를 계속 배우고 있는 개발자입니다 😊

0개의 댓글