You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
반/학년 총점 캐시가 미스되면, 동시에 들어온 요청들이 각자 독립적으로 같은 재계산을 수행합니다. 요청을 합치는 장치가 없습니다.
FetchMyPercentInClassService.kt:40-55 — findClassTotals가 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건으로 합칩니다. 대표 요청이 호출 스레드에서 직접 계산하고, 후발 요청은 그 결과를 기다립니다.
privateval inFlight =ConcurrentHashMap<String, CompletableFuture<Map<Long, Int>>>()
funload(key:String, compute: () ->Map<Long, Int>): Map<Long, Int> {
val mine =CompletableFuture<Map<Long, Int>>()
inFlight.putIfAbsent(key, mine)?.let { leader ->returntry {
leader.get(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
} catch (e:TimeoutException) {
compute() // 대표가 느릴 뿐 → 직접 계산
} catch (e:ExecutionException) {
throw e.cause ?: e // 대표 실패는 전파 (GsmcException 보존)
}
}
returntry {
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 반환)
@QueryMapping이 CompletableFuture<Percentile>을 반환해 요청 스레드를 아예 붙잡지 않는 방식입니다. Spring for GraphQL이 지원합니다.
전용 ThreadPoolTaskExecutor 빈 + application.yaml에 spring.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:18 — JavaLanguageVersion.of(25)). 그런데 spring.threads.virtual.enabled가 설정되어 있지 않아 톰캣이 여전히 플랫폼 스레드 풀로 동작합니다.
이 설정을 켜면 톰캣이 요청마다 가상 스레드를 사용하고, 옵션 A의 유일한 단점인 "후발 요청의 톰캣 스레드 점유"가 사라집니다. 가상 스레드는 블로킹 시 park되면서 캐리어 스레드를 반납하므로, leader.get(timeout)에서 대기해도 플랫폼 스레드를 붙잡지 않습니다.
최대 약 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이 최종 안전장치
문제
반/학년 총점 캐시가 미스되면, 동시에 들어온 요청들이 각자 독립적으로 같은 재계산을 수행합니다. 요청을 합치는 장치가 없습니다.
FetchMyPercentInClassService.kt:40-55—findClassTotals가 null이면 곧바로findAllStudentsByUserGradeAndUserClassNumber+findAllByUserIdIn로 DB 재계산 후saveClassTotalsFetchMyPercentInGradeService.kt:39-51— 학년 단위로 동일한 구조같은 반 학생들이 비슷한 시각에 조회하면(예: 성적 발표 직후) 캐시가 비어 있는 짧은 창 안에 들어온 요청이 전부 미스로 판정되어 동일한 쿼리를 중복 실행합니다.
스탬피드가 나기 쉬운 타이밍
ScoreTotalCacheInvalidator는 반/학년 단위로 캐시를 한 번에 evict합니다(ScoreTotalCacheInvalidator.kt:63-74,:76-95). 점수 승인/거절/제출/삭제 직후 그 반 학생들이 몰려 조회하면 정확히 이 지점에서 겹칩니다.또한
ScoreTotalCachePersistenceAdapter의 TTL이 5분 고정이고 지터가 없어(ScoreTotalCachePersistenceAdapter.kt:27), 여러 키가 동시에 만료될 확률도 있습니다.현재 있는 장치와의 차이
ScoreTotalCacheInvalidator의 5초 디바운스는 **"무효화가 너무 자주 일어나는 것"**을 막습니다. 반면 이 이슈는 **"한 번 미스난 뒤 여러 요청이 동시에 DB로 몰리는 것"**으로, 서로 다른 문제이며 현재 대비책이 없습니다.규모 감안 (중요)
과잉 대응을 피하기 위해 실제 영향 범위를 먼저 적어둡니다.
결론: 분산 락(Redis SETNX 등)은 이 규모에 과합니다. 락 획득/타임아웃/해제 실패라는 새로운 실패 모드를 들여오는 비용이, 중복 쿼리 몇 건을 아끼는 이득보다 큽니다. 캐시 조회/저장 실패를
runCatching으로 삼켜 가용성을 우선하는 기존 어댑터의 설계 방향과도 어긋납니다.제안하는 개선
옵션 A — 동기 싱글플라이트 (권장)
ConcurrentHashMap<String, CompletableFuture<Map<Long, Int>>>으로 같은 키의 동시 재계산을 1건으로 합칩니다. 대표 요청이 호출 스레드에서 직접 계산하고, 후발 요청은 그 결과를 기다립니다.두 개의 catch는 의미가 달라(타임아웃 = 재계산, 실패 = 전파) 합칠 수 없고,
completeExceptionally와 2-argremove는 생략하면 버그입니다. 길이의 대부분이 의식이 아니라 정확성입니다.장점: 스레드풀 불필요,
SecurityContextHolder경계 없음, OSIV 문제 없음,UseCase시그니처 유지, 기존 테스트 대부분 유지. 신규 1파일 / 수정 2파일.대가: 후발 요청이 톰캣 스레드를 잠시 점유(대기 시간 = DB 쿼리 1회). 톰캣 기본 max-threads 200 기준 이 규모에서는 무의미한 비용이며, 가상 스레드를 켜면 이 대가마저 사라집니다(아래 참고).
옵션 B — 비동기 (
CompletableFuture반환)@QueryMapping이CompletableFuture<Percentile>을 반환해 요청 스레드를 아예 붙잡지 않는 방식입니다. Spring for GraphQL이 지원합니다.추가로 필요한 것:
LazyInitializationException으로 실패합니다.ThreadPoolTaskExecutor빈 +application.yaml에spring.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줄) 대폭 재작성옵션 C — 보류하고 측정부터
TTL 5분 + 디바운스로 이미 창이 좁으므로,
myPercentInGrade가 실제로 느린지 데이터를 먼저 수집합니다. 병행해서 HikariCPmaximum-pool-size를 명시하는 것이 좋습니다(현재 미설정 → 기본값 10에 의존 중).#144(fetch join)를 먼저 적용하면 재계산 자체가 크게 빨라져 스탬피드 창이 더 좁아지므로, 그 이후에 재측정하는 것이 합리적입니다.
가상 스레드를 켜면 A가 명확히 우위
이 프로젝트는 이미 Java 25를 씁니다(
build.gradle.kts:18—JavaLanguageVersion.of(25)). 그런데spring.threads.virtual.enabled가 설정되어 있지 않아 톰캣이 여전히 플랫폼 스레드 풀로 동작합니다.이 설정을 켜면 톰캣이 요청마다 가상 스레드를 사용하고, 옵션 A의 유일한 단점인 "후발 요청의 톰캣 스레드 점유"가 사라집니다. 가상 스레드는 블로킹 시 park되면서 캐리어 스레드를 반납하므로,
leader.get(timeout)에서 대기해도 플랫폼 스레드를 붙잡지 않습니다.그러면 옵션 B가 감수해야 했던 비용이 전부 불필요해집니다.
LazyInitializationExceptionspring.task.execution.mode: forceSecurityContextHolder선추출port/in시그니처CompletableFuture로 변경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는 이 기능만이 아니라 애플리케이션 전체의 요청 처리 방식을 바꾸는 설정이므로, 이 이슈에서 함께 켜지 말고 별도로 검토해야 합니다. 확인이 필요한 항목:메모리·GC 관점
inFlight맵을 프로세스에 계속 들고 있는 구조이므로, 누수 가능성을 미리 정리해 둡니다.맵이 무한정 커질 수 없는 이유
키 공간 자체가 유한합니다.
최대 약 66개가 상한이며, 이는 엔트리가 하나도 정리되지 않는 최악의 경우입니다. 실제로는 재계산 1건이 진행되는 동안에만 존재하는 단명 엔트리이므로 평상시 크기는 0에 가깝습니다. 값도
Map<Long, Int>(반이면 30여 개, 학년이면 수백 개 항목)라 66개가 전부 물려 있어도 수백 KB 수준입니다.따라서 정리 로직이 완전히 고장 나더라도 힙을 위협하지 않습니다. 정리(
finally { remove(key, value) })는 이 상한을 실질적으로 0으로 유지하기 위한 것이지, 누수를 막는 마지막 방어선이 아닙니다.GC 튜닝이 필요 없는 이유
엔트리는 요청 하나가 재계산을 수행하는 동안만 살아 있다가 제거됩니다. 이런 단명 객체는 Young Gen에서 수거되어 Old Gen으로 승격되지 않으므로, 이 기능 때문에 GC 파라미터를 조정할 이유는 없습니다.
백그라운드 sweeper / 엔트리 TTL을 두지 않는 이유
맵 엔트리에 별도 TTL을 걸고 주기적으로 청소하는 방식도 고려했으나 채택하지 않았습니다.
진짜로 관리해야 하는 값: 대기 타임아웃
"대표가 끝나지 않으면 후발 요청들이 무한정 쌓인다"는 위험은 맵 크기가 아니라 대기 스레드 수의 문제입니다. 옵션 A에서 이를 바운드하는 것은
WAIT_TIMEOUT_SECONDS하나뿐이므로, 이 상수가 사실상 가장 중요한 파라미터입니다. (가상 스레드를 켜면 대기 비용 자체가 낮아지지만, 대표가 멈췄을 때 후발 요청이 영원히 매달리는 것은 여전히 막아야 하므로 타임아웃은 유지합니다.)초깃값 3초를 제안합니다. #144 적용 후 학년 단위 재계산은 쿼리 2건이므로 정상적으로는 수십~수백 ms 안에 끝납니다. 3초면 충분한 여유를 두면서도, 대표가 비정상적으로 느릴 때 워커 점유를 3초로 제한합니다. 대표 쪽은 JDBC/HikariCP 타임아웃이 별도로 바운드하므로 두 겹이 됩니다.
공통 주의사항 (구현 시)
putIfAbsent를 재계산 시작 전에 호출 — 순서가 뒤집히면 아무것도 합쳐지지 않음computeIfAbsent의 람다 안에서 DB 작업 금지 — 해시 버킷 락을 잡은 채 실행되어 같은 버킷의 무관한 키까지 멈춤completeExceptionally— 누락 시 이미 합류한 요청이 영구 대기(스레드 누수 + 메모리 누수)remove(key, value)2-arg 오버로드 사용includeApprovedOnly포함 필수 — 값에 따라 결과가 달라짐 (ScoreTotalCachePortKDoc 참고)ScoreTotalCacheInvalidator의 디바운스와 동일한 한계이며 5분 TTL이 최종 안전장치작업 목록
spring.threads.virtual.enabled) 도입 여부를 별도 이슈로 검토 — 켜면 옵션 A가 명확히 우위ScoreTotalCacheInvalidator처럼 포트가 아닌@Component)FetchMyPercentInClassService/FetchMyPercentInGradeService에서 사용CountDownLatch로 대표를 붙잡아 합류 창 확보)score:*-total:*삭제 직후 동시 요청으로 수동 확인참고
관련: #144 (fetch join 누락 — 옵션 B의 선행 조건이며, 단독으로도 재계산 속도를 개선함)