N:M 관계
@ManyToMany 를 통해 관계를 표현.

- N:M 관계를 해결하기위해선 필요한 정보를 가지는 중간 테이블을 생성하여 사용함.
단방향
@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",
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;
}
@JoinTable 을 통해 중간테이블을 정의 및 생성
joinColumns = @JoinColumn(name = "food_id") , inverseJoinColumns = @JoinColumn(name = "user_id")) 을 통해서 사져올 외래키들을 지정한다.
- JPA에 의해 생성되는 중간테이블은 개발자가 직접 컨트롤하기 어렵기에 추후 변경사항이 존재할경우 문제가 발생할수있다.
양방향

@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",
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<>();
}
테스트 및 조회
@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 user = new User();
user.setName("Robbie");
user.getFoodList().add(food);
user.getFoodList().add(food2);
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
}
@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 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);
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
@Rollback(value = false)
@DisplayName("N대M 양방향 테스트 : 객체와 양방향의 장점 활용")
void test5() {
User user = new User();
user.setName("Robbie");
User user2 = new User();
user2.setName("Robbert");
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);
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;
}
- 기존의 테이블들이 1:N 관계를 가지며 Order 테이블이 2개의 외래키의 주인이 되는 형태를 가짐.
테스트 및 조회
@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() {
Order order = orderRepository.findById(1L).orElseThrow(NullPointerException::new);
User user = order.getUser();
System.out.println("user.getName() = " + user.getName());
Food food = order.getFood();
System.out.println("food.getName() = " + food.getName());
System.out.println("food.getPrice() = " + food.getPrice());
}
- 기존과 동일하게 외래키의 주인인 Order 테이블이 테이블의 수정 삽입 삭제를 맡고있음.
- Order 테이블이 가지는 음식과 고객의 PK를 활용해 정보를 객체에 담아 조회.
추가사항 (주문일 추가)
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity
@Getter
@Setter
@Table(name = "orders")
@EntityListeners(AuditingEntityListener.class)
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;
@CreatedDate
@Temporal(TemporalType.TIMESTAMP)
private LocalDateTime orderDate;
}
- 기존의 배웠던 Auditing을 활용하면 쉽게 해결이 가능하다!
지연로딩

- 해당 음식 : 고객이 N : 1 관계라 가정했을때 조회.
@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());
}

- 음식의 가격을 조회하려고 했을뿐인데 JPA 내부에서 자동으로 JOIN 문을 사용하여 사용하지 않는 고객테이블도 함께 조회.
- JPA 에서 이를 조정할수있는 방법이 2가지 있고
Fetch Type이라 부름.
Lazy : 지연로딩 이라 부르며 필요한 시점에서 정보를 조회
EAGER : 즉시로딩 으로 조회 즉시 연관된 모든 정보를 가져온다.
- 기본적으로 끝이 Many 일경우 컬렉션 타입이기에 효율적 정보조회를 위해 지연로딩이 사용됌.
- 반대로 One 일경우 객체 1개정도는 큰차이가없어 바로 로딩을 해온다.
영속성 컨텍스트와 지연로딩

- 지연로딩 또한 영속성 컨텍스트 기능의 일부로 작용.
- 지연로딩이 적용된 Entity를
조회 할때 또한 트랜잭션 환경이 적용되어야만 실행가능.
영속성 전이

PERSIST
- 현재까지는 연관관계의 Entity들을 DB에 저장하기위해서 각각의 save() 메소드를 호출해서 영속화 했음.
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
- 이를 JPA 에서
영속성전이(CaseCade) 를 통해 간편하게 해결이 가능함.
- 영속성 전이 : 영속상태의 Entity에서 수행되는 작업이 연관된 Entity 들에게 전파되는 상황.
- 영속성 전이를 활용하여 저장을 하기위해서
PERSIST 옵션 활용.
@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
@DisplayName("영속성 전이 저장")
void test2() {
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);
}
userRepository.save(user); 를 통해서 음식을 저장하지않더라도 영속성 전이를 통해 저장이 가능!
REMOVE
- 영속성 전이를 통한 삭제기능 추가지원.
- 기존의 외래키관계를 삭제를하려면 외래키종속의 데이터를 삭제후 자신을 삭제해야하는 추가작업이 필요했음.
@Test
@Transactional
@Rollback(value = false)
@DisplayName("Robbie 탈퇴")
void test3() {
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
for (Food food : user.getFoodList()) {
System.out.println("food.getName() = " + food.getName());
}
foodRepository.deleteAll(user.getFoodList());
userRepository.delete(user);
}
- 하지만 영속성전이를 활용하여 연관된 ROW를 삭제하는 방법역시 JPA에서 지원하고있음.
@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, CascadeType.REMOVE})
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.setUser(this);
}
}
cascade = {CascadeType.PERSIST, CascadeType.REMOVE} 중괄호를 통해 중복옵션 지정이 가능.
@Test
@Transactional
@Rollback(value = false)
@DisplayName("영속성 전이 삭제")
void test4() {
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
for (Food food : user.getFoodList()) {
System.out.println("food.getName() = " + food.getName());
}
userRepository.delete(user);
}
- 고객ROW만의 삭제로 관련된 음식의 ROW들까지 같이 삭제되는것을 확인이 가능하다.
고아 Entity 삭제
- CASCADE 옵션의 REMOVE의 경우 해당 Entity가 삭제 되었을경우 연관관계가 전부 삭제가 되었지만 연관관계를 제거했을경우는 DB상으로 삭제가되지않음.
orphanRemoval 를 통해 이러한경우 또한 구현이 가능하다.
@Test
@Transactional
@Rollback(value = false)
@DisplayName("연관관계 제거")
void test1() {
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
Food chicken = null;
for (Food food : user.getFoodList()) {
if(food.getName().equals("후라이드 치킨")) {
chicken = food;
}
}
if(chicken != null) {
user.getFoodList().remove(chicken);
}
for (Food food : user.getFoodList()) {
System.out.println("food.getName() = " + food.getName());
}
}
- DB상에서 Robbie가 삭제되지는 않기에 후라이드 치킨은 DB에 유지된다.
@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, orphanRemoval = true)
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.setUser(this);
}
}
- orphanRemoval 옵션을 설정해주면서 연관관계 제거시 해당 관계를 맺는 Entity또한 DB상에서 삭제된다.
- REMOVE 옵션 도 추가적으로 가지기에 Robbie 삭제시 같이삭제된다.
- 해당속성 사용시 꼭! 다른연관관계를 맺고있는지 확인하고 사용해야함.
- A,B를 참조하던 C가 B에서 삭제할경우 A 가 참조하고있던 C가 사라지기에 문제가 발생할수있음.
- 따라서
@ManyToOne 어노테이션 사용처에선 지원하지않음.
- @ManyToOne Entity는 참조되는 객체들이 있을확률이 높기때문에.