Files
Gnuboard7/docs/backend/exceptions.md
T
HeuJung 03cbb99196 fix(extension): 확장 수명주기 캐시 무효화 순서 회귀 및 실패 사유 전달
route:cache 는 새 앱을 부팅해 라우트를 수집하고, 그 부팅의 확장 라우트 프로바이더는
DB 가 아니라 캐시된 활성 확장 목록을 읽는다. 그래서 rebuild 가
invalidate*StatusCache 보다 앞서면 방금 바뀐 상태가 빠진 채 라우트가 박제되고,
라우트 캐시에는 스캔 폴백이 없어 오류도 로그도 없이 404 가 된다. 무효화를 굽기 직전이
아니라 DB 상태 쓰기 직후로 올려, 같은 목록을 읽는 훅 매핑 캐시까지 함께 바로잡았다.
update 경로는 Updating 전이 직후에 비우면 그 창의 오토로드 갱신이 확장을 비활성으로
판정하므로, 상태 복원 직후에 비운 뒤 훅 캐시를 다시 굽는다.

플러그인 라우트 프로바이더에는 활성 게이트가 없어 비활성 플러그인의 API 가 계속
응답했다. 화면·메뉴만 사라지고 기능은 살아 있는 상태였다. 모듈과 같은 기준을 적용했다.

실패 사유가 하위 계층에서 버려져 관리자 화면에 :error 자리표시자가 그대로 노출되던
문제도 고쳤다. 반환 경로를 깨지 않도록 배열 키 reason 과 뒤에 붙인 선택적 out
파라미터로 사유를 실어 올리고, 확장이 수명주기 훅에서 사유를 남길 수 있는 통로를
추가했다. 설치 경로의 광역 RuntimeException catch 는 도메인 예외로 좁혀 원본 키와
파라미터를 응답에 싣는다 — 상태코드 422 는 유지해 사용자 계약을 함께 바꾸지 않는다.
언어팩 화면은 프로덕션에서 예외 원문을 싣지 않는 것이 확정된 계약이므로, 자리를
일반 문구로 채우는 대신 치환 자리 자체를 제거했다. 원문은 종전대로 errors 통로를
거쳐 디버그 모드에서 도달한다.
2026-08-21 17:09:30 +09:00

17 KiB

Custom Exception 다국어 처리

이 문서는 G7에서 Custom Exception 클래스의 다국어 처리 방법을 설명합니다.


TL;DR (5초 요약)

1. 예외 메시지 하드코딩 금지 → __() 함수 필수
2. 메시지 위치: lang/ko/exceptions.php, lang/en/exceptions.php
3. 파라미터 치환: __('exceptions.key', ['param' => $value])
4. 코어 예외: exceptions.template_engine.* 형식
5. 모듈 예외: vendor-module::exceptions.* 형식
6. 응답에는 `getMessage()` 가 아니라 **메시지 키**를 넘긴다 (예외는 키를 들고 다닌다)
7. typed 예외 → 기존 4xx 유지 / generic catch → 5xx (장애를 입력 오류로 위장하지 않는다)

목차

  1. 핵심 원칙
  2. 다국어 파일 위치 및 구조
  3. 파라미터 치환 패턴
  4. 예외 → 응답 매핑
  5. 템플릿 엔진 Custom Exception 예시
  6. Custom Exception 개발 체크리스트
  7. 관련 문서

핵심 원칙

필수: __() 함수 사용 (예외 메시지 하드코딩 금지)
필수: __() 함수를 사용한 다국어 처리

필수 규칙

  • 모든 예외 메시지는 다국어 파일에 정의: /lang/ko/exceptions.php, /lang/en/exceptions.php
  • Exception 생성자에서 __() 함수 사용: 하드코딩된 문자열 사용 금지
  • 파라미터 치환 지원: :trace, :max, :id 등의 동적 값 지원
  • 일관성 유지: 모든 커스텀 예외가 동일한 패턴 사용

다국어 파일 위치 및 구조

파일 위치

/lang/ko/exceptions.php  # 한국어 예외 메시지
/lang/en/exceptions.php  # 영어 예외 메시지

파일 구조 예시

// /lang/ko/exceptions.php
return [
    'circular_reference' => '레이아웃 순환 참조 감지: :trace',
    'max_depth_exceeded' => '레이아웃 중첩 깊이가 최대 허용 깊이(:max)를 초과했습니다.',
    'resource_not_found' => ':resource를 찾을 수 없습니다.',
];

// /lang/en/exceptions.php
return [
    'circular_reference' => 'Layout circular reference detected: :trace',
    'max_depth_exceeded' => 'Layout nesting depth exceeds maximum allowed depth (:max).',
    'resource_not_found' => ':resource not found.',
];

중첩 구조 (네임스페이스 그룹화)

// /lang/ko/exceptions.php
return [
    'template' => [
        'circular_reference' => '레이아웃 순환 참조 감지: :trace',
        'max_depth_exceeded' => '레이아웃 중첩 깊이(:current)가 최대 허용 깊이(:max)를 초과했습니다.',
        'template_not_found' => '템플릿을 찾을 수 없습니다: :identifier',
        'layout_not_found' => '레이아웃을 찾을 수 없습니다: :layout_name',
        'component_not_found' => '컴포넌트를 찾을 수 없습니다: :component_name',
        'invalid_layout_structure' => '유효하지 않은 레이아웃 구조입니다.',
    ],
];

// /lang/en/exceptions.php
return [
    'template' => [
        'circular_reference' => 'Layout circular reference detected: :trace',
        'max_depth_exceeded' => 'Layout nesting depth (:current) exceeds maximum allowed depth (:max).',
        'template_not_found' => 'Template not found: :identifier',
        'layout_not_found' => 'Layout not found: :layout_name',
        'component_not_found' => 'Component not found: :component_name',
        'invalid_layout_structure' => 'Invalid layout structure.',
    ],
];

파라미터 치환 패턴

잘못된 예시 (DON'T)

// ❌ 하드코딩된 예외 메시지 - 금지
class CircularReferenceException extends Exception
{
    public function __construct(array $stack, string $currentLayout)
    {
        $stackTrace = implode(' → ', $stack) . " → {$currentLayout}";
        $message = "레이아웃 순환 참조 감지: {$stackTrace}";  // 한국어만 지원, 다국어 불가
        parent::__construct($message);
    }
}

올바른 예시 (DO)

// ✅ 다국어 함수 사용 (파라미터 치환)
class CircularReferenceException extends Exception
{
    private array $stack;

    public function __construct(array $stack, string $currentLayout)
    {
        $this->stack = $stack;

        // ✅ 다국어 함수 사용 (파라미터 치환)
        $stackTrace = implode(' → ', $stack) . " → {$currentLayout}";
        $message = __('exceptions.circular_reference', ['trace' => $stackTrace]);

        parent::__construct($message);
    }

    public function getStack(): array
    {
        return $this->stack;
    }
}

다중 파라미터 처리

class MaxDepthExceededException extends Exception
{
    public function __construct(int $currentDepth, int $maxDepth)
    {
        // ✅ 다국어 함수 사용 (다중 파라미터)
        $message = __('exceptions.max_depth_exceeded', [
            'current' => $currentDepth,
            'max' => $maxDepth
        ]);

        parent::__construct($message);
    }
}

치환 자리는 비워 둘 수 없다

:error 같은 치환 자리를 가진 키를 파라미터 없이 부르면, 번역기는 그 자리를 그대로 둔 문장을 돌려준다. 그래서 운영자 화면에 모듈 활성화에 실패했습니다: :error 처럼 내부 자리표시자가 노출된다. 예외도 로그도 남지 않고 실패했을 때만 드러나므로, 정상 흐름만 보는 테스트로는 잡히지 않는다.

❌ 금지 ✅ 올바른 사용
error('x.activate_failed') — 치환 자리를 가진 키를 파라미터 없이 error('x.activate_failed', 400, null, ['error' => $reason])
사유를 모른다고 자리를 비워 두기 사유를 알 수 없으면 일반 문구로 채운다 (errors.unknown_error)
원인을 싣지 않기로 한 문구에 :error 자리를 남겨 두기 그 키에서 치환 자리 자체를 없앤다
하위 계층이 false 만 돌려주고 사유를 버리기 사유를 반환 경로에 실어 올린다 (배열 키 또는 선택적 out 파라미터)

셋째 인자 errors 페이로드와 넷째 인자 messageParams 는 다른 통로다. errors 에 예외를 넘기는 것은 진단 정보이고(노출 폭은 ResponseHelper 가 app.debug 로 정한다), 문구의 치환 자리를 채우는 것은 messageParams 뿐이다. 한쪽만 채우면 자리표시자는 그대로 남는다.

응답 문구에 예외 원문을 싣지 않기로 정한 화면(언어팩 관리 등)은 파라미터를 채우는 대신 키에서 치환 자리를 없앤다. 자리를 남긴 채 일반 문구로 채우면 "…실패했습니다: 알 수 없는 오류" 처럼 의미 없는 꼬리가 붙고, 나중에 누군가 그 자리를 예외 원문으로 채우는 회귀를 부른다.


예외 → 응답 매핑

예외를 응답으로 바꾸는 자리에서 두 가지가 자주 어긋난다. 둘 다 오류도 로그도 남기지 않아 코드를 나란히 놓고 보기 전에는 드러나지 않는다.

시작하기 전에 통로부터 나눈다. ResponseHelper::error($messageKey, $statusCode, $errors, $messageParams) 는 인자마다 노출 규칙이 다르다.

인자 통로 노출 규칙
$messageKey (1번째) 사용자에게 보이는 안내 문구 다국어 키 전용. 번역문·예외 원문을 넣으면 키 해석에 실패해 원문이 그대로 나간다
$errors (3번째) — Throwable 진단 app.debug 에서만 debug 블록으로 펼쳐진다
$errors (3번째) — 문자열 진단 500 이상 + 비디버그에서 차단, 그 외 노출
$errors (3번째) — 배열 구조화 페이로드 항상 노출 (검증 오류 구조가 이 통로를 쓴다)
$messageParams (4번째) 문구 치환 응답 본문에 직접 실리지 않는다

그래서 "예외 원문을 응답에 싣느냐" 는 하나의 규칙으로 답할 수 없다. 판단 축은 셋이다.

  • 누구에게 — 관리자 전용 면인가, 공개(비인증) 엔드포인트인가. 공개 면에 SQL 상태코드·경로가 나가면 정보 노출이다. 반대로 관리자에게 실패 사유를 감추면, 서버 로그 접근 권한이 없는 운영자는 "실패했습니다" 외에 아무 근거도 얻지 못한다.
  • 무엇의 원문인가 — 우리 인프라 예외인가, 외부 시스템(결제대행사·배송사 API)이 돌려준 도메인 사유인가. 후자는 우리 스택 정보가 아니고 다국어 키로 옮길 수도 없다. 감추면 조치 가능한 정보가 사라진다.
  • 어느 통로인가 — 위 표. 노출 폭은 호출부가 문자열로 조립하지 말고 헬퍼에 맡긴다.

다국어 키는 유한하고 실패 사유는 무한하다. 키만 고집하면 예상 못 한 실패가 전부 "작업에 실패했습니다" 하나로 붕괴하는데, 이는 아래 2번이 막으려는 "장애가 입력 오류로 위장" 과 같은 종류의 정보 손실이다.

1. 응답에는 메시지 키를 넘긴다

ResponseHelper::error() 의 첫 인자는 다국어 키다. 이미 번역된 $e->getMessage() 를 그 자리에 넘기면 키 해석에 실패해 원문이 그대로 사용자 화면에 나간다 — 원문에는 SQL 상태코드·경로·클래스명이 섞여 있을 수 있다.

그래서 도메인 예외는 번역문이 아니라 키를 들고 다닌다. 생성자에서 키와 치환 파라미터를 보관하고 getMessageKey() / getMessageParams() 로 노출한다.

class BoardTypeOperationException extends Exception
{
    public function __construct(
        private string $messageKey,
        private array $replace = []
    ) {
        parent::__construct(__($messageKey, $replace));
    }

    public function getMessageKey(): string { return $this->messageKey; }

    public function getMessageParams(): array { return $this->replace; }
}
// ❌ 번역문을 키 자리에 — 해석 실패 → 원문 노출
return $this->error($e->getMessage(), 422);

// ✅ 키 + 치환 파라미터
return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());

사유 식별자만 들고 다니는 예외(getReason())라면 호출부가 그 식별자로 키를 조립한다 — 이때도 응답에 들어가는 것은 키다.

2. typed 는 기존 4xx, generic 은 5xx

catch (\Exception) / catch (\Throwable) 는 도메인 예외가 아닌 것을 잡는 자리다. 여기서 4xx 를 돌려주면 인프라 장애와 코드 결함이 "입력 오류" 로 위장되어, 사용자는 고칠 수 없는 안내를 보고 운영자는 장애를 늦게 안다.

try {
    $this->service->doSomething($order, $data);
} catch (OrderModificationException $e) {
    // 도메인 규칙 위반 — 사용자가 고칠 수 있다. 기존 상태코드를 그대로 유지한다.
    return ResponseHelper::error($e->getMessageKey(), 422, null, $e->getMessageParams());
} catch (\Exception $e) {
    // 그 외 — 서버 결함/인프라 장애. 메시지 키 자리에는 원문을 넣지 않는다.
    // (진단이 필요하면 $errors 인자로 Throwable 을 넘겨 헬퍼의 debug 게이트에 맡긴다)
    Log::error('...', ['error' => $e->getMessage()]);

    return ResponseHelper::error('sirsoft-ecommerce::exceptions.operation_failed', 500);
}

규칙 셋:

  • typed 예외를 잡는 분기의 상태코드는 바꾸지 않는다. 예외를 도입하는 작업이 사용자에게 보이는 계약을 함께 바꾸면 회귀다.
  • 서비스가 typed 예외를 던지는데 컨트롤러에 그 typed catch 가 없으면, 도메인 사유가 generic 으로 흘러 500 이 된다. 서비스를 typed 로 승격할 때는 호출하는 컨트롤러 메서드를 전수로 함께 본다.
  • 상태코드 인자를 생략한 ResponseHelper::error('key') / moduleError($mod, 'key') 는 기본값 400 이다. generic catch 안에서는 인자를 생략하지 않는다.
  • 도메인 예외의 부모를 잡지 않는다. 도메인 예외는 대개 \RuntimeException 을 상속하므로, 서비스를 typed 로 승격한 뒤에도 catch (\RuntimeException) 을 남겨 두면 그 자리는 도메인 예외를 잡는 것처럼 보이지만 실제로 남는 것은 인프라 예외뿐이다. 그것까지 4xx 로 나가면 승격 작업이 아무것도 고치지 못한 셈이 된다. 승격한 예외 이름으로 좁힌다.

의도적으로 4xx 를 유지하는 generic catch 도 있다 — 업로드 파일 해석처럼 실패 원인이 대부분 클라이언트 입력인 경우다. 그런 지점은 사유와 함께 명시적으로 선언하고, 판정에서 조용히 빠지게 두지 않는다.

tests/Feature/Http/GenericCatchStatusCodeContractTest.php 가 코어와 모든 번들 확장의 컨트롤러를 훑어 이 두 규칙을 고정한다. 예외 목록을 상수로 선언해 두었으므로, 새 예외를 만들려면 목록에 사유와 함께 추가해야 한다.


템플릿 엔진 Custom Exception 예시

CircularReferenceException (레이아웃 순환 참조)

템플릿 레이아웃 상속 시 순환 참조가 발생하면 이 예외를 발생시킵니다.

<?php

namespace App\Exceptions\Template;

use Exception;

/**
 * 레이아웃 순환 참조 예외
 */
class CircularReferenceException extends Exception
{
    private array $stack;

    public function __construct(array $stack, string $currentLayout)
    {
        $this->stack = $stack;

        // ✅ 다국어 함수 사용
        $stackTrace = implode(' → ', $stack) . " → {$currentLayout}";
        $message = __('exceptions.template.circular_reference', ['trace' => $stackTrace]);

        parent::__construct($message);
    }

    /**
     * 순환 참조 스택 반환
     */
    public function getStack(): array
    {
        return $this->stack;
    }
}

MaxDepthExceededException (레이아웃 깊이 초과)

레이아웃 상속 깊이가 최대 허용 깊이(10)를 초과하면 이 예외를 발생시킵니다.

<?php

namespace App\Exceptions\Template;

use Exception;

/**
 * 레이아웃 최대 깊이 초과 예외
 */
class MaxDepthExceededException extends Exception
{
    private int $currentDepth;
    private int $maxDepth;

    public function __construct(int $currentDepth, int $maxDepth = 10)
    {
        $this->currentDepth = $currentDepth;
        $this->maxDepth = $maxDepth;

        // ✅ 다국어 함수 사용 (다중 파라미터)
        $message = __('exceptions.template.max_depth_exceeded', [
            'current' => $currentDepth,
            'max' => $maxDepth
        ]);

        parent::__construct($message);
    }

    /**
     * 현재 깊이 반환
     */
    public function getCurrentDepth(): int
    {
        return $this->currentDepth;
    }

    /**
     * 최대 허용 깊이 반환
     */
    public function getMaxDepth(): int
    {
        return $this->maxDepth;
    }
}

Service에서 사용 예시 (LayoutService)

<?php

namespace App\Services\Template;

use App\Exceptions\Template\CircularReferenceException;
use App\Exceptions\Template\MaxDepthExceededException;

class LayoutService
{
    private const MAX_DEPTH = 10;

    /**
     * 레이아웃 병합 (상속 체인 처리)
     *
     * @throws CircularReferenceException 순환 참조 발생 시
     * @throws MaxDepthExceededException 깊이 초과 시
     */
    public function mergeLayouts(string $layoutName, array $stack = [], int $depth = 0): array
    {
        // 순환 참조 검사
        if (in_array($layoutName, $stack)) {
            throw new CircularReferenceException($stack, $layoutName);
        }

        // 깊이 검사
        if ($depth > self::MAX_DEPTH) {
            throw new MaxDepthExceededException($depth, self::MAX_DEPTH);
        }

        // 재귀적으로 레이아웃 병합
        // ...
    }
}

Custom Exception 개발 체크리스트

새로운 Custom Exception을 개발할 때 다음 항목을 확인하세요:

  • /lang/ko/exceptions.php에 한국어 메시지 추가
  • /lang/en/exceptions.php에 영어 메시지 추가
  • Exception 생성자에서 모든 메시지는 __() 함수 사용
  • 동적 값은 파라미터 배열로 전달 (예: ['trace' => $stackTrace])
  • 메시지 키와 치환 파라미터를 보관하고 getMessageKey() / getMessageParams() 로 노출 (응답에 번역문 대신 키를 넘기기 위함)
  • 이 예외를 던지는 서비스 메서드를 호출하는 컨트롤러 메서드 전수에 typed catch 가 있는지 확인 (없으면 도메인 사유가 generic 으로 흘러 500 이 된다)
  • 두 언어 모두에서 테스트 수행
  • 예외 메시지가 사용자에게 노출될 경우 보안 정보 포함 금지

새 Exception 추가 절차

  1. 다국어 키 정의: 적절한 네임스페이스로 그룹화
  2. Exception 클래스 생성: __() 함수로 메시지 생성
  3. 필요한 속성 저장: 디버깅이나 로깅에 필요한 값 보관
  4. getter 메서드 제공: 저장된 속성에 접근할 수 있도록 제공
  5. 테스트 작성: 두 언어 환경에서 메시지 확인

관련 문서