PHP에서 Time Travel 테스트 구현하기: 미래 시점 결제 시뮬레이션
※ 이 글의 코드는 개념 설명을 위한 예시이며, 실제 프로덕션 코드와는 다릅니다.
문제: 정기결제를 어떻게 테스트할 것인가
정기결제 로직을 변경한 후, 가장 큰 고민은 테스트였습니다. 정기결제는 특성상 "한 달 후", "1년 후"에 발생하는 이벤트입니다. 실제로 1년을 기다릴 수는 없으니, 시간을 조작해서 테스트해야 했습니다.
처음에는 단순히 date() 함수를 모킹하면 되지 않을까 생각했습니다. 하지만 레거시 PHP 환경에서 date() 함수 모킹은 생각보다 까다로웠고, 무엇보다 실제 DB와 연동된 E2E 테스트가 필요했습니다.
해결책: HTTP 헤더를 통한 시간 조작
결국 선택한 방법은 HTTP 커스텀 헤더를 통한 시간 주입이었습니다. 테스트 환경에서만 동작하도록 조건을 걸어두고, 커스텀 헤더로 가상의 현재 시간을 전달하는 방식입니다.
// cURL 요청 시 가상 시간 설정
$headers = [
'X-Test-User: automation',
'X-Test-Date: 2027-01-31 00:00:01', // 가상의 현재 시간
'X-Skip-External-Api: Y' // 실제 PG 호출 방지
];
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);서버 측에서는 테스트 환경인 경우에만 이 헤더를 인식합니다.
// 서버 측 시간 처리
if (IS_TEST_ENV && !empty($_SERVER['HTTP_X_TEST_DATE'])) {
$today = date('d', strtotime($_SERVER['HTTP_X_TEST_DATE']));
$billing_search->target_date = date('Y-m-d', strtotime($_SERVER['HTTP_X_TEST_DATE']));
} else {
$today = date('d', time());
$billing_search->target_date = date('Y-m-d');
}이렇게 하면 2024년에 개발하면서도 2027년 1월 31일에 결제가 발생하는 상황을 시뮬레이션할 수 있습니다.
PG API 호출 차단
시간 조작만으로는 부족했습니다. 실제 PG API가 호출되면 안 되기 때문입니다. 가상의 날짜로 실제 결제가 일어나면 큰일입니다.
// X-Skip-External-Api 헤더로 PG 호출 차단
$should_call_pg = !(IS_TEST_ENV && getBoolHeader("HTTP_X_SKIP_EXTERNAL_API"));
if ($should_call_pg) {
// 실제 PG API 호출 (프로덕션 환경)
$pg_response = $this->callPaymentGateway($billing_data);
} else {
// 테스트 환경: PG 호출 스킵, 성공 응답으로 가정
$pg_response = ['resultCode' => '0000', 'resultMsg' => '테스트결제'];
}이 조합으로 "2027년 1월 31일에 결제가 발생했지만, 실제 돈은 빠져나가지 않는" 테스트가 가능해졌습니다.
배치 스크립트의 시간 조작
정기결제는 웹 요청뿐 아니라 배치 스크립트로도 처리됩니다. 배치 스크립트에서도 가상 시간을 사용할 수 있어야 했습니다.
CLI 환경에서는 HTTP 헤더가 없으므로, 상수 정의 방식을 추가했습니다.
// 배치 실행 스크립트
// 4번째 인자로 test_date 전달
if (!empty($argv[4])) {
define("TEST_DATE", $argv[4]);
}
// 정기결제 배치
if (IS_TEST_ENV && defined("TEST_DATE")) {
$billing_search->target_date = date('Y-m-d', strtotime(TEST_DATE));
$today = date('d', strtotime(TEST_DATE));
}이제 shell_exec로 배치를 실행할 때 날짜를 지정할 수 있습니다.
// 2027년 4월 30일에 배치 실행 시뮬레이션
$test_date = "2027-04-30";
shell_exec("/usr/local/php/bin/php " . BATCH_DIR . "/recurring_billing.php 1 test {$test_date}");테스트 클래스 구현
이 모든 것을 조합한 테스트 클래스를 만들었습니다. 핵심 메서드는 simulatePayment()입니다.
public function simulatePayment($subscription_id, $billing_key, $period, $test_date) {
$curl = curl_init();
$headers = [
'X-Test-User: automation',
'X-Test-Date: ' . $test_date,
'X-Skip-External-Api: Y'
];
$data = [
'subscription_id' => $subscription_id,
'period_type' => $this->getPeriodType($period),
'payment_method' => 'card',
'billing_key' => $billing_key,
// ... 기타 결제 파라미터
];
curl_setopt($curl, CURLOPT_URL, "https://test.example.com/billing/process");
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($data));
$response = curl_exec($curl);
curl_close($curl);
return true;
}연속 결제 시뮬레이션
한 번의 결제만으로는 부족했습니다. 정기결제는 반복되므로, 2회차, 3회차 결제도 검증해야 했습니다.
// 1차 결제 (웹 요청)
$first_result = $this->simulatePayment($subscription_id, $billing_key, $period, "2027-01-31");
// 검증: 만료일과 다음 결제일이 맞는지 확인
$subscription = $billingService->getSubscription($subscription_id);
if ($expected_end_date != $subscription->next_billing_date) {
echo "1차 결제 후 검증 실패";
return false;
}
// 2차 결제 (배치)
$second_result = shell_exec("... recurring_billing.php 1 test {$subscription->next_billing_date}");
// 3차 결제 (배치)
$third_result = shell_exec("... recurring_billing.php 1 test {$subscription->next_billing_date}");이렇게 하면 "1월 31일 첫 결제 → 2월 28일 2차 결제 → 3월 31일 3차 결제"까지 자동으로 시뮬레이션됩니다.
Slack 알림으로 실패 감지
3,655개 테스트를 돌리다 보면 중간에 실패가 발생할 수 있습니다. 화면을 계속 지켜볼 수 없으니, 실패 시 Slack으로 알림을 보내도록 했습니다.
if ($expected_end_date != $actual_end_date) {
$this->sendSlackAlert(
"BillingTest: 만료일 불일치",
[
'expected' => $expected_end_date,
'actual' => $actual_end_date,
'subscription_id' => $subscription_id
]
);
echo "만료일 불일치: {$subscription_id}";
return false;
}테스트를 돌려놓고 다른 일을 하다가, Slack 알림이 오면 확인하는 방식으로 효율적으로 테스트할 수 있었습니다.
테스트 환경 분리의 중요성
Time Travel 테스트에서 가장 중요한 것은 테스트 환경과 프로덕션 환경의 철저한 분리입니다.
// 모든 시간 조작 코드에 테스트 환경 체크 필수
if (IS_TEST_ENV && !empty($_SERVER['HTTP_X_TEST_DATE'])) {
// 테스트 환경에서만 동작
}
if (IS_TEST_ENV && defined("TEST_DATE")) {
// 테스트 환경에서만 동작
}만약 이 조건이 빠지면, 프로덕션에서 누군가 실수로 테스트 헤더를 넣었을 때 시스템이 미래 날짜로 동작하는 참사가 발생할 수 있습니다.
배운 점
Time Travel 테스트를 구현하면서 몇 가지 교훈을 얻었습니다.
첫째, 시간 의존적인 로직은 시간 주입이 가능하도록 설계해야 한다는 것입니다. date()나 time()을 직접 호출하는 대신, 시간을 외부에서 주입받을 수 있는 구조가 테스트에 유리합니다.
둘째, 테스트 환경 분리는 아무리 강조해도 지나치지 않다는 것입니다. Time Travel 같은 위험한 기능은 반드시 환경 체크 조건이 있어야 합니다.
셋째, E2E 테스트의 가치입니다. 유닛 테스트만으로는 잡지 못하는 문제들이 있습니다. 실제 DB와 연동하고, 실제 배치 스크립트를 실행하는 E2E 테스트가 결국 안전망이 되었습니다.
마치며
레거시 PHP 환경에서 모킹 라이브러리 없이 Time Travel 테스트를 구현하는 것은 쉽지 않았습니다. 하지만 HTTP 헤더와 상수를 조합한 방식으로 충분히 실용적인 테스트 환경을 만들 수 있었습니다.
다음 글에서는 이 Time Travel 테스트를 활용해서 3,655개 테스트 케이스를 어떻게 설계하고 실행했는지 자세히 다루겠습니다.
관련 글
3,655개 테스트 케이스로 검증하는 정기결제 시스템
정기결제 로직 변경 후 모든 케이스의 정확성을 어떻게 증명할까요? 2년치 날짜와 5가지 결제 주기를 조합한 3,655개 테스트 케이스 설계와 자동화 검증 시스템 구축 경험을 공유합니다.
월말 정기결제의 함정: 31일→28일→31일 문제 해결하기
1월 31일에 시작한 정기결제의 2월 결제일은? 월마다 다른 마지막 날짜로 인한 정기결제 시스템의 엣지 케이스와 "원래 의도 보존" 전략을 통한 해결 방법을 공유합니다.
Playwright E2E 테스트: 프론트엔드와 백엔드를 동시에 검증하는 실전 가이드
단위 테스트만으로는 실제 사용자 경험을 보장할 수 없다는 것을 깨달았습니다. Playwright E2E 테스트를 통해 프론트엔드와 백엔드를 동시에 구동하고, 실제 사용자 시나리오를 검증한 경험을 정리했습니다.