diff --git a/README.md b/README.md index 0f6ff6731..9999e954a 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Jira와 GitHub를 동시에 관리해야 하는 수고를 줄이고 팀의 Git ### 📚 기술 아티클 |제목|작성자| |---|---| -|Claude Code 개념부터 활용까지 (Harness, Context, Skills) (작성 예정)|홍지운, 황주희| +|[Claude Code 개념부터 활용까지 (Harness, Context, Skills)](https://github.com/softeerbootcamp-7th/WEB-Team4-Refit/wiki/%5B%ED%99%8D%EC%A7%80%EC%9A%B4%2C-%ED%99%A9%EC%A3%BC%ED%9D%AC%5D-Claude-Code-%EA%B0%9C%EB%85%90%EB%B6%80%ED%84%B0-%ED%99%9C%EC%9A%A9%EA%B9%8C%EC%A7%80-%28Harness%2C-Context%2C-Skills%29) |홍지운, 황주희| |Orval과 n8n을 활용한 OpenAPI 주도 개발 (작성 예정)|홍지운, 황주희| |Web Speech API: 서버 구축 없이 Realtime STT 구현하기 (작성 예정)|홍지운| |PDF.js 기반 대용량 문서 하이라이팅 구현 및 렌더링 이슈 개선(작성 예정)|황주희| @@ -213,9 +213,10 @@ Jira와 GitHub를 동시에 관리해야 하는 수고를 줄이고 팀의 Git ### 📚 기술 아티클 |파트|제목|작성자| |---|---|---| -|BE|질문 카테고리 분류를 위한 클러스터링 배치 로직 설계 과정 (작성 예정)|권찬| +|BE|[질문 카테고리 분류를 위한 클러스터링 배치 로직 설계 과정](https://github.com/softeerbootcamp-7th/WEB-Team4-Refit/wiki/%5B%EA%B6%8C%EC%B0%AC%5D-%EC%A7%88%EB%AC%B8-%EC%B9%B4%ED%85%8C%EA%B3%A0%EB%A6%AC-%EB%B6%84%EC%84%9D%EC%9D%84-%EC%9C%84%ED%95%9C-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0%EB%A7%81-%EB%A1%9C%EC%A7%81-%EC%84%A4%EA%B3%84-%EA%B3%BC%EC%A0%95)|권찬| |BE|면접 기록의 질답 변환이 실패하는 문제의 해결 과정 (작성 예정)|권찬| |BE|rest client와 web client의 성능 비교(작성 예정)|송영범| +|BE|[AWS S3 Presigned URL 무한 업로드 문제 해결](https://github.com/softeerbootcamp-7th/WEB-Team4-Refit/wiki/%5B송영범%5D-S3-Presigned%E2%80%90URL-무한-업로드-문제)|송영범| |BE|벡터 데이터베이스 활용을 위한 객체지향적인 인터페이스 설계 과정 (작성 예정)|이장안| ### 기술 스택 diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/interview/constant/InterviewConstant.java b/backend/src/main/java/com/shyashyashya/refit/domain/interview/constant/InterviewConstant.java index 8736e3f87..85f1d861b 100644 --- a/backend/src/main/java/com/shyashyashya/refit/domain/interview/constant/InterviewConstant.java +++ b/backend/src/main/java/com/shyashyashya/refit/domain/interview/constant/InterviewConstant.java @@ -8,5 +8,5 @@ public final class InterviewConstant { public static final int RAW_TEXT_MAX_LENGTH = 10_000; public static final int KPT_TEXT_MAX_LENGTH = 8_000; - public static final long QNA_SET_CONVERT_RESULT_TIMEOUT_MILLISECONDS = 30_000L; + public static final long QNA_SET_CONVERT_RESULT_TIMEOUT_MILLISECONDS = 180_000L; } diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/RawTextConvertAsyncService.java b/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/RawTextConvertAsyncService.java index f1a717521..64444126e 100644 --- a/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/RawTextConvertAsyncService.java +++ b/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/RawTextConvertAsyncService.java @@ -66,7 +66,7 @@ public void startRawTextConvertAsync(Long interviewId) { GeminiGenerateRequest requestBody = GeminiGenerateRequest.from(prompt); CompletableFuture future = geminiClient.sendAsyncTextGenerateRequest(requestBody, GenerateModel.GEMMA_3_27B_IT); - log.info("request sended"); + log.info("[startRawTextConvertAsync] request sended"); future.thenApplyAsync( response -> { rawTextConvertService.processConvertSuccess(interviewId, response); diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/qnaset/event/QuestionBatchEmbeddingEventHandler.java b/backend/src/main/java/com/shyashyashya/refit/domain/qnaset/event/QuestionBatchEmbeddingEventHandler.java index 7f6d3da3c..24ca71fef 100644 --- a/backend/src/main/java/com/shyashyashya/refit/domain/qnaset/event/QuestionBatchEmbeddingEventHandler.java +++ b/backend/src/main/java/com/shyashyashya/refit/domain/qnaset/event/QuestionBatchEmbeddingEventHandler.java @@ -13,6 +13,7 @@ import com.shyashyashya.refit.global.gemini.dto.GeminiEmbeddingRequest; import com.shyashyashya.refit.global.property.ClusteringProperty; import com.shyashyashya.refit.global.vectordb.model.ScoredVector; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -73,13 +74,36 @@ public void handleQuestionBatchEmbeddingEvent(QuestionBatchEmbeddingEvent event) private List> generateEmbeddings(List qnaSets) { var outputDimensionality = GeminiEmbeddingRequest.OutputDimensionality.fromValue( questionVectorRepository.getCollectionVectorDimension()); - var requests = new GeminiBatchEmbeddingRequest(qnaSets.stream() - .map(qnaSet -> GeminiBatchEmbeddingRequest.GeminiEmbeddingRequest.of( - qnaSet.getQuestionText(), - GeminiEmbeddingRequest.TaskType.SEMANTIC_SIMILARITY, - outputDimensionality)) - .toList()); - return geminiClient.sendAsyncBatchEmbeddingRequest(requests).join().embeddings().stream() + + List embeddings = new ArrayList<>(); + List sendQnaSets = new ArrayList<>(); + for (int i = 0; i < qnaSets.size(); i++) { + sendQnaSets.add(qnaSets.get(i)); + if (sendQnaSets.size() == 100) { + var requests = new GeminiBatchEmbeddingRequest(sendQnaSets.stream() + .map(qnaSet -> GeminiBatchEmbeddingRequest.GeminiEmbeddingRequest.of( + qnaSet.getQuestionText(), + GeminiEmbeddingRequest.TaskType.SEMANTIC_SIMILARITY, + outputDimensionality)) + .toList()); + embeddings.addAll(geminiClient.sendAsyncBatchEmbeddingRequest(requests).join().embeddings().stream() + .toList()); + sendQnaSets.clear(); + } + } + + if (!qnaSets.isEmpty()) { + var requests = new GeminiBatchEmbeddingRequest(sendQnaSets.stream() + .map(qnaSet -> GeminiBatchEmbeddingRequest.GeminiEmbeddingRequest.of( + qnaSet.getQuestionText(), + GeminiEmbeddingRequest.TaskType.SEMANTIC_SIMILARITY, + outputDimensionality)) + .toList()); + embeddings.addAll(geminiClient.sendAsyncBatchEmbeddingRequest(requests).join().embeddings().stream() + .toList()); + } + + return embeddings.stream() .map(GeminiBatchEmbeddingResponse.Embedding::values) .toList(); } diff --git a/backend/src/main/java/com/shyashyashya/refit/global/auth/service/JwtAuthenticationFilter.java b/backend/src/main/java/com/shyashyashya/refit/global/auth/service/JwtAuthenticationFilter.java index 1e8856901..bf2983079 100644 --- a/backend/src/main/java/com/shyashyashya/refit/global/auth/service/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/shyashyashya/refit/global/auth/service/JwtAuthenticationFilter.java @@ -15,6 +15,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.jspecify.annotations.NonNull; +import org.slf4j.MDC; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; @@ -59,6 +60,7 @@ protected void doFilterInternal( requestUserContext.setEmail(email); requestUserContext.setUserId(userId); + MDC.put("userInfo", email); if (isGuestRequest(userId)) { validateGuestRequestNotIllegal(request); diff --git a/backend/src/main/java/com/shyashyashya/refit/global/config/LoggingFilterConfig.java b/backend/src/main/java/com/shyashyashya/refit/global/config/LoggingFilterConfig.java index 9a4d19b42..ed1a0f7d5 100644 --- a/backend/src/main/java/com/shyashyashya/refit/global/config/LoggingFilterConfig.java +++ b/backend/src/main/java/com/shyashyashya/refit/global/config/LoggingFilterConfig.java @@ -1,6 +1,6 @@ package com.shyashyashya.refit.global.config; -import com.shyashyashya.refit.global.filter.LoggingFilter; +import com.shyashyashya.refit.global.filter.ApiLoggingFilter; import lombok.RequiredArgsConstructor; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; @@ -14,9 +14,9 @@ public class LoggingFilterConfig { private final HandlerExceptionResolver handlerExceptionResolver; @Bean - public FilterRegistrationBean loggingFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new LoggingFilter(handlerExceptionResolver)); + public FilterRegistrationBean loggingFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new ApiLoggingFilter(handlerExceptionResolver)); registrationBean.addUrlPatterns("/*"); registrationBean.setOrder(org.springframework.core.Ordered.HIGHEST_PRECEDENCE + 1); // CORS 다음, 인증/보안 필터 전에 실행 return registrationBean; diff --git a/backend/src/main/java/com/shyashyashya/refit/global/config/QuestionEmbeddingAsyncConfig.java b/backend/src/main/java/com/shyashyashya/refit/global/config/QuestionEmbeddingAsyncConfig.java index e32f78aaa..d66579768 100644 --- a/backend/src/main/java/com/shyashyashya/refit/global/config/QuestionEmbeddingAsyncConfig.java +++ b/backend/src/main/java/com/shyashyashya/refit/global/config/QuestionEmbeddingAsyncConfig.java @@ -16,7 +16,7 @@ public Executor embeddingTaskExecutor() { executor.setCorePoolSize(2); executor.setMaxPoolSize(5); executor.setQueueCapacity(100); - executor.setThreadNamePrefix("QuestionEmbedding-Thread-"); + executor.setThreadNamePrefix("qna-embed-"); executor.initialize(); return executor; } diff --git a/backend/src/main/java/com/shyashyashya/refit/global/config/WebClientConfig.java b/backend/src/main/java/com/shyashyashya/refit/global/config/WebClientConfig.java index 7ae13af25..dda8d17ef 100644 --- a/backend/src/main/java/com/shyashyashya/refit/global/config/WebClientConfig.java +++ b/backend/src/main/java/com/shyashyashya/refit/global/config/WebClientConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; @Configuration @@ -11,7 +12,10 @@ public class WebClientConfig { @Bean public WebClient webClient(WebClient.Builder builder) { - return builder.build(); + ExchangeStrategies strategies = ExchangeStrategies.builder() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(2 * 1024 * 1024)) + .build(); + return builder.exchangeStrategies(strategies).build(); } @Bean diff --git a/backend/src/main/java/com/shyashyashya/refit/global/filter/LoggingFilter.java b/backend/src/main/java/com/shyashyashya/refit/global/filter/ApiLoggingFilter.java similarity index 90% rename from backend/src/main/java/com/shyashyashya/refit/global/filter/LoggingFilter.java rename to backend/src/main/java/com/shyashyashya/refit/global/filter/ApiLoggingFilter.java index 09fec06f4..c75d3b7bb 100644 --- a/backend/src/main/java/com/shyashyashya/refit/global/filter/LoggingFilter.java +++ b/backend/src/main/java/com/shyashyashya/refit/global/filter/ApiLoggingFilter.java @@ -17,7 +17,7 @@ @RequiredArgsConstructor @Slf4j -public class LoggingFilter extends OncePerRequestFilter { +public class ApiLoggingFilter extends OncePerRequestFilter { private final HandlerExceptionResolver handlerExceptionResolver; private static final String TRACE_ID = "traceId"; @@ -39,12 +39,14 @@ protected void doFilterInternal( String clientIp = getClientIp(request); String queryString = request.getQueryString(); String decodedQueryString = ""; + MDC.put("userInfo", clientIp); + if (queryString != null && !queryString.isEmpty()) { decodedQueryString = "?" + URLDecoder.decode(queryString, StandardCharsets.UTF_8); } String requestUriWithQuery = uri + decodedQueryString; - log.info("[Request] {} {} (IP: {})", method, requestUriWithQuery, clientIp); + log.info("[Req ] {} {}", method, requestUriWithQuery); long startTime = System.currentTimeMillis(); try { @@ -54,7 +56,8 @@ protected void doFilterInternal( } finally { long duration = System.currentTimeMillis() - startTime; int status = response.getStatus(); - log.info("[Response] HTTP {} ({} {}) - {}ms", status, method, requestUriWithQuery, duration); + log.info("[Resp] HTTP {} ({} {}) - {}ms", status, method, requestUriWithQuery, duration); + MDC.remove("userInfo"); MDC.clear(); } } diff --git a/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiClient.java b/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiClient.java index 2cb9d598d..953191a2b 100644 --- a/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiClient.java +++ b/backend/src/main/java/com/shyashyashya/refit/global/gemini/GeminiClient.java @@ -11,6 +11,7 @@ import java.util.concurrent.CompletableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -68,6 +69,9 @@ public CompletableFuture sendAsyncEmbeddingRequest(Gemi public CompletableFuture sendAsyncBatchEmbeddingRequest( GeminiBatchEmbeddingRequest requestBody) { + log.info( + "[sendAsyncBatchEmbeddingRequest] send generate embedding in batch: request size {}", + requestBody.requests().size()); return webClient .post() .uri(EMBEDDING_BATCH_ENDPOINT) @@ -75,6 +79,11 @@ public CompletableFuture sendAsyncBatchEmbeddingRe .accept(MediaType.APPLICATION_JSON) .bodyValue(requestBody) .retrieve() + .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(String.class) + .flatMap(body -> { + log.error("[sendAsyncBatchEmbeddingRequest] gemini error response : {}", body); + return response.createException(); + })) .bodyToMono(GeminiBatchEmbeddingResponse.class) .timeout(Duration.ofSeconds(geminiProperty.webClientRequestTimeoutSec())) .toFuture(); diff --git a/backend/src/main/resources/application-gemini.yml b/backend/src/main/resources/application-gemini.yml index 546bb37a5..f8dd49b0a 100644 --- a/backend/src/main/resources/application-gemini.yml +++ b/backend/src/main/resources/application-gemini.yml @@ -4,4 +4,4 @@ spring: prompt-path: "${GEMINI_PROMPT_PATH}" rest-client-connect-timeout-sec: 3 rest-client-read-timeout-sec: 30 - web-client-request-timeout-sec: 30 + web-client-request-timeout-sec: 180 diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml index 842c9a85f..c0ae67645 100644 --- a/backend/src/main/resources/logback-spring.xml +++ b/backend/src/main/resources/logback-spring.xml @@ -5,7 +5,7 @@ - %clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%X{traceId:-NO_TRACE}]){yellow} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx} + %clr(%d{MM-dd HH:mm:ss.SS}){faint} %clr(%5p) %clr([%X{traceId:-NO_TRACE}]){yellow} %clr([%-15.-15X{userInfo:-NO_INFO}]){blue} %clr([%13.13t]){faint} %clr(%-26.26logger{26}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx} @@ -13,7 +13,7 @@ logs/refit-application.log - %d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${PID:- } --- [%X{traceId:-NO_TRACE}] [%15.15t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx} + %d{yyyy-MM-dd HH:mm:ss.SS} %5p [%X{traceId:-NO_TRACE}] [%-15.-15X{userInfo:-NO_INFO}] [%13.13t] %-26.26logger{26} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx} diff --git a/frontend/.claude/docs/design-system.md b/frontend/.claude/docs/design-system.md index 2d2f14359..91a72e037 100644 --- a/frontend/.claude/docs/design-system.md +++ b/frontend/.claude/docs/design-system.md @@ -4,6 +4,12 @@ Reusable UI components are in `src/ui/components/` and exported from `src/ui/com - `Button`, `Input`, `Modal`, `Badge`, `Border`, `Checkbox` - `NativeCombobox`, `SearchableCombobox`, `PlainCombobox` + +### 접근성 (A11y) 규칙 + +- `Input`, `NativeCombobox`: `label` prop 전달 시 `useId()`로 자동 생성한 id를 `