[스프링] 9. 연관관계 매핑

PYOUNANI·2023년 11월 27일
0

Spring

목록 보기
29/31
post-thumbnail

연관관계 매핑 종류와 방향

RDBMS를 사용할 때는 테이블 하나만 사용해서 애플리케이션의 모든 기능을 구현하기란 불가능하다. 대체로 설계가 복잡해지면 각 도메인에 맞는 테이블을 설계하고 연관관계를 설정해서 조인(join) 등의 기능을 활용한다. JPA를 사용하는 애플리케이션에서도 테이블의 연관관계를 엔티티 간의 연관관계로 표현할 수 있다. 다만 객체와 테이블의 성질이 달라 정확한 연관관계를 표현할 수는 없다. 이번 장에서는 JPA에서 이러한 제약을 보완하면서 연관관계를 매핑하고 사용하는 방법을 알아보겠다.

연관관계 매핑 종류와 방향

연관관계를 맺는 두 엔티티 간에 생성할 수 있는 연관관계의 종류는 다음과 같다.

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

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

데이터베이스에서는 두 테이블의 연관관계를 설정하면 외래키를 통해 서로 조인해서 참조하는 구조로 생성되지만 JPA를 사용하는 객체지향 모델링에서는 엔티티 간 참조 방향을 설정할 수 있다. 데이터베이스와 관계를 일치시키기 위해 양방향으로 설정해도 무관하지만 비즈니스 로직의 관점에서 봤을 때는 단방향 관계만 설정해도 해결되는 경우가 많다. 이러한 단방향과 양방향 관계에 대해 간단하게 정리하면 다음과 같다.

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

연관관계가 설정되면 한 테이블에서 다른 테이블의 기본값을 외래키로 갖게 된다. 이런 관계에서는 주인(Owner)이라는 개념이 사용된다. 일반적으로 외래키를 가진 테이블이 그 관계의 주인이 되며, 주인은 외래키를 사용할 수 있으나 상대 엔티티는 읽는 작업만 수행할 수 있다.

일대일 매핑

일대일 매핑

먼저 두 엔티티 간에 일대일 매핑을 만들어 보겠다. 우선 지금까지 사용해온 Product 엔티티를 대상으로 아래와 같이 일대일 매핑될 상품 정보 테이블을 생성한다.

위와 같이 하나의 상품에 하나의 상품 정보만 매핑되는 구조는 일대일 관계라고 볼 수 있다.

일대일 단방향 매핑

프로젝트 entity 패키지 안에 아래와 같이 상품정보 엔티티를 작성한다. 상품정보에 대한 도메인은 ProductDetail로 설정해서 진행하겠다.

@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 어노테이션을 사용해 매핑할 외래키를 설정한다. @JoinColumn 어노테이션은 기본값이 설정돼 있어 자동으로 이름을 매핑하지만 의도한 이름이 들어가지 않기 때문에 name속성을 사용해 원하는 칼럼명을 지정하는 것이 좋다. 만약 @JoinColumn어노테이션에서 사용할 수 있는 속성을 설명하면 다음과 같다.

  • name : 매핑할 외래키의 이름을 설정한다.
  • referencedColumnName : 외래키가 참조할 상대 테이블의 칼럼명을 지정한다.
  • foreignKey : 외래키를 생성하면서 지정할 제약조건을 설정한다.(unique, nullable, insertable, updateable 등).

이렇게 엔티티 클래스를 생성하면 단방향 관계의 일대일 관계 매핑이 완성된다. hibernate.dll-auto의 값을 create로 설정한 후 애플리케이션을 실행하면 하이버네이트에서 자동으로 테이블을 생성하며 아래와 같이 데이터베이스의 테이블을 확인할 수 있다.

생성된 상품정보 엔티티 객체들을 사용하기 위해 리포지토리 인터페이스를 생성한다. 아래와 같이 기존에 작성했던 ProductRepository와 동일한 형식으로 작성한다.

public interface ProductDetailRepository extends JpaRepository<ProductDetail, Long> {

}

그럼 연관관계를 활용한 데이터 생성 및 조회 기능을 테스트 코드로 간략하게 작성해보겠다.

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
public 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("savedProduct : " + productDetailRepository.findById(productDetail.getId()).get());
    }
}

@OneToOne 어노테이션 인터페이스를 확인해보자.

public @interface OneToOne {
    Class targetEntity() default void.class;

    CascadeType[] cascade() default {};

    FetchType fetch() default FetchType.EAGER;

    boolean optional() default true;

    String mappedBy() default "";

    boolean orphanRemoval() default false;
}

이후에 더 자세히 살펴볼 예정이므로 여기서는 fetch() 요소와 optional() 요소만 보겠다. @OneToOne 어노테이션은 기본 fetch 전략으로 EAGER, 즉 즉시 로깅 전략이 채택된 것을 볼 수 있다. 그리고 optional() 메서드는 기본값으로 true 가 설정돼 있다. 기본값이 true인 상태는 매핑되는 값이 nullable이라는 것을 의미한다.

일대일 양방향 매핑

이번에는 앞에서 생성한 일대일 단방향 설정을 양방향 설정으로 변경해 보겠다. 사실 객체에서의 양방향 개념은 양쪽에서 단방향으로 서로를 매핑하는 것을 의미한다. 일대일 양방향 매핑을 위해서는 아래와 같이 Product 엔티티를 추가한다.

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
@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
    private ProductDetail productDetail;
    
}

다대일, 일대다 매핑

다대일, 일대다 매핑

상품 테이블과 공급업체 테이블은 아래와 같이 상품 테이블의 입장에서 볼 경우에는 다대일, 공급업체 테이블의 입장에서 볼 경우에는 일대다 관계로 볼 수 있다. 이런 관계는 어떻게 구현해야 할지 직접 매핑하면서 알아보겠다.

다대일 단방향 매핑

먼저 공급업체 테이블에 매핑되는 엔티티 클래스를 만들겠다. 아래와 같이 엔티티 클래스를 생성할 수 있다.

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

공급업체는 Provider라는 도메인을 사용해 정의했다. 공급업체의 정보를 담는다면 더 많은 칼럼이 필요하겠지만 간단한 실습을 위해 필드로는 id와 name만 작성한다. 여기에 BaseEntity를 통해 생성일자와 변경일자를 상속받는다.

상품 엔티티에서는 공급업체의 번호를 받기 위해 다음과 같이 엔티티 필드의 구성을 추가해야 한다. 아래와 같이 상품 엔티티에 필드를 추가하겠다.

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

}

당장은 사용하지 않지만 이후 공급업체 엔티티를 활용할 수 있게 리포지토리를 생성한다. 기존에 리포지토리를 생성했던 방식과 동일하게 아래와 같이 코드를 작성한다.

public interface ProviderRepository extends JpaRepository<Provider, Long> {
    
}

다대일 양방향 매핑

앞에서 상품 엔티티와 공급업체 엔티티 사이에 다대일 단방향 연관관계를 설정했다. 이제 반대로 공급업체를 통해 등록된 상품을 조회하기 위한 일대다 연관관계를 설정해 보겠다. 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", fetch = FetchType.EAGER)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();
    
}

일대다 연관관계의 경우 여러 상품 엔티티가 포함될 수 있어 18번 줄과 같이 컬렉션(Collection, List, Map) 형식으로 필드를 생성한다. 이렇게 @OneToMany가 붙은 쪽에서 @JoinColumn 어노테이션을 사용하면 상대 엔티티에 외래키가 설정된다. 또한 롬복의 ToString에 의해 순환참조가 발생할 수 있어 17번 줄과 같이 ToString에서 제외 처리를 하는 것이 좋다. ‘fetch = FetchType,EAGER‘로 설정한 것은 @OneToMany의 기본 fetch 전략이 Lazy이기 때문에 즉시 로딩으로 조정한 것이다. 아퓨에서 진행할 테스트에서 지연 로딩 방식을 사용하면 ‘no Session’으로 에러가 발생하기 때문에 조정했다.

Provider 엔티티 클래스를 수정해도 애플리케이션을 가동해보면 칼럼은 변경되지 않는다. mappedBy로 설정된 필드는 칼럼에 적용되지 않는다. 즉, 양쪽에서 연관관계를 설정하고 있을 때 RDBMS의 형식처럼 사용하기 위해 mappedBy를 통해 한쪽으로 외래키 관리를 위임한 것이다.

👀 자연로딩 VS 즉시로딩
JPA에서 지연로딩(lazy loading)과 즉시로딩(eager loading)은 중요한 개념이다. 엔티티라는 객체의 개념으로 데이터베이스를 구현했기 때문에 연관관계를 가진 각 엔티티 클래스에는 연관관계가 있는 객체들이 필드에 존재하게 된다.

연관관계와 상관없이 즉각 해당 엔티티의 값만 조회하고 싶거나 연관관계를 가진 테이블의 값도 조회하고 싶은 경우 등 여러 조건들을 만족하기 위해 당장한 개념이 지연로딩과 즉시로딩이다.

일대다 단방향 매핑

앞에서 다대일 연관관계에서의 단방향과 양방향 매핑을 살펴봤다. 이번에는 일대다 단방향 매핑 방법을 알아보겠다. 참고로 일대다 양방향 매핑은 다루지 않을 예정이다. 그 이유는 @OneToMany를 사용하는 입장에서는 어느 엔티티 클래스도 연관관계의 주인이 될 수 없기 때문이다. @OneToMany관계에서 주인이 될 수 없는 이유는 이번 절에서 함께 다루겠다.

먼저 실습을 위해 새로운 엔티티를 생성하겠다. 아래와 같이 상품분류 테이블을 추가한다.

위의 테이블 구조와 맞추기 위한 상품 분류 엔티티를 아래와 같이 생성한다. 상품 분류의 도메인 이름은 Category로 하겠다.

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

상품 분류 엔티티에서 @OneToMany와 @JoinColumn을 사용하면 상품 엔티티에서 별도의 설정을 하지 않아도 일대다 단방향 연관관계가 매핑된다. 앞에서 언급한 것처럼 @JoinColumn어노테이션은 필수 사항은 아니다. 이 어노테이션을 사용하지 않으면 중간 테이블로 join 테이블이 생성되는 전략이 채택된다.

다대다 매핑

다대다 매핑

다대다(M:N) 연관관계는 실무에서 거의 사용되지 않는 구성이다. 다대다 연관관계를 상품과 생산업체의 예로 들자면 한 종류의 상품이 여러 생산업체를 통해 생산될 수 있고, 생산업체 한 곳이 여러 상품을 생산할 수도 있다.

다대다 연관관계에서는 각 엔티티에서 서로를 리스트로 가지는 구조가 만들어진다. 이런 경우에는 교차 엔티티라고 부르는 중간 테이블을 생성해서 다대다 관계를 일대다 또는 다대일 관계로 해소한다.

다대다 단방향 매핑

앞의 그림과 같은 연관관계를 가진 생산업체 엔티티를 생성해보겠다 생산업체에 매핑되는 도메인은 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);
    }
    
}

다대다 연관관계는 @ManyToMany 어노테이션으로 설정한다. 리스트로 필드를 가지는 객체에서는 외래키를 가지지 않기 때문에 별도의 @JoinColumn은 설정하지 않아도 된다. 이렇게 엔티티를 생성하고 애플리케이션을 실해0ㅇ하면 아래와 같이 생산업체 테이블이 생성된다.

다대다 양방향 매핑

다대다 단방향 매핑의 개념을 이해했다면 양방향 매핑을 하는 방법은 간단하다. 상품 엔티티에서 아래와 같이 작성한다.

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

}

필요에 따라 mappedBy 속성을 사용해 두 엔티티 간 연관관계의 주인을 설정할 수도 있다. 이렇게 설정한 후 애플리케이션을 실행하면 데이터베이스의 테이블 구조는 변경되지 않는다. 중간 테이블이 연관관계를 성정하고 있기 때문이다.

영속성 전이

영속성 전이

영속성 전이(cascade)란 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것을 의미한다. 예를 들어 @OneToMany어노테이션의 인터페이스를 살펴보면 아래와 같다.

public @interface OneToMany {
    Class targetEntity() default void.class;

    CascadeType[] cascade() default {};

    FetchType fetch() default FetchType.LAZY;

    String mappedBy() default "";

    boolean orphanRemoval() default false;
}

연관관계와 관련된 어노테이션을 보면 위와 같이 cascade()라는 요소를 볼 수 있다. 이 어노테이션은 영속성 전이를 설정하는 데 활용된다. cascade() 요소와 함께 사용하는 영속성 전이 타입은 아래와 같다.

여기서 알 수 있듯이 영속성 전이에 사용되는 타입은 엔티티 생명주기와 연관이 있다. 한 엔티티가 위와 같이 cascade요소 값으로 주어진 영속 상태의 변경이 일어나면 매핑으로 연관된 엔티티에도 동일한 동작이 일어나도록 전이를 발생시키는 것이다. 위 코드를 보면 cascade()요소의 리턴 타입은 배열 형식인 것을 볼 수 있다. 이 말은 개발자가 사용하고자 하는 cascade 타입을 골라 각 상황에 적용할 수 있다는 것이다.

영속성 전이 적용

이제 영속성 전이를 적용해 보겠다. 여기서 사용할 엔티티는 상품 엔티티와 공급업체 엔티티이다. 예를 들어, 한 가게가 새로운 공급업체와 계약하며 몇 가지 새 상품을 입고시키는 상황에 어떻게 영속성 전이가 적용되는지 살펴보겠다. 우선 엔티티를 데이터베이스에 추가하는 경우로 영속성 전이 타입으로 PERSIST를 지정하겠다.

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

}

영속성 전이 타입을 설정하기 위해서는 @OneToMany 어노테이션의 속성을 활용한다.

고아 객체

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", fetch = FetchType.EAGER, cascade = CascadeType.PERSIST, orphanRemoval = true)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();

}

‘orphanRemoval = true‘속성은 고아 객체를 제거하는 기능이다.

0개의 댓글