오늘 공부하면서 생겼던 에러와 그 해결법을 기록합니다.
글의 목차는 다음과 같습니다.
현재상황
: 제가 사용했던 테이블과 엔티티 클래스의 모습을 보고,에러분석
: 에러가 난 코드를 분석하고해결법
: 해결법을 알아보겠습니다.사용했던 테이블 설계
제가 테스트용으로 만든 테이블은 위와 같습니다.
한명의 사용자가 다수의 블로그를 갖는 비즈니스 로직을 위한 설계입니다.
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;
}
}
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
가 먼저 생기고,
그 record
의 id
를 참조하는 자식 테이블의 record
가 생기는 게 맞는 흐름입니다.
이와 마찬가지로 PC 에 엔티티를 영속 상태로 변경하는 순서는
다음과 같은 순서로 해주는 게 맞습니다!
제가 생각하는 방법은 2가지입니다.
newBlog.decideOwner(newUser);
em.persist(newUser);
em.persist(newBlog);
앞서 설명한 것을 생각하면 됩니다. 단순히 순서를 변경한 겁니다.
그런데 아래처럼 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=?
Transient
상태이기 때문에 PC
는SQL
는 insert 구문에서 user_id
부분은 null
을 넣어줘버립니다.em.persist(user)
를 하면 PC
는 user
엔티티를 관리하게 됩니다. user insert 쿼리도 SQL 저장소에 쌓습니다.em.persist(blog)
에서 사용했던 user
를 뒤늦게 알아낸 Persistence Context
는 SQL
저장소에 update
구문을 추가해줍니다.이러면 뭐가 문제일까요?
분명 em.persist 를 2번 호출하면 insert 구문이 2번만 나가면 끝이어야 하는데,
이상하게 update 가 나가면 상당히 혼란스럽습니다.
그리고 성능이슈도 발생하게 되죠.