서버들의 HTTP 에러 처리가 제각각이네요.
서버가 돌려주는 에러 response 를 form 에 다시 표시하는 처리에 대한 걸 공부하고 있었습니다.
response data로 루프를 도는 부분에서 “서버가 어떤 포맷으로 폼 에러를 뱉을 지 모르지 않나?” 라는 생각이 들었습니다.
그래서 제미나이에게 물어보니 아래와 같이 답을 줍니다.
요약
의외로 스프링이 표준을 지키는 쪽입니다.
자바스크립트 서버들은 역시 중구난방이네요. (zod 라는 이름은 적응이 안 됩니다)
질문 :
ServerSyncForm 의 경우 서버 에러가 다음과 같이 에러가 발생한 form의 필드를 담고 있다고 가정하고 있어.
response: {
data: {
errors: {
username: "이미 사용 중인 이름입니다.",
email: "차단된 이메일 도메인입니다."
}
}
}
복잡한 데이터를 담고 있는 폼의 경우 nested fields 의 에러를 어떻게 리턴하는 걸까? 이런 에러 처리 규약에는 어떤 것들이 있고, 각각을 따르는 서버들은 어떤 것들이 있을까?답변:
복잡한 형태의 데이터(ex: 중첩된 객체 배열 등)를 폼으로 전송할 때 서버에서 유효성 검사 에러를 반환하는 방식은 크게 3가지 패턴으로 나뉩니다. 프론트엔드 폼 라이브러리(React Hook Form 등)와 어떻게 연결할지에 따라 선호되는 방식이 다르며, 백엔드 프레임워크나 API 스펙의 기본 동작도 제각각입니다.
1. 중첩 필드 에러 반환 패턴
A. 점 표기법 (Dot Notation) / 문자열 경로 형태 - (가장 많이 쓰임)
React Hook Form이나 Formik 같은 프론트엔드 라이브러리들이 가장 쉽게 파싱하고 매핑할 수 있는 형태입니다. 객체 구조를 1차원(Flat) 객체로 평탄화하여 알려줍니다.
{
"response": {
"data": {
"errors": {
"address.city": "도시 이름은 필수입니다.",
"address.zipcode": "올바른 우편번호 형식이 아닙니다.",
"users[0].name": "첫 번째 사용자의 이름을 입력해주세요.",
"users.1.email": "두 번째 사용자의 이메일이 중복됩니다." // 프레임워크에 따라 .1. 형태도 사용
}
}
}
}B. 에러 객체 배열 (Array of Error Objects)
어떤 필드에서 어떤 에러가 났는지 객체의 목록으로 반환합니다. 파싱하기 명확하고 확장이 쉽습니다.
{
"response": {
"data": {
"errors": [
{
"field": "address.city", // 또는 path: "address.city"
"message": "도시 이름은 필수입니다.",
"code": "REQUIRED_FIELD" // 에러 코드 확장에 유리함
},
{
"field": "users[0].name",
"message": "첫 번째 사용자의 이름을 입력해주세요."
}
]
}
}
}
C. 데이터 구조와 동일한 중첩 객체 (Nested Object)
요청했던 데이터 구조(Payload)와 완벽히 동일한 구조로 구성된 에러 객체를 보냅니다. 한눈에 보기엔 구조적이지만 프론트엔드 폼 상태에 매핑하려면 재귀적으로 순회하며 구조를 풀어야 하는(Flatten) 번거로움이 있습니다.
{
"response": {
"data": {
"errors": {
"address": {
"city": "도시 이름은 필수입니다."
},
"users": [
{ "name": "첫 번째 사용자의 이름을 입력해주세요." }
]
}
}
}
}
2. 이런 에러 처리 규약을 지키는 서버 및 프레임워크들
백엔드 기술 스택과 API 표준화 명세에 따라 선호하거나 기본적으로 채택하고 있는 규약이 다릅니다.
API 표준 명세 (표준 규약)
JSON:API (jsonapi.org)
JSON Pointer(RFC 6901)를 사용하여 어떤 항목에서 에러가 났는지 알려줍니다. (위의 B 패턴에서 파생)
{"source": {"pointer": "/data/attributes/address/city"}, "detail": "필수입니다."}
RFC 7807 (Problem Details for HTTP APIs)
최근 가장 권장되는 HTTP API 에러 표준 규약입니다.
기본적으로
type,title,status,detail같은 필드를 가지며, 유효성 검사 에러를 담기 위해invalid-params배열 속성을 확장해서 쓰는 경우가 많습니다. (위의 B 패턴)
서버 프레임워크 기본 동작
Spring Boot (Java / Kotlin)
기본적으로
MethodArgumentNotValidException발생 시, B 패턴(객체 배열)과 유사하게 필드 에러 목록을 반환합니다.이때
field프로퍼티 값은address.city나users[0].name형태로 전달됩니다(A 패턴의 문자열 경로 사용).
Laravel (PHP)
기본 구동 시 A 패턴(점 표기법)을 적극적으로 사용합니다.
반환 키값이
{"users.0.name": ["이름이 필요합니다."]}처럼 배열 인덱스도 점(.)으로 반환하는 형식을 취합니다. Laravel과 Vue/React를 함께 쓸 때 이 점 표기법을 자주 경험합니다.
Django REST Framework (Python)
보통 C 패턴(데이터 구조와 동일한 중첩 객체)을 사용합니다. Serializer가 중첩되어 있다면 에러 구조도 그에 맞춰 중첩된 딕셔너리 리스트 구조로 응답합니다.
GraphQL
GraphQL 스펙 자체적으로 에러 응답 포맷이 정의되어 있습니다. (B 패턴과 유사)
에러 객체 내부에
path속성을 배열형태("path": ["users", 0, "name"])로 제공합니다.
프론트엔드에서의 대처
현재 질문자님이 보여주신 코드는 A 패턴을 기대하는 경우입니다. 만약 다양한 서버 명세나 복잡한 C 패턴이 들어올 수도 있다면, 가장 권장되는 방식은 Axios Interceptor 등에서 서버의 다양한 형식(B, C 형식 등)을 프론트엔드 폼 라이브러리가 쉽게 사용할 수 있는 A 형식(점 표기법 평탄화)으로 파싱(Flatten)해서 반환해 주는 것입니다. 이렇게 하면 각 컴포넌트(ServerSyncForm 등)가 서버의 응답 구조에 종속되지 않고 일관된 방식으로 폼 에러 처리를 할 수 있습니다.
추가 질문:
자바스크립트 서버 프레임웍들은 위의 a, b, c 타입으로 분류하면 어디에 해당될까?답변:
자바스크립트 생태계(Node.js 등)의 가장 큰 특징은 "프레임워크 자체의 관여도(Opinionated)가 낮다"는 점입니다. 즉, Spring Boot나 Laravel처럼 "에러는 무조건 이렇게 반환해라"라고 강제하는 규약이 없습니다.
따라서 프레임워크 자체보다는 어떤 '유효성 검사 라이브러리(Validation Library)'를 선택했느냐에 따라 응답 형태가 결정됩니다.
하지만 거의 모든 주류 라이브러리들은 기본적으로 B 타입(에러 객체 배열) 또는 B 타입에서 약간 변형된 형태를 채택하고 있습니다. 대표적인 조합들을 살펴보면 다음과 같습니다.
1. Express + express-validator ➡️ 기본적으로 B 타입 (객체 배열)
Express에서 가장 많이 쓰이는 미들웨어인 express-validator는 에러 검증이 끝난 후 .array() 메서드를 호출하면 아래처럼 B 타입(객체 배열)을 반환합니다. path 필드에 점 표기법이나 인덱스를 알려줍니다.
{
"errors": [
{
"type": "field",
"value": "",
"msg": "이름은 필수입니다.",
"path": "users[0].name", // 어디서 에러가 났는지 문자열로(A 타입 형태) 알려줌
"location": "body"
}
]
}
2. 최신 트렌드: Zod (Next.js, Fastify, tRPC 등) ➡️ B 타입 기반 (개발자 입맛대로 변형)
요즘 가장 인기 있는 Zod는 기본 검사 실패 시(error.issues) 에러 객체 배열(B 타입)을 뱉어냅니다. 단, 경로(path)를 문자열이 아니라 배열로 반환하는 것이 특징입니다.
// Zod의 기본 반환 형태 (B 타입)
[
{
"code": "too_small",
"minimum": 2,
"path": ["users", 0, "name"], // 경로를 배열로 줌
"message": "이름은 2글자 이상이어야 합니다."
}
]
다만 Zod는 error.flatten() 이라는 내장 함수를 제공하여, 개발자가 원한다면 위 배열을 프론트엔드가 폼에 매핑하기 아주 편한 A 타입(점 표기법/1차원 객체)으로 한 번에 바꿔서 응답할 수 있게 해줍니다.
3. NestJS + class-validator ➡️ 기본적으로 B/C 혼합 타입
이전 설명에서 NestJS가 400 Bad Request에 단순히 에러 "문자열의 배열"을 반환한다고 예시를 들었지만, 실제 원본 에러(ValidationError) 구조를 뜯어보면 중첩 객체(Nested)를 지원하기 위해 재귀적인 구조를 가집니다.
// NestJS 원본 에러 객체 형태
[
{
"property": "users",
"children": [
{
"property": "0", // 배열 인덱스
"children": [
{
"property": "name",
"constraints": {
"isNotEmpty": "이름은 필수입니다."
}
}
]
}
]
}
]
이 구조는 프론트엔드에서 파싱하기 너무 까다로워서, 대부분의 NestJS 백엔드 개발자 시니어들은 전역 예외 필터(Global Exception Filter)를 만들어서 이 복잡한 구조를 A 타입이나 심플한 B 타입(path와 message만 있는 객체 배열)으로 변환해서 반환하도록 커스텀합니다.
4. AdonisJS ➡️ 기본적으로 A 타입 (점 표기법)
Node.js 진영에서 드물게 풀스택(MVC)을 지향하는 Laravel 스타일의 프레임워크입니다. 이 프레임워크는 내부 검증기가 실패하면 프론트엔드 폼 라이브러리가 즉시 사용하기 좋도록 A 타입으로 에러를 반환합니다.
{
"errors": {
"users.0.name": ["이름은 필수입니다."]
}
}결론 요약
자바스크립트 서버 생태계의 디폴트는 B 타입(에러 객체 배열)이라고 보시면 됩니다. 에러 목록을 배열로 쭉 나열하고, 각 객체 안에
path프로퍼티로 위치를 알려주는 방식이 파싱하기 가장 명확하기 때문입니다.하지만 질문자님이 다루고 있는 React Hook Form과 같은 클라이언트 라이브러리들은 압도적으로 A 타입(점 표기법 /
{ "users.0.name": "에러"})을 선호합니다. 데이터 구조와 폼 필드name속성을 1:1로 매칭할 수 있기 때문입니다.따라서 가장 이상적인 실무 패턴은 서버가 표준적인 B 타입 형태(
path: "users[0].name", message: "...")로 배열을 던져주고, 프론트엔드의 공통 API 요청 모듈(ex: axios interceptor)이나 커스텀 훅에서 이를 A 타입 형식의 단일 객체로 변환(Reduce/Flatten)하여 폼 라이브러리에 넘겨주는 방식입니다.
