Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion app/src/main/resources/config/ai-setting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ spring:

q-asker:
ai:
chat-timeout-ms: 500000
cache-ttl: 15m # 컨텍스트 캐시 TTL (인터리빙 세션 커버)
# 스트리밍 시한 계층 (connect < read < call). 안쪽 시한이 바깥보다 짧아야
# 각 계층이 자기 실패를 자기 이름의 예외로 먼저 잡는다 — 순서가 무너지면 바깥층이
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/resources/config/app-common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions app/src/main/resources/config/database-config.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ plugins {

group = "com.icc.qasker"

version = "3.6.0"
version = "3.6.1"

subprojects {
tasks.configureEach { task ->
Expand Down
2 changes: 1 addition & 1 deletion infra/monitoring/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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에 병합 또는 별도 실행
# =============================================================

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -77,6 +80,24 @@ public ResponseEntity<CustomErrorResponse> 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<CustomErrorResponse> handleMethodNotSupported(
HttpRequestMethodNotSupportedException e) {
log.warn("[허용되지 않은 메서드] {} 요청 거부", e.getMethod());

ResponseEntity.BodyBuilder builder = ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED);
Set<HttpMethod> 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<CustomErrorResponse> handleMaxUploadSizeExceededException(
MaxUploadSizeExceededException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading