본문 바로가기

⛳️ 공동구매 서비스 총대마켓

♻️ JPA N+1 문제 해결하여 성능 개선하기: LAZY 로딩 전략, fetch join

 

이 글은 문제 해결 과정이 중심이며 개념은 가볍게 설명합니다.

 

JPA의 N+1 문제를 학습하던 중 '과연 우리 서비스는 이 문제가 없을까?' 하는 궁금증에서 시작해, 모든 API의 쿼리 로그를 검증하며 N+1 문제를 찾아냈습니다. 그 중 하나의 예시를 바탕으로 문제를 해결한 과정을 공유합니다.

 

문제: N+1 문제

문제를 명확히 설명하고자 최대한 간략하게 ERD를 작성해보았습니다. 참여 관계 정보를 저장하는 offering_member 테이블과 사용자 정보를 저장하는 member 테이블이 존재하며, 두 테이블은 다대일의 관계를 가집니다.

offering_member와 member가 다대일 연관관계를 맺음

 

N+1 문제가 발생하는 지점은 참여자 목록을 조회할 경우입니다. offering_member 테이블에서 참여 관계 목록을 조회한 후 사용자 정보를 가져오기 위해 member 테이블을 추가 조회합니다. 즉, 참여 관계 목록에서 조회된 사용자 수만큼 member 테이블을 조회합니다. 개발자는 하나의 쿼리가 발생할 것을 예상했지만, JPA에서 실제로 발생하는 쿼리는 N개의 쿼리가 추가 발생하였습니다. 즉, N+1 문제입니다.

 

여기서 만약, 참여자가 999명이라면 어떤 문제가 발생할까요? (서비스 기획상 최대 참여자 수)

1000개의 쿼리가 발생할 것입니다. 이로 인해 불필요하게 데이터베이스에 접근하며, API 처리 속도가 저하될 것입니다. 꼭 필요하지 않은 정보를 조회하기 위해 쿼리가 발생할 수도 있습니다.

 

 

원인: 로딩 전략 EAGER

OfferingMember 엔티티에서 @ManyToOne 관계를 가진 member 필드의 fetchType이 아래와 같이 EAGER로 설정되어 있었습니다. 그렇기 때문에 OfferingMember만 조회하고 싶어도 즉시 로딩 전략으로 인해 Member까지 조회하게 되는 것이죠.

@ManyToOne의 default fetchType은 EAGER입니다.

 

Q. 로딩 전략(fetchType)의 EAGER와 LAZY는 각각 어떻게 동작하나요?

 

로딩전략(fetchType)은 연관관계를 맺은 엔티티를 조회하는 방법입니다. 이는 EAGER와 LAZY로 분류됩니다.

 

EAGER는 즉시 로딩 전략입니다. 부모 테이블이 조회되면 연관된 자식 테이블이 즉시 조회됩니다. 매우 열정적이죠. 위 예시를 통해 설명하자면 offering_member 테이블을 조회할 때 member 테이블에 즉시 접근하여 정보를 가져옵니다. 때문에 불필요한 정보를 한번에 가져오는 상황이 발생할 수 있어 성능 이슈가 존재합니다.

 

LAZY는 지연 로딩 전략입니다. 부모 테이블이 조회된 후 자식 테이블은 정보가 필요한 시점에 조회됩니다. 비교적 게으르죠. 역시 예시를 통해 설명하자면 offering_member 테이블을 조회할 때 member 테이블에 즉시 접근하지 않고, member 테이블이 가진 정보가 필요한 시점에 추가 쿼리가 발생합니다. 지연 로딩을 사용할 때 주의할 점은, 완전히 초기화되지 않은 프록시 객체를 트랜잭션 외부에서 조회할 경우 LazyInitializationException이 발생할 수 있다는 점입니다.

 

 

해결: 로딩 전략 LAZY + fetch join

위 상황은 자식 테이블(member) 정보가 필요한 상황이기 때문에 불필요하게 쿼리가 발생하는 상황은 아닙니다. 하지만 총 1+N개의 커넥션이 발생하는 상황보다는 join을 통해 하나의 커넥션이 발생하는 상황이 성능상 유리할 것으로 판단하여 아래의 두가지 해결책을 적용했습니다.

1️⃣  로딩 전략 변경: EAGER → LAZY

먼저, 무조건적으로 연관 테이블에 접근하는 EAGER 방식은 성능 이슈가 있을 수 있고 예상치 못한 쿼리가 발생할 수 있기 때문에, 예상 가능한 범위에서 프로그램이 동작하도록 로딩 전략을 EAGER에서 LAZY로 변경하였습니다.

 

그럼, 로딩 전략만 LAZY로 변경하면 N+1 문제는 해결될까요? 아닙니다. 결국 자식 테이블의 정보가 필요하면 필요한 만큼의 데이터베이스 접근이 필요해집니다. 즉, N개의 쿼리가 발생하는 시점만 달라질 뿐 N개의 쿼리는 충분히 발생할 수 있습니다. (EAGER는 메인 쿼리 발생 후 즉시, LAZY는 메인 쿼리 발생 후 필요한 시점에)

2️⃣  fetch join 적용

N개의 추가 쿼리를 방지하기 위해서는 한방쿼리가 필요하다고 생각했습니다. 따라서 처음에는 기본 join을 적용해 문제를 해결했습니다.

SELECT om, m
FROM OfferingMemberEntity om
    JOIN MemberEntity m
    ON om.member = m
WHERE om.offering = :offering

 

위 방법으로 문제는 해결했으나 '당장 필요하지 않은 정보를 왜 미리 조회했는지'에 대해 다른 개발자들에게 의도를 전하기 어려웠습니다. 따라서 fetch join을 통해 '지연 로딩으로 인해 추후 필요할 정보를 미리 가져옵니다!' 라는 의도를 담도록 하였습니다.

SELECT om -- 여기서 om만 가져오고
FROM OfferingMemberEntity om
    JOIN FETCH om.member -- 여기서 ON절 필요 없음
WHERE om.offering = :offering

 

위 두 쿼리 모두 실제로는 결국 같은 쿼리를 발생시킵니다.

select
    ome1_0.id,
    ome1_0.created_at,
    ome1_0.member_id,
    m1_0.id,
    m1_0.created_at,
    m1_0.fcm_token,
    m1_0.login_id,
    m1_0.nickname,
    m1_0.password,
    m1_0.provider,
    m1_0.updated_at,
    ome1_0.offering_id,
    ome1_0.role,
    ome1_0.updated_at 
from
    offering_member ome1_0 
join
    member m1_0 
        on m1_0.id=ome1_0.member_id 
where
    ome1_0.offering_id=?

 

기본 join과 fetch join의 차이점은 무엇인가요?

 

1. fetch join은 JPQL단에서 제공하는 명령어 입니다.

fetch join은 기본 join과 같이 SQL단에서 제공하는 명령어가 아닌, JPQL에서 제공하는 명령어입니다. 따라서 실제 쿼리는 기본 join문으로 변형되어 발생합니다.

 

2. 같은 기능, 다른 문법

차이점에 대한 여러 글이 존재하지만, 저는 개인적으로 이 둘은 다른 문법을 가진 동일한 기능을 수행하는 명령어라고 생각합니다. 그 중 SELECT절에 명시해야 하는 정보에 차이가 있고, 기본 join문은 조회를 원하는 모든 테이블을 직접 명시해야하는 반면, fetch join문은 부모 테이블만 명시해도 자식 테이블 정보가 기본으로 조회됩니다.

 

 

성능 비교

참여자 목록을 조회할 때 10,000명의 사용자 정보가 필요한 상황에서 문제 해결 전후의 성능을 비교해보았습니다.

@DisplayName("게시된 공모의 참여자 목록을 확인할 수 있다")
@Test
void should_participantsSuccess() {
    long startTime = System.currentTimeMillis();
    RestAssured.given(spec).log().all()
            .filter(document("participants-success", resource(successSnippets)))
            .cookies(cookieProvider.createCookiesWithMember(proposer))
            .queryParam("offering-id", offering.getId())
            .when().get("/participants")
            .then().log().all()
            .statusCode(200);
    long endTime = System.currentTimeMillis();
    System.out.printf("소요 시간 = %dms\n", endTime - startTime);
}

 

결과: 해결 전 5576ms → 해결 후 5193ms

약 0.3초 차이로, 미세한 성능 개선이 있었습니다. 조회되는 사용자 수를 더 늘린다면 더 큰 성능 차이를 확인할 수 있을 것입니다.

 

 

결론

N+1 문제 해결을 통해 성능 개선이 가능하다.

 

커밋 타입을 refactor로 할지 fix로 할지 고민했던 만큼 이 문제는 치명적인 버그보다는 성능을 저하시키는 문제라고 판단했기 때문에, 로그를 살피며 예방하는 과정이 필요하다고 생각했습니다. JPA에서 야심차게 제공하는 기능인 지연 로딩의 원리를 잘 파악하여 활용하는 것이 중요하겠네요 :)