1월 31일 가입자의 다음 결제일이 3월 3일로 잡힌 이유
문제 발생
정기결제의 다음 결제일을 계산하는 함수였습니다. 지난 결제일에서 한 달을 더하면 끝이라고 생각했습니다.
function nextBillingDate(current) {
const next = new Date(current);
next.setMonth(next.getMonth() + 1);
return next;
}그런데 1월 31일에 가입한 사용자의 다음 결제일이 2월 28일이 아니라 3월 3일로 잡혔습니다. 2월 결제가 통째로 사라진 겁니다. 찾아보니 1월 29일 가입자는 3월 1일, 30일 가입자는 3월 2일이었고, 3월 31일 가입자는 4월을 건너뛰고 5월 1일로 넘어가 있었습니다. 한 번 밀린 날짜는 돌아오지도 않았습니다. 3월 3일에서 다시 한 달을 더하니 4월 3일, 5월 3일로 계속 3일에 결제됐습니다.
급하게 "넘치면 그달 말일로 자른다"는 처리를 넣었습니다. 1월 31일 다음은 2월 28일로 맞게 나왔습니다. 그런데 몇 달 뒤 "31일에 가입했는데 왜 매달 28일에 빠져나가냐"는 문의가 왔습니다. 2월 28일 다음이 3월 28일, 그다음이 4월 28일이었습니다.
원인 분석
setMonth는 날짜를 그대로 둔 채 달만 바꾸고, 넘친 날은 다음 달로 넘깁니다. MDN의 setMonth() 문서는 지금 날짜의 일(day)이 결과에 영향을 준다고 설명합니다. 개념적으로는 새 달의 1일에 현재의 일수만큼을 더하는 셈이라, 2016년 1월 31일에 setMonth(1)을 하면 2016년 3월 2일이 된다고요. 그해 2월이 29일까지였기 때문입니다. 2026년 2월은 28일까지라 "2월 31일"은 3월 3일이 됩니다. 같은 문서는 범위를 벗어난 값을 주면 다른 필드가 그만큼 조정된다고도 적어 둡니다. 에러 없이 조용히 다음 달로 넘어가니 눈치채기 어렵습니다.
로컬 시간대(Asia/Seoul)로 돌려도, UTC 메서드로 돌려도 결과는 같았습니다. 시간대 문제가 아니라 달 계산 규칙 자체의 문제입니다.
말일로 자른 값을 다음 계산의 기준으로 쓰면 원래 날짜를 잃어버립니다. 1월 31일을 2월 28일로 자르는 순간, 이 사용자가 "31일 결제"라는 정보가 사라집니다. 다음 달 계산은 28일에서 출발하니 3월 28일이 나옵니다. 이건 JavaScript만의 문제가 아니었습니다. 말일로 맞춰 주는 Java의 plusMonths와 Python dateutil의 relativedelta로도 지난 결과에 한 달씩 더해 나가 봤더니, 똑같이 3월부터 28일에 고정됐습니다.
지난 결과에 +1개월: 01-31 → 02-28 → 03-28 → 04-28 → 05-28 → 06-28 → 07-28
가입일에서 +n개월: 01-31 → 02-28 → 03-31 → 04-30 → 05-31 → 06-30 → 07-31해결 방안
-
다음 결제일은 항상 기준일에서 계산합니다. 가입일(또는 결제 기준일)과 몇 번째 결제인지를 저장해 두고, n번째 결제일은 "기준일 + n개월, 넘치면 그달 말일"로 구합니다. 지난 결제일은 기준으로 쓰지 않습니다. 위 비교의 아래 줄이 이 방식이고, 2월에만 28일로 내려갔다가 3월에는 31일로 돌아옵니다.
-
Date를 써야 한다면 말일 처리를 직접 넣습니다. 말일은 다음 달 0일로 구할 수 있습니다. MDNsetDate()문서에 따르면 날짜로 0을 주면 이전 달의 마지막 날이 됩니다. 서버 시간대에 흔들리지 않게 UTC로 계산했습니다.
function addMonthsClamped(anchor, n) {
const m = anchor.getUTCMonth() + n;
const year = anchor.getUTCFullYear() + Math.floor(m / 12);
const month = ((m % 12) + 12) % 12;
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
return new Date(Date.UTC(year, month, Math.min(anchor.getUTCDate(), lastDay)));
}
const anchor = new Date(Date.UTC(2026, 0, 31));
addMonthsClamped(anchor, 1); // 2026-02-28
addMonthsClamped(anchor, 2); // 2026-03-31윤년도 같이 맞습니다. 2028년 2월 29일 기준으로 12개월 뒤는 2029년 2월 28일, 48개월 뒤는 다시 2032년 2월 29일이 나왔습니다. 기준일이 29일을 기억하고 있어서입니다. n에 음수를 넣으면 거꾸로도 갑니다.
Temporal을 쓸 수 있으면PlainDate.add()가 기본으로 말일에 맞춰 줍니다. MDN 문서에 따르면 달을 더할 때 일은 그대로 두고, 결과 달에 그 날짜가 없으면overflow옵션을 따릅니다. 기본값인constrain이면 가장 가까운 유효한 날(2월 28일이나 29일)로 맞추고,reject면RangeError를 던집니다. MDN 예제에서도 2021년 1월 31일에 한 달을 더하면 2021년 2월 28일입니다. 여기서도 규칙은 같습니다.anchor.add({ months: n })처럼 기준일에서 더해야지, 결과에 다시 더하면 똑같이 28일에 고정됩니다.
const start = Temporal.PlainDate.from('2021-01-31');
start.add({ months: 1 }).toString(); // '2021-02-28'다만 아직 어디서나 쓸 수 있지는 않습니다. Firefox 139와 Chrome 144에 들어갔고 Node.js는 26부터 기본으로 켜져 있지만, MDN은 널리 쓰이는 일부 브라우저에서 동작하지 않는다며 아직 Baseline이 아닌 것으로 표시합니다. 이 글의 재현은 Node 22에서 했기 때문에 Temporal 예시는 MDN 예제를 그대로 옮겼습니다.
-
서버나 DB에서 계산해도 같은 규칙을 지킵니다. Java
LocalDate.plusMonths는 문서에 적힌 대로 달을 더한 뒤 날짜가 유효하지 않으면 그달의 마지막 유효한 날로 맞춥니다. 2007년 3월 31일에 한 달을 더하면 4월 30일입니다. Python dateutil의relativedelta문서도 한 달을 더해도 달 경계를 넘지 않고 말일을 쓴다고 하고, MySQLDATE_ADD도2024-03-31에 한 달을 더하면2024-04-30인 예를 보여 줍니다. 셋 다 말일 처리는 해 주지만, 지난 결과에 더해 나가면 1번의 함정에 그대로 빠집니다.anchor.plusMonths(n),anchor + relativedelta(months=n),DATE_ADD(anchor, INTERVAL n MONTH)처럼 기준일에서 계산합니다. -
테스트에는 29~31일과 윤년을 넣고, 1년을 이어서 돌려 봅니다. 1월 29일, 30일, 31일, 3월 31일, 8월 31일, 그리고 2028년 2월 29일을 넣습니다. 31일 기준일로 13번 연속 계산했을 때 다시 31일로 돌아오는지도 확인합니다. 한 달짜리 테스트로는 이번의 두 번째 버그를 못 잡습니다.
공식 문서
- MDN: Date.prototype.setMonth()
- MDN: Date.prototype.setDate()
- MDN: Temporal.PlainDate.prototype.add()
- MDN: Firefox 139 release notes — Temporal
- Chrome 144 release notes — Temporal in ECMA262
- Node.js 26.0.0 release — Temporal enabled by default
- Java: LocalDate.plusMonths(long)
- dateutil: relativedelta
- MySQL: Date and Time Functions — DATE_ADD()
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.