Valibot 완벽 가이드 — 경량 스키마 검증

Valibot 완벽 가이드 — 경량 스키마 검증

이 글의 핵심

이 글은 Valibot으로 런타임 검증과 TypeScript 타입을 한 번에 다루는 방법을 설명합니다. 핵심 개념과 스키마·파이프라인, InferOutput·InferInput, 커스텀 검증·에러 커스터마이징, Zod와의 비교, 번들 최적화, React Hook Form 연동까지 실무 관점으로 연결합니다.

이 글의 핵심

Valibot은 의존성 없이 동작하는 경량 스키마 검증 라이브러리입니다. TypeScript의 정적 타입만으로는 부족한 외부 입력(HTTP 본문, 폼, 환경 변수, 설정 파일)을 런타임에 검사하고, 그 결과 타입을 한 스키마에서 추론할 수 있다는 점에서 Zod·ArkType 등과 같은 카테고리에 속합니다. Valibot의 특징은 작은 단위 함수로 구성된 모듈형 API와, 이를 통한 번들 크기·트리 쉐이킹 이점에 있습니다.

이 글에서 다루는 내용은 다음과 같습니다.

  • 핵심 개념: 스키마(schema), 액션(action), 파이프라인(pipe)
  • 스키마 정의와 타입 추론: InferOutput, InferInput로 입·출력 타입 분리 이해
  • 커스텀 검증: custom, check, 객체 단위 제약
  • 에러 메시지: 파이프라인 단계별 메시지, 전역 설정·국제화(i18n) 개요
  • Zod vs Valibot: API 차이, 마이그레이션 시 주의점
  • 번들 사이즈 최적화: import 전략과 실측 관점
  • 실전 폼 검증: React Hook Form + valibotResolver 패턴

아래 예제는 Valibot 1.x 계열 API를 기준으로 합니다. 프로젝트에 설치된 버전의 공식 문서와 타입 정의를 함께 확인하시기 바랍니다.


1. Valibot을 쓰는 이유

웹 프론트엔드와 풀스택 환경에서 런타임 검증이 필요한 이유는 단순합니다. JSON.parse 결과나 fetch 응답, 폼 필드 값은 컴파일 타임에 보장되지 않습니다. TypeScript는 개발 중에는 타입을 강하게 잡아 주지만, 빌드 결과물에는 타입 정보가 남지 않으므로 경계(boundary)에서 한 번 데이터를 «스키마에 맞게» 거르는 작업이 필요합니다.

Valibot은 이런 경계에서 다음을 목표로 합니다.

  1. 타입 안전성: 스키마로부터 출력 타입을 추론해, 검증 이후 코드에서 불필요한 단언(assertion)을 줄입니다.
  2. 작은 런타임: 기능을 잘게 쪼개 두어, 번들러가 사용하지 않는 코드를 제거하기 쉽게 설계되었습니다.
  3. 명시적 파이프라인: 문자열에 .email()처럼 메서드를 연속 체인하기보다, pipe(기본 스키마, …액션들)으로 «무엇을 어떤 순서로 검사하는지»를 드러냅니다.

공식 소개에 따르면, 최소 번들은 700바이트 미만에서 시작한다고 안내되어 있습니다(실제 수치는 스키마·번들러·측정 도구에 따라 달라집니다). 중요한 것은 절대값보다, 프로젝트에서 실제로 어떤 import 그래프가 생성되는지rollup-plugin-visualizer 등으로 확인하는 것입니다.


2. 핵심 개념: 스키마, 액션, 파이프라인

2.1 스키마(Schema)

스키마는 «이 값이 어떤 형태인가»를 기술하는 실행 가능한 설명입니다. string(), number(), object({ … }) 등은 스키마 함수이고, 이들을 조합해 더 큰 스키마를 만듭니다.

객체의 경우 Zod의 z.object()와 대응되는 object()가 있으며, 알 수 없는 키를 허용할지·막을지는 Zod의 strict / passthrough와 같이 별도의 스키마 팩토리로 표현하는 경우가 있습니다. 예를 들어 알려진 키만 허용하려면 strictObject()를 쓰는 식으로, 공식 Objects 가이드와 타입 정의를 맞추는 것이 안전합니다.

2.2 액션(Action)

액션은 스키마를 통과한 값에 대해 추가 검증 또는 변환을 수행하는 단계입니다. 예를 들어 email()은 문자열이 이메일 형식인지 검사하고, minLength(8)은 최소 길이를 검사합니다. 변환이 필요하면 transform()으로 파이프라인 안에서 값을 바꿀 수 있습니다.

2.3 파이프라인(pipe)

Valibot에서 타입에 «덧붙는」 제약은 대개 파이프라인으로 표현합니다.

import * as v from 'valibot';

// 문자열 → 이메일 형식 → (필요 시) 추가 조건
const EmailSchema = v.pipe(v.string(), v.email());

// 비밀번호: 문자열 + 최소 길이 + (예시) 대문자 포함
const PasswordSchema = v.pipe(
  v.string(),
  v.minLength(8, '8자 이상 입력해 주세요.'),
  v.check((input) => /[A-Z]/.test(input), '영문 대문자를 하나 이상 포함해 주세요.')
);

첫 번째 인자는 기본 스키마(여기서는 string()), 이후는 같은 타입을 이어받는 액션들입니다. Zod의 z.string().email()과 사고 모델이 다르므로, 팀 내 코딩 컨벤션에 «파이프라인만 사용한다»를 명시해 두면 리뷰 비용이 줄어듭니다.


3. 데이터 파싱: parse, safeParse, is

Zod는 스키마에 .parse()를 붙이지만, Valibot은 함수의 첫 인자로 스키마를 넘깁니다.

import * as v from 'valibot';

const LoginSchema = v.object({
  email: v.pipe(v.string(), v.email()),
  password: v.pipe(v.string(), v.minLength(8)),
});

// 성공 시 출력 타입의 값 반환, 실패 시 ValiError throw
const data = v.parse(LoginSchema, unknownInput);

// 예외 없이 결과 객체로 분기
const result = v.safeParse(LoginSchema, unknownInput);
if (result.success) {
  const { email, password } = result.output;
} else {
  console.error(result.issues);
}
  • parse: 실패 시 예외를 던집니다. 서버 핸들러에서 한 번에 400 응답으로 매핑할 때 자주 씁니다.
  • safeParse: 성공·실패를 객체로 받습니다. UI에서 필드별 오류를 만들 때 유리합니다.
  • is: 타입 가드로 쓰는 API가 별도로 제공됩니다(가이드: Parse data).

예외를 잡을 때는 ValiErrorissues 배열을 활용합니다. 각 이슈에는 경로·메시지·기대 타입 등 메타데이터가 붙으며, 폼 라이브러리 resolver가 이를 필드 경로로 매핑합니다.


4. 스키마 정의와 타입 추론

4.1 InferOutputInferInput

변환(transform)이 없으면 입·출력 타입이 같지만, 문자열을 숫자로 바꾸는 등 파이프라인 끝의 타입이 바뀌면 두 타입을 구분하는 것이 중요합니다.

import * as v from 'valibot';

const QuantitySchema = v.pipe(
  v.string(),
  v.transform((s) => Number.parseInt(s, 10)),
  v.check((n) => Number.isInteger(n) && n >= 1, '1 이상의 정수여야 합니다.')
);

// 출력: number
type QuantityOut = v.InferOutput<typeof QuantitySchema>;

// 입력: 파이프 시작점 기준 — 여기서는 string
type QuantityIn = v.InferInput<typeof QuantitySchema>;

API 응답·쿼리 스트링처럼 «처음에는 문자열로 들어오나, 도메인에서는 숫자로 쓰고 싶다»는 경우에 InferInput / InferOutput을 나눠 두면, 폼 상태 타입과 제출 직후 타입을 혼동하지 않습니다.

4.2 객체·배열·유니온

import * as v from 'valibot';

const RoleSchema = v.picklist(['admin', 'user', 'guest']);

const UserSchema = v.object({
  id: v.pipe(v.string(), v.uuid()),
  name: v.pipe(v.string(), v.minLength(1)),
  role: RoleSchema,
  tags: v.array(v.string()),
});

type User = v.InferOutput<typeof UserSchema>;

picklist는 문자열 리터럴 유니온에 대응하는 흔한 패턴입니다. 더 복잡한 분기는 union, variant(태그 기반 객체 분기) 등 객체 가이드의 패턴을 참고합니다.

4.3 선택·널 허용

필드가 없을 수 있거나 null일 수 있는 경우, Zod의 optional / nullable에 대응하는 조합을 스키마 단에서 명시합니다. 정확한 이름과 시그니처는 버전별로 확인하되, «입력 JSON에 키가 없음»과 «키는 있는데 null»을 도메인 규칙에 맞게 나누는 설계가 필요합니다.


5. 커스텀 검증

5.1 check: 파이프라인에서의 자유 검증

check(requirement, message)는 입력이 조건을 만족하면 true, 아니면 false를 반환합니다. 실패 시 두 번째 인자의 메시지가 이슈로 붙습니다.

import * as v from 'valibot';

const EvenLengthString = v.pipe(
  v.string(),
  v.check((s) => s.length % 2 === 0, '문자 길이는 짝수여야 합니다.')
);

객체 여러 필드의 관계를 검증할 때는, object({ … })로 만든 뒤 객체 전체에 check를 걸 수 있습니다. 공식 예시처럼 list.length와 별도의 length 필드가 일치하는지 검사하는 패턴이 대표적입니다.

import * as v from 'valibot';

const ListWithCountSchema = v.pipe(
  v.object({
    list: v.array(v.string()),
    length: v.number(),
  }),
  v.check(
    (input) => input.list.length === input.length,
    '목록 길이와 length 필드가 일치하지 않습니다.'
  )
);

5.2 custom: 타입 자체를 함수로 정의

다른 스키마 함수로 표현하기 어려운 도메인 특화 형식(예: 123px 같은 단위 문자열)은 custom으로 검사 함수와 메시지를 묶을 수 있습니다.

import * as v from 'valibot';

const PixelStringSchema = v.custom<`${number}px`>(
  (input) => typeof input === 'string' && /^\d+px$/.test(input),
  '`<숫자>px` 형식이어야 합니다.'
);

custom스키마에 가깝고, check이미 정해진 스키마 위에 규칙을 한 겹 씌우는 액션에 가깝습니다. 팀에서는 «기본 타입은 string/object 등으로 두고 도메인 규칙은 check로»와 같이 규칙을 정하면 재사용성과 테스트 용이성이 좋아집니다.

5.3 비동기 검증

이메일 중복 확인처럼 서버 왕복이 필요한 경우 비동기 파싱 API(parseAsync, safeParseAsync 등)를 사용합니다. 동기·비동기 혼용 시 UI에서는 로딩 상태와 취소(요청 레이스) 처리를 함께 설계해야 합니다. 자세한 패턴은 공식 Async validation 가이드를 따르는 것이 좋습니다.


6. 에러 메시지 커스터마이징

6.1 액션에 직접 메시지 넘기기

많은 빌트인 액션은 두 번째 인자로 메시지를 받습니다. Zod에서 흔히 보는 invalid_type_errormessage 객체를 나누는 방식과 달리, Valibot은 파이프라인 각 단계에 문자열 하나를 붙이는 형태가 일반적입니다.

import * as v from 'valibot';

const NameSchema = v.pipe(
  v.string('이름은 문자열이어야 합니다.'),
  v.minLength(1, '이름을 입력해 주세요.'),
  v.maxLength(50, '이름은 50자 이하로 입력해 주세요.')
);

6.2 전역 메시지·i18n

프로젝트 전체에서 동일한 톤으로 메시지를 쓰려면 전역 설정번역 모듈을 제공합니다. PR 및 문서에 따르면 setGlobalMessage, setLocalMessage, setConfiglang 옵션, valibot/i18n/... 형태의 서브패스 import가 등장합니다. 다국어 서비스라면 언어 전환 시점에 설정을 갱신하고, SSR이라면 요청 로케일별로 설정이 격리되는지 확인해야 합니다.

운영 관점에서는 다음을 권장합니다.

  • 사용자 노출 문구개발자용 디버그 정보를 혼동하지 않기
  • 필드 경로(nested object)와 메시지 코드를 매핑해 클라이언트에서 치환 가능하게 설계하기

7. Zod vs Valibot

두 라이브러리 모두 «스키마 + 타입 추론»이라는 큰 그림은 같습니다. 차이는 API 표면번들 설계 철학에 있습니다.

구분ZodValibot
API 스타일z.string().email() 같은 메서드 체인v.pipe(v.string(), v.email()) 같은 파이프라인
파싱 호출schema.parse(data)v.parse(schema, data)
강제 변환z.coerce.number()pipe + transform 등으로 입력 스키마를 명시
객체 엄격도.strict() 등 메서드strictObject별도 스키마 (가이드 참고)
생태계매우 넓음 (tRPC, RHF 등)성장 중, 공식 마이그레이션·codemod 존재

Zod에서 옮길 때 유용한 자료는 다음과 같습니다.

  • Migrate from Zod — 체인 → 파이프, parse 인자 순서, 에러 메시지, coerce 대체
  • @valibot/zod-to-valibot codemod — 대량 변환 시 드라이런 후 적용

팀이 이미 Zod에 깊게 묶여 있고 문제가 없다면 무리해 바꿀 필요는 없습니다. 번들 예산이 빡박한 브라우저 번들, 라이브러리 자체를 배포해 의존성 크기를 줄여야 하는 경우 등에 Valibot 후보를 두면 설계 논의가 수월합니다.


8. 번들 사이즈 최적화

8.1 Named import와 트리 쉐이킹

Valibot은 기능별로 파일이 잘게 나뉘어 있어, 사용한 함수만 최종 번들에 남기기 쉽습니다.

// 권장: 필요한 것만
import { parse, object, string, pipe, email, minLength } from 'valibot';

export const LoginSchema = object({
  email: pipe(string(), email()),
  password: pipe(string(), minLength(8)),
});

네임스페이스 import(import * as v from 'valibot')는 예제 가독성에 좋지만, 팀 규칙으로 프로덕션 코드는 named import로 통일하는 선택도 흔합니다. 어느 쪽이든 실제 빌드 산출물을 한 번 측정하는 것이 확실합니다.

8.2 중복 래핑 줄이기

같은 pipe(string(), email()) 패턴이 반복되면 팩토리 함수로 묶어 스키마 정의를 한곳에 모읍니다. 스키마 파일이 흩어지면 동일 검증이 복붙되며 번들에 같은 액션이 중복 포함될 수 있습니다(미세하지만 대규모 코드베이스에서는 누적됩니다).

8.3 측정 습관

  • 번들 시각화: 어떤 valibot 모듈이 끌려왔는지 확인
  • 경계 최소화: 앱 전체가 아니라 API 라우트·폼 단위에서만 무거운 스키마를 쓰기

9. 실전 폼 검증: React Hook Form

브라우저 폼은 필드가 많고, 조건부 노출·중첩 객체가 흔합니다. React Hook Form과 함께 쓸 때는 @hookform/resolversValibot resolver로 스키마를 연결합니다.

npm install valibot react-hook-form @hookform/resolvers
import { useForm } from 'react-hook-form';
import { valibotResolver } from '@hookform/resolvers/valibot';
import * as v from 'valibot';

const SignUpSchema = v.object({
  email: v.pipe(v.string(), v.email('올바른 이메일을 입력해 주세요.')),
  password: v.pipe(
    v.string(),
    v.minLength(8, '8자 이상 입력해 주세요.'),
    v.check((s) => /[0-9]/.test(s), '숫자를 포함해 주세요.')
  ),
  agree: v.pipe(
    v.boolean(),
    v.check((b) => b === true, '약관에 동의해야 합니다.')
  ),
});

type SignUpValues = v.InferOutput<typeof SignUpSchema>;

export function SignUpForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignUpValues>({
    resolver: valibotResolver(SignUpSchema),
    mode: 'onBlur',
    defaultValues: {
      email: '',
      password: '',
      agree: false,
    },
  });

  const onSubmit = handleSubmit((data) => {
    console.log('검증 통과', data);
  });

  return (
    <form onSubmit={onSubmit} noValidate>
      <div>
        <label htmlFor="email">이메일</label>
        <input id="email" type="email" autoComplete="email" {...register('email')} />
        {errors.email && <p role="alert">{errors.email.message}</p>}
      </div>
      <div>
        <label htmlFor="password">비밀번호</label>
        <input id="password" type="password" autoComplete="new-password" {...register('password')} />
        {errors.password && <p role="alert">{errors.password.message}</p>}
      </div>
      <div>
        <label>
          <input type="checkbox" {...register('agree')} /> 약관 동의
        </label>
        {errors.agree && <p role="alert">{errors.agree.message}</p>}
      </div>
      <button type="submit">가입</button>
    </form>
  );
}

9.1 설계 팁

  • mode: onChange는 입력마다 검증되어 비용이 듭니다. UX 요구에 맞게 onBlur 또는 onSubmit과 조합합니다.
  • 스키마와 필드 일치: resolver는 스키마에 정의된 필드 중심으로 동작합니다. 숨겨진 필드·동적 필드는 스키마를 분기(union/variant)하거나 폼을 나누는 편이 디버깅에 유리합니다.
  • 서버 재검증: 클라이언트 검증은 편의이며, 서버에서는 동일·상위 규칙으로 반드시 다시 검증해야 합니다.

10. 트러블슈팅 요약

  • parse가 예외를 던진다: 입력이 스키마와 맞지 않을 때입니다. UI에서는 safeParse로 바꾸거나 try/catchValiError를 처리합니다.
  • 타입이 기대와 다르다: transform 이후 출력 타입이 바뀌었는지 InferOutput로 확인합니다. 폼의 defaultValuesInferInput에 맞춥니다.
  • Zod 습관으로 메서드 체인: Valibot은 스키마 첫 인자 + pipe 패턴이 기본입니다. 마이그레이션 가이드의 표를 팀 문서에 붙여 두면 실수가 줄어듭니다.

11. 정리

Valibot은 모듈형 API명시적 파이프라인으로 런타임 검증을 구성하는 라이브러리입니다. InferOutput / InferInput로 경계를 넘나드는 데이터의 타입을 정확히 잡고, check·custom으로 도메인 규칙을 표현하며, 메시지와 i18n 설정으로 사용자 경험을 다듬을 수 있습니다. Zod 대비 번들 이점을 노릴 때는 import 방식과 실측을 함께 고려하고, React Hook Form과는 valibotResolver로 자연스럽게 연결할 수 있습니다.

다음 단계로는 프로젝트 의존성 버전에 맞춰 공식 가이드의 Objects·Async·Error 메뉴를 읽으며, 실제 API 한두 개를 유닛 테스트로 고정해 두는 것을 권장합니다.