한 줄 요약: 제품마다 제각각이던 CI4 기본 에러 화면을, 설계서 한 장으로 규칙을 먼저 정한 뒤 AIvance 브랜드 템플릿 한 벌로 바꿨습니다. 핵심은 예쁜 화면이 아니라 "에러 화면은 그 자체로 에러가 나면 안 된다"는 제약이었습니다.

CI4로 만든 제품이 늘면서 사용자가 보는 에러 화면이 세 종류가 됐습니다. 개발 서버에서는 Whoops 화면이, 운영에서는 프레임워크 기본 "Whoops! We seem to have hit a snag" 문구가, 404에서는 영문 기본 페이지가 나왔습니다. 로고도, 한국어 안내도, 돌아갈 링크도 없었습니다.

사용자 입장에서 에러 화면은 서비스가 가장 불안해 보이는 순간입니다. 그 순간에 영문 기본 화면이 뜨면 "이 회사가 운영을 하고 있긴 한가"라는 인상을 줍니다. 그래서 공통 기반인 ci4-board에 브랜드 에러 템플릿을 넣고, 모든 제품이 물려받게 하기로 했습니다.

이번에도 클로드코드에게 바로 "에러 페이지 만들어줘"라고 하지 않았습니다. 설계서를 먼저 쓰고, 그 설계서를 지시문으로 넘겼습니다. 이 글은 그 설계서의 내용과 구현에서 실제로 걸렸던 지점을 정리한 것입니다.

설계서에 먼저 적은 원칙

설계서는 화면 시안보다 "하지 말아야 할 것"을 먼저 적었습니다. 에러 페이지는 앱이 이미 깨진 상태에서 그려지기 때문입니다. DB가 죽었을 수도, 세션 저장소가 죽었을 수도, 뷰 렌더러 자체가 예외의 원인일 수도 있습니다.

#

원칙

이유

1

자기완결: DB·세션·view()·외부 CDN을 쓰지 않는다

에러를 그리다 또 에러가 나면 빈 화면이 된다

2

상태코드를 바꾸지 않는다

예쁜 404가 200으로 나가면 검색엔진과 모니터링이 성공으로 집계한다

3

내부 정보를 노출하지 않는다

예외 메시지, SQL, 파일 경로는 화면에 나가지 않는다

4

500에는 추적 번호를 보여준다

문의가 오면 로그 한 줄로 바로 찾을 수 있어야 한다

5

다음 행동을 준다

홈, 이전 페이지, 문의 링크 중 상황에 맞는 것

6

접근성 기준을 지킨다

기존 CLAUDE.md의 KWCAG 요구사항 그대로, 대비 4.5:1 이상

7

API 요청에는 HTML을 주지 않는다

API 쪽은 이미 RFC 9457 기반 api-error-contract 규약이 있다

1번과 2번이 이 설계의 거의 전부였습니다. 나머지는 이 두 가지를 지키면서 화면을 얼마나 친절하게 만드느냐의 문제였습니다.

설계서 마지막에는 클로드코드용 지시를 따로 적었습니다. "에러 뷰 안에서 view(), helper(), session(), model()을 호출하지 말 것", "폰트와 CSS는 인라인으로 넣을 것", "각 상태코드마다 Feature 테스트를 쓸 것" 세 줄입니다. 이 세 줄이 없었다면 클로드코드는 거의 확실하게 기존 레이아웃을 extend()해서 만들었을 겁니다. 평소에는 그게 맞는 방법이기 때문입니다.

CI4가 에러 뷰를 고르는 방식

템플릿을 만들기 전에 CI4가 어떤 파일을 고르는지부터 확인했습니다. 4.4 이후 기본 ExceptionHandler는 app/Views/errors/html/ 아래에서 이 순서로 파일을 고릅니다.

  1. 기본값은 production.php입니다.
  2. display_errors가 켜져 있으면(개발 환경) error_exception.php, 곧 Whoops 화면으로 바뀝니다.
  3. 예외가 PageNotFoundException이면 환경과 상관없이 error_404.php를 씁니다.
  4. 예외 코드와 같은 error_<코드>.php 파일이 있으면 그 파일을 씁니다.
// system/Debug/ExceptionHandler.php 의 판단 흐름 (요약)
$view = 'production.php';
if (/* display_errors 켜짐 */) {
    $view = 'error_exception.php';
}
if ($exception instanceof PageNotFoundException) {
    return 'error_404.php';
}
if (is_file($templatePath . 'error_' . $exception->getCode() . '.php')) {
    return 'error_' . $exception->getCode() . '.php';
}
return $view;

여기서 두 가지를 알게 됐습니다. 첫째, 프레임워크 코어를 건드리지 않고 파일을 추가하는 것만으로 상태코드별 화면을 만들 수 있습니다. 둘째, 4번 규칙 때문에 error_403.php, error_429.php처럼 필요한 코드만 골라서 만들면 되고, 나머지는 전부 production.php가 받습니다.

error_exception.php는 손대지 않았습니다. 개발 중에는 스택 트레이스가 보여야 하고, 그 화면은 운영에 절대 나가지 않습니다. 운영 서버의 .env가 CI_ENVIRONMENT = production인지는 배포 체크리스트에서 따로 확인합니다.

템플릿 구조와 문구 설계

파일은 다섯 개로 끝났습니다. 공통 틀은 _shell.php 하나에 두고, 상태코드별 파일은 문구만 정해서 그 틀을 include합니다. view()와 레이아웃 상속을 쓰지 않으니, 뷰 렌더러가 고장 난 상황에서도 순수 PHP include로 화면이 그려집니다.

app/Views/errors/html/
├── _shell.php        # 공통 틀: 인라인 CSS, SVG 로고, 다크모드
├── error_404.php     # 페이지 없음
├── error_403.php     # 권한 없음
├── error_429.php     # 요청 과다
├── production.php    # 그 밖의 모든 오류(500 포함)
└── error_exception.php  # 개발용 Whoops (원본 유지)

문구는 상태코드마다 "무슨 일인지 / 사용자 잘못인지 / 다음에 할 일" 세 가지를 한 화면에 담도록 정했습니다.

코드

제목

안내

버튼

추적 번호

404

페이지를 찾을 수 없습니다

주소가 바뀌었거나 삭제된 페이지입니다

홈으로 · 이전 페이지

없음

403

접근 권한이 없습니다

로그인 계정에 이 메뉴 권한이 없습니다

홈으로 · 다시 로그인

없음

429

잠시 후 다시 시도해 주세요

짧은 시간에 요청이 많았습니다

새로고침

없음

500 외

일시적인 오류가 발생했습니다

저희 쪽 문제이며 기록되었습니다

홈으로 · 문의하기

표시

500 화면에 "저희 쪽 문제"라고 적은 이유가 있습니다. 사용자가 자기가 뭘 잘못 눌렀는지 고민하지 않게 하기 위해서입니다. 반대로 404와 403에는 사과 문구를 넣지 않았습니다. 사과보다 무엇을 하면 되는지가 더 필요한 상황입니다.

브랜드는 덜어서 넣었다

브랜드 요소는 SVG 로고 하나와 브랜드 색 하나로 줄였습니다. AIvance 보라 그라디언트(#8B6EFF → #5B3DF0 → #3B22B0)는 로고 안에만 쓰고, 버튼과 글자에는 가운데 색 #5B3DF0 하나만 씁니다. 로고는 인라인 SVG라 이미지 요청도 없습니다.

폰트는 Pretendard CDN을 쓰지 않고 시스템 폰트 스택으로 갔습니다. 에러 화면이 외부 CDN에 기대면, CDN이 느린 날 에러 화면까지 느려집니다. 한글은 "Apple SD Gothic Neo", "Malgun Gothic"이 받아 주니 화면 차이도 크지 않았습니다.

구현 코드

공통 틀: _shell.php

상태코드별 파일이 변수 몇 개를 채운 뒤 이 파일을 include합니다. 이스케이프도 헬퍼 대신 htmlspecialchars를 직접 씁니다. 의존하는 것이 PHP 자체뿐이어야 하기 때문입니다.

 $actions @var ?string $traceId */
$e = static fn (string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
?>










…

[$label, $href]): ?>

문의하실 때 이 번호를 알려 주세요:



상태코드별 파일: error_404.php

 

기본 error_404.php는 개발 환경에서 $message를 보여 줍니다. 새 파일은 환경과 상관없이 $message를 아예 출력하지 않습니다. 라우트 이름이나 컨트롤러 경로가 메시지에 섞여 나오는 것을 막기 위해서입니다.

나머지 전부: production.php

 

TraceId는 api-error-contract 스킬을 만들 때 넣은 traceId 필터가 요청 시작 시점에 값을 정해 두는 정적 클래스입니다. API 에러 응답의 traceId와 HTML 에러 화면의 번호가 같은 값이라, 사용자가 화면 번호를 알려 주면 API 로그와 웹 로그를 같은 키로 찾을 수 있습니다. 필터가 돌기 전에 예외가 나면 current()가 그 자리에서 무작위 값을 만들어 돌려줍니다.

production.php의 $code를 500으로 적었지만, 이것은 화면에 표시하는 숫자일 뿐입니다. 실제 HTTP 상태는 ExceptionHandler가 템플릿을 고르기 전에 이미 정해 두므로 템플릿은 상태코드를 건드리지 않습니다. 원칙 2번이 코드 구조로 지켜지는 셈입니다.

API 요청과 PHP 바깥의 에러

API에는 HTML을 주지 않는다

CI4 기본 핸들러는 Accept 헤더에 text/html이 없으면 HTML 템플릿 대신 JSON이나 XML로 응답합니다. 이 동작은 그대로 두되, /api/ 경로는 Config\Exceptions::handler()에서 api-error-contract의 핸들러로 넘기도록 했습니다. 그래서 같은 404라도 브라우저는 브랜드 화면을, API 클라이언트는 RFC 9457 형식의 JSON을 받습니다.

// app/Config/Exceptions.php
public function handler(int $statusCode, Throwable $exception): ExceptionHandlerInterface
{
    if (str_starts_with(service('request')->getPath(), 'api/')) {
        return new \App\Exceptions\ApiExceptionHandler($this);   // Problem Details JSON
    }

    return new ExceptionHandler($this);   // errors/html/ 템플릿
}

이 분기가 없으면 브라우저에서 API 주소를 직접 열었을 때 Accept: text/html 때문에 브랜드 HTML이 나갑니다. 사람에게는 괜찮지만, 같은 주소를 호출하는 프런트 코드가 가끔 HTML을 받아 JSON 파싱에서 터집니다. 경로 기준으로 고정하는 편이 예측 가능했습니다.

PHP가 응답하지 못할 때

PHP 템플릿은 PHP가 살아 있을 때만 나옵니다. 배포 중 점검(503)이나 PHP-FPM이 죽었을 때(502)는 웹서버가 직접 응답해야 합니다. 이 두 화면은 같은 _shell.php로 정적 HTML을 한 번 만들어 public/errors/에 두고, 웹서버가 그 파일을 내보내게 했습니다.

# 같은 틀로 정적 페이지 생성 (배포 스크립트에서 실행)
php spark errors:build   # → public/errors/502.html, 503.html

# Apache VirtualHost
ErrorDocument 502 /errors/502.html
ErrorDocument 503 /errors/503.html

# nginx
error_page 502 503 /errors/$status.html;
location ^~ /errors/ { internal; root /var/www/app/public; }

errors:build는 _shell.php를 include해 출력 버퍼를 파일로 저장하는 작은 spark 명령입니다. 화면을 따로 그리지 않으니 디자인을 고칠 때 한 곳만 고치면 됩니다.

검증: 테스트와 대비

템플릿은 따로 떼어서 렌더링해 본다

"자기완결"이라는 원칙은 눈으로 확인할 수 없습니다. 그래서 템플릿 파일을 프레임워크 없이 include만으로 렌더링하는 테스트를 만들었습니다. 이 테스트가 통과한다는 것 자체가 DB·세션·뷰 렌더러 없이 화면이 그려진다는 증명입니다.

final class ErrorTemplateTest extends CIUnitTestCase
{
    /** @return iterable */
    public static function templates(): iterable
    {
        yield '404' => ['error_404.php', 404];
        yield '403' => ['error_403.php', 403];
        yield '429' => ['error_429.php', 429];
        yield '500' => ['production.php', 500];
    }

    #[DataProvider('templates')]
    public function testRendersStandalone(string $file, int $code): void
    {
        $message = 'SQLSTATE[HY000] secret /var/www/app/Controllers/X.php';  // 새면 안 되는 값

        ob_start();
        include APPPATH . 'Views/errors/html/' . $file;
        $html = (string) ob_get_clean();

        $this->assertStringContainsString('lang="ko"', $html);
        $this->assertStringContainsString((string) $code, $html);
        $this->assertStringNotContainsString('SQLSTATE', $html);
        $this->assertStringNotContainsString('/var/www', $html);
        $this->assertStringNotContainsString('

$message에 일부러 민감한 값을 넣어 두는 것이 핵심입니다. 누군가 나중에 "디버깅 편하게" 메시지를 화면에 다시 붙이면 이 테스트가 바로 잡습니다. 사람이든 클로드코드든 마찬가지입니다.

운영 경로는 배포 후에 한 번 더

테스트는 템플릿까지만 봅니다. 실제 운영에서 상태코드가 그대로 나가는지는 배포 직후 스모크 체크로 확인합니다. 웹서버나 프록시가 상태코드를 바꾸는 경우를 이미 한 번 겪었기 때문입니다.

curl -s -o /tmp/404.html -w '%{http_code}\n' https://example.aivance.kr/no-such-page   # 404 여야 함
grep -q 'AIvance' /tmp/404.html && ! grep -q 'Whoops' /tmp/404.html
curl -s -H 'Accept: application/json' https://example.aivance.kr/api/v1/no-such | jq -r .code   # JSON 이어야 함

대비는 계산으로 정했다

원래는 버튼에 로고와 같은 밝은 보라 #8B6EFF를 쓰려 했습니다. 계산해 보니 흰 배경 대비가 기준에 못 미쳤습니다. 그래서 글자와 버튼은 한 단계 진한 #5B3DF0으로 내렸습니다.

조합

대비

4.5:1 기준

쓰는 곳

#8B6EFF 글자 / 흰 배경

3.64:1

미달

쓰지 않음 (로고 그라디언트만)

#5B3DF0 글자 / 흰 배경

6.23:1

통과

상태코드 숫자, 보조 버튼

흰 글자 / #5B3DF0 버튼

6.23:1

통과

주 버튼

#6B7280 글자 / 흰 배경

4.83:1

통과

추적 번호 안내

#C9CDD6 글자 / #14112A 배경

11.53:1

통과

다크모드 본문

#B9A8FF 글자 / #14112A 배경

8.84:1

통과

다크모드 강조·버튼

키보드로 탭을 눌렀을 때 버튼에 포커스 테두리가 보이는지도 확인했습니다. 에러 화면은 마우스 없이 빠져나갈 수 있어야 합니다.

돌아보며

  1.  
  2. 에러 화면은 가장 적게 의존해야 한다. 평소 코드는 재사용을 위해 의존성을 늘립니다. 에러 화면은 반대로 의존성을 줄일수록 튼튼해집니다. 레이아웃 상속 대신 include 하나를 고른 것이 이 작업의 가장 중요한 결정이었습니다.
  3.  
  4. AI에게는 평소와 다른 이유를 적어 줘야 한다. 클로드코드는 기존 레이아웃을 재사용하는 "좋은 습관"을 따릅니다. 이번에는 그 습관이 틀린 경우라서, 설계서에 금지 목록과 이유를 함께 적었습니다. 이유가 있으니 비슷한 상황에서도 같은 판단을 합니다.
  5.  
  6. 예쁜 화면보다 맞는 상태코드. 디자인은 나중에 바꿀 수 있지만, 상태코드가 틀리면 검색엔진과 모니터링이 조용히 잘못된 숫자를 쌓습니다. 템플릿이 상태코드를 아예 만지지 못하는 구조가 가장 안전했습니다.
  7.  
  8. 추적 번호는 사용자와 나를 잇는다. "오류가 났어요"라는 문의가 "번호 a3f9…에서 오류가 났어요"로 바뀌자, 로그를 찾는 시간이 거의 사라졌습니다. API와 웹이 같은 번호를 쓰는 것도 MSA 구조에서 효과가 컸습니다.
  9.  
  10. 브랜드는 덜어낼수록 신뢰를 준다. 로고 하나, 색 하나, 문장 세 개면 충분했습니다. 에러 화면에서 사용자가 원하는 것은 볼거리가 아니라 "괜찮다, 이렇게 하면 된다"는 안내입니다.
  11.  

이 템플릿은 ci4-board에 들어가 있어서, 이후 새 제품은 별도 작업 없이 같은 에러 화면을 갖고 시작합니다. 제품마다 바꿀 것은 _shell.php의 로고와 색 변수 두세 줄뿐입니다. 작은 작업이지만, 혼자 여러 제품을 운영할수록 이런 공통 바닥이 하나씩 쌓이는 것이 가장 큰 자산이라고 느낍니다.