MariaDB같은 RDBMS를 사용할 떄는 테이블 하나만 사용해서 애플리케이션의 모든 기능을 구현하기란 불가능한다. 연관관계를 설정해서 조인(Join) 등의 기능을 활용해야 한다. 이번에는 JPA에서 연관관계를 매핑하고 사용하는 방법을 알아보도록 한다.

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

종류

  • One To One : 일대일(1:1)
  • One To Many : 일대다(1:N)
  • Many To One : 다대일(N:1)
  • Many To Many : 다대다(N:N)

재고관리시스템을 통해 상품을 관리하는 것을 예로 들어 이해해본다. 재고로 등록돼 있는 상품 엔티티에는 가게로 상품을 공급하는 공급업체의 정보 엔티티가 매핑되어있다. 공급업체 입장에서 보면 한 가게게 납품하는 상품이 여러개 있을 수 있으므로 상품 엔티티와는 일대다 관계가 되며, 상품 입장에서 보면 하나의 공급업체에 속하게 되므로 다대일 관계가 된다. 어떤 엔티티를 중심으로 연관 엔티티를 보느냐에 따라 연관관계의 상태가 달라진다.

방향

  • 단방향 : 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식
  • 양방향 : 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식

외래키를 가지고 있는 테이블이 그 관계으 ㅣ주인이된다.


✅일대일 매핑

하나의 상품에 하나의 상품정보만 매핑되는 구조를 일대일 매핑이라고 한다.

▶️ 단방향 매핑

ProductDetail 엔티티를 만들어준다. 이때 @OneToOne, @JoinColum 어노테이션을 붙여준다.

@Entity
@Table(name = "product_detail")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@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;

}

테스트코드를 작성하고 실행한다.

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

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

[ 결과 ]

  • savedProduct : Product(number=1, name=스프링부트 JPA, price=5000, stock=500, provider=null)
  • savedProductDetail : ProductDetail(super=BaseEntity(createdAt=2023-12-30T23:43:55.430458, updateAt=2023-12-30T23:43:55.430458), id=1, description=스프링 부트 JPA 일대일 연관관계, product=Product(number=1, name=스프링부트 JPA, price=5000, stock=500, provider=null))

select 구문을 보면 ProductDetail과 Prodcut 객체가 함께 조회되는 것을 볼 수 있다. 이처럼 엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 것을 즉시 로딩이라고 한다.
그리고 이때 join 부분을 보면 두번의 join을 하는 것을 볼 수 있다. 이는 @OneToOne어노테이션 때문이다.

Fetch 전략

@OneToOne 어노테이션은 fetch전략으로 EAGER를 사용하낟. 즉, 즉시 로딩 전략이 채택된 것이다. 이때 두번의 join이 아니라 한번의 join(최적의 쿼리)을 위해서는 @OneToOne 어노테이션에 optional=false 속성을 주어야한다.

▶️ 양방향 매핑

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

Product 엔티티에 @OneToOne 어노테이션 추가해준다.


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

이번에는 left join을 두번 수행한다. 이처럼 양쪽에서 외래키를 가지고 left join을 두번이나 수행되면 효율성이 떨어진다. 실제 테이블 간 연관관계를 맺으면 한쪽 테이블이 외래키를 가지는 구조로 이루어진다. 이를 위해서 mappedBy 속성을 사용하여 어떤 객체가 주인인지 표시해주어야 한다.


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

✅일대다, 다대일 매핑

하나의 공급업체가 여러개의 상품에 매핑되는 구조를 일대다 매핑, 중점을 상품에 두면 다대일 매핑이 된다.

▶️ 단방향 매핑

Provider 엔티티를 만들어준다.

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

테스트코드를 작성하고 실행시킨다.

@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).get().getProvider().toString());
    }

}

[ 결과 ]

쿼리로 저장할 때는 provider_id값만 들어가는 것을 볼 수 있다. 이렇게 테이블에 @JoinColumn에 설정한 이름을 기반으로 자동으로 값을 선정해서 추가하게 된다.

  • product : Product(number=1, name=가위, price=5000, stock=500, provider=Provider(super=BaseEntity(createdAt=2023-12-30T23:56:46.806549, updateAt=2023-12-30T23:56:46.806549), id=1, name=자바물산))
  • provider : Provider(super=BaseEntity(createdAt=2023-12-30T23:56:46.806549, updateAt=2023-12-30T23:56:46.806549), id=1, name=자바물산)

▶️ 양방향 매핑


@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", fetch = FetchType.EAGER)

    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();

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

        providerRepository.save(provider);

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

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

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

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

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

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


✅다대다 매핑

여러개의 상품을 여러개의 생산자가 생산하는 구조를 다대다 매핑이라고 한다. 하지만 이는 실무에서 거의 사용되지 안흔 구조이다.
다대다 연관관계에서는 각 엔티티에서 서로를 리스트로 가지는 구조가 만들어진다.

▶️ 단방향 매핑


@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> productList = new ArrayList<>();

    public void addProduct(Product product){
        productList.add(product);
    }

}


@SpringBootTest
class ProducerRepositoryTest {

    @Autowired
    ProducerRepository producerRepository;

    @Autowired
    ProductRepository productRepository;

    @Test
    @Transactional
    void relationshipTest1(){
        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().getProductList());

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

가독성을 위해 레포지토리를 통해 테스트를 생성하는 부분을 별도 메서드로 빼서 구현했다.

[ 결과 ]

  • [Product(number=1, name=동글펜, price=500, stock=1000), Product(number=2, name=네모 공책, price=100, stock=2000)]

▶️ 양방향 매핑


@Entity
@Table(name = "product")
@ToString
@Getter
@Setter
@NoArgsConstructor
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> producerList = new ArrayList<>();

    public void addProducer(Producer producer){
        this.producerList.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(producerRepository.findById(1L).get().getProductList());
        System.out.println(productRepository.findById(1L).get().getProducerList());

    }

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

[ 결과 ]

  • [Product(number=1, name=동글펜, price=500, stock=1000), Product(number=2, name=네모 공책, price=100, stock=2000)][Producer(super=BaseEntity(createdAt=2023-12-31T00:17:37.868523, updateAt=2023-12-31T00:17:37.868523), id=1, code=null, name=flature)]

✅영속성 전이

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

영속성 타입의 정류

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

영속성 전이 적용

영속성 전이를 적용할 Provider 엔티티를 다음과 같이 수정해 줍니다.

@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)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();

}

@OneToMany 어노테이션에 cascade속성을 활용한다.

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

}

지금까지는 엔티티를 데이터베이스에 저장하기 위해 각 엔티티를 저장하는 코드를 작성해야 했다. 하지만 영속성 전이를 사용하면 부모 엔티티가 되는 Provider 엔티티만 저장하면 코드에 작성되어 있는 Cascade.PERSIST에 맞춰 상품 엔티티도 함께 저장할 수 있게 된다. 이를 통해 개발의 생산성이 높아질 수 있다.

고아 객체

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

}

orphanRemoval = true 속성으로 고아 객체를 제거하는 기능을 활성화한다.

@Test
    @Transactional
    void orphanRemoval(){
        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);

        providerRepository.findAll().forEach(System.out::println);
        productRepository.findAll().forEach(System.out::println);

        Provider foundProvider = providerRepository.findById(1L).get();
        foundProvider.getProductList().remove(0);

        providerRepository.findAll().forEach(System.out::println);
        productRepository.findAll().forEach(System.out::println);
    }


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

[ 결과 ]

연관관계 끊기 전

연관관계 끊기 전

출력 결과를 보면 실제로 연관관계가 제거되면서 하이버네이트에서는 상태 감지를 통해 삭제하는 쿼리가 수행되는 것을 볼 수있다.
Product1의 연관관계가 끊어지면서 제거된 것을 전, 후 비교로 알 수 있다.

😁 마무리

연관관계를 설정함으로 더 편하게 데이터를 저장할 수 있고 사용할 수 있다는 것을 알게되었다. 또한 영속성 전이를 통해서 연관관계가 끊어진 객체가 자동으로 삭제되기도 하고 자동으로 저장되기도 하는 기능을 배웠다. 항상 save매서드를 반복적으로 사용했었는데 실제로 프로젝트에서 사용한다면 시간적 효율을 증가시킬 수 있겠다는 생각이 들었다.

또한 이번 챕터를 공부하면서 궁금했던 점은 왜 테스트코드에서 @Transactional 어노테이션을 사용했는지와, saveAndFlush() 메서드가 save()와 다른점이 무엇인가 였다. 코드를 이해하기 위해 간략하게는 찾아봤지만 아직 잘 이해가 되지 않아서 추가적인 공부가 필요할것 같다.

profile
다 잘하고 싶은 개발자

0개의 댓글