JPA 프로그래밍 입문 - 엔티티 간 1:1 연관 그리고 즉시 로딩과 지연 로딩

이 글은 “JPA 프로그래밍 입문 (최범균 저)” 책 내용을 정리한 글입니다.

만약 저작권 관련 문제가 있다면 “gunjuko92@gmail.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.

1. 키를 참조하는 1:1 연관 매핑

1:1 관계를 DB 테이블로 표현할 때는 참조키를 사용한다. 예를 들어 membership_card 테이블의 user_email 칼럼은 user 테이블의 주요키를 참조하는데 이 칼럼을 이용해서 membership_card 테이블과 user 테이블이 1:1 연관 관계를 가지게 된다.

JPA를 사용하면 간단한 설정으로 두 엔티티 객체 간의 1:1 연관을 처리할 수 있다. JPA가 지원하는 참조키 기반 1:1 연관에는 단방향 연관과 양방향 연관 두가지가 존재한다.

2. 참조키를 이용한 1:1 단방향 연관

  • @OneToOne : 애노테이션을 사용하면 간단하게 1:1 연관 관계를 설정할 수 있다.
  • @JoinColumn : 다른 엔티티를 참조하려면 해당 엔티티의 식별자 값을 구해야하는데, 이 식별자를 담고 있는 칼럼을 지정할 때 @JoinColumn 애노테이션을 사용한다.
  • @OneToOne으로 연관된 객체의 식별자를 @JoinColumn으로 설정한 칼럼에 저장한다.
  • DB에 저장된 객체를 로딩하면 저장할 때와 반대로 @JoinColumn에 지정한 칼럼을 이용해서 @OneToOne으로 연관된 엔티티를 로딩한다.
    • @JoinColumn으로 매핑한 칼럼 값이 널인 데이터를 로딩하면 연관된 객체도 널이 된다.
  • 만약에 엔티티에 @OneToOne 애노테이션으로 매핑된 엔티티가 존재하면, 해당 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다. 하이버네이트는 left 조인을 이용해서 연관된 엔티티를 조회한다.
  • 연관에 사용할 엔티티 객체는 반드시 영속 상태로 존재해야 한다. 연관에 사용할 엔티티 객체가 영속 상태가 아닌 경우 트랜잭션 커밋 시점에 TransientPropertyValueException이 발생한다.

3. 1:1 연관의 즉시 로딩과 지연 로딩

  • 연관된 객체를 외부 조인을 사용해서 함께 로딩하는 것을 즉시 로딩 (eager loading)이라고 한다.
    • 하이버네이트는 즉시 로딩을 구현하기 위해 조인을 이용해서 1:1로 연관된 객체를 함께 로딩한다.
  • 즉시 로딩은 연관된 객체를 함께 불러오는데 만약 연관된 객체를 함께 사용하지 않으면 필요 없는 객체를 로딩하게 된다.
  • 연관된 객체가 필요한 기능보다 필요하지 않은 기능이 많다면 지연 로딩(lazy loading)을 사용해서 연관된 객체가 필요할 때만 로딩하도록 구현할 수 있다.
  • 지연 로딩은 연관 객체를 실제 사용하는 시점에 로딩하는 방식이다.
    • @OneToOne 애노테이션의 fetch 속성값으로 FetchType.LAZY를 지정하면 된다.
    • 연관 객체 필드에 처음 접근하는 시점에 해당 테이블을 조회해서 데이터를 가져온다.
  • @OneToOne 애노테이션의 fetch 속성은 FetchType.EAGER을 기본값으로 갖는다. 따라서, fetch 속성을 따로 지정하지 않으면 엔티티를 로딩할 때 @OneToOne으로 매핑한 연관 객체로 함께 로딩한다.

4. 참조키를 이용한 1:1 양방향 연관

  • 테이블 구조가 단방향 참조라도 엔티티는 서로 참조하는 양방향 연관을 가질 수 있다.
  • DB 테이블에서 두 엔티티 간의 연관은 참조키를 통해서 이루어진다. 이 때 참조를 소유하는 주체는 참조키를 갖고 있는 테이블이다. JPA의 1:1 연관도 내부적으로 DB 테이블의 참조를 기반으로 구현하기 때문에 본질적으로 참조의 방향은 단방향이다.
  • JPA는 1:1 연관에서 물리적으로 존재하지 않는 연관을 처리하기 위해 mappedBy를 사용한다. mappedBy는 양방향 관계에서 물리적인 연관을 다른 엔티티가 소유한다는 것을 지정한다.
    • mappedBy의 속성값으로는 연관을 소유한 엔티티의 매핑 필드를 지정한다.

5. 주요키를 공유하는 1:1 연관 매핑

  • 두 엔티티가 키를 공유하는 경우도 있다.
  • 식별자를 공유하는 1:1 연관을 DB 테이블로 매핑하면 주요키를 공유하는 구조가 된다. 한 테이블의 주요키는 주요키이면서 연관 테이블을 참조하는 참조키가 된다.

6. 주요키를 공유하는 1:1 단방향 연관

  • 주요키를 공유하는 1:1 단방향 연관은 @OneToOne 애노테이션과 @PrimaryJoinColumn 애노테이션으로 설정한다.
  • @PrimaryJoinColumn 애노테이션은 다음을 의미한다.
    • 연관 객체를 참조할 때 주요키를 이용한다. 이 때 주요키는 @Id와 매핑되는 칼럼이다.
    • 1:1 연관을 맺는 객체들끼리 식별자는 같은 값을 가진다.

7. 주요키를 공유하는 1:1 양방향 연관

  • 참조키를 이용한 1:1 양방향 연관과 마찬가지로 @OneToOne 애노테이션을 사용하며, 매핑을 소유한 주체의 매핑 필드를 mappedBy 속성값으로 지정한다.

8. 1:1 연관 끊기

  • 연관 객체와의 관계를 제거하려면 단순히 널을 할당하면 된다.
  • 양방향 연관을 사용하면 양쪽 연관에 모두 널을 할당하면 된다.

양방향 연관을 사용할 때 실제로는 연관을 소유한 쪽만 널을 할당해도 디비에는 반영된다. 하지만 런타임시에 자바 객체가 올바르게 동작할 수 있도록 양방향 연관을 끊을 때에는 두 객체의 연관을 모두 널로 해주는 것이 좋다.

9. 자동 생성키와 1:1 연관 저장

  • JPA는 EntityManager#persist()를 실행하는 시점에 식별자를 생성하는 방식을 제공한다.
    • 자동증가 칼럼이나 테이블을 이용한 식별자 생성기가 이에 해당한다.
  • 자동 증가 칼럼이나 시퀀스와 같은 식별자 생성기를 이용하는 엔티티와 주요키를 공유하는 1:1 연관을 맺는 경우는 식별자를 생성할 뒤에 연관을 맺어야 한다. 식별자를 생성하기 전에 연관을 맺는 경우 식별자가 널로 셋팅될 수 있다.
  • 참조키를 이용해서 연관을 맺는 경우는 연관을 맺을때 식별자가 필요한 것은 아니다. 연관 객체의 식별자가 필요한 시점은 객체를 DB에 저장하기 위해 insert 쿼리를 실행하는 시점이다.

10. 지연 로딩, 프록시, EntityManager 범위

  • 지연 로딩을 사용하는 경우 연관 엔티티를 사용하는 시점에 필요한 데이터를 조회한다.
  • 하이버네이트는 연관 객체의 지연 로딩을 구현하기 위해 프록시 객체를 사용한다. 하이버네이트가 생성한 프록시 클래스는 연관 클래스를 상속받은 클래스이다. 이 프록시 클래스는 실제 연관 객체의 속성에 접근할 때 데이터를 조회하는 쿼리를 실행한다.
  • 아래와 같은 과정을 거쳐서 지연 로딩이 동작한다.
    • 엔티티 로딩을 EntityManager의 요청하면 해당 엔티티 객체를 생성하는데 필요한 쿼리만 실행하고 1:1 연관된 엔티티 객체를 생성하는데 필요한 쿼리를 실행하지 않는다.
    • EntityManager는 1:1로 연관된 엔티티를 프록시로 대신한다.
    • 실제 연관된 객체의 데이터를 처음 요청하면 프록시 객체는 EntityManager#find 메소드를 이용해서 데이터를 디비에서 조회한다.
    • 프록시가 한 번 실제 엔티티를 로딩하면 이후 접근에 대해서는 조회 쿼리를 실행하지 않고 이미 로딩된 엔티티 객체를 사용한다.
  • 프록시를 통해서 실제 연관 객체의 값에 접근하는 시점에 DB에서 select 쿼리를 실행하기 때문에 DB와의 연결이 끊기면 연관 객체를 로딩할 수 없다. 즉 EntityManager#close()를 해서 DB와의 연결이 끊기면 아직 로딩하지 않은 연관 객체를 읽을 수 없을을 의미한다.
    • DB와의 연결이 끊긴 상태에서 연관 객체를 로딩하려고 하면 LazyInitializationException이 발생한다.
  • 연관된 객체를 로딩하기 위한 가장 쉬운 방법은 실제 객체에 접근하는 것이다.

하이버네이트의 Hibernate#initialize() 메소드를 사용하면 지연 로딩으로 설정된 대상을 로딩할 수 있다.

하이버네이트에서 프록시 객체가 로딩할 실제 객체의 클래스 타입을 구하고 싶다면 HibernateProxyHelper#getClassWithoutInitializingProxy 메소드를 사용하면 된다.