날짜 계산: 월말, 윤년, 만 나이, 음력 생일까지
날짜 강좌 4. 1월 31일 + 1개월이 2월 28일과 3월 3일로 갈리는 표, 2월 29일 + 1년, 서머타임의 23시간짜리 하루, 만 나이, 윤달 때문에 한 달 밀리는 음력 생일.
시리즈: 날짜·시간·타임존 강좌 4/6 (중급). 날짜를 읽고 출력할 수 있게 된 뒤(3강), 갱신일, 무료 체험 기간, "내일 같은 시각" 알림, 나이, 영업일 기준 배송 예정일을 계산해야 하는 개발자를 위한 글입니다. 다 읽으면 대표적인 경계 상황에서 일곱 가지 도구가 무엇을 돌려주는지 표 한 장으로 정리되고, 도구끼리 의견이 갈리는 규칙은 그대로 붙여 쓸 수 있는 짧은 함수로 갖게 됩니다.
날짜 계산은 29일이 지나기 전까지는 정수 계산처럼 보입니다. 1월 31일에 시작한 구독이 어떤 언어에서는 2월 28일, 다른 언어에서는 3월 3일에 갱신되고, 2월 29일생 회원의 생일 쿠폰 코드는 4년에 한 번만 예외를 던집니다. 한국에는 지금 서머타임이 없어 국내 서비스에선 안 보이지만, 해외 사용자에게 보내는 "매일 정오" 알림은 한 시간 어긋납니다. 여기에 만 나이와 음력 생일이라는 한국 특유의 계산이 더해집니다. 모두 Apple Silicon Mac(M1 Pro)에서 Python 3.9.6, PHP 8.5.6, Java 26, .NET 10.0.14, Swift 6.4.2, Dart 3.11.5, SQLite 3.51.0, macOS jsc 를 TZ=… 를 명시해 실행한 결과입니다.
달력 단위와 고정 길이
"시간의 양"에는 두 종류가 있고, 버그 대부분은 둘을 섞는 데서 나옵니다.
- 고정 길이(duration) 는 초 단위 숫자입니다. 24시간은 언제나 86,400초. Java
Duration(PT24H), Pythontimedelta, DartDuration. - 달력 기간(period) 은 달력으로 셉니다. "1개월", "1년", "1일". 초로 몇 초인지는 시작점에 따라 다릅니다. Java
Period(P1M,P1D), PHPDateInterval.
Java 에서 Duration.ofDays(1) 은 PT24H, Period.ofDays(1) 은 P1D 로 출력돼 차이가 눈에 보입니다. 한 달은 절대 고정 길이가 아니고, 서머타임이 있는 지역에서는 하루도 그렇습니다.
1월 31일 + 1개월: 일곱 도구, 표 한 장
2026년 2월은 28일까지라서 "1월 31일의 한 달 뒤"에는 정답이 없습니다. 모든 도구에 물어봤습니다.
| 도구(호출한 것) | 2026-01-31 +1개월 | 2026-03-31 −1개월 | 2028-02-29 +1년 |
|---|---|---|---|
| Python 함수(아래) | 2026-02-28 | 2026-02-28 | 2029-02-28 |
Python date.replace(month=2) |
ValueError: day is out of range for month |
— | ValueError(같은 문구) |
Java plusMonths / plusYears |
2026-02-28 | 2026-02-28 | 2029-02-28 |
.NET AddMonths / AddYears |
2026-02-28 | 2026-02-28 | 2029-02-28 |
Swift Calendar.date(byAdding:) |
2026-02-28 | 2026-02-28 | 2029-02-28 |
PHP modify('+1 month'), add(P1M) |
2026-03-03 | 2026-03-03 | 2029-03-01 |
JavaScript setUTCMonth / setUTCFullYear |
2026-03-03 | 2026-03-03 | 2029-03-01 |
Dart DateTime.utc(2026, 2, 31) |
2026-03-03 | 2026-03-03 | (2027-02-29 → 2027-03-01) |
SQLite date(d, '+1 month') |
2026-03-03 | 2026-03-03 | 2029-03-01 |
SQLite 'floor' 추가 |
2026-02-28 | — | 2029-02-28 |
두 번째 열을 다시 보세요. PHP, JavaScript, Dart, SQLite 에서는 3월 31일의 한 달 전이 3월 3일입니다. 월을 2월로 바꾸고 일 31을 그대로 둔 다음, 없는 "2월 31일"을 사흘 넘겨 정규화하기 때문입니다. 어느 쪽도 틀린 건 아니고 답하는 질문이 다릅니다. 결제 담당자가 말하는 "매달"은 보통 월말로 맞추는 쪽입니다.
Python 표준 라이브러리에는 월 덧셈이 없습니다(많이 쓰는 relativedelta 는 서드파티 패키지라 여기서는 쓰지 않았습니다). Java 처럼 월말로 맞추는 함수입니다.
import calendar
from datetime import date
def add_months(d: date, n: int) -> date:
y, m = divmod(d.month - 1 + n, 12)
y += d.year; m += 1
return d.replace(year=y, month=m, day=min(d.day, calendar.monthrange(y, m)[1]))
PHP 에서는 상대 형식 last day of next month 가 2026-02-28 을 돌려줬고, JavaScript 에서는 new Date(Date.UTC(2026, 2, 0))("3월 0일")이 2026-02-28 이 되어 한 달의 일수를 구할 때 쓸 수 있습니다.
월 덧셈은 결합 법칙이 성립하지 않는다
맞추는 순간 정보가 사라집니다. Java 는 chained -> 2026-03-28 vs plusMonths(2) -> 2026-03-31 을 출력했고 .NET 과 Python 함수도 같았습니다. 정기 결제일은 항상 최초 기준일에서 start.plusMonths(n) 으로 구하세요. 직전 결제일에 더하면 1월 31일 가입자는 2월 이후 영원히 28일 결제가 됩니다. PHP 의 modify('+1 month') 를 이어 붙이면 반대로 01-31 → 03-03 → 04-03 으로 밀렸습니다.
차이 계산도 다릅니다. 2026-01-31 부터 2026-03-01 까지 Java Period.between 은 P1M1D, PHP diff() 는 0 months 29 days 였고 총 29일만 같았습니다.
윤년: 1900년, 2000년, 그리고 2월 29일
그레고리력 규칙은 "4로 나눠지면 윤년, 단 100으로 나눠지면 평년, 단 400으로 나눠지면 윤년"입니다. Python calendar.isleap, Java Year.isLeap, .NET DateTime.IsLeapYear, PHP format('L') 모두 1900 은 평년, 2000 은 윤년으로 일치했습니다. 줄인 규칙 y % 4 == 0 은 1601~2400년 중 [1700, 1800, 1900, 2100, 2200, 2300] 여섯 해에서 calendar.isleap 과 달랐습니다.
깨지는 곳은 윤일 자체입니다.
Java LocalDate.of(2026, 2, 29) -> Invalid date 'February 29' as '2026' is not a leap year
.NET new DateTime(2026, 2, 29) -> Year, Month, and Day parameters describe an un-representable DateTime.
Python date(2028,2,29).replace(year=2029) -> ValueError: day is out of range for month
replace(year=...) 는 생일 쿠폰이나 기념일 처리에서 "3년은 돌고 4년째에 죽는" 대표 버그입니다. "2월 29일 + 1년"은 Python 함수와 Java 가 2029-02-28, PHP·JavaScript·SQLite 가 2029-03-01 이었습니다.
하루와 24시간: 서머타임을 건너면 다르다
Asia/Seoul 은 지금 서머타임이 없어서 Java plusDays(1) 이 정확히 24시간이었습니다. 하지만 해외 사용자에게 알림을 보내거나 해외 리전 서버를 쓰면 달라집니다. America/New_York 은 2026년 3월 8일(일)에 01:59:59 에서 03:00:00 으로, Europe/Madrid 는 3월 29일에 똑같이 넘어갔습니다(설치된 tzdata 를 zdump -v 로 확인). 토요일 정오에서 계산했습니다.
| 도구 | +1일 | +24시간 |
|---|---|---|
Java ZonedDateTime |
plusDays(1) → 12:00−04:00 |
plusHours(24) → 13:00−04:00 |
| Python aware datetime | + timedelta(days=1) → 12:00−04:00 |
+ timedelta(hours=24) → 12:00−04:00 |
PHP modify() |
'+1 day' → 12:00−04:00 |
'+24 hours' → 12:00−04:00 |
PHP add(DateInterval) |
P1D → 12:00−04:00 |
PT24H → 13:00−04:00 |
Swift Calendar |
.day, 1 → 12:00−04:00 |
addingTimeInterval(86400) → 13:00−04:00 |
Dart 로컬(TZ=America/New_York) |
— | add(Duration(days: 1)) → 13:00 |
"내일 같은 시각"은 모두 맞았고 실제로는 23시간 뒤였습니다(Java Duration.between 은 23, PHP 타임스탬프 차이는 82,800초). 놀라운 건 "+24시간" 열입니다. Python 의 aware datetime 은 같은 tzinfo 끼리 벽시계 계산을 해서 timedelta(hours=24) 가 정오에 떨어졌고, 둘의 차이도 1 day, 0:00:00 으로 나왔습니다. UTC 로 바꿔서 빼면 23:00:00 입니다. PHP modify('+24 hours') 도 같았고, add(new DateInterval('PT24H')) 만 진짜 24시간을 더했습니다. .NET DateTimeOffset 은 타임존이 아니라 고정 오프셋이라 AddDays(1) 이 2026-03-08T12:00:00-05:00, 즉 그날 뉴욕이 쓰지 않는 오프셋 값을 돌려줬습니다.
원칙: 사람의 일정은 "사용자 타임존에서 달력 단위", 타임아웃·토큰 만료 같은 정확한 길이는 "UTC 에서 고정 길이".
만 나이: 한 줄로 끝나지만 나누기는 틀린다
한국은 2023년 법 개정으로 법령과 행정에서 나이를 원칙적으로 만 나이로 통일했습니다. 그래도 화면에는 세는 나이, 연 나이를 묻는 요구가 남아 있으니 세 가지를 구분해 둡니다.
def age(birth, today): # 만 나이
return today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day))
born 2000-05-20, on 2026-05-19: age 25 | counting-age style (year diff + 1) 27 | year diff 26
born 2000-05-20, on 2026-05-20: age 26 | counting-age style (year diff + 1) 27 | year diff 26
born 2000-05-20, on 2026-12-31: age 26 | counting-age style (year diff + 1) 27 | year diff 26
같은 사람이 만 나이 25, 연 나이 26, 세는 나이 27 입니다. 흔히 쓰는 (today - birth).days // 365 는 2026-05-14 부터 이미 26 을 돌려, 윤일이 쌓인 만큼 6일 일찍 나이를 먹었습니다. 생일 1950~2010년, "오늘"은 2026년인 무작위 쌍 10만 개(시드 42)에서 days // 365 는 3,037건(3.04%), int(days / 365.25) 도 104건(0.10%) 틀렸습니다. 2008-02-29 생은 우리 함수로 2027-02-28 에 18, 2027-03-01 에 19 였습니다. 평년에 2월 29일생의 생일을 언제로 볼지는 용도에 따라 확인이 필요하니 코드에 함부로 박지 마세요.
음력 생일: 윤달이 있으면 한 달 밀린다
음력 생일을 매년 양력 날짜로 바꾸는 기능은 국내 앱에서 자주 요구됩니다. .NET 의 KoreanLunisolarCalendar 로 해 봤습니다. 이 달력은 윤달이 있는 해에 윤달을 하나의 달로 세기 때문에, GetLeapMonth 가 7을 돌려주면 "7번째 달 = 윤6월"이고 그 뒤의 달 번호가 하나씩 밀립니다.
var kl = new KoreanLunisolarCalendar();
int Index(int y, int m) { int leap = kl.GetLeapMonth(y); return (leap != 0 && m >= leap) ? m + 1 : m; }
for (int y = 2025; y <= 2028; y++) {
var naive = kl.ToDateTime(y, 8, 15, 0, 0, 0, 0);
var right = kl.ToDateTime(y, Index(y, 8), 15, 0, 0, 0, 0);
Console.WriteLine($"{y}: GetLeapMonth={kl.GetLeapMonth(y)} | 8/15 naive -> {naive:yyyy-MM-dd}, leap-aware -> {right:yyyy-MM-dd}");
}
2025: GetLeapMonth=7 | 8/15 naive -> 2025-09-06, leap-aware -> 2025-10-06
2026: GetLeapMonth=0 | 8/15 naive -> 2026-09-25, leap-aware -> 2026-09-25
2027: GetLeapMonth=0 | 8/15 naive -> 2027-09-15, leap-aware -> 2027-09-15
2028: GetLeapMonth=6 | 8/15 naive -> 2028-09-03, leap-aware -> 2028-10-03
윤달이 있는 2025년(윤6월)과 2028년(윤5월)에 단순 호출은 한 달 이른 날짜를 돌려줬습니다. PHP intl 의 dangi 달력으로 교차 확인하니 2025-09-06 은 을사년 7월 15일, 2025-10-06 은 을사년 8월 15일, 2028-10-03 은 무신년 8월 15일 이었습니다. 음력 3월 15일 생일은 2026년 2026-05-01, 2027년 2027-04-21, 2028년 2028-04-09 로 매년 바뀝니다. 이 계산은 런타임에 들어 있는 달력 데이터의 결과이며(이 .NET 버전은 0918-02-19 ~ 2051-02-10 만 지원), 공식 음력은 천문 기관이 발표하는 표가 기준입니다. 또 "윤달에 태어난 사람의 생일"을 평년에 언제로 볼지는 서비스 정책으로 정해야 합니다.
영업일 계산과 실측
영업일(월~금에서 공휴일 제외)은 배송 예정일, 처리 기한에 늘 쓰입니다. 하루씩 도는 반복문은 읽기 쉽고, 계산식은 온전한 주를 5일로 세고 나머지만 돕니다.
from datetime import date, timedelta
HOLIDAYS = {date(2026, 1, 1), date(2026, 5, 1), date(2026, 12, 25)} # 예시. 실제로는 공휴일 표를 읽는다
def bizdays_fast(start: date, end: date, holidays=HOLIDAYS) -> int: # [start, end)
weeks, rest = divmod((end - start).days, 7)
wd = start.weekday()
n = weeks * 5 + sum(1 for i in range(rest) if (wd + i) % 7 < 5)
return n - sum(1 for h in holidays if start <= h < end and h.weekday() < 5)
두 함수 모두 2026년 전체에 258, 2026-12-21 ~ 2027-01-04 에 9 를 돌려줬습니다. 이 9 에는 이미 버그가 있습니다. 표에 2026년 신정만 있어서 2027년 신정이 영업일로 세어졌습니다. 한국은 설·추석 연휴가 음력이라 해마다 날짜가 바뀌고 대체공휴일도 있으니, 공휴일은 코드가 아니라 해마다 갱신하는 데이터로 둡니다.
시작일 2020~2025년, 길이 1~1,094일인 무작위 기간 1만 개(시드 42)에서 두 함수는 모든 쌍이 일치했습니다(assert 로 확인). 5회 중앙값, Python 3.9.6, Apple Silicon Mac(M1 Pro).
| 함수 | 1만 개 처리 시간 |
|---|---|
| 하루씩 반복 | 1,812.0 ms |
| 주 수 × 5 + 나머지 | 10.2 ms |
약 178배 빨랐습니다. 주문 한 건이면 반복문으로 충분하지만, 1년 치 주문 전체의 처리 기한을 계산한다면 계산식을 쓰고 반복문은 테스트 정답으로 남겨 두세요.
흔한 실수 → 실제로 일어난 일 → 고치는 법
| 실수 | 실제 결과 | 고치는 법 |
|---|---|---|
기념일에 date.replace(year=y+1) |
2028-02-29 에서 ValueError |
calendar.monthrange 로 맞추거나 2/28·3/1 중 정책 결정 |
| 직전 결제일에 한 달 더하기 | Java: 01-31 → 02-28 → 03-28 | 항상 anchor.plusMonths(n) |
음력 8월 15일을 ToDateTime(y, 8, 15) |
윤달 있는 해에 한 달 이른 날짜 | GetLeapMonth 로 달 번호 보정 |
aware datetime 에 timedelta(hours=24) |
다음 날 정오, 실제는 23시간 | UTC 로 바꿔 더한 뒤 되돌리기 |
PHP modify('+24 hours') |
같은 벽시계 결과 | add(new DateInterval('PT24H')) |
나이를 days // 365 |
3.04% 오답 | 월·일 비교 |
솔직한 한계와 버전 메모
- SQLite 의
floor수식어는 3.46 에서 추가됐고 여기서는 3.51.0 으로 실행했습니다. 오래된 SQLite 는 넘기기만 합니다. - 표는 각 API 가 이 버전에서 돌려준 값입니다. Java·.NET·Swift 는 월말 맞춤을, PHP·JavaScript 는 범위를 넘는 날의 정규화를 문서에 적어 두고 있습니다.
- Python 표준 라이브러리에는 월 덧셈이 없습니다.
dateutil.relativedelta는 Java 처럼 맞추지만 서드파티라 실행하지 않았습니다. - 서머타임 날짜는 이 Mac 의 tzdata(2026d) 기준입니다. 한국의 과거 서머타임처럼 규칙은 정부가 바꾸므로 기억이 아니라 tzdata 에서 읽습니다.
- JavaScript 는
jsc에서만 실행했고Temporal은 없었습니다.
연습 문제
1. 2026-08-31 에 가입한 월 구독이 있습니다. 기준일에서 월말로 맞추는 규칙으로 다음 세 번의 결제일을 적고, PHP modify('+1 month') 를 이어 붙이면 어떻게 되는지 답하세요.
2. Europe/Madrid 사용자에게 2026-10-24T09:00:00+02:00 부터 "매일 9시" 알림을 보내면서 매번 86,400초를 더합니다. 10월 25일 알림은 현지 몇 시에 도착할까요?
정답
1. 기준일에서 맞추면 2026-09-30, 2026-10-31, 2026-11-30 (n = 1, 2, 3 의 add_months(anchor, n)). PHP 를 이어 붙이면 08-31 + 1개월이 2026-10-01(본문 실행 결과와 같음), 이어서 2026-11-01, 2026-12-01 이 되어 9월 결제가 통째로 사라집니다.
2. 8시. 마드리드는 2026년 10월 25일에 서머타임이 끝나(zdump 로 CEST → CET 확인) 그날이 25시간이었습니다. 09:00+02:00 의 86,400초 뒤는 08:00+01:00 입니다. 타임존 위에서 달력 하루를 더해야 합니다(Java plusDays(1), PHP add(P1D)).
다음 글
- 이전: 날짜 강좌 3/6: 파싱과 포맷, ISO 8601·로캘·음력 표시
- 다음: 날짜 강좌 5/6: 데이터베이스의 날짜와 시간
- 앞의 강좌: 1강 유닉스 타임스탬프와 UTC, 2강 타임존과 서머타임, 마지막: 스케줄링과 시계 오차
- "오늘"에 의존하는 코드 테스트: 시간·난수·동시성 때문에 흔들리는 테스트
- SQLite 날짜 함수의 바탕: SQL 기초: SELECT, WHERE, ORDER BY
- 위 Python 값과 타입: Python 기초: 타입과 가변성
- 전체 강좌: 프로그래밍 학습 로드맵
자주 묻는 질문
1월 31일에서 한 달 뒤는 며칠인가요?
도구마다 다릅니다. Java plusMonths, .NET AddMonths, Swift Calendar, calendar.monthrange 를 쓴 Python 함수는 2026-02-28 로 맞췄고, PHP modify('+1 month'), JavaScript setMonth, Dart, floor 없는 SQLite date() 는 2026-03-03 으로 넘겼습니다. 어떤 규칙이 필요한지 기획에서 정하고 29일, 30일, 31일로 테스트하세요.
만 나이는 어떻게 계산하나요?
올해 연도에서 태어난 해를 빼고, 올해 생일이 아직 안 지났으면 1을 더 뺍니다. today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day)). 일수를 365로 나누는 방식은 무작위 10만 건 중 3.04% 에서 틀렸고, 365.25로 나눠도 0.10% 에서 틀렸습니다.
음력 생일을 매년 양력으로 바꿀 때 주의할 점은?
윤달이 있는 해에는 윤달 뒤의 달 번호가 하나씩 밀립니다. .NET KoreanLunisolarCalendar 에서 ToDateTime(2025, 8, 15) 은 2025-09-06(실제로는 음력 7월 15일)을 돌려줬고, GetLeapMonth 로 보정하면 추석인 2025-10-06 이 나왔습니다. PHP intl 의 dangi 달력으로 교차 확인했습니다.