블로그 이름 뭐하지
[Spring] Entity 연관 관계 본문
DB 테이블과 Entity의 차이
▶ DB 테이블


두 개의 테이블이 있다고 했을 때, 주문 정보는 어디에 들어가야할까.
- 고객 테이블의 경우
한명의 고객이 여러 개의 음식을 주문할 수 있다 (1 : N)
주문한 음식의 정보를 파악하기 위해 food_id 컬럼을 추가하면, 불필요하게 고객의 이름이 중복되는 문제가 발생한다.

- 음식 테이블의 경우
하나의 음식이 여러 명의 고객에게 주문될 수 있다(1 : N)
주문한 고객의 정보를 파악하기 위해 user_id 컬럼을 추가하면 고객 테이블과 같은 문제가 발생한다.
그렇다고 user_id 컬럼에 주문한 모든 고객의 정보를 넣으면 조회 시 많은 문제가 발생하므로 불가능하다.


이럴 때는 주문 테이블을 따로 추가하면 된다.

고객 한 명은 음식 N개를 주문 할 수 있고, 음식 하나는 고객 N명에게 주문 될 수 있으니,
결론적으로 고객과 음식은 N : M 관계이다.
이런 관계에서는 주문 테이블처럼 중간 테이블을 사용하는 것이 좋다.
DB 테이블의 방향
방향에는 단방향, 양방향이 있다.
단방향은 고객 테이블에서만 음식 테이블을 참조할 수 있을 때를 의미하고,
양방향은 두 테이블 모두에서 서로를 참조할 수 있을 때를 의미한다.
하지만 DB 테이블에서는 어느 테이블 기준으로도 JOIN을 통해 원하는 정보를 조회할 수 있다.
즉, DB 테이블에는 방향이 존재하지 않는다.
// 고객 테이블에서 음식 정보를 고객 테이블 기준으로 조회
SELECT u.name as username, f.name as foodname, o.order_date as orderdate
FROM users u
INNER JOIN orders o on u.id = o.user_id
INNER JOIN food f on o.food_id = f.id
WHERE o.user_id = 1;
// 음식 테이블에서 고객 정보를 음식 테이블 기준으로 조회
SELECT u.name as username, f.name as foodname, o.order_date as orderdate
FROM food f
INNER JOIN orders o on f.id = o.food_id
INNER JOIN users u on o.user_id = u.id
WHERE o.user_id = 1;
▶ Entity
음식과 고객의 관계가 N : 1 이라고 가정하고 양방향 관계로 표현해보자
//음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
//고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Food> foodList = new ArrayList<>();
}
한 명의 고객은 여러 번 주문이 가능하다.
Entity에서는 여러 번 가능함을 컬렉션을 이용해 List<Food> foodList = new ArrayList<>() 표현한다
DB에서는 JOIN으로 조회가 가능하지만, 고객 Entity 입장에서는 음식 Entity의 정보가 없으면 조회할 수 없기 때문이다.
해당 코드에서는 음식 Entity와 고객 Entity 모두가 서로를 참조하고 있다.
그럼 단 방향 관계일 때는 어떨까.
//음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
//고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
음식 Entity에서만 고객 Entity를 참조한다.
고객 Entity에는 음식 Entity의 정보가 없어 음식 정보를 조회할 수 없다.
DB 테이블에서는 테이블 사이의 연관관계를 FK(외래키)로 맺을 수 있고, 방향 상관없이 조회가 가능하다
Entity에서는 상대 Entity를 참조해서 Entity간의 연관 관계를 맺을 수 있고,
참조 없이는 상대 Entity를 조회할 수 없으므로 DB 테이블에 없는 방향의 개념이 존재하게 된다.
1 대 1 관계
@OneToOne으로 표기한다.
여행자와 여권, 회원과 사물함 같이 양쪽이 서로 하나의 관계만을 가지는 상태를 의미한다.
이 관계에서 외래키의 주인(외래키를 가지는 Entity)은 양쪽 모두 가능하다.
외래키의 주인 만이 외래키를 등록, 수정, 삭제 할 수 있고 주인이 아닌 쪽은 읽기만 가능하다.
1대 1 관계에서는 주 테이블과 대상 테이블을 나누어 외래키를 가지는 쪽을 선호하는 방향이 다르므로
자신이 원하는 방향대로 외래키를 쥐어주면 된다.
- 주 테이블 : 관계의 주체가 될 수 있는 대상(ex. 여행자, 회원)
객체지향 개발자들이 선호(bc. 대상 테이블을 객체 참조와 비슷하게 사용이 가능) - 대상 테이블 : 관계에서 주체에 의해 관계가 맺어지는 대상(ex. 여권, 사물함)
데이터베이스 개발자들이 선호(bc. 테이블 관계를 1:N 으로 변경할 때 구조 유지 가능)
▶단방향 관계
@JoinColumn() 으로 외래키의 주인을 정한다.
해당 어노테이션에서는 컬럼명, null 여부, unique 여부 등을 지정할 수 있다.
@Entity
public class Passport {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String country;
@OneToOne
// 해당 코드에서 외래키의 주인은 Passport이다.
// name으로 외래키가 들어갈 컬럼 명을 지정할 수 있다.
@JoinColumn(name = "person_id")
private Person person; // 참조할 상대 Entity를 들고온다.
}
@Entity
public class Person {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
@Test
// 테스트에서는 @Transactional 에 의해 자동 rollback 됨으로 false 설정해준다.
@Rollback(value = false)
@DisplayName("1대1 단방향 테스트")
void test1() {
Person person = new Person();
person.setName("홍길동");
// 외래 키의 주인인 Passport Entity person 필드에 person 객체를 추가해 줍니다.
Passport passport = new Passport();
passport.setCountry("한국");
passport.setPerson(person); // 외래 키(연관 관계) 설정
personRepository.save(person);
passportRepository.save(passport);
}
▶양방향 관계
@JoinColumn() 으로 외래키의 주인을 정하고, mappedBy 옵션을 사용해 양방향임을 나타낸다.
@Entity
public class Passport {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String country;
@OneToOne
// 해당 코드에서 외래키의 주인은 Passport이다.
// name으로 외래키가 들어갈 컬럼 명을 지정할 수 있다.
@JoinColumn(name = "person_id")
private Person person; // 참조할 상대 Entity를 들고온다.
}
@Entity
public class Person {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// mappedBy의 속성은 상대 Entity가 외래키로 참조한 필드명이다.
// @JoinColumn(name = "person_id")
// private Person person; << 이 부분을 가져온 것.
@OneToOne(mappedBy = "person")
private Passport passport; // 참조할 상대 Entity를 들고온다.
public void addPassport(Passport passport) {
this.passport = passport;
passport.setPerson(this);
}
}
@Test
@Rollback(value = false)
@DisplayName("1대1 양방향 테스트 : 외래 키 저장 실패")
void test2() {
Passport passport = new Passport();
passport.setContury("한국");
// 외래 키의 주인이 아닌 Person 에서 Passport 를 저장해보겠습니다.
Person person = new Person();
person.setName("홍길동");
person.setPassport(passport);
personRepository.save(person);
passportRepository.save(passport);
// 확인해 보시면 person_id 값이 들어가 있지 않은 것을 확인하실 수 있습니다.
}
@Test
@Rollback(value = false)
@DisplayName("1대1 양방향 테스트 : 외래 키 저장 실패 -> 성공")
void test3() {
Passport passport = new Passport();
passport.setCountry("한국");
// 외래 키의 주인이 아닌 person 에서 Passport 를 저장하기 위해 addPassport() 메서드 추가
// 외래 키(연관 관계) 설정 passport.setPerson(this); 추가
Person person = new Person();
person.setName("홍길동");
person.addPassport(passport);
personRepository.save(person);
passportRepository.save(passport);
}
@Test
@Rollback(value = false)
@DisplayName("1대1 양방향 테스트")
void test4() {
Person person = new Person();
person.setName("홍길동");
Passport passport = new Passport();
passport.setCountry("한국");
Passport.setPerson(person); // 외래 키(연관 관계) 설정
personRepository.save(person);
passportRepository.save(passport);
}
▶ 조회 테스트
@Test
@DisplayName("1대1 조회 : Passport 기준(외래키주인) Person 정보 조회")
void test5() {
Passport passport = passportRepository.findById(1L).orElseThrow(NullPointerException::new);
// 여권 정보 조회
System.out.println("passport.getCountry() = " + passport.getCountry());
// 여권을 가진 사람 정보 조회
System.out.println("passport.getPerson().getName() = " + passport.getPerson().getName());
}
@Test
@DisplayName("1대1 조회 : Person 기준 Passport 정보 조회")
void test6() {
Person person = personRepository.findById(1L).orElseThrow(NullPointerException::new);
// 사람 정보 조회
System.out.println("person.getName() = " + person.getName());
// 해당 인물이 주문한 여권 정보 조회
Passport passport = person.getPassport();
System.out.println("passport.getCountry() = " + passport.getCountry());
}
N 대 1 관계
@ManyToOne으로 표기한다.
학생과 학교, 게시글과 게시판 같이 한 쪽이 여러 관계를 가지는 상태를 의미한다.
이 관계에서 외래키의 주인은 N 이다.
만약 1인 학교 측에서 외래키를 가지고 있다고 가정해보자.
밑의 도표와 같이 여러 명의 학생을 가지게 되어 학교의 이름이 중복되게 된다.
id | school_name | student_id |
1 | a | 1 |
2 | a | 2 |
3 | a | 3 |
반대로 N인 학생 측에서 외래키를 가지면 어떨까.
학생의 이름이 중복되지 않는 것을 확인할 수 있다.
그러니 N:1 이든 1:N이든 외래키의 주인은 N이어야 한다.
id | student_name | school_id |
1 | a | 1 |
2 | b | 1 |
3 | c | 1 |
▶단방향 관계
@Entity
@Table(name = "student")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "school_id")
private School school;
}
@Entity
@Table(name = "schools")
public class School {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
@Test
@Rollback(value = false)
@DisplayName("N대1 단방향 테스트")
void test1() {
School school = new School();
school.setName("A");
Student student = new Student();
student.setName("홍길동");
student.setSchool(school); // 외래 키(연관 관계) 설정
Student student2 = new Student();
student2.setName("김인간");
student2.setSchool(school);
schoolRepository.save(school);
studentRepository.save(student);
studentRepository.save(student2);
}
▶양방향 관계
@Entity
@Table(name = "student")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "school_id")
private School school;
}
@Entity
@Table(name = "schools")
public class School {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// 학교는 1의 상황이므로 @OneToMany를 사용한다.
// 양방향의 참조를 위해 컬렉션을 사용해 상대 Entity를 가져온다
@OneToMany(mappedBy = "school")
private List<Student> studentList = new ArrayList<>();
public void addStudentList(Student student){
this.studentList.add(student);
student.setSchool(this); // 외래 키(연관관계 설정)
}
}
@Test
@Rollback(value = false)
@DisplayName("N대1 양방향 테스트 : 외래 키 저장 실패")
void test2() {
Student student = new Student();
student.setName("홍길동");
Student student2 = new Student();
student2.setName("김인간");
// 외래 키의 주인이 아닌 School 에서 Student 를 저장해보겠습니다.
School school = new School();
school.setName("A");
school.getStudentList().add(student);
school.getStudentList().add(student2);
schoolRepository.save(school);
studentRepository.save(student);
studentRepository.save(student2);
// 확인해 보시면 student_id 값이 들어가 있지 않은 것을 확인하실 수 있습니다.
}
@Test
@Rollback(value = false)
@DisplayName("N대1 양방향 테스트 : 외래 키 저장 실패 -> 성공")
void test3() {
Student student = new Student();
student.setName("홍길동");
Student student2 = new Student();
student2.setName("김인간");
// 외래 키의 주인이 아닌 School 에서 Student 를 쉽게 저장하기 위해 addStudentList() 메서드 생성하고
// 해당 메서드에 외래 키(연관 관계) 설정 food.setUser(this); 추가
School school = new School();
school.setName("A");
school.addStudentList(student);
school.addStudentList(student2);
schoolRepository.save(school);
studentRepository.save(student);
studentRepository.save(student2);
}
@Test
@Rollback(value = false)
@DisplayName("N대1 양방향 테스트")
void test4() {
School school = new School();
school.setName("A");
Student student = new Student();
student.setName("홍길동");
student.setSchool(school); // 외래 키(연관 관계) 설정
Student student2 = new Student();
student2.setName("김인간");
student2.setSchool(school); // 외래 키(연관 관계) 설정
schoolRepository.save(school);
studentRepository.save(student);
studentRepository.save(student2);
}
▶ 조회 테스트
@Test
@DisplayName("N대1 조회 : Student 기준 School 정보 조회")
void test5() {
Student student = studentRepository.findById(1L).orElseThrow(NullPointerException::new);
// 학생 정보 조회
System.out.println("student.getName() = " + student.getName());
// 학생이 입학한 학교 정보 조회
System.out.println("student.getSchool().getName() = " + student.getSchool().getName());
}
@Test
@DisplayName("N대1 조회 : School 기준 Student 정보 조회")
void test6() {
School school = schoolRepository.findById(1L).orElseThrow(NullPointerException::new);
// 학교 정보 조회
System.out.println("school.getName() = " + school.getName());
// 해당 학교에 입학한 학생 정보 조회
List<Student> studentList = school.getStudentist();
for (Student student : studentList) {
System.out.println("student.getName() = " + student.getName());
}
}
1 대 N 관계
@OneToMany으로 표기한다.
N 대 1 관계처럼 한쪽이 여러 관계를 가질 때 사용하는데,
다른 점은 N이 여전히 외래키를 가지고 있지만, 외래키의 주인은 1이라는 것이다.
▶ 단방향 관계
예시 코드는 food가 1, user가 n일 때 1 : N의 관계를 Entity로 나타낸 것이다.
이렇게 처리하면 Insert를 한 번에 처리할 수 있지만 외래키를 user가 가지고 있기 때문에,
추가적인 update가 발생할 수 있다.
보통은 다쪽이 외래키를 소유하고 관리하는 형태를 선호하기 때문에
해당 형태의 1 : N 관계는 권장되지 않는다.
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
//user 테이블에 food_id 컬럼이 들어간다(외래키는 user에 있음)
//하지만 food Entity에서 외래키를 관리함
@OneToMany
@JoinColumn(name = "food_id")
private List<User> userList = new ArrayList<>();
}
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
@Test
@Rollback(value = false)
@DisplayName("1대N 단방향 테스트")
void test1() {
User user = new User();
user.setName("Robbie");
User user2 = new User();
user2.setName("Robbert");
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
food.getUserList().add(user); // 외래 키(연관 관계) 설정
food.getUserList().add(user2); // 외래 키(연관 관계) 설정
userRepository.save(user);
userRepository.save(user2);
foodRepository.save(food);
// 추가적인 UPDATE 쿼리 발생을 확인할 수 있습니다.
}
▶ 양방향 관계
1 : N 관계에서는 일반적으로 양방향 관계가 존재하지 않는다.
food Entity를 외래키의 주인으로 정하기 위해 user Entity에서 mappedBy 옵션을 사용해야 하지만,
@ManyToOne 에서는 mappedBy 속성을 제공하지 않기 때문이다.
아래 코드처럼 N 쪽의 user Entity에서 @JoinColumn의 insertable과 updateble을 false로 설정해
양쪽으로 join 설정을 하면 양방향처럼 설정할 수는 있으나 이 또한 권장되지 않는 방식이다.
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "food_id", insertable = false, updatable = false)
private Food food;
}
▶ 조회 테스트
@Test
@DisplayName("1대N 조회 테스트")
void test2() {
Food food = foodRepository.findById(1L).orElseThrow(NullPointerException::new);
System.out.println("food.getName() = " + food.getName());
// 해당 음식을 주문한 고객 정보 조회
List<User> userList = food.getUserList();
for (User user : userList) {
System.out.println("user.getName() = " + user.getName());
}
}
N 대 M 관계
@ManyToMany로 표기한다.
음식과 고객, 학생과 수업 같이 양쪽이 여러 관계를 맺는 상태를 의미한다.
▶ 단방향 관계
N : M 관계를 풀기 위해 중간 테이블을 생성하여 사용한다.
해당 코드는 Food Entity가 외래키의 주인일 때를 가정하고 작성한 것이다.
생성되는 중간 테이블을 컨트롤하기 어려워 차후에 중간 테이블이 변경될 경우 문제가 발생할 수 있다.
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToMany
@JoinTable(name = "orders", // 중간 테이블 생성
// 현재 위치인 Food Entity 에서 중간 테이블로 조인할 컬럼 설정
joinColumns = @JoinColumn(name = "food_id"),
// 반대 위치인 User Entity 에서 중간 테이블로 조인할 컬럼 설정
inverseJoinColumns = @JoinColumn(name = "user_id"))
private List<User> userList = new ArrayList<>();
}
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
@Test
@Rollback(value = false)
@DisplayName("N대M 단방향 테스트")
void test1() {
User user = new User();
user.setName("Robbie");
User user2 = new User();
user2.setName("Robbert");
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
food.getUserList().add(user);
food.getUserList().add(user2);
userRepository.save(user);
userRepository.save(user2);
foodRepository.save(food);
// 자동으로 중간 테이블 orders 가 create 되고 insert 됨을 확인할 수 있습니다.
}
▶ 양방향 관계
해당 코드는 Food Entity가 외래키의 주인일 때를 가정하고 작성한 것이다.
반대 방향인 User Entity에게 @ManyToMany로 음식 Entity를 연결하고 mappedBy 옵션을 설정해
외래키의 주인을 설정하면 양방향 관계설정이 가능하다
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToMany
@JoinTable(name = "orders", // 중간 테이블 생성
// 현재 위치인 Food Entity 에서 중간 테이블로 조인할 컬럼 설정
// 반대 위치인 User Entity 에서 중간 테이블로 조인할 컬럼 설정
joinColumns = @JoinColumn(name = "food_id"),
inverseJoinColumns = @JoinColumn(name = "user_id"))
private List<User> userList = new ArrayList<>();
}
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(mappedBy = "userList")
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.getUserList().add(this); // 외래 키(연관 관계) 설정
}
}
@Test
@Rollback(value = false)
@DisplayName("N대M 양방향 테스트 : 외래 키 저장 실패")
void test2() {
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
Food food2 = new Food();
food2.setName("양념 치킨");
food2.setPrice(20000);
// 외래 키의 주인이 아닌 User 에서 Food 를 저장해보겠습니다.
User user = new User();
user.setName("Robbie");
user.getFoodList().add(food);
user.getFoodList().add(food2);
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
// orders 테이블에 food_id, user_id 값이 들어가 있지 않은 것을 확인하실 수 있습니다.
}
@Test
@Rollback(value = false)
@DisplayName("N대M 양방향 테스트 : 외래 키 저장 실패 -> 성공")
void test3() {
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
Food food2 = new Food();
food2.setName("양념 치킨");
food2.setPrice(20000);
// 외래 키의 주인이 아닌 User 에서 Food 를 쉽게 저장하기 위해 addFoodList() 메서드를 생성해서 사용합니다.
// 외래 키(연관 관계) 설정을 위해 Food 에서 userList 를 호출해 user 객체 자신을 add 합니다.
User user = new User();
user.setName("Robbie");
user.addFoodList(food);
user.addFoodList(food2);
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
}
@Test
@Rollback(value = false)
@DisplayName("N대M 양방향 테스트")
void test4() {
User user = new User();
user.setName("Robbie");
User user2 = new User();
user2.setName("Robbert");
Food food = new Food();
food.setName("아보카도 피자");
food.setPrice(50000);
food.getUserList().add(user); // 외래 키(연관 관계) 설정
food.getUserList().add(user2); // 외래 키(연관 관계) 설정
Food food2 = new Food();
food2.setName("고구마 피자");
food2.setPrice(30000);
food2.getUserList().add(user); // 외래 키(연관 관계) 설정
userRepository.save(user);
userRepository.save(user2);
foodRepository.save(food);
foodRepository.save(food2);
// User 를 통해 food 의 정보 조회
System.out.println("user.getName() = " + user.getName());
List<Food> foodList = user.getFoodList();
for (Food f : foodList) {
System.out.println("f.getName() = " + f.getName());
System.out.println("f.getPrice() = " + f.getPrice());
}
// 외래 키의 주인이 아닌 User 객체에 Food 의 정보를 넣어주지 않아도 DB 저장에는 문제가 없지만
// 이처럼 User 를 사용하여 food 의 정보를 조회할 수는 없습니다.
}
@Test
@Rollback(value = false)
@DisplayName("N대M 양방향 테스트 : 객체와 양방향의 장점 활용")
void test5() {
User user = new User();
user.setName("Robbie");
User user2 = new User();
user2.setName("Robbert");
// addUserList() 메서드를 생성해 user 정보를 추가하고
// 해당 메서드에 객체 활용을 위해 user 객체에 food 정보를 추가하는 코드를 추가합니다.
// user.getFoodList().add(this);
Food food = new Food();
food.setName("아보카도 피자");
food.setPrice(50000);
food.addUserList(user);
food.addUserList(user2);
Food food2 = new Food();
food2.setName("고구마 피자");
food2.setPrice(30000);
food2.addUserList(user);
userRepository.save(user);
userRepository.save(user2);
foodRepository.save(food);
foodRepository.save(food2);
// User 를 통해 food 의 정보 조회
System.out.println("user.getName() = " + user.getName());
List<Food> foodList = user.getFoodList();
for (Food f : foodList) {
System.out.println("f.getName() = " + f.getName());
System.out.println("f.getPrice() = " + f.getPrice());
}
}
▶ 조회 테스트
@Test
@DisplayName("N대M 조회 : Food 기준 user 정보 조회")
void test6() {
Food food = foodRepository.findById(1L).orElseThrow(NullPointerException::new);
// 음식 정보 조회
System.out.println("food.getName() = " + food.getName());
// 음식을 주문한 고객 정보 조회
List<User> userList = food.getUserList();
for (User user : userList) {
System.out.println("user.getName() = " + user.getName());
}
}
@Test
@DisplayName("N대M 조회 : User 기준 food 정보 조회")
void test7() {
User user = userRepository.findById(1L).orElseThrow(NullPointerException::new);
// 고객 정보 조회
System.out.println("user.getName() = " + user.getName());
// 해당 고객이 주문한 음식 정보 조회
List<Food> foodList = user.getFoodList();
for (Food food : foodList) {
System.out.println("food.getName() = " + food.getName());
System.out.println("food.getPrice() = " + food.getPrice());
}
}
▶ 중간 테이블을 생성하여 해결
테이블을 직접 생성하여 관리하면 변경 발생시 컨트롤이 쉽고, 확장에 용이하다.
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@OneToMany(mappedBy = "food")
private List<Order> orderList = new ArrayList<>();
}
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Order> orderList = new ArrayList<>();
}
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "food_id")
private Food food;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
@Test
@Rollback(value = false)
@DisplayName("중간 테이블 Order Entity 테스트")
void test1() {
User user = new User();
user.setName("Robbie");
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
// 주문 저장
Order order = new Order();
order.setUser(user); // 외래 키(연관 관계) 설정
order.setFood(food); // 외래 키(연관 관계) 설정
userRepository.save(user);
foodRepository.save(food);
orderRepository.save(order);
}
@Test
@DisplayName("중간 테이블 Order Entity 조회")
void test2() {
// 1번 주문 조회
Order order = orderRepository.findById(1L).orElseThrow(NullPointerException::new);
// order 객체를 사용하여 고객 정보 조회
User user = order.getUser();
System.out.println("user.getName() = " + user.getName());
// order 객체를 사용하여 음식 정보 조회
Food food = order.getFood();
System.out.println("food.getName() = " + food.getName());
System.out.println("food.getPrice() = " + food.getPrice());
}
즉시로딩과 지연로딩
JPA에서는 데이터를 조회할 때, 즉시로딩(Eager)과 지연로딩(Lazy)을 사용한다.
즉시로딩은 데이터를 조회할 때 연관된 데이터를 한 번에 불러오는 것이고,
지연로딩은 필요한 시점에 연관된 데이터를 불러오는 것이다.
여기서 연관된 데이터라는 것은 Entity의 연관관계를 말하는 것으로,
Member가 Team과 연관관계를 맺고 있다면 Member를 조회할 때 Team도 조회할 수 있다.
Fetch Type
JPA가 Entity를 조회할 때, 연관관계에 있는 객체들을 어떻게 가져올지 정하는 설정 값이다.
One으로 끝나는 연관관계에서는 Entity 정보가 하나만 들어오기 때문에
즉시 정보를 가져와도 문제가 없어 디폴트 값이 EAGER이다.
Many로 끝나는 연관관계에서는 Entity 정보를 여러개 가져오기 때문에
효율적으로 정보를 조회하기 위해 디폴트 값이 LAZY이다.
연관관계 어노테이션 옆에 (fetch= fetchType.EAGER) 또는 LAZY를 붙여 나타낸다.
▶ 즉시로딩(Eager)
@SpringBootTest
public class FetchTypeTest {
@Autowired
UserRepository userRepository;
@Autowired
FoodRepository foodRepository;
@Test
@Transactional
@Rollback(value = false)
void init() {
List<User> userList = new ArrayList<>();
User user1 = new User();
user1.setName("Robbie");
userList.add(user1);
User user2 = new User();
user2.setName("Robbert");
userList.add(user2);
userRepository.saveAll(userList);
List<Food> foodList = new ArrayList<>();
Food food1 = new Food();
food1.setName("고구마 피자");
food1.setPrice(30000);
food1.setUser(user1); // 외래 키(연관 관계) 설정
foodList.add(food1);
Food food2 = new Food();
food2.setName("아보카도 피자");
food2.setPrice(50000);
food2.setUser(user1); // 외래 키(연관 관계) 설정
foodList.add(food2);
}
@Test
@DisplayName("아보카도 피자 조회")
void test1() {
Food food = foodRepository.findById(2L).orElseThrow(NullPointerException::new);
System.out.println("food.getName() = " + food.getName());
System.out.println("food.getPrice() = " + food.getPrice());
System.out.println("아보카도 피자를 주문한 회원 정보 조회");
System.out.println("food.getUser().getName() = " + food.getUser().getName());
}

Food와 User가 N : 1 양방향 관계를 맺고 있다는
가정 하에 짜여진 코드이다.
실행된 SQL문을 보면 Food Entity를 조회했는데,
그와 함께 User Entity의 정보도 가져오고 있다.
N : 1 의 디폴트 값이 Eager로 설정되어 있기 때문이다.
@Test
@Transactional
@DisplayName("Robbie 고객 조회")
void test2() {
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
System.out.println("Robbie가 주문한 음식 이름 조회");
for (Food food : user.getFoodList()) {
System.out.println(food.getName());
}
}

반대로 User는 1 : N 양방향 관계를 맺고 있어,
User를 조회할 때는 User만 조회되고,
관련된 FoodList를 조회할 때, Food가 조회되는 것을 볼 수 있다.
1 : N 의 디폴트 값이 Lazy로 설정되어 있기 때문이다.
영속성 컨텍스트와 지연로딩
지연로딩 또한 1차 캐시, 쓰기 지연 저장소, 변경 감지와 함께 영속성 컨텍스트의 기능 중 하나이다.
따라서 지연로딩된 Entity의 정보를 조회할 때는 반드시 영속성 컨텍스트가 존재해야하며,
그 말은 트랜잭션이 적용되어 있어야 한다는 뜻이다.
@Test
//@Transactional >> Transactional 주석화
@DisplayName("Robbie 고객 조회")
void test2() {
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
System.out.println("Robbie가 주문한 음식 이름 조회");
for (Food food : user.getFoodList()) {
System.out.println(food.getName());
}
}

영속성 전이
영속 상태의 Entity에서 수행되는 작업들이 연관된 Entity까지 전파되는 상황.
고객 Entity를 저장할 때, 그에 연관된 음식 Entity를 함께 저장해 주문을 완료하거나,
게시글 Entity를 삭제할 때, 그에 연관된 댓글 Entity를 함께 삭제하는 등의 역할을 수행한다.
영속성 전이가 필요한 이유
// User Entity
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.setUser(this);// 외래 키(연관 관계) 설정
}
}
// 주문 test
@Test
@DisplayName("Robbie 음식 주문")
void test1() {
// 고객 Robbie 가 후라이드 치킨과 양념 치킨을 주문합니다.
User user = new User();
user.setName("Robbie");
// 후라이드 치킨 주문
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
user.addFoodList(food);
Food food2 = new Food();
food2.setName("양념 치킨");
food2.setPrice(20000);
user.addFoodList(food2);
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
}
음식과 고객이 N : 1 양방향 관계인 Entity를 만들었다고 가정해보자.
위의 코드와 같이 고객 하나가 음식 두 개를 주문하는 경우
user, food, food2 모두를 직접 save() 해서 저장해야한다.
영속성 전이를 사용하면 user 하나만 저장해도 나머지 food 들은 영속성 전이를 통해 자동으로 저장된다.
// User Entity
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST) //영속성 전이 추가
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.setUser(this);// 외래 키(연관 관계) 설정
}
}
// 주문 test
@Test
@DisplayName("영속성 전이 저장")
void test2() {
// 고객 Robbie 가 후라이드 치킨과 양념 치킨을 주문합니다.
User user = new User();
user.setName("Robbie");
// 후라이드 치킨 주문
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
user.addFoodList(food);
Food food2 = new Food();
food2.setName("양념 치킨");
food2.setPrice(20000);
user.addFoodList(food2);
userRepository.save(user);
}
CascadeType.PERSIST 옵션을 사용한 이후에는 직접 food를 영속 상태로 만들지 않아도,
user를 저장하는 것으로 영속화 시킬 수 있다.
@Test
@Transactional
@Rollback(value = false)
@DisplayName("Robbie 탈퇴")
void test3() {
// 고객 Robbie 를 조회합니다.
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
// Robbie 가 주문한 음식 조회
for (Food food : user.getFoodList()) {
System.out.println("food.getName() = " + food.getName());
}
// 주문한 음식 데이터 삭제
foodRepository.deleteAll(user.getFoodList());
// Robbie 탈퇴
userRepository.delete(user);
}
삭제도 마찬가지이다. 원래는 위의 코드처럼 음식 데이터와 user 데이터를 한 번에 삭제해야한다.
@Test
@Transactional
@Rollback(value = false)
@DisplayName("영속성 전이 삭제")
void test4() {
// 고객 Robbie 를 조회합니다.
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
// Robbie 가 주문한 음식 조회
for (Food food : user.getFoodList()) {
System.out.println("food.getName() = " + food.getName());
}
// Robbie 탈퇴
userRepository.delete(user);
}
User Entity의 Food 연관관계에 CascadeType.Remove를 붙이면,
user만 삭제해도 그와 연관된 food도 삭제할 수 있다.
CascadeType
영속성 전이를 사용할 수 있게 만들어 주는 옵션이다.
연관관계를 매핑하는 것과는 관련이 없으며, 하나의 부모에게 종속하는 경우에만 사용하는 것이 좋다.
CascadeType.ALL | 모든 옵션을 적용 |
CascadeType.PERSIST | 엔티티를 영속화할 때, 연관된 엔티티도 함께 영속화(저장) |
CascadeType.REMOVE | 엔티티를 제거할 때, 연관된 엔티티도 함께 제거 |
CascadeType.MERGE | 엔티티를 병합할 때, 연관된 엔티티도 함께 병합 |
CascadeType.REFRESH | 엔티티를 새로고침할 때, 연관된 엔티티도 함께 새로고침 |
CascadeType.DETACH | 엔티티를 Detach(영속성 컨텍스트에서 엔티티 제거) 할 때, 연관된 엔티티도 함께 Detach |
고아 Entity 삭제
고아 Entity란 부모 Entity에서 제거되거나 관계가 끊어진 자식 Entity를 뜻한다.
예를 들어 게시물에서 삭제된 댓글이나, 특정 고객의 주문 창에서 삭제된 주문 같은 것들이 고아 Entity이다.
orphanRemoval 라는 속성을 사용하며 true일 경우 고아 Entity 삭제가 활성화된다.
아래와 같은 코드일 경우 User Entity의 Food 연관관계에 orphanRemoval = true 옵션을 달아주면
연관관계를 제거하는 것만으로도 Food Entity를 삭제할 수 있다.
@SpringBootTest
public class OrphanTest {
@Autowired
UserRepository userRepository;
@Autowired
FoodRepository foodRepository;
@Test
@Transactional
@Rollback(value = false)
void init() {
List<User> userList = new ArrayList<>();
User user1 = new User();
user1.setName("Robbie");
userList.add(user1);
User user2 = new User();
user2.setName("Robbert");
userList.add(user2);
userRepository.saveAll(userList);
Food food3 = new Food();
food3.setName("후라이드 치킨");
food3.setPrice(15000);
food3.setUser(user1); // 외래 키(연관 관계) 설정
foodList.add(food1);
Food food4 = new Food();
food4.setName("양념 치킨");
food4.setPrice(20000);
food4.setUser(user2); // 외래 키(연관 관계) 설정
foodList.add(food2);
}
@Test
@Transactional
@Rollback(value = false)
@DisplayName("연관관계 제거")
void test1() {
// 고객 Robbie 를 조회합니다.
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
// 연관된 음식 Entity 제거 : 후라이드 치킨
Food chicken = null;
for (Food food : user.getFoodList()) {
if(food.getName().equals("후라이드 치킨")) {
chicken = food;
}
}
if(chicken != null) {
//User Entity Food 객체에서 chicken의 연관관계를 제거했다.
user.getFoodList().remove(chicken);
}
// 연관관계 제거 확인
// 하지만 Delete SQL은 수행되지 않아 DB에는 남아있다.
for (Food food : user.getFoodList()) {
System.out.println("food.getName() = " + food.getName());
}
}
CascadeType.Remove | orphanRemoval = true | |
작동 조건 | 부모 Entity가 삭제될 때 | 부모-자식의 관계가 끊어질 때 |
결과 | 부모와 관련된 모든 자식 Entity 삭제 | 고아가 된 자식 Entity 삭제 |
연관관계에 따른 적용 가능 여부 |
모든 연관관계에 적용 가능 | OneToMany, OneToOne에만 적용 가능 여러 자식이 하나의 부모를 참조하는 ManyToOne, 중간 테이블로 관계를 맺는 ManyToMany(관계만 제거되고 Entity는 삭제되지 않는다)에는 사용할 수 없다. |
'Spring' 카테고리의 다른 글
[Spring] RestTemplate과 OpenAPI (0) | 2024.11.15 |
---|---|
[Spring] Validation (0) | 2024.11.14 |
[Spring] Spring Security (0) | 2024.11.14 |
[Spring] 필터(Filter) (0) | 2024.11.14 |
[Spring] 쿠키와 세션, JWT (0) | 2024.11.13 |