월말 정기결제의 함정: 31일→28일→31일 문제 해결하기
※ 이 글의 코드는 개념 설명을 위한 예시이며, 실제 프로덕션 코드와는 다릅니다.
1월 31일에 정기결제를 시작한 고객의 다음 결제일은 언제일까요? 2월 31일은 존재하지 않습니다. 그렇다면 2월 28일? 3월은 다시 31일로 돌아가야 할까요? 정기결제 시스템에서 가장 까다로운 월말 날짜 처리 문제를 어떻게 해결했는지 공유합니다.
문제 정의: 월말 날짜의 불일치
정기결제 시스템에서 월말 날짜 처리는 생각보다 복잡합니다. 각 월의 마지막 날이 다르기 때문입니다:
1월: 31일
2월: 28일 (윤년은 29일)
3월: 31일
4월: 30일
이 차이로 인해 발생하는 실제 시나리오를 살펴보겠습니다:
시나리오 1: 31일 결제 고객
결제 시작일: 2027년 1월 31일
예상 다음 결제일: 2027년 2월 31일 → 존재하지 않음!
가능한 선택지:
1. 2월 28일로 당김 → 고객에게 3일 손해
2. 3월 1일로 미룸 → 시스템 복잡도 증가
3. 2월 마지막 날(28일)로 처리 → 원래 의도 유지시나리오 2: 30일 결제 고객
결제 시작일: 2027년 1월 30일
2월 결제일: 2월 28일 (30일 없음)
3월 결제일: 3월 30일? 3월 28일?핵심 질문: "원래 의도"를 어떻게 보존할 것인가
우리가 선택한 접근 방식은 "고객의 원래 결제 의도를 기억하고 보존한다"입니다. 31일에 결제를 시작한 고객은 "매월 말일에 결제하겠다"는 의도를 가진 것으로 해석합니다.
구현 전략: 원본 결제일 추적
// 월말 근처 날짜 체크 (28, 29, 30, 31일)
$end_of_month_days = [28, 29, 30, 31];
$today = date('d', strtotime($billing_start_date));
if (in_array($today, $end_of_month_days)) {
// 원본 결제 시작일 기록
$original_billing_day = $today;
// 이 정보를 결제 데이터에 저장
$subscription_data['original_billing_day'] = $original_billing_day;
}월말 날짜 처리 로직
다음 결제일을 계산할 때, 원본 결제일 정보를 활용합니다:
function getNextBillingDate($current_date, $original_day, $months_to_add) {
// 기본 계산: 현재 날짜에서 N개월 후
$next_date = date('Y-m-d', strtotime("+{$months_to_add} months", strtotime($current_date)));
$next_year = date('Y', strtotime($next_date));
$next_month = date('m', strtotime($next_date));
// 해당 월의 마지막 날 확인
$last_day_of_month = date('t', strtotime("{$next_year}-{$next_month}-01"));
// 원본 결제일이 해당 월의 마지막 날보다 크면 마지막 날로 조정
if ($original_day > $last_day_of_month) {
$billing_day = $last_day_of_month;
} else {
$billing_day = $original_day;
}
return "{$next_year}-{$next_month}-{$billing_day}";
}실제 적용 예시
원본 결제 시작일: 2027년 1월 31일 (original_day = 31)
1개월 후 계산:
- 2월의 마지막 날 = 28일
- 31 > 28 이므로, 결제일 = 2월 28일 ✓
2개월 후 계산:
- 3월의 마지막 날 = 31일
- 31 <= 31 이므로, 결제일 = 3월 31일 ✓ (원래 의도 복원!)
3개월 후 계산:
- 4월의 마지막 날 = 30일
- 31 > 30 이므로, 결제일 = 4월 30일 ✓윤년 처리: 2월 29일의 특수 케이스
윤년은 추가적인 고려가 필요합니다:
function isLeapYear($year) {
return (($year % 4 == 0) && ($year % 100 != 0)) || ($year % 400 == 0);
}
function getFebruaryLastDay($year) {
return isLeapYear($year) ? 29 : 28;
}
// 2028년은 윤년
// 원본 결제일 29일인 경우:
// - 2027년 2월: 28일로 조정
// - 2028년 2월: 29일 유지 (윤년)테스트 전략: 모든 날짜 조합 검증
월말 로직의 정확성을 보장하기 위해 체계적인 테스트를 설계했습니다:
// 테스트 대상 날짜: 1일 ~ 31일 (모든 날짜)
// 테스트 기간: 2027년, 2028년 (윤년 포함)
// 결제 주기: 1개월, 3개월, 6개월, 12개월, 24개월
$test_cases = [];
for ($day = 1; $day <= 31; $day++) {
foreach ([1, 3, 6, 12, 24] as $period) {
foreach (['2027', '2028'] as $year) {
// 해당 일자가 존재하는 월만 테스트
for ($month = 1; $month <= 12; $month++) {
$last_day = date('t', strtotime("{$year}-{$month}-01"));
if ($day <= $last_day) {
$test_cases[] = [
'start_date' => "{$year}-{$month}-{$day}",
'period' => $period
];
}
}
}
}
}검증 로직: 자동화된 정확성 확인
function validateBillingDate($expected_day, $actual_date, $original_day) {
$actual_day = date('d', strtotime($actual_date));
$year_month = date('Y-m', strtotime($actual_date));
$last_day_of_month = date('t', strtotime($actual_date));
// 케이스 1: 원본 결제일이 해당 월에 존재하는 경우
if ($original_day <= $last_day_of_month) {
return $actual_day == $original_day;
}
// 케이스 2: 원본 결제일이 해당 월의 마지막 날보다 큰 경우
// 해당 월의 마지막 날이어야 함
return $actual_day == $last_day_of_month;
}실제 운영에서 발견된 엣지 케이스
케이스 1: 연속된 짧은 달
시작일: 2027년 1월 31일
2월 결제: 28일 ✓
3월 결제: 31일 ✓ (복원)
4월 결제: 30일 ✓ (다시 조정)케이스 2: 윤년 경계
시작일: 2027년 2월 28일 (평년)
2028년 2월 결제: 28일? 29일?
→ 28일 유지 (원본 의도가 "28일"이었으므로)케이스 3: 장기 결제 주기
시작일: 2027년 1월 31일
12개월 결제 주기
2028년 1월 결제: 31일 ✓
2029년 1월 결제: 31일 ✓
→ 연 단위 결제는 월말 문제 최소화교훈과 베스트 프랙티스
1. "원래 의도"를 데이터로 보존하라
계산 결과만 저장하지 말고, 고객의 원래 선택을 별도 필드로 기록해두면 나중에 정확한 복원이 가능합니다.
2. 모든 날짜 조합을 테스트하라
1일부터 31일까지, 평년과 윤년, 모든 결제 주기를 조합한 체계적 테스트가 필수입니다.
3. 경계값에 집중하라
28, 29, 30, 31일은 특별히 주의 깊게 테스트해야 합니다. 대부분의 버그는 이 경계값에서 발생합니다.
4. 시간 여행 테스트를 활용하라
실제 1년을 기다릴 수 없으므로, 시스템 시간을 조작하여 미래 시점의 결제를 시뮬레이션하는 테스트 환경이 필요합니다.
마치며
월말 정기결제 처리는 단순해 보이지만, 실제로는 많은 엣지 케이스가 숨어 있습니다. "고객의 원래 의도를 보존한다"는 명확한 원칙을 세우고, 체계적인 테스트로 검증하는 것이 안정적인 결제 시스템의 핵심입니다.
다음 글에서는 이러한 월말 케이스를 포함한 3,655개의 테스트 케이스를 어떻게 설계하고 자동화했는지 상세히 다루겠습니다.
관련 글
3,655개 테스트 케이스로 검증하는 정기결제 시스템
정기결제 로직 변경 후 모든 케이스의 정확성을 어떻게 증명할까요? 2년치 날짜와 5가지 결제 주기를 조합한 3,655개 테스트 케이스 설계와 자동화 검증 시스템 구축 경험을 공유합니다.
PHP에서 Time Travel 테스트 구현하기: 미래 시점 결제 시뮬레이션
정기결제 시스템을 테스트하기 위해 PHP에서 시간 조작 테스트를 구현한 경험. HTTP 헤더를 활용한 가상 시간 설정과 배치 시뮬레이션 방법을 정리했습니다.
미리 개발하지 말자: 운영에서 사용하지 않는 코드는 제거되어야 한다
첫 웹서비스 프로젝트에서 미리 개발한 코드가 3개월째 사용되지 않고 있습니다. 기획 전에 미리 만든 코드가 왜 문제인지, AI 시대에 미사용 코드를 어떻게 다뤄야 하는지 경험을 정리했습니다.