본문 바로가기

Development/Spring

[Spring] 연관 관계 매핑하기

RDBMS를 사용할 때는 각 도메인에 맞는 테이블을 설계하고 연관관계를 설정해서 조인(Join) 등의 기능을 활용합니다.

JPA를 사용하여 테이블의 연관관계를 엔티티 간의 연관관계로 표현할 수 있습니다. 

연관관계 매핑 종류와 방향

One To One(일대일)

테이블의 하나의 레코드가 다른 테이블의 하나의 레코드만 연관되어 있는 관계입니다.

예를 들어 Product 테이블과 Product_Detail 테이블의 경우, 한 Product 당 한 Detail 레코드와 연관 관계를 가질 수 있으므로 일대일 관계라고 볼 수 있습니다.

 

One To Many(일대다), Many To One(다대일)

한 테이블의 레코드가 다른 테이블의 여러 레코드와 연관되어 있는 관계입니다.

예를 들어 Team 테이블과 Player 테이블의 경우, 하나의 Team은 여러 Player와 연관될 수 있으므로 일대다 관계라 볼 수 있으며, 여러 Player는 하나의 Team만 가질 수 있으므로 다대일 관계라고 볼 수 있습니다.

 

Many To Many(다대다)

테이블의 여러 레코드가 다른 테이블의 여러 레코드와 연관될 수 있는 관계입니다.

예를 들어 Students와 Courses 테이블의 경우, 한 명의 Student는 여러 Course를 들을 수 있고, 하나의 Course는 여러 Student에게 수강될 수 있으므로 다대다 관계라고 볼 수 있습니다.

 

단방향

두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식입니다.

 

양방향

두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식입니다.

 

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

스프링 부트에서 엔티티 연관 관계 정의하기

일대일 단방향 매핑

일대일 매핑으로 Product 엔티티와 그 Product를 설명하는 ProductDetail 엔티티를 예시로 하겠습니다.

코드 구현은 다음과 같습니다.

 

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

}
@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어노테이션은 다른 엔티티 객체를 필드로 정의했을 때 일대일 연관관계로 매핑하기 위해 사용됩니다. 

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

위 코드에서 @OneToOne 어노테이션은 기본적으로 fetch 타입이 EAGER로 되어있습니다. 즉 즉시 로딩 전략이 채택되어 ProductDetail 객체를 조회하면 그 외래키를 가진 Product 객체를 함께 조회합니다.

 

또한 optionaltrue로 되어있습니다. 이는 매핑되는 값이 nullable 하다는 의미로, 반드시 값이 있어야 한다면 @OneToOne(optional = false)를 통해 바꿀 수 있습니다. 이 때 조회하는 쿼리가 left outer join에서 Inner join으로 바뀌게 됩니다.

 

LEFT OUTER JOIN VS INNER JOIN

 

이미지 출처: https://static.wixstatic.com/media/2f98e9_c5999377496a4e399bbc042ffe3280fc~mv2.jpg/v1/fill/w_940,h_796,al_c,q_85/2f98e9_c5999377496a4e399bbc042ffe3280fc~mv2.jpg

 

INNER JOIN의 경우 양쪽 테이블 사이에 조건에 일치하는 데이터만 조회하기 때문에 null을 허용하지 않고,

LEFT OUTER JOIN은 한 쪽 테이블을 기준으로 다른 테이블과 일치하지 않아도 모든 데이터를 조회하기 때문에 null이 존재할 수 있습니다.

 

@JoinColumn 어노테이션은 매핑할 외래키를 설정하는데 사용됩니다. 만약 이 어노테이션이 없다면, 엔티티를 매핑하는 중간 테이블이 생기면서 관리 포인트가 늘어나 좋지 않습니다.

@JoinColumn 어노테이션에서 사용할 수 있는 속성으로 다음이 있습니다.

  • name: 매핑할 외래키의 이름을 설정합니다. 이 속성을 통해 원하는 칼럼명을 지정하는 것이 좋습니다.
  • referencedColumnName: 외래키가 참조할 상대 테이블의 칼럼명을 지정합니다.
  • foreignKey: 외래키를 생성하면서 지정할 제약조건을 설정합니다.(unique, nullable,insertable,updatable 등)

위 코드에서 ProductDetail은 Product의 외래키를 가지고 있지만, Product는 ProductDetail의 외래키를 가지고 있지 않으므로 단방향 관계입니다.

양방향 관계로 바꾸려면 Product 클래스에 ProductDetail 필드를 추가합니다.

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

}

양방향 관계에서는 양쪽 테이블에서 서로의 외래키를 갖고 있게 되는데 이 경우 조회 쿼리가 2번이 발생하게 됩니다.

이 때 사용하는 속성이 mappedBy인데, 어떤 객체가 주인인지 표시하는 속성이라 보면 됩니다. . 따라서 한 쪽의 테이블에서만 외래키를 가지도록 합니다.  mappedBy에 들어가는 값은 연관관계를 갖고 있는 상대 엔티티에 있는 연관관계 필드의 이름이 되며, 위 코드에서는 ProductDetail 엔티티가 Product 엔티티의 주인이 됩니다

 

또한 양방향으로 연관관계가 설정되면 ToString을 사용할 때 순환참조가 발생하게 됩니다.

이때 @ToString.Exclude를 적용하여 ToString에서 제외 설정을 하는 것이 좋습니다.

다대다, 일대다 매핑

다대일 단방향 매핑

이번엔 공급업체 엔티티 Provider와 Product 엔티티로 예를 들겠습니다.

하나의 Provider는 여러 Product를 연관 관계를 가질 수 있고,  Product 엔티티는 하나의 Provider 엔티티와 연관 관계를 가집니다. 즉 다대일(Provider 엔티티 기준) 관계를 가집니다.

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@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;

}

다대일 단방향이므로, Provider는 Product 필드를 가지고 있지 않도록 하였습니다. 

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@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")
    private ProductDetail productDetail;

    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;

}

다대일 관계 매핑으로 사용되는 어노테이션은 @ManyToOne 입니다. 다대일중 Product 엔티티가 '다'이므로 Product 엔티티에 @ManyToOne 어노테이션과 Provider 엔티티를 추가하였습니다. 이렇게 하여 다대일 단방향 연관관계를 가집니다.

기본 FetchType는 EAGER, optional은 true 입니다.

@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ManyToOne {
    Class targetEntity() default void.class;

    CascadeType[] cascade() default {};

    FetchType fetch() default FetchType.EAGER;

    boolean optional() default true;
}

다대일 양방향 매핑

Provider 엔티티에 Product를 저장하는 List를 추가하였습니다.

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

}

여기서는 Product 엔티티가 관계에 주인이 됩니다. 따라서 Provider는 외래키(Product 키)를 관리할 수 없기 때문에 객체를 저장하려면 Provider 엔티티를 저장하고, 추후 Product 엔티티에서 Provider 엔티티를 설정하여 저장해야 합니다. 만약 Product 엔티티를 먼저 저장하고 추후 Provider에 productList를 설정하여 DB에 저장한다면, 이는 반영되지 않습니다. 

일대다 매핑

일대다 단방향 매핑

이번엔 Product 엔티티와 Category 엔티티를 예로 보겠습니다. 하나의 Category는 여러 Product를 가질 수 있으며, 하나의 Product는 하나의 Category에 속할 수 있습니다. 따라서 Category 엔티티의 기준으로 일대다 연관관계가 형성됩니다.

예시로 설명할 코드입니다. 

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

    }

 

@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 어노테이션은 기본 fetch 방식이 Lazy 입니다. 지연 로딩을 사용하기 때문에, 즉시 로딩으로 바꾸려면 fetch = FetchType.EAGER로 설정하여 바꿀 수 있습니다. Product 엔티티에 저장할 외래키 이름은 category_id 입니다.

@OneToMany와 @JoinColumn을 사용하여 일대다 단방향 설정이 가능하며, @JoinColumn을 사용하지 않으면 Join 테이블이 자동으로 생성됩니다. 

 

@OneToMany의 추가적인 Update 쿼리

일대다 매핑의 경우 DB 테이블 상으론 일대다 중 '다'의 해당하는 테이블이 '일' 테이블의 키를 외래키로 갖고 있는데,

엔티티상에선 '일'의 해당하는 엔티티가 '다'의 엔티티를 필드로 가지고 있습니다.

이러한 차이로 '다'의 엔티티에도 외래키를 설정하기 위해 추가적인 update 쿼리를 발생시킵니다. 

 

빈번한 Update 쿼리 발생은 성능 오버 헤드가 발생할 수 있어 상황에 따라 적절한 연관관계 매핑을 선택해야 합니다.

다대다 매핑

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

 

다대다 단방향 매핑

예시 코드로 Producer 엔티티를 생성해보겠습니다. 하나의 Product는 여러 Producer를 가질 수 있고, 하나의 Producer는 여러 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(mappedBy = "product")
    @ToString.Exclude 
    private ProductDetail productDetail;

    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;

}
@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 어노테이션을 설정하여 다대다 관계를 설정합니다. 이 테이블에서는 별도의 외래키를 가지고 있지 않습니다.

위와 같이 엔티티를 생성하고 애플리케이션을 실행하면 producer_products 라는 중간 테이블이 생성됩니다.

 

다대다 양방향 매핑

다대다 양방향 매핑은 단순히 Product 엔티티에도 Producer 필드를 추가하면 됩니다. 이때 Producer는 리스트 타입으로, @ManyToMany 어노테이션을 적용합니다.

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

}

 

다대다 관계에서 생긴 중간 테이블은 예기치 못한 쿼리를 발생시킬 수 있습니다. 따라서 중간 테이블을 생성하는 대신, 일대다 다대일로 연관관계를 맺을 수 있는 중간 엔티티로 승격시켜 JPA에서 관리할 수 있게 생성하는 것이 좋습니다.

참고 자료