diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e27f4ff6..e84714d1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @yongjun0511 @Ssamssamukja @2ghrms @juuuuone @dev2yup +* @Ssamssamukja @2ghrms @juuuuone @dev2yup diff --git a/clokey-api/Dockerfile b/clokey-api/Dockerfile index 359aaebc..c8c99a3d 100644 --- a/clokey-api/Dockerfile +++ b/clokey-api/Dockerfile @@ -5,6 +5,7 @@ RUN gradle :clokey-api:build -x test --no-daemon FROM eclipse-temurin:21-jdk-jammy WORKDIR /app +ENV TZ=Asia/Seoul COPY --from=builder /app/clokey-api/build/libs/*.jar app.jar EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "app.jar"] diff --git a/clokey-api/dev-compose.yml b/clokey-api/dev-compose.yml index 222a034c..46bc5775 100644 --- a/clokey-api/dev-compose.yml +++ b/clokey-api/dev-compose.yml @@ -9,6 +9,7 @@ services: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: dev + TZ: Asia/Seoul # Database DEV_MYSQL_HOST: ${DEV_MYSQL_HOST} diff --git a/clokey-api/prod-compose.yml b/clokey-api/prod-compose.yml index c8b3c9b6..5b7185de 100644 --- a/clokey-api/prod-compose.yml +++ b/clokey-api/prod-compose.yml @@ -9,6 +9,7 @@ services: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: prod + TZ: Asia/Seoul # Database PROD_MYSQL_HOST: ${PROD_MYSQL_HOST} diff --git a/clokey-api/src/main/java/org/clokey/domain/auth/util/UniqueUtil.java b/clokey-api/src/main/java/org/clokey/domain/auth/util/UniqueUtil.java index c593e2dd..845bdec5 100644 --- a/clokey-api/src/main/java/org/clokey/domain/auth/util/UniqueUtil.java +++ b/clokey-api/src/main/java/org/clokey/domain/auth/util/UniqueUtil.java @@ -7,6 +7,7 @@ @Component public class UniqueUtil { + private static final int MAX_NICKNAME_LENGTH = 20; private static final String[] PREFIX_NAMES = {"미니멀한", "모던한", "케주얼한", "스트릿한"}; private static final String[] CLOTHING_CATEGORIES = { @@ -22,9 +23,23 @@ public String generateRandomNickname() { String prefix = PREFIX_NAMES[random.nextInt(PREFIX_NAMES.length)]; String category = CLOTHING_CATEGORIES[random.nextInt(CLOTHING_CATEGORIES.length)]; + String nicknamePrefix = prefix + "_" + category + "_"; - String uuidPart = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + int suffixLength = MAX_NICKNAME_LENGTH - nicknamePrefix.length(); + if (suffixLength <= 0) { + return nicknamePrefix.substring(0, MAX_NICKNAME_LENGTH); + } - return prefix + "-" + category + "-" + uuidPart; + return nicknamePrefix + generateSuffix(suffixLength); + } + + private String generateSuffix(int length) { + StringBuilder suffix = new StringBuilder(length); + + while (suffix.length() < length) { + suffix.append(UUID.randomUUID().toString().replace("-", "")); + } + + return suffix.substring(0, length); } } 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-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java index 3260c383..4ca32f01 100644 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java @@ -1,6 +1,7 @@ package org.clokey.domain.coordinate.service; import java.time.LocalDate; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.function.Function; @@ -45,6 +46,8 @@ @Transactional(readOnly = true) public class CoordinateServiceImpl implements CoordinateService { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final MemberUtil memberUtil; private final CoordinateRepository coordinateRepository; @@ -89,7 +92,7 @@ public CoordinateCreateResponse createDailyCoordinate(DailyCoordinateCreateReque validateExceedingCoordinationClothesLimit(request.payloads()); validateDuplicatedClothes(clothes); validateAllClothesOwnership(currentMember, clothes); - validateDailyCoordinateExist(currentMember.getId(), LocalDate.now()); + validateDailyCoordinateExist(currentMember.getId(), LocalDate.now(KST)); Coordinate coordinate = Coordinate.createDailyCoordinate(request.coordinateImageUrl(), currentMember); @@ -522,7 +525,7 @@ private Coordinate getCoordinateById(Long coordinateId) { private Coordinate getTodayDailyCoordinate(Member member) { return coordinateRepository - .findDailyCoordinateByDateAndMemberId(LocalDate.now(), member.getId()) + .findDailyCoordinateByDateAndMemberId(LocalDate.now(KST), member.getId()) .orElseThrow( () -> new BaseCustomException( diff --git a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java index 36229e63..856f8d3e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java @@ -1,6 +1,7 @@ package org.clokey.domain.history.service; import java.time.LocalDate; +import java.time.ZoneId; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -48,6 +49,8 @@ @Transactional(readOnly = true) public class HistoryServiceImpl implements HistoryService { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final MemberUtil memberUtil; private final HistoryRepository historyRepository; @@ -90,7 +93,7 @@ public HistoryCreateResponse createHistory(HistoryCreateRequest request) { final String content = Optional.ofNullable(request.content()).map(String::trim).orElse(null); final History history = - History.createHistory(LocalDate.now(), content, currentMember, situation); + History.createHistory(LocalDate.now(KST), content, currentMember, situation); historyRepository.save(history); List images = new ArrayList<>(); diff --git a/clokey-api/src/main/java/org/clokey/domain/member/batch/InactiveMemberDeletionBatch.java b/clokey-api/src/main/java/org/clokey/domain/member/batch/InactiveMemberDeletionBatch.java index 1dacdf25..b0ab3dc3 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/batch/InactiveMemberDeletionBatch.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/batch/InactiveMemberDeletionBatch.java @@ -1,6 +1,7 @@ package org.clokey.domain.member.batch; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,13 +18,15 @@ @Slf4j public class InactiveMemberDeletionBatch { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final MemberRepository memberRepository; private final AuthService authService; - @Scheduled(cron = "0 0 0 * * *") // 매일 00:00:00 + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") // 매일 00:00:00 KST @Transactional public void deleteInactiveMembers() { - LocalDateTime cutoffDate = LocalDateTime.now().minusDays(15); + LocalDateTime cutoffDate = LocalDateTime.now(KST).minusDays(15); List inactiveMembers = memberRepository.findInactiveMembersBefore(MemberStatus.INACTIVE, cutoffDate); diff --git a/clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedNicknameCheckRequest.java b/clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedNicknameCheckRequest.java index 5c6f3a7d..a0cafc50 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedNicknameCheckRequest.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedNicknameCheckRequest.java @@ -9,7 +9,7 @@ public record DuplicatedNicknameCheckRequest( @NotBlank(message = "닉네임은 비워둘 수 없습니다.") @Size(max = 20, message = "닉네임은 20자 이하여야 합니다.") @Pattern( - regexp = "^[a-z가-힣._]+$", - message = "닉네임은 영어 소문자, 한글, 언더바(_), 점(.)만 허용됩니다.") + regexp = "^[a-z0-9가-힣._]+$", + message = "닉네임은 영어 소문자, 숫자, 한글, 언더바(_), 점(.)만 허용됩니다.") @Schema(description = "중복을 확인할 닉네임", example = "clokey.홍길동") String nickname) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/member/dto/request/ProfileUpdateRequest.java b/clokey-api/src/main/java/org/clokey/domain/member/dto/request/ProfileUpdateRequest.java index db3d0b43..f56b54f0 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/dto/request/ProfileUpdateRequest.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/dto/request/ProfileUpdateRequest.java @@ -3,11 +3,17 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import org.clokey.member.enums.Visibility; public record ProfileUpdateRequest( - @NotBlank(message = "닉네임은 비워둘 수 없습니다.") @Schema(description = "사용자의 닉네임", example = "juwon") + @NotBlank(message = "닉네임은 비워둘 수 없습니다.") + @Size(max = 20, message = "닉네임은 20자 이하여야 합니다.") + @Pattern( + regexp = "^[a-z0-9가-힣._]+$", + message = "닉네임은 영어 소문자, 숫자, 한글, 언더바(_), 점(.)만 허용됩니다.") + @Schema(description = "사용자의 닉네임", example = "juwon") String nickname, @Schema(description = "사용자의 한줄 소개", example = "한줄 소개") @Size(max = 100, message = "바이오는 100자를 넘길 수 없습니다.") diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/repository/CodiveNotificationRepositoryImpl.java b/clokey-api/src/main/java/org/clokey/domain/notification/repository/CodiveNotificationRepositoryImpl.java index b9a7e379..72748e9f 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/repository/CodiveNotificationRepositoryImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/repository/CodiveNotificationRepositoryImpl.java @@ -6,6 +6,7 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import lombok.RequiredArgsConstructor; import org.clokey.domain.notification.dto.response.NotificationListResponse; @@ -19,6 +20,8 @@ @RequiredArgsConstructor public class CodiveNotificationRepositoryImpl implements CodiveNotificationRepositoryCustom { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final JPAQueryFactory queryFactory; @Override @@ -56,7 +59,7 @@ public void updateAllReadStatusByMemberId(Long memberId) { queryFactory .update(codiveNotification) .set(codiveNotification.readStatus, ReadStatus.READ) - .set(codiveNotification.updatedAt, LocalDateTime.now()) + .set(codiveNotification.updatedAt, LocalDateTime.now(KST)) .where( codiveNotification.member.id.eq(memberId), codiveNotification.readStatus.eq(ReadStatus.NOT_READ)) diff --git a/clokey-api/src/main/resources/application-dev.yml b/clokey-api/src/main/resources/application-dev.yml index 2c749b0b..a0fb8b14 100644 --- a/clokey-api/src/main/resources/application-dev.yml +++ b/clokey-api/src/main/resources/application-dev.yml @@ -1,4 +1,6 @@ spring: + jackson: + time-zone: Asia/Seoul datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://${DEV_MYSQL_HOST}:${MYSQL_PORT}/${DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 @@ -12,6 +14,10 @@ spring: hibernate: ddl-auto: validate open-in-view: false + properties: + hibernate: + jdbc: + time_zone: Asia/Seoul flyway: enabled: true diff --git a/clokey-api/src/main/resources/application-local.yml b/clokey-api/src/main/resources/application-local.yml index 6eb9d0fb..557f1f4e 100644 --- a/clokey-api/src/main/resources/application-local.yml +++ b/clokey-api/src/main/resources/application-local.yml @@ -1,4 +1,6 @@ spring: + jackson: + time-zone: Asia/Seoul datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 @@ -12,6 +14,10 @@ spring: hibernate: ddl-auto: validate open-in-view: false + properties: + hibernate: + jdbc: + time_zone: Asia/Seoul flyway: enabled: true diff --git a/clokey-api/src/main/resources/application-prod.yml b/clokey-api/src/main/resources/application-prod.yml index 4e42e0c5..98656fe5 100644 --- a/clokey-api/src/main/resources/application-prod.yml +++ b/clokey-api/src/main/resources/application-prod.yml @@ -1,4 +1,6 @@ spring: + jackson: + time-zone: Asia/Seoul datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://${PROD_MYSQL_HOST}:${MYSQL_PORT}/${DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 @@ -12,6 +14,10 @@ spring: hibernate: ddl-auto: none open-in-view: false + properties: + hibernate: + jdbc: + time_zone: Asia/Seoul flyway: enabled: true diff --git a/clokey-api/src/test/java/org/clokey/domain/member/controller/MemberControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/member/controller/MemberControllerTest.java index 4df72cc4..bd27d2ca 100644 --- a/clokey-api/src/test/java/org/clokey/domain/member/controller/MemberControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/member/controller/MemberControllerTest.java @@ -49,7 +49,7 @@ class 프로필_수정_요청_시 { // given ProfileUpdateRequest request = new ProfileUpdateRequest( - "testNickname", + "testnickname", "testBio", Visibility.PUBLIC, "https://img.example.com/bg.jpg"); @@ -135,7 +135,7 @@ class 프로필_수정_요청_시 { String longBio = "a".repeat(101); ProfileUpdateRequest request = new ProfileUpdateRequest( - "testNickname", + "testnickname", longBio, Visibility.PRIVATE, "https://img.example.com/bg.jpg"); @@ -154,6 +154,59 @@ class 프로필_수정_요청_시 { .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) .andExpect(jsonPath("$.result.bio").value("바이오는 100자를 넘길 수 없습니다.")); } + + @ParameterizedTest + @ValueSource(strings = {"clokey clokey", "CLOKEY", "clokey-user"}) + void 닉네임_패턴을_위배하면_예외가_발생한다(String nickname) throws Exception { + // given + ProfileUpdateRequest request = + new ProfileUpdateRequest( + nickname, + "testBio", + Visibility.PRIVATE, + "https://img.example.com/bg.jpg"); + + // when + ResultActions perform = + mockMvc.perform( + patch("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + .andExpect( + jsonPath("$.result.nickname") + .value("닉네임은 영어 소문자, 숫자, 한글, 언더바(_), 점(.)만 허용됩니다.")); + } + + @Test + void 닉네임이_20자를_초과하면_예외가_발생한다() throws Exception { + // given + ProfileUpdateRequest request = + new ProfileUpdateRequest( + "abcdefghijklmnopqrstu", + "testBio", + Visibility.PRIVATE, + "https://img.example.com/bg.jpg"); + + // when + ResultActions perform = + mockMvc.perform( + patch("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + .andExpect(jsonPath("$.result.nickname").value("닉네임은 20자 이하여야 합니다.")); + } } @Nested @@ -227,10 +280,9 @@ class 아이디_중복확인_요청_시 { .value(containsString("닉네임은 비워둘 수 없습니다."))); } - // 허용 종류 : 영어 소문자, 한글, 언더바(_), 점(.) + // 허용 종류 : 영어 소문자, 숫자, 한글, 언더바(_), 점(.) @ParameterizedTest - @ValueSource( - strings = {"clokey clokey", "CLOKEY", "clokey-user", "clokey,,user^^", "clokey1"}) + @ValueSource(strings = {"clokey clokey", "CLOKEY", "clokey-user", "clokey,,user^^"}) void 닉네임_제약조건을_위배하면_예외가_발생한다(String nickname) throws Exception { // given DuplicatedNicknameCheckRequest request = new DuplicatedNicknameCheckRequest(nickname); @@ -249,7 +301,7 @@ class 아이디_중복확인_요청_시 { .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) .andExpect( jsonPath("$.result.nickname") - .value("닉네임은 영어 소문자, 한글, 언더바(_), 점(.)만 허용됩니다.")); + .value("닉네임은 영어 소문자, 숫자, 한글, 언더바(_), 점(.)만 허용됩니다.")); } @Test @@ -274,7 +326,7 @@ class 아이디_중복확인_요청_시 { } @ParameterizedTest - @ValueSource(strings = {"clokey", "홍길동", "clokey.홍길동", "abc_def"}) + @ValueSource(strings = {"clokey", "홍길동", "clokey.홍길동", "abc_def", "clokey1"}) void 닉네임_제약조건을_만족하면_중복_여부를_반환한다(String nickname) throws Exception { // given DuplicatedNicknameCheckRequest request = new DuplicatedNicknameCheckRequest(nickname); diff --git a/clokey-common-web/src/main/java/org/clokey/response/BaseResponse.java b/clokey-common-web/src/main/java/org/clokey/response/BaseResponse.java index 0cb28765..be404f0e 100644 --- a/clokey-common-web/src/main/java/org/clokey/response/BaseResponse.java +++ b/clokey-common-web/src/main/java/org/clokey/response/BaseResponse.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import java.time.LocalDateTime; +import java.time.ZoneId; import org.clokey.code.BaseSuccessCode; import org.clokey.exception.BaseErrorCode; @@ -14,12 +15,15 @@ public record BaseResponse( String message, LocalDateTime timeStamp, @JsonInclude(JsonInclude.Include.NON_NULL) T result) { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + public static BaseResponse onSuccess(BaseSuccessCode code, T result) { return new BaseResponse<>( true, code.getReasonDto().code(), code.getReasonDto().message(), - LocalDateTime.now(), + LocalDateTime.now(KST), result); } @@ -28,11 +32,11 @@ public static BaseResponse onFailure(BaseErrorCode code, T result) { false, code.getErrorReason().code(), code.getErrorReason().message(), - LocalDateTime.now(), + LocalDateTime.now(KST), result); } public static BaseResponse onFailure(String code, String message, T data) { - return new BaseResponse<>(false, code, message, LocalDateTime.now(), data); + return new BaseResponse<>(false, code, message, LocalDateTime.now(KST), data); } } diff --git a/clokey-domain/src/main/java/org/clokey/common/config/JpaAuditingConfig.java b/clokey-domain/src/main/java/org/clokey/common/config/JpaAuditingConfig.java index 2973bb1a..dd07df03 100644 --- a/clokey-domain/src/main/java/org/clokey/common/config/JpaAuditingConfig.java +++ b/clokey-domain/src/main/java/org/clokey/common/config/JpaAuditingConfig.java @@ -1,8 +1,21 @@ package org.clokey.common.config; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.auditing.DateTimeProvider; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @Configuration -@EnableJpaAuditing -public class JpaAuditingConfig {} +@EnableJpaAuditing(dateTimeProviderRef = "kstDateTimeProvider") +public class JpaAuditingConfig { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + @Bean + public DateTimeProvider kstDateTimeProvider() { + return () -> Optional.of(LocalDateTime.now(KST)); + } +} diff --git a/clokey-domain/src/main/java/org/clokey/member/entity/Member.java b/clokey-domain/src/main/java/org/clokey/member/entity/Member.java index 0d443a59..6a40ce75 100644 --- a/clokey-domain/src/main/java/org/clokey/member/entity/Member.java +++ b/clokey-domain/src/main/java/org/clokey/member/entity/Member.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import java.time.LocalDate; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -25,6 +26,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member extends BaseEntity { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -140,6 +143,6 @@ public void activate() { public void deactivate() { this.memberStatus = MemberStatus.INACTIVE; - this.inactiveDate = LocalDate.now(); + this.inactiveDate = LocalDate.now(KST); } } 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); } }