Spring & Spring boot

[Spring Boot] JPA - 8. 연관관계 매핑 (Relation)

jh4dev 2024. 8. 13. 22:54
<목차>

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 (일대일)

@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 (일대다)

@OneToMany

 

* 일대다 관계의 경우, 한 엔티티가 여러 엔티티와 관계를 가지는 관계를 표현하는 어노테이션이다.

* 단일 엔티티를 참조하는 것이 아니라, 여러 엔티티를 참조하는 컬렉션이기 때문에, 컬렉션 자체는 null이 될 수 있지만, optional 속성은 단일 관계에서만 의미가 있기 때문에, 다수의 관계를 나타내는 @OneToMany에서는 optional() 인터페이스는 사용하지 않는다.

 

* 또한, 일대다 관계에서 fetch() 를 EAGER 로 설정할 경우, 부모 엔티티를 조회할 때, 자식 엔티티를 모두 조회하여 메모리에 로드시키기 때문에 불필요한 메모리 사용이 발생하며, 1개의 부모 레코드를 조회하려는 동작에서, 자식 레코드가 3개인 경우, 3 레코드가 조회되는 등의 문제가 발생할 수 있기 때문에, LAZY를 기본 옵션으로 갖는다.


  • @ManyToOne (다대일)

@ManyToOne

* 여러 엔티티가 하나의 엔티티와 관계를 맺는 다대일 관계를 표현하는 어노테이션

* orphanRemoval() 의 경우, ManyToOne 관계에서는 자식 엔티티가 하나의 부모 엔티티를 참조하는 방식이기 때문에, 자식 엔티티가 부모 엔티티와의 관계가 끊어지더라도 자식 엔티티를 삭제하는 기능이 제공되지 않는다.

* fetch() 또한 자식 엔티티가 하나의 부모 엔티티를 참조하는 방식이기 때문에, EAGER 를 전략으로 자식 엔티티가 로드될 때 부모 엔티티도 즉시 로드한다.


  • @ManyToMany (다대다)

@ManyToMany

* 다대다 관계로, 각각의 자식 엔티티가 여러 부모 엔티티를 참조하는 방식이기 때문에, 자식 엔티티가 부모 엔티티와의 관계가 끊어지더라도 자식 엔티티를 삭제하는 기능이 제공되지 않는다. (orphanRemoval 없음)

* fetch() 또한 OneToMany 와 마찬가지 이유로 LAZY를 사용한다.

 

 


[1 : 1 관계]

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 관계에서는 다음과 같은 구성이 가능하다.

  1. N : 1 단방향 관계
  2. N : 1, 1 : N 양방향 관계
  3. 1 : N 단방향 관계

N : 1 단방향 관계

 

N : 1 관계부터 살펴보자.

PRODUCT 기준 N:1 / PROVIDER 기준 1:N

위와 같이 상품 테이블과 공급업체 테이블의 경우,
상품 테이블 입장에서는 N:1(다대일), 공급업체 테이블 입장에서는 1:N(일대다) 관계이다.

일반적으로 흔히 외래키를 설정하는 케이스는 이와 같은 관계인 경우가 많다.

N: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 : 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 는 아래와 같다.

애플리케이션 실행 시, 생성되는 테이블들의 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