JPA 도입 — OneToOne 관계에서의 LazyLoading 이슈 #1

Yorath Jang
Jul 21, 2019 · 33 min read

JPA를 쓰다 보면, DB설계시 자연스럽게 적용했던 테이블간 1:1 관계로 인한 예상치 못한 어려움과 혼돈을 겪는 경우가 발생한다. 이 글은 1:1 관계로 인해 발생하는 이슈들과 고민, 그에 따른 여러가지 생각들에 대한 글이다.

Legacy 시스템의 DB구조가 자식 테이블이 외래키를 가지고 있는 방식으로 되어 있고, 그 Lagacy 시스템에 JPA를 도입하는 것을 전제로 한 내용이라 ,처한 상황과 관점에 따라서는 중요한 문제일 수도, 아닐 수도 있겠다.

물론 JPA가 1:1 관계에서 가지는 특징과 제약사항들에 대한 내용이 주가 되니, JPA를 처음 사용하는 개발자들에게는 나름 좋은 정보가 되지 않을까 생각된다.

JPA에서의 1:1 연관관계 제약사항

JPA에서는 OneToOne(이하 1:1)관계에서 무조건적인 LazyLoading(지연로딩)을 지원하지 않는다.

정확하게 말하면 특정조건을 모두 만족해야지만 LazyLoading이 작동한다. 그 외에는 객체를 참조하기 위한 패치전략을 Lazy로 지정하더라도 Eager(객체간 Join발동 또는 동시참조)로 작동한다.

Why?

JPA는 객체의 참조가 프록시 기반으로 동작하는데, 연관관계가 있는 객체는 참조를 할때 기본적으로 null이 아닌 프록시 객체를 반환한다. 1:1관계에서는 null이 허용되는 경우, 프록시형태로 null객체를 반환할 수가 없기 때문이다. 1:N관계는 이미 배열의 형태로 이미 참조할 프록시 객체를 싸고 있기 때문에 그 객체가 null이라도 참조할때는 문제가 되지 않는다.

따라서 JPA 구현체는 기본적으로 1:1관계에서는 지연로딩 를 허용하지 않고, 즉시 값을 읽어 들인다. 물론 Lazy를 설정할 수 있지만 특정조건을 모두 만족하지 않는다면 동작하지 않는다.

Issue?

JPA를 적용할때 이런 제약사항을 염두해 두지 않고 당연히 Lazy가 적용되겠지라고 생각하고 1:1 관계를 맺고 사용하게 되면, 부모 엔티티 만을 참조하는 기능을 구현하더라도 지연로딩이 적용되지 않고 1:1관계가 걸려있는 객체 전체에 대한 참조(Join)이 발생하는, 성능에 심각한 이슈를 발생시키게 된다.

예를 들어, 아래와 같이 1:1관계의 부모테이블과 자식테이블이 있다고 가정해보자

- 부모테이블의 데이타 건수: 100건
- 자식테이블의 데이타 건수: 100건

JPA에서 이 두 테이블을 엔티티로 구현하고 부모테이블에서
부모테이블의 데이타 전체를 조회하는 로직을 실행할 경우, 당연히 쿼리가 1번만 수행되기를 기대할텐데 실제로는 101번의 쿼리가 실행된다. Lazyloading이 발동되지 않고 동시참조가 이뤄졌기 때문이다.

예제를 통해서 확인해 보도록 하겠다.


상품기본정보 와 가격정보 테이블 설계

‘상품’이라는 도메인을 설계할 때를 예로 들어보자. 예제로 만들거라 단순하게 상품기본정보상품가격정보 두개의 엔티티가 필요하다는 전제로 진행하겠다.

엔티티 정의와 관계의 설정 ( 비즈니스 요구사항을 담고 있는 서비스설계에서는 매우 중요한 단계이다. 물론 예제이니 단순하게 접근한다. )

  • 상품

속성 및 제약사항

  • 상품의 가격은 단일통화 단일가격 하나만 존재한다. (향후 통화가 늘어날 수도 있다)
  • 상품가격의 통화단위는 원화이다.
  • 상품가격은 수입품이라 환율에 따라 가격이 자주 변경된다.
  • 상품가격은 필수항목이다. 상품등록시 가격도 반드시 같이 등록되어야 한다.
  • 상품목록을 보여주는 화면에서는 가격정보를 표시하지 않는다.
  • 상품상세화면에서 상품의 가격정보를 볼 수 있다.

속성과 제약사항을 토대로 엔티티의 속성과 관계를 정의한다.

상품기본정보 — Products

  • 상품코드(PK)
  • 상품명
  • 상품설명
  • 무게
  • 원산지
  • 제조일자

상품가격정보 — ProductPrices

  • 상품가격코드(PK)
  • 상품코드(FK) -> 자식테이블이 부모테이블의 PK를 외래키로 가지게 된다.
  • 가격
  • 통화 (KRW)

속성과 관계까지 정의되었으면 엔티티 클래스를 JPA기반을 생성한다. JPA설정에 대해서는 본 주제와는 다른 내용이라 별도로 다루지 않는다.

상품기본정보 — Products.,java

@Entity
@ToString
public class Products {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long productId;
@Column
private String name;
@Column
private String description;
@Column
private Integer weight;
@Column
private String origin;
@Column
private LocalDateTime date;
}

상품가격정보 — ProductPriceses.java

@Entity
public class ProductPrices {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long priceId;
@Column
private Double price;
@Column
private String currency;
// 연관관계설정 - 연관엔티티의 PK를 외래키로 가지고 있는 이 엔티티가 관계의 주인. 관계설정은 상품가격정보 엔티티에서 한다.
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "productId")
private Products product;
}

ProductsRepositiory.java

package yorath.springboot.jpa.repository;import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import yorath.springboot.jpa.model.Products;
@Repository
public interface ProductsRepository extends JpaRepository<Products, Long> {
}

ProductsRepository.java

package yorath.springboot.jpa.repository;import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import yorath.springboot.jpa.model.ProductPrices;
@Repository
public interface ProductPricesRepository extends JpaRepository<ProductPrices, Long> {
}

코드를 작성하고 보니 뭔가 어색하다.
상품정보 트랜잭션의 시작이고 엔티티의 성격상 부모테이블이라 여겨지는 Products 엔티티에서 ProductPrices로의 관계설정이 이루어지 않았다. 반대로 자식테이블인 ProductPrices 엔티티에서 부모테이블인 Products 엔티티와 관계설정이 이루어졌다.

정확히 얘기하면 Products 엔티티에서는 관계설정을 할 수가 없다.
JPA에서는 외래키를 가지는 엔티티가 연관관계의 주인이 되기 때문에, 외래키가 없는 부모 엔티티에서는 단방향 연관관계를 맺을 수 없기 때문이다.

JPA에서의 1:1(OneToOne) 관계설정의 특성, DB와의 차이

사실 이 부분이 전통적인 RDB설계구조에 익숙한 개발자에게 혼동을 주는 부분이다.

JPA에서는 DB관점에서의 부모-자식의 관계와는 상관없이 외래키-연관관계 테이블의 PK-를 가지는 엔티티가 관계의 주인이 된다.

DB에서의 부모 — 자식관계와 JPA에서 얘기하는 관계의 주인라는 개념은 완전히 분리해서 생각해야 한다. 하지만, 실제 비즈니스 로직을 구현하다 보면 쉽게 분리되지 않는다.

DB에서의 1:1관계는 외래키가 어디에 있든 비즈니스 로직이 요구하는대로 조인을 통해서 아래의 쿼리를 통해 양방향으로 테이타를 참조할 수 있다. 데이타를 조회하는 관점에서는 외래키가 어디에 있든 크게 문제되지 않는다.

SELECT a.productId,a.name b.price
FROM Products a join ProductPrices b ON a.productId = b.productId

너무 단순한 예제이긴 하지만
쿼리 기반의 어플리케이션에서는 대부분이 이렇게 테이블의 외래키가 어디에 있던 JOIN을 통해서 가져올 수 있고, 가져오게 된다.

등록의 경우에도 상품의 기본정보가 먼저 등록되고 가격정보(부가정보)는 나중에 등록된다는 일반적인 비즈니즈 로직하에 상품테이블이 부모테이블이 된다. 등록이든 조회든 상품의 기본정보(부모)를 통해서 상품의 가격정보(자식)로 순서대로 처리되는, 부모-자식의 관계가 명확하게 지켜진다.

그렇기 때문에 일반적으로 자식테이블이 먼저 등록되는 부모테이블의 PK를 외래키로 가지는 구조로 설계가 되게 된다. 자연스러운 구조이고 서버 개발자들은 지금까지 이런 DB구조에서 문제없이 비즈니스 로직(사실은 쿼리)을 구사하였다.


그런데 JPA에서는 이런 구조의 1:1관계가 자연스럽지 않다.

위의 클래스를 보면 상품 엔티티에서 상품가격 엔티티로의 참조관계가 없다. 어플리케이션에서 부모에서 자식으로 참조할 수는 클래스간 참조정보가 없다는 의미이다.
상품가격정보(자식) -> 상품정보(부모)로의 1:1 단방향 관계만 성립되어 있다.

우선은 현재의 1:1 연관관계에서 지연로딩이 제대로 적용되는지 확인해 보도록 하겠다.

간단히 테스트 코드를 만들어 상품가격정보를 전체를 조회해서 로그를 확인해보자. 여기서 테스트 성공/실패 코드의 구현은 큰 의미가 없겠다. 참고로 현재 각 테이블에는 데이타 각각 100건씩 들어가 있다.

@Transactional
@Rollback(false)
@Test
public void 일대일단방향_조회_test() {
List<ProductPrices> productPricesList = productPricesRepository.findAll();}

실행해보면 아래의 상품가격정보를 조회하는 쿼리만 실행되고 끝난다. 즉, LazyLoading이 정상작동 했다는 의미이다.

2019-07-21 18:28:12.859 DEBUG 76024 --- [           main] o.h.h.internal.ast.QueryTranslatorImpl   : SQL: select productpri0_.priceId as priceId1_0_, productpri0_.currency as currency2_0_, productpri0_.price as price3_0_, productpri0_.productId as productI4_0_ from ProductPrices productpri0_
2019-07-21 18:28:12.859 DEBUG 76024 --- [ main] o.h.hql.internal.ast.ErrorTracker : throwQueryException() : no errors
Hibernate:
select
productpri0_.priceId as priceId1_0_,
productpri0_.currency as currency2_0_,
productpri0_.price as price3_0_,
productpri0_.productId as productI4_0_
from
ProductPrices productpri0_
2019-07-21 18:28:12.887 DEBUG 76024 --- [ main] org.hibernate.loader.Loader : Result set row: 0
2019-07-21 18:28:12.889 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([priceId1_0_] : [BIGINT]) - [1]
2019-07-21 18:28:12.891 DEBUG 76024 --- [ main] org.hibernate.loader.Loader : Result row: EntityKey[yorath.springboot.jpa.model.ProductPrices#1]
2019-07-21 18:28:12.895 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([currency2_0_] : [VARCHAR]) - [KRW]
2019-07-21 18:28:12.895 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([price3_0_] : [DOUBLE]) - [100.0]
2019-07-21 18:28:12.895 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([productI4_0_] : [BIGINT]) - [1]
2019-07-21 18:28:12.896 DEBUG 76024 --- [ main] org.hibernate.loader.Loader : Result set row: 1
2019-07-21 18:28:12.896 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([priceId1_0_] : [BIGINT]) - [2]
2019-07-21 18:28:12.896 DEBUG 76024 --- [ main] org.hibernate.loader.Loader : Result row: EntityKey[yorath.springboot.jpa.model.ProductPrices#2]
2019-07-21 18:28:12.896 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([currency2_0_] : [VARCHAR]) - [KRW]
2019-07-21 18:28:12.897 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([price3_0_] : [DOUBLE]) - [101.0]
2019-07-21 18:28:12.897 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([productI4_0_] : [BIGINT]) - [2]
2019-07-21 18:28:12.897 DEBUG 76024 --- [ main] org.hibernate.loader.Loader : Result set row: 2
2019-07-21 18:28:12.897 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([priceId1_0_] : [BIGINT]) - [3]
2019-07-21 18:28:12.897 DEBUG 76024 --- [ main] org.hibernate.loader.Loader : Result row: EntityKey[yorath.springboot.jpa.model.ProductPrices#3]
2019-07-21 18:28:12.897 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([currency2_0_] : [VARCHAR]) - [KRW]
2019-07-21 18:28:12.897 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([price3_0_] : [DOUBLE]) - [102.0]
2019-07-21 18:28:12.897 TRACE 76024 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([productI4_0_] : [BIGINT]) - [3]
2019-07-21 18:28:12.897 DEBUG 76024 --- [ main] org.hibernate.loader.Loader : Result set row: 3
...
...
...

연관관계에 있는 Products를 참조하는 테스트 코드를 아래와 같이 추가하고 다시 실행해보면, 참조하는 시점에 쿼리가 실행됨을 확인할 수 있다.

@Transactional
@Rollback(false)
@Test
public void 일대일단방향_조회_test() {
List<ProductPrices> productPricesList = productPricesRepository.findAll();

productPricesList.stream().forEach(productPrices -> {
System.out.println("[상품명]" + productPrices.getProduct().getName());
}
);
}

상품명을 출력하는 코드가 실행되는 시점에 쿼리가 실행된다. 이 테스트를 통해 1:1관계에서 단방향이면서 참조하는 객체가 Null이 아닌 경우, Lazyloading이 정상작동됨을 확인했다.

그럼, 반대로 상품품정보를 기준으로 동시에 상품가격정보를 가져오려면 어떻게 해야할까?
( 앞서 얘기했듯이 보통은 이렇게 부모 -> 자식의 순서로 가져오는게 일반적이다. )

우선 현재의 코드로는 가져올 방법이 없다….. 객체간 참조가 없기 때문이다.
상품 -> 상품가격으로의 연관관계를 추가로 설정해 주어야 가능하다. 정확하게는 1:1양방향 관계를 설정해야 한다.

@Entity
@ToString
public class Products {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long productId;
@Column
private String name;
@Column
private String description;
@Column
private Integer weight;
@Column
private String origin;
@Column
private LocalDateTime date;
// 연관관계의 주인이 아님을 mappedBy로 설정
@OneToOne(cascade = CascadeType.PERSIST, fetch = FetchType.LAZY, mappedBy = "products")
@JoinColumn(name = "productId")
private ProductPrices productPrices;
}

연관관계를 추가할 때 유의해야 점은, 상품정보는 연관관계를 맺을 외래키를 가지고 있지 않기 때문에 누가 연관관계의 주인인지 지정해줘야 정상적인 외래키 조인이 이루어진다.

그렇지 않으면 상품 -> 상품가격으로의 또 다른 1:1 관계가 만들어지게 되는데, 그럴 경우 외래키가 없기 때문에 Lazy이건 Eager이건 정상적인 조인관계가 발동하지 않는다. 그 이유는 위에서 충분히 설명이 되었다고 본다.

양방향 관계를 맺기 위해서는 mappedBy 속성을 통해서 나는 연관관계의 주인이 아니라고 알려줘야 한다.
연관관계의 주인인 ProductPrices가 products라는 엔티티에 매핑되어 있다고 알려주게 되면, Products와 ProductsPrices와의 양방향 관계과 성립된다. 즉 mappedBy는 연관관계의 주인이 아닌 클래스가 사용하게 된다.

지금의 주제와는 살짝 벗어난 부분이지만 mappedBy의 사용은 두 엔티티간의 등록,수정,삭제의 처리에서도 매우 중요한 속성값이니 JPA의 cascade가 제대로 작동하기 위해서는 반드시 설정해야 한다. 이해가 안 되면 그냥 단순하게 외우는 것도 방법이 될 수 있다.

테이블에서의 단순한 조인관계를 JPA를 통한 객체간 관계로 표현하는 것은 그리 단순하지는 않다.

이제 부모테이블인 Products를 통해서 데이타를 가져와 보도록 하자.
우선 상품테이블의 데이타를 모두 조회하는 테스트코드를 실행한다.

@Transactional
@Rollback(false)
@Test
public void 일대일단방향_조회_test() {
List<Products> productsList = productsRepository.findAll();}

실행해보면 지금 얘기하려는 주제에 대한 이슈가 발생된다.

2019-07-21 21:24:02.075 DEBUG 86190 --- [           main] o.h.h.internal.ast.QueryTranslatorImpl   : SQL: select products0_.productId as productI1_1_, products0_.date as date2_1_, products0_.description as descript3_1_, products0_.name as name4_1_, products0_.origin as origin5_1_, products0_.weight as weight6_1_ from Products products0_
2019-07-21 21:24:02.075 DEBUG 86190 --- [ main] o.h.hql.internal.ast.ErrorTracker : throwQueryException() : no errors
Hibernate:
select
products0_.productId as productI1_1_,
products0_.date as date2_1_,
products0_.description as descript3_1_,
products0_.name as name4_1_,
products0_.origin as origin5_1_,
products0_.weight as weight6_1_
from
Products products0_
2019-07-21 21:24:02.111 DEBUG 86190 --- [ main] org.hibernate.loader.Loader : Result set row: 0
2019-07-21 21:24:02.113 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([productI1_1_] : [BIGINT]) - [1]
2019-07-21 21:24:02.115 DEBUG 86190 --- [ main] org.hibernate.loader.Loader : Result row: EntityKey[yorath.springboot.jpa.model.Products#1]
2019-07-21 21:24:02.126 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([date2_1_] : [TIMESTAMP]) - [2019-06-15T16:31:50]
2019-07-21 21:24:02.126 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([descript3_1_] : [VARCHAR]) - [description]
2019-07-21 21:24:02.127 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([name4_1_] : [VARCHAR]) - [prd1]
2019-07-21 21:24:02.127 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([origin5_1_] : [VARCHAR]) - [KR]
2019-07-21 21:24:02.127 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([weight6_1_] : [INTEGER]) - [10]
2019-07-21 21:24:02.128 DEBUG 86190 --- [ main] org.hibernate.loader.Loader : Result set row: 1
2019-07-21 21:24:02.129 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([productI1_1_] : [BIGINT]) - [2]
2019-07-21 21:24:02.129 DEBUG 86190 --- [ main] org.hibernate.loader.Loader : Result row: EntityKey[yorath.springboot.jpa.model.Products#2]
2019-07-21 21:24:02.129 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([date2_1_] : [TIMESTAMP]) - [2019-06-15T16:31:50]
2019-07-21 21:24:02.129 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([descript3_1_] : [VARCHAR]) - [description]
2019-07-21 21:24:02.129 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([name4_1_] : [VARCHAR]) - [prd2]
2019-07-21 21:24:02.129 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([origin5_1_] : [VARCHAR]) - [KR]
2019-07-21 21:24:02.129 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([weight6_1_] : [INTEGER]) - [10]
2019-07-21 21:24:02.129 DEBUG 86190 --- [ main] org.hibernate.loader.Loader : Result set row: 2
2019-07-21 21:24:02.129 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([productI1_1_] : [BIGINT]) - [3]
2019-07-21 21:24:02.129 DEBUG 86190 --- [ main] org.hibernate.loader.Loader : Result row: EntityKey[yorath.springboot.jpa.model.Products#3]
2019-07-21 21:24:02.130 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([date2_1_] : [TIMESTAMP]) - [2019-06-15T16:31:50]
2019-07-21 21:24:02.130 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([descript3_1_] : [VARCHAR]) - [description]
2019-07-21 21:24:02.130 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([name4_1_] : [VARCHAR]) - [prd3]
2019-07-21 21:24:02.130 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([origin5_1_] : [VARCHAR]) - [KR]
2019-07-21 21:24:02.130 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([weight6_1_] : [INTEGER]) - [10]
2019-07-21 21:24:02.130 DEBUG 86190 --- [ main] org.hibernate.loader.Loader : Result set row: 3
......
// 상품건수만큼 출력
...
...
...
..
// Lazyloading이 적용되지 않고 추가로 상품건만큼 상품가격정보 참조(쿼리실행)가 일어남.
2019-07-21 21:24:02.200 DEBUG 86190 --- [ main] org.hibernate.loader.Loader : Loading entity: [yorath.springboot.jpa.model.ProductPrices#9]
Hibernate:
select
productpri0_.priceId as priceId1_0_0_,
productpri0_.currency as currency2_0_0_,
productpri0_.price as price3_0_0_,
productpri0_.productId as productI4_0_0_
from
ProductPrices productpri0_
where
productpri0_.productId=?
2019-07-21 21:24:02.200 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [9]
2019-07-21 21:24:02.202 DEBUG 86190 --- [ main] org.hibernate.loader.Loader : Result set row: 0
2019-07-21 21:24:02.202 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([priceId1_0_0_] : [BIGINT]) - [9]
2019-07-21 21:24:02.202 DEBUG 86190 --- [ main] org.hibernate.loader.Loader : Result row: EntityKey[yorath.springboot.jpa.model.ProductPrices#9]
2019-07-21 21:24:02.202 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([currency2_0_0_] : [VARCHAR]) - [KRW]
2019-07-21 21:24:02.202 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([price3_0_0_] : [DOUBLE]) - [108.0]
2019-07-21 21:24:02.202 TRACE 86190 --- [ main] o.h.type.descriptor.sql.BasicExtractor : extracted value ([productI4_0_0_] : [BIGINT]) - [9]

Products 엔티티에 연관관계를 추가할때 분명히 패치전략을 ‘Lazy’으로 설정했다. 따라서 productRepositiory.findAll()을 실행했을때 해당 테이블을 조회하는 쿼리 한번만 실행되어야 한다.
당연히 Products 엔티티에 연결된 Products테이블의 데이타만 조회되는 쿼리가 한 번만 실행될꺼라고 가대했는데, 결과는 연관관계로 설정된 ProductPrices의 테이블을 조회하는 쿼리가 Products에서 조회된 건수만큼 반복해서 실행되었다.

LazyLoading 이 발동되지 않은 것이다.

예를 들어, 상품기본정보만을 응답값으로 제공하는 API를 구현했다고 치자.
이렇게 만들고 API를 클라이언트에 제공했는데, 상품정보 요청이 들어왔을때 실제로는 상품건별로 상품가격정보까지 조회하는, 전혀 필요없는 쿼리가 내부적으로 반복해서 실행되게 된다.

서두에 얘기했던 대로 만약 상품목록을 페이징 처리하지 않았고 전체건을 반환할 필요성이 있는 API라고 가정한다면, 상품건수가 1,000건이라고 했을때 API호출마다 한번만 실행될 쿼리가 1000번 — 그것도 전혀 불필요한 — 이 더 실행되는 참사(?)가 발생한다.

1:1 연관관계의 엔티티가 지금처럼 하나가 아니라 여러개일 경우에는 정말 대형 참사(?)로 이어질 수 있다.

그럼 왜 Lazyloading이 발동하지 않은걸까?


JPA에서의 OneToOne 관계에서의 Lazy Loading 발동조건

  1. nullable이 허용되지 않는 1:1 관계. 즉, 참조 객체가 optional = false 로 지정할 수 있는 관계여야 한다.
  2. 양방향이 아닌 단방향 1:1 관계여야한다.
  3. @PrimaryKeyJoin은 허용되지 않는다. 부모와 자식 엔티티간의 조인컬럼이 모두 PK의 경우를 의미한다.

얼핏 보면 특별해 보이지 않는 조건인 것 같다. 보통의 1:1 관계에서는 자식엔터티가 Null을 허용하는 경우가 필수인 경우보다는 많을 것이다. 또한, 대부분의 테이블간의 관계는 외래키 조인을 통해서 설정하는게 일반적인 방식이라고 보면 위의 조건중 1번과 3번을 만족시키는게 어렵지는 않다.

예제의 경우, 1,2번은 해당사항이 아니다.

문제는 2번이다. 1:1에서는 양방향 관계가 설정되면 LazyLoading이 발동하지 않는다.

위의 예제를 보면 Products 데이타만 가져오고, 개별적으로 ProductPrices의 데이타를 가져오고 싶은 요구사항을 만족시키기 위해서 자연스럽게 양방향 관계를 설정했을 뿐이다.

이 작업은 외래키가 자식테이블에 위치하는, 이런 일반적인 구조의 DB설계로 구축된 레가시 시스템에서는 테이블 구조를 모두 변경하지 않는 이상 JPA를 도입할때 필연적으로 발생하게 되는 작업이다.

사실 1:1관계에서 부모테이블이 자식테이블의 외래키를 가지는 방식-연관관계의 주인이 부모테이블이 됨-으로 설계가 되어 있거나, 그렇게 설계를 한다면 위의 요구사항을 양방향 관계없이 단방향관계로 수용할 수 있다.

문제는 대부분의 레거시 시스템들의 DB관계는 자식테이블이 부모테이블의 PK를 외래키로 가지는 구조로 만들어져 있다는 것이다.


DB에서 1:1 관계는 왜 필요한가?

DB설계에 있어서 1:1관계를 맺는 이유는 다양하다.

  • 한 테이블의 특정컬럼만 빈번하게 변경이 일어나는 경우
  • 특정 컬럼값만 따로 참조해야하는 비즈니스 로직이 요구될 경우 — 게시판의 글목록과 상세정보가 대표적인 예
  • 이런 경우는 정규화과정을 거쳐 테이블을 분리해 낸다.
  • 테이블을 분리할 때는 위에서 얘기한대로 자식테이블이 부모테이블의 PK를 외래키로 가지는 구조로 설계한다.

그러면 1:N관계뿐만이 아니라 1:1관계에서 왜 대부분 자식테이블이 외래키를 가지도록 설계하는 걸까?

개인적인 생각이기는 하지만 크게 두가지 이유이지 않을까 한다.

  1. 그냥 관성이다. 굳이 1:N관계와 반대로 부모테이블이 자식테이블의 PK를 가지도록 설계할 필요가 없기 때문이다.
  • 1.:N 관계는 N의 입장에 있는 자식테이블이 외래키를 가져야 한다. 1:1 관계에서도 자식 테이블이 외래키를 가지는게 DB전체의 관점에서 보았을때도 일관성이 가진다는 점은 의미가 있을 수 있다.

2. 분리해내려는 항목이 미래에 1:N관계로 변경될 수 있는 가능성이 조금이라도 있을 때이다.

  • 예를 든 상품과 상품가격의 경우 현재는 가격통화가 하나라서 1:1관계이나 서비스가 글로벌로 진출할 경우, 하나의 상품에 여러가지 통화의 가격정보를 관리할 수 있어야 할 것 이다.
  • 이렇게 테이블간의 관계가 1:1 에서 1:N으로 관계가 바뀌게 되면, 부모테이블에 외래키를 둔 경우, DB스키마 변경과 기존 데이타의 마이그레이션이라는 작업까지 많은 변경작업이 발생하게 된다.
  • 사실 기존 레거시 DB 모두가 이런 것까지 고려해서 설계했을거라고 생각하지는 않지만, 어쨋거나 저쨋거나 이런 구조가 대부분인게 사실이다.

만약 JPA를 도입하려는 시스템의 DB구조가 1:1 관계에서 부모테이블이 자식테이블의 외래키를 가지고 있는 형태로 설계되어 있다면, 이 글에서 얘기하는 이슈는 고려하지 않아도 될 것이다.

위에서 살펴본 바와 같이 JPA에서는 1:1 연관관계에서 대해서 그 다지 친절하지 않다.
특히 자식테이블이 외래키를 가지는 구조에서는 양방향 관계를 가져야지만 일반적인 비즈니스 요구사항을 수용할 수 있다.
그런데 양방향 관계를 맺게 되면, Lazyloading 전략이 제대로 발동하지 않는다.


Solutions

두 가지 측면으로 생각해 볼 수 있겠다.

신규 서비스에 JPA를 적용하는 경우

사실 신규서비스를 구축하기 위해서 JPA를 도입하는 경우라면, 아래의 몇 가지 사항들만 고려한다면 위에서 장황하게 설명한 이슈들을 다 피해갈 수 있다.

  • 꼭, 정말 필요한 경우가 아니라면 엔티티간 1:1 관계를 맺지 않도록 설계한다.
  • 1:1 관계가 꼭 필요하다면, 부모테이블이 외래키를 가지는 방식으로 설계한다.
  • 조금이라도 1:N관계로 변경될 가능성이 있다면, 처음부터 1:N관계로 설계한다.

레거시 시스템에 JPA를 도입하는 경우

레거시 시스템이 JPA의 의도 — 부모테이블이 외래키를 가지는 방식- 에 맞게 잘 설계되어 있다면
엔티티간 연관관계를 1:1 단방향으로 설정하는게 이슈가 되지 않는다.

그렇지 않을 경우, 1:1 관계의 테이블들을 JPA의 OneToOne으로 적용하면서 발생되는 상기의 치명적인 이슈들에 대해서 반드시 해결책을 찾아야 한다.

그 방법에 대해서는 다음 포스트에서 다루어 보려고 한다.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade