시스템 점검 페이지 띄우는 한 가지 방법

  • 22nd August 2026
  • 8 min read

배포를 하거나 데이터베이스를 손보는 동안, 사용자가 들어오면 곤란한 시간이 있다. 마이그레이션이 절반쯤 돌아간 상태에서 누가 글을 저장하면 어떻게 되는지는 아무도 알고 싶지 않다.

그래서 점검 페이지를 띄운다. 간단해 보이는 기능이다. 그런데 정작 필요한 순간에 점검 페이지 자체가 안 뜨는 일이 있다.

아래는 그 문제를 피하려고 만들어본 한 가지 방법이다. 정답이 하나인 영역은 아니어서, 갈림길마다 왜 그쪽을 골랐는지 함께 적었다. 사정이 다르면 다른 선택이 나을 수도 있다.

점검 페이지를 프레임워크 위에 두면

점검 페이지를 라우트로 만드는 방법이 먼저 떠오른다. /maintenance 컨트롤러를 두고, 미들웨어에서 플래그를 확인해 그쪽으로 보내는 식이다. 설정값은 DB나 관리자 화면에 둔다.

평소에는 잘 동작한다. 문제는 점검 모드를 켜는 상황이 어떤 상황인지다.

점검을 켜는 순간은 대개 배포 직후이거나, 마이그레이션이 도는 중이거나, 뭔가 깨져서 급히 막아야 하는 때다. 바로 그 순간에 vendor 오토로드, 설정 로더, DB 연결, 라우트 캐시, CSS·JS 빌드 산출물 중 무엇이 성한지 알 수 없다.

점검 관문이 그중 하나라도 물고 있으면, 필요한 바로 그때 점검 페이지가 500으로 죽는다. 사용자는 점검 안내 대신 흰 화면이나 스택 트레이스를 본다.

그래서 관문을 프레임워크 부트스트랩보다 앞으로 옮겨봤다.

요청 점검 관문 PHP 기본 함수만 프레임워크 부트스트랩 vendor · DB · 라우트 캐시 애플리케이션 점검 중이고 통과 대상이 아니면 503 점검 페이지 왜 부트스트랩보다 앞인가 점검을 켜는 순간은 대개 배포 중이거나 마이그레이션 중이다. 바로 그때 vendor, DB, 라우트 캐시, 빌드 산출물 중 무엇이 깨져 있을지 모른다. 관문이 그중 하나라도 물고 있으면 정작 필요한 순간에 점검 페이지 자체가 500 으로 죽는다. 상태 파일이 없으면 stat 한 번으로 즉시 통과하므로, 평소 비용은 사실상 없다.
관문이 부트스트랩 앞에 있으면, 프레임워크가 깨진 상황에서도 점검 페이지는 뜬다.
<?php
// public/index.php

// 점검 관문 — 반드시 부트스트랩보다 앞
require __DIR__ . '/../app/library/_maintenance.php';
maintenance_guard();

require __DIR__ . '/../app/library/_init.php';   // 여기서부터 프레임워크

이렇게 두면 관문 파일에 규칙이 하나 생긴다. 아무것에도 의존하지 않는다. 언어 기본 함수만 쓰고, 파일 하나만 읽고, HTML을 인라인으로 뱉는다. 외부 CSS도 링크하지 않는다 — 빌드 산출물이 없을 수도 있으니까.

그래서 공통 유틸리티 함수도 재사용하지 않았다. IP 대역 비교 같은 함수가 이미 있어도, 그 파일이 다른 것을 물고 있을 수 있어서다. 중복을 감수하고 축약 복제하는 쪽을 택했다. 대신 원본을 고칠 때 여기도 봐야 한다는 주석을 남겼다. 중복을 싫어한다면 거슬리는 선택일 텐데, 여기서는 의존성 없음이 더 중요하다고 봤다.

상태는 어디에 두나

"점검 중인가"라는 상태를 어디에 저장할지가 다음 문제다. 후보를 하나씩 지워봤다.

  • DB — DB가 내려간 상황에서도 점검 페이지는 떠야 하니 제외.
  • 환경 변수 파일 — 배포할 때마다 덮이거나, 반영하려면 프로세스를 재시작해야 해서 제외.
  • 코드 상수 — 켜고 끄는 데 배포가 필요해져서 제외. 급할 때 쓸 수 없다.

남은 것은 파일 하나였다. 대신 배포 동기화에서 제외되는 디렉터리에 둬야 한다. rsyncgit pull로 덮이면 점검 중에 배포가 한 번 돌면서 점검이 꺼져버린다.

// storage/maintenance.json — 파일이 없으면 곧 '꺼짐'
{
    "on": true,
    "title": "시스템 점검",
    "message": "데이터베이스 이전 작업",
    "start": "2026-08-22 22:00:00",
    "end": "2026-08-22 23:30:00",
    "auto_off": true,
    "scope": "all",
    "pass": "a1b2c3d4e5f6",
    "allow": []
}

파일이 없으면 꺼짐으로 본다. 평소 비용은 stat 한 번이라 사실상 없다.

판단이 안 되면 통과시킨다

여기서 갈림길이 하나 나온다. 상태 파일이 깨졌을 때 어느 쪽으로 기울 것인가.

JSON이 잘못됐거나, 파일을 읽을 수 없거나, 형식이 예상과 다르면 어떻게 할까. 안전해 보이는 선택은 "확실하지 않으니 막는다"이다. 여기서는 반대로 갔다.

파일 하나가 잘못돼서 전 서비스가 막히는 쪽이 훨씬 나쁘다. 점검을 켜야 하는데 안 켜지는 것은 사람이 알아채고 다시 켜면 된다. 반대로 서비스 전체가 원인 모를 이유로 막히면, 원인을 찾는 동안 계속 막혀 있다.

보안 검사라면 "의심스러우면 차단"이 맞다. 다만 이 관문은 보안 장치가 아니라 안내 장치라고 봤기 때문에 방향을 반대로 잡았다. 점검 중에 반드시 아무도 못 들어와야 하는 요건이 있다면 이 판단은 달라진다.

그런데 나는 어떻게 들어가지

점검 페이지를 띄우고 나면 바로 부딪히는 문제다. 막아놓고 작업을 확인해야 하는데, 나도 막힌다.

흔한 답은 IP 허용 목록이다. 사무실 IP를 열어두는 것이다. 그런데 상황에 따라 이게 잘 안 맞는다.

사무실 IP는 하나뿐이라 "나"와 "다른 직원"을 구분하지 못한다. 사무실을 열어두면 아무것도 모르는 동료가 작업 중인 화면에 들어와 데이터를 건드린다. 사무실도 막으면 나도 못 들어간다. 게다가 집이나 휴대폰 LTE에서 확인하려면 IP가 매번 달라진다.

그래서 통과 링크를 붙여봤다. 점검을 켤 때마다 토큰을 새로 만들어 출력하고, 그 링크로 한 번 들어오면 쿠키를 심어 그 브라우저만 계속 통과시킨다.

https://example.com/?pass=a1b2c3d4e5f6
    ↓  쿠키를 심고 파라미터를 뺀 주소로 되돌린다
https://example.com/          ← 이후 이 브라우저는 계속 통과

IP가 아니라 브라우저 단위로 여는 것이 요점이다. 사무실을 통째로 열지 않아도 되고, 집에서든 휴대폰에서든 링크 하나면 된다. 점검을 끄면 토큰도 함께 사라진다.

통과 판정 순서는 이렇게 된다.

순서 조건 결과
1통과 쿠키가 있다통과
2허용 IP + 사외만 차단 모드통과
3상태 파일을 읽을 수 없다통과 (fail open)
4명령줄 실행(크론)대상 아님
5그 외503 점검 페이지

차단 범위는 둘로 나눠뒀다. 기본값은 전체 차단이고, 여럿이 함께 확인해야 할 때만 "외부만 차단"으로 켠다. 기본값을 엄격한 쪽에 둔 건 급할 때 실수를 줄이기 위해서다.

크론은 대상에서 뺐다. 관문을 웹 진입점에서만 부르고 명령줄 실행이면 즉시 반환한다. 배치까지 멈춰야 하는 작업이라면 별도로 막는 편이 낫다고 봤다.

HTTP 상태 코드를 제대로 주기

이 부분은 취향의 영역이 아니라 사양이 정해둔 쪽이 있다. 점검 페이지를 200 OK로 내보내면 검색엔진에게 "이 사과문이 이 주소의 내용입니다"라고 말하는 셈이 된다. 운이 나쁘면 그대로 색인된다.

http_response_code(503);
header('Retry-After: 5400');                        // 남은 시간(초)
header('Cache-Control: no-store, no-cache, must-revalidate');
  • 503 — "지금은 서비스할 수 없지만 일시적이다". 검색엔진은 이걸 보고 색인을 건드리지 않고 나중에 다시 온다.
  • Retry-After — 종료 예정 시각까지 남은 초를 넣는다. 크롤러와 클라이언트가 재시도 시점을 잡는 근거가 된다.
  • 캐시 금지 — 점검이 끝났는데 캐시된 점검 페이지가 계속 보이면 곤란하다.

API 요청에는 HTML 대신 JSON을

놓치기 쉬운 부분이다. 요즘 화면은 대부분 비동기 요청을 쓴다. 점검 중에 그 요청이 HTML 페이지를 받으면 클라이언트는 JSON 파싱에 실패하고 정체불명의 오류를 띄운다. 사용자는 점검 중인지도 모른 채 "알 수 없는 오류"만 본다.

$wantsJson = strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'xmlhttprequest'
          || strpos($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json') !== false;

if ($wantsJson) {
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode([
        'result'  => false,
        'code'    => 503,
        'message' => '시스템 점검 중입니다. 오늘 23시 30분부터 이용하실 수 있습니다.',
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

같은 안내를 같은 상태 코드로, 형식만 바꿔서 준다.

페이지에는 딱 하나만 크게

페이지에 무엇을 쓸지는 이렇게 정리했다. 사용자가 알고 싶은 것은 대개 하나다 — 언제 다시 쓸 수 있나.

시스템 점검 오늘 23시 30분 부터 이용하실 수 있습니다 22시 시작 · 1시간 30분 소요 예정 데이터베이스 이전 작업 사용자가 알고 싶은 것은 이거 하나뿐이다 근거는 작게 "불편을 드려 죄송합니다" 는 아무 정보도 주지 않는다. 언제 다시 쓸 수 있는지를 크게 적는다.
점검 페이지가 답해야 할 질문은 하나다.

"불편을 드려 죄송합니다"는 아무 정보도 주지 않는다. 사과가 필요 없다는 뜻이 아니라, 그게 가장 큰 글씨일 이유가 없다는 뜻이다. 가장 큰 글씨는 종료 예정 시각이어야 한다.

시간을 다루니 상태가 셋으로 갈린다.

  • 예약 — 시작 시각이 미래면 아직 막지 않는다. 미리 켜두고 시간이 되면 자동으로 막히게 할 수 있다.
  • 진행 중 — 종료 예정 시각을 보여준다.
  • 지연 — 종료 예정을 넘겼는데 아직 안 끝났다면, 지난 시각을 계속 보여주면 안 된다. "예정보다 지연되고 있습니다"로 문구를 바꾼다. 거짓말하는 안내는 없느니만 못하다.

끝나는 시각을 아는 작업이라면 자동 해제를 걸어둘 수 있다. 종료 시각이 지나면 관문이 스스로 상태 파일을 지운다. 점검을 끄는 것을 잊어서 다음 날 아침까지 막혀 있는 사고가 생각보다 흔하다.

켜고 끄는 일은 명령 하나로

새벽 두 시에 JSON을 손으로 편집하고 싶은 사람은 없다. 오타 하나로 서비스가 막히거나, 반대로 안 막힌다.

그래서 작은 CLI를 하나 뒀다. 인자 없이 실행하면 현재 상태와 메뉴를 보여주고, 켤 때는 필요한 것들을 물어본다 — 작업 내용, 시작 시각, 예상 소요 시간, 차단 범위. 그리고 통과 링크를 만들어 출력한다.

$ tools/maintenance

  점검 모드: 꺼짐

  1) 켜기   2) 예약   3) 상태 보기   0) 나가기
  > 1

  작업 내용   : 데이터베이스 이전 작업
  시작        : 22:00
  소요 시간   : 90분
  차단 범위   : 전체

  점검 모드를 켰습니다.
  통과 링크 → https://example.com/?pass=a1b2c3d4e5f6

통과 링크를 켜는 시점에 출력하게 한 것이 편했다. 나중에 찾아 헤매지 않아도 되고, 켤 때마다 토큰이 새로 생기니 지난 링크가 살아 있을 걱정도 없다.

통과한 요청에 표시를 남긴다

통과 링크로 들어오면 사이트가 평소와 똑같이 보인다. 그래서 점검을 켜둔 사실을 잊는다. 작업을 마치고 브라우저를 닫은 뒤에도 사용자는 계속 막혀 있다.

header('X-Maintenance: on; via=pass; 2026-08-22 23:30:00');

통과한 요청에 헤더를 하나 붙여두면 개발자 도구에서 바로 확인된다. 화면 구석에 작은 배지를 띄우는 방법도 있다.

이 방법의 선택들

  • 관문을 프레임워크 부트스트랩보다 앞에 뒀다. 아무것에도 의존하지 않는다.
  • 상태는 배포에 덮이지 않는 위치의 파일 하나로. 없으면 꺼짐.
  • 판단이 안 되면 통과. 보안 장치가 아니라 안내 장치로 봤기 때문이다.
  • 관리자 출입은 IP 대신 통과 링크 + 쿠키로. 켤 때마다 새로 발급한다.
  • 503Retry-After. 비동기 요청에는 JSON으로. (이건 사양이 정해둔 쪽)
  • 페이지에는 종료 예정 시각을 가장 크게. 지연되면 문구를 바꾼다.
  • 자동 해제통과 표시 헤더로 끄는 것을 잊는 사고를 줄였다.

점검 페이지는 평소에 아무도 보지 않는 기능이다. 그래서 대충 만들어두기 쉽고, 정작 필요한 날 제 역할을 못 하기도 한다. 위의 선택들이 전부 정답이라고는 생각하지 않는다. 다만 가장 아무것도 믿을 수 없는 순간에 동작해야 하는 코드라는 성격 하나만 붙들고 있으면, 어디에 두고 무엇에 기대지 말지를 정하기가 한결 쉬워진다.