개요
OneToOne 양방향 관계로 설정한 엔티티에서 의도치 않게 N+1 이슈가 발생하였다.
관련된 개념인 프록시 객체와 즉시 로딩 & 지연 로딩에 대해 정리하고, 이슈를 공유하려 한다.
프록시 객체

실제 엔티티 객체 대신 사용되는 가짜 객체이다. 프록시 객체는 엔티티를 상속받고 위임 패턴으로 구현된다.
//실제 엔티티
//DB 조회 쿼리 발생
Member member = em.find(Member.class, member.getId());
//프록시 객체
//DB 조회 쿼리 발생되지 않음
Member proxyMember = em.getReference(Member.class, member2.getId());
//이 때, DB 조회 쿼리 발생
proxyMember.getName();
//다시 한 번 조회할 때는 쿼리 발생하지 않음.
proxyMember.getName();
프록시 객체를 조회하는 방법은 getReference() 메서드를 사용하면 된다.
엔티티와 프록시 객체의 가장 큰 차이점은 쿼리 발생 시점에 있다.
엔티티는 find()를 통해 조회할 경우 바로 쿼리를 전송하며 엔티티를 구성한다.
그에 반해 프록시 객체는 getReference()를 통해 조회할 경우 바로 쿼리를 전송하지 않는다. 프록시 객체의 id를 제외한 필드를 조회하려고 할 때, 영속성 컨텍스트에 초기화 요청을 한다. 영속성 컨텍스트가 DB 조회 쿼리를 발생한 후 프록시 객체를 초기화 시킨다.
초기화 된 프록시 객체에 대해서 다시 한 번 필드를 조회할 때는 추가적인 쿼리가 발생하지 않는다.
또한
그렇다면 프록시 객체는 왜 필요한것일까? 그건 바로 지연 로딩(Lazy Loading)을 위함이다.
프록시 객체는 id는 반드시 포함하고 있어야 한다. 그렇기 때문에 프록시 객체에서 id만 조회할 때는 쿼리가 발생하지 않는다.
즉시 로딩(Eager Loading)과 지연 로딩 (Lazy Loading)
즉시 로딩과 지연 로딩에 대해 알아보고 동작이 어떻게 다른지 비교해보자.
즉시 로딩
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
//지연 로딩 X
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
Member 엔티티가 있고, Team 객체와 연관관계가 설정되어 있다.
Team 객체에 대해 지연 로딩을 설정하지 않았다.
//Member 조회
//Team도 함께 조회
Member member = em.find(Member.class, 1L);
//객체 초기화 상태 -> DB 조회 쿼리 발생 X
Team team = member.getTeam();
team.getName();
Member 객체를 조회하면 즉시 로딩으로 설정된 Team 데이터가 join 쿼리로 함께 조회되며, Team 객체는 즉시 초기화된다.
Team 객체에 대한 필드를 조회해도 추가 쿼리가 발생하지 않는다.
지연 로딩
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
//지연 로딩 설정
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
}
이번에는 Team 객체에 대해 지연 로딩을 설정하였다.
//Member 조회
Member member = em.find(Member.class, 1L);
//프록시 상태 -> 아직 DB 조회 쿼리 발생 X
Team team = member.getTeam();
//Team 객체에 대한 DB 조회 쿼리 발생
team.getName();
이번에는 Member를 조회할 때, Team 객체가 초기화되지 않았다.
Team 객체를 갖고오는 순간까지는 추가 동작이 발생하지 않지만, id를 제외한 필드에 접근한 순간 쿼리가 발생하고 Team 객체는 초기화된다.
이처럼 프록시 객체는 지연 로딩 동작의 핵심 메커니즘이다.
OneToOne 양방향 연관관계 이슈
개요
@Entity
@Table(name = "coaching")
class Coaching(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
//지연 로딩 설정
@OneToOne(mappedBy = "coaching",fetch = FetchType.LAZY)
var notice: Notice? = null
...
) : BaseTime()
@Entity
class Notice(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
//연관관계 주인
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "coaching_id", nullable = false)
val coaching: Coaching,
var title: String,
@Column(columnDefinition = "TEXT")
var content: String
) : BaseTime()
Coaching과 Notice 엔티티가 존재한다. 둘은 1:1 관계이고, Notice가 연관관계 주인이다.
Coaching에서 Notice 객체에 대해 지연 로딩으로 설정한 것에 주목하자.
//Coaching 조회
//Coaching 조회와 동시에 Notice에 대한 별도의 쿼리 발생
coachingRepository.findById(coachingId);
//Coaching 갯수만큼 Notice 쿼리 수 발생
coachingRepository.findAll();
분명 Notice에 대한 지연 로딩을 설정했지만, Coaching을 조회하면 Notice를 조회하는 쿼리들이 별도로 발생하였다. 뭔가 잘못 설정한 건가 싶어서 코드를 찬찬히 살펴보았지만 잘못 작성한 것이 없었고, 내 지식의 근간이 흔들리려던 찰나였다.
문제 파악
프록시 객체는 id는 반드시 포함하고 있어야 한다.
위에서 프록시 객체에 대한 설명 중 일부였다. 만약 id가 존재한다면 프록시 객체를 생성하고, id가 존재하지 않는다면 null로 셋팅하면 되는 것이다.
@Entity
@Table(name = "coaching")
class Coaching(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
//Coaching에 입장에서는 Notice에 대한 id가 존재하는지 알 수가 없음
@OneToOne(mappedBy = "coaching",fetch = FetchType.LAZY)
var notice: Notice? = null
...
) : BaseTime()
문제는 Coaching이 연관관계 주인이 아니라는 것이였다. 그렇기에 Notice를 프록시로 생성해야 하는지 null로 생성해야 하는지 알 수가 없던 것이다.
Notice에 대한 프록시 객체를 생성 여부를 판단하기 위해서는 Notice의 id의 존재 여부를 알아내야 했기에 추가 쿼리가 불가피하였던 것이다.
해결
비즈니스적으로 보았을 때, Coaching에서 Notice쪽으로 향하는 경우는 많았지만, Notice에서 Coaching으로 향하는 경우는 거의 없었다.
@Entity
@Table(name = "coaching")
class Coaching(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
//지연 로딩 설정
//@OneToOne(mappedBy = "coaching",fetch = FetchType.LAZY)
//참조키 이관
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notice_id", nullable = false)
var notice: Notice? = null
...
) : BaseTime()
@Entity
class Notice(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
//Coaching 연관관계
//@OneToOne(fetch = FetchType.LAZY)
//@JoinColumn(name = "coaching_id", nullable = false)
//참조키 이관
@OneToOne(mappedBy = "coaching",fetch = FetchType.LAZY)
val coaching: Coaching,
var title: String,
@Column(columnDefinition = "TEXT")
var content: String
) : BaseTime()
DB 레벨에서 FK를 Coaching으로 이관하였으며, 엔티티에서도 연관관계 주인을 변경하였다.
이전에는 Notice를 저장할 때 Notice의 Coaching을 셋팅 후 저장하였지만, 변경 후에는 Notice을 저장 후 별도로 Coaching의 update하였다. 이 부분 말고는 큰 변화는 없이 문제를 해결할 수 있었다.
정리
프록시 객체와 로딩 방식에 대해서 정리해보고 OneToOne 양방향 연관관계 이슈에 대해 알아보았다.
이슈를 해결하기 위해 FK를 이관하는 방법 외에 다른 방법도 알아보았지만, hibernate의 구조적인 문제로 해결이 어렵다 판단하였다.
세상엔 아주 완벽한 기술은 없다는 것을 다시금 피부로 느끼게 되었다.
'JPA' 카테고리의 다른 글
| [JPA] saveAll() 문제점과 JDBC를 통한 해결 (0) | 2026.01.14 |
|---|---|
| [JPA] @SQLRestriction으로 soft delete 구현하기 (0) | 2025.12.21 |
| [JPA] 영속성 컨텍스트와 flush 시점 (0) | 2025.12.20 |
| [JPA] 테스트를 통해 알아본 JPA와 영속성 컨텍스트 (0) | 2024.03.15 |
| [Query DSL] Expressions를 사용하여 여러 값에 대한 존재 여부 확인하기 (0) | 2023.12.14 |