diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java index 5dbb5711..7c8595a3 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ExecutionLogDto.java @@ -1,5 +1,7 @@ package site.icebang.domain.workflow.dto; +import java.time.Instant; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -17,6 +19,6 @@ public class ExecutionLogDto { private String logLevel; // info, success, warning, error private String status; // running, success, failed, etc private String logMessage; - private String executedAt; + private Instant executedAt; private Integer durationMs; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java index 618a6214..8ebe6c51 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/JobRunDto.java @@ -1,5 +1,6 @@ package site.icebang.domain.workflow.dto; +import java.time.Instant; import java.util.List; import lombok.AllArgsConstructor; @@ -19,8 +20,8 @@ public class JobRunDto { private String jobDescription; private String status; private Integer executionOrder; - private String startedAt; - private String finishedAt; + private Instant startedAt; + private Instant finishedAt; private Integer durationMs; private List taskRuns; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java index 9005c45a..b6bc9a3d 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/TaskRunDto.java @@ -1,5 +1,7 @@ package site.icebang.domain.workflow.dto; +import java.time.Instant; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -18,7 +20,7 @@ public class TaskRunDto { private String taskType; private String status; private Integer executionOrder; - private String startedAt; - private String finishedAt; + private Instant startedAt; + private Instant finishedAt; private Integer durationMs; } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java index 20b8ecd2..af2a3005 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowRunDto.java @@ -1,5 +1,7 @@ package site.icebang.domain.workflow.dto; +import java.time.Instant; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -17,9 +19,9 @@ public class WorkflowRunDto { private String runNumber; private String status; private String triggerType; - private String startedAt; - private String finishedAt; + private Instant startedAt; + private Instant finishedAt; private Integer durationMs; private Long createdBy; - private String createdAt; + private Instant createdAt; } diff --git a/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java index 7029b7d9..9369f887 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java @@ -1,13 +1,19 @@ package site.icebang.global.config; import java.time.Duration; +import java.util.TimeZone; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + /** * 애플리케이션의 웹 관련 설정을 담당하는 Java 기반 설정 클래스입니다. * @@ -51,4 +57,27 @@ public RestTemplate restTemplate(RestTemplateBuilder builder) { // 3. 빌더에 직접 생성한 requestFactory를 설정 return builder.requestFactory(() -> requestFactory).build(); } + + /** + * Z 포함 UTC 형식으로 시간을 직렬화하는 ObjectMapper 빈을 생성합니다. + * + *

이 ObjectMapper는 애플리케이션 전역에서 사용되며, 다음과 같은 설정을 적용합니다: + * + *

+ * + * @return Z 포함 UTC 형식이 설정된 ObjectMapper 인스턴스 + * @since v0.0.1 + */ + @Bean + @Primary + public ObjectMapper objectMapper() { + return new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .setTimeZone(TimeZone.getTimeZone("UTC")); + } } diff --git a/apps/user-service/src/main/resources/application.yml b/apps/user-service/src/main/resources/application.yml index 55fece16..f6302bc7 100644 --- a/apps/user-service/src/main/resources/application.yml +++ b/apps/user-service/src/main/resources/application.yml @@ -7,11 +7,6 @@ spring: context: cache: maxSize: 1 - jackson: - time-zone: UTC - serialization: - write-dates-as-timestamps: false - mybatis: # Mapper XML 파일 위치 mapper-locations: classpath:mapper/**/*.xml diff --git a/apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql b/apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql index 3ae6c57b..018b4d18 100644 --- a/apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql +++ b/apps/user-service/src/main/resources/sql/schema/03-schema-h2-timezone.sql @@ -6,7 +6,7 @@ -- 모든 timestamp 컬럼의 기본값 제거 (H2에서는 MODIFY COLUMN 문법이 다름) -- H2에서는 ALTER TABLE table_name ALTER COLUMN column_name 문법 사용 -- H2 MariaDB 모드에서는 백틱으로 테이블명을 감싸야 함 - +SET TIME ZONE 'UTC'; ALTER TABLE `permission` ALTER COLUMN created_at SET DEFAULT NULL; ALTER TABLE `permission` ALTER COLUMN updated_at SET DEFAULT NULL; diff --git a/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java b/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java index 3b7ce243..c7b18ce8 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/setup/config/E2eTestConfiguration.java @@ -9,15 +9,10 @@ import org.testcontainers.containers.MariaDBContainer; import org.testcontainers.containers.Network; import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; import org.testcontainers.utility.DockerImageName; @TestConfiguration(proxyBeanMethods = false) public class E2eTestConfiguration { - @Bean - public ObjectMapper objectMapper() { - return new ObjectMapper(); - } @Bean public Network testNetwork() { diff --git a/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java b/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java index 97d1cf0d..002cd307 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java @@ -12,7 +12,8 @@ import org.springframework.http.client.ClientHttpResponse; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; -import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; + +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; diff --git a/apps/user-service/src/test/java/site/icebang/integration/setup/config/RestDocsConfiguration.java b/apps/user-service/src/test/java/site/icebang/integration/setup/config/RestDocsConfiguration.java index f60de9cc..16285140 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/setup/config/RestDocsConfiguration.java +++ b/apps/user-service/src/test/java/site/icebang/integration/setup/config/RestDocsConfiguration.java @@ -6,8 +6,6 @@ import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; import org.springframework.restdocs.operation.preprocess.Preprocessors; -import com.fasterxml.jackson.databind.ObjectMapper; - @TestConfiguration public class RestDocsConfiguration { @@ -21,9 +19,4 @@ public RestDocumentationResultHandler restDocumentationResultHandler() { Preprocessors.removeHeaders("Content-Length", "Date", "Keep-Alive", "Connection"), Preprocessors.prettyPrint())); } - - @Bean - public ObjectMapper testObjectMapper() { - return new ObjectMapper(); - } } diff --git a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java index f2be6c1f..f83e0142 100644 --- a/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java +++ b/apps/user-service/src/test/java/site/icebang/integration/tests/workflow/WorkflowHistoryApiIntegrationTest.java @@ -57,21 +57,21 @@ void getWorkflowRunDetail_success() throws Exception { .andExpect(jsonPath("$.data.workflowRun.runNumber").isEmpty()) .andExpect(jsonPath("$.data.workflowRun.status").value("FAILED")) .andExpect(jsonPath("$.data.workflowRun.triggerType").isEmpty()) - .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22 18:18:43")) - .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22 18:18:44")) + .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22T18:18:43Z")) + .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22T18:18:44Z")) .andExpect(jsonPath("$.data.workflowRun.durationMs").value(1000)) .andExpect(jsonPath("$.data.workflowRun.createdBy").isEmpty()) .andExpect(jsonPath("$.data.workflowRun.createdAt").exists()) // UTC 시간 형식 검증 (시간대 보장) - 마이크로초 포함 가능 .andExpect( jsonPath("$.data.workflowRun.startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.workflowRun.finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.workflowRun.createdAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) // jobRuns 배열 확인 .andExpect(jsonPath("$.data.jobRuns").isArray()) .andExpect(jsonPath("$.data.jobRuns.length()").value(1)) @@ -83,16 +83,19 @@ void getWorkflowRunDetail_success() throws Exception { .andExpect(jsonPath("$.data.jobRuns[0].jobDescription").value("키워드 검색, 상품 크롤링 및 유사도 분석 작업")) .andExpect(jsonPath("$.data.jobRuns[0].status").value("FAILED")) .andExpect(jsonPath("$.data.jobRuns[0].executionOrder").isEmpty()) - .andExpect(jsonPath("$.data.jobRuns[0].startedAt").value("2025-09-22 18:18:44")) - .andExpect(jsonPath("$.data.jobRuns[0].finishedAt").value("2025-09-22 18:18:44")) + .andExpect(jsonPath("$.data.jobRuns[0].startedAt").value("2025-09-22T18:18:44Z")) + .andExpect(jsonPath("$.data.jobRuns[0].finishedAt").value("2025-09-22T18:18:44Z")) .andExpect(jsonPath("$.data.jobRuns[0].durationMs").value(0)) // JobRun UTC 시간 형식 검증 - 마이크로초 포함 가능 .andExpect( - jsonPath("$.data.jobRuns[0].startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + jsonPath( + "$.data.jobRuns[0].startedAt", + matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) + // finishedAt 도 동일하게 .andExpect( - jsonPath("$.data.jobRuns[0].finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + jsonPath( + "$.data.jobRuns[0].finishedAt", + matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) // taskRuns 배열 확인 .andExpect(jsonPath("$.data.jobRuns[0].taskRuns").isArray()) .andExpect(jsonPath("$.data.jobRuns[0].taskRuns.length()").value(1)) @@ -105,17 +108,18 @@ void getWorkflowRunDetail_success() throws Exception { .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].taskType").value("FastAPI")) .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].status").value("FAILED")) .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].executionOrder").isEmpty()) - .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt").value("2025-09-22 18:18:44")) .andExpect( - jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt").value("2025-09-22 18:18:44")) + jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt").value("2025-09-22T18:18:44Z")) + .andExpect( + jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt").value("2025-09-22T18:18:44Z")) .andExpect(jsonPath("$.data.jobRuns[0].taskRuns[0].durationMs").value(0)) // TaskRun UTC 시간 형식 검증 - 마이크로초 포함 가능 .andExpect( jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andDo( document( "workflow-run-detail", @@ -269,29 +273,29 @@ void getWorkflowRunDetail_utc_time_validation() throws Exception { // WorkflowRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 .andExpect( jsonPath("$.data.workflowRun.startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.workflowRun.finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.workflowRun.createdAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) // JobRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 .andExpect( jsonPath("$.data.jobRuns[0].startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.jobRuns[0].finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) // TaskRun 시간이 UTC 형식인지 검증 - 마이크로초 포함 가능 .andExpect( jsonPath("$.data.jobRuns[0].taskRuns[0].startedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) .andExpect( jsonPath("$.data.jobRuns[0].taskRuns[0].finishedAt") - .value(matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?"))) + .value(matchesPattern("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"))) // 시간 순서 논리적 검증 (startedAt <= finishedAt) - .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22 18:18:43")) - .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22 18:18:44")); + .andExpect(jsonPath("$.data.workflowRun.startedAt").value("2025-09-22T18:18:43Z")) + .andExpect(jsonPath("$.data.workflowRun.finishedAt").value("2025-09-22T18:18:44Z")); } }