스키마‑우선 React 폼: 하나의 스키마, 세 개의 오류 레이어, 제로 글루

발행: (2026년 2월 20일 오전 10:45 GMT+9)
11 분 소요
원문: Dev.to

Source: Dev.to

위 링크에 있는 글의 전체 내용을 제공해 주시면, 해당 텍스트를 한국어로 번역해 드리겠습니다. (코드 블록, URL, 마크다운 형식 등은 그대로 유지됩니다.)

파트 3 – Railway‑Oriented TypeScript

Part 1fieldValidatorssetServerErrors가 글루 코드를 없애는 방법을 보여줍니다. 이번 파트에서는 폼 훅이 실제로 세 가지 오류 소스를 어떻게 다루는지, 그리고 단일 스키마가 백엔드 파이프라인 프론트엔드 폼을 어떻게 구동하는지 깊이 살펴봅니다.

오류의 세 가지 소스

우선순위소스설정 방법해제 시점
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 재렌더링 없이 이루어짐. 매우 크거나 인터랙티브한 폼에서 더 빠름.
기능UncontrolledControlled
재렌더링 전략
DevTools
커뮤니티 규모LargeSmall

† 사이즈는 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 없이 순수 데이터 변환만 수행합니다.

0 조회
Back to Blog

관련 글

더 보기 »