스키마‑우선 React 폼: 하나의 스키마, 세 개의 오류 레이어, 제로 글루
Source: Dev.to
위 링크에 있는 글의 전체 내용을 제공해 주시면, 해당 텍스트를 한국어로 번역해 드리겠습니다. (코드 블록, URL, 마크다운 형식 등은 그대로 유지됩니다.)
파트 3 – Railway‑Oriented TypeScript
Part 1은 fieldValidators와 setServerErrors가 글루 코드를 없애는 방법을 보여줍니다. 이번 파트에서는 폼 훅이 실제로 세 가지 오류 소스를 어떻게 다루는지, 그리고 단일 스키마가 백엔드 파이프라인 및 프론트엔드 폼을 어떻게 구동하는지 깊이 살펴봅니다.
오류의 세 가지 소스
| 우선순위 | 소스 | 설정 방법 | 해제 시점 |
|---|---|---|---|
| 1 (가장 낮음) | 스키마 검증 | change / blur / submit 시 자동 | 각 검증 실행 시 |
| 2 | 비동기 필드 검증기 | fieldValidators 옵션 | 필드 검증기가 다시 실행될 때 |
| 3 (가장 높음) | 서버 오류 | form.setServerErrors(...) | 사용자가 해당 필드를 편집할 때 |
우선순위가 높을수록 우선 적용됩니다.
서버 오류는 스키마 검증이 통과해도 계속 표시됩니다 — 서버가 최종 권한을 가집니다.
비동기 “사용자 이름 중복” 오류는 스키마 수준의 “너무 짧음” 오류를 덮어씁니다 — 실시간 검사가 더 구체적이기 때문입니다.
필드를 편집하면 서버 오류가 사라지고 다시 스키마 검증이 적용됩니다.
컴포넌트 코드에서 이를 직접 관리할 필요가 없습니다; form.errors.email을 읽어 그대로 표시하면 됩니다.
{form.touched.email && form.errors.email && (
{form.errors.email}
)}
{/* Could be a schema error, async field error, or server error.
Always shows the highest‑priority one. */}
설치
npm install @railway-ts/use-form @railway-ts/pipelines
하나의 스키마, 두 세계
스키마는 프론트엔드 폼과 백엔드 파이프라인을 모두 구동합니다 — 전체 스택 섹션에서 자세히 다룹니다. 한 번 정의하고 공유 파일에서 내보내세요.
// schema.ts
import {
object,
required,
optional,
chain,
string,
nonEmpty,
email,
minLength,
parseNumber,
min,
max,
array,
stringEnum,
refineAt,
type InferSchemaType,
} from "@railway-ts/pipelines/schema";
export const registrationSchema = chain(
object({
username: required(
chain(string(), nonEmpty("Username is required"), minLength(3)),
),
email: required(chain(string(), nonEmpty("Email is required"), email())),
password: required(
chain(string(), nonEmpty("Password is required"), minLength(8)),
),
confirmPassword: required(
chain(string(), nonEmpty("Please confirm your password")),
),
age: required(
chain(parseNumber(), min(18, "Must be at least 18"), max(120)),
),
contacts: optional(array(stringEnum(["email", "phone", "sms"]))),
}),
refineAt(
"confirmPassword",
(d) => d.password === d.confirmPassword,
"Passwords must match",
),
);
export type Registration = InferSchemaType;
useForm와 스키마 사용하기
import { useForm } from "@railway-ts/use-form";
import { registrationSchema, type Registration } from "./schema";
const form = useForm(registrationSchema, {
initialValues: {
username: "",
email: "",
password: "",
confirmPassword: "",
age: 0,
contacts: [],
},
fieldValidators: {
username: async (value) => {
const { available } = await fetch(
`/api/check-username?u=${encodeURIComponent(value)}`,
).then((r) => r.json());
return available ? undefined : "Username is already taken";
},
},
onSubmit: async (values) => {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) form.setServerErrors(await res.json());
else navigate("/welcome");
},
});
- 타입은 스키마에서
initialValues,errors,touched,getFieldProps로 흐릅니다.
form.getFieldProps("usernam")은 존재하지 않는 필드 이름이므로 TypeScript 오류를 발생시킵니다. fieldValidators키는 오직Registration에 정의된 유효한 필드 이름만 허용하도록 타입이 지정됩니다.
서버‑측 오류
setServerErrors는 필드‑레벨 오류를 처리합니다. 특정 필드에 연결되지 않은 오류(예: 네트워크 실패, 속도 제한)의 경우 예약된 루트 키를 사용합니다:
import { ROOT_ERROR_KEY } from "@railway-ts/pipelines/schema";
form.setServerErrors({
[ROOT_ERROR_KEY]: "Network error. Please try again.",
});
{form.errors[ROOT_ERROR_KEY] && (
{form.errors[ROOT_ERROR_KEY]}
)}
ROOT_ERROR_KEY는 문자열 "_root"이며, 상수로 내보내져서 컴포넌트 전역에 리터럴 문자열을 흩뿌릴 필요가 없습니다.
일반 UI 패턴
체크박스 그룹 (정적 옵션 → 배열 필드)
{["email", "phone", "sms"].map((option) => (
{option}
))}
동적 리스트 (런타임에 추가/제거)
const {
push,
remove,
insert,
swap,
replace,
} = form.arrayHelpers("todos");
모든 작업은 type‑safe합니다. 오류 경로는 자동으로 생성됩니다 — 예를 들어 todos[2].text가 검증에 실패하면 form.errors["todos.2.text"]에 메시지가 들어갑니다. 오류 경로 문자열을 직접 만들 필요가 없습니다.
풀스택 이점
동일한 registrationSchema가 프론트엔드 폼을 구동하면서 백엔드 요청을 검증하고, 파이프라인이 생성하는 오류 형식은 setServerErrors가 정확히 기대하는 형태와 일치합니다.
// server.ts
import { validate, formatErrors } from "@railway-ts/pipelines/schema";
import { pipeAsync } from "@railway-ts/pipelines/composition";
import { ok, err, flatMapWith, match } from "@railway-ts/pipelines/result";
import { registrationSchema } from "./schema";
export const register = pipeAsync(
async (req) => {
const body = await req.json();
return validate(registrationSchema, body);
},
flatMapWith((data) => {
// …business logic, e.g. create user in DB
return ok(data);
}),
match({
Ok: (data) => new Response(JSON.stringify(data), { status: 200 }),
Err: (e) =>
new Response(JSON.stringify(formatErrors(e)), { status: 400 }),
}),
);
이제 프론트엔드와 백엔드는 검증, 오류 포맷팅, TypeScript 타입에 대한 단일 진실 원천을 공유합니다 — Railway‑Oriented TypeScript의 핵심이 바로 이것입니다.
백엔드 – 회원가입 흐름
import { registrationSchema, type Registration } from "./schema"; // same file
const checkEmailUnique = async (data: Registration) => {
const exists = await db.user.findUnique({ where: { email: data.email } });
return exists
? err([{ path: ["email"], message: "Email already registered" }])
: ok(data);
};
const createUser = async (data: Registration) => {
const user = await db.user.create({
data: {
username: data.username,
email: data.email,
password: await hash(data.password),
age: data.age,
},
});
return ok(user);
};
const handleRegistration = async (body: unknown) => {
const result = await pipeAsync(
validate(body, registrationSchema), // Result
flatMapWith(checkEmailUnique), // runs only if validation succeeded
flatMapWith(createUser), // runs only if email is unique
);
return match(result, {
ok: (user) => ({ status: 201, body: { id: user.id } }),
err: (errors) => ({ status: 422, body: formatErrors(errors) }),
});
};
app.post("/api/register", async (req, res) => {
const { status, body } = await handleRegistration(req.body);
res.status(status).json(body);
});
validate(body, registrationSchema)는Result를 반환합니다.- 검증이 통과하면
checkEmailUnique가 실행됩니다. - 이메일이 고유하면
createUser가 실행됩니다. match는 한 번만 분기하여Result를 HTTP 응답으로 변환합니다.
오류 포맷팅
// ValidationError[] from validate() or checkEmailUnique
[
{ path: ["email"], message: "Email already registered" }
]
// formatErrors() → Record
{
email: "Email already registered"
}
formatErrors가 생성하는 형태는 프론트엔드의 form.setServerErrors()가 정확히 기대하는 형태이므로, 추가 변환이나 필드명 매핑이 필요하지 않습니다.
Source: …
Frontend – @railway-ts/use-form Hook
import { z } from "zod";
import { useForm } from "@railway-ts/use-form";
const zodSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
age: z.coerce.number().min(18, "Must be at least 18"),
});
type ZodUser = z.infer;
const form = useForm(zodSchema, {
initialValues: { username: "", email: "", password: "", age: 0 },
onSubmit: (values) => console.log(values),
});
- Resolver가 필요 없음 – 훅이 Zod(또는 Valibot) 스키마를 자동으로 감지합니다.
- 전체 훅 API를 사용할 수 있습니다:
getFieldProps,touched,setServerErrors,fieldValidators,arrayHelpers, 그리고 3‑계층 오류 시스템. - 백엔드와 프론트엔드가 동일한 스키마를 공유하기 때문에 서버‑사이드 오류가 폼 필드에 바로 매핑됩니다.
Controlled vs. Uncontrolled Inputs
| 접근 방식 | 특징 |
|---|---|
@railway-ts/use-form (controlled) | 모든 키 입력이 React 상태를 업데이트 → 컴포넌트 재렌더링. 사고 모델이 단순하고, 전체 상태를 볼 수 있으며 디버깅이 쉬움. |
| React‑Hook‑Form (uncontrolled) | 입력이 ref에 의해 관리되고, DOM 업데이트가 React 재렌더링 없이 이루어짐. 매우 크거나 인터랙티브한 폼에서 더 빠름. |
| 기능 | Uncontrolled | Controlled |
|---|---|---|
| 재렌더링 전략 | ✅ | ❌ |
| DevTools | ✅ | ❌ |
| 커뮤니티 규모 | Large | Small |
† 사이즈는 bundlephobia(gzip) 기준.
†† @railway-ts 사이즈는 size‑limit 기준; 약 7.8 kB brotli.
프로젝트에 이미 Zod가 포함되어 있다면 RHF + resolver의 추가 비용은 약 22.5 kB 정도입니다. @railway-ts 전체 용량에는 폼 훅 및 전체 파이프라인/검증 라이브러리가 포함되어 있는데, 백엔드에서 이미 사용하고 있다면 폼 훅 자체는 gzip 기준으로 약 4.8 kB만 추가됩니다.
Source:
데모 및 리소스
- StackBlitz 데모 – 스키마 검증, 교차 필드 규칙, 비동기 사용자 이름 확인, 서버 오류, 체크박스 그룹 및 로딩 상태가 포함된 완전한 회원가입 양식.
- GitHub
@railway-ts/pipelines– 스키마,Result타입, 파이프라인.@railway-ts/use-form– React 폼 훅.
보너스 – 데이터 처리 파이프라인
같은 파이프라인 라이브러리를 React 외부에서도 ETL‑스타일 배치 처리에 사용할 수 있습니다: combine, combineAll, partition, 재사용 가능한 서브‑파이프라인, 그리고 구조화된 오류 보고. UI 없이 순수 데이터 변환만 수행합니다.