Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package site.icebang.domain.workflow.dto;

import java.time.Instant;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package site.icebang.domain.workflow.dto;

import java.time.Instant;
import java.util.List;

import lombok.AllArgsConstructor;
Expand All @@ -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<TaskRunDto> taskRuns;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package site.icebang.domain.workflow.dto;

import java.time.Instant;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package site.icebang.domain.workflow.dto;

import java.time.Instant;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
Expand All @@ -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;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InstantTimezone을 나누어 사용하는 이유는 데이터의 정확성시스템의 유연성을 모두 확보하기 위함이며, 이는 서버 개발의 표준적인 모범 사례입니다.


Instant vs. Timezone

구분 | Instant (언제) | Timezone (어디서) -- | -- | -- 핵심 개념 | 타임라인 위의 절대적인 한 순간 | 특정 지역의 시간을 정의하는 규칙 또는 오프셋 기준 | 항상 UTC (협정 세계시) | UTC로부터의 시간 차이 (예: +09:00) 표현 예시 | 2025-09-26T07:00:00Z | Asia/Seoul, KST 역할 | 기계가 이해하는 시간 (내부 처리용) | 사람이 이해하는 시간 (표시용)

왜 나누어서 사용해야 하는가?

서버는 전 세계 어디서든 동작할 수 있고, 사용자 또한 전 세계 어디서든 접속할 수 있습니다. 이때 시간 데이터를 모호하게 처리하면 심각한 문제가 발생할 수 있습니다.

1. 데이터 무결성 보장: 서버는 Instant를 사용

서버 내부, 특히 데이터베이스에 시간을 기록할 때는 전 세계적으로 동일한 절대적인 시점을 나타내는 Instant (UTC 기준)를 사용해야 합니다.

  • 장점: 서울에 있는 서버가 기록한 Instant 값과, 나중에 미국에 있는 서버가 그 값을 읽었을 때, 두 서버는 완벽하게 동일한 물리적 순간을 이해하게 됩니다. 만약 "2025년 9월 26일 오후 4시"와 같이 지역 시간으로 저장했다면, 이것이 한국 시간인지 미국 시간인지 알 수 없어 데이터의 정합성이 깨집니다.

2. 유연한 표현: 사용자는 Timezone이 적용된 시간을 봄

사용자에게 시간을 보여줄 때는, 서버에 저장된 절대적인 Instant 값에 사용자의 지역 Timezone을 적용하여 변환한 후 보여줍니다.

  • 장점: 한국에 있는 사용자는 "오후 4시"로 보고, 동시에 접속한 미국 사용자는 "오전 3시"로 보게 됩니다. 이처럼 단 하나의 Instant 데이터만으로 전 세계 모든 사용자에게 각자의 지역 시간에 맞는 올바른 정보를 제공할 수 있습니다.

코드 리뷰 관점에서의 결론

현재 프로젝트에서 TaskRun과 같은 도메인 모델에는 Instant를 사용하여 서버 내부의 데이터 정확성을 확보하고, TaskRunDto와 같은 DTO에서는 이를 String으로 변환하여 외부(클라이언트)에 명확한 정보를 제공하는 방식은, 이러한 역할과 책임을 완벽하게 분리한 매우 훌륭하고 표준적인 설계입니다.

Original file line number Diff line number Diff line change
@@ -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 기반 설정 클래스입니다.
*
Expand Down Expand Up @@ -51,4 +57,27 @@ public RestTemplate restTemplate(RestTemplateBuilder builder) {
// 3. 빌더에 직접 생성한 requestFactory를 설정
return builder.requestFactory(() -> requestFactory).build();
}

/**
* Z 포함 UTC 형식으로 시간을 직렬화하는 ObjectMapper 빈을 생성합니다.
*
* <p>이 ObjectMapper는 애플리케이션 전역에서 사용되며, 다음과 같은 설정을 적용합니다:
*
* <ul>
* <li>JavaTimeModule 등록으로 Java 8 시간 API 지원
* <li>timestamps 대신 ISO 8601 문자열 형식 사용
* <li>UTC 타임존 설정으로 Z 포함 형식 보장
* </ul>
*
* @return Z 포함 UTC 형식이 설정된 ObjectMapper 인스턴스
* @since v0.0.1
*/
@Bean
@Primary
public ObjectMapper objectMapper() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전역 ObjectMapper를 Bean으로 등록하여 애플리케이션 전체의 JSON 직렬화/역직렬화 방식을 표준화한 것은 매우 훌륭한 설계입니다. 특히 시간(Time) 데이터를 UTC로 통일한 것은 분산 시스템에서 발생할 수 있는 수많은 버그를 사전에 방지하는 핵심적인 모범 사례입니다.

잘된 점 (Pros)

  1. 중앙화된 시간 관리 (setTimeZone):

    • .setTimeZone(TimeZone.getTimeZone("UTC")) 설정을 통해, 애플리케이션의 모든 JSON 변환에서 시간을 UTC(협정 세계시) 기준으로 통일했습니다. 이는 서버의 위치나 설정에 관계없이 항상 일관된 시간 데이터를 보장하여, 타임존 관련 버그를 원천적으로 차단하는 가장 좋은 방법입니다.
  2. Java 8 시간 API 지원 (JavaTimeModule):

    • .registerModule(new JavaTimeModule())을 통해 Instant, LocalDateTime 등 Java 8의 시간 타입을 Jackson이 올바르게 처리할 수 있도록 했습니다. 이는 최신 Java 개발 환경의 필수 설정입니다.
  3. 가독성 높은 시간 포맷 (WRITE_DATES_AS_TIMESTAMPS):

    • .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) 설정을 통해, 1727339859000과 같은 숫자 타임스탬프 대신, 사람이 읽기 쉬운 ISO 8601 형식(2025-09-26T09:17:39.000+00:00)의 문자열로 시간을 표현하도록 했습니다. 이는 API 응답을 디버깅하고 이해하는 데 큰 도움이 됩니다.
  4. 전역 적용 (@Primary):

    • @Primary 어노테이션을 사용하여, 여기서 설정한 ObjectMapper가 Spring 컨테이너 내에서 기본값으로 사용되도록 했습니다. 이제 @RestController가 JSON 응답을 생성할 때 등 별도의 설정 없이도 이 Bean이 자동으로 사용되어 애플리케이션 전체의 동작 일관성을 보장합니다.

추가 제안 (고려 사항)

  • API의 안정성을 더욱 높이고 싶다면, 역직렬화(JSON -> Java Object) 시 알 수 없는 필드가 있어도 에러를 발생시키지 않도록 아래 설정을 추가하는 것을 고려해볼 수 있습니다.
    .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)

결론적으로, 이 코드는 안정적이고 예측 가능한 데이터 처리를 위한 훌륭한 기반을 마련한 매우 잘 작성된 설정입니다.

return new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.setTimeZone(TimeZone.getTimeZone("UTC"));
}
}
5 changes: 0 additions & 5 deletions apps/user-service/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -21,9 +19,4 @@ public RestDocumentationResultHandler restDocumentationResultHandler() {
Preprocessors.removeHeaders("Content-Length", "Date", "Keep-Alive", "Connection"),
Preprocessors.prettyPrint()));
}

@Bean
public ObjectMapper testObjectMapper() {
return new ObjectMapper();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
Expand All @@ -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",
Expand Down Expand Up @@ -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"));
}
}
Loading