[Spring Boot] 프로필 하나 조회하는데 쿼리가 5방? (JPA N+1 문제 해결과 성능 최적화)

2026. 1. 26. 01:22How to become a real programmer/Projects

 

안녕하십니까. 또 밤이네요. 이거 해결하고 자려다가, 기분이 너무 상쾌해서 블로그 글까지 쓰고 자려고 합니다.

실은 3일 전에 머지한 코드인건 비밀입니다.

 

현재 진행 중인 '경찰과 도둑' 프로젝트에서 회원 프로필 조회 API를 개발하고 있었습니다.

 

내 정보 보"는 서비스에서 가장 빈번하게 호출되는 API 중 하나죠. 기능 구현은 끝났고, 뿌듯한 마음으로 스웨거로 http 요청을 날려봤습니다.

응답은 잘 옵니다. JSON도 예쁘고요. 그런데... 하이버네이트 로그를 본 순간, 등골이 서늘해졌습니다.

 

1. 상황 발생 : DB가 비명을 지르다 (쿼리 폭격)

 

사용자 한 명의 프로필을 조회했습니다. 제가 기대한 건 SELECT * FROM Member WHERE id = ? 같은 깔끔한 쿼리 한 줄이었습니다.

하지만 현실은 시궁창이었습니다.

Hibernate: select ... from member where id=?
Hibernate: select ... from member_profile where member_id=?
Hibernate: select ... from member_stat where member_id=?
Hibernate: select ... from member_stat_police where member_id=?
Hibernate: select ... from member_stat_thief where member_id=?

 

...네, 이게 한 명 조회할 때 나가는 쿼리입니다. 무려 5방입니다.

 

조회 한번 눌렀을 뿐인데, 왜?

 

 

만약 사용자가 1,000명이 동시에 "내 정보 보기"를 눌렀다? DB에는 순식간에 5,000번의 쿼리가 꽂힙니다.

 

이 정도면 서비스 오픈하자마자 DB 서버가 뻗어서 제가 짐 싸서 집에 가야 할 수도 있습니다.

 

범인은 바로 저의 '객체지향적이지 못한' 서비스 코드였습니다.

 

[MemberServiceImpl.java]

public MemberProfileResponse getMemberProfile(Long memberId) {
    // 1. 회원 기본 정보 조회 (쿼리 1)
    Member member = memberRepository.findById(memberId)...
    
    // 2. 프로필 상세 조회 (쿼리 2)
    MemberProfile profile = memberProfileRepository.findById(memberId)...
    
    // 3. 통합 전적 조회 (쿼리 3)
    MemberStat stat = memberStatRepository.findById(memberId)...
    
    // 4. 경찰 전적 조회 (쿼리 4)
    var policeStat = memberStatPoliceRepository.findById(memberId)...
    
    // 5. 도둑 전적 조회 (쿼리 5)
    var thiefStat = memberStatThiefRepository.findById(memberId)...

    return MemberProfileResponse.of(member, profile, stat, policeStat, thiefStat);
}

 

필요한 데이터가 흩어져 있다고 해서, 각각의 Repository를 주입받아 하나하나 정성스럽게 findById를 호출하고 있었습니다. 이건 JPA를 쓰는 게 아니라, 그냥 SQL Mapper를 5번 쓰는 거나 다름없었죠.

 

전형적인 N+1 문제의 변형입니다. (엄밀히 말하면 단건 조회라 1+N 문제라고 봐야겠네요.)

 

2. 처방 : Aggregate Root와 Join Fetch

 

DDD(도메인 주도 설계) 관점에서 보면 Member는 루트 엔티티(Aggregate Root)입니다. 프로필, 전적, 경찰 기록 등은 모두 Member에 종속된 데이터들이죠.

 

즉, "멤버를 부를 때, 연관된 친구들도 한 방에 데려와라!" 라고 명령해야 합니다.

해결 방법은 명확합니다.

 

  1. 연관관계 매핑: Member 엔티티가 친구들을 알게 한다.
  2. 한방 쿼리: JOIN FETCH를 써서 한 번에 가져온다.

 

Step 1) 엔티티 연결하기

기존에는 Member 엔티티에 연관관계 필드가 없었습니다. 그래서 서비스에서 따로 조회했던 거죠. @OneToOne으로 다리를 놓아줍니다.

public class Member extends BaseEntity {
    // ... 기존 필드

    // 이제 Member가 친구들을 참조합니다.
    @OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
    private MemberProfile memberProfile;

    @OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
    private MemberStat memberStat;
    
    // 경찰, 도둑 전적도 동일하게 추가...
}

 

여기서 중요한 건 FetchType.LAZY입니다. 필요 없을 땐 안 가져오겠다는 건데, 이번 케이스는 "필요하니까 한 번에 가져오게" 쿼리를 짤 겁니다.

 

Step 2) MemberRepository에 필살기 추가 (JOIN FETCH)

 

JPA의 JOIN FETCH 구문을 사용하면 연관된 엔티티를 SQL의 JOIN 한 번으로 싹 긁어와서 영속성 컨텍스트에 넣어줍니다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    // 마법의 쿼리: 5번 나갈 걸 1번으로 줄여줍니다.
    @Query("SELECT m FROM Member m " +
           "LEFT JOIN FETCH m.memberProfile " + 
           "LEFT JOIN FETCH m.memberStat " +
           "LEFT JOIN FETCH m.memberStatPolice " +
           "LEFT JOIN FETCH m.memberStatThief " +
           "WHERE m.id = :id")
    Optional<Member> findMemberWithAllStats(@Param("id") Long id);
}

(참고: 회원은 있는데 프로필이 아직 없을 수도 있으니 INNER JOIN 대신 LEFT JOIN을 사용했습니다.)

 

Step 3) 서비스 코드 다이어트

 

이제 서비스 코드가 획기적으로 줄어듭니다. Repository 5개를 주입받던 뚱뚱한 서비스가 날씬해졌습니다.

public MemberProfileResponse getMemberProfile(Long memberId) {
    // 쿼리 1방으로 모든 데이터 로드 완료!
    Member member = memberRepository.findMemberWithAllStats(memberId)
            .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));

    // DTO 변환 (이제 Member 안에 다 들어있으니 Member만 넘기면 됨)
    return MemberProfileResponse.from(member);
}

 

3. 최종 결과 : 5 vs 1

 

수정을 마치고 다시 API를 호출해봤습니다. 로그 창을 뚫어져라 쳐다봤습니다.

 

Before:

SELECT ... FROM member ...
SELECT ... FROM member_profile ...
SELECT ... FROM member_stat ...
SELECT ... FROM member_stat_police ...
SELECT ... FROM member_stat_thief ...

 

 

아오.. 1 + 5 조회 시치!! 네 이놈!!

 

 

After:

SELECT 
    m.id, m.email, ..., 
    p.nickname, ..., 
    s.wins, ... 
FROM member m 
LEFT JOIN member_profile p ON ... 
LEFT JOIN member_stat s ON ...
...
WHERE m.id = ?

 

멀끔 시치

 

 

깔끔하게 딱 1줄!

 

DB 접근 횟수가 1/5로 감소했습니다. 네트워크 왕복 비용(Network RTT)도 5번에서 1번으로 줄었으니, 트래픽이 몰릴수록 성능 격차는 어마어마하게 벌어질 겁니다.

 

4. 결론 : 아는 만큼 빨라진다

 

이번 트러블 슈팅을 통해 다시 한번 느꼈습니다.

  1. 기능이 돌아간다고 끝이 아니다: 로그를 확인하지 않았다면, 서비스가 커졌을 때 DB가 뻗는 시한폭탄을 안고 갈 뻔했습니다.
  2. JPA는 잘 쓰면 약, 못 쓰면 독: find... 메서드를 남발하면 N+1 지옥에 빠집니다. 연관관계를 맺고 Fetch Join을 적절히 활용하는 것이 성능 최적화의 핵심입니다.
  3. Entity 설계의 중요성: Aggregate Root를 중심으로 연관관계를 잘 맺어두면, 비즈니스 로직도 단순해지고 성능도 챙길 수 있습니다.

이제 사용자 100만 명이 들어와도(희망사항이지만), 프로필 조회만큼은 자신 있습니다. 농담입니다. 개운하네요. 이제 자러 갑니다.


Q&A (Self)

Q. 그냥 FetchType.EAGER를 쓰면 안 되나요?

 

A. 안 됩니다. EAGER(즉시 로딩)를 쓰면, 멤버를 조회하는 모든 곳에서 의도치 않게 프로필과 전적을 다 끌고 옵니다. (예: 로그인할 때 필요 없는 전적까지 다 가져옴). 필요한 시점에만 JOIN FETCH로 직접 명시해서 가져오는 것이 정석입니다.

 

 

Q. 서비스 코드에서 Repository를 여러 개 쓰는 게 나쁜가요?

 

A. 나쁜 건 아니지만, "같은 Aggregate(덩어리)" 데이터를 조회할 때는 Root Repository(여기선 MemberRepository)를 통하는 것이 데이터 일관성과 성능 면에서 훨씬 유리합니다.

 

 

adios 입니다 감기 조심하세요들