JPA 연관관계 & 영속성 전이

Panda·2023년 4월 20일
0

Spring

목록 보기
36/45

이번에 '스프링부트 핵심가이드' 라는 책을 읽다가 JPA에 대해 무지하다는걸 또 한번 느꼈네요 (연관관계를 지금까지 감으로 이럴때 쓰는거겠지 하고있었으니...)

그래서 다시 이해한 Spring Data JPA 연관관계 & 영속성을 공부해보려 합니다.

연관관계

// Entity 관련 어노테이션 생략
public class Product {
	...
}

// Entity 관련 어노테이션 생략
public class ProductDetail {
	...
}

// Entity 관련 어노테이션 생략
public class Provider {
	...
}

// Entity 관련 어노테이션 생략
public class Category {
	...
}

위와 같은 객체들이 존재한다고 가정하고 연관관계 매핑을 진행해보려고 합니다.

일대일

단방향

// Entity 관련 어노테이션 생략
public class ProductDetail {
	@OneToOne
    @JoinColumn(name = "product_number")
    private Product product;
}
  • @JoinColumn(name = ) : 외래키 이름 설정
  • @JoinColumn(refrencedColumnName = ) : 외래키 참조할 상대 테이블 컬럼명 지정
  • @JoinColumn(foreignKey = ) : 외래키 생성 시 제약조건 설정

이렇게 설정하게 된다면 실제 데이터베이스 ProductDetail 테이블에
product_number라는 외래키가 생성이 됩니다.

ProductDetail을 조회하게 된다면 Product를 Join을 하여 조회하게 됩니다.

양방향

public class ProductDetail {
	@OneToOne
    @JoinColumn(name = "product_number")
    private Product product;
}

public class Product {
	@OneToOne
    private ProductDetail productDetail;
}

이런식으로 양방향 관계를 맺을수가 있는데
이 경우 데이터베이스에서는 ProductDetail은 product_number 외래키를 가지고, Product는 product_detail_id 외래키를 가지게 됩니다.

이 상황에서 ProductDetail을 조회를 하게 된다면 Join이 2번 발생하는 비효율적인 상황이 발생합니다.
(1. ProductDetail -Join- 2. Product -Join- 3. ProductDetail)

그래서 양방향 관계에서는 주인 개념이 가장 중요합니다.
그러면 주인관계를 어떻게 설정하느냐?
바로 밑에와 같습니다.

public class ProductDetail {
	@OneToOne
    @JoinColumn(name = "product_number")
    private Product product;
}

public class Product {
	// ProductDetail을 주인으로 설정
	@OneToOne(mappedBy = "product")	// mappedBy 속성으로 주인 설정!!
    private ProductDetail productDetail;
}

바로 mappedBy 라는 속성값을 사용하여 주인을 정할 수 있습니다.
mappedBy 속성값은 연관관계를 갖고 있는 상대 엔티티에 있는 연관관계 필드의 이름이 됩니다.

일반적으로는 외래키를 가지고 있는 쪽이 주인이 됩니다.

데이터베이스에서는 ProductDetail이 product_number 외래키를 가지게 되고,
Product는 외래키를 가지고 있지 않습니다.

여기까지 연관관계가 설정이 되었다면 Product를 조회하든 ProductDetail을 조회하든 연관관계가 설정된 엔티티의 정보를 같이 가지고 있습니다. (Join도 한번씩만 발생!)

단 여기서 주의사항이 존재하는데 ToString, Json 직렬화 시 서로가 서로를 참조하는 순환참조가 발생하게 되는데 밑에와 같이 해결할 수 있습니다.

public class Product {
	@OneToOne(mappedBy = "product")	// mappedBy 속성으로 주인 설정!!
    @ToString.Exclude // 또는 @JsonIgnore
    private ProductDetail productDetail;
}

Response를 엔티티 자체가 아닌 DTO로 내보내고 ToString, Json 직렬화를 안하는 상황이라면 어노테이션을 사용을 안해도 순환참조 방지가 되겠죠? (하지만 왠만하면 적는게 좋을 듯 싶어요!)

다대일

단방향

public class Provider {
	...
}

public class Product {
	@OneToOne(mappedBy = "product")
    @ToString.Exclude
    private ProductDetail productDetail;
    
    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;
}

데이터베이스에서는 Provide는 외래키를 가지지 않고, Product는 provider_id 외래키를 가집니다.

Product 조회 시 Provider, ProductDetail을 Join 합니다.

양방향

public class Provider {
	...
    
    // OneToMany로 관계를 맺어주고 mappedBy로 연관관계 주인 설정
    // 여기서는 Product가 주인입니다.
    @OneToMany(mappedBy = "provider", fetch = FetchType.EAGER)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
}

public class Product {
	@OneToOne(mappedBy = "product")
    @ToString.Exclude
    private ProductDetail productDetail;
    
    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;
}

데이터베이스에서 Provider는 외래키를 안가지고 있고, Product는 provider_id 외래키를 가지고 있습니다.

FetchType은 이전에 N + 1 문제를 설명하면서 이야기 했었으니 넘어가겠습니다.

양방향 관계이기 때문에 Provider 조회 시 Product가 Join 되고 Product 조회 시 Provider가 Join 됩니다.

다대일 양방향 관계에서 주의사항 (주인이 설정이 되어있는 관계일시)

Provider provider = new Provider();
...

Product product = new Product();
...

// 해당 코드는 무시가 됨
provider.getProductList().add(product);

위에 코드에서 Provider에서 연관관계를 설정 하였는데 Provider는 주인이 아닌 엔티티이기 때문에 연관관계 설정이 무시됩니다.

따라서 밑에와 같이 해결할 수 있습니다.

Provider provider = new Provider();
...

providerRepository.save(provider);

Product product = new Product();
...
// 주인인 엔티티 측에서 연관관계 설정
product.setProvider(provider);
productRepository.save(product);

일대다

단방향

public class Category {
	...

	@OneToMany(fetch = FetchType.EAGER)
    @JoinColumn(name = "category_id")
    private List<Product> products = new ArrayList<>();
}

public class Product {
	@OneToOne(mappedBy = "product")
    @ToString.Exclude
    private ProductDetail productDetail;
    
    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;
}

데이터베이스에는 Category는 외래키를 가지지 않고, Product는 provider_id, category_id 외래키를 가지고 있습니다.

저는 여기서 신기했었던게 단방향 관계로 설정했는데 외래키를 연관관계인 엔티티가 가졌다는 점이 신기했습니다.

하지만 해당 관계에는 이러한 상황일때 단점이 존재합니다.

Product product = new Product();
...

productRepository.save(product);

Category category = new Category();
...
// 연관관계 설정
category.getProducts().add(product);
categoryRepository.save(category);

위에 같은 코드는 밑에와 같은 쿼리로 작동합니다.

// 컬럼 속성은 생략

// productRepository.save(product) 로 발생
INSERT INTO product(...) VALUES(?, ?, ?, ?, ?, ?)

// categoryRepository.save(category) 로 발생
INSERT INTO category(...) VALUES(?,?)
// + Category, Product 연관관계 때문에 발생
UPDATE product SET category_id=? WHERE number=?

INSERT문이 2개면 끝날 동작을 UPDATE 쿼리가 추가되어 총 3개의 쿼리를 작동시키고 있습니다.

왜 이런걸까요? 바로 연관관계를 설정해줄 수있는 Category에서 연관관계를 설정을 하게 되면
실제 데이터베이스에 Product가 가지고있는 category_id 값을 설정이 되어야 하는데
이미 Product는 데이터베이스에 저장이 되었고 외래키 값을 지정하기 위해 UPDATE 쿼리가 추가적으로 날라가게 되는겁니다.
(만약 외래키 조건이 nullable이 허용이 안되었다면 에러가 발생했을 겁니다.)

따라서 해당 문제를 해결하기 위해서는 일대다 단방향 관계보다는 다대일 연관관계를 사용하는 것이 좋을 것 같습니다.

양방향

  • 일대다 기준에서 어느 엔티티도 연관관계의 주인이 될 수 없기 때문에 설명하지 않겠습니다.

다대다

  • 다대다는 쓸일이 거의 없고 중간 Join 테이블이 생성되서 관리하면 편하다라고만 이해하고 넘어갔습니다.
  • 나중에 다대다 관계를 신박하게 쓸일이 있으면 그때 소개를 하겠습니다.

영속성 전이

영속성 전이 종류

  • ALL : 모든 영속 상태 변경에 대해 영속성 전이를 적용
  • PERSIST : 엔티티가 영속화할 때 연관된 엔티티도 영속화
  • MERGE : 엔티티를 영속성 컨텍스트에 병합할 때 연관된 엔티티도 병합
  • REMOVE : 엔티티를 제거할 때 연관된 엔티티도 제거
  • REFRESH : 엔티티를 새로고침할 때 연관된 엔티티도 새로고침
  • DETACH : 엔티티를 영속성 컨텍스트에서 제외하면 연관된 엔티티도 제외

적용 예시

public class Provider {
	...

	@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
}
Provider provider = new Provider();
...

Product product = new Product();
// 연관관계 설정
product.setProvider(provider);

// 연관관계 설정
provider.getProductList().addAll(Lists.newArrayList(product));

// provider 만 영속화 (지만 실제로는 product까지 영속화)
providerRepository.save(provider);

위와 같은 코드는 겉보기로는 Provider만 영속화를 한 것처럼 보이지만 영속성 전이로 인해 실제로 Provider 뿐만 아니라 Product까지 영속화 되어 데이터베이스에 저장이 되는 것을 확인할 수 있습니다.

cascade의 값은 배열이므로 여러가지의 영속성 전이 타입을 설정해줄 수 있습니다.

그리고 CascadeType.ALL 은 최대한 사용안하는 것이 좋을 것 같습니다. 왜냐하면 모든 영속성 전이 타입이 포함되어있기 때문에 REMOVE 라던가 REMOVE 라던가 ~~ 포함이 되어있어서
자칫하면 연관관계 되어있는 엔티티들이 전부 삭제가 되기 때문입니다.

CascadeType.REMOVE 과 비슷하게 (실제로는 전혀 달라요) 고아 객체를 제거할 수 있는 설정도 있기는 하지만 [거의 안쓰임 + 위험함] 의 이유로 넘어가겠습니다.

느낀 점

JPA에서는 양방향 관계만 있는줄 알았는데... 단방향도 있었네요..........
(항상 개발하면서 단방향 관계가 필요했었는데..... 이제 알다니.................)
양방향 관계에서는 주인관계가 되~~게 중요하다라는것을 다시 한번 깨닫게 되고
Join != 외래키 라는 사실을 다시 한번 확실하게 느낍니다.

지금까지 내가 했던 연관관계들은 대체 무슨 생각가지고 설정했나~ 라고 생각하게 되네요.
앞으로는 일대일, 다대일 위주로만 사용하지 않을까 싶네요.

이번 기회로 연관관계 및 영속성 전이를 완벽하게 이해했다고 자신감이 넘쳐나서
좋은 공부였던 것 같습니다.

profile
실력있는 개발자가 되보자!

0개의 댓글