날짜에 ‘개월’을 더하는 건 생각보다 어렵다
날짜에 7일을 더하는 건 간단하다. 숫자를 하나 얻고, 7을 더하면 끝이다. 하지만 한 달을 더하는 건 완전히 다른 문제다.
문제는 달마다 길이가 다르다는 점이다. 1월은 31일이다. 2월은 28일이고, 어떤 해에는 29일이다. 1월 31일에서 한 달을 더하면 2월 31일에 도착하게 되는데 — 그런 날짜는 존재하지 않는다.
모든 캘린더 라이브러리, 스프레드시트, 데이터베이스는 “그 다음을 어떻게 할지”에 대해 저마다의 규칙을 갖고 있다.
대부분의 도구가 하는 일
가장 흔한 동작은 목표 달의 마지막 날로 맞춰 버리는 것이다. 1월 31일 + 1개월 = 2월 28일(윤년이면 29일). 3월 31일 + 1개월 = 4월 30일.
이것을 월말 클램핑(end-of-month clamping) 이라고 하며, Excel, Google Sheets, Python의 dateutil, 그리고 대부분의 날짜 라이브러리가 기본값으로 이렇게 동작한다.
합리적이지만 미묘한 문제가 있다. 이 연산은 되돌릴 수 없다. 1월 31일에 한 달을 더하면 2월 28일이 된다. 여기서 한 달을 빼면 1월 28일이 된다 — 1월 31일이 아니다. 3일이 사라진다.
오버플로(overflow) 방식
어떤 시스템은 클램핑 대신 날짜를 다음 달로 넘겨 버리는 오버플로를 허용한다. 1월 31일 + 1개월 = 3월 3일(윤년이면 3월 2일, 2월이 29일이기 때문).
이 방식은 총 일수는 보존하지만, 결과가 의도한 달과 전혀 다른 달에 떨어진다. 놀랍고, 사용자 관점에서는 보통 잘못된 결과다.
일부 데이터베이스의 SQL INTERVAL 문법은 설정에 따라 이런 동작을 한다. 어떤 방식인지 모르고 쓰면 쉽게 문제를 겪는다.
연도를 더할 때도 같은 문제가 있다
2월 29일은 윤년에만 존재한다. 2024년 2월 29일에 1년을 더하면 2025년 2월 29일이 되어야 하는데 — 그런 날짜는 없다. 클램핑을 적용하면 2025년 2월 28일이 된다.
같은 동작, 같은 트레이드오프다.
이런 것이 실제로 버그가 되는 경우
구독 결제가 대표적인 예다. 사용자가 1월 31일에 가입한다. 다음 결제일은 2월 28일이 된다. 그 다음은 3월 28일, 그 다음은 4월 28일… 첫 달 이후로는 매달 2~3일씩 더 빨리 결제되는 셈이라 사용자는 “예상보다 빨리” 청구된다고 느낀다.
반복 캘린더 일정도 마찬가지다. “매달 31일”은 31일이 없는 달에서는 조용히 “매달 말일”로 바뀐다.
대출 상환 일정, 급여 지급일, 그리고 “매월” 반복이 들어가는 모든 것들은 결국 이 엣지 케이스를 만나게 된다.
일(day)을 더하면 이런 문제가 없다
“한 달 뒤”가 아니라 “30일 뒤”를 표현하고 싶은 것이라면 30일을 더하면 된다. 결과는 모호하지 않고, 되돌릴 수도 있다.
이 차이는 중요하다. 30일 주기의 결제와 월 단위 결제는 같은 것이 아니며, 시간이 지나면 빠르게 벌어진다.
사용하는 도구/라이브러리에서 확인할 것
어떤 시스템에서든 날짜 더하기를 믿고 쓰기 전에, 다음을 아는 것이 좋다.
- 월말 날짜에서 클램핑을 하는가, 오버플로를 하는가?
- 여러 번 더할 때 ‘일자(몇 일)’를 유지하는가, 아니면 매번 다시 클램핑되는가?
- 윤년 엣지 케이스에서 2월 29일 + 1년은 어떻게 되는가?
날짜 계산기는 어떤 날짜와 오프셋에 대해서도 정확한 결과를 보여 준다 — 코드에 적용하기 전에 sanity check로 유용하다.