From 7c36000727c33d11c5d5a108afc1a36636774f77 Mon Sep 17 00:00:00 2001 From: leedy5521 Date: Mon, 23 Feb 2026 20:47:53 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20cloth-ai=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85=20=EB=B3=B4=EC=99=84=20=EB=B0=8F=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloth/service/ClothAiServiceImpl.java | 430 ++++++++++++------ .../java/org/clokey/util/WebClientUtil.java | 58 ++- 2 files changed, 346 insertions(+), 142 deletions(-) diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java index 4b3b799b..f8f52580 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java @@ -3,8 +3,10 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.clokey.category.entity.Category; import org.clokey.cloth.enums.Season; import org.clokey.domain.category.exception.CategoryErrorCode; @@ -43,8 +45,11 @@ // FIXME: 현재는 Tomcat Thread Pool을 점유하고 있는 비효율적인 구조이기 때문에 나중에 비동기 처리를 통해 트래픽이 생길 경우 최적화가 필요합니다. @Service @RequiredArgsConstructor +@Slf4j public class ClothAiServiceImpl implements ClothAiService { + private static final long SLOW_REQUEST_THRESHOLD_MS = 3000L; + private final MemberUtil memberUtil; private final CategoryRepository categoryRepository; private final S3Util s3Util; @@ -74,94 +79,129 @@ public ClothImagesPresignedUrlResponse getClothUploadPresignedUrls( @Override public ClothInfoExtractResponse extractClothInfo(ClothInfoExtractRequest request) { final Member currentMember = memberUtil.getCurrentMember(); + final Long memberId = currentMember.getId(); final List clothImageUrls = request.clothImageUrls(); + final long startedAtNs = System.nanoTime(); + long validationMs = 0L; + long presignMs = 0L; + long aiCallMs = 0L; + long postProcessMs = 0L; + String errorCode = null; - validateImageUrls(clothImageUrls); - - // AI Server에게 N개의 사진을 전처리한 후 업로드할 수 있는 presignedUrl을 넘겨줍니다. - List presignedUrls = - createPresignedUrls(currentMember.getId(), clothImageUrls.size()); - - ClothInfoExtractAiResponseDTO aiResponse; try { - aiResponse = - webClientUtil - .postToAiServer( - webClientProperties.clothInferencePath(), - new ClothInfoExtractAiRequestDTO(clothImageUrls, presignedUrls), - ClothInfoExtractAiResponseDTO.class) - .block(); - } catch (Exception e) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); - } - - if (aiResponse == null) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); - } - - if (!Boolean.TRUE.equals(aiResponse.isSuccess())) { - throw new BaseCustomException(mapAiErrorCode(aiResponse.errorCode())); - } - - if (aiResponse.result() == null || aiResponse.result().isEmpty()) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); - } - - if (aiResponse.result().size() != clothImageUrls.size()) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_RESULT_MISMATCH); - } - - List resultItems = aiResponse.result(); - List payloads = - new java.util.ArrayList<>(resultItems.size()); - - Set categoryIds = - resultItems.stream() - .map(ClothInfoExtractAiResponseDTO.ResultItem::categories) - .filter(categories -> categories != null && !categories.isEmpty()) - .map(categories -> categories.get(0).id()) - .collect(Collectors.toSet()); - - Map categoryMap = - categoryRepository.findAllByIdWithParent(categoryIds).stream() - .collect(Collectors.toMap(Category::getId, c -> c)); - - for (int i = 0; i < resultItems.size(); i++) { - ClothInfoExtractAiResponseDTO.ResultItem resultItem = resultItems.get(i); - String clothImageUrl = resultItem.uploadedUrl(); + long phaseStartedAtNs = System.nanoTime(); + validateImageUrls(clothImageUrls); + validationMs = elapsedMillis(phaseStartedAtNs); + + // AI Server에게 N개의 사진을 전처리한 후 업로드할 수 있는 presignedUrl을 넘겨줍니다. + phaseStartedAtNs = System.nanoTime(); + List presignedUrls = createPresignedUrls(memberId, clothImageUrls.size()); + presignMs = elapsedMillis(phaseStartedAtNs); + + ClothInfoExtractAiResponseDTO aiResponse; + try { + phaseStartedAtNs = System.nanoTime(); + aiResponse = + webClientUtil + .postToAiServer( + webClientProperties.clothInferencePath(), + new ClothInfoExtractAiRequestDTO( + clothImageUrls, presignedUrls), + ClothInfoExtractAiResponseDTO.class) + .block(); + } catch (Exception e) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); + } finally { + aiCallMs = elapsedMillis(phaseStartedAtNs); + } - List categories = resultItem.categories(); - if (categories == null || categories.isEmpty()) { - throw new BaseCustomException(ClothErrorCode.ClOTH_NOT_FOUND); + if (aiResponse == null) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); } - ClothInfoExtractAiResponseDTO.CategoryItem categoryItem = categories.get(0); - Category category = categoryMap.get(categoryItem.id()); - if (category == null) { - throw new BaseCustomException(CategoryErrorCode.CATEGORY_NOT_FOUND); + + if (!Boolean.TRUE.equals(aiResponse.isSuccess())) { + ClothAiErrorCode mappedErrorCode = mapAiErrorCode(aiResponse.errorCode()); + throw new BaseCustomException(mappedErrorCode); } - Category parentCategory = category.getParent(); - List seasonItems = resultItem.seasons(); - if (seasonItems == null || seasonItems.isEmpty()) { - throw new BaseCustomException(ClothErrorCode.ClOTH_NOT_FOUND); + if (aiResponse.result() == null || aiResponse.result().isEmpty()) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); } - List seasons = new java.util.ArrayList<>(seasonItems.size()); - for (ClothInfoExtractAiResponseDTO.SeasonItem seasonItem : seasonItems) { - seasons.add(convertSeasonNameToEnum(seasonItem.name())); + if (aiResponse.result().size() != clothImageUrls.size()) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_RESULT_MISMATCH); } - payloads.add( - new ClothInfoExtractResponse.Payload( - clothImageUrl, - seasons, - parentCategory != null ? parentCategory.getId() : null, - parentCategory != null ? parentCategory.getName() : null, - category.getId(), - category.getName())); + phaseStartedAtNs = System.nanoTime(); + List resultItems = aiResponse.result(); + List payloads = + new java.util.ArrayList<>(resultItems.size()); + + Set categoryIds = + resultItems.stream() + .map(ClothInfoExtractAiResponseDTO.ResultItem::categories) + .filter(categories -> categories != null && !categories.isEmpty()) + .map(categories -> categories.get(0).id()) + .collect(Collectors.toSet()); + + Map categoryMap = + categoryRepository.findAllByIdWithParent(categoryIds).stream() + .collect(Collectors.toMap(Category::getId, c -> c)); + + for (int i = 0; i < resultItems.size(); i++) { + ClothInfoExtractAiResponseDTO.ResultItem resultItem = resultItems.get(i); + String clothImageUrl = resultItem.uploadedUrl(); + + List categories = + resultItem.categories(); + if (categories == null || categories.isEmpty()) { + throw new BaseCustomException(ClothErrorCode.ClOTH_NOT_FOUND); + } + ClothInfoExtractAiResponseDTO.CategoryItem categoryItem = categories.get(0); + Category category = categoryMap.get(categoryItem.id()); + if (category == null) { + throw new BaseCustomException(CategoryErrorCode.CATEGORY_NOT_FOUND); + } + Category parentCategory = category.getParent(); + + List seasonItems = resultItem.seasons(); + if (seasonItems == null || seasonItems.isEmpty()) { + throw new BaseCustomException(ClothErrorCode.ClOTH_NOT_FOUND); + } + + List seasons = new java.util.ArrayList<>(seasonItems.size()); + for (ClothInfoExtractAiResponseDTO.SeasonItem seasonItem : seasonItems) { + seasons.add(convertSeasonNameToEnum(seasonItem.name())); + } + + payloads.add( + new ClothInfoExtractResponse.Payload( + clothImageUrl, + seasons, + parentCategory != null ? parentCategory.getId() : null, + parentCategory != null ? parentCategory.getName() : null, + category.getId(), + category.getName())); + } + postProcessMs = elapsedMillis(phaseStartedAtNs); + + ClothInfoExtractResponse response = ClothInfoExtractResponse.of(payloads); + return response; + } catch (BaseCustomException e) { + errorCode = e.getErrorReasonDto().code(); + throw e; + } finally { + logClothAiObservation( + "extractClothInfo", + memberId, + clothImageUrls.size(), + elapsedMillis(startedAtNs), + validationMs, + presignMs, + aiCallMs, + postProcessMs, + errorCode); } - - return ClothInfoExtractResponse.of(payloads); } private Season convertSeasonNameToEnum(String seasonName) { @@ -176,89 +216,157 @@ private Season convertSeasonNameToEnum(String seasonName) { @Override public HistoryStyleInferenceResponse inferHistoryStyle(HistoryStyleInferenceRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + final Long memberId = currentMember.getId(); final String historyImageUrl = request.historyImageUrl(); + final long startedAtNs = System.nanoTime(); + long validationMs = 0L; + long aiCallMs = 0L; + long postProcessMs = 0L; + String errorCode = null; - validateImageUrl(historyImageUrl); - - HistoryStyleInferenceAiResponseDTO aiResponse; try { - aiResponse = - webClientUtil - .postToAiServer( - webClientProperties.styleInferencePath(), - new HistoryStyleInferenceAiRequestDTO(historyImageUrl), - HistoryStyleInferenceAiResponseDTO.class) - .block(); - } catch (Exception e) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); - } + long phaseStartedAtNs = System.nanoTime(); + validateImageUrl(historyImageUrl); + validationMs = elapsedMillis(phaseStartedAtNs); + + HistoryStyleInferenceAiResponseDTO aiResponse; + try { + phaseStartedAtNs = System.nanoTime(); + aiResponse = + webClientUtil + .postToAiServer( + webClientProperties.styleInferencePath(), + new HistoryStyleInferenceAiRequestDTO(historyImageUrl), + HistoryStyleInferenceAiResponseDTO.class) + .block(); + } catch (Exception e) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); + } finally { + aiCallMs = elapsedMillis(phaseStartedAtNs); + } - if (aiResponse.result() == null) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); - } + if (aiResponse == null || aiResponse.result() == null) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); + } - HistoryStyleInferenceAiResponseDTO.Result result = aiResponse.result(); + phaseStartedAtNs = System.nanoTime(); + HistoryStyleInferenceAiResponseDTO.Result result = aiResponse.result(); - if (result.situations() == null || result.situations().isEmpty()) { - throw new BaseCustomException(SituationErrorCode.SITUATION_NOT_FOUND); - } - HistoryStyleInferenceAiResponseDTO.SituationItem situationItem = result.situations().get(0); - - if (result.styles() == null || result.styles().isEmpty()) { - throw new BaseCustomException(StyleErrorCode.STYLE_NOT_FOUND); - } + if (result.situations() == null || result.situations().isEmpty()) { + throw new BaseCustomException(SituationErrorCode.SITUATION_NOT_FOUND); + } + HistoryStyleInferenceAiResponseDTO.SituationItem situationItem = + result.situations().get(0); - List styles = - result.styles().stream() - .map( - style -> - new HistoryStyleInferenceResponse.StylePayload( - style.id(), style.name())) - .toList(); + if (result.styles() == null || result.styles().isEmpty()) { + throw new BaseCustomException(StyleErrorCode.STYLE_NOT_FOUND); + } - return HistoryStyleInferenceResponse.of(situationItem.id(), situationItem.name(), styles); + List styles = + result.styles().stream() + .map( + style -> + new HistoryStyleInferenceResponse.StylePayload( + style.id(), style.name())) + .toList(); + postProcessMs = elapsedMillis(phaseStartedAtNs); + + HistoryStyleInferenceResponse response = + HistoryStyleInferenceResponse.of( + situationItem.id(), situationItem.name(), styles); + return response; + } catch (BaseCustomException e) { + errorCode = e.getErrorReasonDto().code(); + throw e; + } finally { + logClothAiObservation( + "inferHistoryStyle", + memberId, + 1, + elapsedMillis(startedAtNs), + validationMs, + 0L, + aiCallMs, + postProcessMs, + errorCode); + } } @Override public ClothDetectResponse detectClothes(ClothDetectRequest request) { final Member currentMember = memberUtil.getCurrentMember(); + final Long memberId = currentMember.getId(); final String imageUrl = request.imageUrl(); + final long startedAtNs = System.nanoTime(); + long validationMs = 0L; + long presignMs = 0L; + long aiCallMs = 0L; + long postProcessMs = 0L; + String errorCode = null; - validateImageUrl(imageUrl); - - List presignedUrls = createPresignedUrls(currentMember.getId(), 10); - - ClothDetectAiResponseDTO aiResponse; try { - aiResponse = - webClientUtil - .postToAiServer( - webClientProperties.clothDetectPath(), - new ClothDetectAiRequestDTO(imageUrl, presignedUrls), - ClothDetectAiResponseDTO.class) - .block(); - } catch (Exception e) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); - } - - if (aiResponse == null) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); - } + long phaseStartedAtNs = System.nanoTime(); + validateImageUrl(imageUrl); + validationMs = elapsedMillis(phaseStartedAtNs); + + phaseStartedAtNs = System.nanoTime(); + List presignedUrls = createPresignedUrls(memberId, 10); + presignMs = elapsedMillis(phaseStartedAtNs); + + ClothDetectAiResponseDTO aiResponse; + try { + phaseStartedAtNs = System.nanoTime(); + aiResponse = + webClientUtil + .postToAiServer( + webClientProperties.clothDetectPath(), + new ClothDetectAiRequestDTO(imageUrl, presignedUrls), + ClothDetectAiResponseDTO.class) + .block(); + } catch (Exception e) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); + } finally { + aiCallMs = elapsedMillis(phaseStartedAtNs); + } - if (!Boolean.TRUE.equals(aiResponse.isSuccess())) { - throw new BaseCustomException(mapAiErrorCode(aiResponse.errorCode())); - } + if (aiResponse == null) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); + } - if (aiResponse.result() == null || aiResponse.result().uploadedUrls() == null) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); - } + if (!Boolean.TRUE.equals(aiResponse.isSuccess())) { + ClothAiErrorCode mappedErrorCode = mapAiErrorCode(aiResponse.errorCode()); + throw new BaseCustomException(mappedErrorCode); + } - List payloads = - aiResponse.result().uploadedUrls().stream() - .map(ClothDetectResponse.Payload::new) - .toList(); + if (aiResponse.result() == null || aiResponse.result().uploadedUrls() == null) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); + } - return ClothDetectResponse.of(payloads); + phaseStartedAtNs = System.nanoTime(); + List payloads = + aiResponse.result().uploadedUrls().stream() + .map(ClothDetectResponse.Payload::new) + .toList(); + postProcessMs = elapsedMillis(phaseStartedAtNs); + + ClothDetectResponse response = ClothDetectResponse.of(payloads); + return response; + } catch (BaseCustomException e) { + errorCode = e.getErrorReasonDto().code(); + throw e; + } finally { + logClothAiObservation( + "detectClothes", + memberId, + 1, + elapsedMillis(startedAtNs), + validationMs, + presignMs, + aiCallMs, + postProcessMs, + errorCode); + } } private void validateImageUrls(List imageUrls) { @@ -299,4 +407,48 @@ private List createPresignedUrls(Long memberId, int count) { ImageType.CLOTH_IMAGE, memberId, FileExtension.JPEG)) .toList(); } + + private void logClothAiObservation( + String operation, + Long memberId, + int itemCount, + long totalMs, + long validationMs, + long presignMs, + long aiCallMs, + long postProcessMs, + String errorCode) { + if (errorCode != null) { + log.warn( + "[cloth-ai] {} 실패 - memberId: {}, itemCount: {}, errorCode: {}, totalMs: {}, validationMs: {}, presignMs: {}, aiCallMs: {}, postProcessMs: {}", + operation, + memberId, + itemCount, + errorCode, + totalMs, + validationMs, + presignMs, + aiCallMs, + postProcessMs); + return; + } + + if (totalMs >= SLOW_REQUEST_THRESHOLD_MS) { + log.warn( + "[cloth-ai] {} 지연 감지 - memberId: {}, itemCount: {}, totalMs: {}, validationMs: {}, presignMs: {}, aiCallMs: {}, postProcessMs: {}", + operation, + memberId, + itemCount, + totalMs, + validationMs, + presignMs, + aiCallMs, + postProcessMs); + return; + } + } + + private long elapsedMillis(long startedAtNs) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNs); + } } diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/WebClientUtil.java b/clokey-infrastructure/src/main/java/org/clokey/util/WebClientUtil.java index 95382964..341c4100 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/util/WebClientUtil.java +++ b/clokey-infrastructure/src/main/java/org/clokey/util/WebClientUtil.java @@ -1,10 +1,13 @@ package org.clokey.util; +import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.clokey.properties.WebClientProperties; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; @Component @@ -16,8 +19,9 @@ public class WebClientUtil { private final WebClientProperties webClientProperties; public Mono postToAiServer(String path, T requestBody, Class responseType) { - WebClient webClient = - webClientBuilder.baseUrl("http://" + webClientProperties.aiServerIp()).build(); + final long startedAtNs = System.nanoTime(); + final String aiServerIp = webClientProperties.aiServerIp(); + WebClient webClient = webClientBuilder.baseUrl("http://" + aiServerIp).build(); return webClient .post() @@ -25,6 +29,54 @@ public Mono postToAiServer(String path, T requestBody, Class respon .bodyValue(requestBody) .retrieve() .bodyToMono(responseType) - .doOnError(error -> log.error("AI 서버 요청 실패: {}", error.getMessage(), error)); + .doOnError( + error -> + logAiCallFailure( + path, + aiServerIp, + responseType.getSimpleName(), + elapsedMillis(startedAtNs), + error)); + } + + private void logAiCallFailure( + String path, String aiServerIp, String responseType, long elapsedMs, Throwable error) { + if (error instanceof WebClientResponseException webClientResponseException) { + log.error( + "[web-client] AI 서버 응답 오류 - path: {}, aiServerIp: {}, responseType: {}, status: {}, elapsedMs: {}, message: {}", + path, + aiServerIp, + responseType, + webClientResponseException.getStatusCode().value(), + elapsedMs, + webClientResponseException.getMessage(), + error); + return; + } + + if (error instanceof WebClientRequestException) { + log.error( + "[web-client] AI 서버 요청/연결 실패 - path: {}, aiServerIp: {}, responseType: {}, elapsedMs: {}, message: {}", + path, + aiServerIp, + responseType, + elapsedMs, + error.getMessage(), + error); + return; + } + + log.error( + "[web-client] AI 서버 예상치 못한 오류 - path: {}, aiServerIp: {}, responseType: {}, elapsedMs: {}, message: {}", + path, + aiServerIp, + responseType, + elapsedMs, + error.getMessage(), + error); + } + + private long elapsedMillis(long startedAtNs) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNs); } }