mutable 한 Date 로 일어날 수 있는 문제
자바스크립트의 날짜를 나타내는 표준 객체인 Date 는 불변하지 않다. 즉, 변경을 해도 동일한 객체가 변경된다.
서버사이드에서는 불변하지 않는 mutable 한 특징은 유지보수에 은연중에 문제를 일으킨다. 코드의 내부 위험성을 항시 내포하게 된다. 그렇다고 표준 객체이기 때문에 계륵같은 녀석이다.
처음에는 그 위험성이 잘 와닿지 않았다. 예제를 만들어보며 발생 가능한 문제들을 이해하게 좋다고 생각했다.
왜 불변이어야 하는가
Old 라는 예전 서비스에서 다른 회사에서 인수해서 새로운 서비스인 New 를 런칭했다고 하자.
이전 Old 서비스의 기존 이메일 계정을 사용하는 사람에게는 더 좋은 혜택을 주기 위해 메일을 비교하는 로직이 있다고 하자. 유저의 타입은 아래와 같다.
type User = {
name: string
email: string
createdDate: Date
}단순한 예제를 위해 db 라는 배열에는 기존 유저 메일에 대한 정보가 담겨 있고, 이를 연동하는 함수가 있다. 그리고 정책상 생성일자를 통일화했다고 하자. (코드 상 문제가 있지만 아래에서 같이 살펴볼 예정)
const db = [
// ...
'test@test.com',
'test2@test.com',
// ...
]
function connectExistedUser(newUser: User) {
// ...
if(db.includes(newUser.email)) {
// 기존 메일이라면 계정 생성일자 통일화 (2020-01-01 의도)
newUser.createdDate.setFullYear(2020);
newUser.createdDate.setMonth(1);
newUser.createdDate.setDate(1);
}
// ...
}그리고 문제가 되는 함수다. 기본 생성일자를 오늘일자로 세팅하고 기존 회원인지 확인하고 연동하는 함수다.
function formatDate(date: Date) {
return date.toISOString().split('T')[0]
}
function problemFn() {
const now = new Date();
const newUser = {
name: 'test',
email: 'test@test.com',
createdDate: now,
} satisfies User
connectExistedUser(newUser)
console.log(`오늘 ${formatDate(now)} ${newUser.name} 님이 합류했어요!`);
}어디서 문제일까? 쉬운 예제여서 바로 알아챌 수 있다. now Date 객체를 유저객체의 createdDate 에 바인딩해서 같은 참조를 바라보고 있다.
그래서 setter 를 통해 날짜를 바꾸는 로직은 now 변수와 동일한 Date 객체를 변경하게 된다.

problemFn 함수의 마지막 출력부분에는 오늘 날짜가 아닌 변경된 일자가 출력되는 것을 확인할 수 있다.
console.log(`오늘 ${formatDate(now)} ${newUser.name} 님이 합류했어요!`);물론 위와 같은 간단한 상황은 금방 추적이 가능하고 개발자가 실수하기 어려울 거다.
그리고 Date 표준 내장 객체의 단점으로 뽑히는 직관적이지 않은 인터페이스도 있다. connectExistedUser 함수를 설명할 때 문제가 있다고 미리 얘기했다.
자바스크립트 표준 날짜 객체를 자주 다룬 사람은 금방 찾는다.
newUser.createdDate.setMonth(1); // 사실 1월은 0날짜의 달을 지정할 때, 인덱스처럼 숫자를 0부터 계산해야한다. 그리고 날짜 계산을 위한 깔끔한 메소드도 존재하지 않는다.
한 달 후인 달로 지정한다고 할 때 아래와 같이 작성해야한다.
newUser.createdDate.setMonth(newUser.createdDate.getMonth() + 1);이런 mutable 하고 좋지 못한 인터페이스를 가진 내장 표준 객체 Date 대안으로 어떤 게 있을까?
js-joda
향로님의 블로그 (링크)에서 해당 라이브러리를 처음 알게 되었다. 프론트엔드에선 date-fns 와 dayjs 가 대세였기 때문에 이것만 쓰는 줄 알았다.
프론트엔드에서 날짜 관련 라이브러리들도 불변성이 중요했지만, 유저 경험을 위해선 빠르게 리소스를 제공하기 위해선 tree shaking 이 필수적이었다. 왜냐면 가벼울 수록 다운로드 속도를 개선할 수 있기 때문이다.
날짜 핸들링하는데 필요한 다양한 함수들을 제공하고 있지만, 필요한 코드들만 번들링 되도록 하는 빌드 최적화 기법 중 하나이다. 하지만 서버 사이드에선 트리쉐이킹은 옵션이다.
서버 사이드에서는 번들링된 코드를 유저에게 제공하기 위함이 아니기 때문에 우선 순위가 낮다. 만약 빌드타임을 줄인다거나, 불필요한 다른 코드들이 너무 많이 차지하게 된다면 그때는 고려할만 하다.
js-joda 는 트리쉐이킹을 지원하지 않는다고 한다. 그래도 자바의 LocalDate 와 유사한 풍부한 인터페이스를 지원하고 있다. 자바 진영에서도 Java8부터 추가된, 기존 Date 를 대체할 LocalDate 클래스와 비슷하다.
TypeORM 에서 ts-jenum 사용하기
어플리케이션 단 타입과 데이터베이스 단 타입의 불일치를 typeORM 에선 ValueTransformer 를 통해 조회/변경 시 변형을 자동으로 해준다.
enum 을 typeORM 에 적용할 때 어떤 과정을 거쳤는 지 여기에 정리한 적이 있다. 향로님 블로그에선 postgresql 기준으로 확인했으니 나는 mysql 기준으로 확인해보려고 한다.
version: '3.9'
services:
db:
image: mysql:8.0.37
# ...mysql 버전은 8.0.37 으로 진행하였고 엔티티의 필드 타입을 아래와 같이 구성했다.
@CreateDateColumn({
type: "timestamp",
transformer: new LocalDateTimeTransformer(),
})
createdDate: LocalDateTime;mysql 에는 postgresql 의 timestampz 시간 관련 타입이 똑같이 없고, 대신 시간대를 반영한 timestamp 타입이 있다. 시간대를 포함하지 않고 로컬 시간대를 기준으로 하는 DATETIME 타입과 다르다.
LocalDateTime 와 Date 간 변환을 위한 Transformer 는 아래처럼 처리해준다.
import { ValueTransformer } from "typeorm";
import { LocalDateTime, ZoneId, ZonedDateTime, nativeJs } from "@js-joda/core";
export class LocalDateTimeTransformer implements ValueTransformer {
to(entityValue: LocalDateTime): Date {
const zonedDateTime = entityValue.atZone(ZoneId.SYSTEM);
return new Date(zonedDateTime.toInstant().toEpochMilli());
}
from(databaseValue: Date): LocalDateTime {
const zonedDateTime = ZonedDateTime.ofInstant(
nativeJs(databaseValue).toInstant(),
ZoneId.SYSTEM,
);
return zonedDateTime.toLocalDateTime();
}
}to 는 APP -> DB 로 변환할 때 호출되는 메소드이고, from 은 DB -> APP 으로 변환될 때 호출되는 메소드다.
이렇게 생각하고 있었는데, 예전부터 CreateDateColumn 과 ValueTransformer 를 같이 사용할 때 발생한 문제가 아직 해결되지 않은 모양이다.
아직 해결되지 않은 이슈
Column 데코레이터가 아닌 CreateDateColumn, UpdateDateColumn 인 경우 transformer 에 날짜가 넘어 오지 않는다.
대표적으로 아래와 같이 save 를 호출한다고 할 때, 예외가 발생한다. 예외가 발생한 지점은 transformer 에서 발생한다.
const newUser = new User();
await dataSource.manager.save(user, {
transaction: false,
reload: false, // insert 쿼리만 처리 (default 는 select 쿼리까지 호출)
});to 와 from 에 각 로깅을 남겨보았다.
to(entityValue: LocalDateTime): Date {
console.log("entityValue ", entityValue);
// ...
}
from(databaseValue: Date): LocalDateTime {
console.log("databaseValue ", databaseValue);
// ...
}콘솔을 확인해보니 undefined 으로 생성일자가 바인딩되지 않은 것이다.

이를 해결하기 위해선 ValueTransformer 에서 undefined 혹은 null 에 대한 분기 처리를 해주거나 기본 Date 객체를 사용해 LocalDateTime 을 위한 getter 를 따로 추가해주는 방법이 있다.
1. transform 분기 처리
export function isNil(value: any): value is undefined | null {
return isUndefined(value) || isNull(value)
}
export class LocalDateTimeTransformer implements ValueTransformer {
to(entityValue: LocalDateTime | null | undefined): Date | null {
if(isNil(entityValue)) {
return null;
}
const zonedDateTime = entityValue.atZone(ZoneId.SYSTEM);
return new Date(zonedDateTime.toInstant().toEpochMilli());
}
from(databaseValue: Date | null | undefiend): LocalDateTime | null {
if(isNil(databaseValue)) {
return null;
}
const zonedDateTime = ZonedDateTime.ofInstant(
nativeJs(databaseValue).toInstant(),
ZoneId.SYSTEM,
);
return zonedDateTime.toLocalDateTime();
}
}물론 nullable 을 참으로 옵션을 수정해줘야 한다.
2. LocalDateTime 으로 변환하는 getter 추가
기본 객체인 Date 를 사용하고 createdDate 의 값을 직접 가져오도록 하지말고 추가한 getter 를 통해 불변하도록 로직을 작성해나갈 수 있다.
import { ZoneId, ZonedDateTime, nativeJs } from "@js-joda/core";
export class DateTimeUtil {
static toLocalDateTime(date: Date) {
const zonedDateTime = ZonedDateTime.ofInstant(
nativeJs(date).toInstant(),
ZoneId.SYSTEM,
);
return zonedDateTime.toLocalDateTime();
}
}
class MyEntity {
@Column({
type: 'timestamp',
})
createdDate: Date;
getCreatedDate(): LocalDateTime {
return DateTimeUtil.toLocalDateTime(this.createdDate);
}
}마무리
날짜를 보다 잘 다루기 위한 방법을 알 수 있었다. 다만 TypeORM 내부 문제 때문에 원하는 코드 구성으로 작성할 수 없는 게 다소 아쉽다.