3,655개 테스트 케이스로 검증하는 정기결제 시스템
※ 이 글의 코드는 개념 설명을 위한 예시이며, 실제 프로덕션 코드와는 다릅니다.
정기결제 로직을 변경한 후, "모든 케이스에서 정상 동작한다"는 것을 어떻게 증명할 수 있을까요? 우리는 2년치 모든 날짜와 5가지 결제 주기를 조합하여 3,655개의 테스트 케이스를 설계했습니다. 이 대규모 테스트를 어떻게 자동화했는지 공유합니다.
테스트 설계의 시작: 변수 식별
정기결제 시스템에서 결과에 영향을 미치는 변수들을 먼저 식별했습니다:
핵심 변수
결제 시작일: 1일 ~ 31일 (월에 따라 가변)
결제 주기: 1개월, 3개월, 6개월, 12개월, 24개월
시작 연도: 평년(2027) vs 윤년(2028)
시작 월: 1월 ~ 12월
테스트 케이스 수 계산
연간 유효 날짜 수:
- 31일 월(1,3,5,7,8,10,12): 7개월 × 31일 = 217일
- 30일 월(4,6,9,11): 4개월 × 30일 = 120일
- 2월: 28일 (평년) 또는 29일 (윤년)
2027년(평년): 217 + 120 + 28 = 365일
2028년(윤년): 217 + 120 + 29 = 366일
총 시작일 조합: 365 + 366 = 731일
결제 주기: 5가지
이론적 최대 테스트 케이스: 731 × 5 = 3,655개테스트 데이터 생성 전략
1단계: 더미 사용자 생성
/**
* 테스트용 더미 사용자 240명 생성
* 각 사용자가 여러 테스트 구독을 보유
*/
public function createTestUsers() {
$total_users = 240;
for ($i = 1; $i <= $total_users; $i++) {
$user_data = [
'email' => "test_billing_{$i}@test.example.com",
'name' => "테스트사용자{$i}",
'password' => $this->generateTestPassword(),
'is_test' => true
];
$user_id = $this->userRepository->insert($user_data);
$this->test_users[] = $user_id;
}
return count($this->test_users);
}2단계: 테스트 구독 생성 (핵심 로직)
/**
* 모든 날짜 × 모든 결제 주기 조합의 테스트 구독 생성
*/
public function createTestSubscriptions() {
$billing_periods = [1, 3, 6, 12, 24]; // 결제 주기 (개월)
$test_years = ['2027', '2028']; // 평년 + 윤년
$subscription_count = 0;
$user_index = 0;
foreach ($test_years as $year) {
for ($month = 1; $month <= 12; $month++) {
// 해당 월의 마지막 날 계산
$last_day = date('t', strtotime("{$year}-{$month}-01"));
for ($day = 1; $day <= $last_day; $day++) {
foreach ($billing_periods as $period) {
// 순환하며 사용자에게 구독 할당
$user_id = $this->test_users[$user_index % count($this->test_users)];
$start_date = sprintf("%s-%02d-%02d", $year, $month, $day);
$subscription_data = [
'user_id' => $user_id,
'subscription_name' => "TEST_{$start_date}_{$period}M",
'billing_start_date' => $start_date,
'billing_period' => $period,
'is_test' => true
];
$this->subscriptionRepository->insert($subscription_data);
$subscription_count++;
$user_index++;
}
}
}
}
return $subscription_count; // 3,655개
}Time Travel 기반 결제 시뮬레이션
3,655개 구독 각각에 대해 결제를 시뮬레이션합니다:
/**
* 특정 날짜로 시간 여행하여 결제 실행
*/
public function simulateBilling($subscription_id, $test_date) {
$api_url = "https://test.example.com/api/v1/billing/process";
$headers = [
'X-Test-User: automation',
'X-Test-Date: ' . $test_date, // 시간 여행
'X-Skip-External-Api: Y' // 실제 PG 호출 차단
];
$post_data = [
'subscription_id' => $subscription_id,
'billing_type' => 'recurring'
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $api_url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($post_data),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}결제 시뮬레이션 실행
/**
* 모든 테스트 구독에 대해 결제 시뮬레이션 실행
*/
public function runBillingSimulation() {
$test_subscriptions = $this->getTestSubscriptions();
$results = [];
foreach ($test_subscriptions as $subscription) {
// 결제 시작일로 시간 여행
$billing_date = $subscription['billing_start_date'];
// 결제 실행
$result = $this->simulateBilling(
$subscription['subscription_id'],
$billing_date
);
$results[] = [
'subscription_id' => $subscription['subscription_id'],
'start_date' => $billing_date,
'period' => $subscription['billing_period'],
'success' => $result['success'] ?? false,
'new_end_date' => $result['end_date'] ?? null
];
// API 부하 분산을 위한 딜레이
usleep(50000); // 50ms
}
return $results;
}자동화된 검증 시스템
검증 로직: 다음 결제일 정확성 확인
/**
* 결제 결과가 정확한지 검증
*/
public function validateBillingResult($subscription_id) {
$subscription = $this->subscriptionRepository->findById($subscription_id);
// 원본 결제 시작일과 주기 정보
$original_start_date = $subscription['billing_start_date'];
$original_start_day = date('d', strtotime($original_start_date));
$period = $subscription['billing_period'];
// 현재 저장된 다음 결제일
$actual_next_date = $subscription['next_billing_date'];
// 기대되는 다음 결제일 계산
$expected_next_date = $this->calculateExpectedNextDate(
$original_start_date,
$original_start_day,
$period
);
// 비교 검증
$is_correct = ($actual_next_date === $expected_next_date);
if (!$is_correct) {
$this->logFailure([
'subscription_id' => $subscription_id,
'expected' => $expected_next_date,
'actual' => $actual_next_date,
'original_start' => $original_start_date,
'period' => $period
]);
}
return $is_correct;
}
/**
* 기대 결제일 계산 (월말 로직 포함)
*/
private function calculateExpectedNextDate($start_date, $original_day, $period) {
$next_date = date('Y-m-d', strtotime("+{$period} months", strtotime($start_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 = str_pad($original_day, 2, '0', STR_PAD_LEFT);
}
return "{$next_year}-{$next_month}-{$billing_day}";
}배치 스크립트를 통한 대규모 테스트
실제 배치 스크립트와 동일한 환경에서 테스트하기 위해 배치 파일도 시뮬레이션합니다:
/**
* 배치 스크립트 실행 시뮬레이션
*/
public function executeBatchSimulation($test_date) {
// CLI 환경에서 TEST_DATE 상수 정의
$command = sprintf(
'php -d "TEST_DATE=%s" /path/to/jobs/recurring_billing.php 2>&1',
escapeshellarg($test_date)
);
$output = shell_exec($command);
return [
'command' => $command,
'output' => $output,
'executed_at' => date('Y-m-d H:i:s')
];
}실패 시 자동 알림: Slack 연동
/**
* 테스트 실패 시 Slack 알림 전송
*/
private function sendSlackAlert($failure_data) {
$webhook_url = getenv('SLACK_WEBHOOK_URL');
$message = [
'channel' => '#test-alerts',
'username' => 'Billing Test Bot',
'icon_emoji' => ':warning:',
'attachments' => [[
'color' => 'danger',
'title' => '정기결제 테스트 실패 감지',
'fields' => [
['title' => 'Subscription ID', 'value' => $failure_data['subscription_id'], 'short' => true],
['title' => 'Period', 'value' => $failure_data['period'] . '개월', 'short' => true],
['title' => 'Expected', 'value' => $failure_data['expected'], 'short' => true],
['title' => 'Actual', 'value' => $failure_data['actual'], 'short' => true],
['title' => 'Original Start', 'value' => $failure_data['original_start'], 'short' => false]
]
]]
];
$ch = curl_init($webhook_url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($message),
CURLOPT_HTTPHEADER => ['Content-Type: application/json']
]);
curl_exec($ch);
curl_close($ch);
}전체 테스트 실행 플로우
/**
* 전체 테스트 실행 마스터 함수
*/
public function runFullBillingTest() {
$start_time = microtime(true);
$results = [
'total' => 0,
'passed' => 0,
'failed' => 0,
'failures' => []
];
// 1. 테스트 환경 초기화
$this->resetTestEnvironment();
// 2. 더미 데이터 생성
$user_count = $this->createTestUsers();
$subscription_count = $this->createTestSubscriptions();
echo "테스트 준비 완료: {$user_count}명 사용자, {$subscription_count}개 구독\n";
// 3. 결제 시뮬레이션 실행
$billing_results = $this->runBillingSimulation();
// 4. 결과 검증
foreach ($billing_results as $br) {
$results['total']++;
if ($this->validateBillingResult($br['subscription_id'])) {
$results['passed']++;
} else {
$results['failed']++;
$results['failures'][] = $br;
// 실패 즉시 Slack 알림
$this->sendSlackAlert($br);
}
// 진행률 출력 (100개마다)
if ($results['total'] % 100 === 0) {
echo "Progress: {$results['total']}/{$subscription_count}\n";
}
}
$elapsed_time = round(microtime(true) - $start_time, 2);
// 5. 최종 결과 리포트
$this->generateTestReport($results, $elapsed_time);
return $results;
}테스트 결과 리포트
========================================
정기결제 테스트 결과 리포트
========================================
실행 시간: 2027-01-15 14:30:00
소요 시간: 847.23초 (약 14분)
총 테스트 케이스: 3,655개
성공: 3,655개 (100.00%)
실패: 0개 (0.00%)
테스트 범위:
- 시작 연도: 2027년(평년), 2028년(윤년)
- 결제 주기: 1, 3, 6, 12, 24개월
- 날짜 범위: 1일 ~ 31일 (월별 가변)
특이 케이스 검증:
- 31일 시작 → 2월 28일 조정: ✓ 통과
- 윤년 2월 29일 처리: ✓ 통과
- 24개월 장기 결제: ✓ 통과
========================================테스트 환경 정리
/**
* 테스트 완료 후 환경 정리
*/
public function cleanupTestEnvironment() {
// 테스트 구독 삭제
$this->subscriptionRepository->deleteByCondition(['is_test' => true]);
// 테스트 사용자 삭제
$this->userRepository->deleteByCondition(['is_test' => true]);
// 테스트 결제 로그 삭제
$this->billingLogRepository->deleteByCondition(['is_test' => true]);
echo "테스트 환경 정리 완료\n";
}교훈과 인사이트
1. 조합 폭발을 두려워하지 말라
3,655개 케이스가 많아 보이지만, 자동화된 테스트는 14분 만에 전체를 검증합니다. 수동 테스트로는 불가능한 커버리지입니다.
2. 실제 환경과 동일하게 테스트하라
API 호출, 배치 스크립트 실행 등 실제 운영 환경과 동일한 경로로 테스트해야 숨은 버그를 찾을 수 있습니다.
3. 즉각적인 피드백 시스템을 구축하라
Slack 알림으로 실패 즉시 인지하고, 상세한 컨텍스트를 포함하여 디버깅 시간을 단축합니다.
4. 테스트 데이터도 설계가 필요하다
무작위 데이터가 아닌, 의도적으로 모든 경계값과 조합을 포함하는 체계적인 테스트 데이터 설계가 중요합니다.
마치며
대규모 테스트는 "시간이 오래 걸린다"는 인식이 있지만, 적절한 자동화와 병렬 처리로 충분히 실용적인 시간 내에 실행할 수 있습니다. 정기결제처럼 금전이 오가는 시스템에서는 이러한 체계적인 테스트가 선택이 아닌 필수입니다.
3,655개의 테스트 케이스가 모두 통과했을 때의 안도감은, 그 어떤 수동 테스트로도 얻을 수 없는 확신을 제공합니다.
관련 글
PHP에서 Time Travel 테스트 구현하기: 미래 시점 결제 시뮬레이션
정기결제 시스템을 테스트하기 위해 PHP에서 시간 조작 테스트를 구현한 경험. HTTP 헤더를 활용한 가상 시간 설정과 배치 시뮬레이션 방법을 정리했습니다.
월말 정기결제의 함정: 31일→28일→31일 문제 해결하기
1월 31일에 시작한 정기결제의 2월 결제일은? 월마다 다른 마지막 날짜로 인한 정기결제 시스템의 엣지 케이스와 "원래 의도 보존" 전략을 통한 해결 방법을 공유합니다.
k6와 실시간 Pool 모니터링으로 시스템 한계점 찾기
k6로 시스템 한계점을 찾는 Breakpoint 테스트와 NestJS Connection Pool 실시간 모니터링 시스템을 구현한 경험. 최적 RPS를 찾기까지의 과정을 정리했습니다.