연관관계 매핑
RDBMS를 사용할 때는 테이블 하나만 사용해서 애플리케이션의 모든 기능을 구현하기 불가능하다. 따라서 설계가 복잡해지면 각 도메인에 맞는 테이블을 설계하고 연관관계를 설정해서 조인(Join)등의 기능을 활용한다.
JPA를 사용하는 애플리케이션에서도 테이블의 연관관계를 엔티티 간의 연관관계로 표현할 수 있다. 다만 객체와 테이블의 성질이 달라서 정확한 연관관계를 표현할 수는 없다.
연관관계 매핑 종류와 방향
연관관계를 맺는 두 엔티티 간에 생성할 수 있는 연관관계의 종류는 다음과 같습니다.
- One To One : 일대일 (1 : 1)
- One to Many : 일대다 (1 : N)
- Many To One : 다대일 (N : 1)
- Many to Many : 다대다 (N : M)
연관관계를 이해하기 위해 한 가게가 재고관리시스템을 통해 상품을 관리하고 있다고 가정해보자. 재고로 등록돼 있는 상품 엔티티에는 가게로 상품을 공급하는 공급업체의 정보 엔티티가 매핑돼 있다. 공급업체 입장에서는 한 가게에 납품하는 상품이 여러 개 있을 수 있으므로 상품 엔티티와는 일대다 관계가 되며, 상품 입장에서 보면 하나의 공급업체에 속하게 되므로 다대일 관계가 된다. 즉, 어떤 엔티티를 중심으로 연관 엔티티를 보느냐에 따라 연관관계의 상대가 달라집니다.
데이터베이스에서는 두 테이블의 연관관계를 설정하면 외래키를 통해 서로 조인해서 참조하는 구조로 생성되지만 JPA를 사용하는 객체지향 모델링에서는 엔티티 간 참조 방향을 설정할 수 있다. 데이터베이스와 관계를 일치시키기 위해 양방향으로 설정해도 무관하지만 비즈니스 로직의 관점에서 봤을 때는 단방향 관계만 설정해도 해결되는 경우가 많다.
- 단방향 : 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식이다.
- 양방향 : 두 엔티티의 관계에서 각 엔티티가 서로 엔티티를 참조하는 형식이다.
연관관계가 설정되면 한 테이블에서 다른 테이블의 기본값을 외래키로 갖게 된다. 이런 관계에서는 주인(Owner)라는 개념이 사용된다. 일반적으로 외래키를 가진 테이블이 그 관계의 주인이고, 주인은 외래키를 사용할 수 있지만 상대 엔티티는 읽는 작업만 수행할 수 있다.
일대일 매핑
두 엔티티 간에 일대일 매핑을 만들어봅니다. 하나의 상품에 하나의 상품정보만 매핑되는 구조는 일대일 관계라고 볼 수 있습니다.
일대일 단방향 매핑
@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”을 이용한다.
@OneToOne엔티티는 다른 엔티티 객체를 필드로 정의했을 때 일대일 연관관계로 매핑하기 위해 사용됩니다.
그리고 @JoinColumn 어노테이션을 사용해 매핑할 외래키를 설정합니다. @JoinColumn 어노테이션은 기본값이 설정돼 있어 자동으로 이름을 매핑하지만 의도한 이름이 들어가지 않기 때문에 name 속성을 사용해 원하는 칼럼명을 지정하는 것이 좋습니다. 만약 @JoinColumn을 선언하지 않으면 엔티티를 매핑하는 중간 테이블이 생기면서 관리 포인트가 늘어나 좋지 않습니다.
- name : 매핑할 외래키의 이름을 설정한다.
- referencedColumnName : 외래키가 참조할 상대 테이블의 칼럼명을 지정한다.
- foreignKey : 외래키를 생성하면서 지정할 제약조건을 설정한다. (unique, nullable, insertable, updatable 등)
이렇게 엔티티 클래스를 생성하면 단방향 관계의 일대일 관계 매핑이 완성됩니다.
그리고, 생성된 상품정보 엔티티 객체들을 사용하기 위해 리포지토리 인터페이스를 생성합니다.
public interface ProductDetailRepository extends JpaRepository<ProductDetail, Long> {
}
일대일 양방향 매핑
객체에서의 양방향 개념은 양쪽에서 단방향으로 서로를 매핑하는 것을 의미합니다. 일대일 양방향 매핑을 위해서 아래와 같이 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
private ProductDetail productDetail;
}
이렇게 작성하고 애플리케이션을 실행하면 product 테이블에도 칼럼이 생성됩니다.
JPA에서도 실제 데이터베이스의 연관관계를 반영해서 한쪽의 테이블에서만 외래키를 바꿀 수 있도록 정하는것이 좋습니다. 이 경우 엔티티는 양방향으로 매핑하되 한쪽에게만 외래키를 줘야하는데, 이때 사용되는 속성 값이 mappedBy입니다.mappedBy는 어떤 객체가 주인인지 표시하는 속성입니다.
“@OneToOne” 어노테이션에 “mappedBy” 속성값을 사용해주면 됩니다.
@OneToOne(mappedBy = "product")
private ProductDetail productDetail;
이 설정을 하게되면 “ProductDetail” 엔티티가 “Product” 엔티티의 주인이 됩니다.
이렇게 애플리케이션을 실행하게 되면 외래키가 사라지는 것을 볼 수 있습니다. “toString”을 실행하는 시점에서 StackOverflowError가 발생하는것인데, 양방향으로 연관관계가 설정되면 “ToString”을 사용할 때 순환참조가 발생하기 때문입니다.
그렇기 때문에 필요한 경우가 아니라면 대체로 단방향으로 연관관계를 설정하거나 양방향 설정이 필요할 경우에는 순환참조 제거를 위해 “exclude”를 사용해 “ToString”에서 제외 설정을 하는것이 좋습니다.
@OneToOne(mappedBy = "product")
@ToString.Exclude
private ProductDetail productDetail;
다대일, 일대다 매핑
상품 테이블과 공급업체 테이블은 다음 그림과 같이 상품 테이블의 입장에서 볼 경우에 다대일, 공급업체 테이블의 입장에서 볼 경우에는 일대다 관계로 볼 수 있다.
다대일 단방향 매핑
다음은 공급업체 엔티티 클래스이다.
@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
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")
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
}
일대다 연관관계의 경우 여러 상품 엔티티가 포함될 수 있어 컬렉션(Collection, List, Map) 형식으로 필드를 생성한다. 이렇게 “@OneToMany”가 붙은 쪽에서 “@JoinColumn” 어노테이션을 사용하면 상대 엔티티에 외래키가 설정된다. 또한 Lombok의 ToString에 의해 순환참조가 발생할 수 있어 ToString에서 제외 처리를 하는 것이 좋다.
“@OneToMany” 속성에서 사용한 “fetch = FetchType.EAGER”로 설정한 것은 “@OneToMany”의 기본 fetch 전략이 Lazy이기 때문에 즉시 로딩으로 조정한 것이다.
지연 로딩 방식을 사용하게 되면 ‘no Session’으로 에러가 발생하기 때문이다.
<aside> 💡 지연로딩과 즉시로딩
JPA에서 지연로딩(lazy loading)과 즉시로딩(eager loading)은 중요한 개념이다. 엔티티라는 객체의 개념으로 데이터베이스를 구현했기 때문에 연관관계를 가진 각 엔티티 클래스에는 연관관계가 있는 객체들이 필드에 존재하게 된다. 연관관계와 상관없이 즉각 해당 엔티티의 값만 조회하고 싶거나 연관관계를 가진 테이블의 값도 조회하고 싶은 경우 등 여러 조건들을 만족하기 위해 등장한 개념이 지연로딩과 즉시로딩이다.
</aside>
다대다 매핑
다대다(M:N) 연관관계는 실무에서 거의 사용되지 않는 구성이다. 다대다 연관관계를 상품과 생산업체의 예로 들자면 한 종류의 상품이 여러 생산업체를 통해 생산될 수 있고, 생산업체 한 곳이 여러 상품을 생산할 수도 있다.
다대다 단방향 매핑
다음으로 생산업체 엔티티를 만들어보자.
@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”은 설정하지 않아도 된다. 이렇게 엔티티를 생성하고 애플리케이션을 실행하면 생산업체 테이블이 생성된다.
그리고 생산업체 엔티티에 대한 리포지토리를 생성한다.
public interface ProducerRepository extends JpaRepository<Producer, Long> {
}
다대다 양방향 매핑
다대다 양방향 매핑은 상품 엔티티에서 생산업체 엔티티 연관관계 설정으로 진행한다.
@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);
}
영속성 전이
영속성 전이(cascade)란 특저 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것을 의미한다.
다음은 “@OneToMany” 어노테이션의 인터페이스에 있는 cascade 요소이다.
public @interface OneToMany {
/**
* (Optional) The entity class that is the target
* of the association. Optional only if the collection
* property is defined using Java generics.
* Must be specified otherwise.
*
* <p> Defaults to the parameterized type of
* the collection when defined using generics.
*/
Class targetEntity() default void.class;
/**
* (Optional) The operations that must be cascaded to
* the target of the association.
* <p> Defaults to no operations being cascaded.
*
* <p> When the target collection is a {@link java.util.Map
* java.util.Map}, the <code>cascade</code> element applies to the
* map value.
*/
CascadeType[] cascade() default {};
/** (Optional) Whether the association should be lazily loaded or
* must be eagerly fetched. The EAGER strategy is a requirement on
* the persistence provider runtime that the associated entities
* must be eagerly fetched. The LAZY strategy is a hint to the
* persistence provider runtime.
*/
FetchType fetch() default LAZY;
/**
* The field that owns the relationship. Required unless
* the relationship is unidirectional.
*/
String mappedBy() default "";
/**
* (Optional) Whether to apply the remove operation to entities that have
* been removed from the relationship and to cascade the remove operation to
* those entities.
* @since 2.0
*/
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", cascade = CascadeType.PERSIST)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
}
영속성 전이 타입을 설정하기 위해 “@OneToMany” 어노테이션의 속성을 활용한다.
고아 객체
JPA에서 고아(oprhan)란 부모 엔티티와 연관관계가 끊어진 엔티티를 의미한다. 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<>();
}
“@OneToMany” 어노테이션에 속성값으로 “orphanRemoval = true”값을 넣어주면 된다.
이 속성은 고아 객체를 제거해주는 기능을 한다.
정리
JPA를 사용할 때 영속이라는 개념은 매우 중요하다. 코드를 통해 편리하게 데이터베이스에 접근하기 위해서는 중간에서 엔티티의 상태를 조율하는 영속성 컨텍스트가 어떻게 동작하는지 이해해야 한다.
이 과정에서 하이버네이트를 직접 사용하는 것과 Spring Data JPA를 사용하는 데는 차이가 있다.
따라서, 하이버네이트만 사용하는 JPA도 함께 공부하는것이 학습에 좋다. JPA 자체를 그대로 다뤄보는 경험을 해보면 DAO와 리포지토리의 차이에 대해서도 더 알 수 있게 되고 스프링 부트 JPA에 대해서도 폭 넓게 이해할 수 있다.
'Backend > Spring Boot' 카테고리의 다른 글
Spring boot - 트랜잭션(Transaction) (0) | 2023.12.07 |
---|---|
Spring Boot - 용어 (0) | 2023.08.11 |
Spring Boot - MVC 모델 (0) | 2023.07.30 |
Spring Boot - Spring Data JPA 활용 (0) | 2023.07.29 |
Spring Boot - API를 작성하는 다양한 방법 (0) | 2023.07.23 |