Spring 입문 4-3 (Entity Relationship 2)

SJ.CHO·2024년 10월 11일

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"), // 현재 위치인 Food Entity 에서 중간 테이블로 조인할 컬럼 설정
    inverseJoinColumns = @JoinColumn(name = "user_id")) // 반대 위치인 User Entity 에서 중간 테이블로 조인할 컬럼 설정
    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"), // 현재 위치인 Food Entity 에서 중간 테이블로 조인할 컬럼 설정
    inverseJoinColumns = @JoinColumn(name = "user_id")) // 반대 위치인 User Entity 에서 중간 테이블로 조인할 컬럼 설정
    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 에서 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;
}
  • 기존의 테이블들이 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() {
    // 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());
}
  • 기존과 동일하게 외래키의 주인인 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() {
    // 고객 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);
}
  • userRepository.save(user); 를 통해서 음식을 저장하지않더라도 영속성 전이를 통해 저장이 가능!

REMOVE

  • 영속성 전이를 통한 삭제기능 추가지원.
  • 기존의 외래키관계를 삭제를하려면 외래키종속의 데이터를 삭제후 자신을 삭제해야하는 추가작업이 필요했음.
@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);
}
  • 하지만 영속성전이를 활용하여 연관된 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() {
    // 고객 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);
}
  • 고객ROW만의 삭제로 관련된 음식의 ROW들까지 같이 삭제되는것을 확인이 가능하다.

고아 Entity 삭제

  • CASCADE 옵션의 REMOVE의 경우 해당 Entity가 삭제 되었을경우 연관관계가 전부 삭제가 되었지만 연관관계를 제거했을경우는 DB상으로 삭제가되지않음.
  • orphanRemoval 를 통해 이러한경우 또한 구현이 가능하다.
@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.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는 참조되는 객체들이 있을확률이 높기때문에.
profile
70살까지 개발하고싶은 개발자

0개의 댓글