422 Unprocessable Entity 에러의 원인과 해결 방법|유효성 검사 에러를 올바르게 반환하는 방법
API를 개발할 때, 클라이언트에서 전송한 데이터가 구문상으로는 정확하지만 비즈니스 로직 요구사항을 충족하지 않는 경우가 자주 발생합니다. 이 경우 반환해야 할 HTTP 상태 코드는 422 Unprocessable Entity입니다. 이 문서에서는 422와 400의 차이, 주요 프레임워크의 구현 패턴, 파일 업로드에서 422의 활용 방법, 그리고 RFC 7807을 준수하는 에러 응답 설계까지 설명합니다.
422와 400의 차이: 구문 에러 vs 의미 에러
HTTP 400 (Bad Request)과 422 (Unprocessable Entity)는 혼동되기 쉬운 상태 코드이지만, 명확한 구분 기준이 있습니다.
| 상태 | 이름 | 의미 | 구체적인 예 |
|---|---|---|---|
| 400 | Bad Request | 요청 구문이 잘못되어 서버에서 해석할 수 없음 | 잘못된 JSON, 필수 헤더 누락, Content-Type 불일치 |
| 422 | Unprocessable Entity | 구문은 올바르지만 포함된 데이터의 의미가 부정확함 | 잘못된 이메일 형식, 범위를 벗어난 숫자 값, 잘못된 파일 형식 |
간단히 말해서, 400은 파싱 에러 (JSON이 손상되었다는 등), 422는 검증 에러 (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 응답을 반환합니다. 이는 FormRequest 또는 $request->validate()를 사용할 때의 기본 동작입니다.
// 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을 기대하는지(Accept: application/json) 여부에 따라 달라집니다. 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에서는 모델 검증 실패 시 :unprocessable_entity 심볼로 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 타입 | 확장자는 .jpg이지만 MIME 타입이 text/plain인 경우 |
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はインターセプターで処理済み
}
}
이 기사에서 사용할 수 있는 테스트 파일 (무료)
- → 테스트 이미지 목록 — MIME 타입 및 파일 확장자 검증 테스트용
- → 경계값 테스트 파일 목록 — 파일 크기 상한 검증의 경계값 테스트용
- → 1MB 테스트 PNG 이미지 — 이미지 업로드 기본 테스트용
- → PDF 테스트 파일 목록 — 파일 형식 유효성 검증 테스트용