Java/JPA

OneToOne 관계에서의 지연 로딩과 N+1 문제 심층 분석

blogger903 2025. 2. 21. 15:37
728x90

OneToOne 관계에서의 지연 로딩과 N+1 문제 심층 분석

샘플 엔티티 관계

     Team
     ▲   ▼
     │   │
  N  │   │ 1
     │   │
     │   │
    Member ◄──── 1:1 ────► Profile

샘플 엔티티

@Entity  
class Member(  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    @Column(name = "member_id")  
    val id: Long? = null,  

    val name: String,  
    val email: String,  

    @ManyToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "team_id")  
    var team: Team? = null,  

    @OneToOne(mappedBy = "member", cascade = [CascadeType.ALL], orphanRemoval = true)  
    var profile: Profile? = null  
) {  
    fun changeProfile(profile: Profile) {  
        this.profile = profile  
        profile.member = this  
    }  
}  

@Entity  
class Profile(  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    @Column(name = "profile_id")  
    val id: Long? = null,  

    val bio: String,  
    val imageUrl: String,  

    @OneToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "member_id")  
    var member: Member? = null  
)  


@Entity  
class Team(  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    @Column(name = "team_id")  
    val id: Long? = null,  

    val name: String,  

    @OneToMany(mappedBy = "team", cascade = [CascadeType.ALL], orphanRemoval = true)  
    val members: MutableList<Member> = mutableListOf()  
) {  
    fun addMember(member: Member) {  
        members.add(member)  
        member.team = this  
    }  
}

OneToOne 관계에서 주인이 아닌 쪽의 문제 (Member → Profile)

첫 번째 예제에서 Member 엔티티는 Profile과 OneToOne 관계를 맺고 있지만, 연관관계의 주인이 아닙니다

@OneToOne(mappedBy = "member", cascade = [CascadeType.ALL], orphanRemoval = true)
var profile: Profile? = null

이 상황에서 눈여겨볼 점은 사용자가 profile 필드에 명시적으로 접근하지 않았음에도 N+1 쿼리가 발생했다는 것입니다

// Member만 조회하는 쿼리
select m1_0.member_id,m1_0.email,m1_0.name,m1_0.team_id from member m1_0;

// Profile을 접근하지 않았는데도 각 Member별로 Profile 쿼리 발생
select p1_0.profile_id,p1_0.bio,p1_0.image_url,p1_0.member_id from profile p1_0 where p1_0.member_id=1;
select p1_0.profile_id,p1_0.bio,p1_0.image_url,p1_0.member_id from profile p1_0 where p1_0.member_id=2;
// ... 이하 생략

왜 이런 현상이 발생할까?

Hibernate가 이렇게 동작하는 이유는 근본적으로 프록시의 한계 때문입니다

  1. 식별자 접근성 문제: Member 엔티티만 로드했을 때, Profile의 존재 여부와 ID를 알 수 없습니다. 외래 키가 Profile 테이블에 있기 때문입니다.
  2. null 여부 확인 필요: Hibernate는 member.profile이 null인지 아닌지 즉시 알아야 합니다. 이 정보를 알아내기 위해 데이터베이스 쿼리를 실행할 수밖에 없습니다.
  3. 프록시 객체 생성 불가: 컬렉션과 달리 단일 객체는 "빈" 상태를 표현할 수 없습니다. 객체는 있거나(프록시) 없거나(null) 둘 중 하나여야 합니다.

이렇게 OneToOne 관계에서 주인이 아닌 쪽에서는 지연 로딩을 설정해도 사실상 즉시 로딩처럼 동작하게 됩니다.

OneToOne 관계에서 주인 쪽의 동작 (Profile → Member)

두 번째로, Profile에서 Member를 참조하는 경우를 살펴보겠습니다. 여기서 Profile은 연관관계의 주인입니다

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
var member: Member? = null

이 경우, 로그에서 볼 수 있듯이 default_batch_fetch_size 설정에 의해 효율적으로 배치 로딩이 일어납니다

// Profile 엔티티만 조회
select p1_0.profile_id,p1_0.bio,p1_0.image_url,p1_0.member_id from profile p1_0;

// Member와 Profile을 한 번에 배치로 로딩 (IN 절 사용)
select m1_0.member_id,m1_0.email,m1_0.name,p1_0.profile_id,p1_0.bio,p1_0.image_url,m1_0.team_id 
from member m1_0 left join profile p1_0 on m1_0.member_id=p1_0.member_id 
where m1_0.member_id in (4,1,2,3,5,6,7,...);

왜 이렇게 작동할까?

Profile이 연관관계의 주인이므로 foreign key(member_id)를 가지고 있습니다.

  1. 식별자 접근 가능: Profile 엔티티를 로드할 때 member_id 값도 함께 로드됩니다.
  2. 명확한 null 여부: foreign key가 null인지 아닌지 바로 알 수 있으므로, null이 아닌 경우에만 프록시 객체를 생성할 수 있습니다.
  3. 프록시 초기화 가능: 프록시는 이미 식별자 값을 알고 있어, 실제 접근 시점에 필요한 데이터만 로드할 수 있습니다.

기술적 개선 방법

분석 결과에 따라, OneToOne 관계에서의 N+1 문제를 해결하기 위한 실용적인 접근 방법을 살펴보겠습니다

1. 연관관계의 주인 측에서 접근하기

연관관계의 주인이 아닌 쪽에서 쿼리를 시작하면 N+1 문제가 자연스럽게 발생합니다. 가능하다면 연관관계의 주인 쪽에서 쿼리를 시작하고, default_batch_fetch_size 설정을 활용하는 것이 좋습니다.

// Profile(주인)에서 시작 → 효율적인 배치 로딩 가능
val profiles = profileRepository.findAll()

2. 프로젝션 사용하기

연관 엔티티의 데이터가 필요하지 않다면, 프로젝션을 통해 필요한 필드만 선택적으로 조회하는 것이 좋습니다

@Query("SELECT new com.example.ProfileResponse(p.id, p.bio, p.imageUrl) FROM Profile p")
fun findAllProfilesProjection(): List<ProfileResponse>

이 방식의 장점:

  • 엔티티 객체를 생성하지 않아 연관관계 로딩이 발생하지 않음
  • 필요한 데이터만 선택적으로 가져와 네트워크 트래픽 감소
  • 영속성 컨텍스트에 영향을 주지 않음

3. 페치 조인 활용하기

연관 엔티티 데이터가 필요하다면 명시적인 페치 조인이 가장 효과적입니다

ToOne관계에서는 fetch join으로 N+1문제 해결하는게 좋습니다.

@Query("SELECT p FROM Profile p JOIN FETCH p.member")
fun findAllWithMember(): List<Profile>

Profile → Member 조회 시:

// 단일 쿼리로 모든 데이터 조회
select p1_0.profile_id,p1_0.bio,p1_0.image_url,m1_0.member_id,m1_0.email,m1_0.name,m1_0.team_id 
from profile p1_0 join member m1_0 on m1_0.member_id=p1_0.member_id;

4. OneToMany 관계를 사용한 대체 설계

Paweł Kępka가 제안한 방법처럼, OneToOne 대신 OneToMany 리스트로 설계하는 방법도 있습니다:

@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Profile> profiles;

// 사용 시
Profile profile = member.getProfiles().isEmpty() ? null : member.getProfiles().get(0);

이 방식은 컬렉션 래퍼를 통해 지연 로딩을 효과적으로 구현할 수 있게 해줍니다.

페이징 처리에 대한 추가 고려사항

데이터에 페이징을 적용할 때 연관관계의 종류에 따라 접근법이 달라집니다

ToOne 관계(OneToOne, ManyToOne)에서의 페이징

ToOne 관계에서는 페치 조인과 페이징을 함께 사용해도 문제가 없습니다:

@Query("SELECT m FROM Member m JOIN FETCH m.team JOIN FETCH m.profile WHERE m.team.id = :teamId")
fun findMembersByTeamIdWithTeam(teamId: Long, pageable: Pageable): Page<Member>

이렇게 하면 단일 쿼리로 페이징된 결과를 얻을 수 있습니다.

ToMany 관계(OneToMany, ManyToMany)에서의 페이징

ToMany 관계에서는 데이터 중복 문제로 페치 조인과 페이징을 함께 사용할 수 없습니다. 

대신

  1. 먼저 일반 쿼리로 페이징 처리
  2. default_batch_fetch_size 설정으로 연관 엔티티 일괄 로딩
// 먼저 Team만 페이징 조회
val teamsPage = teamRepository.findAll(pageable)
// DTO 변환 과정에서 batch fetch 동작
return teamsPage.map { TeamResponse.from(it) }

결론

OneToOne 관계에서 N+1 문제를 해결하는 핵심은 연관관계의 방향과 주인 설정입니다

  1. 설계 단계: 가능하면 자주 조회하는 방향이 연관관계의 주인이 되도록 설계합니다.
  2. 조회 최적화: 페치 조인, 배치 사이즈 설정, 프로젝션 등의 기법을 적절히 활용합니다.
  3. 대체 설계 검토: 정말 필요한 경우가 아니라면 양방향 OneToOne 관계는 피하고, 단방향으로 설계하거나 OneToMany로 대체합니다.

OneToOne 관계에서 지연 로딩 문제는 결국 프록시 객체의 특성과 관련되어 있습니다. 프록시는 최소한 식별자 값을 알아야 생성될 수 있고, null 여부도 명확해야 합니다. 이러한 특성을 이해하고 설계하면 N+1 문제를 효과적으로 최소화할 수 있습니다.

'Java > JPA' 카테고리의 다른 글

Spring Data JPA - join, 페이징 정리  (0) 2024.08.21
Enum - Converter  (0) 2024.07.31