From 16466a7475b4c90cff233d5e9077d6f984308774 Mon Sep 17 00:00:00 2001 From: Oh YoungJe Date: Tue, 8 Sep 2026 16:27:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[ICC-415]=20=EB=A1=9C=EA=B7=B8=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=ED=95=98=EC=97=AC=20=EC=8B=9D=EB=B3=84=EB=90=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20(#434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ICC-415] ๐Ÿ› fix: ๋ฉ”์„œ๋“œ ๋ถˆ์ผ์น˜ ์š”์ฒญ์ด 500์œผ๋กœ ์‘๋‹ต๋˜๋˜ ๋ฌธ์ œ ์ˆ˜์ • @ControllerAdvice ์˜ @ExceptionHandler ๊ฐ€ Spring ๋‚ด์žฅ DefaultHandlerExceptionResolver ๋ณด๋‹ค ๋จผ์ € ๋Œ๊ธฐ ๋•Œ๋ฌธ์—, HttpRequestMethodNotSupportedException ์ด ์บ์น˜์˜ฌ Exception ํ•ธ๋“ค๋Ÿฌ์— ์žกํ˜€ 405 ๋Œ€์‹  500 + ERROR ๋กœ๊ทธ๋กœ ๋‚˜๊ฐ€๊ณ  ์žˆ์—ˆ๋‹ค. - HttpRequestMethodNotSupportedException ์ „์šฉ ํ•ธ๋“ค๋Ÿฌ ์ถ”๊ฐ€ (405 + WARN) - RFC 9110 ์ด ์š”๊ตฌํ•˜๋Š” Allow ํ—ค๋”๋ฅผ ์ง€์› ๋ฉ”์„œ๋“œ๋กœ ์ฑ„์›€ - ExceptionMessage.METHOD_NOT_ALLOWED ์ถ”๊ฐ€ ์บ์น˜์˜ฌ 500 ERROR ์— ์ง„์งœ ๋ฏธ์ฒ˜๋ฆฌ ์˜ˆ์™ธ๋งŒ ๋‚จ๊ฒŒ ๋˜์–ด, ERROR ๋กœ๊ทธ ๊ธฐ๋ฐ˜ ์•Œ๋ฆผ์˜ ์‹ ๋ขฐ๋„๊ฐ€ ์˜ฌ๋ผ๊ฐ„๋‹ค. * [ICC-415] โšก perf: ๋ณด๋ฅ˜ ๋ฌธํ•ญ ์žฌ์ƒ์„ฑ ๋ณ‘๋ ฌํ™” ๋ฐ ์ƒ์„ฑ ํ๋ฆ„ 4๋‹จ๊ณ„ ๊ตฌ์กฐํ™” ๋ณด๋ฅ˜ ๋ฌธํ•ญ ์žฌ์ƒ์„ฑ์ด ์ง๋ ฌ์ด๋ผ ๊ฑด๋‹น 40~280์ดˆ๊ฐ€ ๊ทธ๋Œ€๋กœ ๋ˆ„์ ๋๋‹ค. ์‹ค์ธก ์„ธ์…˜ ํ•˜๋‚˜์—์„œ 9๊ฑด์ด 11๋ถ„ 27์ดˆ๋ฅผ ๋จน์–ด ์ „์ฒด 16๋ถ„ 11์ดˆ ์ค‘ ๋Œ€๋ถ€๋ถ„์„ ์ฐจ์ง€ํ–ˆ๊ณ , ์ด ๋•Œ๋ฌธ์— ์„ธ์…˜์ด ์Šคํ…Œ์ผ ์ •๋ฆฌ ์ž„๊ณ„๋ฅผ ๋„˜๊ฒผ๋‹ค. - ์›Œ์ปค๊ฐ€ heldQueue ๋ฅผ poll ๋กœ ๋‚˜๋ˆ  ์†Œ๋น„ํ•˜๋„๋ก ๋ณ€๊ฒฝ (ํ•ญ๋ชฉ ๊ฐ„ ์ˆœ์„œ ์˜์กด์ด ์—†๊ณ  conversation ์€ ์ฝ๊ธฐ ์ „์šฉ) - ๋ชฉํ‘œ ๋ฌธํ•ญ ์ˆ˜ ์ดˆ๊ณผ ์ €์žฅ์€ Semaphore ํ‹ฐ์ผ“ ์„ ์ ์œผ๋กœ ์ฐจ๋‹จ, ์‚ฐ์ถœ ์‹คํŒจ ์‹œ ๋ฐ˜๋‚ฉํ•ด ๋‹ค์Œ ํ•ญ๋ชฉ์ด ์“ฐ๊ฒŒ ํ•œ๋‹ค - ๋™์‹œ์„ฑ ์ƒํ•œ์„ 30 ์œผ๋กœ ์ƒํ–ฅ Vertex ๋Š” generateContent ์— ๋™์‹œ์„ฑ ์ฟผํ„ฐ๋ฅผ ๋‘์ง€ ์•Š๊ณ (RPMยทTPM ๋งŒ), quizCount ์ตœ๋Œ€๊ฐ€ 30 ์ด๋ผ ์ž‘์—… ๊ฐœ์ˆ˜๊ฐ€ ๊ณง ์ƒํ•œ์ด๋‹ค - run() ์„ ์บ์‹œ ์ƒ์„ฑ โ†’ ํ€ด์ฆˆ ์ƒ์„ฑ โ†’ ํ‰๊ฐ€ โ†’ ์žฌ์ƒ์„ฑ 4๋‹จ๊ณ„๋กœ ์ •๋ฆฌํ•˜๊ณ  ๊ฐ ๋‹จ๊ณ„์— ๋”ธ๋ฆฐ ๋ฉ”์„œ๋“œ๋ฅผ ๊ตฌ์—ญ์œผ๋กœ ๋ชจ์Œ ๊ฐ™์€ ์กฐ๊ฑด์—์„œ ์žฌ์ƒ์„ฑ ๊ตฌ๊ฐ„์ด 11๋ถ„ 27์ดˆ โ†’ ์•ฝ 3๋ถ„์œผ๋กœ ์ค„์–ด๋“ ๋‹ค. * [ICC-415] ๐Ÿ› fix: ์ •๋ฆฌ ์Šค์ผ€์ค„๋Ÿฌ๊ฐ€ ์ง„ํ–‰ ์ค‘์ธ ์ƒ์„ฑ ์„ธํŠธ๋ฅผ ์‚ญ์ œํ•˜๋˜ ๋ฌธ์ œ ์ˆ˜์ • STALE_THRESHOLD_MINUTES ๊ฐ€ 10๋ถ„์ด๋ผ ๊ทธ๋ณด๋‹ค ์˜ค๋ž˜ ๊ฑธ๋ฆฐ ์ƒ์„ฑ์ด "๋ฐฉ์น˜๋œ GENERATING"์œผ๋กœ ํŒ์ •๋ผ ์‚ญ์ œ๋๋‹ค. ์Šค์ผ€์ค„๋Ÿฌ๋Š” DB ํ–‰๋งŒ ์ง€์šฐ๊ณ  ์ƒ์„ฑ ์Šค๋ ˆ๋“œ์—๋Š” ์•„๋ฌด ์‹ ํ˜ธ๋„ ๋ณด๋‚ด์ง€ ์•Š์œผ๋ฏ€๋กœ, ์„ธ์…˜์€ ์ด๋ฏธ ์‚ฌ๋ผ์ง„ ์„ธํŠธ์— ์ €์žฅ์„ ์‹œ๋„ํ•˜๋‹ค finalizePartialSuccess ์—์„œ ํ„ฐ์กŒ๋‹ค. 7์ผ ๊ด€์ธก: ์„ธ์…˜ 40๊ฑด ์ค‘ 10๋ถ„ ์ดˆ๊ณผ 12๊ฑด, ๊ทธ์ค‘ 11๊ฑด์ด ๊ฒฐ๊ณผ๋ฌผ ์ „์†. - ์ž„๊ณ„๋ฅผ 15๋ถ„์œผ๋กœ ์ƒํ–ฅ (์„ธ์…˜ ์‹œํ•œ 10๋ถ„ + ์—ฌ์œ ) - ๋ถ€ํ•˜ ํ•˜๋„ค์Šค๊ฐ€ ๊ฐ™์€ ์ฟผ๋ฆฌ๋ฅผ ํƒœ์šฐ๋ฏ€๋กœ Mock ๊ตฌํ˜„๋„ ๋™๊ธฐํ™” - ๋กœ๊ทธยทjavadocยทํ…Œ์ŠคํŠธ ํ‘œ๊ธฐ์˜ "10๋ถ„"์„ ํ•จ๊ป˜ ์ •์ • ์„ธ์…˜ ์‹œ๊ณ„๋Š” ์„ธํŠธ createdAt ๋ณด๋‹ค ๋Šฆ๊ฒŒ ์ถœ๋ฐœํ•˜๊ณ  ์Šค์ผ€์ค„๋Ÿฌ ํŒ์ •๋„ ์ฃผ๊ธฐ(1๋ถ„)๋งŒํผ ๋ฐ€๋ฆฌ๋ฏ€๋กœ, ๋‘ ๊ฐ’์„ ๊ฐ™๊ฒŒ ๋‘๋ฉด ๊ฒฝ๊ณ„์—์„œ ๋‹ค์‹œ ์žฌํ˜„๋œ๋‹ค. ๊ทธ ๊ทผ๊ฑฐ๋ฅผ ์ƒ์ˆ˜ ์ฃผ์„์— ๋‚จ๊ฒผ๋‹ค. * [ICC-415] ๐Ÿ”ง chore: ์šด์˜์—์„œ ๋ฌดํšจ์ธ chat-timeout-ms ์„ค์ • ์ œ๊ฑฐ ์„ธ์…˜๊ธ‰ ์‹œํ•œ์„ ์˜๋„ํ•œ ๊ฐ’์ด์—ˆ์ง€๋งŒ, GeminiClientConfig ๊ฐ€ customHttpClient ๋ฅผ ์“ฐ๋ฉด SDK ๊ฐ€ HttpOptions.timeout ์„ ๊ฑด๋„ˆ๋›ฐ๋ฏ€๋กœ ์šด์˜ ๊ฒฝ๋กœ์—์„œ๋Š” ์•„๋ฌด ํšจ๋ ฅ์ด ์—†์—ˆ๋‹ค. ๊ฐ’์„ ์กฐ์ •ํ•ด๋„ ๋™์ž‘์ด ๋ณ€ํ•˜์ง€ ์•Š์•„ ์„ค์ •๊ณผ ์‹ค์ œ๊ฐ€ ์–ด๊ธ‹๋‚˜ ์žˆ์—ˆ๋‹ค. - q-asker.ai.chat-timeout-ms ๋ฐ QAskerAiProperties.chatTimeoutMs ์‚ญ์ œ - test/mock ํด๋ผ์ด์–ธํŠธ์˜ httpOptions ๋„ ์ œ๊ฑฐ (์‹ค์ œ ํ˜ธ์ถœ์„ ํ•˜์ง€ ์•Š๋Š” ์Šคํ…์ด๋ผ ์‹œํ•œ์„ ๊ฑธ ๋Œ€์ƒ์ด ์—†๋‹ค) ์‹คํšจ ์‹œํ•œ์€ OkHttpClient ์— ์ง์ ‘ ๊ฑด connect/read/call ์„ธ ๊ณ„์ธต๋งŒ ๋‚จ๋Š”๋‹ค. ๋‹ค์‹œ HttpOptions.timeout ์„ ๋ถ™์ด๋ ค๋Š” ์‹œ๋„๋ฅผ ๋ง‰๋„๋ก ๊ฒฝ๊ณ  ์ฃผ์„์€ ์œ ์ง€. * [ICC-415] ๐Ÿ’ก docs: ์ปค๋„ฅ์…˜ ํ’€ยทํ†ฐ์บฃ ๊ธฐ๋ณธ๊ฐ’ ๋ช…์‹œ ๋ฐ prod keepalive ๋‹จ์ถ• ๊ฐ’์„ ์กฐ์ •ํ•  ๋•Œ ์ถœ๋ฐœ์ ์ด ๋ฌด์—‡์ด์—ˆ๋Š”์ง€ ๋ณด์ด๋„๋ก, ์ง€๊ธˆ๊นŒ์ง€ ๋ฏธ์„ค์ •์œผ๋กœ ๊ธฐ๋ณธ๊ฐ’์— ๋งก๊ธฐ๋˜ ํ•ญ๋ชฉ์„ ๊ทธ๋Œ€๋กœ ์ ์–ด๋’€๋‹ค. ๋™์ž‘์€ ๋Œ€๋ถ€๋ถ„ ๋™์ผํ•˜๋‹ค. - database-config.yml: HikariCP 7.0.2 ๊ธฐ๋ณธ๊ฐ’ ๋ช…์‹œ ๊ฐ€์ƒ ์Šค๋ ˆ๋“œ๋ผ ์š”์ฒญ ๋™์‹œ์„ฑ์—” ์ƒํ•œ์ด ์—†์–ด ์‹ค์งˆ ์ฒ˜๋ฆฌ๋Ÿ‰์€ maximum-pool-size(10)๊ฐ€ ๊ฒฐ์ •ํ•œ๋‹ค๋Š” ์ , minimum-idle ์„ ์ˆซ์ž๋กœ ๋ฐ•์€ ์ด์ƒ ํ’€์„ ํ‚ค์šธ ๋• ๋‘˜์„ ํ•จ๊ป˜ ์˜ฌ๋ ค์•ผ ํ•œ๋‹ค๋Š” ์ ์„ ๋‚จ๊น€ - app-common.yml: Tomcat 11.0.22 ๊ธฐ๋ณธ๊ฐ’ ๋ช…์‹œ ๊ฐ€์ƒ ์Šค๋ ˆ๋“œ๊ฐ€ ์ผœ์ ธ ์žˆ์–ด threads.max/min-spare ๋Š” ์ ์šฉ๋˜์ง€ ์•Š๋Š”๋‹ค - prod ๋งŒ keepalive-time 60000 ์œผ๋กœ ์˜ค๋ฒ„๋ผ์ด๋“œ (๊ธฐ๋ณธ 120000) ๋ฌดํŠธ๋ž˜ํ”ฝ ์‹œ๊ฐ„๋Œ€์— DB ๊ฐ€ ๋‚ด๋ ค๊ฐ”์„ ๋•Œ์˜ ํƒ์ง€ ์ง€์—ฐ์„ ์ ˆ๋ฐ˜์œผ๋กœ ์ขํžŒ๋‹ค - CLAUDE.md ์„ค์ • ํŒŒ์ผ ์„ค๋ช…๊ณผ JUnit ๋ฒ„์ „ ๊ฐฑ์‹  - Alloy ์ธ์Šคํ„ด์Šค ์ŠคํŽ™ ํ‘œ๊ธฐ ์ •์ • (8GB โ†’ 10GB) --- CLAUDE.md | 8 +- app/src/main/resources/config/ai-setting.yml | 1 - app/src/main/resources/config/app-common.yml | 31 ++ .../main/resources/config/database-config.yml | 43 +++ infra/monitoring/docker-compose.yml | 2 +- .../qasker/global/error/ExceptionMessage.java | 1 + .../global/error/GlobalExceptionHandler.java | 21 ++ .../error/GlobalExceptionHandlerTest.java | 14 + .../qasker/ai/config/GeminiClientConfig.java | 6 +- .../ai/properties/QAskerAiProperties.java | 3 - .../AbstractChunkedQuizOrchestrator.java | 314 +++++++++--------- .../MockStaleGenerationRecoveryService.java | 2 +- .../scheduler/StaleGenerationRecovery.java | 2 +- .../StaleGenerationRecoveryScheduler.java | 2 +- .../StaleGenerationRecoveryService.java | 9 +- .../StaleGenerationRecoverySchedulerTest.java | 2 +- 16 files changed, 290 insertions(+), 171 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8d5dcbd3..22f1bc05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ | ์ปจํ…Œ์ด๋„ˆ | Jib (Docker) | 3.5.3 | | ํฌ๋งทํ„ฐ | Spotless + Google Java Format | 8.8.0 / 1.25.2 | | DB ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ | Flyway | (Boot BOM) | -| ํ…Œ์ŠคํŠธ | JUnit 5 | (Boot BOM) | +| ํ…Œ์ŠคํŠธ | JUnit 5 | 5.14.4 | ## ๋ช…๋ น์–ด (Scripts) @@ -72,13 +72,13 @@ q-asker/api/ โ”‚ โ”œโ”€โ”€ application-test.yml # test ํ”„๋กœํŒŒ์ผ (CI/JUnit, H2 + ๋”๋ฏธ Jasypt/OCI) โ”‚ โ”œโ”€โ”€ db/migration/ # Flyway ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ SQL (V1~V20) โ”‚ โ””โ”€โ”€ config/ # ๋ถ„๋ฆฌ๋œ ์„ค์ • ํŒŒ์ผ๋“ค -โ”‚ โ”œโ”€โ”€ database-config.yml # ์„œ๋ฒ„, DB, JPA, ์บ์‹œ -โ”‚ โ”œโ”€โ”€ ai-setting.yml # Google Gemini AI ์„ค์ • (์ƒ์„ฑ/ESSAY ์ฑ„์ /ํ’ˆ์งˆ ๊ฒ€์ฆ ๋ชจ๋ธ, ํ† ํฐ ๋‹จ๊ฐ€) +โ”‚ โ”œโ”€โ”€ database-config.yml # DB(HikariCP), JPA, ์บ์‹œ, Flyway (hikari ๋ธ”๋ก์€ ๋Œ€๋ถ€๋ถ„ ๊ธฐ๋ณธ๊ฐ’์„ ๋ช…์‹œ๋งŒ ํ•œ ๊ฒƒ โ€” ๊ฐ€์ƒ ์Šค๋ ˆ๋“œ๋ผ ์š”์ฒญ ๋™์‹œ์„ฑ์—” ์ƒํ•œ์ด ์—†์–ด ์‹ค์งˆ ์ฒ˜๋ฆฌ๋Ÿ‰์€ maximum-pool-size(10)๊ฐ€ ๊ฒฐ์ •ํ•˜๊ณ , minimum-idle ์ด ์ˆซ์ž๋กœ ๋ฐ•ํ˜€ ์žˆ์–ด ํ’€์„ ํ‚ค์šธ ๋• ๋‘˜์„ ํ•จ๊ป˜ ์˜ฌ๋ ค์•ผ ๊ณ ์ • ํฌ๊ธฐ ์„ฑ์งˆ์ด ์œ ์ง€๋œ๋‹ค. prod ๋งŒ keepalive-time 60s ๋กœ ์˜ค๋ฒ„๋ผ์ด๋“œ) +โ”‚ โ”œโ”€โ”€ ai-setting.yml # Google Gemini AI ์„ค์ • (์ƒ์„ฑ/ESSAY ์ฑ„์ /ํ’ˆ์งˆ ๊ฒ€์ฆ ๋ชจ๋ธ, ํ† ํฐ ๋‹จ๊ฐ€ โ€” ๋ชจ๋ธ์„ ๋ฐ”๊พธ๋ฉด ๊ฐ™์€ ๊ณ„์ธต์˜ price-*-per-1m ๋„ ๊ทธ ๋ชจ๋ธ ์‹ค๋‹จ๊ฐ€๋กœ ํ•จ๊ป˜ ๊ณ ์ณ์•ผ GeminiMetricsRecorder ๋น„์šฉ ์นด์šดํ„ฐ๊ฐ€ ๋งž๋Š”๋‹ค) โ”‚ โ”œโ”€โ”€ spring-security.yml # JWT, OAuth2, CORS โ”‚ โ”œโ”€โ”€ oci-bucket-config.yml # OCI Object Storage, CDN โ”‚ โ”œโ”€โ”€ jodconverter.yml # LibreOffice ๋ฌธ์„œ๋ณ€ํ™˜ โ”‚ โ”œโ”€โ”€ actuator.yml # Actuator, Prometheus -โ”‚ โ”œโ”€โ”€ app-common.yml # ์•ฑ ์ปค์Šคํ…€ ์„ค์ • +โ”‚ โ”œโ”€โ”€ app-common.yml # ์•ฑ ์ปค์Šคํ…€ ์„ค์ • + ์„œ๋ฒ„(graceful shutdown, server.tomcat)ยท๋ฉ€ํ‹ฐํŒŒํŠธยท๊ฐ€์ƒ ์Šค๋ ˆ๋“œ ํ™œ์„ฑํ™” (tomcat ๋ธ”๋ก์€ ์ „๋ถ€ ๊ธฐ๋ณธ๊ฐ’์„ ๋ช…์‹œ๋งŒ ํ•œ ๊ฒƒ โ€” ๊ฐ€์ƒ ์Šค๋ ˆ๋“œ๊ฐ€ ์ผœ์ ธ ์žˆ์–ด threads.max/min-spare ๋Š” ์ ์šฉ๋˜์ง€ ์•Š๋Š”๋‹ค) โ”‚ โ”œโ”€โ”€ github.yml # ํ”ผ๋“œ๋ฐฑ โ†’ GitHub ์ด์Šˆ ์ž๋™ ๋“ฑ๋ก (owner/repo/ํ† ํฐ/๋ผ๋ฒจ) โ”‚ โ”œโ”€โ”€ resilience.yml # Circuit Breaker โ”‚ โ”œโ”€โ”€ spring-doc.yml # Swagger/OpenAPI diff --git a/app/src/main/resources/config/ai-setting.yml b/app/src/main/resources/config/ai-setting.yml index 29be0245..a61b65f4 100644 --- a/app/src/main/resources/config/ai-setting.yml +++ b/app/src/main/resources/config/ai-setting.yml @@ -13,7 +13,6 @@ spring: q-asker: ai: - chat-timeout-ms: 500000 cache-ttl: 15m # ์ปจํ…์ŠคํŠธ ์บ์‹œ TTL (์ธํ„ฐ๋ฆฌ๋น™ ์„ธ์…˜ ์ปค๋ฒ„) # ์ŠคํŠธ๋ฆฌ๋ฐ ์‹œํ•œ ๊ณ„์ธต (connect < read < call). ์•ˆ์ชฝ ์‹œํ•œ์ด ๋ฐ”๊นฅ๋ณด๋‹ค ์งง์•„์•ผ # ๊ฐ ๊ณ„์ธต์ด ์ž๊ธฐ ์‹คํŒจ๋ฅผ ์ž๊ธฐ ์ด๋ฆ„์˜ ์˜ˆ์™ธ๋กœ ๋จผ์ € ์žก๋Š”๋‹ค โ€” ์ˆœ์„œ๊ฐ€ ๋ฌด๋„ˆ์ง€๋ฉด ๋ฐ”๊นฅ์ธต์ด diff --git a/app/src/main/resources/config/app-common.yml b/app/src/main/resources/config/app-common.yml index b29207da..e24761d1 100644 --- a/app/src/main/resources/config/app-common.yml +++ b/app/src/main/resources/config/app-common.yml @@ -24,6 +24,37 @@ q-asker: server: shutdown: graceful + # ์•„๋ž˜ tomcat ๋ธ”๋ก์€ ์ „๋ถ€ Spring Boot 4.1.0 / Tomcat 11.0.22 ๊ธฐ๋ณธ๊ฐ’์„ ๊ทธ๋Œ€๋กœ ์ ์–ด๋‘” ๊ฒƒ์ด๋‹ค. + # ๋™์ž‘์€ ์ง€๊ธˆ๊ณผ ๋™์ผํ•˜๋ฉฐ, ๊ฐ’์„ ์กฐ์ •ํ•  ๋•Œ ์ถœ๋ฐœ์ ์ด ๋ฌด์—‡์ด์—ˆ๋Š”์ง€ ๋ณด์ด๊ฒŒ ํ•˜๋Š” ๊ฒƒ์ด ๋ชฉ์ . + tomcat: + # ๋™์‹œ์— ์ˆ˜์šฉํ•˜๋Š” ์†Œ์ผ“ ์ปค๋„ฅ์…˜ ์ƒํ•œ. ์ดˆ๊ณผ๋ถ„์€ accept-count ๋ฐฑ๋กœ๊ทธ์— ์Œ“์ด๊ณ , + # ๊ทธ๋งˆ์ € ์ฐจ๋ฉด OS๊ฐ€ ์—ฐ๊ฒฐ์„ ๊ฑฐ๋ถ€ํ•œ๋‹ค. + max-connections: 8192 + accept-count: 100 + + threads: + # spring.threads.virtual.enabled: true ์ด๋ฉด Boot๊ฐ€ ์ปค๋„ฅํ„ฐ executor๋ฅผ ๊ฐ€์ƒ ์Šค๋ ˆ๋“œ๋กœ ๊ต์ฒดํ•˜๋ฏ€๋กœ + # ์ด ๋‘ ๊ฐ’์€ ์ ์šฉ๋˜์ง€ ์•Š๋Š”๋‹ค. ๊ฐ€์ƒ ์Šค๋ ˆ๋“œ๋ฅผ ๋„๋Š” ์ˆœ๊ฐ„ ๋˜์‚ด์•„๋‚œ๋‹ค. + max: 200 + min-spare: 10 + + # Boot๋Š” ์ด ๋‘˜์„ ์„ค์ •ํ•˜์ง€ ์•Š๊ณ  Tomcat ๊ธฐ๋ณธ๊ฐ’์— ๋งก๊ธด๋‹ค(๋‘˜ ๋‹ค 60s). + # keep-alive-timeout์€ ๋ฏธ์ง€์ • ์‹œ connection-timeout์„ ๋”ฐ๋ผ๊ฐ€๋ฏ€๋กœ ํ•จ๊ป˜ ์›€์ง์ธ๋‹ค. + connection-timeout: 60s + keep-alive-timeout: 60s + max-keep-alive-requests: 100 + + max-http-form-post-size: 2MB + max-http-response-header-size: 8KB + max-swallow-size: 2MB + max-parameter-count: 1000 + max-part-count: 50 + max-part-header-size: 512B + + processor-cache: 200 + background-processor-delay: 10s + uri-encoding: UTF-8 + spring: lifecycle: timeout-per-shutdown-phase: 300s diff --git a/app/src/main/resources/config/database-config.yml b/app/src/main/resources/config/database-config.yml index 049c8646..49ae6acc 100644 --- a/app/src/main/resources/config/database-config.yml +++ b/app/src/main/resources/config/database-config.yml @@ -1,5 +1,40 @@ # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค, JPA, ์บ์‹œ ์„ค์ • spring: + # ์•„๋ž˜ hikari ๋ธ”๋ก์€ ์ „๋ถ€ HikariCP 7.0.2 ๊ธฐ๋ณธ๊ฐ’์„ ๊ทธ๋Œ€๋กœ ์ ์–ด๋‘” ๊ฒƒ์ด๋‹ค. + # ๋™์ž‘์€ ์ง€๊ธˆ๊ณผ ๋™์ผํ•˜๋ฉฐ, ๊ฐ’์„ ์กฐ์ •ํ•  ๋•Œ ์ถœ๋ฐœ์ ์ด ๋ฌด์—‡์ด์—ˆ๋Š”์ง€ ๋ณด์ด๊ฒŒ ํ•˜๋Š” ๊ฒƒ์ด ๋ชฉ์ . + # (์—ฌ๊ธฐ์— ์—†๋Š” connection-test-queryยทconnection-init-sqlยทtransaction-isolationยทpool-name์˜ + # ๊ธฐ๋ณธ๊ฐ’์€ ๋ชจ๋‘ ๋ฏธ์„ค์ •์ด๋ผ ๋ช…์‹œํ•  ๊ฐ’์ด ์—†๋‹ค โ€” JDBC4 isValid ๊ฒ€์ฆ, ๋“œ๋ผ์ด๋ฒ„ ๊ธฐ๋ณธ ๊ฒฉ๋ฆฌ์ˆ˜์ค€์„ ์“ด๋‹ค.) + datasource: + hikari: + # ์ปค๋„ฅ์…˜ ์ƒํ•œ. spring.threads.virtual.enabled: true ๋ผ ์š”์ฒญ ๋™์‹œ์„ฑ์—” ์ƒํ•œ์ด ์—†์œผ๋ฏ€๋กœ, + # ๋ถ€ํ•˜ ์‹œ ์‹ค์งˆ ์ฒ˜๋ฆฌ๋Ÿ‰์€ ์ด ๊ฐ’์ด ๊ฒฐ์ •ํ•œ๋‹ค. + maximum-pool-size: 10 + # ๊ธฐ๋ณธ๊ฐ’์€ "maximum-pool-size์™€ ๋™์ผ"(๊ณ ์ • ํฌ๊ธฐ ํ’€)์ด๋‹ค. ์—ฌ๊ธฐ์„œ ์ˆซ์ž๋กœ ๋ฐ•์•„๋’€์œผ๋ฏ€๋กœ + # maximum-pool-size๋ฅผ ์˜ฌ๋ฆด ๋•Œ ์ด ๊ฐ’๋„ ํ•จ๊ป˜ ์˜ฌ๋ ค์•ผ ๊ฐ™์€ ์„ฑ์งˆ์ด ์œ ์ง€๋œ๋‹ค. + minimum-idle: 10 + + # ์ปค๋„ฅ์…˜ ํš๋“ ๋Œ€๊ธฐ ํ•œ๋„. ํ’€์ด ๋งˆ๋ฅด๋ฉด ์ด ์‹œ๊ฐ„ ๋’ค SQLTransientConnectionException. + connection-timeout: 30000 + # ์ปค๋„ฅ์…˜ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์ž์ฒด์˜ ์‹œํ•œ. + validation-timeout: 5000 + # ์œ ํœด ์ปค๋„ฅ์…˜ ํšŒ์ˆ˜ ํ•œ๋„. minimum-idle == maximum-pool-size ์ธ ๋™์•ˆ์€ ํšŒ์ˆ˜ํ•  ์œ ํœด๋ถ„์ด + # ์—†์œผ๋ฏ€๋กœ ๋™์ž‘ํ•˜์ง€ ์•Š๋Š”๋‹ค. + idle-timeout: 600000 + # ์ปค๋„ฅ์…˜ ์ตœ๋Œ€ ์ˆ˜๋ช…. DB์˜ wait_timeout๋ณด๋‹ค ์งง๊ฒŒ ๋‘์–ด ์„œ๋ฒ„๊ฐ€ ๋จผ์ € ๋Š๋Š” ์ƒํ™ฉ์„ ํ”ผํ•œ๋‹ค. + max-lifetime: 1800000 + # ์œ ํœด ์ปค๋„ฅ์…˜ ํ•‘ ์ฃผ๊ธฐ. ์ž์„ธํ•œ ๋‚ด์šฉ์€ ์•„๋ž˜ prod ์˜ค๋ฒ„๋ผ์ด๋“œ ์ฐธ๊ณ . + keepalive-time: 120000 + + # ๋Œ€์—ฌ ํ›„ ๋ฐ˜๋‚ฉ๋˜์ง€ ์•Š๋Š” ์ปค๋„ฅ์…˜ ๊ฒฝ๊ณ  ์ž„๊ณ„. 0์ด๋ฉด ๋ˆ„์ˆ˜ ํƒ์ง€๋ฅผ ํ•˜์ง€ ์•Š๋Š”๋‹ค. + leak-detection-threshold: 0 + # ๊ธฐ๋™ ์‹œ ์ฒซ ์ปค๋„ฅ์…˜ ํ™•๋ณด ์‹คํŒจ๋ฅผ ์–ด๋–ป๊ฒŒ ๋‹ค๋ฃฐ์ง€. ์–‘์ˆ˜๋ฉด ๊ทธ ์‹œ๊ฐ„๋งŒํผ ์žฌ์‹œ๋„ํ•œ ๋’ค ๊ธฐ๋™ ์‹คํŒจ. + initialization-fail-timeout: 1 + + auto-commit: true + read-only: false + register-mbeans: false + allow-pool-suspension: false + isolate-internal-queries: false jpa: open-in-view: false properties: @@ -42,6 +77,14 @@ spring: type: simple datasource: driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + # ์œ ํœด ์ปค๋„ฅ์…˜์„ ์ฃผ๊ธฐ์ ์œผ๋กœ ํ•‘(COM_PING)ํ•ด ์ฃฝ์—ˆ์œผ๋ฉด ์ฆ‰์‹œ ํ๊ธฐยท์žฌ์ƒ์„ฑํ•œ๋‹ค. + # ๊ธฐ๋ณธ๊ฐ’ 120000(2๋ถ„)์„ ์ ˆ๋ฐ˜์œผ๋กœ ์ค„์—ฌ, ๋ฌดํŠธ๋ž˜ํ”ฝ ์‹œ๊ฐ„๋Œ€์— DB๊ฐ€ ๋‚ด๋ ค๊ฐ”์„ ๋•Œ์˜ + # ์žฅ์•  ํƒ์ง€ ์ง€์—ฐ์„ ๊ทธ๋งŒํผ ์ขํžŒ๋‹ค. 0์œผ๋กœ ๋„๋ฉด ํ’€์ด ์œ ํœด ์ปค๋„ฅ์…˜์˜ ์ƒ์‚ฌ๋ฅผ ๊ฐ์‹œํ•˜์ง€ ์•Š์•„ + # max-lifetime(30๋ถ„) ๋กœํ…Œ์ด์…˜์ด ๊ทธ ์ปค๋„ฅ์…˜์„ ๊ฑด๋“œ๋ฆด ๋•Œ๊นŒ์ง€ "์ •์ƒ ๋ณด์œ "๋กœ ๋‚จ๋Š”๋‹ค. + # ๋‹จ์œ„๋Š” ๋ฐ€๋ฆฌ์ดˆ(HikariConfig.setKeepaliveTime(long)). ์ตœ์†Œ ํ—ˆ์šฉ๊ฐ’ 30000, + # max-lifetime๋ณด๋‹ค ์ž‘์•„์•ผ ํ•˜๋ฉฐ ๊ฒ€์‚ฌ ์‹œํ•œ์€ validation-timeout(5s)์„ ๋”ฐ๋ฅธ๋‹ค. + keepalive-time: 60000 jpa: hibernate: ddl-auto: validate diff --git a/infra/monitoring/docker-compose.yml b/infra/monitoring/docker-compose.yml index f06f2a71..ee09ccee 100644 --- a/infra/monitoring/docker-compose.yml +++ b/infra/monitoring/docker-compose.yml @@ -1,6 +1,6 @@ # ============================================================= # #2 Spring Boot ์ธ์Šคํ„ด์Šค - Alloy ์—์ด์ „ํŠธ -# OCI ARM 2 OCPU / 8GB | Spring Boot + JODConverter ๋กœ๊ทธ ์ˆ˜์ง‘ +# OCI ARM 2 OCPU / 10GB | Spring Boot + JODConverter ๋กœ๊ทธ ์ˆ˜์ง‘ # ๊ธฐ์กด Spring Boot docker-compose์— ๋ณ‘ํ•ฉ ๋˜๋Š” ๋ณ„๋„ ์‹คํ–‰ # ============================================================= diff --git a/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java b/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java index 5d1a0a59..dcbe08ae 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java +++ b/modules/global/src/main/java/com/icc/qasker/global/error/ExceptionMessage.java @@ -10,6 +10,7 @@ public enum ExceptionMessage { // ## ๊ณตํ†ต (global) DEFAULT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), INVALID_REQUEST(HttpStatus.BAD_REQUEST, "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ์š”์ฒญ ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค."), FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "ํŒŒ์ผ ํฌ๊ธฐ๊ฐ€ ์ œํ•œ์„ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."), // ## ํŒŒ์ผ ์—…๋กœ๋“œ/๋ณ€ํ™˜ (oci, quiz-make, util) diff --git a/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java b/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java index 66b6483f..6c3b081d 100644 --- a/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java +++ b/modules/global/src/main/java/com/icc/qasker/global/error/GlobalExceptionHandler.java @@ -2,11 +2,14 @@ import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import java.util.Objects; +import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.apache.catalina.connector.ClientAbortException; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -77,6 +80,24 @@ public ResponseEntity handleMethodArgumentNotValid( return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new CustomErrorResponse(message)); } + /** + * ๊ฒฝ๋กœ ๋งคํ•‘์€ ์žˆ์œผ๋‚˜ HTTP ๋ฉ”์„œ๋“œ๊ฐ€ ๋‹ค๋ฅธ ์š”์ฒญ(์˜ˆ: POST ์ „์šฉ /generation ์— GET). ํด๋ผ์ด์–ธํŠธ ์˜ค๋ฅ˜์ด๋ฏ€๋กœ 405๋กœ ์‘๋‹ตํ•œ๋‹ค(๋ฏธ์ฒ˜๋ฆฌ ์‹œ + * catch-all์˜ 500์œผ๋กœ ์ƒ˜ โ€” @ControllerAdvice๊ฐ€ Spring ๋‚ด์žฅ 405 ๋ณ€ํ™˜๋ณด๋‹ค ๋จผ์ € ๋ˆ๋‹ค). RFC 9110์ƒ 405 ์‘๋‹ต์€ Allow ํ—ค๋”๋กœ + * ํ—ˆ์šฉ ๋ฉ”์„œ๋“œ๋ฅผ ์•Œ๋ ค์•ผ ํ•œ๋‹ค. + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupported( + HttpRequestMethodNotSupportedException e) { + log.warn("[ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ๋ฉ”์„œ๋“œ] {} ์š”์ฒญ ๊ฑฐ๋ถ€", e.getMethod()); + + ResponseEntity.BodyBuilder builder = ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED); + Set supported = e.getSupportedHttpMethods(); + if (supported != null && !supported.isEmpty()) { + builder.allow(supported.toArray(new HttpMethod[0])); + } + return builder.body(new CustomErrorResponse(ExceptionMessage.METHOD_NOT_ALLOWED.getMessage())); + } + @ExceptionHandler(MaxUploadSizeExceededException.class) public ResponseEntity handleMaxUploadSizeExceededException( MaxUploadSizeExceededException e) { diff --git a/modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java b/modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java index d30bd71d..5c9f11c6 100644 --- a/modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java +++ b/modules/global/src/test/java/com/icc/qasker/global/error/GlobalExceptionHandlerTest.java @@ -4,6 +4,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -131,6 +132,19 @@ void unhandledException() throws Exception { .andExpect(jsonPath("$.message").value(ExceptionMessage.DEFAULT_ERROR.getMessage())); } + @Test + @DisplayName("POST ์ „์šฉ ๊ฒฝ๋กœ์— GET โ†’ 405 + Allow ํ—ค๋” + WARN ๋กœ๊ทธ (500 ์•„๋‹˜)") + void methodNotSupported() throws Exception { + mockMvc + .perform(get("/validate")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(header().string("Allow", "POST")) + .andExpect(jsonPath("$.message").value(ExceptionMessage.METHOD_NOT_ALLOWED.getMessage())); + + assertThat(logAppender.list) + .anyMatch(e -> e.getLevel() == Level.WARN && e.getFormattedMessage().contains("GET")); + } + @Test @DisplayName("์š”์ฒญ ๋ฐ”๋”” ๊ฒ€์ฆ ์‹คํŒจ(@NotBlank) โ†’ 400 + ํ•„๋“œ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ (500 ์•„๋‹˜)") void bodyValidationFails() throws Exception { diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiClientConfig.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiClientConfig.java index 314df29d..a8fdef7a 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiClientConfig.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/config/GeminiClientConfig.java @@ -2,7 +2,6 @@ import com.google.genai.Client; import com.google.genai.types.ClientOptions; -import com.google.genai.types.HttpOptions; import com.icc.qasker.ai.properties.QAskerAiProperties; import lombok.RequiredArgsConstructor; import okhttp3.OkHttpClient; @@ -40,9 +39,6 @@ public Client googleGenAiClient(GoogleGenAiConnectionProperties properties) { @Bean @org.springframework.context.annotation.Profile({"test", "mock"}) public Client googleGenAiClientTest() { - return Client.builder() - .apiKey("ci-dummy-key") - .httpOptions(HttpOptions.builder().timeout(aiProperties.getChatTimeoutMs()).build()) - .build(); + return Client.builder().apiKey("ci-dummy-key").build(); } } diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/properties/QAskerAiProperties.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/properties/QAskerAiProperties.java index 2502c342..b864083a 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/properties/QAskerAiProperties.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/properties/QAskerAiProperties.java @@ -10,9 +10,6 @@ @ConfigurationProperties(prefix = "q-asker.ai") public class QAskerAiProperties { - /** Gemini Chat API ํƒ€์ž„์•„์›ƒ (ms) โ€” q-asker.ai.chat-timeout-ms */ - private int chatTimeoutMs; - /** ์ปจํ…์ŠคํŠธ ์บ์‹œ TTL โ€” q-asker.ai.cache-ttl. ํ•œ ์„ธํŠธ ์ƒ์„ฑ ์„ธ์…˜(์ธํ„ฐ๋ฆฌ๋น™ ์ „์ฒด)์„ ์ปค๋ฒ„ํ•˜๊ณ , ์ข…๋ฃŒ ์‹œ ๋ช…์‹œ์ ์œผ๋กœ ์‚ญ์ œํ•œ๋‹ค. */ private Duration cacheTtl; diff --git a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/AbstractChunkedQuizOrchestrator.java b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/AbstractChunkedQuizOrchestrator.java index 1258b1cd..3249a174 100644 --- a/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/AbstractChunkedQuizOrchestrator.java +++ b/modules/quiz-ai/impl/src/main/java/com/icc/qasker/ai/service/AbstractChunkedQuizOrchestrator.java @@ -49,8 +49,17 @@ @Slf4j public abstract class AbstractChunkedQuizOrchestrator implements QuizTypeOrchestrator { - /** ๋น„๋™๊ธฐ ์ƒ์„ฑ ๊ฒŒ์ดํŠธ ๊ฒ€์ฆ์˜ ๋™์‹œ ์‹คํ–‰ ์ƒํ•œ(์™ธ๋ถ€ ๊ฒ€์ฆ ๋ชจ๋ธ ํ˜ธ์ถœ ๋ฒ„์ŠคํŠธ๋ฅผ ์ œํ•œ). */ - private static final int VERIFY_CONCURRENCY = 8; + /** + * ๋น„๋™๊ธฐ ์ƒ์„ฑ ๊ฒŒ์ดํŠธ ๊ฒ€์ฆ์˜ ๋™์‹œ ์‹คํ–‰ ์ƒํ•œ. Vertex ๋Š” generateContent ์— ๋™์‹œ์„ฑ ์ฟผํ„ฐ๋ฅผ ๋‘์ง€ ์•Š์œผ๋ฏ€๋กœ(์ œํ•œ์€ RPMยทTPM) ์ž‘์—… ๊ฐœ์ˆ˜๊ฐ€ ๊ณง ์ƒํ•œ์ด๋‹ค + * โ€” quizCount ์ตœ๋Œ€ 30({@code GenerationRequest.DEFAULT_ALLOWED_COUNTS})์„ ๋ฎ์–ด ์„ธ๋งˆํฌ์–ด๊ฐ€ ๋ง‰์ง€ ์•Š๊ฒŒ ํ•œ๋‹ค. + * + *

TODO: ์ด ๊ฐ’์€ ์„ธ์…˜ 1๊ฑด ๊ธฐ์ค€์ด๋‹ค. ์„ธ๋งˆํฌ์–ดยท์ต์Šคํํ„ฐ๊ฐ€ Session ์ธ์Šคํ„ด์Šค ํ•„๋“œ๋ผ ๋™์‹œ ์ƒ์„ฑ ์š”์ฒญ ์ˆ˜๋งŒํผ ๊ณฑํ•ด์ง„๋‹ค(3์š”์ฒญ โ†’ ์ตœ๋Œ€ 90). ์ „์—ญ + * ์ƒํ•œ์ด ์—†์œผ๋ฏ€๋กœ ๋™์‹œ ์ƒ์„ฑ์ด ๋Š˜๋ฉด ์ œํ•œ ๊ณ„์ธต์„ ์ถ”๊ฐ€ํ•ด์•ผ ํ•œ๋‹ค. + */ + private static final int VERIFY_CONCURRENCY = 30; + + /** ๋ณด๋ฅ˜ ๋ฌธํ•ญ ์žฌ์ƒ์„ฑ์˜ ๋™์‹œ ์‹คํ–‰ ์ƒํ•œ. ๋ณด๋ฅ˜ ํ ์ตœ๋Œ€์น˜(= quizCount)๋ฅผ ๋ฎ์–ด ํ•œ ๋ผ์šด๋“œ์— ์†Œ์ง„ํ•œ๋‹ค. ์„ธ์…˜๋‹น ๊ธฐ์ค€์ธ ์ ์€ ์œ„์™€ ๊ฐ™๋‹ค. */ + private static final int REGENERATE_CONCURRENCY = 30; private final GeminiFileService geminiFileService; private final ChatModel chatModel; @@ -76,25 +85,16 @@ protected AbstractChunkedQuizOrchestrator( this.cacheManager = new GeminiContextCacheManager(chatModel, metricsRecorder); } - /** ์ฒญํฌ K(Kโ‰ฅ2) ์‚ฌ์šฉ์ž ํ”„๋กฌํ”„ํŠธ ๊ผฌ๋ฆฌ์— ๋ถ™๋Š” ํƒ€์ž…๋ณ„ ์ค‘๋ณต ํšŒํ”ผ ์ง€์นจ ๋ฌธ๊ตฌ. */ protected abstract String dedupInstruction(); - /** ์ŠคํŠธ๋ฆฌ๋ฐ JSON ๋ฐฐ์—ด ์š”์†Œ ํƒ€์ž… โ€” {@link StreamingJsonArrayExtractor} ๊ตฌ์„ฑ์šฉ(์ œ๋„ค๋ฆญ ํƒ€์ž… ์†Œ๊ฑฐ ์šฐํšŒ). */ protected abstract Class elementType(); - /** ์‘๋‹ต JSON ์Šคํ‚ค๋งˆ. customInstruction ๋ฐ˜์˜(์„ ์ง€ํ˜•/์„œ์ˆ ํ˜•๋ณ„). */ protected abstract String responseSchema(String customInstruction); - /** - * ํŒŒ์‹ฑ๋œ ์งˆ๋ฌธ 1๊ฐœ๋ฅผ ์ €์žฅ์šฉ {@link AIProblem}์œผ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค. ์„ ์ง€ํ˜•์€ ์„ ์ง€ ์ •๋ ฌ์„ ํก์ˆ˜ํ•˜๊ณ , ์„œ์ˆ ํ˜•์€ ํ•ญ๋“ฑ ๋งคํ•‘ํ•œ๋‹ค. ์ €์žฅ ์ˆœ์„œ = ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ ์ˆœ์„œ - * ๊ธฐ์ค€์ด๋ฏ€๋กœ ์ด ์‹œ์ ์— ํ™•์ •ํ•œ๋‹ค. - */ protected abstract AIProblem toProblem(T question, List sourcePages); - /** ์ด ์งˆ๋ฌธ์„ ์ฑ„ํƒํ• ์ง€ ํŒ๋‹จํ•œ๋‹ค. ์„ ์ง€ํ˜•: ์„ ์ง€ ์ˆ˜ โ‰ค ์ƒํ•œ. ์„œ์ˆ ํ˜•: ํ•ญ์ƒ true. ๋ฏธ๋‹ฌ ์‹œ drop. */ protected abstract boolean accept(T question); - /** ์žฌ์ƒ์„ฑ ์‘๋‹ต ํ…์ŠคํŠธ์—์„œ ์ฒซ ์งˆ๋ฌธ์„ ํŒŒ์‹ฑํ•œ๋‹ค. ๊ตฌ์ฒด ์‘๋‹ตํƒ€์ž…({@code BeanOutputConverter})์€ ํ•˜์œ„๊ฐ€ ์•ˆ๋‹ค. */ protected abstract Optional parseFirst(String text); @Override @@ -102,46 +102,46 @@ public void generateQuiz(GenerationRequestToAI request) { new Session(request).run(); } - /** - * ํ•œ ์š”์ฒญ๋ถ„์˜ ์ธํ„ฐ๋ฆฌ๋น™ ์‹คํ–‰ ์ƒํƒœ. ํ˜‘๋ ฅ ๋นˆ์€ ๋ฐ”๊นฅ ์ธ์Šคํ„ด์Šค์—์„œ, ์š”์ฒญ๋ณ„ ์ƒํƒœ(์บ์‹œยท๋Œ€ํ™” ์Šค๋ ˆ๋“œยท๋ณด๋ฅ˜ ํยท๋ˆ„์  ๋ฉ”ํŠธ๋ฆญ)๋Š” ์ด ์ธ์Šคํ„ด์Šค์—์„œ ๊ด€๋ฆฌํ•œ๋‹ค. {@code - * generateQuiz} ํ˜ธ์ถœ๋งˆ๋‹ค ์ƒˆ๋กœ ๋งŒ๋“ค์–ด์ง€๋ฏ€๋กœ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ดํ„ฐ ๋นˆ์€ ๋ฌด์ƒํƒœ๋กœ ์œ ์ง€๋œ๋‹ค. - */ private final class Session { - // ํ˜‘๋ ฅ ๊ฐ์ฒดยท๋ถˆ๋ณ€ ์„ค์ • (์ƒ์„ฑ์ž์—์„œ 1ํšŒ ํ™•์ •) + // ์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ private final GenerationRequestToAI request; private final QuizBatchSink sink; private final int quizCount; private final QuizType quizType; + private final String tag = getSupportedType().toString(); private final String genGuideLine; + + // PDF (์™ธ๋ถ€ ์ž์› ํ•ธ๋“ค) private final FileMetadata metadata; private final Media pdfMedia; - // ์ปจํ…์ŠคํŠธ ์บ์‹œ: ์ „์ฒด ์ง€์นจ(๋ฌธ์ œ+ํ•ด์„ค)+PDF๋ฅผ 1๊ฐœ ์บ์‹œ. ์ƒ์„ฑยท์žฌ์ƒ์„ฑ์ด ๊ณต์œ ํ•œ๋‹ค. ์‹คํŒจ ์‹œ null โ†’ ํด๋ฐฑ. - private final CacheRef genCache; - // Pass 1 ๊ฒ€์ฆ ์บ์‹œ(๊ฒ€์ฆ ๋ฃจ๋ธŒ๋ฆญ+PDF ์›๋ฌธ): ์„ธ์…˜๋‹น 1๊ฐœ. ์‹คํŒจ ์‹œ null โ†’ ์›๋ฌธ ๋Œ€์กฐ ์—†๋Š” ๊ฒ€์ฆ. - private final CacheRef verifyCache; - private final String tag = getSupportedType().toString(); - private final long startNanos = System.nanoTime(); - // ๋ˆ„์  ์ƒํƒœ - // ๋ฉ€ํ‹ฐํ„ด ๋Œ€ํ™” ์Šค๋ ˆ๋“œ โ€” U/A ํ„ด๋งŒ ๋ˆ„์ . ์บ์‹œ ์‚ฌ์šฉ ์‹œ systemยทPDF๋Š” ์บ์‹œ์—, ํด๋ฐฑ ์‹œ ๊ฐ ์š”์ฒญ์ด phase๋ณ„ system(+PDF)์„ ๋ถ™์ธ๋‹ค. + // ์ปจํ…์ŠคํŠธ ์บ์‹œ โ€” โ‘  ์บ์‹œ ์ƒ์„ฑ ๋‹จ๊ณ„๊ฐ€ ์ฑ„์šฐ๊ณ  finally ์—์„œ ํ•ด์ œํ•œ๋‹ค + private CacheRef genCache; + private CacheRef verifyCache; + + // ๋น„๋™๊ธฐ ๊ฒ€์ฆ ์ธํ”„๋ผ + private final ExecutorService verifyExecutor = Executors.newVirtualThreadPerTaskExecutor(); + private final Semaphore verifySlots = new Semaphore(VERIFY_CONCURRENCY); + private final List> verifyFutures = new CopyOnWriteArrayList<>(); + + // ๋Œ€ํ™” ์Šค๋ ˆ๋“œ โ€” ์ฒญํฌ ๋ฃจํ”„(๋‹จ์ผ ์Šค๋ ˆ๋“œ)๋งŒ ์“ด๋‹ค private final List conversation = new ArrayList<>(); - // ์ƒ์„ฑ ๊ฒŒ์ดํŠธ์—์„œ ๋ฏธ๋‹ฌ ํŒ์ •๋œ ๋ฌธํ•ญ์˜ ๋ณด๋ฅ˜ ํ. ์—ฌ๋Ÿฌ ๊ฒ€์ฆ ์›Œ์ปค๊ฐ€ ๋™์‹œ์— add ํ•˜๋ฏ€๋กœ ๋ฝํ”„๋ฆฌ ๋™์‹œ ํ๋ฅผ ์“ฐ๊ณ , - // ์ฒญํฌ ๋ฃจํ”„ ์ข…๋ฃŒ(๋ฐฐ๋ฆฌ์–ด) ํ›„ ๋‹จ์ผ ์Šค๋ ˆ๋“œ์—์„œ FIFO๋กœ 1ํšŒ ์žฌ์ƒ์„ฑ ์†Œ์ง„ํ•œ๋‹ค. + + // ์ง„ํ–‰ ์ƒํƒœ โ€” ๊ฒ€์ฆยท์žฌ์ƒ์„ฑ ์›Œ์ปค๊ฐ€ ๋™์‹œ์— ๊ฐฑ์‹ ํ•œ๋‹ค private final Queue heldQueue = new ConcurrentLinkedQueue<>(); - private final DoubleAdder totalCost = new DoubleAdder(); - private final AtomicLong firstNanos = new AtomicLong(0); - private final AtomicLong lastNanos = new AtomicLong(0); - // delivered = ๊ฒ€์ฆ ํ†ต๊ณผยท์ €์žฅ๋œ ๋ฌธํ•ญ ์ˆ˜. submitted = ๊ฒ€์ฆ์— ์ œ์ถœํ•œ(์ƒ์„ฑ) ๋ฌธํ•ญ ์ˆ˜(ํŒŒ์‹ฑ ์ค‘๋‹จยท์ฒญํฌ ์ง„ํ–‰ ํŒ๋‹จ ๊ธฐ์ค€). private final AtomicInteger delivered = new AtomicInteger(0); private final AtomicInteger submitted = new AtomicInteger(0); - // ์ƒ์„ฑ ์ŠคํŠธ๋ฆผ ์†Œ๋น„์™€ ๊ฒŒ์ดํŠธ ๊ฒ€์ฆ์„ ๋ถ„๋ฆฌํ•˜๋Š” ๋น„๋™๊ธฐ ์ธํ”„๋ผ. - // ์ŠคํŠธ๋ฆผ ์ฝœ๋ฐฑ์€ ๋ฌธํ•ญ์„ verifyExecutor์— ์ œ์ถœํ•˜๊ณ  ์ฆ‰์‹œ ๋ฆฌํ„ดํ•œ๋‹ค(์ŠคํŠธ๋ฆผ ๋…ผ๋ธ”๋กœํ‚น). - // ์›Œ์ปค๋Š” verifySlots๋กœ ๋™์‹œ์„ฑ์„ ์ œํ•œํ•œ ์ฑ„ ๊ฒ€์ฆโ†’ํ†ต๊ณผ: ์ €์žฅยทํ†ต์ง€ / ๋ฏธ๋‹ฌ: ๋ณด๋ฅ˜ ํ. - private final ExecutorService verifyExecutor = Executors.newVirtualThreadPerTaskExecutor(); - private final Semaphore verifySlots = new Semaphore(VERIFY_CONCURRENCY); - private final List> verifyFutures = new CopyOnWriteArrayList<>(); + // ์ฒญํฌ ์ง„ํ–‰ โ€” โ‘ก ํ€ด์ฆˆ ์ƒ์„ฑ ๋‹จ๊ณ„(๋‹จ์ผ ์Šค๋ ˆ๋“œ)๋งŒ ์“ด๋‹ค + private int chunkTotal; + private int chunksDone; + + // ๊ณ„์ธก + private final long startNanos = System.nanoTime(); + private final AtomicLong firstNanos = new AtomicLong(0); + private final AtomicLong lastNanos = new AtomicLong(0); + private final DoubleAdder totalCost = new DoubleAdder(); private Session(GenerationRequestToAI request) { this.request = request; @@ -152,79 +152,79 @@ private Session(GenerationRequestToAI request) { this.metadata = resolvePdf(request); this.pdfMedia = new Media(MimeTypeUtils.parseMimeType("application/pdf"), URI.create(metadata.uri())); - // PDF + ์‹œ์Šคํ…œ ๊ฐ€์ด๋“œ๋ผ์ธ์€ ์–ด๋–ค ์„ธ์…˜์ด๋“  ๊ณต์œ ํ•˜๋Š” ์บ์‹œ, ๋ฉ”์ธ ๋ชจ๋ธ์ด ์‚ฌ์šฉํ•˜๋Š” ์บ์‹œ - this.genCache = - cacheManager - .create(tag, genGuideLine, metadata.uri(), aiProperties.getCacheTtl()) - .orElse(null); - // ๊ฒ€์ฆ ๋ชจ๋ธ์ด ์‚ฌ์šฉํ•˜๋Š” ์บ์‹œ - this.verifyCache = qualityGate.createPass1Cache(metadata.uri(), quizType.name()).orElse(null); } private void run() { - List chunkPlan = - ChunkPlanner.plan(quizCount, aiProperties.getChunk().getChunkSize()); - log.info( - "{} ๋ฌธ์ œ ์ˆ˜์— ๋งž๊ฒŒ ์ฒญํฌ ๋ถ„ํ•  ์™„๋ฃŒ: ์š”์ฒญ={}๋ฌธํ•ญ, chunk-size={}, ์ฒญํฌ={}๊ฐœ", - tag, - quizCount, - aiProperties.getChunk().getChunkSize(), - chunkPlan.size()); - - int chunksDone = 0; try { - for (int chunkIndex = 0; chunkIndex < chunkPlan.size(); chunkIndex++) { - if (submitted.get() >= quizCount) break; - int chunkSize = chunkPlan.get(chunkIndex); - - List generated = runGenerationPhase(chunkIndex, chunkSize); - - if (generated.isEmpty()) { - log.warn("{} ์ฒญํฌ #{} ์‚ฐ์ถœ๋ฌผ ์—†์Œ โ€” ๋ถ€๋ถ„ ์ €์žฅยท์™„๋ฃŒ.", tag, chunkIndex); - break; - } - chunksDone++; - } - - // ๋ฐฐ๋ฆฌ์–ด: ๋น„๋™๊ธฐ ๊ฒ€์ฆ์ด ๋ชจ๋‘ ์ •์ฐฉํ•ด์•ผ heldQueueยทdelivered๊ฐ€ ํ™•์ •๋œ๋‹ค. + // 1. ์บ์‹œ ์ƒ์„ฑ + createCaches(); + // 2. ํ€ด์ฆˆ ์ƒ์„ฑ โ€” ๋ฌธํ•ญ์„ ์ŠคํŠธ๋ฆผ์œผ๋กœ ๋ฐ›์œผ๋ฉฐ ํ‰๊ฐ€๋ฅผ ๋น„๋™๊ธฐ๋กœ ์ œ์ถœํ•œ๋‹ค + generateChunks(); + // 3. ํ‰๊ฐ€ โ€” ์ œ์ถœ๋œ ํ‰๊ฐ€๊ฐ€ ๋ชจ๋‘ ์ •์ฐฉํ•  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฐ๋‹ค awaitVerifications(); - - // ๋ณด๋ฅ˜๋œ ๋ฏธ๋‹ฌ ๋ฌธํ•ญ์„ ์„ธ์…˜ ๋‚ด์—์„œ 1ํšŒ ์žฌ์ƒ์„ฑํ•ด ํ†ต๊ณผ๋ถ„์„ ์ฑ„์šด๋‹ค(์›๋ฌธ ์ปจํ…์ŠคํŠธยท๋Œ€ํ™” ์Šค๋ ˆ๋“œ ํ™œ์šฉ). - processHeldQueue(); + // 4. ์žฌ์ƒ์„ฑ โ€” ํ‰๊ฐ€์—์„œ ํƒˆ๋ฝํ•œ ๋ฌธํ•ญ์„ 1๋ฌธ์ œ์”ฉ ๋‹ค์‹œ ๋งŒ๋“ ๋‹ค + regenerateHeld(); } catch (CustomException e) { awaitVerifications(); if (delivered.get() == 0) throw e; - log.warn("{} ์ธํ„ฐ๋ฆฌ๋น™ ๋„์ค‘ ๋น„์ฆˆ๋‹ˆ์Šค ์˜ค๋ฅ˜. {}๋ฌธํ•ญ ๋ณด์กด.", tag, delivered.get(), e); + log.warn("{} ํ€ด์ฆˆ ์ƒ์„ฑ ์ค‘ ๋น„์ฆˆ๋‹ˆ์Šค ์˜ค๋ฅ˜. {}๋ฌธํ•ญ ๋ณด์กด.", tag, delivered.get(), e); } catch (Exception e) { awaitVerifications(); if (delivered.get() == 0) throw new GeminiInfraException("Gemini ์ธํ”„๋ผ ์žฅ์• ", e); - log.warn("{} ์ธํ„ฐ๋ฆฌ๋น™ ๋„์ค‘ ์ธํ”„๋ผ ์˜ค๋ฅ˜. {}๋ฌธํ•ญ ๋ณด์กด.", tag, delivered.get(), e); + log.warn("{} ํ€ด์ฆˆ ์ƒ์„ฑ ์ค‘ ์ธํ”„๋ผ ์˜ค๋ฅ˜. {}๋ฌธํ•ญ ๋ณด์กด.", tag, delivered.get(), e); metricsRecorder.recordStreamingTimeout(tag); } finally { - // ์˜ˆ์™ธ๋กœ ์ค‘๋‹จ๋์–ด๋„ ์ธํ”Œ๋ผ์ดํŠธ ๊ฒ€์ฆ์„ ๋งˆ์ € ์ •์ฐฉ์‹œํ‚จ ๋’ค(๋ฉฑ๋“ฑ) ์บ์‹œ๋ฅผ ์‚ญ์ œํ•œ๋‹ค. awaitVerifications(); verifyExecutor.shutdown(); - cacheManager.delete(tag, genCache == null ? null : genCache.name()); - qualityGate.deletePass1Cache(verifyCache); + releaseCaches(); } + recordOutcome(); + } + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // 1. ์บ์‹œ ์ƒ์„ฑ + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + private void createCaches() { + this.genCache = + cacheManager + .create(tag, genGuideLine, metadata.uri(), aiProperties.getCacheTtl()) + .orElse(null); + this.verifyCache = qualityGate.createPass1Cache(metadata.uri(), quizType.name()).orElse(null); + } + + private void releaseCaches() { + cacheManager.delete(tag, genCache == null ? null : genCache.name()); + qualityGate.deletePass1Cache(verifyCache); + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // 2. ํ€ด์ฆˆ ์ƒ์„ฑ + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + private void generateChunks() { + List chunkPlan = + ChunkPlanner.plan(quizCount, aiProperties.getChunk().getChunkSize()); + chunkTotal = chunkPlan.size(); log.info( - "{} ์ฒญํฌ ์ƒ์„ฑ ์™„๋ฃŒ: ์ „๋‹ฌ={}๋ฌธํ•ญ(๋ชฉํ‘œ {}), ๋ฐฐ์น˜ ์™„๋ฃŒ={}/{}, ์ด ์†Œ์š”={}ms", + "{} ๋ฌธ์ œ ์ˆ˜์— ๋งž๊ฒŒ ์ฒญํฌ ๋ถ„ํ•  ์™„๋ฃŒ: ์š”์ฒญ={}๋ฌธํ•ญ, chunk-size={}, ์ฒญํฌ={}๊ฐœ", tag, - delivered.get(), quizCount, - chunksDone, - chunkPlan.size(), - (System.nanoTime() - startNanos) / 1_000_000); + aiProperties.getChunk().getChunkSize(), + chunkTotal); - Long first = firstNanos.get() == 0 ? null : firstNanos.get(); - Long last = lastNanos.get() == 0 ? null : lastNanos.get(); - metricsRecorder.recordRequestDuration(chunksDone, startNanos, first, last, totalCost.sum()); - metricsRecorder.recordQuizCounts(tag, quizCount, delivered.get(), chunksDone); + for (int chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) { + if (submitted.get() >= quizCount) break; + + List generated = generateChunk(chunkIndex, chunkPlan.get(chunkIndex)); + + if (generated.isEmpty()) { + log.warn("{} ์ฒญํฌ #{} ์‚ฐ์ถœ๋ฌผ ์—†์Œ โ€” ๋ถ€๋ถ„ ์ €์žฅยท์™„๋ฃŒ.", tag, chunkIndex); + break; + } + chunksDone++; + } } - /** 1๋‹จ๊ณ„ ์ƒ์„ฑ */ - private List runGenerationPhase(int chunkIndex, int chunkSize) { + private List generateChunk(int chunkIndex, int chunkSize) { String userPrompt = quizType.generateRequestPrompt( request.referencePages(), chunkSize, request.customInstruction()); @@ -233,27 +233,20 @@ private List runGenerationPhase(int chunkIndex, int chunkSize) { userPrompt = userPrompt + dedupInstruction(); } - // ์บ์‹œ๊ฐ€ ์žˆ์œผ๋ฉด PDF๋Š” ์บ์‹œ ํ”„๋ฆฌํ”ฝ์Šค์— ์žˆ์œผ๋ฏ€๋กœ ์ฒจ๋ถ€ํ•˜์ง€ ์•Š๋Š”๋‹ค. ํด๋ฐฑ(์บ์‹œ ์—†์Œ) ์‹œ ์ฒซ ์‚ฌ์šฉ์ž ํ„ด์—๋งŒ ์ฒจ๋ถ€ํ•œ๋‹ค. UserMessage.Builder ub = UserMessage.builder().text(userPrompt); if (genCache == null && chunkIndex == 0) { ub.media(pdfMedia); } UserMessage phase1User = ub.build(); - // ์บ์‹œ ์‚ฌ์šฉ ์‹œ systemยทPDF๋Š” ์บ์‹œ์— ์žˆ์œผ๋ฏ€๋กœ ์š”์ฒญ์—” ๋Œ€ํ™” ํ„ด๋งŒ. ํด๋ฐฑ ์‹œ ์ „์ฒด ์ง€์นจ(๋ฌธ์ œ+ํ•ด์„ค)์„ prepend. List messages = new ArrayList<>(); if (genCache == null) { messages.add(new SystemMessage(genGuideLine)); } messages.addAll(conversation); messages.add(phase1User); - - // ์ „์ฒด ์Šคํ‚ค๋งˆ(์„ ์ง€๋ณ„ ํ•ด์„ค ํฌํ•จ) โ€” ๋ฌธ์ œยทํ•ด์„ค์„ ํ•œ ์‘๋‹ต์— ์ƒ์„ฑํ•œ๋‹ค. ์ €์žฅ ์‹œ sink๊ฐ€ ์ธ๋ผ์ธ ํ•ด์„ค์„ ๋งˆํฌ๋‹ค์šด์œผ๋กœ ์กฐ๋ฆฝํ•œ๋‹ค. String schema = responseSchema(request.customInstruction()); Prompt prompt = new Prompt(messages, buildOptions(schema, genCache)); - - // ์ด ์ฒญํฌ์—์„œ ์ƒ์„ฑ๋œ(๊ฒ€์ฆ ์ด์ „) ๋ฌธํ•ญ โ€” ์ƒ์„ฑ ์ˆœ์„œ. ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ ๊ตฌ์„ฑ๊ณผ ์ฒญํฌ ์ง„ํ–‰ ํŒ๋‹จ์— ์“ด๋‹ค. - // ์ŠคํŠธ๋ฆผ ์ฝœ๋ฐฑ(๋‹จ์ผ ๋ฆฌ์•กํ„ฐ ์Šค๋ ˆ๋“œ)์—์„œ๋งŒ add ํ•˜๊ณ , streamInto ์ข…๋ฃŒ(blockLast) ์ดํ›„ ์ฝ์œผ๋ฏ€๋กœ ๊ฐ€์‹œ์„ฑ์ด ๋ณด์žฅ๋œ๋‹ค. List generated = new ArrayList<>(); StreamingJsonArrayExtractor extractor = new StreamingJsonArrayExtractor<>( @@ -266,9 +259,6 @@ private List runGenerationPhase(int chunkIndex, int chunkSize) { AIProblem arranged = toProblem(question, metadata.sourcePages()); int order = submitted.incrementAndGet(); generated.add(arranged); - - // ์ฒ˜์Œ fast-serve-count๊ฐœ๋Š” ์ƒ์„ฑ ๊ฒŒ์ดํŠธ ์—†์ด ์ฆ‰์‹œ ์ €์žฅํ•œ๋‹ค(์ฆ‰์„ ์„œ๋น™ โ€” ์‚ฌํ›„ Pass 2๊ฐ€ ํŒ์ •). - // ๊ทธ ์™ธ์—” ์ƒ์„ฑ ๊ฒŒ์ดํŠธ๋ฅผ ๋น„๋™๊ธฐ๋กœ ์ œ์ถœํ•˜๊ณ  ์ฆ‰์‹œ ๋ฆฌํ„ดํ•œ๋‹ค โ€” ์ŠคํŠธ๋ฆผ ์†Œ๋น„๋ฅผ ๊ฒ€์ฆ์ด ๋ถ™์žก์ง€ ์•Š๋Š”๋‹ค. if (order <= aiProperties.getFastServeCount()) { verifyFutures.add(submitFastServe(arranged)); } else { @@ -279,13 +269,27 @@ private List runGenerationPhase(int chunkIndex, int chunkSize) { streamInto(prompt, extractor, chunkIndex); - // ์–ด์‹œ์Šคํ„ดํŠธ ํ„ด = ์ด ์ฒญํฌ์—์„œ ์ƒ์„ฑ๋œ ์ „์ฒด ๋ฌธํ•ญ(๊ฒ€์ฆ ๊ฒฐ๊ณผ์™€ ๋ฌด๊ด€). ๊ฒ€์ฆ์„ ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š๊ณ  ๋‹ค์Œ ์ฒญํฌ์˜ ์ค‘๋ณต ํšŒํ”ผ ์ปจํ…์ŠคํŠธ๋ฅผ ํ™•์ •ํ•œ๋‹ค. conversation.add(phase1User); conversation.add(new AssistantMessage(serializeProblems(generated))); return generated; } - /** ์ฆ‰์„ ์„œ๋น™: ์ƒ์„ฑ ๊ฒŒ์ดํŠธ ๊ฒ€์ฆ ์—†์ด ์ €์žฅยทํ†ต์ง€ํ•œ๋‹ค. ์ฒ˜์Œ ๋ฌธํ•ญ๋“ค์˜ ์ฒด๊ฐ ์ง€์—ฐ(TTFQ)์—์„œ ๊ฒ€์ฆ ์ฝœ ์ง€์—ฐ์„ ์ œ๊ฑฐํ•œ๋‹ค. */ + private void streamInto( + Prompt prompt, StreamingJsonArrayExtractor extractor, int chunkIndex) { + Flux stream = chatModel.stream(prompt); + stream + .doOnNext( + response -> { + String text = extractText(response); + if (text != null) extractor.feed(text); + recordUsage(response, tag + " chunk #" + chunkIndex); + }) + .blockLast(); + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // 3. ํ‰๊ฐ€ + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• private CompletableFuture submitFastServe(AIProblem problem) { return CompletableFuture.runAsync( () -> { @@ -298,11 +302,6 @@ private CompletableFuture submitFastServe(AIProblem problem) { verifyExecutor); } - /** - * ์ƒ์„ฑ ๊ฒŒ์ดํŠธ ๊ฒ€์ฆ์„ ๋น„๋™๊ธฐ๋กœ ์ˆ˜ํ–‰ํ•œ๋‹ค. ํ†ต๊ณผยท๊ฒ€์ฆ๋ถˆ๊ฐ€๋ฉด ์ €์žฅยทํ†ต์ง€ํ•˜๊ณ , ๋ฏธ๋‹ฌ(v1)์ด๋ฉด ๋ณด๋ฅ˜ ํ์— ๋„ฃ๋Š”๋‹ค(์ฒญํฌ ๋ฃจํ”„ ์ข…๋ฃŒ ํ›„ 1ํšŒ ์žฌ์ƒ์„ฑ). - * - *

์ €์žฅ ์ˆœ๋ฒˆ์€ ์ด ์›Œ์ปค๊ฐ€ saveProblem์„ ํ˜ธ์ถœํ•˜๋Š” ์ˆœ์„œ = ๊ฒ€์ฆ ํ†ต๊ณผ ์ˆœ์„œ์ด๋ฏ€๋กœ, ๋จผ์ € ํ†ต๊ณผํ•œ ๋ฌธํ•ญ์ด ๋‚ฎ์€ ๋ฒˆํ˜ธ๋ฅผ ๋ฐ›๋Š”๋‹ค. - */ private CompletableFuture submitVerification(AIProblem problem) { return CompletableFuture.runAsync( () -> { @@ -334,7 +333,6 @@ private CompletableFuture submitVerification(AIProblem problem) { verifyExecutor); } - /** ํ†ต๊ณผ ๋ฌธํ•ญ์„ ์ €์žฅยทํ†ต์ง€ํ•˜๊ณ  v1 ๋กœ๊ทธ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ์—ฌ๋Ÿฌ ๊ฒ€์ฆ ์›Œ์ปค๊ฐ€ ๋™์‹œ์— ํ˜ธ์ถœํ•˜๋ฏ€๋กœ sink๋Š” ์Šค๋ ˆ๋“œ ์•ˆ์ „ํ•ด์•ผ ํ•œ๋‹ค(consumerLock ์ง๋ ฌํ™”). */ private void store(AIProblem problem, String v1Feedback) { int number = sink.saveProblem(problem); sink.recordV1(number, problem, v1Feedback); @@ -348,7 +346,6 @@ private void store(AIProblem problem, String v1Feedback) { } } - /** ์ œ์ถœ๋œ ๋ชจ๋“  ๋น„๋™๊ธฐ ๊ฒ€์ฆ์ด ์ •์ฐฉํ•  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐํ•œ๋‹ค(๋ฉฑ๋“ฑ โ€” ์—ฌ๋Ÿฌ ๋ฒˆ ํ˜ธ์ถœํ•ด๋„ ์•ˆ์ „). */ private void awaitVerifications() { try { CompletableFuture.allOf(verifyFutures.toArray(CompletableFuture[]::new)).join(); @@ -357,60 +354,55 @@ private void awaitVerifications() { } } - private void streamInto( - Prompt prompt, StreamingJsonArrayExtractor extractor, int chunkIndex) { - Flux stream = chatModel.stream(prompt); - stream - .doOnNext( - response -> { - String text = extractText(response); - if (text != null) extractor.feed(text); - recordUsage(response, tag + " chunk #" + chunkIndex); - }) - // ๋Œ€๊ธฐ ์ƒํ•œ์€ OkHttp ์‹œํ•œ(connect/read/call, gemini-http ์„ค์ •)์ด ์ „๋‹ดํ•œ๋‹ค โ€” ๋™๊ธฐ ํ’€ ๊ตฌ์กฐ๋ผ - // blockLast์˜ Duration์€ ๋ฐœํ™” ๋ถˆ๋Šฅ์ด๋ฏ€๋กœ ๋‘์ง€ ์•Š๋Š”๋‹ค. - .blockLast(); - } + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // 4. ์žฌ์ƒ์„ฑ (1๋ฌธ์ œ์”ฉ) + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + private void regenerateHeld() { + if (heldQueue.isEmpty()) { + return; + } + int workers = Math.min(REGENERATE_CONCURRENCY, heldQueue.size()); + log.info("{} ๋ณด๋ฅ˜ ๋ฌธํ•ญ ์žฌ์ƒ์„ฑ ์‹œ์ž‘: {}๊ฑด (์›Œ์ปค {})", tag, heldQueue.size(), workers); - private void recordUsage(ChatResponse response, String label) { - if (response.getMetadata().getUsage().getCompletionTokens() > 0) { - double cost = - metricsRecorder.recordChunkUsage(label, startNanos, response.getMetadata().getUsage()); - totalCost.add(cost); + // ๋ชฉํ‘œ ๋ฌธํ•ญ ์ˆ˜๊นŒ์ง€ ๋‚จ์€ ์นธ์„ ํ‹ฐ์ผ“์œผ๋กœ ๋‚˜๋ˆ  ๊ฐ–๋Š”๋‹ค. ์›Œ์ปค๊ฐ€ ํ˜ธ์ถœ ์ „์— ํ•˜๋‚˜ ์ง‘๊ณ  ์‚ฐ์ถœ ์‹คํŒจ ์‹œ ๋ฐ˜๋‚ฉํ•ด + // ๋‹ค์Œ ํ•ญ๋ชฉ์ด ์“ฐ๊ฒŒ ํ•œ๋‹ค โ€” ๋™์‹œ์— ๋Œ์•„๋„ ๋ชฉํ‘œ ๋ฌธํ•ญ ์ˆ˜๋ฅผ ์ดˆ๊ณผ ์ €์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค. + Semaphore budget = new Semaphore(Math.max(0, quizCount - delivered.get())); + + List> tasks = new ArrayList<>(); + for (int i = 0; i < workers; i++) { + tasks.add(CompletableFuture.runAsync(() -> drainHeldQueue(budget), verifyExecutor)); } + CompletableFuture.allOf(tasks.toArray(CompletableFuture[]::new)).join(); } - /** - * ๋ณด๋ฅ˜๋œ ๋ฏธ๋‹ฌ ๋ฌธํ•ญ์„ ์„ธ์…˜ ๋‚ด์—์„œ 1ํšŒ์”ฉ ์žฌ์ƒ์„ฑํ•œ๋‹ค(FR: 1ํšŒ ์บก). ๊ฐœ์„ ๋ณธ(v2)์€ ๊ฒ€์ฆ ์—†์ด ์ €์žฅํ•˜๊ณ , ํ’ˆ์งˆ ๋กœ๊ทธ์—” v1ยทv2๋ฅผ ํ•จ๊ป˜ ๊ธฐ๋กํ•œ๋‹ค(์‚ฌํ›„ Pass 2๊ฐ€ - * v2๋ฅผ ํŒ์ •). ์žฌ์ƒ์„ฑ ๋ถˆ๊ฐ€โ†’์ œ์™ธ. ์ด๋ฏธ ๋ชฉํ‘œ ๋ฌธํ•ญ ์ˆ˜๋ฅผ ์ฑ„์› ์œผ๋ฉด ์ค‘๋‹จํ•œ๋‹ค. - */ - private void processHeldQueue() { - if (heldQueue.isEmpty()) { - return; - } - log.info("{} ๋ณด๋ฅ˜ ๋ฌธํ•ญ ์žฌ์ƒ์„ฑ ์‹œ์ž‘: {}๊ฑด", tag, heldQueue.size()); - for (HeldProblem held : heldQueue) { - if (delivered.get() >= quizCount) { - break; + private void drainHeldQueue(Semaphore budget) { + while (budget.tryAcquire()) { + HeldProblem held = heldQueue.poll(); + if (held == null) { + budget.release(); + return; } + boolean produced = false; try { AIProblem v2 = regenerateOne(held); - if (v2 == null) { - continue; + if (v2 != null) { + // v2๋Š” ๊ฒ€์ฆ ์—†์ด ์ €์žฅํ•œ๋‹ค + int number = sink.saveProblem(v2); + sink.recordV1(number, held.problem(), held.feedback()); + sink.recordV2(number, v2); + delivered.incrementAndGet(); + produced = true; } - // v2๋Š” ๊ฒ€์ฆ ์—†์ด ์ €์žฅํ•œ๋‹ค(์‚ฌํ›„ Pass 2๊ฐ€ ํŒ์ •). ํ’ˆ์งˆ ๋กœ๊ทธ์—” ๋ฏธ๋‹ฌ ์›๋ณธ(v1)๊ณผ ๊ฐœ์„ ๋ณธ(v2)์„ ํ•จ๊ป˜ ๊ธฐ๋กํ•ด ์ „ํ›„ ๋น„๊ต๋ฅผ ๋‚จ๊ธด๋‹ค. - int number = sink.saveProblem(v2); - sink.recordV1(number, held.problem(), held.feedback()); - sink.recordV2(number, v2); - delivered.incrementAndGet(); } catch (Exception e) { // ์žฌ์ƒ์„ฑ ๋ถˆ๊ฐ€ โ†’ ์ œ์™ธ(๋ฌธํ•ญ ์ˆ˜ ์ถ•์†Œ). log.warn("{} ๋ณด๋ฅ˜ ๋ฌธํ•ญ ์žฌ์ƒ์„ฑ ์‹คํŒจ โ€” ์ œ์™ธ", tag, e); } + if (!produced) { + budget.release(); + } } } - /** ๋ณด๋ฅ˜ ๋ฌธํ•ญ 1๊ฑด์„ ์žฌ์ƒ์„ฑํ•œ๋‹ค. ์‚ด์•„์žˆ๋Š” ๋Œ€ํ™” ์Šค๋ ˆ๋“œ(์›๋ฌธ ์ปจํ…์ŠคํŠธ)์— ๊ฐœ์„  ์ง€์‹œ๋ฅผ ์–น์–ด ๊ฐœ์„  ๋ฌธํ•ญ 1๊ฐœ๋ฅผ ์‚ฐ์ถœํ•œ๋‹ค. */ private AIProblem regenerateOne(HeldProblem held) { List messages = new ArrayList<>(); if (genCache == null) { @@ -437,9 +429,35 @@ private AIProblem regenerateOne(HeldProblem held) { } return toProblem(q, metadata.sourcePages()); } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // ๊ณ„์ธก (๋‹จ๊ณ„ ์•„๋‹˜ โ€” ๊ฐ ๋‹จ๊ณ„๊ฐ€ ํ˜ธ์ถœ) + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + private void recordUsage(ChatResponse response, String label) { + if (response.getMetadata().getUsage().getCompletionTokens() > 0) { + double cost = + metricsRecorder.recordChunkUsage(label, startNanos, response.getMetadata().getUsage()); + totalCost.add(cost); + } + } + + private void recordOutcome() { + log.info( + "{} ์ฒญํฌ ์ƒ์„ฑ ์™„๋ฃŒ: ์ „๋‹ฌ={}๋ฌธํ•ญ(๋ชฉํ‘œ {}), ๋ฐฐ์น˜ ์™„๋ฃŒ={}/{}, ์ด ์†Œ์š”={}ms", + tag, + delivered.get(), + quizCount, + chunksDone, + chunkTotal, + (System.nanoTime() - startNanos) / 1_000_000); + + Long first = firstNanos.get() == 0 ? null : firstNanos.get(); + Long last = lastNanos.get() == 0 ? null : lastNanos.get(); + metricsRecorder.recordRequestDuration(chunksDone, startNanos, first, last, totalCost.sum()); + metricsRecorder.recordQuizCounts(tag, quizCount, delivered.get(), chunksDone); + } } - /** PDF ์—…๋กœ๋“œ(์บ์‹œ) ํ›„ FileMetadata๋ฅผ ํ™•๋ณดํ•œ๋‹ค. */ private FileMetadata resolvePdf(GenerationRequestToAI request) { String cacheKey = geminiFileService.generateCacheKey(request.fileUrl(), request.referencePages()); @@ -464,10 +482,6 @@ private static String extractText(ChatResponse response) { return response.getResult().getOutput().getText(); } - /** - * JSON ์‘๋‹ต ์˜ต์…˜์„ ๋งŒ๋“ ๋‹ค. cacheName์ด ์žˆ์œผ๋ฉด ํ•ด๋‹น ์ปจํ…์ŠคํŠธ ์บ์‹œ๋ฅผ ์ฐธ์กฐํ•œ๋‹ค({@code useCachedContent}๋Š” {@code - * cachedContentName}๊ณผ ํ•จ๊ป˜์—ฌ์•ผ ChatModel์ด ์‹ค์ œ๋กœ ์ ์šฉํ•œ๋‹ค โ€” Spring AI 2.0.0์˜ {@code autoCache*}๋Š” ๋ฏธ์†Œ๋น„ ํœด๋ฉด ์˜ต์…˜). - */ private GoogleGenAiChatOptions buildOptions(String responseSchema, CacheRef cacheRef) { GoogleGenAiChatOptions.Builder builder = GoogleGenAiChatOptions.builder() @@ -480,7 +494,6 @@ private GoogleGenAiChatOptions buildOptions(String responseSchema, CacheRef cach return builder.build(); } - /** ์ƒ์„ฑ ๊ฒŒ์ดํŠธ์—์„œ ๋ฏธ๋‹ฌ ํŒ์ •๋ผ ๋ณด๋ฅ˜๋œ ๋ฌธํ•ญ(v1)๊ณผ ๊ฐœ์„  ํ”ผ๋“œ๋ฐฑ. */ private record HeldProblem(AIProblem problem, String feedback) {} private String buildRegenerationPrompt(HeldProblem held) { @@ -493,7 +506,6 @@ private String buildRegenerationPrompt(HeldProblem held) { return RegenerationPrompt.forHeldProblem(problemJson, held.feedback()); } - /** ์ƒ์„ฑ๋œ ๋ฌธํ•ญ์„ ์–ด์‹œ์Šคํ„ดํŠธ ํ„ด JSON์œผ๋กœ ์ง๋ ฌํ™”ํ•œ๋‹ค({"questions":[...]}). */ private String serializeProblems(List problems) { try { return objectMapper.writeValueAsString(Map.of("questions", problems)); diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/MockStaleGenerationRecoveryService.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/MockStaleGenerationRecoveryService.java index bd4682e4..903a8d39 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/MockStaleGenerationRecoveryService.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/MockStaleGenerationRecoveryService.java @@ -24,7 +24,7 @@ @RequiredArgsConstructor public class MockStaleGenerationRecoveryService implements StaleGenerationRecovery { - private static final long STALE_THRESHOLD_MINUTES = 10; + private static final long STALE_THRESHOLD_MINUTES = 15; private static final List TARGET_STATUSES = List.of(GenerationStatus.FAILED, GenerationStatus.GENERATING); diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecovery.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecovery.java index 62c07a21..74ce091e 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecovery.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecovery.java @@ -6,6 +6,6 @@ */ public interface StaleGenerationRecovery { - /** FAILED ๋˜๋Š” 10๋ถ„ ์ด์ƒ GENERATING ์ƒํƒœ๋กœ ๋ฐฉ์น˜๋œ ProblemSet์„ ์ •๋ฆฌํ•˜๊ณ  ๋Œ€์ƒ ๊ฑด์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. */ + /** FAILED ๋˜๋Š” 15๋ถ„ ์ด์ƒ GENERATING ์ƒํƒœ๋กœ ๋ฐฉ์น˜๋œ ProblemSet์„ ์ •๋ฆฌํ•˜๊ณ  ๋Œ€์ƒ ๊ฑด์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. */ int purgeStaleProblemSets(); } diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoveryScheduler.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoveryScheduler.java index 0695dcc6..55b30d19 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoveryScheduler.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoveryScheduler.java @@ -22,7 +22,7 @@ public class StaleGenerationRecoveryScheduler { public void deleteStaleProblemSets() { int purged = staleGenerationRecoveryService.purgeStaleProblemSets(); if (purged > 0) { - log.warn("[๋ฐฉ์น˜ ProblemSet ์ •๋ฆฌ] FAILED/GENERATING 10๋ถ„ ์ดˆ๊ณผ ์‚ญ์ œ count={}", purged); + log.warn("[๋ฐฉ์น˜ ProblemSet ์ •๋ฆฌ] FAILED/GENERATING 15๋ถ„ ์ดˆ๊ณผ ์‚ญ์ œ count={}", purged); } } } diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoveryService.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoveryService.java index a3fda028..8f0a41cb 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoveryService.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoveryService.java @@ -17,13 +17,18 @@ @RequiredArgsConstructor public class StaleGenerationRecoveryService implements StaleGenerationRecovery { - private static final long STALE_THRESHOLD_MINUTES = 10; + /** + * ์„ธ์…˜ ์‹œํ•œ(10๋ถ„)๋ณด๋‹ค ๊ธธ๊ฒŒ ์žก๋Š”๋‹ค. ๊ฐ™์€ ๊ฐ’์ด๋ฉด ์ง„ํ–‰ ์ค‘์ธ ์ƒ์„ฑ์ด ์ €์žฅ ์ง์ „์— ์‚ญ์ œ๋œ๋‹ค โ€” ์Šค์ผ€์ค„๋Ÿฌ๋Š” ์„ธํŠธ createdAt ๊ธฐ์ค€์ด๊ณ  ์„ธ์…˜ ์‹œ๊ณ„๋Š” PDF ์—…๋กœ๋“œ ๋’ค์— + * ์ถœ๋ฐœํ•ด ํ•ญ์ƒ ๋Šฆ์œผ๋ฉฐ, ์ฃผ๊ธฐ๊ฐ€ 1๋ถ„์ด๋ผ ํŒ์ •๋„ ์ตœ๋Œ€ 1๋ถ„ ๋Šฆ๊ฒŒ ์˜จ๋‹ค. + */ + private static final long STALE_THRESHOLD_MINUTES = 15; + private static final List TARGET_STATUSES = List.of(GenerationStatus.FAILED, GenerationStatus.GENERATING); private final ProblemSetRepository problemSetRepository; - /** FAILED ๋˜๋Š” 10๋ถ„ ์ด์ƒ GENERATING ์ƒํƒœ๋กœ ๋ฐฉ์น˜๋œ ProblemSet์„ ์‚ญ์ œํ•˜๊ณ  ์‚ญ์ œ ๊ฑด์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. */ + /** FAILED ๋˜๋Š” 15๋ถ„ ์ด์ƒ GENERATING ์ƒํƒœ๋กœ ๋ฐฉ์น˜๋œ ProblemSet์„ ์‚ญ์ œํ•˜๊ณ  ์‚ญ์ œ ๊ฑด์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. */ @Override @Transactional public int purgeStaleProblemSets() { diff --git a/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoverySchedulerTest.java b/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoverySchedulerTest.java index 89e6b962..db6cfeab 100644 --- a/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoverySchedulerTest.java +++ b/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/scheduler/StaleGenerationRecoverySchedulerTest.java @@ -23,7 +23,7 @@ class StaleGenerationRecoverySchedulerTest extends JpaIntegrationTestBase { @Autowired private ProblemRepository problemRepository; @Test - @DisplayName("10๋ถ„ ์ดˆ๊ณผ ๋ฐฉ์น˜ ์„ธํŠธ ์ค‘ FAILED/GENERATING๋งŒ ์‚ญ์ œ๋˜๊ณ  COMPLETED๋Š” ์ƒ์กดํ•œ๋‹ค") + @DisplayName("15๋ถ„ ์ดˆ๊ณผ ๋ฐฉ์น˜ ์„ธํŠธ ์ค‘ FAILED/GENERATING๋งŒ ์‚ญ์ œ๋˜๊ณ  COMPLETED๋Š” ์ƒ์กดํ•œ๋‹ค") void scheduler_keeps_completed_sets() { persistSet("s-failed", GenerationStatus.FAILED); persistSet("s-generating", GenerationStatus.GENERATING); From c89a55f113ff6f52d46412802549a9ca0c32024e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 8 Sep 2026 07:27:56 +0000 Subject: [PATCH 2/2] chore: bump version to 3.6.1 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index b069060f..760bbaa9 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ plugins { group = "com.icc.qasker" -version = "3.6.0" +version = "3.6.1" subprojects { tasks.configureEach { task ->