<목차>
1. 개요
2. 1 : 1 관계
3. 1 : N / N : 1 관계
4. N : M 관계
[개요]
테이블이 많아지고 설계가 복잡해지는 경우,
RDBMS를 사용하고 있다면, 테이블 간의 연관관계를 설정하면, 외래키를 통해 서로 조인하여 참조하는 구조로 사용할 수 있지만,
JPA를 사용하는 객체지향 모델링에서는 엔티티 간 참조 방향을 설정할 수 있다.
데이터베이스와 관계를 일치시키기 위해 양방향으로 설정해도 무관하나, 단방향 설정을 지향하자.
단방향 / 양방향
- 단방향 : 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식
- 양방향 : 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식
연관관계를 설정하게되면, 한 테이블에서 다른 테이블의 기본값(ID / PK) 를 외래키로 갖도록 생성된다.
이런 관계에서, 외래키를 가진 테이블의 그 관계의 Owner 가 되며, Owner 는 외래키를 사용할 수 있으나, 상대 엔티티는 읽는 기능만 수행할 수 있다.
연관관계의 종류
- 1 : 1 / 일대일 관계 / @OneToOne
- 1 : N / 일대다 관계 / @OneToMany
- N : 1 / 다대일 관계 / @ManyToOne
- N : M / 다대다 관계 / @ManyToMany
연관 관계를 설정하기에 앞서, 각 어노테이션 인터페이스의 구성에 대해 살펴보자.
- @OneToOne (일대일)
* targetEntity() : 연관된 엔티티의 타입을 명시적으로 지정할 때 사용되지만, JPA가 자동으로 연관된 엔티티의 타입을 추론할 수 있기 때문에 잘 사용되지 않는다.
* cascade() : 관련 엔티티에서 수행되는 작업이 이 관계에서도 동일하게 수행되도록 하는 데 사용된다.
엔티티의 생명주기와 연관이 있는 내용으로, 한 엔티티가 cascade 요소의 값으로 주어진 영속 상태의 변경이 일어나면, 매핑으로 연관된 엔티티에도 동일한 동작이 일어나도록 전이를 발생시키는 것.
영속성 전이 타입설명사용 예시
타입 | 설명 | 예시 |
ALL | 모든 영속성 전이 동작을 포함. PERSIST, MERGE, REMOVE, REFRESH, DETACH 모두 적용 |
부모 엔티티와 연관된 자식 엔티티를 함께 관리해야 하는 경우. 예: 주문과 주문 항목(Order와 OrderItem). |
PERSIST | 부모 엔티티가 persist 상태가 될 때 연관된 자식 엔티티도 함께 persist 상태가 되도록 함. | 부모 엔티티를 저장할 때 연관된 자식 엔티티도 함께 저장해야 하는 경우. 예: 새로 생성된 부모와 자식 엔티티를 함께 저장. |
MERGE | 부모 엔티티가 merge 상태가 될 때 연관된 자식 엔티티도 함께 merge 상태가 되도록 함. | 부모 엔티티를 업데이트할 때 연관된 자식 엔티티도 함께 업데이트해야 하는 경우. 예: 부모와 자식 엔티티를 함께 수정. |
REMOVE | 부모 엔티티가 삭제될 때 연관된 자식 엔티티도 함께 삭제되도록 함. | 부모 엔티티를 삭제할 때 연관된 자식 엔티티도 함께 삭제해야 하는 경우. 예: 게시글(Post)과 댓글(Comment) 관계. |
REFRESH | 부모 엔티티가 새로 고쳐질 때 연관된 자식 엔티티도 함께 새로 고쳐지도록 함. | 부모 엔티티의 상태를 새로 고칠 때 연관된 자식 엔티티의 상태도 새로 고쳐야 하는 경우. 예: 캐시된 상태를 초기화할 때. |
DETACH | 부모 엔티티가 detach 상태가 될 때 연관된 자식 엔티티도 함께 detach 상태가 되도록 함. | 부모 엔티티를 영속성 컨텍스트에서 분리할 때 자식 엔티티도 함께 분리해야 하는 경우. 예: 부모와 자식의 영속성 분리. |
* fetch() : 엔티티가 데이터베이스에서 로드될 때 관련된 엔티티를 언제 가져올지를 지정합니다. 두 가지 주요 옵션이 있다.
-- FetchType.EAGER: 주 엔티티가 로드될 때 관련된 엔티티도 즉시 로드
-- FetchType.LAZY: 관련된 엔티티는 실제로 접근될 때 로드됩니다.
* optional() : 관계된 엔티티가 선택적인지 여부를 지정한다. true로 설정하면, 관계된 엔티티가 없을 수도 있다. false로 설정하면, 관계된 엔티티가 반드시 있어야 한다.
* mappedBy() : 양방향 관계에서 사용된다. 주 엔티티가 아닌, 종속 엔티티에서 주 엔티티의 관계를 나타내는 필드의 이름을 지정하며, 주 엔티티 쪽은 외래 키를 관리하지 않고, 외래 키를 종속 엔티티에서 관리하게 된다.
* orphanRemoval() : 부모 엔티티와의 관계가 제거될 때, 관련된 엔티티를 자동으로 삭제한다. cascade = CascadeType.REMOVE와 유사하지만, 관계가 분리될 때만 작동합니다.
- @OneToMany (일대다)
* 일대다 관계의 경우, 한 엔티티가 여러 엔티티와 관계를 가지는 관계를 표현하는 어노테이션이다.
* 단일 엔티티를 참조하는 것이 아니라, 여러 엔티티를 참조하는 컬렉션이기 때문에, 컬렉션 자체는 null이 될 수 있지만, optional 속성은 단일 관계에서만 의미가 있기 때문에, 다수의 관계를 나타내는 @OneToMany에서는 optional() 인터페이스는 사용하지 않는다.
* 또한, 일대다 관계에서 fetch() 를 EAGER 로 설정할 경우, 부모 엔티티를 조회할 때, 자식 엔티티를 모두 조회하여 메모리에 로드시키기 때문에 불필요한 메모리 사용이 발생하며, 1개의 부모 레코드를 조회하려는 동작에서, 자식 레코드가 3개인 경우, 3 레코드가 조회되는 등의 문제가 발생할 수 있기 때문에, LAZY를 기본 옵션으로 갖는다.
- @ManyToOne (다대일)
* 여러 엔티티가 하나의 엔티티와 관계를 맺는 다대일 관계를 표현하는 어노테이션
* orphanRemoval() 의 경우, ManyToOne 관계에서는 자식 엔티티가 하나의 부모 엔티티를 참조하는 방식이기 때문에, 자식 엔티티가 부모 엔티티와의 관계가 끊어지더라도 자식 엔티티를 삭제하는 기능이 제공되지 않는다.
* fetch() 또한 자식 엔티티가 하나의 부모 엔티티를 참조하는 방식이기 때문에, EAGER 를 전략으로 자식 엔티티가 로드될 때 부모 엔티티도 즉시 로드한다.
- @ManyToMany (다대다)
* 다대다 관계로, 각각의 자식 엔티티가 여러 부모 엔티티를 참조하는 방식이기 때문에, 자식 엔티티가 부모 엔티티와의 관계가 끊어지더라도 자식 엔티티를 삭제하는 기능이 제공되지 않는다. (orphanRemoval 없음)
* fetch() 또한 OneToMany 와 마찬가지 이유로 LAZY를 사용한다.
[1 : 1 관계]
실제 업무에서는 같은 테이블로 구성해도 무방한 수준의 테이블이나, 설명을 위해 분리한다고 가정한다.
1 : 1 관계 구성을 위해서는 @OneToOne 어노테이션을 사용한다.
또한, @JoinColumn 어노테이션을 활용하여 매핑할 외래키를 설정한다.
@JoinColumn
* 엔티티간 관계 매핑 시, Join 할 컬럼을 설정하지 않으면, 엔티티를 매핑하는 중간 테이블이 생성되며 관리포인트가 늘어나 좋지 않다.
-- name : 매핑할 외래키의 컬럼 이름을 설정한다.
-- referencedColumnName : 외래키가 참조할 상대 테이블의 컬럼명을 지정한다.
-- foreignKey : 외래키를 생성하면서 지정할 제약조건을 설정한다.
<ProductDetail> Entity
@Entity
@Table(name="product_detail")
@Data
@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;
}
<Product> Entity
@Entity
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(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;
// 1:1관계에서 양방향 외래키 설정 시, left outer join 2회 수행되어 효율성이 떨어짐
// mappedBy 설정하여, ProductDetail 엔티티가 Product 엔티티의 주인이 되도록 설정
@OneToOne(mappedBy = "product")
private ProductDetail productDetail;
... 이하 생략
※ 양방향 관계 설정 시 주의할 점이 있다.
별다른 속성을 주지 않고, 두 테이블에 서로 @OneToOne 어노테이션을 적용한 컬럼이 존재할 경우, JPA의 조회 쿼리에서는 같은 테이블을 중복으로 LEFT OUTER JOIN 하는 비효율적인 쿼리가 생성된다.
이를 방지하기 위해, 양방향 관계 설정 시 mappedBy 속성을 사용하여 한쪽에만 외래키를 주어야 한다.
mappedBy 의 값은, 연관 관계를 갖고있는 상태 엔티티에 있는 연관 관계 필드의 이름이 된다.
Product 엔티티의 아래 부분으로 예시를 들자면, ProductDetail 엔티티의 product 필드를 가진 컬럼과 같은 역할이라는 의미이다.
@OneToOne(mappedBy = "product")
private ProductDetail productDetail;
[ N : 1 / 1 : N 관계]
N : 1 / 1 : N 관계에서는 다음과 같은 구성이 가능하다.
- N : 1 단방향 관계
- N : 1, 1 : N 양방향 관계
- 1 : N 단방향 관계
N : 1 단방향 관계
N : 1 관계부터 살펴보자.
상품 테이블 입장에서는 N:1(다대일), 공급업체 테이블 입장에서는 1:N(일대다) 관계이다.
일반적으로 흔히 외래키를 설정하는 케이스는 이와 같은 관계인 경우가 많다.
따라서, Product 엔티티(N)에서 단반향으로 Provider 엔티티(1)와 연관관계를 맺기 위해서는 다음과 같이 구성할 수 있다.
<Provider> Entity
@Entity
@Data
@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;
}
<Product> Entity
@Entity
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(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;
//다대일 설정
@ManyToOne
@JoinColumn(name = "provider_id")
@ToString.Exclude
private Provider provider;
... 이하 생략
이와 같이 엔티티를 정의함으로서, product 테이블에는 provider 테이블의 id 컬럼을 provider_id 라는 이름의 외래키 컬럼을 가지게 된다.
이렇게 다대일 관계의 경우, 대다수의 상황에서 단방향으로 설정하여도 충분히 테이블 간의 관계는 명확하며 데이터 상으로도 문제가 될 부분이 없다.
양방향 연관관계를 꼭 사용해야 하는 케이스는 주로 객체 간의 상호 참조가 필요하거나, 양쪽 객체에서 모두 자주 연관 데이터를 조회해야 하는 경우이다.
게시판-게시글 관계를 예로 들어보자면,
'A 게시판에 포함된 모든 게시글 조회' 는 게시판 엔티티에서 게시글 목록 필드를 통해 접근할 수 있다.
'1 번 게시글이 어떤 게시판에 속해있는지 조회' 는 게시글 엔티티에서 게시판 필드를 통해 접근할 수 있다.
위와 같은 분명한 기능적 목적이 있는게 아니라면, 단방향으로 N 의 역할을 하는 엔티티에 @ManyToOne 을 적용하도록 하자..!
N : 1 , 1 : N 양방향 관계
그래도 위 Product - Provider 구조에서 양방향 설정 방법에 대해 알아보도록 하자.
Product 입장에서는 N : 1 관계이지만, Provider 입장에서는 1 : N 관계가 된다.
그렇기에 Provider 에서는 @OneToMany 어노테이션을 사용해야 하며, 컬렉션 형식으로 필드를 생성해야 한다.
<Provider> Entity 일대다 설정
@Entity
@Data
@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")
private List<Product> productList;
}
역시 양방향 설정을 한다면, mappedBy 속성을 통해 Owner 를 지정해주어야 한다.
1 : N 관계에서는 N 이 외래키를 가져야 하므로, Provider 엔티티에 mappedBy 속성을 지정해주도록 하자.
쉽게 생각하자면, mappedBy 를 지정한 컬럼은 실제 테이블에 생성되지 않는다.
1 : N 단방향 관계
앞서 설명한 Product - Provider 의 양방향 관계 설정하는 방법 중, @OneToMany 어노테이션 사용 방법과 동일하다.
위에서는 @ManyToOne 에 대응하는 @OneToMany 의 사용법에 대해 알아보았다면,
이번에는 @OneToMany 에 대해서만 알아보자.
가장 중요한 점은, 외래키가 @OneToMany 어노테이션을 적용한 엔티티가 아닌, 반대 엔티티에 추가된다는 것이다.
1 : N 구조에서 N 쪽 테이블에 외래키가 설정되는 것은 당연한 구조이긴 하다.
<Category> Entity
@Entity
@Data
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "category")
public class Category extends BaseEntity {
@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<>();
}
[N : M 관계]
N : M 관계에서는 각 엔티티가 서로를 리스트로 갖는 구조가 만들어지게 된다.
이런 경우에는 교차 엔티티 라고 불리는 중간 테이블이 생성되어 N : M 관계를 N : 1 / 1 : M 관계로 분리시킨다.
이러한 N : M 매핑은 @ManyToMany 어노테이션을 사용하여 설정하며, 일단 N : M 관계를 설정해야 하는 상황이라면 양방향으로 설정해야하는 케이스가 대부분일 것이다.
다른 연관관계에서 사용해온 @JoinColumn 은 외래키를 필요로하는 구조가 아니기에 사용하지 않으며, 중간 테이블의 이름을 설정하고 싶다면, @JoinTable(name="") 을 사용하면 된다.
<Producer> Entity
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@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) {
this.products.add(product);
}
}
<Product> Entity
@Entity
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(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;
// 1:1관계에서 양방향 외래키 설정 시, left outer join 2회 수행되어 효율성이 떨어짐
// mappedBy 설정하여, ProductDetail 엔티티가 Product 엔티티의 주인이 되도록 설정
@OneToOne(mappedBy = "product")
private ProductDetail productDetail;
@ManyToOne
@JoinColumn(name = "provider_id")
@ToString.Exclude
private Provider provider;
@ManyToMany
@ToString.Exclude
private List<Producer> producers = new ArrayList<>();
@ColumnDefault("true")
private boolean isActive;
@PrePersist
protected void onCreate() {
if (!isActive) { // isActive 필드가 false 로 설정되어 있으면 true 로 변경
isActive = true;
}
}
}
위 과정을 걸쳐 생성된 엔티티들의 ERD 는 아래와 같다.
[관련 소스 GitHub]
위 내용과 관련된 소스는, 아래 경로를 참고 바란다.
https://github.com/jh4dev/spring-boot-study/tree/main/relation
spring-boot-study/relation at main · jh4dev/spring-boot-study
스프링 부트 스터디. Contribute to jh4dev/spring-boot-study development by creating an account on GitHub.
github.com
'Spring & Spring boot' 카테고리의 다른 글
[Spring Boot] Validation - 2. @Validated 어노테이션 사용방법 (0) | 2024.08.14 |
---|---|
[Spring Boot] Validation - 1. @Valid 어노테이션 사용방법 (0) | 2024.08.14 |
[Spring Boot] JPA - 7. QueryDSL 사용 방법 (0) | 2024.08.11 |
[Spring Boot] JPA - 6. QueryDSL 설정 (0) | 2024.08.11 |
[Spring Boot] JPA - 5. JPQL Query Method 쿼리 메서드 (0) | 2024.08.10 |