Skip to content

[score] 백분위 조회 fetch join 누락으로 인한 N+1 (OSIV에 가려짐) #144

Description

@jyx-07

문제

ScorePersistenceAdapter.findAllByUserIdIn (ScorePersistenceAdapter.kt:65-94)이 실행하는 두 쿼리에서 fetch join이 4곳 누락되어 있고, toDomain()이 그 지연 로딩 프록시를 읽습니다.

쿼리 1 — scoreJpaEntity 조회 (:68-78)

fetch join 대상: user, category, evidence

ScoreJpaEntity.toDomain()이 읽는 값 fetch join 결과
user.userId 안전
category.toDomain() 안전 (CategoryJpaEntity에는 연관관계 없음)
evidence?.toDomain() → 내부에서 user.userId (EvidenceJpaEntityExtensions.kt:15) evidence만 ✅, evidence.user 프록시 초기화
project?.projectId (ScoreJpaEntityExtensions.kt:32) 프록시 초기화

쿼리 2 — fileJpaEntity 조회 (:82-89)

fetch join 대상: score

FileJpaEntity.toDomain()이 읽는 값 fetch join 결과
user.userId (FileJpaEntityExtensions.kt:15) 프록시 초기화
score?.scoreId 안전
evidence?.evidenceId (FileJpaEntityExtensions.kt:20) 프록시 초기화

해당 연관관계는 모두 @ManyToOne(fetch = FetchType.LAZY)입니다 — ScoreJpaEntity.kt:76-78(project), EvidenceJpaEntity.kt:35-37(user), FileJpaEntity.kt:32-34(user) / :40-42(evidence).

왜 지금까지 드러나지 않았는가

application.yamlspring.jpa.open-in-view 설정이 없어 Spring Boot 기본값인 true가 적용됩니다. OSIV가 요청 스레드에 EntityManager를 열어둔 채 유지하므로, 위 프록시 초기화가 오류 없이 추가 쿼리로 조용히 해결됩니다.

즉 기능은 정상 동작하지만, 하나의 영속성 컨텍스트 안에서 서로 다른 미초기화 프록시 개수만큼 쿼리가 추가로 발생합니다.

ScorePersistenceAdapter의 기존 KDoc도 이미 같은 위험을 인지하고 있습니다:

Kotlin + JPA는 기본적으로 FIELD Access를 사용하므로, 지연 로딩 프록시의 식별자(user.userId)를 읽는 것만으로도 초기화(추가 쿼리)가 유발될 수 있어 user도 fetch join 대상에 포함합니다.

user에는 이 원칙을 적용했지만 project, evidence.user, file.user, file.evidence에는 누락되었습니다.

영향

  • myPercentInGrade는 학년 전체 학생의 점수를 로드하므로, 서로 다른 project / evidence.user / file.user / file.evidence 개수에 비례해 추가 쿼리가 발생합니다. 학년 규모가 커질수록 선형으로 증가합니다.
  • findAllByUserIdfindAllByUserIdIn에 위임하므로(:63) 개인 점수 조회 경로도 함께 영향받습니다.
  • 추가로, 이 누락은 재계산을 별도 스레드로 옮기는 것을 막는 제약이기도 합니다. OSIV는 요청 스레드에만 EntityManager를 바인딩하므로, 워커 스레드에서 같은 코드가 실행되면 프록시 초기화가 LazyInitializationException으로 실패합니다. [score] 백분위 조회 캐시 스탬피드 방지 장치 부재 #145 참고.

제안하는 개선

누락된 4곳에 fetch join을 추가합니다. 넷 다 to-one 연관이므로 행이 늘지 않습니다(카테시안 곱 없음).

쿼리 1

evidence.user는 중첩 fetch join이므로 QEvidenceJpaEntity 별칭을 잡습니다.

queryFactory
    .selectFrom(scoreJpaEntity)
    .join(scoreJpaEntity.user).fetchJoin()
    .join(scoreJpaEntity.category).fetchJoin()
    .leftJoin(scoreJpaEntity.evidence, evidenceJpaEntity).fetchJoin()
    .leftJoin(evidenceJpaEntity.user).fetchJoin()   // 추가
    .leftJoin(scoreJpaEntity.project).fetchJoin()   // 추가
    .where(scoreJpaEntity.user.userId.`in`(userIds))
    .fetch()

쿼리 2

queryFactory
    .selectFrom(fileJpaEntity)
    .join(fileJpaEntity.score).fetchJoin()
    .join(fileJpaEntity.user).fetchJoin()           // 추가
    .leftJoin(fileJpaEntity.evidence).fetchJoin()   // 추가
    .where(fileJpaEntity.score.scoreId.`in`(entities.map { it.scoreId }))
    .fetch()

작업 목록

  • spring.jpa.show-sql=true로 수정 전 쿼리 수 측정 (myPercentInGrade 기준)
  • 쿼리 1에 project, evidence.user fetch join 추가
  • 쿼리 2에 user, evidence fetch join 추가
  • 수정 후 쿼리 수가 2건으로 줄어드는지 확인
  • ScorePersistenceAdapter KDoc에 이번에 채운 4곳 반영
  • ScorePersistenceAdapterTest 스텁이 새 쿼리 형태와 어긋나지 않는지 확인

참고

  • spring.jpa.open-in-view를 명시적으로 false로 두는 것은 이 이슈의 범위를 넘습니다(다른 경로의 지연 로딩까지 영향). 여기서는 fetch join 누락 자체만 고칩니다.
  • 현재 단위 테스트가 전부 mock 기반이라 이 문제는 실제 구동 + show-sql로만 검증 가능합니다.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions