From f6d2b5b20fe2258292ff2225f91cf77c1254a485 Mon Sep 17 00:00:00 2001 From: config25 Date: Tue, 7 Apr 2026 14:39:30 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81:=20@Value?= =?UTF-8?q?=20=E2=86=92=20@ConfigurationProperties=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 산재되어 있던 @Value 필드 주입을 타입 안전한 record properties로 통합. 테스트 시 reflection 없이 생성자로 직접 주입할 수 있어 단위 테스트 도입의 사전 작업 역할. 신규 properties (4개, demo-external/.../config/properties): - OpenAiProperties (apiKey, chatEndpoint, model) - YoloProperties (url, 기본값 http://localhost:5000) - LamaProperties (url, 기본값 http://localhost:7870) - AwsS3Properties (bucket) 전환 대상: - GptVisionClient, YoloDetectionClient, LamaInpaintClient, S3ImageStore (demo-external) - ExternalFileClientImpl (demo-external) — bucket 일관성 확보 - OpenAiConfig (demo-api) — RestTemplate interceptor의 apiKey - RoomGenieApplication에 @ConfigurationPropertiesScan 추가 application.properties 정리: - 키 마이그레이션: openai.api.key → openai.api-key openai.api.url2 → openai.chat-endpoint openai.model1 → openai.model - 죽은 키 3개 제거: openai.api.url, openai.model2, openai.api.url.image - main + test 양쪽 모두 동일하게 적용 검증: - ./gradlew compileJava 통과 - ./gradlew test 통과 (Spring context 정상 로드) 비고: - cloud.aws.s3.base-url은 demo-domain의 FileGenerator에서 사용 중이며, 의존 방향(demo-domain ↛ demo-external) 때문에 본 PR 스코프 밖. AwsS3Properties는 우선 bucket만 포함하고 base-url 정리는 후속 PR로 분리. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/team9/demo/RoomGenieApplication.java | 6 ++--- .../java/team9/demo/config/OpenAiConfig.java | 10 +++++--- .../src/main/resources/application.properties | 9 +++---- .../src/test/resources/application.properties | 9 +++---- .../demo/external/ExternalFileClientImpl.java | 12 ++++----- .../demo/external/ai/GptVisionClient.java | 17 +++++-------- .../demo/external/ai/LamaInpaintClient.java | 8 +++--- .../team9/demo/external/ai/S3ImageStore.java | 13 +++++----- .../demo/external/ai/YoloDetectionClient.java | 8 +++--- .../config/properties/AwsS3Properties.java | 14 +++++++++++ .../config/properties/LamaProperties.java | 15 +++++++++++ .../config/properties/OpenAiProperties.java | 25 +++++++++++++++++++ .../config/properties/YoloProperties.java | 15 +++++++++++ 13 files changed, 106 insertions(+), 55 deletions(-) create mode 100644 demo-external/src/main/java/team9/demo/external/config/properties/AwsS3Properties.java create mode 100644 demo-external/src/main/java/team9/demo/external/config/properties/LamaProperties.java create mode 100644 demo-external/src/main/java/team9/demo/external/config/properties/OpenAiProperties.java create mode 100644 demo-external/src/main/java/team9/demo/external/config/properties/YoloProperties.java diff --git a/demo-api/src/main/java/team9/demo/RoomGenieApplication.java b/demo-api/src/main/java/team9/demo/RoomGenieApplication.java index de2a4c1..58c66c3 100644 --- a/demo-api/src/main/java/team9/demo/RoomGenieApplication.java +++ b/demo-api/src/main/java/team9/demo/RoomGenieApplication.java @@ -2,12 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.annotation.ComponentScan; - -import java.util.Arrays; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication +@ConfigurationPropertiesScan("team9.demo.external.config.properties") public class RoomGenieApplication { public static void main(String[] args) { SpringApplication.run(RoomGenieApplication.class, args); diff --git a/demo-api/src/main/java/team9/demo/config/OpenAiConfig.java b/demo-api/src/main/java/team9/demo/config/OpenAiConfig.java index 6915786..9940b33 100644 --- a/demo-api/src/main/java/team9/demo/config/OpenAiConfig.java +++ b/demo-api/src/main/java/team9/demo/config/OpenAiConfig.java @@ -1,23 +1,25 @@ package team9.demo.config; -import org.springframework.beans.factory.annotation.Value; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.MediaType; import org.springframework.web.client.RestTemplate; +import team9.demo.external.config.properties.OpenAiProperties; @Configuration +@RequiredArgsConstructor public class OpenAiConfig { - @Value("${openai.api.key}") - private String openAiApiKey; + + private final OpenAiProperties openAiProperties; @Bean public RestTemplate template() { RestTemplate restTemplate = new RestTemplate(); restTemplate.getInterceptors().add((request, body, execution) -> { request.getHeaders().setContentType(MediaType.APPLICATION_JSON); - request.getHeaders().set("Authorization", "Bearer " + openAiApiKey); + request.getHeaders().set("Authorization", "Bearer " + openAiProperties.apiKey()); return execution.execute(request, body); }); return restTemplate; diff --git a/demo-api/src/main/resources/application.properties b/demo-api/src/main/resources/application.properties index a676f55..7e18faf 100644 --- a/demo-api/src/main/resources/application.properties +++ b/demo-api/src/main/resources/application.properties @@ -1,11 +1,8 @@ spring.config.import=db.yml, optional:file:.env[.properties] -openai.api.key=${OPENAI_API_KEY} -openai.model1=${OPENAI_MODEL1} -openai.model2=${OPENAI_MODEL2} -openai.api.url=${OPENAI_API_URL} -openai.api.url2=${OPENAI_API_URL2} -openai.api.url.image=${OPENAI_API_URL_IMAGE} +openai.api-key=${OPENAI_API_KEY} +openai.model=${OPENAI_MODEL1} +openai.chat-endpoint=${OPENAI_API_URL2} spring.servlet.multipart.max-file-size=50MB spring.servlet.multipart.max-request-size=100MB diff --git a/demo-api/src/test/resources/application.properties b/demo-api/src/test/resources/application.properties index 9ce0ace..788cf50 100644 --- a/demo-api/src/test/resources/application.properties +++ b/demo-api/src/test/resources/application.properties @@ -1,12 +1,9 @@ spring.config.import=db.yml, optional:file:.env[.properties] server.port=8080 -openai.api.key=${OPENAI_API_KEY:test} -openai.model1=${OPENAI_MODEL1:test} -openai.model2=${OPENAI_MODEL2:test} -openai.api.url=${OPENAI_API_URL:http://localhost} -openai.api.url2=${OPENAI_API_URL2:http://localhost} -openai.api.url.image=${OPENAI_API_URL_IMAGE:http://localhost} +openai.api-key=${OPENAI_API_KEY:test} +openai.model=${OPENAI_MODEL1:test} +openai.chat-endpoint=${OPENAI_API_URL2:http://localhost} spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=20MB diff --git a/demo-external/src/main/java/team9/demo/external/ExternalFileClientImpl.java b/demo-external/src/main/java/team9/demo/external/ExternalFileClientImpl.java index cf7f2b7..d4235e5 100644 --- a/demo-external/src/main/java/team9/demo/external/ExternalFileClientImpl.java +++ b/demo-external/src/main/java/team9/demo/external/ExternalFileClientImpl.java @@ -3,10 +3,10 @@ import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.ObjectMetadata; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import team9.demo.error.ConflictException; import team9.demo.error.ErrorCode; +import team9.demo.external.config.properties.AwsS3Properties; import team9.demo.model.media.FileData; import team9.demo.model.media.Media; @@ -16,9 +16,7 @@ public class ExternalFileClientImpl implements ExternalFileClient { private final AmazonS3 amazonS3; - - @Value("${cloud.aws.s3.bucket}") - private String bucket; + private final AwsS3Properties s3Properties; @Override public void uploadFile(FileData file, Media media) { @@ -27,7 +25,7 @@ public void uploadFile(FileData file, Media media) { metadata.setContentLength(file.getSize()); metadata.setContentType(media.getType().value()); - amazonS3.putObject(bucket, media.getPath(), file.getInputStream(), metadata); + amazonS3.putObject(s3Properties.bucket(), media.getPath(), file.getInputStream(), metadata); } catch (Exception e) { throw new ConflictException(ErrorCode.FILE_UPLOAD_FAILED); } @@ -36,13 +34,13 @@ public void uploadFile(FileData file, Media media) { @Override public void removeFile(Media media) { try { - amazonS3.deleteObject(bucket, media.getPath()); + amazonS3.deleteObject(s3Properties.bucket(), media.getPath()); } catch (Exception e) { throw new ConflictException(ErrorCode.FILE_DELETE_FAILED); } } public String getPublicUrl(Media media) { - return amazonS3.getUrl(bucket, media.getPath()).toString(); + return amazonS3.getUrl(s3Properties.bucket(), media.getPath()).toString(); } } diff --git a/demo-external/src/main/java/team9/demo/external/ai/GptVisionClient.java b/demo-external/src/main/java/team9/demo/external/ai/GptVisionClient.java index 6463f79..08ab4ff 100644 --- a/demo-external/src/main/java/team9/demo/external/ai/GptVisionClient.java +++ b/demo-external/src/main/java/team9/demo/external/ai/GptVisionClient.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; @@ -12,6 +11,7 @@ import team9.demo.dto.ChatGPTRequest; import team9.demo.error.AiException; import team9.demo.error.ErrorCode; +import team9.demo.external.config.properties.OpenAiProperties; import team9.demo.model.ai.analysis.ChatResponse; import team9.demo.model.ai.analysis.Choice; import team9.demo.model.ai.analysis.TextMessage; @@ -32,23 +32,18 @@ @RequiredArgsConstructor public class GptVisionClient { - @Value("${openai.api.url2}") - private String chatEndpoint; - - @Value("${openai.model1}") - private String model; - private final RestTemplate restTemplate; private final S3ImageStore s3ImageStore; + private final OpenAiProperties openAiProperties; /** 단일 이미지 분석 — 정리 가이드 텍스트 응답을 반환한다. */ public ChatResponse requestImageAnalysis(String imageUrl, String requestText) { try { String dataUrl = "data:image/jpeg;base64," + s3ImageStore.encodeBase64(imageUrl); - ChatGPTRequest requestDto = ChatGPTRequest.of(model, requestText, dataUrl, 500); + ChatGPTRequest requestDto = ChatGPTRequest.of(openAiProperties.model(), requestText, dataUrl, 500); ResponseEntity> response = restTemplate.exchange( - chatEndpoint, HttpMethod.POST, new HttpEntity<>(requestDto), getMapType() + openAiProperties.chatEndpoint(), HttpMethod.POST, new HttpEntity<>(requestDto), getMapType() ); return parseChatResponse(response); @@ -67,7 +62,7 @@ public ChatResponse requestCompareAnalysis(String beforeUrl, String afterUrl, St String afterBase64 = s3ImageStore.encodeBase64(afterUrl); Map requestBody = Map.of( - "model", model, + "model", openAiProperties.model(), "messages", List.of(Map.of( "role", "user", "content", List.of( @@ -80,7 +75,7 @@ public ChatResponse requestCompareAnalysis(String beforeUrl, String afterUrl, St ); ResponseEntity> response = restTemplate.exchange( - chatEndpoint, HttpMethod.POST, new HttpEntity<>(requestBody), getMapType() + openAiProperties.chatEndpoint(), HttpMethod.POST, new HttpEntity<>(requestBody), getMapType() ); return parseChatResponse(response); diff --git a/demo-external/src/main/java/team9/demo/external/ai/LamaInpaintClient.java b/demo-external/src/main/java/team9/demo/external/ai/LamaInpaintClient.java index 3b69112..d1531c0 100644 --- a/demo-external/src/main/java/team9/demo/external/ai/LamaInpaintClient.java +++ b/demo-external/src/main/java/team9/demo/external/ai/LamaInpaintClient.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -18,6 +17,7 @@ import org.springframework.web.client.RestTemplate; import team9.demo.error.AiException; import team9.demo.error.ErrorCode; +import team9.demo.external.config.properties.LamaProperties; import team9.demo.model.user.UserId; import java.util.List; @@ -34,10 +34,8 @@ @RequiredArgsConstructor public class LamaInpaintClient { - @Value("${ai.lama.url:http://localhost:7870}") - private String lamaServerUrl; - private final S3ImageStore s3ImageStore; + private final LamaProperties lamaProperties; private final RestTemplate lamaClient = new RestTemplate(List.of( new FormHttpMessageConverter(), @@ -57,7 +55,7 @@ public String editImage(byte[] imageBytes, byte[] maskBytes, String prompt, User headers.setContentType(MediaType.MULTIPART_FORM_DATA); ResponseEntity response = lamaClient.exchange( - lamaServerUrl + "/inpaint", + lamaProperties.url() + "/inpaint", HttpMethod.POST, new HttpEntity<>(body, headers), byte[].class diff --git a/demo-external/src/main/java/team9/demo/external/ai/S3ImageStore.java b/demo-external/src/main/java/team9/demo/external/ai/S3ImageStore.java index 4b9bb52..559f218 100644 --- a/demo-external/src/main/java/team9/demo/external/ai/S3ImageStore.java +++ b/demo-external/src/main/java/team9/demo/external/ai/S3ImageStore.java @@ -5,10 +5,10 @@ import com.amazonaws.services.s3.model.S3Object; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import team9.demo.error.AiException; import team9.demo.error.ErrorCode; +import team9.demo.external.config.properties.AwsS3Properties; import team9.demo.model.user.UserId; import java.io.ByteArrayInputStream; @@ -32,14 +32,12 @@ public class S3ImageStore { private final AmazonS3 amazonS3; - - @Value("${cloud.aws.s3.bucket}") - private String bucket; + private final AwsS3Properties s3Properties; /** S3 오브젝트를 다운로드해 byte 배열로 반환한다. */ public byte[] download(String imageUrl) throws IOException { String key = extractKey(imageUrl); - S3Object object = amazonS3.getObject(bucket, key); + S3Object object = amazonS3.getObject(s3Properties.bucket(), key); try (InputStream inputStream = object.getObjectContent()) { return inputStream.readAllBytes(); } @@ -59,13 +57,13 @@ public String uploadCleanedImage(byte[] image, UserId userId) { metadata.setContentType("image/png"); try (InputStream inputStream = new ByteArrayInputStream(image)) { - amazonS3.putObject(bucket, key, inputStream, metadata); + amazonS3.putObject(s3Properties.bucket(), key, inputStream, metadata); } catch (IOException e) { log.error("S3 업로드 실패: {}", e.getMessage(), e); throw new AiException(ErrorCode.AI_S3_UPLOAD_FAILED); } - return amazonS3.getUrl(bucket, key).toString(); + return amazonS3.getUrl(s3Properties.bucket(), key).toString(); } /** S3 URL(path-style / virtual-hosted style)에서 object key를 추출한다. */ @@ -73,6 +71,7 @@ private String extractKey(String imageUrl) throws IOException { URL url = new URL(imageUrl); String host = url.getHost(); String path = url.getPath(); + String bucket = s3Properties.bucket(); if (path.startsWith("/" + bucket + "/")) { return path.substring(("/" + bucket + "/").length()); diff --git a/demo-external/src/main/java/team9/demo/external/ai/YoloDetectionClient.java b/demo-external/src/main/java/team9/demo/external/ai/YoloDetectionClient.java index 21e9073..19f4f16 100644 --- a/demo-external/src/main/java/team9/demo/external/ai/YoloDetectionClient.java +++ b/demo-external/src/main/java/team9/demo/external/ai/YoloDetectionClient.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -12,6 +11,7 @@ import org.springframework.web.client.RestTemplate; import team9.demo.error.AiException; import team9.demo.error.ErrorCode; +import team9.demo.external.config.properties.YoloProperties; import team9.demo.model.ai.mask.Box; import java.util.Collections; @@ -28,10 +28,8 @@ @RequiredArgsConstructor public class YoloDetectionClient { - @Value("${ai.yolo.url:http://localhost:5000}") - private String yoloServerUrl; - private final RestTemplate restTemplate; + private final YoloProperties yoloProperties; public List detectClutterBoxes(byte[] imageBytes) { try { @@ -39,7 +37,7 @@ public List detectClutterBoxes(byte[] imageBytes) { headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); ResponseEntity response = restTemplate.exchange( - yoloServerUrl + "/yolo", + yoloProperties.url() + "/yolo", HttpMethod.POST, new HttpEntity<>(imageBytes, headers), List.class diff --git a/demo-external/src/main/java/team9/demo/external/config/properties/AwsS3Properties.java b/demo-external/src/main/java/team9/demo/external/config/properties/AwsS3Properties.java new file mode 100644 index 0000000..4e78278 --- /dev/null +++ b/demo-external/src/main/java/team9/demo/external/config/properties/AwsS3Properties.java @@ -0,0 +1,14 @@ +package team9.demo.external.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * AWS S3 버킷 설정. + * + * @param bucket S3 버킷 이름 (application.properties: cloud.aws.s3.bucket) + */ +@ConfigurationProperties(prefix = "cloud.aws.s3") +public record AwsS3Properties( + String bucket +) { +} diff --git a/demo-external/src/main/java/team9/demo/external/config/properties/LamaProperties.java b/demo-external/src/main/java/team9/demo/external/config/properties/LamaProperties.java new file mode 100644 index 0000000..d0bf245 --- /dev/null +++ b/demo-external/src/main/java/team9/demo/external/config/properties/LamaProperties.java @@ -0,0 +1,15 @@ +package team9.demo.external.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * 자체 호스팅 LAMA 인페인팅 서버 설정. + * + * @param url LAMA 서버 base URL (application.properties: ai.lama.url) + */ +@ConfigurationProperties(prefix = "ai.lama") +public record LamaProperties( + @DefaultValue("http://localhost:7870") String url +) { +} diff --git a/demo-external/src/main/java/team9/demo/external/config/properties/OpenAiProperties.java b/demo-external/src/main/java/team9/demo/external/config/properties/OpenAiProperties.java new file mode 100644 index 0000000..c567619 --- /dev/null +++ b/demo-external/src/main/java/team9/demo/external/config/properties/OpenAiProperties.java @@ -0,0 +1,25 @@ +package team9.demo.external.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * OpenAI 호출에 필요한 설정. + *

+ * Spring Boot relaxed binding으로 application.properties의 다음 키와 매핑된다: + *

+ *   openai.api-key       → apiKey
+ *   openai.chat-endpoint → chatEndpoint
+ *   openai.model         → model
+ * 
+ * + * @param apiKey Bearer 토큰 (RestTemplate interceptor에서 사용) + * @param chatEndpoint chat/completions 엔드포인트 + * @param model 사용할 모델 식별자 (예: gpt-4-vision-preview) + */ +@ConfigurationProperties(prefix = "openai") +public record OpenAiProperties( + String apiKey, + String chatEndpoint, + String model +) { +} diff --git a/demo-external/src/main/java/team9/demo/external/config/properties/YoloProperties.java b/demo-external/src/main/java/team9/demo/external/config/properties/YoloProperties.java new file mode 100644 index 0000000..b960319 --- /dev/null +++ b/demo-external/src/main/java/team9/demo/external/config/properties/YoloProperties.java @@ -0,0 +1,15 @@ +package team9.demo.external.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * 자체 호스팅 YOLO 감지 서버 설정. + * + * @param url YOLO 서버 base URL (application.properties: ai.yolo.url) + */ +@ConfigurationProperties(prefix = "ai.yolo") +public record YoloProperties( + @DefaultValue("http://localhost:5000") String url +) { +}