본문 바로가기
ZB 백엔드 스쿨/블로그 과제

스프링 핵심가이드) 북스터디 5주차 : 09장

by son_i 2023. 10. 20.
728x90

09 연관관계 매핑

 

9.1 연관관계 매핑 종류와 방향

- One to One

- One to Many

- Many to One

- Many To Many

 

어떤 엔티티를 중심으로 연관 엔티티를 보느냐에 따라 연관관게의 상태가 달라짐.

 

DB에서는 두 테이블의 연관관계를 설정하면 외래키를 통해 서로 조인해서 참조하는 구조로 생성,

JPA를 사용하는 객체지향 모델링에서는 엔티티간 참조 방향 설정 가능.

  - 단방향/양방향으로 설정할 수 있다.

 

연관관계가 설정되면 한 테이블에서 다른 테이블의 기본키 값을 외래키로 갖는다.

외래키를 가진 테이블이 '주인'이 된다. 주인은 외래키를 사용(등록, 편집, 수정)할 수 있고 상대엔티티는 읽기만 가능.

 


9.3 일대일 매핑

9.3.1 일대일 단방향 매핑

하나의 상품에 하나의 상품정보만 매핑되는 구조는 일대일 관계.

 

@Entity
@Table(name = "product_detail")
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class ProductDetail extends BaseEntity {

	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String description;
    
    @OneToOne
    @JoinColumn(name = "product_number")
    private Product product;
}

@OneToOne : 다른 엔티티 객체를 필드로 정의했을 때 일대일 연관관계로 매핑.

@JoinColumn : 매핑할 외래키를 설정. 자동으로 이름을 매핑하긴 하지만 의도한 이름이 들어가지 않기 때문에 설정하는 것이 좋음.

     - 속성 : name(매핑할 외래키의 이름을 설정), referencedColumnName(외래키가 참조할 상대 테이블의 컬럼명지정),

                foreignKey (외래키를 생성하면서 지정할 제약조건 설정 unique, nullable, insertable, updatable etc...)

 

ProductDetailRepository.findById(productDetail.getId()).get().getProduct();

//ProductDetail객체에서 Product 객체를 일대일 단방향 연관관계를 설정했기 때문에 

ProductRepository에서 ProductDetail 객체를 조회한 후 연관 매핑된 Product객체를 조회할 수 있음.

 

ProductDetail 객체와 Product 객체가 함께 조회됨. == 엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 것=>'즉시로딩'

left outer join이 수행됨.

 

fetch() : OnetoOne은 기본적으로 EAGER (즉시로딩)

optional() : 기본값 true. 매핑되는 값이 nullable임을 의미.

  반드시 값이 있어야 한다면 @OneToOne(optional = false)로 해주면 됨. 이렇게 되면 Product가 null인 값을 허용하지 않음.

 

9.3.2 일대일 양방향 매핑

객체에서의 양방향은 양쪽에서 서로를 단방향으로 매핑하는 것을 의미.

@Entity
@Table(name = "product")
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Product extends BaseEntity {

	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false)
    private Integer price;
    
    @Column(nullable = false)
    private Integer stock;
    
    @OneToOne
    private ProductDetail productDetail;
}

Product 엔티티에 23-24번째 줄을 추가 -> Product테이블에도 컬럼 생성됨.

 

여기서도 @OneToOne 때문에 left outer join이 수행되는데 양쪽에서 외래키를 가지고 left outer join이 각각 두 번 수행되는 것은 효율적이지 않음. 실제 DB에서도 테이블 간 연관관계를 맺으면 한 쪽 테이블이 외래키를 가지는 구조로 이루어짐.

-> '주인' 개념 

 

JPA에서도 실제 DB의 연관관계를 반영해서 한쪽의 테이블에서만 외래키를 바꿀 수 있도록 정의하는 것이 좋음.

 -> 엔티티는 양방향으로 매핑하되, 한 쪽에게만 외래키를 줌. mappedBy 속성을 사용하면 됨.

 

* mappedBy 속성 : 어떤 객체가 주인인지 표시하는 속성

@Entity
@Table(name = "product")
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Product extends BaseEntity {

	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false)
    private Integer price;
    
    @Column(nullable = false)
    private Integer stock;
    
    @OneToOne(mappedBy = "product")
    private ProductDetail productDetail;
}

mappedBy 속성을 추가함. mappedBy에 들어가는 값은 연관관계를 갖고있는 상대 엔티티에 있는 연관관계 필드의 이름.

 

이 설정 하면 ProductDetail 엔티티가 Product 엔티티의 주인이 됨

 

정리 !

- 두 엔티티의 연관관계에서는 한 쪽을 주인으로 지정해줘서 그 쪽에만 외래키를 줌.

- 주인이 아닌 쪽은 mappedBy 속성을 이용해서 주인 엔티티의 연관관계 필드 이름을 적어줌.

- 양방향으로 연관관계 설정 시 toString 실행하는 시점에 stackOverflowError가 발생. -> Exclude 설정을 해주던가 필요한 경우가 아니라면 단방향으로 연관관계 설정.
     @OneToOne(mappedBy = "product")
     @ToString.Exclude
     private ProductDetail productDetail;

9.4 다대일, 일대다 매핑

공급업체 하나가 여러개의 상품을 취급할 경우 상품의 입장에서는 다대일, 공급업체의 입장에서는 일대다 관계.

 

9.4.1 다대일 단방향 매핑

- 공급업체 Provider 엔티티

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity{
	@Id
    @GeneratedValue(stratege = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
}

 

- 상품 엔티티

@Entity
@Table(name = "product")
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Product extends BaseEntity {

	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false)
    private Integer price;
    
    @Column(nullable = false)
    private Integer stock;
    
    @OneToOne(mappedBy = "product")
    @ToString.Exclude
    private ProductDetail productDetail;
    
    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;
}

27 ~29번째 줄은 공급업체 엔티티에 대한 다대일 연관관계 설정. 이 경우 상품 엔티티가 공급업체 엔티티의 주인.

 

쿼리로 데이터를 저장할 때는 provider_id값만 들어감. product테이블에는 @JoinColumn에 설정한 이름을 기반으로 자동으로 값을 선정해서 추가하게 됨.

 

Product 엔티티에서 단방향으로 Provider 엔티티 연관관계를 맺고 있어서 ProductRepository만으로 Provider 객체도 조회가 가능.

 

9.4.2 다대일 양방향 매핑

공급업체를 통해 등록된 상품을 조회하기 위한 일대다 연관관계 설정

 

- 공급업체 엔티티에서만 연관관계 설정

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity{
	@Id
    @GeneratedValue(stratege = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "provider", fetch = FetchType.EAGER)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
}

일대다 연관관계의 경우 여러 상품 엔티티가 포함될 수 있으므로 컬렉션 형식으로 필드를 지정함.

@OneToMany가 붙은 쪽에서 @JoinColumn을 사용하면 상대 엔티티에 외래키가 설정됨.

 

OneToMany의 기본 fetch 전략이 Lazy(지연로딩 방식 no Session으로 에러가 발생)여서 EAGER로 설정해줌. 

- mappedBy가 설정된 필드는 컬럼으로 생겨나지 않는다.

   양쪽에서 연관관계 설정하고 있을 때 RDBMS의 형식처럼 사용하기 위해 mappedBy를 통해 한 쪽으로 외래키 관리 위임

 

지연로딩 / 즉시로딩

엔티티라는 객체 개념으로 DB를 구현했기 때문에 연관관계를 가진 각 엔티티 클래스에는 연관관계가 있는 객체들이 필드에 존재하게 됨.

연관관계와 상관없이 해당 엔티티의 값을 조회하고 싶거나 연관관계를 가진 테이블의 값도 함께 조회하고 싶을 때 등등의 조건들을 만족하기 위한 개념이 지연로딩/즉시로딩임.

 

Provider 엔티티 클래스는 주인이 아니라 외래키를 관리할 수 없음 ! 

Provider 엔티티에 정의한 productList 필드에 Product 엔티티를 아래와 같이 추가하는 방식으로 DB 레코드 저장 시,

Provider은 연관관계의 주인이 아니기 때문에 DB에 반영되지 않음.

provider.getProductList().add(product1); //무시
provider.getProductList().add(product2); //무시
provider.getProductList().add(product3); //무시

 

9.4.3 일대다 단방향 매핑

@OneToMany를 사용하는 입장에서는 어느 엔티티 클래스도 연관관계의 주인이 될 수 없음.

상품 분류 N : 상품 1

 

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
@EqualsAndHashCode
@Table(name = "category")
public class Category{
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(unique = true)
    private String code;
    
    private String name;
    
    @OneToMany(fetch = FetchType.EAGER)
    @JoinColumn(name = "category_id")
    private List<Product> products = new ArrayList<>();
}

앞이랑 다른 부분은 @JoinColumn을 통해 외래키를 지정해 주었는데 이 테이블에 외래키가 생성되지 않고 

product 테이블에 외래키 category_id가 추가됨.

 

상품 분류  엔티티에서 @OneToMany와 @JoinColumn을 사용하면 상품엔티티에서 별도의 설정을 하지 않아도 일대다 단방향 연관관계가 매핑됨. @JoinColumn은 필수 사항은 아님.

 

** 일대다 단방향 관계의 단점 : 매핑의 주체가 아닌 반대 테이블에 외래키가 추가된다는 점. !!! 

   이 방식은 다대일 구조와 다르게 외래키를 설정하기 위해 다른 테이블에 대한 update 쿼리를 발생시킴.

 

-> 이같은 문제 해결 위해서 일대다 연관관계 사용보다는 다대일 연관관계를 사용하는 것이 좋음.

 


9.5 다대다 매핑

실무에서는 거의 사용되지 않음. 

한 종류의 상품이 여러 생산업체를 통해 생산될 수 있고, 생산업체 한 곳이 여러 상품을 생산할 수 있음.

다대다 연관관계에서는 각 엔티티에서 서로를 리스트로 가지는 구조가 만들어짐.

이런 경우 교차 엔티티라고 부르는 중간 테이블을 생성해 다대다 관계를 일대다 / 다대일 관계로 해소.

 

9.5.1 다대다 단방향 매핑

생산업체에 매핑되는 도메인 Producer

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "producer")
public class Producer extends BaseEntity{
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String code;
    
    private String name;
    
    @ManyToMany
    @ToString.Exclude
    private List<Product> products = new ArrayList<>();
    
    public void addProduct(Product product) {
    	products.add(product);
    }
}

리스트로 필드를 가지는 객체에서는 ㅇ뇌래키를 가지지 않기 때문에 별도의 @JoinColumn을 설정하지 않아도 됨.

 

생산업체(Producrer) 테이블에는 별도의 외래키가 추가되지 않고 DB에 추가로 중간 테이블이 생성되어있음.

테이블은 별도 설정 없으면 producer_products라는 이름으로 설정. table이름 설정하고 싶으면

@ManyToMany 아래에 @JoinTable(name = "이름") 형식으로 설정.

 

producer_products 테이블의 경우 상품테이블과 생산업체 테이블에서 id값을 가져와 두 개의 외래키가 설정됨.

 

9.5.2 다대다 양방향 매핑

상품 엔티티 수정

@Entity
@Table(name = "product")
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Product extends BaseEntity {

	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false)
    private Integer price;
    
    @Column(nullable = false)
    private Integer stock;
    
    @OneToOne(mappedBy = "product")
    @ToString.Exclude
    private ProductDetail productDetail;
    
    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;

	@ManyToMany
    @ToString.Exclude
    private List<Producer> producers = new ArrayList<>();
    
    public void addProducer(Producer producer) {
    	this.producers.add(producer);
    }
}

필요에 따라 mappedBy 속성을 사용해 두 엔티티간 연관관계의 주인 설정 가능.

 

다대다 연관관계 설정 시 중간 테이블을 통해 연관된 엔티티의 값을 가져올 수 있음.

그치만 예기치 못 한 쿼리가 중간테이블을 통해 생겨날 수 있음.

==> 다대다 연관관계보다는 일대다, 다대일을 사용하는 것이 좋음.

 


 9.6 영속성 전이

- 영속성 전이 ? ) 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 연관을 끼쳐 영속성 상태를 변경하는 것.

 

@OneToMany 어노테이션의 인터페이스에는 cascade()라는 요소가 있음. 이 어노테이션이 영속성 전이를 설정하는데 활용됨. 

 

- cascade() 요소와 함께 사용하는 영속성 전이 타입의 종류

  ALL : 모든 영속 상태 변경에 대해 영속성 전이 적용

  PERSIST : 엔티티가 영속화 할 때 연관된 엔티티도 함께 영속화

  MERGE : 엔티티를 영속성 컨텍스트에 병합할 때 연관된 엔티티도 병합

  REMOVE : 엔티티를 제거할 때 연관된 엔티티도 제거

  REFRESH : 엔티티를 새로고침할 때 연관된 엔티티도 새로고침

  DETACH : 엔티티를 영속성 컨텍스트에서 제외하면 연관된 엔티티도 제외

 

 -> 영속성 전이 타입은 엔티티 생명주기와 연관이 있음.

cascade() 요소의 리턴타입은 배열형식 -> 개발자가 사용하고자 하는 cascade 타입을 골라 각 상황에 적용할 수 있다는 것

 

9.6.1 영속성 전이 사용

상품엔티티와 공급업체 엔티티를 이용하여 영속성 전이 적용

ex) 한 가게가 새로운 공급업체와 계약하며 몇 가지 새 상품을 입고시키는 상황.

 

엔티티를 DB에 추가하는 경우로 영속성 전이타입으로 PERSIST 지정.

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity{
	@Id
    @GeneratedValue(stratege = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
}

@OneToMany 속성을 활용하여 cascade 설정.

 

테스트 할 때 productRepository.save는 해주지 않았는데 부모 엔티티가 되는 Provider 엔티티가 저장되면서 코드에 있는 CascadeType.PERSIST에 맞춰 상품엔티티도 함께 저장이 됨  !! 

 

영속성 전이를 사용하면 편리해지지만 REMOVE나 REMOVE를 포함하는 ALL같은 타입을 무분별하게 사용시 연관된 에니티가 의도치 않게 삭제될 수 있어서 다른 타입들보다 특히 더 사이드이펙트를 고려해서 사용해야된다.

 

9.6.2 고아객체

- 고아객체 ? ) 부모 엔티티와 연관관계가 끊어진 엔티티를 의미.

JPA에는 고아객체를 자동으로 제거하는 기능이 있음. 

 

@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)

 

orphanRemoval = true 은 고아 객체를 제거하는 기능.

 


9.7 정리

JPA 사용에서 영속이라는 개념은 중요하다 !

하이버네이트를 직접 사용하는 것 VS Spring Data JPA를 사용하는 것은 차이가 존재.