소개
글로벌 플랫폼에서는 각기 다른 시간대를 사용하는 다양한 국가의 사용자들이 서비스를 이용합니다. 이러한 환경에서는 시간 데이터를 어떻게 저장하고 제공하는지가 사용자 경험에 큰 영향을 미칩니다. 이번 글에서는 한국 시간(KST)으로만 관리되던 시간 데이터를 사용자 시간대에 맞게 변환하는 과정을 소개하고, 이를 구현한 기술적 접근 방식을 공유합니다.
문제
초기 환경(KST 기반 데이터 관리)
처음에는 한국 서버에서만 운영되었기 때문에 모든 시간 데이터가 KST(한국 표준시)로 관리되었습니다. 하지만 플랫폼이 성장하며 미국, 몽골, 베트남 등 다양한 국가의 사용자들이 늘어나자, 동일한 시간 데이터가 사용자마다 다르게 해석되는 문제가 발생했습니다.
사용자 혼란 사례
예를 들어, 2024년 10월 30일 08:00(KST)에 제출된 데이터는 미국 동부 시간(EST) 기준으로 2024년 10월 29일 19:00에 해당합니다. 미국 사용자는 해당 데이터를 다음 날로 잘못 인식하거나 제출 시간을 혼동할 수 있습니다.
접근
기존 데이터 유지
데이터 일관성을 유지하기 위해 DB에 저장된 기존 데이터는 계속 KST를 기준으로 관리하기로 결정했습니다.
- 현재 운영중인 시간대를 UTC로 전환하거나 사용자별로 다르게 관리할 경우, 배치 작업이나 스케줄링 로직에서 복잡성과 오류가 발생할 가능성이 높습니다.
DB는 계속해서 KST로 데이터를 저장합니다.
- 클라이언트 요청 시에는 사용자 시간대를 KST로 변환하여 저장.
- 데이터 조회 시에는 KST 데이터를 사용자 시간대로 변환하여 제공하는 방식이 적합하다고 판단했습니다.
중복 코드 제거를 위한 일관된 처리
API마다 시간대 변환 로직을 삽입하면 중복 코드가 증가하고 유지보수성이 저하됩니다. 이를 해결하기 위해 프로젝트 전역에서 일관된 변환 처리가 필요했습니다. 이를 위해 Converter, JsonDeserializer, JsonSerializer를 각각 구현하여 시간대 변환을 자동화했습니다.
구현
LocalDateTime 요청/응답 변환 메서드 구현
시간대 변환의 핵심 로직은 공통적으로 사용하므로 따로 유틸 클래스로 분리하여 구현하였습니다.
public class AuthenticationUtils {
private static final Pattern DATE_PATTERN = Pattern.compile("\\\\\\\\d{4}-\\\\\\\\d{2}-\\\\\\\\d{2}");
private static final Pattern DATE_TIME_MINUTES_PATTERN = Pattern.compile("\\\\\\\\d{4}-\\\\\\\\d{2}-\\\\\\\\d{2} \\\\\\\\d{2}:\\\\\\\\d{2}");
private static final Pattern DATE_TIME_HOUR_PATTERN = Pattern.compile("\\\\\\\\d{4}-\\\\\\\\d{2}-\\\\\\\\d{2} \\\\\\\\d{2}");
public static final DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 역직렬화는 클라이언트의 Request 요청 시 사용됩니다.
public static LocalDateTime transformAccountTimeZoneLocalDateTimeDeserialize(String source) {
// 1) 한국 시간대 오프셋을 설정 (GMT+9)
ZoneOffset koreaOffset = ZoneOffset.ofHours(9);
// 2) source가 "yyyy-MM-dd" 형식이라면 " 00:00:00"을 추가
if (DATE_PATTERN.matcher(source).matches()) {
source += " 00:00:00";
}
// 3) source가 "yyyy-MM-dd HH:mm" 형식이라면 ":00"을 추가
else if (DATE_TIME_MINUTES_PATTERN.matcher(source).matches()) {
source += ":00";
}
// 4) source가 "yyyy-MM-dd HH" 형식이라면 "00:00"을 추가
else if (DATE_TIME_HOUR_PATTERN.matcher(source).matches()) {
source += ":00:00";
}
// 5) 문자열 source를 LocalDateTime 형식으로 변환
LocalDateTime clientDateTime = LocalDateTime.parse(source, df);
// 6) 사용자 계정의 시간대 정보에서 GMT 시간대를 추출하여 오프셋 값으로 설정 Asia/Seoul:GMT+09:00 와 같은 형태로 DB에 저장되어있음
String accountTimeZone = AuthenticationUtils.getAccount().getAccountTimeZone().split(":GMT")[1];
ZoneOffset clientOffset = ZoneOffset.of(accountTimeZone);
// 7) clientDateTime에 계정의 시간대 오프셋을 적용하여 OffsetDateTime 생성
OffsetDateTime clientOffsetDateTime = clientDateTime.atOffset(clientOffset);
// 8) 계정 시간대에서 한국 시간대 (GMT+9)로 시간 변환
OffsetDateTime koreaOffsetDateTime = clientOffsetDateTime.withOffsetSameInstant(koreaOffset);
// 9) 변환된 시간을 LocalDateTime으로 반환
return koreaOffsetDateTime.toLocalDateTime();
}
// 직렬화는 @RestController의 ResponseDTO 반환 시 사용됩니다. LocalDateTime -> String
public static void transformAccountTimeZoneLocalDateTimeSerialize(LocalDateTime value, JsonGenerator gen) throws IOException {
// 1) 한국 시간대를 설정 (GMT+9)
ZoneOffset koreaOffset = ZoneOffset.ofHours(9);
// 2) LocalDateTime을 한국 시간대 오프셋을 가진 OffsetDateTime으로 변환
OffsetDateTime koreaOffsetDateTime = value.atOffset(koreaOffset);
// 3) 사용자 계정의 시간대 정보에서 GMT 시간대를 추출하여 오프셋 값으로 설정
String accountTimeZone = AuthenticationUtils.getAccount().getAccountTimeZone().split(":GMT")[1];
ZoneOffset clientOffset = ZoneOffset.of(accountTimeZone);
// 4) 한국 시간을 사용자의 시간대로 변환
OffsetDateTime clientOffsetDateTime = koreaOffsetDateTime.withOffsetSameInstant(clientOffset);
// 5) 변환된 시간을 문자열로 포맷하여 JSON에 출력
gen.writeString(clientOffsetDateTime.format(df));
}
}
Pattern.compile을 미리 캐싱한 이유
코드에서 Pattern.compile을 통해 정규식을 static final 변수로 선언하여 미리 컴파일한 이유는 성능 최적화와 관련이 있습니다.
- 정규 표현식의 컴파일 비용 절감: **Pattern.compile**은 정규식을 컴파일하는 과정에서 높은 리소스를 소모합니다. 이를 매번 요청마다 새로 컴파일하면 불필요한 CPU와 메모리 사용량이 증가합니다.
- 캐싱을 통한 효율성 향상: static final로 패턴을 캐싱하면, 해당 패턴이 재사용될 때 추가적인 컴파일 비용 없이 빠르게 매칭을 수행할 수 있습니다. 이를 통해 응답 속도 향상과 서버 리소스 절감을 동시에 달성할 수 있습니다.
다양한 입력 형식을 처리하기 위한 패턴 설계
이 로직은 클라이언트(프론트엔드) 측에서 다양한 시간 데이터 형식을 전달하기 때문에 필요합니다. 클라이언트마다 시간 데이터를 제공하는 방식이 다를 수 있습니다.
- 날짜만 제공: 2024-11-13과 같은 형식.
- 시와 분만 제공: 2024-11-13 15:45와 같은 형식.
- 시까지만 제공: 2024-11-13 15.
- 전체 시간 제공: 2024-11-13 15:45:30.
서버에서는 이러한 다양한 형식을 일관되게 유효성 검증하고 처리할 필요가 있습니다. 이를 위해 각 형식을 감지하는 정규식을 만들어 패턴 매칭 후, 부족한 시간 정보를 자동으로 보완하여 LocalDateTime 객체로 변환하도록 구현했습니다.
결과적으로, 이러한 패턴 기반의 접근은 입력 유연성과 데이터 일관성을 보장합니다. 서버가 모든 클라이언트의 입력 데이터를 표준화된 방식으로 처리할 수 있으므로, 이후의 시간대 변환 작업에서도 오류 없이 안정적으로 동작할 수 있습니다.
[Request]요청 데이터 처리 - LocalDateTimeDeserializer
클라이언트로부터 JSON 요청 데이터를 받을 때, 사용자 시간대를 KST로 변환하여 서버에서 처리합니다.(데이터 가공 후 저장, where절 조건문 등)
public class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException, JsonProcessingException {
String source = p.getText();
return AuthenticationUtils.transformAccountTimeZoneLocalDateTimeDeserialize(source);
}
}
[Request]요청 데이터 처리2 - StringToLocalDateTimeConverter (form-data, param)
폼 데이터나 URL 쿼리 파라미터로 전달된 LocalDateTime 시간 문자열을 변환하기 위해 Converter를 구현했습니다.
@Component
public class StringToLocalDateTimeConverter implements Converter<String, LocalDateTime> {
@Override
public LocalDateTime convert(String source) {
return AuthenticationUtils.transformAccountTimeZoneLocalDateTimeDeserialize(source);
}
}
[Response]응답 데이터 처리 - LocalDateTimeSerializer
DB에 KST로 저장된 데이터를 사용자 시간대로 변환하여 클라이언트에 응답합니다.
public class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
@Override
public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
AuthenticationUtils.transformAccountTimeZoneLocalDateTimeSerialize(value, gen);
}
}
글로벌 설정
StringToLocalDateTimeConverter는 @Component를 통해 빈으로 등록되며, Spring MVC가 자동으로 전역 컨버터로 인식하여 form-data 및 query param 요청에 적용됩니다.
JSON 요청 및 응답에서 시간 변환이 일관되게 적용될 수 있도록, ObjectMapper 설정은 아래와 같이 WebConfig 클래스에 등록하여 사용합니다.
@Configuration
public class WebConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
SimpleModule module = new SimpleModule();
module.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer());
module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer());
objectMapper.registerModule(module);
return objectMapper;
}
}
회원 테이블에 시간대 정보 추가
사용자의 시간대를 개별적으로 관리하기 위해 Account 엔티티에 time_zone 필드를 추가했습니다.
public class Account {
@ColumnDefault("'Asia/Seoul:GMT+09:00'")
@Comment("사용자 시간대")
@Column(name = "account_time_zone")
private String accountTimeZone;
}
JWT 토큰에 시간대 포함
JWT 토큰에 사용자 시간대 정보를 포함하여 API 요청 시 변환 로직에 활용했습니다. 이를 통해 모든 요청과 응답에 추가적인 DB 조회 없이 시간대 변환을 수행할 수 있었습니다.
결과
- 한국 시간(KST) 기준 데이터 일관성 유지 - 기존 데이터를 변환하지 않고 유지하면서도 사용자에게 선택한 시간대로 데이터를 제공.
- 유지보수성 향상 - 모든 API에서 일관된 시간 변환 처리로 중복 코드 감소.
- 사용자 경험 개선 - 각 사용자가 자신의 시간대에 맞춘 데이터를 확인 가능.
마무리
이번 시간대 변환 기능 구현을 통해 글로벌 환경에서의 날짜 데이터 관리 문제를 효과적으로 해결할 수 있었습니다. 기존 KST 기반의 데이터 일관성을 유지하면서도 사용자에게는 각자의 시간대에 맞는 정보를 제공하여 혼란을 줄이고 사용자 경험을 개선할 수 있었습니다.
특히, 공통 로직을 유틸리티로 분리하고 전역 설정을 적용함으로써 유지보수성과 효율성을 높였습니다. 앞으로도 다양한 사용자 환경에 대응할 수 있는 기능을 지속적으로 개선해 나갈 계획입니다.