(JPA) JPA에서 지연 로딩은 항상 동작하는가? (feat: OneToOne 양방향 연관관계)
개요
JPA는 엔티티 간의 관계를 설정할 때 다양한 페치 전략을 제공하며, 그 중 지연 로딩(Lazy Loading) 은 성능 최적화를 위해 자주 사용된다. 지연 로딩은 실제로 연관된 데이터가 필요할 때만 데이터베이스에서 로딩하는 방식으로, 초기 로딩 시 불필요한 데이터를 가져오는 것을 방지하여 애플리케이션의 응답성을 향상시킨다.
이번 글에서는 JPA에서 지연 로딩이 왜 사용되는지, 연관 관계의 주인이 무엇인지, 지연 로딩 시 프록시와 null이 어떻게 할당되는지, 그리고 특히 OneToOne 양방향 관계에서 지연 로딩이 정상적으로 동작하지 않는 이유에 대해 생각해보겠다.
연관 관계의 주인
JPA에서 연관 관계의 주인(Owner) 이란, 두 엔티티 간의 관계에서 실제로 데이터베이스의 외래 키(Foreign Key)를 관리하는 쪽을 의미한다.
JPA에서 연관 관계의 주인(Owner) 이란,
두 엔티티 간의 관계에서 실제로 데이터베이스의 외래 키(Foreign Key)를 관리하는 쪽을 의미한다.
연관 관계의 주인은 관계의 상태를 관리하며, 데이터베이스에 외래 키를 업데이트하는 책임을 진다. 반대로, 비주인(Inverse) 쪽은 단순히 주인 쪽을 참조할 뿐 외래 키를 직접 관리하지 않는다. 연관 관계의 주인을 지정하는 이유는 데이터의 일관성을 유지하고, JPA가 관계를 효율적으로 관리할 수 있도록 돕기 위함이다. 연관 관계의 주인은 @JoinColumn 어노테이션을 사용하여 외래 키를 관리하며, 비주인 쪽은 mappedBy 속성을 통해 주인 쪽의 필드를 참조한다.
@Entity
public class Employee {
@Id @GeneratedValue
private Long id;
private String name;
@OneToOne(mappedBy = "employee", fetch = FetchType.LAZY)
private ParkingSpot parkingSpot;
// getters and setters
}
@Entity
public class ParkingSpot {
@Id @GeneratedValue
private Long id;
private String location;
@OneToOne
@JoinColumn(name = "employee_id") // 외래 키 관리
private Employee employee;
// getters and setters
}
위 예제에서 ParkingSpot 엔티티가 Employee 엔티티를 참조하며, @JoinColumn 어노테이션을 통해 외래 키를 관리한다. 반면, Employee 엔티티는 mappedBy = "employee" 속성을 통해 ParkingSpot의 employee 필드가 연관 관계의 주인임을 지정하고 있다. 따라서 ParkingSpot이 연관 관계의 주인이며, 외래 키는 ParkingSpot 테이블에 존재한다.
생각해보기: 왜 연관 관계의 주인은 하나여야 할까? 서로 FK로 참조하면 안될까?
일방향 관계와 양방향 관계
일방향 관계 (Unidirectional Relationship)
일방향 관계는 한 엔티티가 다른 엔티티를 참조하는 단방향적인 관계이다. 즉, 한 쪽 엔티티만이 다른 쪽 엔티티를 알고 있으며, 반대쪽 엔티티는 이를 알지 못한다.
@Entity
public class Employee {
@Id @GeneratedValue
private Long id;
private String name;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parking_spot_id") // 외래 키 관리
private ParkingSpot parkingSpot;
// getters and setters
}
@Entity
public class ParkingSpot {
@Id @GeneratedValue
private Long id;
private String location;
// getters and setters
}
위 예제에서 Employee 엔티티는 ParkingSpot을 참조하지만, ParkingSpot 엔티티는 Employee를 참조하지 않는다.
양방향 관계 (Bidirectional Relationship)
양방향 관계는 두 엔티티가 서로를 참조하는 관계이다. 즉, 양쪽 엔티티가 서로를 알고 있으며, 데이터 조회 시 양방향으로 탐색이 가능하다.
@Entity
public class Employee {
@Id @GeneratedValue
private Long id;
private String name;
@OneToOne(mappedBy = "employee", fetch = FetchType.LAZY)
private ParkingSpot parkingSpot;
// getters and setters
}
@Entity
public class ParkingSpot {
@Id @GeneratedValue
private Long id;
private String location;
@OneToOne
@JoinColumn(name = "employee_id") // 외래 키 관리
private Employee employee;
// getters and setters
}
위 예제에서 ParkingSpot 엔티티가 Employee 엔티티를 참조하고, Employee 엔티티는 ParkingSpot 엔티티를 참조한다. ParkingSpot이 연관 관계의 주인이며, Employee는 비주인 쪽으로 설정되어 있다.
Lazy와 Proxy
지연 로딩(Lazy Loading) 은 연관된 엔티티를 실제로 접근할 때까지 데이터베이스에서 로딩하지 않는 전략이다. 이를 구현하기 위해 JPA는 프록시(Proxy) 객체를 사용한다. 프록시 객체는 실제 엔티티를 대신하여 존재하며, 실제 데이터가 필요할 때 데이터베이스에서 로딩한다.
지연 로딩이 정상적으로 동작하기 위해서는 다음과 같은 조건이 충족되어야 한다:
- 프록시 객체 생성 가능: JPA는 연관된 엔티티를 프록시 객체로 대체할 수 있어야 한다. 이를 위해 엔티티 클래스는 final 클래스로 선언되지 않아야 하며, 메서드도 final로 선언되지 않아야 한다.
- 외래 키의 존재 여부 확인: 연관 관계의 주인 쪽은 외래 키를 통해 연관된 엔티티의 존재 여부를 확인할 수 있어야 한다. 외래 키가 null인 경우 연관된 엔티티가 없음을 의미하며, 그렇지 않은 경우 프록시 객체를 할당하여 실제 엔티티가 존재함을 나타낸다.
프록시와 null의 할당 과정
- 주인 엔티티가 로딩될 때, 연관된 엔티티가 존재하는지 외래 키를 통해 확인한다.
- 외래 키가 null인 경우, 연관된 엔티티는 없으므로 null이 할당된다.
- 외래 키가 null이 아닌 경우, 프록시 객체가 생성되어 연관된 엔티티를 대신하게 된다.
OneToOne 양방향 관계의 경우 연관 관계의 주인이 아닌 엔티티에서 프록시나 null을 할당한 것인가?
양방향 OneToOne 관계에서 비주인 쪽 엔티티가 지연 로딩을 지원하지 않는 이유는, 비주인 쪽이 외래 키를 직접 관리하지 않기 때문이다. 외래 키를 관리하는 주인 쪽에서는 외래 키를 통해 연관된 엔티티의 존재 여부를 쉽게 확인할 수 있어, 프록시 객체를 생성하거나 null을 할당할 수 있다. 그러나 비주인 쪽에서는 외래 키가 없기 때문에 이러한 작업이 불가능하다.
연관 관계 주인 여부에 따른 프록시 할당 가능 여부
- 주인 쪽: 주인 쪽 엔티티는 외래 키를 관리하며, 이를 통해 연관된 엔티티의 존재 여부를 확인할 수 있다. 따라서 지연 로딩 시 프록시 객체를 생성하거나 null을 할당할 수 있다.
- 비주인 쪽: 비주인 쪽 엔티티는 외래 키를 관리하지 않으므로, 연관된 엔티티의 존재 여부를 직접 확인할 수 없다. 이로 인해 지연 로딩을 위한 프록시 객체를 생성할 수 없으며, JPA는 이를 해결하기 위해 즉시 로딩(EAGER)으로 설정하게 된다.
결론
양방향 OneToOne 관계에서 비주인 쪽에 FetchType.LAZY를 설정하더라도, JPA는 이를 무시하고 즉시 로딩으로 처리할 수 있다. 이는 비주인 쪽이 외래 키를 관리하지 않기 때문에 발생하는 제약이며, 프록시 객체나 null을 적절히 할당할 수 없기 때문이다.