· 오늘 공부한 것

Entity 연관 관계

  • 1 대 1 관계
  • N 대 1 관계
  • 1 대 N 관계
  • N 대 M 관계

지연 로딩과 즉시로딩 (LAZY, EAGER)

영속성 전이 (cascade)

고아 Entity 삭제 (orphanRemoval)

· 공부한 내용, 궁금한 내용, 부족한 내용

entity를 작성을 전에 했을 때는 1개를 가지고 진행했는데 이번에는 entity끼리의 관계설정하는 방법을 학습했다. entity관계 설정 시 단방향인지 양방향인지에 따라서 참조하여 조회를 할 수 있고 없고를 정할 수 있다. 하지만 DB 테이블 간의 관계에서는 이러한 방향의 개념은 없다. FK로 연결되어 있으면 JOIN으로 어느 테이블에서든지 참조해서 가져올 수 있다. 그렇기에 지금 하는 것은 entity끼리의 관계이기 때문에 DB테이블 관계와 약간은 분리해서 생각해야 할 부분들이 있다. 아래가 정리한 부분이다.

  • DB 테이블에서는 테이블 사이의 연관관계를 FK(외래 키)로 맺을 수 있고 방향 상관없이 조회가 가능합니다.
  • Entity에서는 상대 Entity를 참조하여 Entity 사이의 연관관계를 맺을 수 있습니다.
  • 하지만 상대 Entity를 참조하지 않고 있다면 상대 Entity를 조회할 수 있는 방법이 없습니다.
  • 따라서 Entity에서는 DB 테이블에는 없는 방향의 개념이 존재합니다.

1 : 1 관계

@OneToOne 애너테이션을 사용한다.

단방향 관계

보통 FK의 주인은 N(다)의 관계인 Entity이지만 1 대 1 관계에서는 FK의 주인을 직접 지정해야 한다.

외래 키 주인만이 외래 키 를 등록, 수정, 삭제할 수 있으며, 주인이 아닌 쪽은 오직 외래 키를 읽기만 가능합니다.

 

@JoinColumn() 은 FK 주인이 활용하는 애너테이션이다.

  • 칼럼명, null 여부, unique 여부 등을 지정할 수 있다.

음식 Entity가 FK의 주인인 경우

@Entity
@Table(name = "food")
public class Food {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private double price;

    @OneToOne
    @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;
}

 

양방향 관계

  • FK의 주인을 지정해 줄 때 mappedBy 옵션을 사용한다.
    • mappedBy의 속성값은 FK의 주인인 상대 Entity의 필드명을 의미한다.
  • FK의 주인 Entity에서 @JoinColumn() 애너테이션을 사용하지 않아도 defalut 옵션이 적용되어 생략이 가능하다.
    • 그러나 1 대 N 관계에서 외래 키의 주인 Entity가 @JoinColumn() 애너테이션을 생략한다면 JPA가 외래 키를 저장할 컬럼을 파악할 수가 없어서 의도하지 않은 중간 테이블이 생성된다.
    • 그러니 생략하지 말고 활용을 해주자!
  • 양방향 관계에서 mappedBy 옵션도 위와 같은 문제가 생길 수 있기 때문에 설정해주는게 좋다.

 

음식 Entity가 외래 키의 주인인 경우

@Entity
@Table(name = "food")
public class Food {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private double price;

    @OneToOne
    @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;

    @OneToOne(mappedBy = "user")
    private Food food;
}

 

N 대 1 관계

@ManyToOne 애너테이션을 사용한다.

단방향 관계

음식 Entity가 N의 관계로 외래 키의 주인

@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
@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<>();
}

 

1 대 N 관계

@OneToMany 애너테이션을 사용한다.

단방향 관계

FK를 관리하는 주인은 음식 Entity이지만 실제 FK는 고객 Entity가 가지고 있다.

  • 1 : N에서 N 관계의 테이블이 FK를 가질 수 있기 때문에 FK는 N관계인 고객 테이블에 FK칼럼을 만들어서 추가하지만 FK의 주인인 음식 Entity를 통해 관리한다.
  • 그렇기에 실제 DB에서 FK를 고객 테이블이 가지고 있기 때문에 추가적인 UPDATE가 발생된다는 단점이 존재한다.
@Entity
@Table(name = "food")
public class Food {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private double price;

    @OneToMany
    @JoinColumn(name = "food_id") // users 테이블에 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;
}

 

양방향 관계

  • 1 대 N 관계에서는 일반적으로 양방향 관계가 존재하지 않는다.
  • 양방향 관계를 맺으려면 음식 Entity를 FK의 주인으로 정해주기 위해 고객 Entity에서 mappedBy 옵션을 사용해야 하지만 @ManyToOne 애너테이션은 mappedBy 속성을 제공하지 않는다.
  • N관계의 Entity인 고객 Entity에서 @JoinColumn의 insertable과 updatable 옵션을 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;
}

 

N 대 M 관계

@ManyToMany 애너테이션을 사용한다.

단방향 관계

  • N : M 관계를 풀어내기 위해 중간 테이블(orders)을 생성하여 사용한다.
  • 생성되는 중간 테이블을 컨트롤하기 어렵기 때문에 중간 테이블의 변경이 발생할 경우 문제가 발생할 가능성이 있다.
@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;
}

 

양방향 관계

  • 반대 방향인 고객 Entity에 @ManyToMany로 음식 Entity를 연결하고 mappedBy 옵션을 설정하여 FK의 주인을 설정하면 양방향 관계 맺음이 가능하다.
@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<>();
}

 

중간테이블을 직접 생성해서 N : M 관계 설정하기

  • 중간 테이블 orders를 직접 생성하여 관리하면 변경 발생 시 컨트롤하기 쉽기 때문에 확장성에 좋다.
@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;
}

 

지연로딩과 즉시로딩

JPA는 연관관계가 설정된 Entity의 정보를 바로 가져올지 필요할 때 가져올지 정할 수 있다.

  • 즉, 가져오는 방법을 정하게 되는데 JPA에서는 Fetch Type이라 부릅니다.
  • Fetch Type의 종류에는 2가지가 있는데 하나는 LAZY, 다른 하나는 EAGER입니다.
  • LAZY는 지연 로딩으로 필요한 시점에 정보를 가져옵니다.
  • EAGER는 즉시 로딩으로 이름의 뜻처럼 조회할 때 연관된 모든 Entity의 정보를 즉시 가져옵니다.

기본적으로 @OneToMany 애너테이션은 defalut값이 LAZY로 지정되어 있고 @ManyToOne 애너테이션은 EAGER로 되어 있다.

다른 애너테이션도 default 값이 있는데 이를 구분하는 방법은 아래와 같다.

  • 애너테이션 이름에서 뒤쪽에 Many가 붙어있으면 설정된 해당 필드가 Java 컬렉션 타입일 것입니다.
    • 즉, 해당 Entity의 정보가 여러 개 들어있을 수 있다는 것을 의미합니다.
    • 따라서 효율적으로 정보를 조회하기 위해 지연 로딩이 default로 설정되어 있습니다.
  • 반대로 이름 뒤쪽이 One일 경우 해당 Entity 정보가 한 개만 들어오기 때문에 즉시 정보를 가져와도 무리가 없어 즉시 로딩이 default로 설정되어 있습니다.

JPA에서 영속성 콘텍스트의 기느은 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());
}
@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());
    }
}

위에 코드는 실행 시 left join까지 실행하여 한 번에 연관된 모든 Entity의 정보를 가져오는데 아래에 있는 코드는 user.getFoodList()가 호출될 때 select문을 사용하여 정보를 가져온다. 그리고 지연로딩이기 때문에 @Transactional 애너테이션이 사용된 모습이다.

 

영속성 전이

  • 영속 상태의 Entity에서 수행되는 작업들이 연관된 Entity까지 전파되는 상황을 뜻한다.
  • 이를 적용하여 해당 Entity를 저장할 때 연관된 Entity까지 자동으로 저장하기 위해서는 자동으로 저장하려고 하는 연관된 Entity에 추가한 연관관계 애너테이션에 CASCADE의 PERSIST 옵션을 설정하면 된다.

옵션을 사용하지 않으면 아래코드와 같이 save() 메서드를 여러 번 사용하게 된다.

@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);
}
@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);// 외래 키(연관 관계) 설정
    }
}

하지만 User entity에 cascade PERSIST 옵션을 추가하게 되면 userRepository.save(user); 를 한 번만 쓰게 되면 영속성 전이로 food도 저장이 된다.

 

반대로 이렇게 연관된 Entity를 쉽게 삭제하는 방법인 cascade REMOVE 옵션이 있다.

@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에 중괄호({})를 사용하면 여러 개의 옵션을 사용할 수 있다.

cascade의 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);
}

원래는 user를 삭제하기 전에 연관된 Entity Food에서 해당 user가 주문한 음식 데이터를 삭제하는 작업이 필요하다. 하지만 위에서 REMOVE옵션을 걸어줬기 때문에 foodRepository.deleteAll(user.getFoodList()); 와 같은 작업 없이 userRepository.delete(user); 만 하면 연관된 데이터도 삭제가 된다.

 

고아 Entity 삭제

위에서 알아본 REMOVE옵션의 경우 해당 Entity 객체를 삭제했을 때 연관된 Entity객체들을 자동으로 삭제할 수 있었다. 하지만 연관된 Entity와 관계를 제거했다고 해서 자동으로 해당 Entity가 삭제되지는 않는다. 그래서 사용하는 옵션이 orphanRemoval이다.

@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);// 외래 키(연관 관계) 설정
    }
}

@OneToMany 애너테이션에 orphanRemoval = true 옵션을 준 모습이다.

@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());
    }
}

만약 orphanRemoval 옵션을 주지 않고 위에 코드를 실행하게 되면 Delete SQL이 수행되지 않는다. 하지만 옵션을 주면 Delete가 수행되는 것을 확인할 수 있었다.

 

그런데 여기서 REMOVE, orphanRemoval 옵션을 사용할 때 주의할 부분이 있다. 그것은 삭제하려고 하는 연관된 Entity를 다른 곳에서 참조하고 있는지 아닌지를 꼭 확인해야 하는 것이다.

  • A와 B에 참조되고 있던 C를 B를 삭제하면서 같이 삭제하게 되면 A는 참조하고 있던 C가 사라졌기 때문에 문제가 발생할 수 있습니다.
  • 따라서 orphanRemoval 같은 경우 @ManyToOne 같은 애너테이션에서는 사용할 수 없습니다.
    • ManyToOne이 설정된 Entity는 해당 Entity 객체를 참조하는 다른 Entity 객체들이 있을 수 있기 때문에 속성으로 orphanRemoval를 가지고 있지 않습니다.

 

· 오늘 서칭 한 자료

강의자료 대체

 

 

· 느낀 점

- 전에 node.js로 서버 만들 때 express 사용해서 구현했는데 관계 설정이 어느 정도 기억이 났다.

- 방식은 당연히 다르지만 관계설정을 하는 개념이 같았고 특히 다대다 관계에서 중간테이블을 생성해서 관리하기 편하게 해주는 방식이 같았다.

- entity 작성이 코드 초반작업이기 때문에 관계설정까지 확실히 해두지 않으면 DB에서 무슨 작업을 할 때 어려움이 생길 수 있다고 생각했다.

반응형

'Today I Learned' 카테고리의 다른 글

2023-12-04 TIL  (0) 2023.12.04
2023-11-27 TIL  (0) 2023.11.27
2023-11-15 TIL  (0) 2023.11.15
2023-11-14 TIL  (0) 2023.11.14
2023-11-13 TIL  (0) 2023.11.13

+ Recent posts