Yebali

Spring JPA의 @OneToOne 관계와 지연로딩 본문

Spring

Spring JPA의 @OneToOne 관계와 지연로딩

예발이 2021. 10. 11. 22:13

JPA는 일반적으로 @OneToOne에 지연 로딩을 지원하지 않는다.

JPA는 객체의 참조가 프록시 기반으로 동작한다.
즉 연관 관계가 있는 객체는 참조할 때 기본적으로 Null이 아닌 객체를 반환한다.

 

1:1 관계에서는 Null이 허용되는 경우 프록시 형태로 Null 객체를 반환할 수 없기 때문이다.
(= Nullable한 엔티티에 대해 프록시 객체 생성을 보장할 수 없다)

 

그런 이유로 JPA구현체는 1:1 관계에서 지연 로딩을 허용하지 않고, 값을 즉시(Eager) 읽어드린다.

그럼 1:N은?

1:N 관계는 이미 배열의 형태로 참조할 프록시 객체를 싸고 있기 때문에 그 객체가 Null이라도 참조할 때는 문제가 되지 않는다.

지연 로딩이 되지 않는게 문제가 되나요..?

된다.

이런 제약사항을 염두하지 않고 당연히 지연로딩이 되겠다는 가정하게 코딩하면 성능에 심각한 문제를 겪을 수 있다.

 

예) 1:1 관계의 부모/자식 테이블이 있고 각각의 테이블에 100건의 데이터가 있다고 가정했을 때, 부모 테이블 전체를 조회하면 쿼리가 몇 번 나갈까?

 

개발자는 1건의 쿼리 (select * from tbl_부모)를 바랐겠지만 실제로는 101건의 쿼리가 실행된다.
부모 테이블에 있는 레코드와 연결된 레코드를 모두 조회하기 때문이다.

 

하지만 특정 조건들을 만족하면 지연 로딩 기능을 사용할 수 있다.

  1. Nullable이 허용되지 않는 1:1 관계여야 한다.
  2. 양방향이 아닌 단방향 1:1 관계여야 한다.
  3. @PrimaryKeyJoin은 허용되지 않는다.
    : 부모, 자식 엔티티 간 조인 칼럼이 모두 PK이면 안된다.

위의 조건일 때 FK를 가진 엔티티 쪽에서만 지연 로딩이 동작한다.

 

임의로 1:1 관계의 human - IDCard 엔티티를 만들어 조회하는 예시 코드를 작성했다.

@Entity
class Human(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long,

    var name: String,

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "idcard")
    var idCard: IDCard?
) {

    constructor() : this(
        0L, "", null
    )

    companion object {
        fun createHuman(name:String, idCard: IDCard): Human {
            val human = Human()
            human.name = name
            human.bindIDCard(idCard)

            return human
        }
    }
    fun bindIDCard(idCard: IDCard) {
        this.idCard = idCard
        idCard.human = this
    }
}

@Entity
class IDCard (
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long,

    @Column(name = "number")
    var idNumber: String,

    @OneToOne(mappedBy = "idCard", fetch = FetchType.LAZY)
    var human: Human?
)

 

FK를 보유한 Human을 조회했을 때, 코드와 결과는 아래와 같다.

@Test
fun testLazy() {
    var idCard = IDCard(id = 0, idNumber = "123-456", null)
    val i1 = idCardRepository.save(idCard).id

    val human = Human.createHuman("yeseong", idCard)
    val i2 = humanRepository.save(human).id

    em.flush()
    em.clear()

    val findOne = humanRepository.findById(i2).get()
}

조회 쿼리 결과 : 실제로 단 1개의 select 쿼리가 실행된다.

select human0_.id as id1_0_0_, human0_.idcard as idcard3_0_0_, human0_.name as name2_0_0_ from human human0_ where human0_.id=1;

 

FK를 보유하지 않은 IDCard를 조회 했을때, 코드와 결과는 아래와 같다.

@Test
fun testLazy() {
    var idCard = IDCard(id = 0, idNumber = "123-456", null)
    val i1 = idCardRepository.save(idCard).id

    val human = Human.createHuman("yeseong", idCard)
    val i2 = humanRepository.save(human).id

    em.flush()
    em.clear()

    val findOne = idCardRepository.findById(i1).get()
}

조회 쿼리 결과 : idcard를 조회하는 쿼리 1개, 해당 id를 가진 human을 조회하는 쿼리 1개
총 2개의 쿼리가 실행된다.

select idcard0_.id as id1_1_0_, idcard0_.number as number2_1_0_ from idcard idcard0_ where idcard0_.id=4;

select human0_.id as id1_0_0_, human0_.idcard as idcard3_0_0_, human0_.name as name2_0_0_ from human human0_ where human0_.idcard=4;

FK를 가지고 있지 않은 엔티티를 조회했을 때에는 fetchType을 Lazy로 해도 즉시 로딩이 되는 걸 볼 수 있다.

단순히 생각하면 FK가 없어서 지연 로딩을 할 수 없기 때문에 바로 조회하는 것으로 생각된다.

여담. 1:1 관계에서 FK가 Non-nullable일 때 테이블을 분리해야 하는 경우가 있을까?

흔히 테이블을 분리(partitioning)했을 때 장점과 단점은 다음과 같다.

장점

  • 관리적 측면
    • 전체 데이터를 손실할 가능성이 줄어든다.
    • 분할된 테이블 별로 백업/복구가 가능하다.
    • 분할된 테이블 단위로 I/O 분산이 가능하며 UPDATE 성능이 향상된다.
  • 성능적 측면
    • 데이터 전체 검색 (full scan) 시 필요한 부분만 탐색하여 성능이 향상된다.
    • 필요한 데이터만 빠르게 조회 할 수 있어 쿼리 자체가 단순하고 가볍다.

단점

  • 테이블간 Join 비용이 발생한다.

테이블 하나가 너무 거대해 졌거나 일부 컬럼에 대한 조회가 많을 때,
분리하여 따로 관리하면 조금의 성능 향상을 얻을 수 있지 않을까?