Skip to content

[score] 백분위 조회 캐시 스탬피드 방지 장치 부재 #145

Description

@jyx-07

문제

반/학년 총점 캐시가 미스되면, 동시에 들어온 요청들이 각자 독립적으로 같은 재계산을 수행합니다. 요청을 합치는 장치가 없습니다.

  • FetchMyPercentInClassService.kt:40-55findClassTotals가 null이면 곧바로 findAllStudentsByUserGradeAndUserClassNumber + findAllByUserIdIn로 DB 재계산 후 saveClassTotals
  • FetchMyPercentInGradeService.kt:39-51 — 학년 단위로 동일한 구조
  • 두 경로 모두 락도, 진행 중인 재계산에 합류하는 코디네이션(싱글플라이트)도 없음

같은 반 학생들이 비슷한 시각에 조회하면(예: 성적 발표 직후) 캐시가 비어 있는 짧은 창 안에 들어온 요청이 전부 미스로 판정되어 동일한 쿼리를 중복 실행합니다.

스탬피드가 나기 쉬운 타이밍

ScoreTotalCacheInvalidator는 반/학년 단위로 캐시를 한 번에 evict합니다(ScoreTotalCacheInvalidator.kt:63-74, :76-95). 점수 승인/거절/제출/삭제 직후 그 반 학생들이 몰려 조회하면 정확히 이 지점에서 겹칩니다.

또한 ScoreTotalCachePersistenceAdapter의 TTL이 5분 고정이고 지터가 없어(ScoreTotalCachePersistenceAdapter.kt:27), 여러 키가 동시에 만료될 확률도 있습니다.

현재 있는 장치와의 차이

ScoreTotalCacheInvalidator의 5초 디바운스는 **"무효화가 너무 자주 일어나는 것"**을 막습니다. 반면 이 이슈는 **"한 번 미스난 뒤 여러 요청이 동시에 DB로 몰리는 것"**으로, 서로 다른 문제이며 현재 대비책이 없습니다.

규모 감안 (중요)

과잉 대응을 피하기 위해 실제 영향 범위를 먼저 적어둡니다.

  • 학급 인원은 30명 안팎, 학년은 수백 명 수준입니다.
  • 따라서 최악의 경우도 **"같은 쿼리가 수십 번 중복 실행"**이며, 장애로 번지는 시나리오는 아닙니다.
  • TTL 5분 + 5초 디바운스가 이미 스탬피드 창을 상당히 좁혀놓았습니다.

결론: 분산 락(Redis SETNX 등)은 이 규모에 과합니다. 락 획득/타임아웃/해제 실패라는 새로운 실패 모드를 들여오는 비용이, 중복 쿼리 몇 건을 아끼는 이득보다 큽니다. 캐시 조회/저장 실패를 runCatching으로 삼켜 가용성을 우선하는 기존 어댑터의 설계 방향과도 어긋납니다.

제안하는 개선

옵션 A — 동기 싱글플라이트 (권장)

ConcurrentHashMap<String, CompletableFuture<Map<Long, Int>>>으로 같은 키의 동시 재계산을 1건으로 합칩니다. 대표 요청이 호출 스레드에서 직접 계산하고, 후발 요청은 그 결과를 기다립니다.

private val inFlight = ConcurrentHashMap<String, CompletableFuture<Map<Long, Int>>>()

fun load(key: String, compute: () -> Map<Long, Int>): Map<Long, Int> {
    val mine = CompletableFuture<Map<Long, Int>>()
    inFlight.putIfAbsent(key, mine)?.let { leader ->
        return try {
            leader.get(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
        } catch (e: TimeoutException) {
            compute()                // 대표가 느릴 뿐 → 직접 계산
        } catch (e: ExecutionException) {
            throw e.cause ?: e       // 대표 실패는 전파 (GsmcException 보존)
        }
    }

    return try {
        compute().also(mine::complete)
    } catch (e: Throwable) {
        mine.completeExceptionally(e)   // 누락 시 후발 요청이 영구 대기
        throw e
    } finally {
        inFlight.remove(key, mine)      // 2-arg: 내 future일 때만 제거
    }
}

두 개의 catch는 의미가 달라(타임아웃 = 재계산, 실패 = 전파) 합칠 수 없고, completeExceptionally와 2-arg remove는 생략하면 버그입니다. 길이의 대부분이 의식이 아니라 정확성입니다.

CompletableFuture.orTimeout()으로 더 짧게 쓰고 싶어질 수 있으나 사용하면 안 됩니다. leader는 후발 요청 전원이 공유하는 future이므로, 여기에 orTimeout을 걸면 타임아웃 시 합류한 모두를 실패시킵니다. get(timeout)은 공유 future의 상태를 바꾸지 않으므로 이쪽이 맞습니다.

장점: 스레드풀 불필요, SecurityContextHolder 경계 없음, OSIV 문제 없음, UseCase 시그니처 유지, 기존 테스트 대부분 유지. 신규 1파일 / 수정 2파일.

대가: 후발 요청이 톰캣 스레드를 잠시 점유(대기 시간 = DB 쿼리 1회). 톰캣 기본 max-threads 200 기준 이 규모에서는 무의미한 비용이며, 가상 스레드를 켜면 이 대가마저 사라집니다(아래 참고).

옵션 B — 비동기 (CompletableFuture 반환)

@QueryMappingCompletableFuture<Percentile>을 반환해 요청 스레드를 아예 붙잡지 않는 방식입니다. Spring for GraphQL이 지원합니다.

추가로 필요한 것:

  • 선행 조건: [score] 백분위 조회 fetch join 누락으로 인한 N+1 (OSIV에 가려짐) #144 (fetch join 누락) 해결. 워커 스레드에는 OSIV가 바인딩한 EntityManager가 없어 지연 로딩 프록시 초기화가 LazyInitializationException으로 실패합니다.
  • 전용 ThreadPoolTaskExecutor 빈 + application.yamlspring.task.execution.mode: force (필수Executor 타입 빈이 있으면 Boot의 applicationTaskExecutor 자동설정이 꺼집니다. TaskExecutorConfigurations.OnExecutorCondition@ConditionalOnMissingBean(Executor.class))
  • MemberUtil(SecurityContextHolder 기반)은 ThreadLocal이므로 비동기 경계 이전에 요청 스레드에서 선추출
  • port/in 시그니처가 CompletableFuture로 바뀌며 service·web adapter까지 전파
  • FetchMyPercentInClassServiceTest(275줄) / FetchMyPercentInGradeServiceTest(259줄) 대폭 재작성

예외 래핑은 문제되지 않는 것으로 확인했습니다. spring-graphql 2.0.4의 ExceptionResolversExceptionHandler.unwrapException()이 리졸버 호출 전에 CompletionException을 한 겹 벗기므로, GsmcExceptionResolver는 수정 없이 GsmcException을 그대로 받습니다. 다만 한 겹만 벗기므로 체인 안에서 future.get() 호출은 금지해야 합니다(2중 래핑 시 도메인 에러가 500으로 뭉개짐).

옵션 C — 보류하고 측정부터

TTL 5분 + 디바운스로 이미 창이 좁으므로, myPercentInGrade가 실제로 느린지 데이터를 먼저 수집합니다. 병행해서 HikariCP maximum-pool-size를 명시하는 것이 좋습니다(현재 미설정 → 기본값 10에 의존 중).

#144(fetch join)를 먼저 적용하면 재계산 자체가 크게 빨라져 스탬피드 창이 더 좁아지므로, 그 이후에 재측정하는 것이 합리적입니다.

가상 스레드를 켜면 A가 명확히 우위

이 프로젝트는 이미 Java 25를 씁니다(build.gradle.kts:18JavaLanguageVersion.of(25)). 그런데 spring.threads.virtual.enabled가 설정되어 있지 않아 톰캣이 여전히 플랫폼 스레드 풀로 동작합니다.

이 설정을 켜면 톰캣이 요청마다 가상 스레드를 사용하고, 옵션 A의 유일한 단점인 "후발 요청의 톰캣 스레드 점유"가 사라집니다. 가상 스레드는 블로킹 시 park되면서 캐리어 스레드를 반납하므로, leader.get(timeout)에서 대기해도 플랫폼 스레드를 붙잡지 않습니다.

그러면 옵션 B가 감수해야 했던 비용이 전부 불필요해집니다.

옵션 B (비동기) 옵션 A + 가상 스레드
톰캣 워커 점유 안 함 안 함 (park)
OSIV / LazyInitializationException #144 선행 필수 해당 없음 (같은 스레드 유지)
전용 스레드풀 + spring.task.execution.mode: force 필요 불필요
SecurityContextHolder 선추출 필요 불필요
port/in 시그니처 CompletableFuture로 변경 그대로
기존 테스트 약 500줄 재작성 거의 유지

Java 24의 JEP 491로 synchronized 블록에서 캐리어 스레드가 pin되던 문제도 해결되어, Java 25에서는 그 함정도 없습니다.

다른 최신 API를 쓰지 않는 이유

  • StructuredTaskScope(구조적 동시성) — Java 25에서 여전히 프리뷰입니다(JEP 505, 5차 프리뷰). --enable-preview가 필요해 프로덕션 코드에는 적합하지 않습니다.
  • ScopedValue — Java 25에서 정식화되었으나(JEP 506), SecurityContextHolder는 Spring이 자체 전략으로 관리하므로 이 이슈에서 교체할 대상이 아닙니다.

주의: 별도 이슈로 다뤄야 함

spring.threads.virtual.enabled: true는 이 기능만이 아니라 애플리케이션 전체의 요청 처리 방식을 바꾸는 설정이므로, 이 이슈에서 함께 켜지 말고 별도로 검토해야 합니다. 확인이 필요한 항목:

  • HikariCP가 진짜 병목이 됩니다. 가상 스레드로 동시 요청이 늘어도 커넥션은 미설정 상태의 기본값 10개입니다. 이는 오히려 싱글플라이트의 가치를 키우는 방향입니다.
  • ThreadLocal 캐싱을 많이 쓰는 라이브러리는 가상 스레드 수만큼 인스턴스가 늘어 메모리가 증가할 수 있습니다.
  • OSIV는 가상 스레드에서도 정상 동작합니다(ThreadLocal 지원).

메모리·GC 관점

inFlight 맵을 프로세스에 계속 들고 있는 구조이므로, 누수 가능성을 미리 정리해 둡니다.

맵이 무한정 커질 수 없는 이유

키 공간 자체가 유한합니다.

반 키:   학년(3) × 반(약 10) × includeApprovedOnly(2)  ≈ 60
학년 키: 학년(3) × includeApprovedOnly(2)               =  6

최대 약 66개가 상한이며, 이는 엔트리가 하나도 정리되지 않는 최악의 경우입니다. 실제로는 재계산 1건이 진행되는 동안에만 존재하는 단명 엔트리이므로 평상시 크기는 0에 가깝습니다. 값도 Map<Long, Int>(반이면 30여 개, 학년이면 수백 개 항목)라 66개가 전부 물려 있어도 수백 KB 수준입니다.

따라서 정리 로직이 완전히 고장 나더라도 힙을 위협하지 않습니다. 정리(finally { remove(key, value) })는 이 상한을 실질적으로 0으로 유지하기 위한 것이지, 누수를 막는 마지막 방어선이 아닙니다.

GC 튜닝이 필요 없는 이유

엔트리는 요청 하나가 재계산을 수행하는 동안만 살아 있다가 제거됩니다. 이런 단명 객체는 Young Gen에서 수거되어 Old Gen으로 승격되지 않으므로, 이 기능 때문에 GC 파라미터를 조정할 이유는 없습니다.

백그라운드 sweeper / 엔트리 TTL을 두지 않는 이유

맵 엔트리에 별도 TTL을 걸고 주기적으로 청소하는 방식도 고려했으나 채택하지 않았습니다.

  • 상한이 66개인 단명 맵을 위해 스레드 또는 스케줄 태스크 추가 + "언제 stale인지" 판단할 타임스탬프 상태 추가 + sweeper와 대표 스레드가 동시에 같은 키를 지우려 할 때의 새로운 경합을 들여오게 됩니다.
  • 얻는 것보다 새로 생기는 실패 모드가 큽니다. 분산 락을 배제한 것과 같은 판단입니다.

진짜로 관리해야 하는 값: 대기 타임아웃

"대표가 끝나지 않으면 후발 요청들이 무한정 쌓인다"는 위험은 맵 크기가 아니라 대기 스레드 수의 문제입니다. 옵션 A에서 이를 바운드하는 것은 WAIT_TIMEOUT_SECONDS 하나뿐이므로, 이 상수가 사실상 가장 중요한 파라미터입니다. (가상 스레드를 켜면 대기 비용 자체가 낮아지지만, 대표가 멈췄을 때 후발 요청이 영원히 매달리는 것은 여전히 막아야 하므로 타임아웃은 유지합니다.)

  • 너무 길면: 대표가 멈췄을 때 톰캣 워커가 계속 점유되어 응답 지연으로 번짐
  • 너무 짧으면: 후발 요청이 곧바로 직접 계산으로 폴백해 합치는 의미가 사라짐

초깃값 3초를 제안합니다. #144 적용 후 학년 단위 재계산은 쿼리 2건이므로 정상적으로는 수십~수백 ms 안에 끝납니다. 3초면 충분한 여유를 두면서도, 대표가 비정상적으로 느릴 때 워커 점유를 3초로 제한합니다. 대표 쪽은 JDBC/HikariCP 타임아웃이 별도로 바운드하므로 두 겹이 됩니다.

공통 주의사항 (구현 시)

  • putIfAbsent를 재계산 시작 전에 호출 — 순서가 뒤집히면 아무것도 합쳐지지 않음
  • computeIfAbsent의 람다 안에서 DB 작업 금지 — 해시 버킷 락을 잡은 채 실행되어 같은 버킷의 무관한 키까지 멈춤
  • 예외 경로에서 반드시 completeExceptionally — 누락 시 이미 합류한 요청이 영구 대기(스레드 누수 + 메모리 누수)
  • 정리는 remove(key, value) 2-arg 오버로드 사용
  • 키에 includeApprovedOnly 포함 필수 — 값에 따라 결과가 달라짐 (ScoreTotalCachePort KDoc 참고)
  • in-process 병합이므로 다중 인스턴스에서는 인스턴스별 1회 재계산 가능. ScoreTotalCacheInvalidator의 디바운스와 동일한 한계이며 5분 TTL이 최종 안전장치

작업 목록

  • 방향 결정 (A / B / C)
  • [score] 백분위 조회 fetch join 누락으로 인한 N+1 (OSIV에 가려짐) #144 선행 적용 후 재계산 소요 시간 재측정
  • 가상 스레드(spring.threads.virtual.enabled) 도입 여부를 별도 이슈로 검토 — 켜면 옵션 A가 명확히 우위
  • 싱글플라이트 협력 객체 추가 (ScoreTotalCacheInvalidator처럼 포트가 아닌 @Component)
  • FetchMyPercentInClassService / FetchMyPercentInGradeService에서 사용
  • 동시 요청이 실제로 1건으로 합쳐지는지 검증하는 테스트 (CountDownLatch로 대표를 붙잡아 합류 창 확보)
  • Redis에서 score:*-total:* 삭제 직후 동시 요청으로 수동 확인

참고

관련: #144 (fetch join 누락 — 옵션 B의 선행 조건이며, 단독으로도 재계산 속도를 개선함)

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