Files
Gnuboard7/docs/backend/validation.md
T
HeuJung b10e89bef0 fix(core,board,ecommerce,page,gdpr,ckeditor5,kginicis,admin_basic,basic): 목록 조회·검증 계층 정비 및 감사 지적 전건 처리
목록 조회 성능 축(깊은 OFFSET·정렬·색인)과 그 검증 계층에서 계획서 전수검수와 3회에 걸친
감사가 지적한 항목을 처리했다. 세부 경위는 의 각 회차 문서에 있다.

검증 계층 — 컨트롤러가 base Request 를 직접 주입받던 확장 13곳을 전용 FormRequest 로 옮겼다.
옮기면서 기존 동작 계약은 그대로 두었다: 상한 초과 limit 을 거부하지 않고 상한까지 반환하던
공개 API, per_page 를 범위로 조정하던 관리자 목록, 미지원 period 를 year 로 해석하던 인기글
목록 모두 종전과 같은 응답을 낸다. 상한/폴백을 rules 로 승격시키면 200 이던 응답이 422 가
되어 기존 링크가 깨지므로, 규칙은 타입만 닫고 클램프·폴백은 접근자가 맡는다. period 는
접근자가 닫힌 집합만 반환해 캐시 키 공간도 함께 닫힌다.

ckeditor5 이미지 업로드만 ResponseHelper 봉투를 쓰지 않는다. 응답을 파싱하는 주체가 CDN 으로
로드되는 상위 CKEditor5 43.3.1 의 SimpleUploadAdapter 라 규약을 바꿀 수 없어, 각 응답 지점에
사유를 명시한 면제를 부착하고 근거를 API 문서에 남겼다.

하네스 — 룰 5개의 대상 경로 패턴이 매처와 맞지 않아 번들 확장 컨트롤러가 검사 대상에서
통째로 빠져 있었다. 패턴을 고치자 확장 위반 17건이 드러나 전건 처리했다. severity 오타가
요약 집계 양쪽에 안 잡혀 "0 error" 로 보고되던 문제도 런너 사전 검증으로 막았다.

정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다.
탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
2026-08-02 15:29:29 +09:00

47 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 가 되면 그 테스트가 무엇을 고정하고 있는지 먼저 읽는다. 리팩토링에서 테스트가 깨질 때의 기본 해석은 "테스트가 낡았다" 가 아니라 "계약을 바꿨다" 이다.


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 는 면제.


관련 문서