@ManyToOne과 @OneToMany를 실습해보면서 발생할 수 있는 에러들을 대처해보는 예제입니다.
본 글은 JPA에 익숙하지 않은 분들을 위한 포스팅입니다. 실습 예제는 여기에 있습니다.
서론
'학교'와 '학생'은 1:N의 관계에 있습니다.
그러므로 '학교'는 기본키, '학생'은 외래키로 활용되며 아래와 같이 Entity를 만들 수 있습니다.
School Entity
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
public class School {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL)
private List<Student> students = new ArrayList<>();
public School(String name) {
this.name = name;
}
public void addStudent(Student student){
this.students.add(student);
student.updateSchool(this);
}
}
Student Entity
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "school_id")
private School school;
public Student(String name) {
this.name = name;
}
public void updateSchool(School school){
this.school = school;
}
}
본론
본론으로 들어가 실제 예제 코드를 보면서 이해해보겠습니다.
첫번째 예제는 School 테이블에서 Id가 1인 값을 들고 오는 것입니다.
@Test
public void School조회() throws Exception {
School school = schoolRepository.findById(1L).get();
System.out.println(school.getName());
}
그리고 School과 Student를 같이 조회할 때는 아래 예제처럼 사용할 수 있습니다.
@Test
public void School과Student조회() throws Exception {
School school = schoolRepository.findById(1L).get();
Student student = studentRepository.findById(1L).get();
System.out.println(school.getName());
System.out.println(student.getName());
}
하지만 위와 같이 사용하면 단순히 Mybatis에서 SQL을 여러번 적어서 조회하는 것과 다를게 없습니다.
JPA로 조회용 코드를 여러번 작성하는 것이 마치 조회용 SQL을 여러개 적어서 사용하는 것과 다를게 없기 때문입니다.
이럴때 JPA의 @ManyToOne과 @OneToMany 등으로 연관관계를 설정해준다면 무분별한 CRUD 쿼리로부터 벗어날 수 있게 되며 객체 중심의 모델링이 가능해지기 때문에 하나의 객체로부터 다른 객체를 접근하는 것이 가능합니다.
즉, School을 가져와서 가져온 값을 통해 Student를 조회할 수 있습니다.
@Test
public void School로부터Student조회() throws Exception {
School school = schoolRepository.findById(1L).get();
List<Student> studentList = school.getStudents();
for(Student student: studentList){
System.out.println(student.getName());
}
}
반대로 Student로 부터 School도 조회가 가능합니다.
@Test
public void Student로부터School조회() throws Exception {
Student student = studentRepository.findById(1L).get();
School school = student.getSchool();
System.out.println(school.getName());
}
@ManyToOne과 @OneToMany의 쓰는 방법을 자세하게 알기 위해서는 연관관계의 주인이란 것을 알아야합니다.
연관관계의 주인
연관관계를 설정하는 것은 @ManyToOne과 @OneToMany으로 설정할 수 있습니다.
기본적으로는 @ManyToOne으로만 단방향 연관관계를 거는 것이 정석이지만 실습을 위해 다대일 양방향 연관관계를 걸겠습니다.
연관관계의 주인을 설정하는 것은 @JoinColumn과 mappedBy를 통해 설정할 수 있습니다.
주인 설정은 왜 하는 것일까요?
주인이 아닌 반대편은 읽기만 가능하게 하고 외래키를 변경하지 못하게 하기 위함입니다.
@ManyToOne
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "school_id")
private School school;
- @ManyToOne은 1:N 관계에서 N인 Entity의 필드에 써줍니다. 무슨 말이냐면 위의 코드에서 1:N 관계를 놓고보면 1은 School이고 N은 Student입니다. 그래서 N인 Entity는 Student고 school의 필드에 @ManyToOne을 적어준 것 입니다.
- 외래키를 가지고 있는 Entity가 보통 주인이 됩니다. 외래키는 RDBMS에서 보통 1:N 관계중 N이 갖고 있습니다. 1은 기본키를 갖고 있겠죠. 그러므로 Student의 컬럼이 연관관계의 주인이 됩니다. 그러므로 @ManyToOne은 외래키를 가지고 있다는 뜻이 되므로 보통은 @ManyToOne이 주인이 됩니다.(@OneToMany가 주인이 될 수는 있으나 드물다)
- 더불어서 @JoinColumn을 설정함으로써 주인이라는 뜻을 명확하게 줄 수 있습니다.
- @JoinColumn 어노테이션을 생략하면 "필드명_참조테이블의기본키"로 매핑합니다. 생략하는 것보다는 직관적일 수 있도록 school_id라고 명시해주는 게 좋습니다.
- @ManyToOne의 fetch = FetchType.EAGER는 즉시 로딩 기능입니다. fetch까지 설명하면 범위를 벗어나기 때문에 여기서는 벗어나기 때문에 다음 포스팅으로 이어집니다.
@OneToMany
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL)
private List<Student> students = new ArrayList<>();
- @OneToMany는 1:N 관계에서 1에 써줍니다.
- mappedBy를 설정해줌으로써 주인이 아니다라는 뜻을 줄 수 있습니다.
- mappedBy 값은 주인에서 사용하는 외래키 필드명을 적어줍니다.
- mappedBy를 설정해주는 것이 필수는 아닙니다.
저장 시에 발생하기 쉬운 에러 케이스
TransientPropertyValueException 임시 프로퍼티 값 예외
해당 예외는 객체 저장 시 그 객체 안에 저장되지 않은 객체(Student 내 School 객체)가 저장되려고 할 때 발생합니다.
@Test
public void TransientPropertyValueException발생() throws Exception {
School school = new School();
school.setName("MySchool");
Student student = new Student();
student.setName("loose");
student.setSchool(school);
schoolRepository.save(school);
}
이럴땐 School도 저장해주거나 혹은 Cascade 기능을 이용해 한꺼번에 저장되게 만들면 됩니다.
DataIntegrityViolationException의 ConstraintViolationException 제약 조건 위배 예외
@After
public void cleanAll() {
schoolRepository.deleteAll();
studentRepository.deleteAll();
}
@Test
public void ConstraintViolationException발생() throws Exception {
School school = new School();
school.setName("MySchool");
Student student = new Student();
student.setName("loose");
student.setSchool(school);
schoolRepository.save(school);
studentRepository.save(student);
}
save까진 잘 저장되지만 @After에서 school을 삭제 시에 발생하는 에러입니다.
왜 school을 삭제할 때 에러가 날까요?
일단 @After영역도 @Transactional이 종료되지 않은 상태이므로 영속성 컨텍스트가 존재합니다.
일반적이라면 School 부모를 삭제했을 때 cascade 설정으로 인해 Student 자식들을 먼저 모두 삭제하고 부모를 삭제하게 됩니다.
하지만 school을 삭제할 때 자식인 student를 참조할 수가 없습니다. 왜냐면 student는 setSchool 메소드를 통해 School 정보를 넣어서 student에서 school을 바라볼 수가 있었습니다.
하지만 school 입장에선 student에 대한 정보를 set을 통해 넣어준 적이 없기 때문에 영속성 컨텍스트에서 deleteAll는 자식이 없다고 판단하여 바로 "부모 삭제"를 진행하게 됩니다.
이때 이미 영속성 컨텍스트 내에 외래키로 student가 존재하다보니 외래키만 냅두고 기본키를 삭제할 수가 없는 외래키 참조 무결성 제약 조건 위배 에러가 발생합니다.
이를 해결하는 방법은 save의 로직과 delete의 로직을 분리하는 것입니다. 하지만 그건 임시방편일 뿐 올바른 코드는 아닙니다. 우리가 원하는 진행했던 코드는 하나의 로직에 save와 delete가 다 있는것이죠.
아래처럼 school에도 student 정보를 세팅해 주어야만 에러가 발생하지 않습니다.
@Test
public void ConstraintViolationException제거() throws Exception {
School school = new School();
school.setName("MySchool");
Student student = new Student();
student.setName("loose");
student.setSchool(school);
student.getSchool().getStudents().add(student); //School에도 student에 대한 정보 추가
schoolRepository.save(school);
studentRepository.save(student);
}
이런 것을 연관 관계 설정이라고 합니다.
student.setSchool(school);
student.getSchool().getStudents().add(student); //School에도 student에 대한 정보 추가
그래서 JPA에선 위의 2줄 코드를 데이터 저장 시 항상 만들어줘야 정상적으로 저장이 되고 추후 처리도 가능합니다.
그리고 이 2줄은 여러번 사용될 수 있으므로 Entity의 필드마다 연관관계 전용 메소드를 만들어서 사용하게 됩니다.
이렇듯 객체 지향 관점에서 보면 양방향 연관관계에서는 양쪽 다 데이터를 저장하지 않으면, 즉, 동기화 하지 않아서 연관관계가 설정이 제대로 안되어있으면 Hibernate는 연관관계의 상태 변화를 데이터베이스에 삽입, 삭제하는것을 보장하지 않습니다.
'📖 ORM' 카테고리의 다른 글
Fetch Join 사용 시 조건문(Condition) 올바르게 사용하기 - 실습으로 배우는 JPA 4편 (0) | 2022.11.03 |
---|---|
@Transactional 사용 시 자기 호출(Self-Invocation) 이슈 - 실습으로 배우는 JPA 3편 (2) | 2022.07.06 |
findAll()에 관한 N+1 테스트 - 실습으로 배우는 JPA 2편 (0) | 2022.06.30 |
@OnDelete와 CascadeType.ALL, orphanRemoval 속성 (0) | 2022.06.24 |
Kotlin 에서의 JPA Builder (0) | 2021.03.29 |