본문으로 건너뛰기

돌아가기만 하는 date formatting

· 약 21분
arch-spatula

드럽고 끔찍한 코드로 돌아가는 date formatting 하는 방법입니다.

카드 시간 보여주기

튜토리얼 리소스 접근하기

자바스크립트에서 가장 이상한 Date 문법 - 코딩애플

먼저 쉬운 것부터 학습해보겠습니다.

const date = new Date();
console.log(date); // Tue Jun 20 2023 06:01:27 GMT+0900 (한국 표준시)

Tue Jun 20 2023 06:01:27 GMT+0900 (한국 표준시)이런 형식을 보고 RFC 형식 시간이라고 부르는 듯합니다.

const date = new Date();
console.log(date.toISOString()); // 2023-06-19T21:02:35.889Z

2023-06-19T21:02:35.889Z 가독성이 당황스러울 정도로 좋아졌습니다.

라이브러리 활용하는 방법이 있지만 프로젝트가 한참 작아서 아직 설치하지 않기로 했습니다.

const date = new Date();
const foo = Intl.DateTimeFormat('kr').format(date);
console.log(foo); // 2023. 6. 20.

깔끔해졌습니다.

const foo = new Intl.RelativeTimeFormat('kr').format(-10, 'days');
console.log(foo); // 10일 전

이렇게 하면 표현을 할 수 있게 됩니다.

Time is Relative, even in JavaScript - Beyond Fireship

const formatter = new Intl.RelativeTimeFormat('kr');
const diff = new Date() - new Date('2023/06/18');

formatter.format(Math.round(-diff / (1000 * 60 * 60 * 24)), 'days'); // 2일 전

오늘 6월 20일 시점에 2일 전으로 설정할 수 있게 되었습니다.

2가지 형식

Wed May 17 2023 21:11:26 GMT+0900 (한국 표준시)

서버에서 통신할 때는 위과 같은 형식으로 생성했습니다.

2023-06-19T08:05:14.613Z

클라이언트에서 통신할 때는 위와 같은 형식으로 리소스가 생성되었습니다.

하지만 문자열로 대입하면 포멧팅 문제는 해결됩니다.

const submitDate = '2023-06-19T08:05:14.613Z'; // Wed May 17 2023 21:11:26 GMT+0900 (한국 표준시)
const formatter = new Intl.RelativeTimeFormat('kr');
const diff = new Date() - new Date(submitDate);

const showDate = formatter.format(
Math.round(diff / (1000 * 60 * 60 * 24)),
'days'
); // 2일 전

The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.

The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.

이런 에러가 발생했습니다.

const diff = new Date() - new Date('2023-06-19T08:05:14.613Z');

그냥 모두 number로 변환하면 됩니다.

const diff = +new Date() - +new Date('2023-06-19T08:05:14.613Z');

TS2362 - Error when doing arithmetic operations on date #5710

date 포멧팅 관심사 분리

화면상 랜더링을 보여주는데 형식 변환하는 관심사를 분리하는 것이 필요해보였습니다. 그리고 관심사를 분리하면 테스트 코드 작성도 쉬워보였습니다.

export function formatDate(
submitDate: Date | string | number,
stackCount: number
) {
const formatter = new Intl.RelativeTimeFormat('ko');
const diff = +new Date() - +new Date(submitDate);

const showDate = formatter.format(
Math.round(diff / (1000 * 60 * 60 * 24)),
'days'
);
return showDate;
}
import { describe, expect, it } from 'vitest';
import { formatDate } from '.';

describe('dateFormat', () => {
it('며칠 후', () => {
const submitDate = new Date('2023-06-19T08:05:14.613Z');

const showDate = formatDate(submitDate, 0);

expect(showDate).toBe('1일 후');
});
});

며칠까지는 문제가 없지만 달을 표시하는 것이 문제입니다.

또 현재를 표현하는 방법을 모르겠습니다.

테스트 코드를 작성하면서 생기는 단점인데 테스트가 일관된 결과를 갖으려면 기준일을 인자로 넘겨줘야 합니다. 저 테스트는 오늘은 맞아도 내일은 틀릴 것입니다. 다른 방법은 given에 date 전제에 추가 처리하는 방법도 있습니다.

JavaScript Intl API 사용하기

위 아티클이 좋아보입니다.

풀이까지 남은 기간 구하기

describe('intervalDate', () => {
const baseDate = new Date('2023-06-01T00:00:00');

it('should return the same date when count is negative', () => {
const result = intervalDate(baseDate, -1);
expect(result).toStrictEqual(baseDate);
});

it('should return the correct date when count is within range', () => {
const result = intervalDate(baseDate, 0);
expect(result).toStrictEqual(new Date('2023-06-01T00:10:00'));

const result2 = intervalDate(baseDate, 3);
expect(result2).toStrictEqual(new Date('2023-06-03T00:00:00'));

const result3 = intervalDate(baseDate, 7);
expect(result3).toStrictEqual(new Date('2023-06-15T00:00:00'));
});

it('should return the date with incremented year when count exceeds the range', () => {
const result = intervalDate(baseDate, 12);
expect(result).toStrictEqual(new Date('2024-06-01T00:00:00'));

const result2 = intervalDate(baseDate, 15);
expect(result2).toStrictEqual(new Date('2024-06-01T00:00:00'));
});
});

구현할 테스트 케이스들입니다. 물론 실제로는 구현하고 테스트 케이스 작성하면서 확인했습니다.

export function intervalDate(date: Date, count: number) {
const newDate = new Date(date);

const intervalMap = [
10, // 0 틀림 10분
60, // 1번 맞춤 1시간
60 * 24, // 2번 맞춤 내일
60 * 24 * 2,
60 * 24 * 3,
60 * 24 * 4,
60 * 24 * 7, // 6번 맞춤 다음주
60 * 24 * 14, // 7번 맞춤 다다음주
60 * 24 * 30.4375, // 8번 맞춤 다음달
60 * 24 * 30.4375 * 2, // 9번 다다음달
60 * 24 * 91.3125, // 10번 맞춤 다음분기
60 * 24 * 182.625, // 11번 맞춤 다음반기
];

if (count < 0) return newDate;

if (count > 11) {
newDate.setFullYear(newDate.getFullYear() + 1);
return newDate;
}

newDate.setMinutes(newDate.getMinutes() + intervalMap[count]);

return newDate;
}

배열을 활용해서 읽기만 하니까 이렇게 보면 구현은 단순합니다. 그전에는 if문, switch case문을 활용하고 있었습니다. map을 활용할까? 했었는데 읽기 위주는 굳이 사용할 필요가 없고 또 맞춘횟수가 인덱스랑 일치해서 배열을 활용했습니다.

순수함수라 비교적 테스트 작성이 쉬웠습니다.

남은 기간 포멧팅

import { describe, expect, it } from 'vitest';
import { formatDate, intervalDate } from '.';

describe('formatDate', () => {
const baseDateStr = '2023-06-01T00:00:00';
const baseDate = new Date(baseDateStr);

it('should return "지금" when there is no remaining time', () => {
const result = formatDate(baseDate, -1, baseDate);
expect(result).toBe('지금');
});

it('should return formatted remaining time', () => {
const now = new Date('2023-06-01T00:09:30'); // 9분 30초 후
const result1 = formatDate(baseDate, 0, now);
expect(result1).toBe('30초 후');

const now2 = new Date('2023-06-01T00:55:00'); // 55분 후
const result2 = formatDate(baseDate, 1, now2);
expect(result2).toBe('5분 후');

const now3 = new Date('2023-06-01T12:00:00'); // 12시간 후
const result3 = formatDate(baseDate, 2, now3);
expect(result3).toBe('12시간 후');

const now4 = new Date('2023-06-02T00:00:00'); // 1일 후
const result4 = formatDate(baseDate, 3, now4);
expect(result4).toBe('1일 후');

const now5 = new Date('2023-06-01T06:00:00'); // 2일 6시간 후
const result5 = formatDate(baseDate, 4, now5);
expect(result5).toBe('2일 후');

const now6 = new Date('2023-06-01T12:00:00'); // 3일 12시간 후
const result6 = formatDate(baseDate, 5, now6);
expect(result6).toBe('3일 후');

const now7 = new Date('2023-06-01T00:00:00'); // 7일 후
const result7 = formatDate(baseDate, 6, now7);
expect(result7).toBe('1주 후');

const now8 = new Date('2023-06-01T00:00:00'); // 14일 후
const result8 = formatDate(baseDate, 7, now8);
expect(result8).toBe('2주 후');

const now9 = new Date('2023-06-01T00:00:00'); // 1달 후
const result9 = formatDate(baseDate, 8, now9);
expect(result9).toBe('1개월 후');

const now10 = new Date('2023-06-01T00:00:00'); // 2달 후
const result10 = formatDate(baseDate, 9, now10);
expect(result10).toBe('2개월 후');

const now11 = new Date('2023-06-01T00:00:00'); // 3달 후
const result11 = formatDate(baseDate, 10, now11);
expect(result11).toBe('3개월 후');

const now12 = new Date('2023-06-01T00:00:00'); // 6달 후
const result12 = formatDate(baseDate, 11, now12);
expect(result12).toBe('6개월 후');

const now13 = new Date('2023-06-01T00:00:00'); // 1년 후
const result13 = formatDate(baseDate, 12, now13);
expect(result13).toBe('1년 후');
});
});

구현할 징그러운 테스트 케이스입니다. 물론 ChatGPT를 활용해 작성했습니다.

/**
* 다음 풀이까지 남은 시간을 포멧팅하고 표시합니다.
*/
export function formatDate(
submitDate: Date | string | number,
stackCount: number,
now = new Date()
) {
const diff = Math.max(
+intervalDate(new Date(submitDate), stackCount) - +now,
0
);

if (diff === 0) {
return '지금';
}

const diffInSeconds = Math.floor(diff / 1000);
const diffInMinutes = Math.floor(diff / (1000 * 60));
const diffInHours = Math.floor(diff / (1000 * 60 * 60));
const diffInDays = Math.floor(diff / (1000 * 60 * 60 * 24));
const diffInWeeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7));
const diffInMonths = Math.floor(diff / (1000 * 60 * 60 * 24 * 30.4375)); // 평균 월 수 (365.25 / 12)
const diffInQuarters = Math.floor(diff / (1000 * 60 * 60 * 24 * 91.3125)); // 평균 분기 수 (365.25 / 4)
const diffInHalfYears = Math.floor(diff / (1000 * 60 * 60 * 24 * 182.625)); // 평균 반년 수 (365.25 / 2)
const diffInFullYears = Math.floor(diff / (1000 * 60 * 60 * 24 * 365.25));

const formatter = new Intl.RelativeTimeFormat('ko');

if (diffInSeconds < 60) {
return formatter.format(diffInSeconds, 'second');
} else if (diffInMinutes < 60) {
return formatter.format(diffInMinutes, 'minute');
} else if (diffInHours < 24) {
return formatter.format(diffInHours, 'hour');
} else if (diffInDays < 7) {
return formatter.format(diffInDays, 'day');
} else if (diffInWeeks < 4) {
return formatter.format(diffInWeeks, 'week');
} else if (diffInMonths < 12) {
return formatter.format(diffInMonths, 'month');
} else if (diffInQuarters < 4) {
return formatter.format(diffInQuarters, 'quarter');
} else if (diffInHalfYears < 2) {
return formatter.format(diffInHalfYears, 'quarter');
} else {
return formatter.format(diffInFullYears, 'years');
}

물론 ChatGPT가 대신 작성해준 것이지만 순수함수만 호출하고 있기 때문에 테스트 케이스 작성이 수월했습니다.

지금 코드에서 아쉬운점이 많이 있습니다. 하지만 테스트코드가 있으면 리팩토링은 나중에 언제든지 쉽게 할 수 있습니다. 지금은 구현 달성에 성공한 것으로 간주하고 다음 작업을 진행하겠습니다.

리팩토링

리팩토링을 시도하면서 문제가 생겼습니다.

/**
* 다음 풀이까지 남은 시간을 포멧팅하고 표시합니다.
*/
export function formatDate(
submitDate: Date | string | number,
stackCount: number,
now = new Date()
) {
const diff = Math.max(
+getNextIntervalDate(new Date(submitDate), stackCount) - +now,
0
);

if (diff === 0) {
return '지금';
}

const diffInSeconds = Math.floor(diff / 1000);
const diffInMinutes = Math.floor(diff / (1000 * 60));
const diffInHours = Math.floor(diff / (1000 * 60 * 60));
const diffInDays = Math.floor(diff / (1000 * 60 * 60 * 24));
const diffInWeeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7));
const diffInMonths = Math.floor(diff / (1000 * 60 * 60 * 24 * 30.4375)); // 평균 월 수 (365.25 / 12)
const diffInQuarters = Math.floor(diff / (1000 * 60 * 60 * 24 * 91.3125)); // 평균 분기 수 (365.25 / 4)
const diffInHalfYears = Math.floor(diff / (1000 * 60 * 60 * 24 * 182.625)); // 평균 반년 수 (365.25 / 2)
const diffInFullYears = Math.floor(diff / (1000 * 60 * 60 * 24 * 365.25));

const formatter = new Intl.RelativeTimeFormat('ko');

switch (true) {
case diffInSeconds < 60:
return formatter.format(diffInSeconds, 'second');
case diffInMinutes < 60:
return formatter.format(diffInMinutes, 'minute');
case diffInHours < 24:
return formatter.format(diffInHours, 'hour');
case diffInDays < 7:
return formatter.format(diffInDays, 'day');
case diffInWeeks < 4:
return formatter.format(diffInWeeks, 'week');
case diffInMonths < 12:
return formatter.format(diffInMonths, 'month');
case diffInQuarters < 4:
return formatter.format(diffInQuarters, 'quarter');
case diffInHalfYears < 2:
return formatter.format(diffInHalfYears, 'quarter');
default:
return formatter.format(diffInFullYears, 'years');
}
}

이 코드를 보면 성능 문제가 당연히 발생할 것입니다. 필요없는 계산들을 처리하고 있습니다.

일단은 switch case 문으로 수정했습니다.

이 계산 처리를 분명 상수시간 복잡성을 갖게 만들 수 있습니다.

일단 발생하는 문제 중 하나는 모두 Math.floor 처리를 하고 있습니다.

직감상 바로 해야하는 것 중 하나는 배열을 활용하는 것입니다.

const diffInSeconds = Math.floor(diff / 1000);
const diffInMinutes = Math.floor(diff / (1000 * 60));
const diffInHours = Math.floor(diff / (1000 * 60 * 60));
const diffInDays = Math.floor(diff / (1000 * 60 * 60 * 24));
const diffInWeeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7));
const diffInMonths = Math.floor(diff / (1000 * 60 * 60 * 24 * 30.4375)); // 평균 월 수 (365.25 / 12)
const diffInQuarters = Math.floor(diff / (1000 * 60 * 60 * 24 * 91.3125)); // 평균 분기 수 (365.25 / 4)
const diffInHalfYears = Math.floor(diff / (1000 * 60 * 60 * 24 * 182.625)); // 평균 반년 수 (365.25 / 2)
const diffInFullYears = Math.floor(diff / (1000 * 60 * 60 * 24 * 365.25));

이부분이 제일 치명적으로 보였는데 활용하지 않을 계산을 하고 있었습니다.

이것은 전산학을 전공하지 않고 소양도 없기 때문에 알게된 것인데 여러 구간을 구할 때 상수시간복잡성으로 구하는 것은 불가능하다고 합니다.

/**
* 다음 풀이까지 남은 시간을 포멧팅하고 표시합니다.
*/
export function formatDate(
submitDate: Date | string | number,
stackCount: number,
now = new Date()
) {
const diff = Math.max(
+getNextIntervalDate(new Date(submitDate), stackCount) - +now,
0
);

if (diff === 0) {
return '지금';
}

const diffInterval = [
1000, // 1초
60 * 1000, // 1분
60 * 60 * 1000, // 1시간
24 * 60 * 60 * 1000, // 1일
7 * 24 * 60 * 60 * 1000, // 1주
30.4375 * 24 * 60 * 60 * 1000, // 평균 한달 (30.4375일)
91.3125 * 24 * 60 * 60 * 1000, // 평균 3달 (91.3125일)
182.625 * 24 * 60 * 60 * 1000, // 평균 6달 (182.625일)
365.25 * 24 * 60 * 60 * 1000, // 평균 1년 (365.25일)
];

const formatter = new Intl.RelativeTimeFormat('ko');

const diffInUnits: Intl.RelativeTimeFormatUnit[] = [
'second',
'minute',
'hour',
'day',
'week',
'month',
'quarter',
'year',
];

let idx = 0;

while (idx < diffInterval.length - 1 && diff >= diffInterval[idx + 1]) {
idx += 1;
}

const diffValue = Math.floor(diff / diffInterval[idx]);

return formatter.format(diffValue, diffInUnits[idx]);
}

이제 구했습니다.

/**
* 다음 풀이까지 남은 시간을 포멧팅하고 표시합니다.
*/
export function formatDate(
submitDate: Date | string | number,
stackCount: number,
now = new Date()
) {
const diff = Math.max(
+getNextIntervalDate(new Date(submitDate), stackCount) - +now,
0
);

if (diff === 0) return '지금';

const diffInUnits: {
value: number;
unit: Intl.RelativeTimeFormatUnit;
}[] = [
{ unit: 'second', value: 1 },
{ unit: 'minute', value: 60 },
{ unit: 'hour', value: 60 * 60 },
{ unit: 'day', value: 60 * 60 * 24 },
{ unit: 'week', value: 60 * 60 * 24 * 7 },
{ unit: 'month', value: 60 * 60 * 24 * 30.4375 }, // 평균 월 수 (365.25 / 12)
{ unit: 'quarter', value: 60 * 60 * 24 * 91.3125 }, // 평균 분기 수 (365.25 / 4)
{ unit: 'quarter', value: 60 * 60 * 24 * 182.625 }, // 평균 반년 수 (365.25 / 2)
{ unit: 'year', value: 60 * 60 * 24 * 365.25 },
];

const formatter = new Intl.RelativeTimeFormat('ko');

const duration = [60, 60, 24, 7, 4, 12, 4, 2];

let idx = 0;

while (idx < diffInUnits.length - 1 && getDiffValue(idx) >= duration[idx]) {
idx += 1;
}
const diffValue = getDiffValue(idx);

return formatter.format(diffValue, diffInUnits[idx].unit);

function getDiffValue(idx: number) {
return Math.floor(diff / (1000 * diffInUnits[idx].value));
}
}

최종적으로는 이런 형태로 리팩토링했습니다. 아쉬운점은 여전히 있습니다. duration이 하드코딩되어 있다는 것입니다. 또 diffInUnits 배열의 value 속성은 공유를 봇하고 있습니다.

분명 자기합리화이지만 테스트 코드가 있어서 리팩토링이 가능했습니다.

생각도 안하고 디버깅이 참 오래걸렸습니다.

참고로

const diffInUnits: {
value: number;
unit: Intl.RelativeTimeFormatUnit;
}[] = [
{ unit: 'second', value: 1 },
{ unit: 'minute', value: 60 },
{ unit: 'hour', value: 60 * 60 },
{ unit: 'day', value: 60 * 60 * 24 },
{ unit: 'week', value: 60 * 60 * 24 * 7 },
{ unit: 'month', value: 60 * 60 * 24 * 30.4375 }, // 평균 월 수 (365.25 / 12)
{ unit: 'quarter', value: 60 * 60 * 24 * 91.3125 }, // 평균 분기 수 (365.25 / 4)
{ unit: 'quarter', value: 60 * 60 * 24 * 182.625 }, // 평균 반년 수 (365.25 / 2)
{ unit: 'year', value: 60 * 60 * 24 * 365.25 },
];

/**
* 다음 풀이까지 남은 시간을 포멧팅하고 표시합니다.
*/
export function formatDate(
submitDate: Date | string | number,
stackCount: number,
now = new Date()
) {
const diff = Math.max(
+getNextIntervalDate(new Date(submitDate), stackCount) - +now,
0
);

if (diff === 0) return '지금';

const formatter = new Intl.RelativeTimeFormat('ko');

const duration = [60, 60, 24, 7, 4, 12, 4, 2];

let idx = 0;

while (idx < diffInUnits.length - 1 && getDiffValue(idx) >= duration[idx]) {
idx += 1;
}
const diffValue = getDiffValue(idx);

return formatter.format(diffValue, diffInUnits[idx].unit);

function getDiffValue(idx: number) {
return Math.floor(diff / (1000 * diffInUnits[idx].value));
}
}

마지막으로 조그만한 성능개선을 했습니다(이론적인 개선입니다). 함수 내부에서 객체를 생성하고 삭제하면 성능상 비효율적이고 모듈 스코프에 1번 선언하고 계산하고 참조만 하는 방식으로 바꿨습니다.

지금 다시 보니까 getDiffValue 헬퍼 함수도 모듈 스코프로 빼야겠습니다. 함수도 객체이기 때문에 제거하는 것이 적절합니다.

다른 문제도 있습니다. 리팩토링이라는 노력을 지불해도 여전히 코드가 드럽습니다. 아직도 실력이 형편없습니다.