❄️ 트러블 슈팅

[트러블슈팅] @OneToOne(mappedBy) LAZY 로딩이 동작하지 않는 이유와 해결 방법

le2donguk 2026. 3. 22. 18:09

문제 상황

회원가입 및 로그인 기능을 구현하던 중 memberRepository.findMemberByEmail() 메서드를 호출했을 때, 조회하지 않은 연관 엔티티의 쿼리까지 함께 실행되는 문제가 발생했습니다.

단순히 Member만 조회했지만 실제로는 다음과 같은 SQL이 실행되었습니다.

 
 

sql

SELECT * FROM member WHERE email = ?

SELECT * FROM business WHERE member_id = ?
SELECT * FROM profile_image WHERE member_id = ?
SELECT * FROM auth WHERE member_id = ?

 

@OneToOne(fetch = FetchType.LAZY)로 설정했기 때문에 연관 엔티티는 실제 접근 시점에 Lazy Loading 되어야 한다고 생각했였습니다

그런데 실제로는 즉시 조회(Eager)처럼 여러 쿼리가 날아가는 동작을 하고 있었습니다.


원인 분석

연관관계 구조

MemberEntity
// Member 엔티티 (inverse side - FK 없음)
@OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
private Business business;

 

위 구조에서 Member 엔티티는 연관관계의 주인이 아닌 inverse side입니다.

즉, FK는 Business 테이블에 존재합니다.

 

Hibernate 내부 동작 원리

Hibernate가 LAZY 로딩을 구현하는 방식은 프록시(Proxy) 객체를 생성하여 실제 객체 대신 placeholder를 주입해 동작합니다.

그런데 프록시를 생성하려면 반드시 다음을 알아야 합니다.

  • 연관 엔티티가 존재하는가 → 프록시 객체 주입
  • 연관 엔티티가 존재하지 않는가 → null 주입

여기서 핵심 문제가 발생합니다.

Member(inverse side)는 FK를 갖고 있지 않기 때문에, Hibernate는 Business 테이블에 해당 member_id를 가진 row가 있는지 없는지 알 수 없습니다.

결과적으로 프락시를 null로 초기화해야 할지, 실제 프락시 객체를 주입해야 할지 판단할 수 없어 즉시 SELECT 쿼리를 실행하여 존재 여부를 확인합니다.

이는 FK를 보유한 owner side와 대조됩니다.

Owner side는 FK 컬럼 값만 읽으면 연관 엔티티의 존재 여부를 즉시 알 수 있으므로, LAZY 프락시를 정상적으로 생성할 수 있습니다.

// ✅ Owner side: FK 보유 → LAZY 정상 동작
@Entity
public class Business {
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}

// ❌ Inverse side: FK 없음 → LAZY 무시, 즉시 SELECT 실행
@Entity
public class Member {
    @OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
    private Business business;
}

기존 구조의 문제

기본 구조에선 Member 엔티티에 @OneToOne(mappedBy) 관계가 여러 개 선언되어 있었습니다.

@Entity
public class Member {

    @OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
    private Business business;

    @OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
    private ProfileImage profileImage;

    @OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
    private Auth auth;
}

 

이로 인해  Member를 단 한 번 조회할 때마다 4개의 쿼리가 발생했습니다.


해결 방법

연관 엔티티 조회가 실제로 필요하지 않은 경우라면, 양방향 연관관계를 제거하고 필요한 데이터만 별도 Repository에서 직접 조회하도록 구조를 변경했습니다.

 

변경 전: Member 조회 → JPA가 연관 엔티티를 자동 조회

변경 후: Member 조회 → 필요한 데이터만 Repository에서 명시적으로 조회

 
Member member = memberRepository.findMemberByEmail(email);

if (member == null)
    throw new UsernameNotFoundException("유저를 찾을 수 없습니다.");

List<AuthStatus> findAuthList =
    authRepository.findStatusesByMemberId(member.getId());

if (findAuthList.isEmpty())
    throw new UsernameNotFoundException("권한이 없는 사용자입니다.");

LoginMemberDto dto = LoginMemberDto.of(member, findAuthList);

return new CustomUser(dto);

 

또한 만일 Fk 만 필요한 경우 getReferenceId() 를 통해  fk만 가져올 수 있습니다

주의할 점은 이건 fk 만 가져오는것이라서 나중에 해당 필드를 통해 다른 객체 그래프를 타게 된다면 똑같은 문제가 발생할 수 있습니다.

이 부분은 나중에 정리 해보겠습니다.


개선 결과

변경 후 실행된 SQL입니다.

 
 
sql
-- Member 조회
SELECT m1_0.member_id, m1_0.email, m1_0.name, m1_0.password, ...
FROM member m1_0
WHERE m1_0.email = ?

-- 필요한 Auth만 조회
SELECT a1_0.status
FROM auth a1_0
WHERE a1_0.member_id = ?

불필요했던 business, profile_image 조회 쿼리가 완전히 제거되었습니다.

정리해 보자면 

 

  이전  이후 
실행 쿼리 수 4개 2개
연관관계 결합도 높음 낮음
조회 로직 제어 JPA 자동 서비스 레이어 명시적 제어

 정리

@OneToOne(mappedBy) 관계에서 LAZY 옵션이 제대로 동작하지 않았던 이유는 FK가 없는 inverse side에서 Hibernate가 연관 엔티티의 존재 여부를 판단할 수 없기 때문입니다.

 

이 문제를 해결하는 방법으로는 다음 두 가지를 고려할 수 있습니다.

  1. 양방향 관계 제거 → 필요한 데이터만 Repository에서 직접 조회 (이번에 적용한 방식)
  2. @LazyToOne(LazyToOneOption.NO_PROXY) + 바이트코드  적용 (설정 복잡도가 높음)

엔티티 간 결합도를 낮추고 조회 로직을 명확하게 제어하고 싶다면, 단순히 양방향 관계를 끊고 필요한 시점에 명시적으로 조회하는 방식이 더 실용적인 선택이 될 수 있습니다.

 

📌 한 줄 요약 @OneToOne(mappedBy) 관계에서 LAZY 설정은 무시될 수 있습니다. FK가 없는 inverse side에서 Hibernate는 연관 엔티티의 존재 여부를 알 수 없어 즉시 쿼리를 실행하기 때문입니다.