Files
HeuJung 71abdeaf60 fix(security): KVE-2026-1914/1915/1919 remediation 전건
위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던
결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을
내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고
같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다.

- 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거
 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는
 DB 쓰기에 선행하여 거부 시 상태 불변.
- 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체.
 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static
 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형.
- secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙
 소유권·비밀·발행 상태 검사 통일.
- 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를
 런타임·저장측·정적검사 3층 동형화.
- 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
2026-08-17 01:45:09 +09:00

49 KiB

검증 (Validation)

상위 문서: 백엔드 가이드 인덱스


TL;DR (5초 요약)

1. 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지)
2. 복잡한 검증은 Custom Rule 클래스로 분리
3. 다국어 필드: LocaleRequiredTranslatable (필수) / TranslatableField (선택) 사용
4. 다국어 메시지: __('validation.xxx') 함수 사용
5. 훅 확장: HookManager::applyFilters()로 동적 규칙 추가
6. 필수: Rule::exists(Model::class, 'col') 사용 (문자열 테이블명 사용 금지)
7. 런타임 조건부 검증: 드라이버/모드별 분기 시 메서드 추출 패턴 사용
8. Store/Update 조건부 규칙은 대칭 — 한쪽 누락 시 과잉 검증(수정 실패) 또는 검증 우회
9. min/size 가 붙은 필드는 required_if 로 조건부화 불가 → exclude_if (조건 필드가 nullable 이면 exclude_unless)

목차


검증 로직 구현 원칙

필수: FormRequest + Custom Rule 패턴 사용 (Service에 검증 로직 배치 금지)

핵심 원칙:

  • 검증은 Controller 진입 전에 완료: FormRequest가 자동으로 검증 수행
  • Service는 순수 비즈니스 로직만: Service에 도달하는 데이터는 이미 검증 완료됨
  • 재사용성: Custom Rule은 여러 FormRequest에서 재사용 가능
  • Laravel 표준 준수: Laravel 공식 검증 패턴 따름

검증 계층 구조

Controller → FormRequest (검증) → Service (비즈니스 로직) → Repository
              ↓
         Custom Rules (복잡한 검증)

잘못된 예시 (❌ DON'T):

// ❌ Service에 검증 로직 - 금지
class LayoutService
{
    public function createLayout(array $data)
    {
        // 검증 로직이 Service에 있음 - 잘못됨
        if (!isset($data['version'])) {
            throw new Exception('version is required');
        }

        if (!$this->isValidEndpoint($data['endpoint'])) {
            throw new Exception('Invalid endpoint');
        }

        // 비즈니스 로직
        return $this->repository->create($data);
    }
}

올바른 예시 (✅ DO):

// ✅ FormRequest로 검증
class StoreLayoutRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'version' => ['required', 'string'],
            'endpoint' => ['required', new WhitelistedEndpoint],
            'components' => ['required', 'array', new ValidLayoutStructure],
        ];
    }
}

// ✅ Custom Rule로 복잡한 검증
class WhitelistedEndpoint implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        // 실제 룰은 /api/(modules|plugins)/{vendor-id}/ 확장 프리픽스도 허용 — app/Rules/WhitelistedEndpoint.php 참조
        if (!preg_match('/^\/api\/(admin|auth|public)\//', $value)) {
            $fail(__('validation.custom.whitelisted_endpoint'));
        }
    }
}

// ✅ Service는 순수 비즈니스 로직만
class LayoutService
{
    public function createLayout(array $validatedData)
    {
        // 검증 완료된 데이터로 비즈니스 로직만 수행
        HookManager::doAction('layout.before_create', $validatedData);
        $layout = $this->repository->create($validatedData);
        HookManager::doAction('layout.after_create', $layout);
        return $layout;
    }
}

FormRequest 검증

규칙:

  • 모든 검증 로직은 FormRequest 클래스로 분리
  • 컨트롤러에서 인라인 검증 금지
  • 권한 검증은 라우트의 permission 미들웨어에서 수행
  • FormRequest의 authorize() 메서드는 항상 true 반환
  • 복잡한 검증은 Custom Rule 클래스로 분리
  • exists/unique 규칙: Rule::exists(Model::class, 'column') / Rule::unique(Model::class, 'column') 사용 필수 (문자열 테이블명 사용 금지)

패턴:

<?php

namespace Modules\Sirsoft\Ecommerce\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreProductRequest extends FormRequest
{
    /**
     * 사용자가 이 요청을 수행할 권한이 있는지 확인
     *
     * 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * 요청에 적용할 검증 규칙
     *
     * @return array
     */
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'description' => ['required', 'string'],
            'price' => ['required', 'numeric', 'min:0'],
            'category_id' => ['required', Rule::exists(Category::class, 'id')],
            'is_active' => ['boolean'],
        ];
    }

    /**
     * 검증 오류 메시지 커스터마이징
     *
     * @return array
     */
    public function messages(): array
    {
        return [
            'name.required' => '상품명을 입력해주세요.',
            'price.required' => '가격을 입력해주세요.',
            'price.min' => '가격은 0 이상이어야 합니다.',
            'category_id.exists' => '존재하지 않는 카테고리입니다.',
        ];
    }
}

prepareForValidation() 데이터 전처리

FormRequest의 prepareForValidation() 메서드를 사용하여 검증 전에 데이터를 전처리합니다.

주요 용도

용도 예시
기본값 설정 collection 미전송 시 'default' 설정
라우트 파라미터 병합 $this->route('identifier')를 검증 대상에 포함
데이터 형식 변환 JSON 문자열 → 배열 변환
역호환성 처리 문자열 name → 다국어 배열 name 변환
충돌 필드 제거 roles 객체 배열이 있으면 role_ids로 변환

패턴 예시

protected function prepareForValidation(): void
{
    // 1. 기본값 설정
    $this->merge([
        'collection' => $this->collection ?? 'default',
        'source_type' => $this->source_type ?? AttachmentSourceType::Core->value,
    ]);
}
protected function prepareForValidation(): void
{
    // 2. 라우트 파라미터 병합 (검증 대상에 포함)
    $this->merge([
        'identifier' => $this->route('identifier'),
        'path' => $this->route('path'),
    ]);
}
protected function prepareForValidation(): void
{
    // 3. JSON 문자열 → 배열 변환
    $content = $this->input('content');
    if (is_string($content)) {
        $decoded = json_decode($content, true);
        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
            $this->merge(['content' => $decoded]);
        }
    }
}

prepareForValidation 주의사항

  • prepareForValidation()은 rules() 호출 전에 실행됨
  • $this->merge()로 값을 병합 (기존 값 덮어쓰기)
  • 검증 로직은 넣지 않음 — 검증은 rules()에서 처리

훅 기반 동적 Validation Rules 확장

필수: 모든 코어 FormRequest의 rules()는 훅을 통해 확장 가능해야 함 (코어에 모듈/플러그인 필드 하드코딩 금지)
필수: 모듈/플러그인은 훅을 통해 자체 필드의 validation rules 추가

배경

Laravel의 $request->validated()는 rules()에 정의된 필드만 반환합니다. 모듈/플러그인에서 동적으로 추가한 필드가 코어 FormRequest의 rules에 없으면 validated() 결과에서 제외되어 Service 계층까지 전달되지 않습니다.

코어 FormRequest 필수 패턴

모든 코어 FormRequest는 다음 패턴을 따라야 합니다:

<?php

namespace App\Http\Requests\User;

use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;

class UpdateUserRequest extends FormRequest
{
    public function rules(): array
    {
        $rules = [
            'name' => 'required|string|max:255',
            'email' => 'required|email|max:255',
            // ... 기본 필드들
        ];

        // ✅ 필수: 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
        return HookManager::applyFilters('core.user.update_validation_rules', $rules, $this);
    }
}

훅 네이밍 규칙

core.[entity].[action]_validation_rules

예시:
core.user.create_validation_rules      # 사용자 생성
core.user.update_validation_rules      # 사용자 수정
core.role.store_validation_rules       # 역할 저장
core.permission.update_validation_rules # 권한 수정

모듈/플러그인에서의 필드 확장

모듈/플러그인이 코어 엔티티에 필드를 추가하려면:

  1. HookListenerInterface 구현
  2. validation rules 훅 구독
  3. addValidationRules 메서드로 필드 추가
<?php

namespace Modules\Sirsoft\Board\Listeners;

use App\Contracts\Extension\HookListenerInterface;

class UserNotificationSettingsListener implements HookListenerInterface
{
    /**
     * 알림 설정 필드 목록
     */
    private const NOTIFICATION_FIELDS = [
        'notify_post_complete',
        'notify_post_reply',
        'notify_comment',
        'notify_reply_comment',
    ];

    public static function getSubscribedHooks(): array
    {
        return [
            // ✅ Validation Rules 확장 훅
            'core.user.create_validation_rules' => [
                'method' => 'addValidationRules',
                'priority' => 10,
                'type' => 'filter',
            ],
            'core.user.update_validation_rules' => [
                'method' => 'addValidationRules',
                'priority' => 10,
                'type' => 'filter',
            ],

            // 데이터 필터 훅 (검증 후 처리)
            'core.user.filter_update_data' => [
                'method' => 'filterUpdateData',
                'priority' => 10,
                'type' => 'filter',
            ],
        ];
    }

    /**
     * FormRequest validation rules에 알림 설정 필드 추가
     *
     * @param array $rules 기존 validation rules
     * @return array 알림 설정 필드가 추가된 rules
     */
    public function addValidationRules(array $rules): array
    {
        return array_merge($rules, [
            'notify_post_complete' => 'nullable|boolean',
            'notify_post_reply' => 'nullable|boolean',
            'notify_comment' => 'nullable|boolean',
            'notify_reply_comment' => 'nullable|boolean',
        ]);
    }

    /**
     * 수정 데이터 필터: 알림 설정 필드 추출 후 모듈 테이블에 저장
     */
    public function filterUpdateData(array $data, User $user): array
    {
        $notificationData = $this->extractNotificationData($data);
        if (!empty($notificationData)) {
            $this->service->createOrUpdate($user->id, $notificationData);
        }

        // 코어 테이블에 저장되지 않도록 필드 제거
        return $this->removeNotificationFields($data);
    }
}

데이터 흐름

Request
   ↓
FormRequest.rules()
   ↓
HookManager::applyFilters('core.entity.action_validation_rules', $rules)
   ↓
모듈 Listener: addValidationRules() → 필드 추가
   ↓
validated() → 모듈 필드 포함된 데이터 반환
   ↓
Service
   ↓
HookManager::applyFilters('core.entity.filter_update_data', $data)
   ↓
모듈 Listener: filterUpdateData() → 모듈 테이블에 저장 후 필드 제거
   ↓
Repository → 코어 테이블에 저장

주의사항

  1. 훅 우선순위: 여러 모듈이 같은 훅을 구독할 수 있으므로 priority 값을 적절히 설정
  2. 필드 충돌 방지: 모듈별로 고유한 필드명 사용 권장 (예: board_notify_*, shop_option_*)
  3. 모듈 비활성화 시 자동 제거: 모듈이 비활성화되면 훅이 등록되지 않아 validation rules도 자동 제거됨

코어 FormRequest 체크리스트

신규 코어 FormRequest 생성 시:

  • use App\Extension\HookManager; import 추가
  • rules() 메서드 마지막에 HookManager::applyFilters() 호출
  • 훅 이름: core.[entity].[action]_validation_rules 형식
  • 두 번째 인자로 $this (FormRequest 인스턴스) 전달
  • exists/unique 규칙에 문자열 테이블명 미사용 확인 → Rule::exists(Model::class, 'column') 형태만 사용 (상세)

런타임 조건부 Validation Rules (드라이버/모드별 분기)

주의: 런타임 입력값에 따라 검증 규칙이 달라지는 경우 별도 메서드로 분리
✅ 필수: 조건부 규칙은 메서드 추출 패턴 사용 (가독성 + 재사용성)
✅ 필수: 비활성 드라이버 필드는 nullable 유지 (데이터 보존)

배경

설정 화면처럼 드라이버/모드 선택에 따라 필수 필드가 달라지는 경우가 있습니다. 예: SMTP 선택 시 host/port 필수, Mailgun 선택 시 domain/secret 필수.

패턴: 메서드 추출

class SaveSettingsRequest extends FormRequest
{
    public function rules(): array
    {
        $rules = [
            'mail.mailer' => ['sometimes', 'string', 'in:smtp,mailgun,ses'],
            'mail.from_address' => ['sometimes', 'email', 'max:255'],
            // ... 공통 필드
        ];

        // ✅ 드라이버별 조건부 규칙은 별도 메서드로 분리
        $rules = array_merge($rules, $this->getMailerRules());

        return HookManager::applyFilters('core.settings.save_validation_rules', $rules, $this);
    }

    /**
     * 메일 드라이버별 조건부 검증 규칙을 반환합니다.
     *
     * @return array<string, array<mixed>>
     */
    private function getMailerRules(): array
    {
        $tab = $this->input('tab');
        $mailer = $this->input('mail.mailer', 'smtp');

        return match ($mailer) {
            'smtp' => [
                'mail.host' => $this->getTabRules($tab, 'mail', 'string|max:255'),
                'mail.port' => $this->getTabRules($tab, 'mail', 'integer|min:1|max:65535'),
                // Mailgun/SES 필드는 nullable
                'mail.mailgun_domain' => ['nullable', 'string', 'max:255'],
            ],
            'mailgun' => [
                'mail.mailgun_domain' => $this->getTabRules($tab, 'mail', 'string|max:255'),
                'mail.mailgun_secret' => $this->getTabRules($tab, 'mail', 'string|max:255'),
                // SMTP/SES 필드는 nullable
                'mail.host' => ['nullable', 'string', 'max:255'],
            ],
            // ... 다른 드라이버
        };
    }
}

테스트 메일처럼 독립 요청인 경우

설정 저장이 아닌 테스트 실행용 요청에서도 동일 패턴을 적용합니다:

class TestMailRequest extends FormRequest
{
    public function rules(): array
    {
        $mailer = $this->input('mailer', 'smtp');

        return [
            'to_email' => ['required', 'email', 'max:255'],
            'from_address' => ['required', 'email', 'max:255'],
            // SMTP 필드: smtp일 때만 required
            'host' => $mailer === 'smtp'
                ? ['required', 'string', 'max:255']
                : ['sometimes', 'nullable', 'string', 'max:255'],
            // Mailgun 필드: mailgun일 때만 required
            'mailgun_domain' => $mailer === 'mailgun'
                ? ['required', 'string', 'max:255']
                : ['sometimes', 'nullable', 'string', 'max:255'],
            // ...
        ];
    }
}

조건부 Validation 원칙

원칙 설명
비활성 필드는 nullable 다른 드라이버 필드는 삭제하지 않고 nullable 유지 (데이터 보존)
$this->input() 사용 런타임 입력값으로 분기 (request body에서 직접 읽음)
메서드 추출 규칙이 3개 이상 분기되면 getXxxRules() 메서드로 분리
기본값 제공 $this->input('mailer', 'smtp') — 미전송 시 기본 드라이버 적용
Store/Update 정책 일치 같은 리소스의 Store 와 Update 는 동일 필드에 동일한 조건부 규칙을 적용한다. 한쪽에만 exclude_if 등이 있으면 다른 쪽에서만 저장이 막힌다
sometimes 는 부분 수정 지원 수단이 아니다 관리자 폼은 조회 응답 전체를 그대로 PUT 하므로 요청에는 항상 모든 키가 존재한다. 토글 OFF 상태의 종속 필드는 sometimes 가 아니라 exclude_if 로 검증에서 배제한다
상호 필수 조합은 결과 상태로 판정 required_with/required_without 는 "상대 필드 부재" 자체가 발화 조건이라 부분 수정 요청과 상충한다. Update 에서는 페이로드 단독이 아니라 "기존 레코드 + 페이로드" 결과 상태를 기준으로 판정한다

exclude_if 는 규칙 배열의 첫 번째에 둔다. 배제는 규칙 루프 도중 발동하며 이미 추가된 실패 메시지는 회수되지 않으므로, 앞선 규칙이 먼저 실패하면 배제되어도 오류가 남는다.

// ❌ DON'T: 요청에 키가 항상 있으므로 sometimes 가 발동하지 않아 빈 값이 차단된다
'allowed_extensions' => ['sometimes', 'array', 'min:1'],

// ✅ DO: 토글이 꺼져 있으면 필드 자체를 검증에서 제외한다
'allowed_extensions' => ['exclude_if:use_file_upload,false', 'sometimes', 'required', 'array', 'min:1'],

조건부 규칙의 종류에 따라 Update 로 옮기는 방식이 달라진다.

Store 규칙 Update 에 옮기는 방식
required_if:other,value 그대로 복사한다. 조건 필드가 요청에 없으면 발화하지 않아 부분 수정과 충돌하지 않는다
exclude_if / exclude_unless 그대로 복사한다. 조건 필드가 요청에 없으면 배제가 동작하지 않으므로(ValidatesAttributes::validateExcludeIf), 부분 수정 경로가 있으면 prepareForValidation() 에서 기존 레코드 값을 주입해 판정 기준을 세운다
required_with / required_without 그대로 복사하면 안 된다. 상대 필드가 요청에 없다는 사실만으로 발화하므로, 그 조합과 무관한 필드 하나만 바꾸는 요청이 전부 422 가 된다

required_with/required_without 는 "요청에 무엇이 실려 왔는지"가 아니라 "저장 후 레코드가 유효한지"를 묻는 규칙이다. 따라서 Update 에서는 요청에 없는 필드를 저장된 값으로 메운 뒤 판정한다. 이때 폼 제출 키와 저장 컬럼명이 다르면(예: 해외 주소를 intl_city 로 제출하고 city 컬럼에 저장) 메우는 쪽에서 그 매핑을 반영해야 한다 — 매핑을 빠뜨리면 저장된 값이 있는데도 빈 값으로 보여 정상 수정 경로가 막힌다.

// ❌ DON'T: Store 규칙을 그대로 복사 — 기본 배송지 토글만 바꾸는 요청이 422
'zipcode' => 'required_without:intl_postal_code|nullable|string|max:10',

// ✅ DO: 결과 상태(기존 레코드 + 페이로드) 기준으로 withValidator() 에서 판정
$validator->after(function (Validator $validator) {
    $existing = app(UserAddressRepositoryInterface::class)->findByUserIdAndId($userId, $addressId);
    $effective = array_key_exists('zipcode', $payload) ? $payload['zipcode'] : $existing?->zipcode;
    // ... 국가별 필수 조합 판정 후 $validator->errors()->add(...)
});

이 방식은 formrequest-store-update-conditional-symmetry 룰의 토큰 비교로는 "누락"으로 보이므로, 해당 필드에 // audit:allow formrequest-store-update-conditional-symmetry reason: ... 로 판정 위치를 남긴다.

exclude_if vs exclude_unless — 조건 필드가 nullable 일 때

exclude_if:other,false 의 값 비교는 strict 다. 조건 필드가 nullable|boolean 이라 null 이 올 수 있으면 null !== false 라 매칭되지 않아 배제가 일어나지 않는다. "true 가 아니면 제외" 를 뜻하는 exclude_unless:other,true 는 null/false/미전송을 모두 포괄한다.

// 조건 필드가 boolean 고정 — false 가 확실히 온다
'allowed_extensions' => ['exclude_if:use_file_upload,false', 'sometimes', 'required', 'array', 'min:1'],

// 조건 필드가 nullable — null 이 올 수 있다
'basic_defaults.allowed_extensions' => ['exclude_unless:basic_defaults.use_file_upload,true', 'sometimes', 'required', 'array', 'min:1'],

중첩 배열의 조건 필드는 dot notation 으로 쓴다. exclude_unless:use_file_upload,true 처럼 최상위 키만 적으면 요청 데이터에 그 키가 없어 조건이 성립하지 않고, 배제가 조용히 동작하지 않는다. 검증 대상 필드가 basic_defaults.allowed_extensions 라면 조건 필드도 basic_defaults.use_file_upload 여야 한다.

조건 필드가 요청에 없을 때 — 저장값 주입

exclude_if/exclude_unless 는 조건 필드가 데이터에 없으면 배제하지 않는다(ValidatesAttributes::validateExcludeIf). 부분 수정 경로가 있는 Update 요청은 종속 필드만 전송되고 조건 토글은 빠질 수 있으므로, prepareForValidation() 에서 기존 레코드의 값을 주입해 판정 기준을 세운다.

protected function prepareForValidation(): void
{
    $data = $this->all();

    // 종속 필드가 왔는데 조건 토글이 없으면 저장값으로 판정 기준을 세운다
    if (array_key_exists('allowed_extensions', $data) && ! array_key_exists('use_file_upload', $data)) {
        $board = $this->route('board');

        if ($board !== null && isset($board->use_file_upload)) {
            $data['use_file_upload'] = (bool) $board->use_file_upload;
        }
    }

    $this->merge($data);
}

주입 조건에 종속 필드가 실제로 전송되었는지를 반드시 포함한다. 무조건 주입하면 사용자가 보내지도 않은 토글이 검증 대상으로 올라와, 그 필드와 무관한 부분 수정이 조건부 규칙의 영향을 받는다.

자동 차단: 정적 검사 대상. store/update 사이의 조건부 검증 비대칭은 차단되고, sometimes + array + min 조합이 무효화되는 형태는 경고로 보고된다.


권한 체크 방식

// ✅ DO: 라우트에서 permission 미들웨어로 권한 체크
Route::post('/products', [ProductController::class, 'store'])
    ->middleware('permission:sirsoft-ecommerce.products.create')
    ->name('api.admin.products.store');

// ❌ DON'T: FormRequest의 authorize()에서 권한 체크
public function authorize(): bool
{
    return $this->user()->can('sirsoft-ecommerce.products.create'); // 사용 금지
}

Custom Rule 검증 메시지 다국어 처리

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

핵심 원칙:

  • 모든 검증 오류 메시지는 다국어 파일에 정의: /lang/ko/validation.php, /lang/en/validation.php
  • Custom Rule에서 __() 함수 사용 필수 (하드코딩된 문자열 사용 금지)
  • 파라미터 치환 지원: :attribute, :field, :value 등의 동적 값 지원
  • 일관성 유지: 모든 검증 규칙이 동일한 패턴 사용

다국어 파일 위치:

/lang/ko/validation.php  # 한국어 검증 메시지
/lang/en/validation.php  # 영어 검증 메시지

잘못된 예시 (❌ DON'T):

// ❌ 하드코딩된 오류 메시지 - 금지
class ValidLayoutStructure implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!is_array($value)) {
            $fail('레이아웃 데이터는 배열이어야 합니다.');  // 한국어만 지원
            return;
        }

        if (!isset($value['version'])) {
            $fail("필수 필드 'version'이 누락되었습니다.");  // 다국어 불가
            return;
        }
    }
}

올바른 예시 (✅ DO):

1. 다국어 파일 정의:

// /lang/ko/validation.php
return [
    'layout' => [
        'must_be_array' => '레이아웃 데이터는 배열이어야 합니다.',
        'required_field_missing' => "필수 필드 ':field'가 누락되었습니다.",
        'invalid_json' => '유효하지 않은 JSON 형식입니다.',
        'max_depth_exceeded' => '컴포넌트 중첩 깊이가 최대 허용 깊이(:max)를 초과했습니다.',
    ],
];

// /lang/en/validation.php
return [
    'layout' => [
        'must_be_array' => 'Layout data must be an array.',
        'required_field_missing' => "Required field ':field' is missing.",
        'invalid_json' => 'Invalid JSON format.',
        'max_depth_exceeded' => 'Component nesting depth exceeds maximum allowed depth (:max).',
    ],
];

2. Custom Rule에서 사용:

class ValidLayoutStructure implements ValidationRule
{
    private const MAX_DEPTH = 10;

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        // ✅ 다국어 함수 사용 (파라미터 없음)
        if (!is_array($value)) {
            $fail(__('validation.layout.must_be_array'));
            return;
        }

        // ✅ 다국어 함수 사용 (파라미터 치환)
        if (!isset($value['version'])) {
            $fail(__('validation.layout.required_field_missing', ['field' => 'version']));
            return;
        }

        // ✅ 다국어 함수 사용 (다중 파라미터)
        if ($depth > self::MAX_DEPTH) {
            $fail(__('validation.layout.max_depth_exceeded', ['max' => self::MAX_DEPTH]));
            return;
        }
    }
}

3. 동적 인덱스/키 처리:

// ✅ 배열 인덱스를 파라미터로 전달
$fail(__('validation.layout.component_must_be_array', ['index' => $index]));

// 다국어 파일: "components[:index]는 배열이어야 합니다."
// 결과: "components[0]는 배열이어야 합니다."

다국어 필드 검증 규칙

필수: LocaleRequiredTranslatable 또는 TranslatableField Rule 사용 (로케일 하드코딩 금지)

핵심 원칙

  • 현재 로케일 동적 결정: app()->getLocale()로 현재 요청의 로케일 자동 감지
  • 현재 로케일만 필수: 사용자의 언어만 required, 다른 로케일은 nullable
  • 일관된 검증: 모든 다국어 필드에 동일한 규칙 적용

Rule 종류

Rule 용도 현재 로케일 다른 로케일
LocaleRequiredTranslatable 필수 다국어 필드 required nullable + 조건 검증
TranslatableField 선택 다국어 필드 하나 이상 값 존재 시 OK nullable + 조건 검증

LocaleRequiredTranslatable 사용법

파일 위치: app/Rules/LocaleRequiredTranslatable.php

use App\Rules\LocaleRequiredTranslatable;

// 기본 사용 (maxLength: 255)
'name' => ['required', 'array', new LocaleRequiredTranslatable()],

// maxLength 지정
'name' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 100)],

// minLength + maxLength 지정
'title' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 200, minLength: 2)],

// 특정 로케일 강제 지정 (테스트 또는 특수 케이스)
'name' => ['required', 'array', new LocaleRequiredTranslatable(
    maxLength: 100,
    requiredLocale: 'en'  // 영어 필수
)],

동작 방식:

  1. app()->getLocale() → 현재 요청 로케일 결정 (예: 'ko', 'en')
  2. 현재 로케일 값 필수 검증 (빈 문자열, null, whitespace 불가)
  3. 모든 로케일에 대해 string, min, max 검증

TranslatableField 사용법

파일 위치: app/Rules/TranslatableField.php

use App\Rules\TranslatableField;

// 기본 사용 (nullable, 하나 이상 값 있으면 OK)
'description' => ['nullable', 'array', new TranslatableField()],

// maxLength 지정
'description' => ['nullable', 'array', new TranslatableField(maxLength: 1000)],

잘못된 예시 (❌ DON'T)

// ❌ 로케일 하드코딩 - 금지
$locales = config('app.translatable_locales');
foreach ($locales as $locale) {
    $rules["name.{$locale}"] = $locale === 'ko'
        ? 'required|string|max:100'
        : 'nullable|string|max:100';
}

// ❌ 첫 번째 로케일 하드코딩
$rules["name.{$locales[0]}"] = 'required|string|max:100';

// ❌ messages()에 로케일별 키 하드코딩
'name.ko.required' => '한국어 이름을 입력해주세요.',

올바른 예시 (✅ DO)

use App\Rules\LocaleRequiredTranslatable;
use App\Rules\TranslatableField;

public function rules(): array
{
    return [
        // 필수 다국어 필드
        'name' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 100)],

        // 선택 다국어 필드
        'description' => ['nullable', 'array', new TranslatableField(maxLength: 1000)],
    ];
}

public function messages(): array
{
    return [
        // Rule 내부에서 다국어 메시지 처리하므로 로케일별 키 불필요
        'name.required' => __('validation.name_required'),
    ];
}

다국어 메시지

파일 위치: lang/ko/validation.php, lang/en/validation.php

// lang/ko/validation.php
'translatable' => [
    'must_be_array' => '다국어 필드는 배열이어야 합니다.',
    'current_locale_required' => ':locale 언어의 값은 필수입니다.',
    'unsupported_language' => "지원하지 않는 언어 코드(':lang')입니다.",
    'must_be_string' => "':lang' 번역은 문자열이어야 합니다.",
    'max_length' => "':lang' 번역은 최대 :max자까지 가능합니다.",
    'min_length' => "':lang' 번역은 최소 :min자 이상이어야 합니다.",
    'at_least_one_required' => '최소 하나의 언어에 값을 입력해야 합니다.',
],

// lang/en/validation.php
'translatable' => [
    'must_be_array' => 'The translatable field must be an array.',
    'current_locale_required' => 'The :locale language value is required.',
    'unsupported_language' => "Unsupported language code: ':lang'.",
    'must_be_string' => "The ':lang' translation must be a string.",
    'max_length' => "The ':lang' translation must not exceed :max characters.",
    'min_length' => "The ':lang' translation must be at least :min characters.",
    'at_least_one_required' => 'At least one language must have a value.',
],

MultilingualDefaultLocaleRequiredRule과의 차이

Rule 로케일 결정 용도
LocaleRequiredTranslatable app()->getLocale() (동적) 사용자 요청 기반 검증
MultilingualDefaultLocaleRequiredRule config('app.supported_locales')[0] (고정) 시스템 기본 로케일 기준 검증

사용 시나리오:

  • 일반 API (사용자 언어 기반): LocaleRequiredTranslatable 사용
  • 관리자 시스템 설정 (시스템 기본 언어 기준): MultilingualDefaultLocaleRequiredRule 사용

사용자 입력 다국어 필드 정규화 (prepareForValidation)

관리자(Admin) 폼은 {"ko": "집", "en": "Home"} 형태의 다국어 배열을 직접 제출합니다. 반면, 사용자(User) 폼은 단일 문자열만 입력하지만 DB 컬럼은 JSON(다국어) 형식인 경우가 있습니다.

필수: 사용자 입력 문자열 → 다국어 배열 변환은 FormRequest의 prepareForValidation()에서 수행
FormRequest가 데이터 정규화의 Single Source of Truth (Service에서 변환 금지)

패턴

// ✅ 올바른 패턴: FormRequest에서 정규화
class StoreUserAddressRequest extends FormRequest
{
    protected function prepareForValidation(): void
    {
        if ($this->has('name') && is_string($this->input('name'))) {
            $name = $this->input('name');
            $locales = config('app.supported_locales', [config('app.locale', 'ko')]);
            $localized = [];

            foreach ($locales as $locale) {
                $localized[$locale] = $name;
            }

            $this->merge(['name' => $localized]);
        }
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'array'],
            'name.*' => ['required', 'string', 'max:50'],
            // ...
        ];
    }
}
// ❌ 잘못된 패턴: Service에서 변환
class UserAddressService
{
    public function createAddress(array $data): UserAddress
    {
        // ❌ Service는 검증/정규화를 하지 않음
        if (is_string($data['name'])) {
            $data['name'] = ['ko' => $data['name'], 'en' => $data['name']];
        }
    }
}

Repository에서 JSON 컬럼 비교

다국어 JSON 컬럼에 대한 중복 확인 등 비교 시, Laravel JSON arrow 문법을 사용합니다:

// ✅ 현재 로케일 기준으로 JSON 컬럼 비교
public function findByUserIdAndName(int $userId, string $name): ?UserAddress
{
    $locale = app()->getLocale();

    return $this->model
        ->where('user_id', $userId)
        ->where("name->{$locale}", $name)
        ->first();
}

적용 시나리오

폼 유형 name 제출 형태 FormRequest 정규화
Admin 폼 {"ko": "집", "en": "Home"} LocaleRequiredTranslatable 불필요
User 폼 "집" (문자열) prepareForValidation() + array 검증 문자열 → 다국어 배열

동적 스키마 기반 FormRequest 패턴

문제: 빈 validated() 반환

플러그인 설정처럼 동적 스키마 기반 검증에서, 플러그인이 등록되지 않았거나 스키마가 없으면 rules()가 빈 배열을 반환합니다. 이 경우 validated()도 빈 배열을 반환하여 설정값이 전달되지 않습니다.

// 문제 상황: rules()가 빈 배열 반환 시
class UpdatePluginSettingsRequest extends FormRequest
{
    public function rules(): array
    {
        $plugin = $this->getPlugin();

        if (!$plugin) {
            return [];  // 플러그인이 없으면 빈 배열
        }

        // 스키마 기반 동적 규칙 생성
        return $this->buildRulesFromSchema($plugin->getSettingsSchema());
    }
}

// Controller에서
$settings = $request->validated();  // 빈 배열 반환!

해결: Controller에서 fallback 처리

/**
 * 플러그인 설정 업데이트
 *
 * validated()가 빈 배열이면 all()에서 설정값을 가져옵니다.
 * (PluginManager에 등록되지 않은 플러그인의 경우)
 */
public function update(UpdatePluginSettingsRequest $request, string $identifier): JsonResponse
{
    // ✅ validated()가 빈 배열이면 all()로 fallback
    $settings = $request->validated();
    if (empty($settings)) {
        $settings = $request->all();
    }

    $result = $this->pluginSettingsService->save($identifier, $settings);

    return ResponseHelper::success('plugins.settings.updated', $settings);
}

적용 대상

이 패턴은 다음 경우에 적용합니다:

케이스 설명
플러그인 설정 플러그인별로 다른 설정 스키마
모듈 설정 모듈별로 다른 설정 스키마
동적 폼 런타임에 필드가 결정되는 폼

주의사항

주의: all() fallback은 신뢰할 수 있는 데이터에만 사용
✅ 플러그인/모듈 설정처럼 관리자만 접근하는 API에 적합
❌ 일반 사용자 입력에는 사용 금지 (검증 우회 위험)

exists/unique 검증 규칙

필수: Rule::exists(Model::class, 'column') / Rule::unique(Model::class, 'column') 사용 (문자열 테이블명 금지)

핵심 원칙

  • 모델 기반 테이블 식별: 테이블명이 변경되어도 모델의 $table 속성이 자동 반영
  • 타입 안전성: IDE에서 모델 클래스 참조 추적 및 리팩토링 지원
  • 버그 예방: 문자열 테이블명 오타로 인한 런타임 오류 방지

잘못된 예시 (❌ DON'T)

// ❌ 문자열 테이블명 - 금지
'user_id' => 'required|integer|exists:users,id',
'email' => 'required|email|unique:users,email',
'role_id' => ['required', 'integer', 'exists:roles,id'],

// ❌ Rule 클래스에 문자열 테이블명 - 금지
Rule::exists('users', 'id'),
Rule::unique('users', 'email'),

올바른 예시 (✅ DO)

use App\Models\User;
use App\Models\Role;
use Illuminate\Validation\Rule;

// ✅ 모델 클래스 기반
'user_uuid' => ['required', 'uuid', Rule::exists(User::class, 'uuid')],
'email' => ['required', 'email', Rule::unique(User::class, 'email')],
'role_id' => ['required', 'integer', Rule::exists(Role::class, 'id')],

// ✅ unique with ignore (수정 시 자기 자신 제외)
'email' => [
    'required', 'email',
    Rule::unique(User::class, 'email')->ignore($userId),
],

// ✅ unique with where (복합 조건)
'name' => [
    'required', 'string',
    Rule::unique(TemplateLayout::class, 'name')
        ->where('template_id', $this->input('template_id')),
],

변환 패턴 참조

Before After
'exists:users,uuid' (배열 내) Rule::exists(User::class, 'uuid')
'required|exists:users,id' (파이프) ['required', Rule::exists(User::class, 'id')]
Rule::unique('users', 'email') Rule::unique(User::class, 'email')
'unique:users,email,'.$id Rule::unique(User::class, 'email')->ignore($id)
'unique:users' (컬럼 없음) Rule::unique(User::class)

동적 테이블 예외

모델 클래스로 변환할 수 없는 동적 테이블명은 Rule::exists() 형태만 사용합니다:

// ✅ 동적 테이블 - 문자열 테이블명 허용 (Model::class 불가)
Rule::exists("board_{$slug}_posts", 'id'),

계층 리소스 순환 참조

parent_id 로 자기 자신을 참조하는 계층 리소스(메뉴, 카테고리 등)의 수정·순서 변경 요청은 Rule::exists 만으로 부족하다. exists 는 행의 존재만 보장하므로 자기 자신이나 자신의 자손을 부모로 지정해 사이클을 만들 수 있고, DB 의 FK 제약도 이를 막지 못한다.

사이클이 생기면 path/depth 재계산 재귀가 종료되지 않아 요청이 스택 오버플로로 실패하고, 사이클 노드는 루트에서 도달할 수 없어 트리에서 사라진다. 관리자 화면으로는 복구할 수 없다.

// ❌ 존재만 확인 — 자기 자신/자손을 부모로 지정 가능
'parent_id' => ['nullable', Rule::exists(Category::class, 'id')],

// ✅ 자손 전체를 검사하는 Rule 부착
'parent_id' => [
    'nullable',
    Rule::exists(Category::class, 'id'),
    new NotCircularCategoryParent($categoryId),
],

규율

항목 규칙
적용 대상 Update*Request / Reorder*Request. 생성 요청은 자기 자신이 아직 없어 순환이 불가하나, 두 엔드포인트의 규칙 구성을 같게 두면 검증 강도가 갈라지지 않는다
검사 범위 자기 자신만이 아니라 자손 전체. 자기참조만 막는 규칙(NotSelfParent)은 A → B → A 를 통과시킨다
동일 리소스 일관성 한 리소스의 모든 부모 변경 경로(수정 / 순서 변경 / 일괄 이동)가 같은 강도를 가져야 한다. 한 곳만 강화하면 나머지가 우회로가 된다
자손 판정 구현 path 머티리얼라이즈드 컬럼이 있으면 prefix 매칭(쿼리 1회). 없으면 parent_id 재귀 — 이때 방문 ID 집합으로 유한 종료를 보장한다
Service 2차 방어 검증을 우회하는 경로(시더 / 훅 / 기존 오염 데이터)를 위해 재귀 함수에 방문 ID 가드를 둔다. 무한 루프 대신 유한 실패 + 로그
코어 Rule 재사용 App\Rules\NotCircularParent 는 App\Models\Menu 하드코딩이다. 다른 모델에는 모듈 로컬 Rule 을 신설한다 (코어 시그니처 변경 = 확장 버전 제약 연쇄)

자동 차단: 정적 검사 대상 (위반 시 차단).


배열 항목의 상위 스코프

요청 본문의 배열 항목이 하위 리소스 ID 를 담고, 라우트에 상위 리소스 ID 가 있다면 Rule::exists 에 상위 스코프 where 절을 붙인다. 붙이지 않으면 A 의 경로로 B 의 하위 리소스를 조작할 수 있다.

// ❌ 전역 존재 확인 — 다른 주문의 옵션도 통과
'items.*.option_id' => ['required', 'integer', Rule::exists(OrderOption::class, 'id')],

// ✅ 경로의 상위 리소스로 스코프
$orderId = $this->route('order')?->id;
'items.*.option_id' => [
    'required',
    'integer',
    Rule::exists(OrderOption::class, 'id')->where('order_id', $orderId),
],

422 가 의미적으로 옳다 — 입력값이 이 요청 맥락에서 유효하지 않다는 뜻이기 때문이다. Service 에도 동일한 검증을 두어 FormRequest 를 우회한 내부/훅 호출을 막는다.


관대한 기존 동작을 규칙으로 승격하지 않는다

컨트롤러가 base Request 를 주입받던 코드를 FormRequest 로 옮길 때, 그 컨트롤러가 하던 일이 거부였는지 수용이었는지 먼저 읽는다. 수용하던 동작(클램핑·폴백·별칭 매핑)을 rules() 로 올리면 200 이던 응답이 422 가 된다. 위반을 없애는 것이 목적이지 계약을 바꾸는 것이 아니다.

// 원본 컨트롤러 — 상한 초과를 거부하지 않고 상한까지 반환한다
$limit = min((int) $request->input('limit', 20), 50);

// ❌ 규칙으로 승격 — `?limit=100` 이 200 → 422 (기존 링크·북마크가 깨진다)
public function rules(): array
{
    return ['limit' => ['nullable', 'integer', 'max:50']];
}

// ✅ 규칙은 타입만 닫고, 상한은 접근자가 클램프한다
public function rules(): array
{
    return ['limit' => ['nullable', 'integer', 'min:0']];
}

public function limit(): int
{
    return min((int) $this->validated('limit', 20), self::MAX_LIMIT);
}

같은 원칙이 어휘에도 적용된다. 저장소가 미지원 값을 기본 분기로 처리해 왔다면 Rule::in 으로 막는 순간 그 값을 담은 URL 이 전부 깨진다. 어휘를 닫아야 할 별도 이유(예: 요청 값이 캐시 키에 그대로 들어가 키 공간이 무한히 늘어남)가 있어도, 접근자가 닫힌 집합만 반환하게 하면 응답 계약을 건드리지 않고 목적을 달성한다.

// ✅ 응답은 종전과 동일(미지원 값 → year), 캐시 키만 정규화된다
public function period(): string
{
    $period = $this->validated('period');

    if ($period === null || $period === '') {
        return self::DEFAULT_PERIOD;
    }

    return in_array((string) $period, self::RESOLVED_PERIODS, true) ? (string) $period : 'year';
}

Rule::in 승격이 적절한 경우는 입력원이 화면 컨트롤 하나뿐일 때다 — 관리자 DataGrid 의 필터 select 처럼 사용자가 URL 을 손으로 만들 일이 없는 면. 판정 기준은 "북마크 가능한 공개 경로인가".

기존 테스트가 이 전환에서 RED 가 되면 그 테스트가 무엇을 고정하고 있는지 먼저 읽는다. 리팩토링에서 테스트가 깨질 때의 기본 해석은 "테스트가 낡았다" 가 아니라 "계약을 바꿨다" 이다.


보안 게이트 대칭성 (KVE-2026-1914/1915/1919)

같은 리소스를 다루는 두 엔드포인트가 서로 다른 검증 강도를 가지면, 약한 쪽이 우회로가 된다. 부모를 변경·서빙하는 모든 경로는 동일 강도의 검증을 거쳐야 한다.

같은 리소스, 같은 검증 강도

수정·순서변경·상태변경·권한부여처럼 같은 리소스를 바꾸는 경로가 여럿이면, 그중 하나라도 검증이 약하면 공격자는 그 경로로 우회한다. 예: 삭제 FormRequest 만 등급 상한을 검사하고 수정 FormRequest 는 검사하지 않으면, 수정 경로로 슈퍼 관리자를 조작할 수 있다. 판정 규칙은 한 곳(게이트/Rule)에 두고 모든 경로가 그것을 재사용한다.

레이아웃 표현식 검증은 표현식 트리에 부착한다

SafeLayoutExpressions 처럼 값의 구조를 재귀 탐색하는 저장측 규칙은, 표현식이 실릴 수 있는 배열/객체 트리 전체(레이아웃의 content)에 부착해야 한다. 문자열 하위 필드 (content.endpoint 등)에만 부착하면 규칙이 비-배열 값에서 조기 반환(is_array 가드)해 no-op 이 된다 — 검증이 걸려 있는 것처럼 보이지만 실제로는 아무것도 검사하지 않는다.

// ❌ 문자열 endpoint 에만 부착 — is_array 가드로 무력화(no-op)
'content.endpoint' => ['string', new SafeLayoutExpressions],

// ✅ 표현식 트리를 담는 content 배열에 부착
'content' => ['required', 'array', new ValidLayoutStructure, new SafeLayoutExpressions],

부착 위치는 "어느 필드가 표현식 트리를 담는가" 라는 도메인 판정이라 정적으로 강제하기 어렵다 — 레이아웃 저장 FormRequest(Store/Update/UpdateContent/UpdateExtensionContent) 4종의 부착을 wiring 테스트로 회귀 고정한다.

서비스/리포지토리 계층의 비밀 게이트 재적용·hash 서빙 게이트·등급 상한 대칭은 service-repository.md "보안 게이트 대칭성" 참조.


Custom Rule 개발 체크리스트

  • /lang/ko/validation.php에 한국어 메시지 추가
  • /lang/en/validation.php에 영어 메시지 추가
  • Custom Rule에서 모든 $fail() 호출 시 __() 함수 사용
  • 동적 값은 파라미터 배열로 전달 (예: ['field' => $fieldName])
  • 두 언어 모두에서 테스트 수행

Service-Repository 패턴

FormRequest 의 검증 로직(closure rule, prepareForValidation, withValidator, messages 등)이 데이터 조회를 필요로 할 때, Service 와 동일하게 Repository Interface 경유 패턴을 따른다.

금지 — Model facade 직접 호출

// ❌ Closure rule 안에서 Model::where 직접 호출
protected function uniqueRule(): Closure
{
    return function ($attribute, $value, $fail) {
        $exists = User::where('email', $value)->exists(); // ❌
        if ($exists) { $fail(__('...')); }
    };
}

// ❌ prepareForValidation 안에서 Model facade
protected function prepareForValidation(): void
{
    $template = Template::where('identifier', $name)->first(); // ❌
}

필수 — Repository Interface 경유

FormRequest 는 생성자 주입 대신 app(Interface::class) 로 해석한다 (Laravel 의 FormRequest 는 컨테이너 해석 시점이 다르며, 모든 검증 closure 가 인스턴스 스코프 안에서 동작하므로 helper 호출이 가장 단순).

use App\Contracts\Repositories\UserRepositoryInterface;

protected function uniqueRule(): Closure
{
    return function ($attribute, $value, $fail) {
        $exists = app(UserRepositoryInterface::class)->existsByEmail($value);
        if ($exists) { $fail(__('...')); }
    };
}

protected function prepareForValidation(): void
{
    $template = app(TemplateRepositoryInterface::class)
        ->findByIdentifier($this->route('templateName'));
}

조회에 필요한 메서드가 Repository Interface 에 없으면 Interface 에 메서드를 추가 한 뒤 호출한다 (검증 로직 자체를 Service 로 옮기지 않는다 — 검증은 FormRequest 책임).

자동 검증

정적 검사가 FormRequest 내 DB facade / Model 정적 호출 / 영속 메서드(save/saveQuietly/forceDelete) 직접 호출을 자동 차단한다. Rule::exists(Model::class, ...) 같은 validation rule helper 는 면제.


관련 문서