콘텐츠로 건너뛰기

422 Unprocessable Entity 에러의 원인과 해결 방법|유효성 검사 에러를 올바르게 반환하는 방법

카테고리: HTTP·API 설계
이 기사는 현재 일본어로만 제공됩니다. 번역본은 순차적으로 공개될 예정입니다.

API를 개발할 때, 클라이언트에서 전송한 데이터가 구문상으로는 정확하지만 비즈니스 로직 요구사항을 충족하지 않는 경우가 자주 발생합니다. 이 경우 반환해야 할 HTTP 상태 코드는 <strong>422 Unprocessable Entity</strong>입니다. 이 문서에서는 422와 400의 차이, 주요 프레임워크의 구현 패턴, 파일 업로드에서 422의 활용 방법, 그리고 RFC 7807을 준수하는 에러 응답 설계까지 설명합니다.

422와 400의 차이: 구문 에러 vs 의미 에러

HTTP 400 (Bad Request)과 422 (Unprocessable Entity)는 혼동되기 쉬운 상태 코드이지만, 명확한 구분 기준이 있습니다.

상태 이름 의미 구체적인 예
400 Bad Request 요청 구문이 잘못되어 서버에서 해석할 수 없음 잘못된 JSON, 필수 헤더 누락, Content-Type 불일치
422 Unprocessable Entity 구문은 올바르지만 포함된 데이터의 의미가 부정확함 잘못된 이메일 형식, 범위를 벗어난 숫자 값, 잘못된 파일 형식

간단히 말해서, <strong>400은 파싱 에러</strong> (JSON이 손상되었다는 등), <strong>422는 검증 에러</strong> (JSON은 올바르지만 내용이 요구 사항을 충족하지 않음)입니다. 422는 원래 WebDAV 확장 (RFC 4918)에서 정의되었지만 현재는 REST API에서 널리 채택되고 있습니다.

// 400 Bad Request の例: JSONの構文が壊れている
// リクエストボディ: {"name": "太郎", "email": }  ← JSONパースエラー

// 422 Unprocessable Entity の例: JSONは正しいがバリデーション失敗
// リクエストボディ: {"name": "", "email": "not-an-email"}
// レスポンス:
{
    "message": "The given data was invalid.",
    "errors": {
        "name": ["名前は必須です。"],
        "email": ["有効なメールアドレスを入力してください。"]
    }
}

Laravel에서 422 사용법

Laravel은 유효성 검사 실패 시 자동으로 422 응답을 반환합니다. 이는 <code>FormRequest</code> 또는 <code>$request->validate()</code>를 사용할 때의 기본 동작입니다.

// Laravel: バリデーション失敗時に自動で422を返す
class StoreUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name'  => ['required', 'string', 'max:255'],
            'email' => ['required', 'email', 'unique:users,email'],
            'age'   => ['required', 'integer', 'min:0', 'max:150'],
            'avatar' => ['nullable', 'image', 'mimes:jpg,png,webp', 'max:5120'],
        ];
    }

    public function messages(): array
    {
        return [
            'name.required'  => '名前は必須です。',
            'email.required' => 'メールアドレスは必須です。',
            'email.email'    => '有効なメールアドレスを入力してください。',
            'email.unique'   => 'このメールアドレスは既に登録されています。',
            'avatar.max'     => 'アバター画像は5MB以下にしてください。',
        ];
    }
}

// コントローラー
class UserController extends Controller
{
    public function store(StoreUserRequest $request)
    {
        // バリデーション通過済み - ここに到達した時点で422は返らない
        $user = User::create($request->validated());
        return response()->json($user, 201);
    }
}

Laravel이 유효성 검사 실패 시 반환하는 응답은 요청이 JSON을 기대하는지(<code>Accept: application/json</code>) 여부에 따라 달라집니다. JSON 요청의 경우 422 상태와 함께 오류 JSON이 반환되고, 일반 양식 요청의 경우 세션에 오류를 저장한 후 원본 페이지로 리다이렉트됩니다.

Django에서 422 사용하기

Django REST Framework(DRF)는 기본적으로 유효성 검사 실패 시 400을 반환하지만, 422를 사용하도록 커스터마이즈할 수 있습니다.

# Django REST Framework: カスタム例外ハンドラーで422を返す
from rest_framework.views import exception_handler
from rest_framework.exceptions import ValidationError
from rest_framework import status

def custom_exception_handler(exc, context):
    response = exception_handler(exc, context)

    if isinstance(exc, ValidationError) and response is not None:
        # バリデーションエラーのステータスを422に変更
        response.status_code = status.HTTP_422_UNPROCESSABLE_ENTITY

    return response

# settings.py に設定
# REST_FRAMEWORK = {
#     'EXCEPTION_HANDLER': 'myapp.utils.custom_exception_handler'
# }

# シリアライザーでのバリデーション
from rest_framework import serializers

class UserSerializer(serializers.Serializer):
    name = serializers.CharField(max_length=255)
    email = serializers.EmailField()
    avatar = serializers.ImageField(required=False)

    def validate_email(self, value):
        if User.objects.filter(email=value).exists():
            raise serializers.ValidationError(
                "このメールアドレスは既に登録されています。"
            )
        return value

Rails에서 422 사용 방법

Ruby on Rails에서는 모델 검증 실패 시 <code>:unprocessable_entity</code> 심볼로 422를 반환하는 것이 일반적입니다.

# Rails: バリデーションエラー時に422を返す
class UsersController < ApplicationController
  def create
    user = User.new(user_params)

    if user.save
      render json: user, status: :created
    else
      render json: {
        message: "バリデーションエラー",
        errors: user.errors.full_messages
      }, status: :unprocessable_entity  # 422
    end
  end

  private

  def user_params
    params.require(:user).permit(:name, :email, :avatar)
  end
end

# モデルのバリデーション
class User < ApplicationRecord
  validates :name, presence: true, length: { maximum: 255 }
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :email, uniqueness: true

  has_one_attached :avatar
  validates :avatar, content_type: ['image/jpeg', 'image/png', 'image/webp'],
                     size: { less_than: 5.megabytes }
end

파일 업로드에서의 422

파일 업로드 기능에서는 다양한 검증으로 422를 반환하는 경우가 있습니다. 413(크기 초과)과 달리, 422는 서버가 요청을 정상적으로 수신했지만 파일 내용이 요건을 충족하지 않을 때 반환됩니다.

검증 항목 설명 스테이터스 코드
유효하지 않은 MIME 타입 확장자는 <code>.jpg</code>이지만 MIME 타입이 <code>text/plain</code>인 경우 422
잘못된 확장자 허용되지 않는 파일 형식(.exe 등) 422
이미지 크기가 범위를 벗어남 최소 크기 미만 또는 최대 크기 초과 422
파일 손상 이미지로 로드할 수 없는 손상된 파일 422
바이러스 감지 ClamAV 등에서 악성 코드가 감지된 경우 422
크기 초과 애플리케이션 측 제한 초과 422 or 413
// ファイルアップロードの詳細バリデーション例(Laravel)
public function uploadAvatar(Request $request)
{
    $request->validate([
        'avatar' => ['required', 'file'],
    ]);

    $file = $request->file('avatar');

    // MIMEタイプの二重チェック(拡張子偽装対策)
    $allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
    $detectedMime = $file->getMimeType(); // finfo による判定

    if (!in_array($detectedMime, $allowedMimes)) {
        return response()->json([
            'message' => 'バリデーションエラー',
            'errors' => [
                'avatar' => [
                    "許可されていないファイル形式です。検出されたMIMEタイプ: {$detectedMime}"
                ]
            ]
        ], 422);
    }

    // 画像として正常に読み込めるか確認
    $imageInfo = @getimagesize($file->getRealPath());
    if ($imageInfo === false) {
        return response()->json([
            'message' => 'バリデーションエラー',
            'errors' => [
                'avatar' => ['ファイルが破損しているか、有効な画像ファイルではありません。']
            ]
        ], 422);
    }

    // 画像寸法チェック
    [$width, $height] = $imageInfo;
    if ($width < 100 || $height < 100) {
        return response()->json([
            'message' => 'バリデーションエラー',
            'errors' => [
                'avatar' => ["画像は100x100px以上である必要があります。現在: {$width}x{$height}px"]
            ]
        ], 422);
    }

    // バリデーション通過 - 保存処理
    $path = $file->store('avatars', 'public');
    return response()->json(['path' => $path], 201);
}

API의 422 응답 형식(RFC 7807 Problem Details)

API의 에러 응답을 표준화하기 위해 RFC 7807(Problem Details for HTTP APIs)이 제정되었습니다. 이 사양을 준수함으로써 클라이언트 측에서 에러 처리를 일관되게 수행할 수 있습니다.

// RFC 7807 準拠の 422 レスポンス例
// Content-Type: application/problem+json

{
    "type": "https://example.com/problems/validation-error",
    "title": "バリデーションエラー",
    "status": 422,
    "detail": "送信されたデータに2件のエラーがあります。",
    "instance": "/api/users",
    "errors": [
        {
            "field": "email",
            "message": "有効なメールアドレスを入力してください。",
            "code": "invalid_format"
        },
        {
            "field": "avatar",
            "message": "ファイル形式はJPEG・PNG・WebPのみ対応しています。",
            "code": "invalid_mime_type"
        }
    ]
}
// Laravel で RFC 7807 準拠のレスポンスを返す
use Symfony\Component\HttpFoundation\Response;

class ApiController extends Controller
{
    protected function validationProblem(
        array $errors,
        string $detail = 'バリデーションエラーが発生しました。'
    ): Response {
        $formattedErrors = [];
        foreach ($errors as $field => $messages) {
            foreach ($messages as $message) {
                $formattedErrors[] = [
                    'field'   => $field,
                    'message' => $message,
                ];
            }
        }

        return response()->json([
            'type'   => 'https://example.com/problems/validation-error',
            'title'  => 'Unprocessable Entity',
            'status' => 422,
            'detail' => $detail,
            'errors' => $formattedErrors,
        ], 422, [
            'Content-Type' => 'application/problem+json',
        ]);
    }
}

프론트엔드 오류 표시 패턴

프론트엔드가 422 응답을 받으면 각 필드별로 에러 메시지를 사용자가 쉽게 이해할 수 있도록 표시해야 합니다.

// fetch API での 422 エラーハンドリング
async function submitForm(formData) {
    try {
        const response = await fetch('/api/users', {
            method: 'POST',
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(formData),
        });

        if (response.status === 422) {
            const data = await response.json();

            // フィールドごとにエラーを表示
            clearErrors();
            for (const [field, messages] of Object.entries(data.errors)) {
                const input = document.querySelector(`[name="${field}"]`);
                if (input) {
                    input.classList.add('border-red-500');
                    const errorDiv = document.createElement('div');
                    errorDiv.className = 'text-red-500 text-sm mt-1';
                    errorDiv.textContent = messages[0];
                    input.parentNode.appendChild(errorDiv);
                }
            }
            return;
        }

        if (!response.ok) {
            throw new Error('サーバーエラーが発生しました。');
        }

        const result = await response.json();
        showSuccess('登録が完了しました。');
    } catch (error) {
        showError(error.message);
    }
}

function clearErrors() {
    document.querySelectorAll('.border-red-500').forEach(el => {
        el.classList.remove('border-red-500');
    });
    document.querySelectorAll('.text-red-500').forEach(el => {
        el.remove();
    });
}
// axios での 422 エラーハンドリング(Vue.js / React 等で利用)
import axios from 'axios';

// グローバルインターセプターで422を処理
axios.interceptors.response.use(
    response => response,
    error => {
        if (error.response && error.response.status === 422) {
            // バリデーションエラーをストアに保存
            const errors = error.response.data.errors;
            store.commit('setValidationErrors', errors);
        }
        return Promise.reject(error);
    }
);

// コンポーネントでの使用例
async function handleSubmit() {
    try {
        store.commit('clearValidationErrors');
        const response = await axios.post('/api/users', formData);
        // 成功処理
    } catch (error) {
        if (error.response?.status !== 422) {
            // 422以外のエラーはグローバルで処理
            alert('予期しないエラーが発生しました。');
        }
        // 422はインターセプターで処理済み
    }
}

이 기사에서 사용할 수 있는 테스트 파일 (무료)

  • → <a href="/ja/files/images/" class="text-primary-600 dark:text-primary-400 hover:underline">테스트 이미지 목록</a> — MIME 타입 및 파일 확장자 검증 테스트용
  • → <a href="/ja/files/threshold/" class="text-primary-600 dark:text-primary-400 hover:underline">경계값 테스트 파일 목록</a> — 파일 크기 상한 검증의 경계값 테스트용
  • → <a href="/ja/files/images/png/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">1MB 테스트 PNG 이미지</a> — 이미지 업로드 기본 테스트용
  • → <a href="/ja/files/pdf/" class="text-primary-600 dark:text-primary-400 hover:underline">PDF 테스트 파일 목록</a> — 파일 형식 유효성 검증 테스트용

관련 기사

  • → <a href="/ja/blog/http-413-error/" class="text-primary-600 dark:text-primary-400 hover:underline">413 Request Entity Too Large 에러의 원인과 해결방법</a>
  • → <a href="/ja/blog/file-validation-checklist/" class="text-primary-600 dark:text-primary-400 hover:underline">웹 폼 파일 검증 구현 체크리스트</a>
  • → <a href="/ja/blog/laravel-file-upload/" class="text-primary-600 dark:text-primary-400 hover:underline">Laravel 파일 업로드 구현 가이드|검증·Storage·S3 대응</a>
  • → <a href="/ja/blog/http-507-error/" class="text-primary-600 dark:text-primary-400 hover:underline">507 Insufficient Storage 에러의 원인과 해결방법</a>