안녕하세요. J4J입니다.
이번 포스팅은 JPA 연관관계 매핑(단방향)에 대해 적어보는 시간을 가져보려고 합니다.
들어가기에 앞서 이전에 작성된 포스팅을 읽고 오시면 이해가 더 잘되실 겁니다.
2021.03.21 - [Spring/JPA] - [JPA] JPA 환경설정
2021.03.22 - [Spring/JPA] - [JPA] JPA Repository설정 및 CRUD
2021.03.23 - [Spring/JPA] - [JPA] 조건절(where) 메서드
2021.03.24 - [Spring/JPA] - [JPA] 복합키(Composite Key) 엔티티
연관관계 매핑이란?
객체의 참조와 테이블의 외래 키를 매핑하는 작업을 연관관계 매핑이라고 합니다.
데이터베이스 관점에서는 외래키를 가지고 다른 테이블에 존재하는 데이터를 조인하여 조회하는 것이라고 볼 수 있습니다.
연관관계는 크게 단방향과 양방향이 존재합니다.
단방향은 한 객체만 다른 객체를 참조하는 것이고 양방향은 두 객체가 서로 참조하고 있는 것입니다.
결과적으로 양방향은 서로 단방향으로 매핑되어 있으면 양방향 매핑이라고 할 수 있습니다.
오늘은 이 두 관계 중 단방향에 대해 얘기해보도록 하겠습니다.
다중성 (Multiplicity)
다중성의 종류는 총 4가지입니다.
- 일대일 (1:1)
- 일대다 (1:n)
- 다대일 (n:1)
- 다대다 (n:m)
일대일 관계는 한 값이 다른 하나의 값에만 매핑되는 것으로 한 명의 학생에게 한 개의 개인정보만 존재하는 것을 예로 들 수 있습니다.
JPA에서 일대일 관계를 표현하기 위해 사용되는 어노테이션은 @OneToOne입니다.
일대다 관계는 한 값이 다른 여러 개의 값에 매핑되는 것으로 한 학교에 여러 명의 학생이 존재하는 것을 예로 들 수 있습니다.
JPA에서 일대다 관계를 표현하기 위해 사용되는 어노테이션은 @OneToMany입니다.
다대일 관계는 여러 개의 값이 다른 하나의 값에만 매핑되는 것으로 여러 명의 학생이 한 학교에 존재하는 것을 예로 들 수 있습니다.
JPA에서 다대일 관계를 표현하기 위해 사용되는 어노테이션은 @ManyToOne입니다.
다대다 관계는 여러 개의 값이 다른 여러 개의 값에 매핑되는 것으로 여러 명의 학생이 여러 개의 시험을 보는 것을 예로 들 수 있습니다.
JPA에서 다대다 관계를 표현하기 위해 사용되는 어노테이션은 @ManyToMany입니다.
일대다(1:n) 단방향 매핑
이전 포스팅에서 사용되었던 학교와 학생 테이블을 예시로 들어보겠습니다.
학교와 학생 간의 관계는 1:n관계가 될 수 있습니다.
school에 있는 한 개의 school_id값이 student에 여러 개가 존재한다는 것입니다.
위의 관계가 성립될 수 있도록 이전 코드들을 활용하여 일대다 단방향 매핑을 구현해보겠습니다.
[ 1. MySQL 테이블 / 데이터 생성 쿼리 ]
create table school (
school_id int auto_increment,
name varchar(50),
region varchar(50),
ranking int,
primary key(school_id)
);
create table student (
student_id varchar(30),
name varchar(30),
school_id int,
score int,
primary key(student_id, name)
);
insert into school(name, region, ranking) values ('강원 고등학교', '강원', 3);
insert into school(name, region, ranking) values ('서울 고등학교', '서울', 1);
insert into school(name, region, ranking) values ('제주 고등학교', '제주', 4);
insert into school(name, region, ranking) values ('경기 고등학교', '경기', 2);
insert into student values ('1234', '김고기', 3, 89);
insert into student values ('2345', '정덮밥', 1, 78);
insert into student values ('3456', '박찌개', 2, 82);
insert into student values ('4567', '문초밥', 3, 99);
insert into student values ('5678', '이족발', 4, 73);
insert into student values ('6789', '진짬뽕', 2, 85);
[ 2. School 엔티티 클래스 ]
package com.spring.jpa.dto;
import java.io.Serializable;
import java.util.List;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToMany;
import javax.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity // 영속성 컨텍스트에 의해 관리되는 클래스를 의미
@Table(name = "school") // 엔티티에 매핑되는 DB 테이블 명
public class School implements Serializable { // Serializable을 상속받지 않으면 Entity가 여러개일 때 에러 발생
@Id // 기본키가 될 변수를 의미
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB의 auto_increment로 값을 저장하는 것을 의미
@Column(name = "school_id") // DB에서는 컬럼명이 school_id
int id;
private String name;
private String region;
private int ranking;
@OneToMany // 일대다 관계
@JoinColumn(name = "school_id") // Student엔티티의 school_id를 이용하여 조인
private List<Student> students;
}
[ 3. StudentID 식별자 클래스 ]
package com.spring.jpa.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class StudentID implements Serializable {
private String studentId;
private String name;
}
[ 4. Student 엔티티 클래스 ]
package com.spring.jpa.dto;
import java.io.Serializable;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "student")
@IdClass(StudentID.class)
public class Student implements Serializable {
@Id
@Column(name = "student_id")
private String studentId;
@Id
private String name;
private int school_id;
private int score;
}
[ 5. SchoolRepository 클래스 ]
package com.spring.jpa.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import com.spring.jpa.dto.School;
public interface SchoolRepository extends JpaRepository<School, Integer> { // 제네릭 타입: <엔티티 클래스, 엔티티클래스의 기본키>
}
[ 6. 단위 테스트 ]
package com.spring.jpa;
import javax.transaction.Transactional;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import com.spring.jpa.config.RootContext;
import com.spring.jpa.repository.SchoolRepository;
import com.spring.jpa.repository.StudentRepository;
import lombok.extern.slf4j.Slf4j;
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = RootContext.class)
@Slf4j
public class JPATest {
@Autowired
SchoolRepository schoolRepository;
@Test
@Transactional // fetch 타입이 lazy일 때 필수
public void schoolTest() {
log.info(schoolRepository.findAll().toString());
}
}
작성된 테스트 코드를 실행할 경우 다음과 같은 로그가 출력됩니다.
Hibernate:
/* select
generatedAlias0
from
School as generatedAlias0 */ select
school0_.school_id as school_i1_0_,
school0_.name as name2_0_,
school0_.ranking as ranking3_0_,
school0_.region as region4_0_
from
school school0_
Hibernate:
select
students0_.school_id as school_i3_1_0_,
students0_.name as name1_1_0_,
students0_.student_id as student_2_1_0_,
students0_.name as name1_1_1_,
students0_.student_id as student_2_1_1_,
students0_.school_id as school_i3_1_1_,
students0_.score as score4_1_1_
from
student students0_
where
students0_.school_id=?
Hibernate:
select
students0_.school_id as school_i3_1_0_,
students0_.name as name1_1_0_,
students0_.student_id as student_2_1_0_,
students0_.name as name1_1_1_,
students0_.student_id as student_2_1_1_,
students0_.school_id as school_i3_1_1_,
students0_.score as score4_1_1_
from
student students0_
where
students0_.school_id=?
Hibernate:
select
students0_.school_id as school_i3_1_0_,
students0_.name as name1_1_0_,
students0_.student_id as student_2_1_0_,
students0_.name as name1_1_1_,
students0_.student_id as student_2_1_1_,
students0_.school_id as school_i3_1_1_,
students0_.score as score4_1_1_
from
student students0_
where
students0_.school_id=?
Hibernate:
select
students0_.school_id as school_i3_1_0_,
students0_.name as name1_1_0_,
students0_.student_id as student_2_1_0_,
students0_.name as name1_1_1_,
students0_.student_id as student_2_1_1_,
students0_.school_id as school_i3_1_1_,
students0_.score as score4_1_1_
from
student students0_
where
students0_.school_id=?
INFO : com.spring.jpa.JPATest - [School(id=1, name=강원 고등학교, region=강원, ranking=3, students=[Student(studentId=2345, name=정덮밥, school_id=1, score=78)]), School(id=2, name=서울 고등학교, region=서울, ranking=1, students=[Student(studentId=3456, name=박찌개, school_id=2, score=82), Student(studentId=6789, name=진짬뽕, school_id=2, score=85)]), School(id=3, name=제주 고등학교, region=제주, ranking=4, students=[Student(studentId=1234, name=김고기, school_id=3, score=89), Student(studentId=4567, name=문초밥, school_id=3, score=99)]), School(id=4, name=경기 고등학교, region=경기, ranking=2, students=[Student(studentId=5678, name=이족발, school_id=4, score=73)])]
출력된 로그 부분을 보시면 한 School 클래스에 1개 이상의 학생 데이터가 정상적으로 들어가 있는 것을 확인할 수 있습니다.
FetchType
위의 테스트 예제에서 짚고 넘어가셔야 될 점은 테스트 코드 메서드에 @Transactional이 있다는 것입니다.
FetchType이 LAZY인 경우에는 @Transactional을 붙여줘야 되는데 @OneToMany의 FetchType이 현재 LAZY이기 때문입니다.
조금 더 자세하게 설명드리겠습니다.
JPA의 다중성에는 다음과 같은 fetch라는 속성 값이 존재합니다.
- FetchType.EAGER
- FetchType.LAZY
EAGER는 즉시 로딩, LAZY는 지연 로딩을 의미합니다.
즉시 로딩이란 말 그대로 코드가 실행되면 참조되는 쿼리도 같이 즉시 실행시킨다는 것입니다.
지연 로딩은 코드를 실행하면 참조되는 쿼리를 실행시키지 않고 프록시 객체를 가져와 저장한 뒤 이후 코드에서 참조 객체의 변수 값들이 실제로 사용될 때 쿼리를 실행시켜 가져온다는 것입니다.
즉시 로딩보다 지연 로딩을 사용하는 것을 권장을 하는데 그 이유는 데이터를 조회할 때 참조되는 객체를 사용하지 않는다면 굳이 조회할 필요가 없기 때문입니다.
다른 표현으로는 낭비라고 말할 수 있습니다.
또한 EAGER를 사용할 경우 DB에 부담을 줄 수 있습니다.
예를 들어 단순히 모든 학교 데이터만 조회하면 한 개의 쿼리만 동작시키면 됩니다.
하지만 조회하기 위해 findAll()을 실행하게 되면 매핑되어 있는 컬럼 값의 개수만큼 학생 데이터를 추가적으로 조회하게 됩니다.
현재 테스트 중인 것을 예시로 들면 school_id값으로 연관관계가 매핑되어 있기 때문에 School 테이블의 school_id의 개수만큼 추가적인 조회를 하게 된다는 것입니다.
테스트에서는 school_id가 4개밖에 없어서 추가적인 조회를 4번밖에 안 하지만 school_id가 100개가 있다고 한다면 100번의 추가적인 조회가 발생된다는 것입니다.
이런 문제를 발생시키지 않게 하기 위해 EAGER보다는 LAZY사용이 권장되는 것입니다.
그리고 각각의 다중성에는 다음과 같이 FetchType이 디폴트로 정해져 있습니다.
- @OneToOne: EAGER
- @OneToMany: LAZY
- @ManyToOne: EAGER
- @ManyToMany: LAZY
@OneToMany의 기본 값은 프록시 객체를 가져오는 LAZY이고 지연 로딩을 하기 위해서는 @Transactional설정이 되어있어야 하기 때문에 위의 코드에서 @Transactional이 붙어있는 것입니다.
그럼 만약 @OneToMany의 FetchType을 EAGER로 바꾸면 어떻게 될까요?
FetchType을 EAGER로 바꾸고 @Transactional도 없애보겠습니다.
package com.spring.jpa.dto;
import java.io.Serializable;
import java.util.List;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToMany;
import javax.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity // 영속성 컨텍스트에 의해 관리되는 클래스를 의미
@Table(name = "school") // 엔티티에 매핑되는 DB 테이블 명
public class School implements Serializable { // Serializable을 상속받지 않으면 Entity가 여러개일 때 에러 발생
@Id // 기본키가 될 변수를 의미
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB의 auto_increment로 값을 저장하는 것을 의미
@Column(name = "school_id") // DB에서는 컬럼명이 school_id
int id;
private String name;
private String region;
private int ranking;
@OneToMany(fetch = FetchType.EAGER) // 일대다 관계, 즉시로딩으로 변경
@JoinColumn(name = "school_id") // Student엔티티의 school_id를 이용하여 조인
private List<Student> students;
}
package com.spring.jpa;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import com.spring.jpa.config.RootContext;
import com.spring.jpa.repository.SchoolRepository;
import com.spring.jpa.repository.StudentRepository;
import lombok.extern.slf4j.Slf4j;
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = RootContext.class)
@Slf4j
public class JPATest {
@Autowired
SchoolRepository schoolRepository;
@Test
// @Transactional // fetch 타입이 lazy일 때 필수
public void schoolTest() {
log.info(schoolRepository.findAll().toString());
}
}
코드를 실행할 경우 다음과 같이 정상적으로 실행되는 것을 확인할 수 있습니다.
Hibernate:
/* select
generatedAlias0
from
School as generatedAlias0 */ select
school0_.school_id as school_i1_0_,
school0_.name as name2_0_,
school0_.ranking as ranking3_0_,
school0_.region as region4_0_
from
school school0_
Hibernate:
select
students0_.school_id as school_i3_1_0_,
students0_.name as name1_1_0_,
students0_.student_id as student_2_1_0_,
students0_.name as name1_1_1_,
students0_.student_id as student_2_1_1_,
students0_.school_id as school_i3_1_1_,
students0_.score as score4_1_1_
from
student students0_
where
students0_.school_id=?
Hibernate:
select
students0_.school_id as school_i3_1_0_,
students0_.name as name1_1_0_,
students0_.student_id as student_2_1_0_,
students0_.name as name1_1_1_,
students0_.student_id as student_2_1_1_,
students0_.school_id as school_i3_1_1_,
students0_.score as score4_1_1_
from
student students0_
where
students0_.school_id=?
Hibernate:
select
students0_.school_id as school_i3_1_0_,
students0_.name as name1_1_0_,
students0_.student_id as student_2_1_0_,
students0_.name as name1_1_1_,
students0_.student_id as student_2_1_1_,
students0_.school_id as school_i3_1_1_,
students0_.score as score4_1_1_
from
student students0_
where
students0_.school_id=?
Hibernate:
select
students0_.school_id as school_i3_1_0_,
students0_.name as name1_1_0_,
students0_.student_id as student_2_1_0_,
students0_.name as name1_1_1_,
students0_.student_id as student_2_1_1_,
students0_.school_id as school_i3_1_1_,
students0_.score as score4_1_1_
from
student students0_
where
students0_.school_id=?
INFO : com.spring.jpa.JPATest - [School(id=1, name=강원 고등학교, region=강원, ranking=3, students=[Student(studentId=2345, name=정덮밥, school_id=1, score=78)]), School(id=2, name=서울 고등학교, region=서울, ranking=1, students=[Student(studentId=3456, name=박찌개, school_id=2, score=82), Student(studentId=6789, name=진짬뽕, school_id=2, score=85)]), School(id=3, name=제주 고등학교, region=제주, ranking=4, students=[Student(studentId=1234, name=김고기, school_id=3, score=89), Student(studentId=4567, name=문초밥, school_id=3, score=99)]), School(id=4, name=경기 고등학교, region=경기, ranking=2, students=[Student(studentId=5678, name=이족발, school_id=4, score=73)])]
그런데 여기서 한 가지 더 의문점이 풀리지 않으신 분이 계실 수도 있습니다.
그것은 바로 @OneToMany가 FetchType이 LAZY여서 지연 로딩인데 초기 테스트를 실행할 때 바로 참조 객체의 쿼리도 조회했다는 것입니다.
그 이유는 toSring() 메서드를 이용하여 변수들을 출력하기 위해 변수값들을 사용하고 있기 때문입니다.
School의 FetchType을 다시 원복 하고 toString() 메서드를 이용하지 않고 findAll()만 해보겠습니다.
package com.spring.jpa.dto;
import java.io.Serializable;
import java.util.List;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToMany;
import javax.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity // 영속성 컨텍스트에 의해 관리되는 클래스를 의미
@Table(name = "school") // 엔티티에 매핑되는 DB 테이블 명
public class School implements Serializable { // Serializable을 상속받지 않으면 Entity가 여러개일 때 에러 발생
@Id // 기본키가 될 변수를 의미
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB의 auto_increment로 값을 저장하는 것을 의미
@Column(name = "school_id") // DB에서는 컬럼명이 school_id
int id;
private String name;
private String region;
private int ranking;
@OneToMany // 일대다 관계
@JoinColumn(name = "school_id") // Student엔티티의 school_id를 이용하여 조인
private List<Student> students;
}
package com.spring.jpa;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;
import com.spring.jpa.config.RootContext;
import com.spring.jpa.repository.SchoolRepository;
import com.spring.jpa.repository.StudentRepository;
import lombok.extern.slf4j.Slf4j;
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = RootContext.class)
@Slf4j
public class JPATest {
@Autowired
SchoolRepository schoolRepository;
@Test
@Transactional // fetch 타입이 lazy일 때 필수
public void schoolTest() {
schoolRepository.findAll();
}
}
실행을 하게 되면 다음과 같이 로그가 출력됩니다.
Hibernate:
/* select
generatedAlias0
from
School as generatedAlias0 */ select
school0_.school_id as school_i1_0_,
school0_.name as name2_0_,
school0_.ranking as ranking3_0_,
school0_.region as region4_0_
from
school school0_
지연 로딩이 되어 참조 객체에 관련된 쿼리는 보이지 않는 것을 확인할 수 있습니다.
다대일(n:1) 단방향 매핑
이번에는 여러 학생들에 한 개의 학교 값이 들어있는 n:1 단방향 매핑을 해보겠습니다.
위에서 했던 것은 진행하지 않았다고 가정하고 다시 새롭게 작성해보겠습니다.
[ 1. MySQL 테이블 / 데이터 생성 쿼리 ]
create table school (
school_id int auto_increment,
name varchar(50),
region varchar(50),
ranking int,
primary key(school_id)
);
create table student (
student_id varchar(30),
name varchar(30),
school_id int,
score int,
primary key(student_id, name)
);
insert into school(name, region, ranking) values ('강원 고등학교', '강원', 3);
insert into school(name, region, ranking) values ('서울 고등학교', '서울', 1);
insert into school(name, region, ranking) values ('제주 고등학교', '제주', 4);
insert into school(name, region, ranking) values ('경기 고등학교', '경기', 2);
insert into student values ('1234', '김고기', 3, 89);
insert into student values ('2345', '정덮밥', 1, 78);
insert into student values ('3456', '박찌개', 2, 82);
insert into student values ('4567', '문초밥', 3, 99);
insert into student values ('5678', '이족발', 4, 73);
insert into student values ('6789', '진짬뽕', 2, 85);
insert into student values ('0000', '김가네', 5, 89);
[ 2. School 엔티티 클래스 ]
package com.spring.jpa.dto;
import java.io.Serializable;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity // 영속성 컨텍스트에 의해 관리되는 클래스를 의미
@Table(name = "school") // 엔티티에 매핑되는 DB 테이블 명
public class School implements Serializable { // Serializable을 상속받지 않으면 Entity가 여러개일 때 에러 발생
@Id // 기본키가 될 변수를 의미
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB의 auto_increment로 값을 저장하는 것을 의미
@Column(name = "school_id") // DB에서는 컬럼명이 school_id
int id;
private String name;
private String region;
private int ranking;
}
[ 3. StudentID 식별자 클래스 ]
package com.spring.jpa.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class StudentID implements Serializable {
private String studentId;
private String name;
}
[ 4. Student 엔티티 클래스 ]
package com.spring.jpa.dto;
import java.io.Serializable;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "student")
@IdClass(StudentID.class)
public class Student implements Serializable {
@Id
@Column(name = "student_id")
private String studentId;
@Id
private String name;
@ManyToOne // 다대일 관계
@JoinColumn(name = "school_id") // name의 school_id = school테이블을 매핑하는 엔티티의 변수명이 id인것에 매핑
private School school;
private int score;
}
[ 5. StudentRepository 클래스 ]
package com.spring.jpa.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import com.spring.jpa.dto.Student;
import com.spring.jpa.dto.StudentID;
public interface StudentRepository extends JpaRepository<Student, StudentID> { // 제네릭 타입: <엔티티 클래스, 엔티티 클래스의 기본키>
}
[ 6. 단위 테스트 ]
package com.spring.jpa;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import com.spring.jpa.config.RootContext;
import com.spring.jpa.dto.StudentID;
import com.spring.jpa.repository.StudentRepository;
import lombok.extern.slf4j.Slf4j;
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = RootContext.class)
@Slf4j
public class JPATest {
@Autowired
StudentRepository studentRepository;
@Test
public void studentTest() {
log.info(studentRepository.findById(new StudentID("1234", "김고기")).toString());
}
}
작성된 단위 테스트 코드를 실행시키면 다음과 같은 로그가 출력이 됩니다.
Hibernate:
select
student0_.name as name1_1_0_,
student0_.student_id as student_2_1_0_,
student0_.school_id as school_i4_1_0_,
student0_.score as score3_1_0_,
school1_.school_id as school_i1_0_1_,
school1_.name as name2_0_1_,
school1_.ranking as ranking3_0_1_,
school1_.region as region4_0_1_
from
student student0_
left outer join
school school1_
on student0_.school_id=school1_.school_id
where
student0_.name=?
and student0_.student_id=?
INFO : com.spring.jpa.JPATest - Optional[Student(studentId=1234, name=김고기, school=School(id=3, name=제주 고등학교, region=제주, ranking=4), score=89)]
Inner Join vs Outer Join
위의 코드를 보시면 outer join이 되어 쿼리가 실행된 것을 확인할 수 있습니다.
그 이유는 @ManyToOne의 optional 속성의 디폴트 값이 true이기 때문입니다.
이번에도 더 자세하게 설명드려보겠습니다
@OneToOne과 @ManyToOne에는 optional이라는 속성 값을 설정할 수 있습니다.
optional에는 다음과 같은 값들이 들어갑니다.
- true
- false
true일 경우에는 null값을 허용한다는 뜻으로 outer join으로 쿼리가 실행되도록 해줍니다.
false일 경우에는 null값을 허용하지 않는다는 듯으로 inner join으로 쿼리가 실행되도록 해줍니다.
그리고 두 어노테이션 모두 optional의 속성의 디폴트 값은 true입니다.
그렇기 때문에 위의 테스트에서 outer join으로 쿼리가 만들어진 것입니다.
@ManyToOne의 optional 속성을 false로 변경한 뒤 동일한 테스트 코드를 실행해보도록 하겠습니다.
package com.spring.jpa.dto;
import java.io.Serializable;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "student")
@IdClass(StudentID.class)
public class Student implements Serializable {
@Id
@Column(name = "student_id")
private String studentId;
@Id
private String name;
@ManyToOne(optional = false) // 다대일 관계, inner join 사용
@JoinColumn(name = "school_id") // name의 school_id = school테이블을 매핑하는 엔티티의 변수명이 id인것에 매핑
private School school;
private int score;
}
테스트 코드를 실행할 경우 다음과 같은 로그가 출력이 됩니다.
Hibernate:
select
student0_.name as name1_1_0_,
student0_.student_id as student_2_1_0_,
student0_.school_id as school_i4_1_0_,
student0_.score as score3_1_0_,
school1_.school_id as school_i1_0_1_,
school1_.name as name2_0_1_,
school1_.ranking as ranking3_0_1_,
school1_.region as region4_0_1_
from
student student0_
inner join
school school1_
on student0_.school_id=school1_.school_id
where
student0_.name=?
and student0_.student_id=?
INFO : com.spring.jpa.JPATest - Optional[Student(studentId=1234, name=김고기, school=School(id=3, name=제주 고등학교, region=제주, ranking=4), score=89)]
여기서 한 가지 더 아셔야 될 점은 join을 선택할 수 있는 방법이 또 있다는 것입니다.
@JoinColumn 어노테이션에도 nullable이라는 속성 값이 존재하는데 optional과 모든 것이 동일합니다.
@JoinColumn(name = "school_id", nullable = true) // outer join
@JoinColumn(name = "school_id", nullable = false) // inner join
optional과 nullable 중 하나라도 값이 false인 경우에는 inner join으로 쿼리가 만들어지고 둘 다 true일 경우에만 outer join으로 조회가 됩니다.
@NotFound
제가 테스트를 하던 도중 궁금증이 발생했었습니다.
outer join으로 조회한다는 것은 데이터가 없을 경우 null값으로 전달받는다는 것을 의미하기 때문에 실제로 데이터가 없을 경우 null값이 리턴되는지 확인해보는 테스트를 추가 진행했습니다.
[ 1. MySQL에 school 테이블에 존재하지 않는 school_id값으로 데이터 생성 ]
insert into student values ('0000', '김가네', 5, 89);
[ 2. Student 엔티티 클래스 (optional 원복) ]
package com.spring.jpa.dto;
import java.io.Serializable;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "student")
@IdClass(StudentID.class)
public class Student implements Serializable {
@Id
@Column(name = "student_id")
private String studentId;
@Id
private String name;
@ManyToOne // 다대일 관계
@JoinColumn(name = "school_id") // name의 school_id = school테이블을 매핑하는 엔티티의 변수명이 id인것에 매핑
private School school;
private int score;
}
[ 3. 단위 테스트 ]
package com.spring.jpa;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import com.spring.jpa.config.RootContext;
import com.spring.jpa.dto.StudentID;
import com.spring.jpa.repository.StudentRepository;
import lombok.extern.slf4j.Slf4j;
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = RootContext.class)
@Slf4j
public class JPATest {
@Autowired
StudentRepository studentRepository;
@Test
public void studentTest() {
log.info(studentRepository.findById(new StudentID("0000", "김가네")).toString());
}
}
위의 코드를 실행하게 된다면 제가 예상한 결과가 나와야 했습니다.
Hibernate:
select
student0_.name as name1_1_0_,
student0_.student_id as student_2_1_0_,
student0_.school_id as school_i4_1_0_,
student0_.score as score3_1_0_,
school1_.school_id as school_i1_0_1_,
school1_.name as name2_0_1_,
school1_.ranking as ranking3_0_1_,
school1_.region as region4_0_1_
from
student student0_
left outer join
school school1_
on student0_.school_id=school1_.school_id
where
student0_.name=?
and student0_.student_id=?
Hibernate:
select
school0_.school_id as school_i1_0_0_,
school0_.name as name2_0_0_,
school0_.ranking as ranking3_0_0_,
school0_.region as region4_0_0_
from
school school0_
where
school0_.school_id=?
INFO : com.spring.jpa.JPATest - Optional.empty
하지만 저의 예상과 다르게 결과값이 Optional.empty값이 나온 것을 확인할 수 있었습니다.
이런 상황에서 제가 원하는 결과를 만들기 위해 사용된 것이 @NotFound 어노테이션입니다.
@NotFound 어노테이션의 action 속성 값에는 다음과 같은 값들을 사용할 수 있습니다.
- NotFoundAction.EXCEPTION
- NotFoundAction.IGNORE
EXCEPTION은 값이 발견되지 않을 때 예외처리를 해버리는 것이고 IGNORE은 무시해버리는 속성 값들입니다.
위의 결과가 Optional.empty가 나온 이유는 기본적으로 NotFoundAction값이 EXCEPTION으로 되어 있어 예외처리가 되었기 때문입니다.
연관관계 매핑이 되는 school변수 값을 IGNORE로 수정하여 다시 테스트를 진행해보겠습니다.
package com.spring.jpa.dto;
import java.io.Serializable;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import org.hibernate.annotations.NotFound;
import org.hibernate.annotations.NotFoundAction;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "student")
@IdClass(StudentID.class)
public class Student implements Serializable {
@Id
@Column(name = "student_id")
private String studentId;
@Id
private String name;
@ManyToOne // 다대일 관계
@JoinColumn(name = "school_id") // name의 school_id = school테이블을 매핑하는 엔티티의 변수명이 id인것에 매핑
@NotFound(action = NotFoundAction.IGNORE) // 값이 발견되지 않으면 무시
private School school;
private int score;
}
다시 동일한 테스트 코드를 실행시키면 다음과 같은 로그가 출력이 됩니다.
Hibernate:
select
student0_.name as name1_1_0_,
student0_.student_id as student_2_1_0_,
student0_.school_id as school_i4_1_0_,
student0_.score as score3_1_0_,
school1_.school_id as school_i1_0_1_,
school1_.name as name2_0_1_,
school1_.ranking as ranking3_0_1_,
school1_.region as region4_0_1_
from
student student0_
left outer join
school school1_
on student0_.school_id=school1_.school_id
where
student0_.name=?
and student0_.student_id=?
Hibernate:
select
school0_.school_id as school_i1_0_0_,
school0_.name as name2_0_0_,
school0_.ranking as ranking3_0_0_,
school0_.region as region4_0_0_
from
school school0_
where
school0_.school_id=?
INFO : com.spring.jpa.JPATest - Optional[Student(studentId=0000, name=김가네, school=null, score=89)]
실제 DB에서 동작되는 것이기도 하며 제가 생각했던 대로 결과가 나오는 것을 확인할 수 있었습니다.
참조
[JPA] 즉시 로딩과 지연 로딩(FetchType.LAZY or EAGER)
JPA 사용시 테스트 코드에서 @Transactional 주의하기
이상으로 JPA 연관관계 매핑(단방향)에 대해 간단하지 않게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'Spring > JPA' 카테고리의 다른 글
[JPA] @Query, 직접 쿼리 작성 (1) | 2021.03.29 |
---|---|
[JPA] 연관관계 매핑 (양방향) (0) | 2021.03.28 |
[JPA] 복합키(Composite Key) 엔티티 (2) | 2021.03.24 |
[JPA] 조건절(where) 메서드 (0) | 2021.03.23 |
[JPA] JPA Repository설정 및 CRUD (0) | 2021.03.22 |
댓글