글은 스프링 부트 핵심 가이드(장정우 저)를 읽고 개인적으로 정리하기 위한 글입니다.
왜 연관관계 매핑이 필요할까에 대해서 책에서 말하는 내용을 먼저 요약하자면

복잡한 애플리케이션에서 RDBMS를 사용할 때 테이블 하나만 사용해서 애플리케이션의 모든 기능을 구현하기란 사실상 불가능하다.
설계가 복잡해지면 각 도메인에 맞는 테이블을 설계하고 연관관계를 설정해서
조인(Join)등의 기능을 활용해야 한다.

JPA를 사용하는 애플리케이션도 테이블의 연관관계를 엔티티(entity) 간의 연관관계로 표현할 수 있다.
하지만 객체와 테이블의 성질이 달라 정확한 연관관계를 표현할 수는 없다.

JPA에서 이러한 제약을 보완하면서 연관관계를 매핑하고 사용하는 방법에 대해 알아본다.

 

연관관계 매핑 종류와 방향

  • One To One : 일대일(1 : 1)
  • One To Many : 일대다(1 : N)
  • Many To One : 다대일(N :1)
  • Many To Many : 다대다(N : M)
  • 단방향 : 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식
  • 양방향 : 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식
  • 연관관계가 설정 시 한 테이블에서 다른 테이블의 기본값을 외래키로 갖게 된다. 이런 관계에서 주인(Owner)이라는 개념이 사용 일반적으로 외래키를 가진 테이블이 관계의 주인 주인은 외래키를 사용할 수 있으나 상대 엔티티는 읽는 작업만 수행 가능하다.

일대일 매핑(1 : 1)

예시) 하나의 상품에 하나의 상품정보만 매핑이 되는구조(1 : 1)
// 상품 엔티티

package com.springboot.relationship.data.entity;

import java.util.ArrayList;
import java.util.List;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToMany;
import javax.persistence.ManyToOne;
import javax.persistence.OneToMany;
import javax.persistence.OneToOne;
import javax.persistence.Table;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
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);
    }

}

 

// 상품정보 엔티티

package com.springboot.relationship.data.entity;

import lombok.*;
import javax.persistence.*;

@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 어노테이션에 'optional=false' 속성 설정 시 product가 null인 값을 허용하지 않음
    */
    @OneToOne //(optional=false)
    @JoinColumn(name = "product_number")
    private Product product;

}
  • @JoinColumn : 어노테이션을 사용해 매핑할 외래키 설정
    • name : 매핑할 외래키의 이름을 설정
    • referencedColumnName : 외래키가 참조할 상대 테이블의 칼럼명을 지정
    • foreignKey : 외래키를 생성하면서 지정할 제약조건을 설정(unique, nullable, updatable 등)
// 상품정보 엔티티 객체들을 사용하기 위해 리포지토리 인터페이스 생성

package com.springboot.relationship.data.repository;

import com.springboot.relationship.data.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;


public interface ProductRepository extends JpaRepository<Product, Long> {


}
// ProductRepository와 ProductDetailRepository에 대한 테스트 코드

package com.springboot.relationship.data.repository;

import com.springboot.relationship.data.entity.Product;
import com.springboot.relationship.data.entity.ProductDetail;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class ProductDetailRepositoryTest {

    @Autowired
    ProductDetailRepository productDetailRepository;

    @Autowired
    ProductRepository productRepository;

    @Test
    public void saveAndReadTest1() {
        Product product = new Product();
        product.setName("스프링 부트 JPA");
        product.setPrice(5000);
        product.setStock(500);

        productRepository.save(product);

        ProductDetail productDetail = new ProductDetail();
        productDetail.setProduct(product);
        productDetail.setDescription("스프링 부트와 JPA를 함께 볼 수 있는 책");

        productDetailRepository.save(productDetail);

        // 생성한 데이터 조회
        System.out.println("savedProduct : " + productDetailRepository.findById(
                productDetail.getId()).get().getProduct());

        System.out.println("savedProductDetail : " + productDetailRepository.findById(
                productDetail.getId()).get());
    }
}

 

일대일 양방향 매핑

  • 앞에서 생성한 일대일 단방향 설정을 양방향으로 설정 변경
  • 사실 객체에서의 양방향 개념은 양쪽에서 단방향으로 서로를 매핑하는 것을 의
  • 일대일 양방향을 위한 Product 엔티티
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
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;

}
  • JPA에서도 실제 데이터 베이스의 연관관계를 반영해서 한쪽 테이블에서만 외래키를 바꿀 수 있도록 정하는 것이 좋다.
  • 이 경우 엔티티는 양방향으로 매핑하되 한쪽에게만 외래키를 줘야 하는데, 이때 사용되는 속성 값이 mappedBy, mappedBy는 어떤 객체가 주인인지 표시하는 속성, 여기서는 Product 객체에 붙음
  • 양방향으로 연관관계 설정 후 toString 사용할 때 순환참조 발생
  • 대체로 단방향으로 연관관계 설정 및 양방향 설정이 필요할 경우 순환참조 제거를 위해 exclude를 사용해 ToString에서 제외 설정하는 것이 좋다.
@OneToOne(mappedBy = "product")
@ToString.Exclude
    private ProductDetail productDetail;

 

다대일(N : 1), 일대다(1 : N) 매핑

  • 상품 테이블과 공급업체 테이블은 상품 테이블의 입장에서 볼 경우에는 다대일
  • 공급업체 테이블의 입장에서 볼 경우에는 일대다 관계로 볼 수 있다.

 

다대일 단방향 매핑

  • 공급업체 엔티티 클래스(Provider)
  • BaseEntity를 통해 생성일자와 변경일자를 상속받는다.
  • 일반적으로 외래키를 갖는 쪽이 주인 역할을 수행하기 때문에 상품 엔티티가 공급업체 엔티티의 주인
// 공급업체 엔티티

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
// 상품 엔티티와 공급업체 엔티티의 다대일 연관관계 설정
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
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 // 예제 9.8
    private ProductDetail productDetail;

    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;
// ProviderRepository 인터페이스
public interface ProviderRepository extends JpaRepository<Provider, Long> {

}
// Product, Provider 엔티티 연관관계 테스트
@SpringBootTest
class ProviderRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    @Autowired
    ProviderRepository providerRepository;

    @Test
    void relationshipTest1() {
        // 테스트 데이터 생성
        Provider provider = new Provider();
        provider.setName("ㅇㅇ물산");

        providerRepository.save(provider);

        Product product = new Product();
        product.setName("가위");
        product.setPrice(5000);
        product.setStock(500);
        product.setProvider(provider);

        productRepository.save(product);

        // 테스트
        System.out.println("product : " + productRepository.findById(1L).orElseThrow(RuntimeException::new));

        System.out.println("provider : " + productRepository.findById(1L).orElseThrow(RuntimeException::new).getProvider());
    }
}

 

다대일 양방향 매핑

  • 공급업체 엔티티와 상품 엔티티의 일대다 연관관계 설정
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
    @ToString.Exclude
    // 일대다 연관관계의 경우 여러 상품 엔티티가 포함될 수 있어 컬렉션(Collection, List, Map)형식으로 필드를 생성
    private List<Product> productList = new ArrayList<>();

}
// Provider 엔티티 기반의 테스트 코드
@SpringBootTest
class ProviderRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    @Autowired
    ProviderRepository providerRepository;

@Test
    void relationshipTest() {
        // 테스트 데이터 생성
        Provider provider = new Provider();
        provider.setName("ㅇㅇ상사");

        providerRepository.save(provider);

        Product product1 = new Product();
        product1.setName("펜");
        product1.setPrice(2000);
        product1.setStock(100);
        product1.setProvider(provider);

        Product product2 = new Product();
        product2.setName("가방");
        product2.setPrice(20000);
        product2.setStock(200);
        product2.setProvider(provider);

        Product product3 = new Product();
        product3.setName("노트");
        product3.setPrice(3000);
        product3.setStock(1000);
        product3.setProvider(provider);

        productRepository.save(product1);
        productRepository.save(product2);
        productRepository.save(product3);

        System.out.println("check 1");

        List<Product> products = providerRepository.findById(provider.getId()).get()
                .getProductList();

        for (Product product : products) {
            System.out.println(product);
        }

    }
}
  • Provider 엔티티 클래스는 Product 엔티티와의 연관관계에서 주인이 아니기 때문에 외래키를 관리할 수 없다.
  • 주인이 아닌 엔티티에서 연관관계를 설정한 예
provider.getProductList().add(product1); // 무시
provider.getProductList().add(product2); // 무시
provider.getProductList().add(product3); // 무시

 

일대다 단방향 매핑

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

// 상품분류 엔티티
@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<>();

}
// CategoryRepository 인터페이스
public interface CategoryRepository extends JpaRepository<Category, Long> {

}
// CategoryRepository를 활용한 테스트
@SpringBootTest
class CategoryRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    @Autowired
    CategoryRepository categoryRepository;

    @Test
    void relationshipTest(){
        // 테스트 데이터 생성
        Product product = new Product();
        product.setName("펜");
        product.setPrice(2000);
        product.setStock(100);

        productRepository.save(product);

        Category category = new Category();
        category.setCode("S1");
        category.setName("도서");
        category.getProducts().add(product);

        categoryRepository.save(category);

        // 테스트 코드
        List<Product> products = categoryRepository.findById(1L).get().getProducts();

        for(Product foundProduct : products){
            System.out.println(product);
        }
    }
}

 

다대다 매핑

  • 다대다(M : N) 연관관계는 실무에서 거의 사용되지 않는 구성
  • ex) 상품과 생산업체 → 한 종류의 상품이 여러 생산업체를 통해 생산될 수 있고, 생산업체 한 곳이 여러 상품을 생산할 수도 있다.
  • 다대다 연관관계에서는 각 엔티티에서 서로를 리스트로 가지는 구조가 만들어진다.
  • 이런 경우 교차 엔티티라고 부르는 중간 테이블을 생성해서 다대다 관계를 일대다 또는 다대일 관계로 해결

 

다대다 단방향 매핑

// 생산업체 엔티티
@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);
    }

}
// ProducerRepository 인터페이스
public interface ProducerRepository extends JpaRepository<Producer, Long> {

}
// 생산업체 연관관계 테스트
@SpringBootTest
class ProducerRepositoryTest {

    @Autowired
    ProducerRepository producerRepository;

    @Autowired
    ProductRepository productRepository;

    @Test
    @Transactional
    void relationshipTest() {
        Product product1 = saveProduct("동글펜", 500, 1000);
        Product product2 = saveProduct("네모 공책", 100, 2000);
        Product product3 = saveProduct("지우개", 152, 1234);

        Producer producer1 = saveProducer("flature");
        Producer producer2 = saveProducer("wikibooks");

        producer1.addProduct(product1);
        producer1.addProduct(product2);

        producer2.addProduct(product2);
        producer2.addProduct(product3);

        producerRepository.saveAll(Lists.newArrayList(producer1, producer2));

        System.out.println(producerRepository.findById(1L).get().getProducts());
    }

		private Product saveProduct(String name, Integer price, Integer stock) {
		        Product product = new Product();
		        product.setName(name);
		        product.setPrice(price);
		        product.setStock(stock);
		
		        return productRepository.save(product);
		    }
		
		    private Producer saveProducer(String name) {
		        Producer producer = new Producer();
		        producer.setName(name);
		
		        return producerRepository.save(producer);
		    }
}

 

다대다 양방향 매핑

// 상품 엔티티에서 생산업체 엔티티 연관관계 설정
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
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 // 예제 9.8
    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);
    }

}
// 다대다 양방향 연관관계 테스트
@Test
    @Transactional
    void relationshipTest2() {
        Product product1 = saveProduct("동글펜", 500, 1000);
        Product product2 = saveProduct("네모 공책", 100, 2000);
        Product product3 = saveProduct("지우개", 152, 1234);

        Producer producer1 = saveProducer("flature");
        Producer producer2 = saveProducer("wikibooks");

        producer1.addProduct(product1);
        producer1.addProduct(product2);
        producer2.addProduct(product2);
        producer2.addProduct(product3);

        product1.addProducer(producer1);
        product2.addProducer(producer1);
        product2.addProducer(producer2);
        product3.addProducer(producer2);

        producerRepository.saveAll(Lists.newArrayList(producer1, producer2));
        productRepository.saveAll(Lists.newArrayList(product1, product2, product3));

        System.out.println("products : " + producerRepository.findById(1L).get().getProducts());

        System.out.println("producers : " + productRepository.findById(2L).get().getProducers());

    }
  • 다대다 연관관계를 설정하면 중간 테이블을 통해 연관된 엔티티의 값을 가져올 수 있다.
  • 다만 다대다 연관관계에서는 중간 테이블이 생성되기 때문에 예기치 못한 쿼리가 생길 수 있음
  • 즉 관리하기 힘든 포인트가 발생한다는 문제가 있다.
  • 다대다 연관관계의 한계를 극복하기 위해 중간 테이블 생성 대신 일대다 다대일로 연관관계를 맺을 수 있는 중간 엔티티로 승격시켜 JPA에서 관리할 수 있게 생성하는 것이 좋다.

 

영속성 전이

  • 영속성 전이(cascade)란 특정 엔티티의 영속성 상태를 변경할 때 그엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것을 의미

영속성 전이 적용

  • 여기서 사용할 엔티티는 상품 엔티티와 공급업체 엔티티
  • 예)한 가게가 새로운 공급업체와 계약하며 몇 가지 새 상품을 입고시키는 상황에 어떻게 영속성 전이가 적용이 되는 상
// 공급업체 엔티티에 영속성 전이 설정

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

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

}
// 영속성 전이 테스트

@Test
    void cascadeTest() {
        Provider provider = savedProvider("새로운 공급업체");

        Product product1 = savedProduct("상품1", 1000, 1000);
        Product product2 = savedProduct("상품2", 500, 1500);
        Product product3 = savedProduct("상품3", 750, 500);

        // 연관관계 설정
        product1.setProvider(provider);
        product2.setProvider(provider);
        product3.setProvider(provider);

        provider.getProductList().addAll(Lists.newArrayList(product1, product2, product3));

        providerRepository.save(provider);

    }

		private Provider savedProvider(String name) {
        Provider provider = new Provider();
        provider.setName(name);

        return provider;
    }

    private Product savedProduct(String name, Integer price, Integer stock) {
        Product product = new Product();
        product.setName(name);
        product.setPrice(price);
        product.setStock(stock);

        return product;
    }

 

고아 객체

  • JPA에서 고아(orphan)란 부모 엔티티와 연관관계가 끊어진 엔티티를 의미
  • JPA에는 이러한 고아 객체를 자동으로 제거하는 기능이 있다.
// 공급업체 엔티티에 고아 객체를 제거하는 기능을 추가

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

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

}
// 고아 객체의 제거 기능 테스트

@Test
    @Transactional
    void orphanRemovalTest() {
        Provider provider = savedProvider("새로운 공급업체");

        Product product1 = savedProduct("상품1", 1000, 1000);
        Product product2 = savedProduct("상품2", 500, 1500);
        Product product3 = savedProduct("상품3", 750, 500);

        // 연관관계 설정
        product1.setProvider(provider);
        product2.setProvider(provider);
        product3.setProvider(provider);

        provider.getProductList().addAll(Lists.newArrayList(product1, product2, product3));

        providerRepository.saveAndFlush(provider);

        System.out.println("## Before Removal ##");
        System.out.println("## provider list ##");
        providerRepository.findAll().forEach(System.out::println);

        System.out.println("## product list ##");
        productRepository.findAll().forEach(System.out::println);

        // 연관관계 제거
        Provider foundProvider = providerRepository.findById(1L).get();
        foundProvider.getProductList().remove(0);

        System.out.println("## After Removal ##");
        System.out.println("## provider list ##");
        providerRepository.findAll().forEach(System.out::println);

        System.out.println("## product list ##");
        productRepository.findAll().forEach(System.out::println);
    }

 

정리

  • 이번 장에서는 연관관계를 설정하는 방법과 영속성 전이라는 개념을 알아봄
  • JPA 사용 시 영속이라는 개념은 매우 중요
  • 코드를 통해 편리하게 DB에 접근하기 위해서는 중간에서 엔티티의 상태를 조율하는 영속성 컨텍스트가 어떻게 동작하는지 이해해야 한다.

+ Recent posts