Contents

typeORM 시간대 설정에 관한 고찰 - typeORM의 dateStrings와 timezone 옵션에 따른 시간대 혼란

 

먼저 알아두기

mySQL - Date 필드의 타입 종류

mySQL에서 Date 필드의 타입 종류는 다음과 같다.

  • DATE - 날짜만
  • TIME - 시간만
  • DATETIME - 시간대가 반영되지 않은 현재 서버 시간대의 날짜와 시간 데이터
  • TIMESTAMP - 시간대가 반영된 날짜와 시간 데이터

여기서 주의할 점은 TIMESTAMP 이다.
이 데이터는 해당 서버의 시간대를 반영하기에 DB에 저장될 때에는 UTC(+00:00) 시간대로 변환되어 저장된다.
단, 조회시 세션 설정에 따른 시간대를 반영하여 해당 시간대로 변환된 데이터로 조회된다.

typeORM의 dateStrings와 timezone 옵션

dateStrings와 timezone에 대한 개념을 확실하게 잡지 않은 상태에서 서비스를 운영해보니 굉장히 혼란스러웠다. 정확한 서버나 DB의 상태를 내가 통제하고 있지 않은 상태에서 일단 결과물은 내가 원하는대로 나오니까 그동안은 이게 전부이고 이게 맞다고 생각했었다. 하지만 코드를 하나만 바꾸어도 내가 전혀 의도치 않은 방향대로 결과물이 나오는 것을 보며 이는 곧 나의 코드가 다른 환경에서는 언제든지 내가 원치않는 결과를 낼 수 있음을 의미하기에 내심 찝찝했다.
때문에 이에 대한 문서를 살펴보고 다음과 같이 직접 테스트를 해본다.

현재 조건

mySQL의 Date 필드의 타입은 timestamp이다.

dateStrings

dateStrings: true
DB로부터 Date 타입의 데이터를 String으로 변환하여 가져온다.
이 때, 시스템의 Local TimeZone이 반영된다.
단점은 당연하게도 반환값이 String타입이기에 해당 컬럼과 관련된 서비스 로직에서 Date 타입을 이용한 변환을 사용할 수 없다.
 

dateStrings: false
Date 타입을 js 객체 형태로 가지고 있으며 시스템의 Local TimeZone을 적용되지 않고 UTC 시간대 그대로 출력한다.
(번거롭지만) 데이터를 개발자가 더 세밀하게 제어할 수 있다.

timezone

timezone에 대한 실험

mySQL 세션 시간대 설정과의 관계
SET GLOBAL TIME_ZONE = '+00:00' -- 시간대 설정
SHOW GLOBAL VARIABLES LIKE 'TIME_ZONE' -- 시간대 확인
-- 시간대 변환 후, mySQL의 세션(typeORM)을 종료후 다시 연결 하여야 반영된다.

 

위와 같은 mySQL 서버의 시간대 설정과 typeORM의 timezone을 통한 시간대 설정은 서로 무관하다. 위 설정은 mySQL의 세션 실행시 적용할 시간대를 설정해주는 건데,
바로 이 부분을 typeORM의 세션에서는 timezone 설정이 이를 따로이 대신 설정해주는 개념이라고 이해하면 편하다.

timezone: 'Z'

현재 DB에서 받아오는 시간대를 Zulu 시간대(+00:00)라고 해석하여 받아들인다.

실험
  • typeORM - timezone = 'Z' 옵션을 추가
  • 한국표준시(KST) 기준 24.02.05 20:04 데이터 입력
결과
  • 2024-02-05T11:04:52.788Z

실제 DB에서는 이 시간대로 저장되어 있으며 typeorm에서도 이 시간으로 나온다.
(물론 DB 조회시 조회 세션의 시간대 설정에 따라 변환되어 조회될 수는 있다.)

이 때 서버의 시간대는 KST(한국표준시)라 하더라도 이것은 전혀 무관하게 작동한다.

timezone = '<LOCAL>'

DB로부터 가져온 데이터를 서버의 시간대에 맞춰 변환한다.

즉, timezone = 'Asia/Seoul로 설정되어있을 때,

DB에서 09:00로 내보냈다면 서버는 이 데이터를 서버의 시간대인 KST로 변환하여 반영한다.

timezone = 'Z'와 '(LOCAL)'의 차이점

‘Z’

  • 형태 : 2024-02-05T11:04:52.788Z
  • typeof : Object (Date 객체)

 

‘(LOCAL)’

  • 형태 : 2024-02-05 11:04:52.78829
  • typeof : string

즉 서버의 시간대를 반영하여 내보내고 싶을 경우, 이 옵션을 사용하여 해당 시간대로 맞추어 사용할 수 있다.

고민

서버별 시간대가 다르더라도 이를 자동반영할 수 있는 방법?

최종사용자에 따른 시간대를 각각 적용하는 방법은 백엔드 입장에선 사실 굉장히 단순하고 쉽게 생각된다. 그저 Date필드의 값을 UTC 기준으로 내보내고 클라이언트에서는 최종사용자의 브라우저로부터 수집된 국가코드를 판별해 이를 바탕으로 변환하면 되지 싶다.
대충 다음과 같은 코드가 만들어진다.

// UTC로 받은 시간 문자열
const utcDate = '2024-03-02T12:00:00Z';

// 로컬 시간대로 변환
const localDate = new Date(utcDate).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });

console.log(localDate); // 변환된 로컬 시간 출력

 

하지만 백엔드 서버를 관리하는 입장에서는 UTC로 데이터가 나오니 테스트를 하면서도 시간대에 대한 감이 맞지 않아 불편하다.
즉, 어떤 시간대에서 애플리케이션 서버가 돌아가더라도 따로이 코드를 수정하지 않고 현지 시간대로 Date값을 반환할 수 있는 방법을 찾고 싶었다.
구글링을 해보면 간단하게 다음 라이브러리들이 나온다.

moment - npm moment-timezone - npm

그런데…
moment - npm 라이브러리를 살펴보던 중 해당 공식문서에서 이상한 문구를 발견했다.

You Probably Don’t Need Moment.js Anymore
You don’t (may not) need Moment.js
Why you shouldn’t use Moment.js…
4 alternatives to moment.js for internationalizing dates

사실 당신은 아마 이 라이브러리가 필요없을지도 모른다‘라니…
살펴보니 무겁게 라이브러리를 사용할 필요 없이 간단한 플랫폼의 메소드만으로도 충분히 가능했다.
무조건 무거운 라이브러리 패키지를 사용하는 것만이 능사는 아니라고 말하고 있다.1

적용방안

조건

다음 조건으로 DB로부터 UTC 기준의 시간대를 서버로 가져온다.

  • dateStrings = false (따로이 설정하지 않으면 false가 기본값이다.)
  • timezone은 따로 설정하지 않고 DB의 시간대를 그대로 받아온다는 조건을 만든다.

코드

  • DB서버에서 받아온 Date 정보(UTC 시간대)로부터
  • 애플리케이션 서버의 현지 시간대(getTimezoneOffset())와의 차이를 계산하여
    반환함으로써, 어느 시간대의 서버에서든지 서버가 설치된 현지시간대로 데이터를 받아볼 수 있다.
// 애플리케이션 서버의 타임존을 고려하여 Date타입을 재가공 
// (ex. 2021-08-01T00:00:00.000Z -> 2021-08-01 00:00:00)
public formatDate(date: Date): string {
 const localDateTime: Date = new Date(
    			        date.getTime() - date.getTimezoneOffset() * 60 * 1000
    			      );
    			  
    			      return localDateTime.toISOString().substring(0, 19).replace('T', ' ');
    			    }

코드 적용

class DateUtils {  
  public static formatDate(date: Date): string {  
    const localDateTime: Date = new Date(  
      date.getTime() - date.getTimezoneOffset() * 60 * 1000  
    );  
  
    return localDateTime.toISOString().substring(0, 19).replace('T', ' ');  
  }
}

class UserService {
  async getUser(targetId: number): Promise<ExtendedUser> {
    const result = await UserRepository.findOne( {where: {id: targetId} } );
    if (!result) {
      return null; // 사용자를 찾을 수 없는 경우
    }
    return {
      ...result,
      created_at: DateUtils.formatDate(user.created_at),
      updated_at: DateUtils.formatDate(user.updated_at),
      // deleted_at은 선택적 필드이므로, 존재할 때만 변환
      deleted_at: user.deleted_at ? DateUtils.formatDate(user.deleted_at) : null,
    };

  async getUsers(condition: any): Promise<ExtendedUser[]> {
	const result = await SomeRepository.find(condition);
	return result.map((data: User) => ({
	 ...data, 
	 created_at: DateUtils.formatDate(data.created_at),
	 updated_at: DateUtils.formatDate(data.updated_at),
	 deleted_at: data.deleted_at ? DateUtils.formatDate(data.deleted_at) : null, }));  
  }  
}

 

위와 같이 필요한 컬럼에 위 함수를 적용하여 시간대와 형태를 바꿀 수 있다.
 

단점은 이로 인해 해당 entity의 반환 타입이 깨진다는 문제가 생긴다.
예를들어 원래 User entity에서 created_at 컬럼의 타입은 Date타입이다. 그런데 해당 함수로 인해 반환되는 값의 타입은 string이기에 타입 에러가 나타나고 따라서 추가적인 작업을 해줘야 한다.

entity의 타입 확장하기

위에서 나타나는 타입 에러를 해결하는 방법은 2가지가 있다.
먼저 간단하게 반환 타입을 Promise<any>로 처리하는 방법이 있다.
무적의 any이다.
 

두번째는 아래와 같이 확장된 타입 또는 인터페이스를 만들어주어 반환 타입으로 적용해주는 것이다.

// 원래의 User 엔티티 타입, Date 관련 컬럼의 타입은 `Date`이다.
class User extends BaseEntity {  
  @PrimaryGeneratedColumn()  
  id!: number;  
  
  @CreateDateColumn({ type: 'timestamp', transformer: dateTransformer })  
  created_at!: Date;  
  
  @UpdateDateColumn({ type: 'timestamp', transformer: dateTransformer })  
  updated_at!: Date;  
  
  @DeleteDateColumn({ type: 'timestamp', transformer: dateTransformer })  
  deleted_at?: Date | null;  

  @Column({ unique: true })  
  nickname: string;  
  
  @Column({ select: false })  
  password: string;  
  
  @Column({ unique: true })  
  email: string;
}

// User 타입에서 Date관련 컬럼의 타입을 string으로 바꾼 인터페이스를 생성한다.
interface ExtendedUser  
  extends Omit<User, 'created_at' | 'updated_at' | 'deleted_at'> {  
  created_at: string;  
  updated_at: string;  
  deleted_at: string | null;  
}

장점

DB 서버의 시간대는 DB 담당자 편한 시간대로 바꾸어도 필드는 timestamp이기에 앱서버에서는 균일하게 UTC 시간대로 받아온다. 따라서 애플리케이션 서버 담당자는 서버의 시간대만 신경쓰면 된다.
만약, 클라이언트에서 브라우저별 시간대를 고려하겠다면 localDateTime 변수대신 date 인자를 그대로 반환하면 된다.

그리고 그 저 함수를 클라이언트에서 그대로 다시 사용할 수도 있다.

추가 업데이트 1 - formatDate함수를 Entity에 바로 적용하기

위 함수를 서비스 로직에서 적용하고자 하는 해당 Date 필드에 따로이 사용할 수도 있지만 애시당초 typeORM에서 모든 Date 필드에 한번에 적용하게끔 하는 방법도 있다.
무엇보다 이 방법은 위에서 언급한 추가적인 interface를 사용하지 않고 entity 본래의 타입 그대로 반환 타입에서 사용할 수 있다는 장점이 있다.
 

바로 typeORM의 transformer 옵션을 사용하면 된다.

현재 프로젝트의 entities에서 id, created_at, updated_at, deleted_at과 같은 기본 필드는 base entity라는 추상화 클래스로 따로이 만들어놓고 여기에서 확장하여 각각의 entity를 작성하였다.
즉 base entity에서 해당 컬럼에 다음과 같이 적용하면 이제 모든 Date 필드는 자동으로 위 함수가 적용된 채로 조회할 수 있다.

import { DateUtils } from '../utils/dateUtils';  
  
export const dateTransformer: ValueTransformer = {  
  from: (value: Date) =>  
    value instanceof Date ? DateUtils.formatDate(value) : value,  
  to: (value: Date) => value,  
};  
  
export abstract class Base extends BaseEntity {  
  @PrimaryGeneratedColumn()  
  id!: number;  
  
  @CreateDateColumn({ type: 'timestamp', transformer: dateTransformer })  
  created_at!: Date;  
  
  @UpdateDateColumn({ type: 'timestamp', transformer: dateTransformer })  
  updated_at!: Date;  
  
  @DeleteDateColumn({ type: 'timestamp', transformer: dateTransformer })  
  deleted_at?: Date | null;  
}

이 방법은 일일이 서비스로직에서 해당 필드에 함수를 적용했을 때보다 훨씬 더 코드가 깔끔해지고 단일게 공통적으로 적용할 수 있다는 장점이 있다.

추가 업데이트 2

만약 클라이언트는 UTC 기준의 시간대로 데이터를 받아보려하고, 서버관리자는 현지시간대로 확인을 하려한다면??

여러가지로 고민해봤지만 일단 Entity의 transformer를 동적으로 건드릴 수는 없었다. 왜냐하면 entity파일은 DB연결시점에 고정적으로 작동하기 때문이다. 때문에 결국은 모든 서비스 로직을 손봐야한다.

우선, 아래와 같이 미들웨어를 만들어준다.

// '/global' 경로를 포함하는 요청에 대한 미들웨어 추가 (최상단 위치시킬 것)
router.use((req, res, next) => { 
// 요청 경로가 '/global'로 시작하는지 확인 
req.global = req.path.startsWith('/global'); 
next(); 
});

// 이후 router.use 코드들...

그리고 formatDate함수의 클래스에 추가적인 메소드를 더해준다.
아래의 processDateValues() 메소드가 그것이다.

export class DateUtils {  
    const localDateTime: Date = new Date(  
      date.getTime() - date.getTimezoneOffset() * 60 * 1000  
    );  
  
    return localDateTime.toISOString().substring(0, 19).replace('T', ' ');  
  }  
  
  // DB에서 가져오는 반환 값 중 Date 타입의 모든 값을 formatDate 함수로 처리해주는 함수  
  // typeORM Entities의 options 중 transformer를 사용하지 않고 service 로직에서 처리를 할 때 사용  
  public static async processDateValues(result: any): Promise<any> {  
    // 쿼리 실행 후에 반환된 결과를 가공  
    if (Array.isArray(result)) {  
      // 반환된 결과가 배열인 경우  
      return result.map(item => this.processItem(item));  
    } else {  
      // 반환된 결과가 단일 객체인 경우  
      return this.processItem(result);  
    }  
  }  
  
  private static processItem(item: any): any {  
    // 객체의 모든 속성을 순회하면서 Date 타입인 경우에만 가공  
    for (const key in item) {  
      if (item.hasOwnProperty(key) && item[key] instanceof Date) {  
        item[key] = DateUtils.formatDate(item[key]);  
      } else if (typeof item[key] === 'object') {  
        // 객체인 경우 재귀적으로 processItem 호출  
        item[key] = this.processItem(item[key]);  
      }  
    }  
    return item;  
  }  
}

그리고 모든 서비스 로직을 손본다.

class SomeService {
  async processData(req) {
    const result = await someRepository.find();

    // 요청 경로에 '/global'이 포함되어 있지 않은 경우에만 데이터 변환 적용
    if (!req.isGlobal) {
      return DateUtils.processDateValues(result);
    }
    return result;
  }
}

우선은 원하는대로, 클라이언트에서의 UTC 시간대 값과 서버의 현지시간대를 고려한 값 모두의 요구를 맞출 수 있도록 구색은 갖춰봤지만…

아무래도 위 방법은 모든 서비스로직을 번거롭고 거추장스럽게 하고 있고, 깨끗한 코드(Clean Code)라고 하기 어렵기에 추천하지 않지만 이런 방법을 이용할 수 있다는 의도로 기록을 남긴다.

가장 추천하는 방법은 백엔드에서는 시간대를 UTC로 내보내고, 클라이언트에서 이를 따로이 변환하는 것이 가장 깔끔하다고 생각한다.

github 관련 커밋 보기 - github-inchan code NEW | 3.2.0


함께 읽어볼만한 글


  1. 관련된 재밌는 기사가 있다. 2024년 자바스크립트 부피 팽창 | GeekNews 에서는, 필요기능만을 구현하기위한 가장 가볍고 최적화된 코드는 숙련된 순수 자바스크립트 코드라고도 한다. ↩︎