9.1 연관관계 매핑 - 일대일 매핑

김찬미·2024년 6월 24일
0
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)

연관관계를 이해하기 위해 한 가게가 재고관리시스템을 통해 상품을 관리하고 있다고 해보자. 재고로 등록돼 있는 상품 엔티티에는 가게로 상품을 공급하는 공급업체의 정보 엔티티가 매핑되어 있다.

공급 업체 입장에서 보면 한 가게에 납품하는 상품이 여러 개 있을 수 있으므로 상품 엔티티와는 1:N 관계가 되며, 상품 입장에서 보면 하나의 공급업체에 속하게 되므로 N:1 관계가 된다. 즉, 어떤 엔티티를 중심으로 연관 엔티티를 보느냐에 따라 연관관계의 상태가 달라진다. 그림으로 표현하면 다음과 같이 표현할 수 있다.

데이터베이스에서는 외래키를 통해 서로 조인해서 참조하는 구조로 연관관계를 설정하지만, 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 어노테이션을 사용해 매핑할 외래키를 설정한다. 이 어노테이션은 기본값이 설정되어 있지만 의도한 이름을 넣기 위해 name 속성으로 원하는 컬럼명을 지정하는 편이 좋다.

@JoinColumn 어노테이션에서 사용할 수 있는 속성은 다음과 같다.

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

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

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

public interface ProductDetailRepository extends JpaRepository<ProductDetail, Long> {

}

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

package com.springboot.relationship.data.repository;

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

위와 같은 테스트 코드를 실행하기 위해서는 12~16번 줄과 같이 상품과 상품정보에 매핑된 리포지토리에 대한 의존성 주입을 받아야 한다. 그리고 이 테스트에서 조회할 엔티티 객체를 20~31번 줄과 같이 저장한다.

여기서 주요 코드는 34~35번 줄이다. ProductDetail 객체에서 Product 객체를 일대일 단방향 연관관계를 설정했기 때문에 ProductDetailRepository 에서 ProductDetail 객체를 조회한 후 연관 매핑된 Product 객체를 조회할 수 있다. 34~35번 줄과 37~38번 줄에서 조회하는 쿼리는 다음과 같이 표현된다.

Hibernate: 
    select
        productdet0_.id as id1_1_0_,
        productdet0_.created_at as created_2_1_0_,
        productdet0_.updated_at as updated_3_1_0_,
        productdet0_.description as descript4_1_0_,
        productdet0_.product_number as product_5_1_0_,
        product1_.number as number1_0_1_,
        product1_.created_at as created_2_0_1_,
        product1_.updated_at as updated_3_0_1_,
        product1_.name as name4_0_1_,
        product1_.price as price5_0_1_,
        product1_.stock as stock6_0_1_ 
    from
        product_detail productdet0_ 
    left outer join
        product product1_ 
            on productdet0_.product_number=product1_.number 
    where
        productdet0_.id=?

select 구문을 보면 ProductDetail 객체와 Product 객체가 함께 조회되는 것을 볼 수 있다. 이처럼 엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 것을 ‘즉시 로딩’ 이라고 한다. 그리고 16~18번 줄에서 ‘left outer join’이 수행되는 것을 볼 수 있다. 여기서 left outer join이 수행되는 이유는 @OneToOne 어노테이션 때문이다. 아래에서 @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이라는 것을 의미한다. 반드시 값이 있어야 한다면 ProductDetail 엔티티에서 속성값에 아래와 같이 설정할 수 있다.

    @OneToOne(optional = false)
    @JoinColumn(name = "product_number")
    private Product product;

위와 같이 @OneToOne 어노테이션에 optional = false 속성을 설정하면 productnull인 값을 허용하지 않게 된다. 위와 같이 설정하고 애플리케이션을 실행하면 다음과 같이 테이블을 생성하는 쿼리에서 not null이 설정되는 것을 확인할 수 있다.

Hibernate: 
    create table product_detail (
       id bigint not null auto_increment,
        created_at datetime(6),
        updated_at datetime(6),
        description varchar(255),
        product_number bigint not null,
        primary key (id)
    ) engine=InnoDB

그리고 앞에서 작성한 예제를 실행하면 다음과 같이 쿼리문이 바뀌어 실행된다.

Hibernate: 
    select
        productdet0_.id as id1_1_0_,
        productdet0_.created_at as created_2_1_0_,
        productdet0_.updated_at as updated_3_1_0_,
        productdet0_.description as descript4_1_0_,
        productdet0_.product_number as product_5_1_0_,
        product1_.number as number1_0_1_,
        product1_.created_at as created_2_0_1_,
        product1_.updated_at as updated_3_0_1_,
        product1_.name as name4_0_1_,
        product1_.price as price5_0_1_,
        product1_.stock as stock6_0_1_ 
    from
        product_detail productdet0_ 
    inner join
        product product1_ 
            on productdet0_.product_number=product1_.number 
    where
        productdet0_.id=?

즉, @OneToOne 어노테이션에 optional = false 속성을 지정한 경우에는 16~18번 줄과 같이 left outer join 이 inner join으로 바뀌어 실행된다. 이처럼 객체에 대한 설정에 따라 JPA는 최적의 쿼리를 생성해서 실행한다.

이후 내용을 진행하기 위해 @OneToOne에 적용했던 optional = false 속성은 제거하겠다.

  • optional = false: 일치하는 레코드들만 조인되어 null 데이터가 존재할 수 없는 Inner join으로 수행된다.
  • optional = true: 일치하지 않은 레코드가 존재할 경우 null로 채우는 특성을 가진 outer join을 수행한다.

일대일 양방향 매핑

이번에는 앞에서 생성한 일대일 단방향 설정을 양방향 설정으로 변경해 보겠다. 객체에서의 양방향 개념은 양쪽에서 단방향으로 서로를 매핑하는 것을 의미한다. 일대일 양방향 매핑을 위해서는 아래와 같이 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;
    
}

추가된 코드는 23~24번 줄이다. 이렇게 설정하고 애플리케이션을 실행하면 아래와 같이 Product 테이블에도 칼럼이 생성되는 것을 볼 수 있다.

그리고 위 예제를 실행하면 다음과 같은 쿼리가 생성되는 것을 볼 수 있다.

Hibernate: 
    select
        productdet0_.id as id1_1_0_,
        productdet0_.created_at as created_2_1_0_,
        productdet0_.updated_at as updated_3_1_0_,
        productdet0_.description as descript4_1_0_,
        productdet0_.product_number as product_5_1_0_,
        product1_.number as number1_0_1_,
        product1_.created_at as created_2_0_1_,
        product1_.updated_at as updated_3_0_1_,
        product1_.name as name4_0_1_,
        product1_.price as price5_0_1_,
        product1_.product_detail_id as product_7_0_1_,
        product1_.stock as stock6_0_1_,
        productdet2_.id as id1_1_2_,
        productdet2_.created_at as created_2_1_2_,
        productdet2_.updated_at as updated_3_1_2_,
        productdet2_.description as descript4_1_2_,
        productdet2_.product_number as product_5_1_2_ 
    from
        product_detail productdet0_ 
    left outer join
        product product1_ 
            on productdet0_.product_number=product1_.number 
    left outer join
        product_detail productdet2_ 
            on product1_.product_detail_id=productdet2_.id 
    where
        productdet0_.id=?

여러 테이블끼리 연관관계가 설정돼 있어 여러 left outer join이 설정되는 것은 괜찮으나 위와 같이 양쪽에서 외래키를 가지고 left outer join이 두번이나 수행되는 경우는 효율성이 떨어진다. 실제 데이터베이스에서도 테이블 간 연관관계를 맺으면 한쪽 테이블이 외래키를 가지는 구조로 이뤄진다. 바로 앞에서 언급한 ‘주인(Owner)’ 개념이다.

JPA에서도 실제 데이터베이스의 연관관계를 반영해서 한쪽의 테이블에서만 외래키를 바꿀 수 있도록 정하는 것이 좋다. 이 경우 엔티티는 양방향으로 매핑하되 한쪽에게만 외래키를 줘야 하는데, 이때 사용되는 속성 값이 mappedBy이다. mappedBy는 어떤 객체가 주인인지 표시하는 속성이라고 볼 수 있다. 아래와 같이 Product 객체에 mappedBy 속성을 추가해 보겠다.

    @OneToOne(mappedBy = "product")
    private ProductDetail productDetail;

mappedBy에 들어가는 값은 연관관계를 갖고 있는 상대 엔티티에 있는 연관관계 필드의 이름이 된다. 이 설정을 마치면 ProductDetail 엔티티가 Product 엔티티의 주인이 되는 것이다. 애플리케이션을 실행하고 데이터베이스 테이블을 보면 아래와 같이 외래키가 사라진 것을 볼 수 있다.

그리고 다시 테스트 코드를 실행하면 toString 을 실행하는 시점에서 StackOverflowError가 발생하는 것을 볼 수 있다. 양방향으로 연관관계가 설정되면 ToString을 사욜할 때 순환참조가 발생하기 때문이다.

그렇게 때문에 필요한 경우가 아니라면 대체로 단방향으로 연관관계를 설정하거나 양방향 설정이 필요할 경우에는 순환참조 제거를 위해 아래와 같이 exclude를사용해 ToString에서 제외 설정을 하는 것이 좋다.

@OneToOne(mappedBy = "product")
@ToString.Exclude
private ProductDetail productDetail;

위와 같이 Product 엔티티 클래스의 코드를 수정하면 기존 테스트 코드가 정상적으로 동작하는 것을 볼 수 있다.

profile
백엔드 개발자

0개의 댓글

관련 채용 정보