1. 개요
@Column 옵션 중 nullable = false를 적용하는 순간 ConstraintViolationException: could not execute statement 예외가 발생하였다.
2. 배경
nullable = false 옵션은 null은 허용하지 않는다는 의미이다. null을 허용할 경우 연관관계가 맺어진 테이블 조회 시 left outer join을 하게되어 성능상 이슈를 가져올 수 있으나, 허용하지 않을 경우 inner join을 하여 성능상 이점을 가져올 수 있기에 해당 옵션을 추가하였다. 그런데 옵션을 추가하자마자 아래와 같은 에러가 발생하였다. null을 허용하지 않는 컬럼에 null이 들어가 SQL을 실행할 수 없다는 에러였다.
3. 원인 분석
CLASS_ID는 Student 엔티티와 다대일 관계에 있는 Classroom 엔티티의 Join Column이다. 메인 메서드에서 Student 엔티티를 생성한 후 Classroom 설정을 하지 않았는지 확인해보았다.
1) main 메서드
- main 메서드에서는 Student의 수정자 메서드와 classroom의 addStudent 메서드를 통해 양방향으로 주입 가능하도록 메서드를 구현했었다. 어쨌든 student1과 student2는 이를 사용하여 classroom을 set하는 것을 확인하였다. 그후 메서드 구현부분을 차례로 확인해보았다.
Student student1 = new Student();
student1.setName("심심심");
em.persist(student1);
Student student2 = new Student();
student2.setName("홍길동");
em.persist(student2);
Classroom classroom = new Classroom();
classroom.setName("배드민턴반");
em.persist(classroom);
student1.setClassroom(classroom); // student 1에 classroom set
classroom.addStudent(student2); // student 2에 classroom set
List<Student> list = classroom.getStudentList();
for(Student student : list){
System.out.println("========================="+student.getName());
}
2) Student.java
@Entity
@Getter
@Setter
public class Student {
@Id
@GeneratedValue
@Column(name = "STUDENT_ID")
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "CLASS_ID", nullable = false)
private Classroom classroom;
public void setClassroom(Classroom classroom){
if(this.classroom != null){
this.classroom.getStudentList().remove(this);
}
this.classroom = classroom;
classroom.getStudentList().add(this);
}
}
Student의 경우 setClassroom 시 Student.classroom에 set 함과 동시에 객체 상태의 classroom에도 Student를 반영해주기 위해 classroom.getStudentList().add(this)구문을 추가해주었다. 어쨌든 classroom이 'this.classroom = classroom' 코드에 의해 설정되므로 여기서 null을 발생시키진 않는 것으로 판단하였다.
3) Classroom.java
@Entity
@Getter
@Setter
public class Classroom {
@Id
@GeneratedValue
@Column(name = "CLASSROOM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "classroom")
private List<Student> studentList = new ArrayList<>();
public void addStudent(Student student){
if(student.getClassroom() != null){
student.getClassroom().getStudentList().remove(student);
}
student.setClassroom(this);
}
}
addStudent 메서드도 studentList에 대한 순수 객체 상태도 엔티티와 동일하게 가져가기 위해 요청으로 들어온 Student가 속한 classroom이 있을 경우, 해당 classroom의 StudentList에서 요청으로 들어온 student를 remove해주는 코드를 넣었다. 중요한 부분은 Student 엔티티의 classroom이 set 되냐 마냐인데 student.setClassroom(this)를 통해 요청으로 들어온 Student 엔티티에도 classroom이 설정되는 것을 확인하였다.
결론은 문제의 원인이 될만한 곳을 발견하지 못했다. 디버깅을 해봐도 student1과 student2 엔티티 모두 classroom을 갖고 있는 것을 확인하였다. 그럼 뭐가 문제일까를 고민하다가 자연스레 JPA의 동작원리를 생각해보았다.
엔티티 매니저의 persist 메서드를 사용하면 엔티티 매니저가 관리하는 영속성 컨텍스트로 엔티티가 관리된다. 동시에 1차 캐시에 엔티티 id와 엔티티가 key, value 형식으로 저장되고 쓰기지연 SQL에 엔티티에 대한 insert 쿼리가 생성되어 쌓인다. 후에 EntityTransaction혹은 EntityManager에 의해 flush() 메서드가 호출되면 쓰기지연 SQL에 있는 쿼리가 실제 DB에서 실행되어 데이터가 저장된다.
여기서 쓰기지연 SQL에 들어간 insert쿼리가 어떤 순서로 실행됐는지를 확인해보기 위해 실행된 쿼리를 확인해보았다.
그 결과 가장 먼저 실행된 쿼리는 student에 대한 INSERT 쿼리였다. 이 시점에서는 CLASS_ID가 할당되지 않는다. 왜냐하면 student에 classroom을 set 하기 전에 em.persist를 호출했고, 이때 쓰기지연 SQL에는 CLASS_ID가 할당되지 않은 Student의 INSERT 쿼리가 적재되기 때문이다.
더 나아가면 아래 Student 엔티티에 대한 setClassroom을 하는 시점에 UPDATE 쿼리가 적재되게 된다.
Student student1 = new Student();
student1.setName("심심심");
em.persist(student1);
..
student1.setClassroom(classroom)
4. 해결방안
student1, 2에 classroom을 set한 이후 em.persist를 호출하는 쪽으로 em.persist 메서드 위치를 변경하였다. 이제 Student에 대한 쓰기지연 SQL에는 classroom이 할당된 상태의 INSERT 쿼리가 나가게 되었다.
Student student1 = new Student();
student1.setName("심심심");
Student student2 = new Student();
student2.setName("홍길동");
Classroom classroom = new Classroom();
classroom.setName("배드민턴반");
em.persist(classroom);
student1.setClassroom(classroom);
classroom.addStudent(student2);
em.persist(student1);
em.persist(student2);
결론은 em.persist의 순서였는데, 순서로 인한 이런 에러는 추후에도 발생할 여지가 충분히 있다고 생각되었다. 만약 널을 허용하지 않는 옵션이 일반 Column이었다면 실제로 set을 하지 않아 발생한 것이므로 바로 원인을 찾았을 것이다. 하지만 위 케이스의 경우 classroom이라는 부모 엔티티를 수정자 메서드를 통해 설정해줬음에도 불구하고 에러가 발생하기 때문에 원인 찾기가 힘들 수 있다고 생각되었다.
어쨌든간에 null을 허용하지 않는 자식, 부모 관계에 있는 엔티티를 자식, 부모 순서로 persist 하여 발생하였는데, 부모 엔티티만 persist해도 연관된 엔티티들이 함께 영속상태로 들어가는 영속성 전이를 사용하면 더 깔끔하게 해결 가능하다.
부모 엔티티의 연관관계 어노테이션에 cascade 옵션을 PERSIST로 설정하면 되며, 자식객체를 persist하는 코드 생략과 앞서 발생했던 문제들을 예방하는 효과를 얻을 수 있었다.
참고로 영속성 전이는 em.persist()를 실행할 때가 아닌 flush()를 호출할 때 발생한다.
1) Classroom.java
@Entity
@Getter
@Setter
public class Classroom {
@Id
@GeneratedValue
@Column(name = "CLASSROOM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "classroom", cascade = CascadeType.PERSIST) // 영속성 전이 옵션
private List<Student> studentList = new ArrayList<>();
public void addStudent(Student student){
if(student.getClassroom() != null){
student.getClassroom().getStudentList().remove(student);
}
student.setClassroom(this);
}
}
2) main 메서드
Student student1 = new Student();
student1.setName("심심심");
Student student2 = new Student();
student2.setName("홍길동");
Classroom classroom = new Classroom();
classroom.setName("배드민턴반");
student1.setClassroom(classroom); // 연관관계 추가
classroom.addStudent(student2); // 연관관계 추가
em.persist(classroom); // classroom 영속화
tx.commit();
5. 회고
코드상 원인은 em.persist의 순서였지만, 근본 원인은 JPA에 대한 매커니즘을 생각하지 않고 코드를 작성한 나 자신이었다 :( JPA는 프레임워크라는 사실을 잊지말자. 스프링도 마찬가지라고 생각하는데 프레임워크를 사용하기 위해서는 매커니즘과 그 성격을 이해하고 사용하는 것이 정말 중요하다고 다시한번 느끼게되었다.
'백엔드 > JPA' 카테고리의 다른 글
[JPA] JPQL 내부 조인, 외부 조인, 세타 조인 (0) | 2023.04.13 |
---|---|
[JPA] 프로젝션 및 JPA 페이징 API (0) | 2023.04.13 |
[JPA] JPQL 개념 및 기본 문법 (0) | 2023.04.12 |