date-fns
JavaScript에서 날짜를 다루는 건 악명 높게 불편하다. 네이티브 Date 객체는 1995년에 Java의 java.util.Date를 그대로 베낀 것인데, Java는 진작에 이걸 deprecated 시키고 새로운 API로 교체했다. JavaScript만 그 유산을 아직도 안고 있다.
const date = new Date(2024, 0, 31); // 1월 31일 — 월이 0부터 시작
date.setMonth(1); // 2월로 바꾸려는데...
console.log(date); // 3월 2일 또는 3일 (!) — 2월 31일이 없어서 오버플로우
이런 함정이 곳곳에 있다. 월이 0-indexed인 것, Date 객체가 mutable해서 메서드 호출이 원본을 바꿔버리는 것, 포맷팅 기능이 사실상 없는 것. 이런 문제들 때문에 날짜 라이브러리가 필수적이었다.
오랫동안 Moment.js가 사실상 표준이었다. 하지만 Moment.js에는 구조적 문제가 있었다. 모든 기능이 하나의 거대한 객체에 체이닝되는 OOP 방식이라 트리쉐이킹이 불가능했다. format() 하나만 쓰더라도 라이브러리 전체(72KB gzipped)가 번들에 포함됐다. Moment.js 팀 스스로도 2020년에 "새 프로젝트에서는 다른 라이브러리를 쓰라"고 선언했다.
date-fns는 이 문제를 함수형 접근으로 해결한다. 날짜 조작의 모든 기능을 독립적인 순수 함수로 제공하고, 필요한 함수만 import해서 쓸 수 있다. 번들러가 사용하지 않는 함수를 자동으로 제거(트리쉐이킹)할 수 있어서, 최소 번들 사이즈가 200바이트에 불과하다.
설계 철학: 함수형 + 불변
date-fns의 모든 함수는 네이티브 Date 객체를 받아서 새로운 Date 객체를 반환한다. 원본을 변경하지 않는다. 이건 Moment.js와의 가장 큰 차이점이다.
// Moment.js — mutable, 원본이 바뀜
const m = moment("2024-01-15");
m.add(7, "days"); // m 자체가 변경됨
// date-fns — immutable, 새 객체 반환
import { addDays } from "date-fns";
const original = new Date(2024, 0, 15);
const result = addDays(original, 7);
console.log(original); // 2024-01-15 — 원본 유지
console.log(result); // 2024-01-22 — 새 객체
이 방식이 좋은 이유는 예측 가능성 때문이다. 함수를 호출해도 입력값이 바뀌지 않으니 디버깅이 쉽고, 여러 곳에서 같은 Date 객체를 참조해도 의도치 않은 변경이 없다. React 같은 불변성을 중시하는 환경과 궁합이 좋다.
또한 date-fns는 커스텀 래퍼 객체를 만들지 않는다. 입력도 Date, 출력도 Date다. 다른 라이브러리나 API와 변환 과정 없이 바로 호환된다.
핵심 함수 카테고리
date-fns는 200개가 넘는 함수를 제공하지만, 실무에서 자주 쓰는 함수는 몇 가지 카테고리로 나뉜다.
포맷팅과 파싱
가장 많이 쓰는 기능이다. format은 Date를 문자열로, parse는 문자열을 Date로 변환한다.
import { format, parse, parseISO } from "date-fns";
// Date → 문자열
format(new Date(2024, 0, 15), "yyyy-MM-dd"); // "2024-01-15"
format(new Date(2024, 0, 15), "yyyy년 M월 d일"); // "2024년 1월 15일"
format(new Date(2024, 0, 15, 14, 30), "a h:mm"); // "PM 2:30"
// 문자열 → Date
parse("2024-01-15", "yyyy-MM-dd", new Date()); // Date 객체
parseISO("2024-01-15T14:30:00Z"); // ISO 8601 문자열 파싱
포맷 토큰에서 주의할 점이 있다. MM은 월(0112)이고 59)이다. mm은 분(00yyyy는 연도(2024)이고 YYYY는 ISO week year다. 대소문자 하나 잘못 쓰면 완전히 다른 결과가 나온다.
주요 포맷 토큰:
| 토큰 | 의미 | 예시 |
|---|---|---|
yyyy | 연도 (4자리) | 2024 |
MM | 월 (2자리, zero-padded) | 01, 12 |
M | 월 (패딩 없음) | 1, 12 |
dd | 일 (2자리) | 05, 31 |
d | 일 (패딩 없음) | 5, 31 |
HH | 시간 (24시간, 2자리) | 00, 23 |
h | 시간 (12시간) | 1, 12 |
mm | 분 (2자리) | 00, 59 |
ss | 초 (2자리) | 00, 59 |
a | AM/PM | AM, PM |
EEEE | 요일 (전체) | Monday |
EEE | 요일 (축약) | Mon |
MMMM | 월 이름 (전체) | January |
parse의 세 번째 인자는 기준 Date(reference date)다. 파싱된 문자열에 포함되지 않은 정보(시간, 분, 초 등)를 이 기준 Date에서 가져온다. 보통 new Date()를 넘기면 된다.
날짜 조작
날짜에 기간을 더하거나 빼는 함수들이다. 이름이 직관적이라 따로 외울 필요가 없다.
import { addDays, addMonths, addYears, subHours, addWeeks } from "date-fns";
const today = new Date(2024, 5, 15); // 6월 15일
addDays(today, 7); // 6월 22일
addMonths(today, 1); // 7월 15일
addYears(today, -1); // 2023년 6월 15일 (음수도 가능)
subHours(today, 3); // 6월 14일 21:00
addWeeks(today, 2); // 6월 29일
add와 sub 접두사가 있는 함수 쌍이 있지만, add에 음수를 넘겨도 동일하게 작동한다. 코드 가독성을 위해 상황에 맞는 쪽을 쓰면 된다.
월 연산에서 date-fns가 똑똑한 점이 있다. 1월 31일에 1개월을 더하면 2월 31일이 아니라 2월 28일(또는 29일)로 자동 조정된다. 네이티브 Date의 오버플로우 문제가 없다.
import { addMonths } from "date-fns";
addMonths(new Date(2024, 0, 31), 1); // 2024-02-29 (윤년이라 29일)
addMonths(new Date(2023, 0, 31), 1); // 2023-02-28
날짜 비교
두 날짜 사이의 간격을 계산하거나, 순서를 비교하는 함수들이다.
import {
differenceInDays,
differenceInHours,
differenceInYears,
isBefore,
isAfter,
isEqual,
isWithinInterval,
compareAsc,
} from "date-fns";
const start = new Date(2024, 0, 1);
const end = new Date(2024, 11, 31);
differenceInDays(end, start); // 365
differenceInHours(end, start); // 8760
differenceInYears(end, start); // 0 (연도 차이니까)
isBefore(start, end); // true
isAfter(start, end); // false
isWithinInterval(new Date(2024, 5, 15), {
start: start,
end: end,
}); // true
differenceIn* 함수들은 항상 첫 번째 인자에서 두 번째 인자를 뺀다. 순서를 바꾸면 음수가 나온다. 실수하기 쉬운 부분이니 "later - earlier" 순서를 기억하면 된다.
compareAsc와 compareDesc는 배열 정렬에 직접 넘길 수 있다.
const dates = [
new Date(2024, 5, 15),
new Date(2024, 0, 1),
new Date(2024, 11, 31),
];
dates.sort(compareAsc);
// [2024-01-01, 2024-06-15, 2024-12-31]
날짜 구간 경계
특정 단위의 시작/끝 시점을 구하는 함수들이다. "이번 주 월요일", "이번 달 1일" 같은 계산에 쓴다.
import {
startOfDay,
endOfDay,
startOfWeek,
endOfMonth,
startOfYear,
} from "date-fns";
const date = new Date(2024, 5, 15, 14, 30); // 6월 15일 14:30
startOfDay(date); // 6월 15일 00:00:00.000
endOfDay(date); // 6월 15일 23:59:59.999
startOfWeek(date); // 6월 9일 (일요일 — 기본 weekStartsOn: 0)
endOfMonth(date); // 6월 30일 23:59:59.999
startOfYear(date); // 1월 1일 00:00:00.000
startOfWeek의 기본값은 일요일(0)이다. 한국처럼 월요일을 주의 시작으로 쓰는 경우 옵션으로 바꿀 수 있다.
startOfWeek(date, { weekStartsOn: 1 }); // 6월 10일 (월요일)
상대적 시간 표현
"3일 전", "2시간 후"처럼 사람이 읽기 좋은 상대 시간을 만드는 함수다. 채팅 앱, SNS 타임라인에서 자주 볼 수 있는 패턴이다.
import { formatDistanceToNow, formatDistance, formatRelative } from "date-fns";
import { ko } from "date-fns/locale";
const pastDate = new Date(2024, 5, 10);
// 현재 시점 기준 상대 시간
formatDistanceToNow(pastDate, { addSuffix: true });
// "5 days ago" (영어 기본)
formatDistanceToNow(pastDate, { addSuffix: true, locale: ko });
// "5일 전"
// 두 날짜 사이의 거리
formatDistance(new Date(2024, 0, 1), new Date(2024, 11, 31));
// "about 12 months"
// 상대적 날짜 표현 (요일/시간 포함)
formatRelative(new Date(2024, 5, 14), new Date(2024, 5, 15));
// "yesterday at 12:00 AM"
formatDistanceToNow은 addSuffix: true 옵션을 줘야 "ago"/"전" 같은 접미사가 붙는다. 기본은 접미사 없이 "5 days"만 나온다.
includeSeconds: true 옵션을 쓰면 1분 이내의 차이를 "less than 5 seconds", "less than 10 seconds" 등으로 세밀하게 표현한다. 실시간 채팅에서 유용하다.
로케일 시스템
date-fns의 로케일 처리 방식이 Moment.js와 크게 다르다. Moment.js는 전역 로케일을 설정하면 모든 인스턴스에 적용됐다. date-fns는 함수 호출마다 로케일을 명시적으로 전달한다.
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import { ja } from "date-fns/locale";
const date = new Date(2024, 5, 15);
format(date, "EEEE, MMMM do", { locale: ko });
// "토요일, 6월 15일"
format(date, "EEEE, MMMM do", { locale: ja });
// "土曜日, 6月 15日"
이 방식의 장점은 트리쉐이킹이다. 한국어 로케일만 import하면 일본어, 프랑스어 등 다른 로케일은 번들에 포함되지 않는다. Moment.js는 기본으로 모든 로케일을 포함해서 번들 사이즈가 컸다(별도 설정으로 제거 가능했지만 번거로웠다).
매번 locale을 전달하는 게 번거롭다면 래퍼 함수를 만들면 된다.
import { format as dateFnsFormat } from "date-fns";
import { ko } from "date-fns/locale";
export function format(date, formatStr, options = {}) {
return dateFnsFormat(date, formatStr, {
locale: ko,
...options,
});
}
사용 가능한 로케일은 60개 이상이다. 각 로케일 파일에는 해당 언어의 월 이름, 요일 이름, 상대 시간 표현, AM/PM 표기 등이 포함되어 있다.
트리쉐이킹과 번들 사이즈
date-fns가 Moment.js를 대체한 가장 큰 이유가 번들 사이즈다. 비교해보면 차이가 극적이다.
| 라이브러리 | 전체 크기 (gzipped) | format만 사용 시 |
|---|---|---|
| Moment.js | ~72KB | ~72KB (트리쉐이킹 불가) |
| date-fns | ~80KB | ~2KB |
Moment.js는 OOP 체이닝 구조라서 번들러가 어떤 메서드가 실제로 사용되는지 정적 분석할 수 없다. moment().format()에서 format을 떼어낼 방법이 없다.
date-fns는 모든 함수가 독립된 named export이라 번들러가 정확히 어떤 함수를 사용하는지 알 수 있다.
// 이렇게 import하면 format과 addDays만 번들에 포함
import { format, addDays } from "date-fns";
// 서브패스 import도 가능 (v2 스타일, v3에서도 호환)
import format from "date-fns/format";
v3부터는 메인 엔트리포인트(date-fns)에서 named import하는 것만으로 트리쉐이킹이 정상 작동한다. 서브패스 import은 호환성을 위해 유지되지만 굳이 쓸 필요는 없다.
v3의 주요 변경점
v3는 2023년 12월에 출시됐다. 가장 큰 변화는 TypeScript로 완전 재작성된 것이다.
TypeScript 퍼스트 클래스 지원
v2까지는 JSDoc에서 타입을 자동 생성했는데, 23,000줄짜리 괴물이 되어버렸다. v3에서는 모든 함수를 TypeScript로 재작성하고 타입을 직접 설계했다. 옵션 인터페이스도 전부 export되어서 타입 재사용이 쉽다.
import { format, type FormatOptions } from "date-fns";
const options: FormatOptions = {
locale: ko,
weekStartsOn: 1,
};
인자 검증 제거
v2에서는 런타임에 인자 개수와 타입을 검사하는 코드가 모든 함수에 있었다. TypeScript로 이 역할을 대체하면서 해당 코드를 제거했고, 최소 번들 사이즈가 300바이트에서 200바이트로 줄었다.
문자열 인자 허용
v1에서는 format("2024-01-15", "yyyy-MM-dd")처럼 문자열을 직접 넘길 수 있었는데, v2에서 "잘못된 문자열을 넘기는 실수 방지"를 위해 제거했었다. 하지만 실제로는 개발자들이 new Date("2024-01-15")로 감싸서 넘기면서 같은 버그가 발생했고, 오히려 마이그레이션만 불편해졌다. v3에서 다시 허용했다.
// v3에서 다시 가능
format("2024-01-15", "yyyy-MM-dd"); // OK
Date 클래스 확장 지원
v3의 야심찬 변화 중 하나다. UTCDate 같은 Date 확장 클래스를 지원해서, date-fns 함수들이 UTC 계산을 자연스럽게 수행할 수 있게 됐다.
import { addHours } from "date-fns";
import { UTCDate } from "@date-fns/utc";
// UTCDate를 넘기면 UTC 기준으로 계산
const utcDate = new UTCDate(2024, 5, 15, 12, 0);
addHours(utcDate, 5); // UTCDate 객체 반환 (로컬 타임존 영향 없음)
이전에는 타임존 처리를 위해 date-fns-tz라는 별도 패키지를 써야 했다. v4에서는 @date-fns/tz로 통합되어 IANA 타임존을 일급 시민으로 지원할 예정이다.
ESM + named export
v2에서는 default export를 사용했는데, v3에서 모든 함수가 named export으로 변경됐다. ESM 환경에서 더 안정적이고, 일부 번들러에서 발생하던 트리쉐이킹 문제가 해결됐다.
// v2
import format from "date-fns/format";
// v3 (권장)
import { format } from "date-fns";
실전 패턴
날짜 범위 생성
달력 UI나 차트에서 연속된 날짜 배열이 필요할 때 eachDayOfInterval을 쓴다.
import { eachDayOfInterval, eachMonthOfInterval, format } from "date-fns";
// 6월 1일 ~ 6월 7일의 모든 날짜
const days = eachDayOfInterval({
start: new Date(2024, 5, 1),
end: new Date(2024, 5, 7),
});
// [Date(6/1), Date(6/2), ..., Date(6/7)]
// 2024년의 모든 월 첫째 날
const months = eachMonthOfInterval({
start: new Date(2024, 0, 1),
end: new Date(2024, 11, 31),
});
날짜 유효성 검사
사용자 입력에서 받은 날짜가 올바른지 확인하는 패턴이다.
import { isValid, parse } from "date-fns";
function isValidDateString(str) {
const parsed = parse(str, "yyyy-MM-dd", new Date());
return isValid(parsed);
}
isValidDateString("2024-02-29"); // true (윤년)
isValidDateString("2023-02-29"); // false
isValidDateString("abc"); // false
isValid는 Date 객체가 유효한지 판단한다. Invalid Date를 감지하는 유일하게 깔끔한 방법이다.
비즈니스 로직: D-day 계산
import { differenceInCalendarDays, isPast, isFuture } from "date-fns";
function getDday(targetDate) {
const today = new Date();
const diff = differenceInCalendarDays(targetDate, today);
if (diff === 0) return "D-Day";
if (diff > 0) return `D-${diff}`;
return `D+${Math.abs(diff)}`;
}
differenceInCalendarDays와 differenceInDays의 차이를 아는 게 중요하다. differenceInDays는 정확한 24시간 단위로 계산하고, differenceInCalendarDays는 자정 기준 달력상의 날짜 차이를 계산한다. D-day처럼 "며칠 남았나"를 표현할 때는 differenceInCalendarDays가 맞다.
import { differenceInDays, differenceInCalendarDays } from "date-fns";
const a = new Date(2024, 5, 15, 23, 0); // 6월 15일 23:00
const b = new Date(2024, 5, 16, 1, 0); // 6월 16일 01:00
differenceInDays(b, a); // 0 (2시간 차이, 24시간 미만)
differenceInCalendarDays(b, a); // 1 (달력상 다른 날)
Moment.js, Day.js와의 비교
세 라이브러리는 접근 방식이 근본적으로 다르다.
Moment.js는 래퍼 객체 방식이다. moment()로 래퍼를 만들고 메서드를 체이닝한다. 가장 오래되고 기능이 풍부하지만, mutable하고 트리쉐이킹이 안 된다. 2020년부로 유지보수 모드에 들어갔다.
Day.js는 Moment.js의 API를 거의 그대로 가져오면서 크기를 2KB로 줄인 라이브러리다. immutable이고 플러그인 시스템으로 기능을 확장한다. Moment.js에서 마이그레이션할 때 코드 변경이 적다.
date-fns는 함수형 유틸리티 모음이다. 래퍼 객체 없이 순수 함수만 쓴다. 트리쉐이킹에 가장 유리하고, TypeScript 지원이 가장 좋다.
// Moment.js — OOP, mutable
moment("2024-01-15").add(7, "days").format("YYYY-MM-DD");
// Day.js — OOP, immutable
dayjs("2024-01-15").add(7, "day").format("YYYY-MM-DD");
// date-fns — FP, immutable
format(addDays(parseISO("2024-01-15"), 7), "yyyy-MM-dd");
선택 기준은 명확하다:
- Moment.js에서 최소 비용으로 옮기고 싶다 → Day.js
- 번들 사이즈를 최소화하고 TypeScript를 적극 활용한다 → date-fns
- 새 프로젝트에서 Moment.js → 쓰지 마라
정리
- 네이티브 Date의 mutable + 0-indexed 월 + 오버플로우 문제를 순수 함수 + 불변 반환으로 해결하고, named export 구조로 트리쉐이킹이 자연스럽게 작동한다.
- format/parse 토큰 대소문자(MM vs mm, yyyy vs YYYY), differenceInDays vs differenceInCalendarDays 차이, 로케일 명시적 전달 방식을 알아두면 실수를 줄일 수 있다.
- v3에서 TypeScript 재작성, 문자열 인자 재허용, UTCDate 확장 지원이 추가됐고, Moment.js → Day.js(최소 변경) 또는 date-fns(최소 번들) 중 프로젝트 상황에 맞게 선택하면 된다.