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
속성을 설정하면 product
가 null
인 값을 허용하지 않게 된다. 위와 같이 설정하고 애플리케이션을 실행하면 다음과 같이 테이블을 생성하는 쿼리에서 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
엔티티 클래스의 코드를 수정하면 기존 테스트 코드가 정상적으로 동작하는 것을 볼 수 있다.