스키마로 검색 매개변수 검증
Zod, Valibot, ArkType와 같은 인기 검증 라이브러리를 사용해 검색 매개변수에 견고한 스키마 검증을 추가하는 방법을 알아봅니다. 이 가이드에서는 프로덕션 애플리케이션을 위한 검증 설정, 오류 처리, 타입 안전성, 일반적인 검증 패턴을 다룹니다.
사전 요구 사항: 기본 검색 매개변수 설정 - 검색 매개변수를 읽고 사용하는 기본 개념
빠른 시작
사용자 지정 오류 메시지, 복잡한 타입, 프로덕션에 적합한 오류 처리를 사용해 견고한 검증을 추가합니다.
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { z } from 'zod'
const productSearchSchema = z.object({
query: z.string().min(1, 'Search query required'),
category: z.enum(['electronics', 'clothing', 'books', 'home']).optional(),
minPrice: z.number().min(0, 'Price cannot be negative').default(0),
maxPrice: z.number().min(0, 'Price cannot be negative').default(1000),
inStock: z.boolean().default(true),
tags: z.array(z.string()).optional(),
dateRange: z
.object({
start: z.string().datetime().optional(),
end: z.string().datetime().optional(),
})
.optional(),
})
export const Route = createFileRoute('/products')({
validateSearch: productSearchSchema,
errorComponent: ({ error }) => {
const router = useRouter()
return (
<div className="error">
<h2>Invalid Search Parameters</h2>
<p>{error.message}</p>
<button
onClick={() => router.navigate({ to: '/products', search: {} })}
>
Reset Search
</button>
</div>
)
},
component: ProductsPage,
})
function ProductsPage() {
// All search params are validated, type-safe, and have fallback values applied
const { query, category, minPrice, maxPrice, inStock, tags, dateRange } =
Route.useSearch()
return (
<div>
<h1>Products</h1>
<p>Search: {query}</p>
<p>Category: {category || 'All'}</p>
<p>
Price Range: ${minPrice} - ${maxPrice}
</p>
<p>In Stock Only: {inStock ? 'Yes' : 'No'}</p>
{tags && <p>Tags: {tags.join(', ')}</p>}
{dateRange && (
<p>
Date Range: {dateRange.start} to {dateRange.end}
</p>
)}
</div>
)
}
검증 라이브러리 옵션
TanStack Router는 어댑터를 통해 여러 검증 라이브러리를 지원합니다.
Zod (권장)
TypeScript 통합이 뛰어나고 가장 널리 사용됩니다. Zod v3에서는 검증에 @tanstack/zod-adapter를 사용합니다.
import { zodValidator, fallback } from '@tanstack/zod-adapter'
import { z } from 'zod'
const searchSchema = z.object({
query: z.string().min(1).max(100),
page: fallback(z.number().int().positive(), 1),
sortBy: z.enum(['name', 'date', 'relevance']).optional(),
filters: z.array(z.string()).optional(),
})
export const Route = createFileRoute('/search')({
validateSearch: zodValidator(searchSchema),
component: SearchPage,
})
Zod v4에서는 더 이상 어댑터가 필요하지 않습니다.
import { z } from 'zod'
const searchSchema = z.object({
query: z.string().min(1).max(100),
page: z.number().int().positive().default(1),
sortBy: z.enum(['name', 'date', 'relevance']).optional(),
filters: z.array(z.string()).optional(),
})
export const Route = createFileRoute('/search')({
validateSearch: searchSchema,
component: SearchPage,
})
Valibot
모듈식 설계를 제공하는 경량 대안입니다.
import { valibotValidator } from '@tanstack/valibot-adapter'
import * as v from 'valibot'
const searchSchema = v.object({
query: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
page: v.fallback(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
sortBy: v.optional(v.picklist(['name', 'date', 'relevance'])),
filters: v.optional(v.array(v.string())),
})
export const Route = createFileRoute('/search')({
validateSearch: valibotValidator(searchSchema),
component: SearchPage,
})
ArkType
TypeScript를 우선하며 런타임 검증을 지원합니다.
import { type } from 'arktype'
const searchSchema = type({
query: 'string>0&<=100',
page: 'number>0 = 1',
'sortBy?': "'name'|'date'|'relevance'",
'filters?': 'string[]',
})
export const Route = createFileRoute('/search')({
validateSearch: searchSchema,
component: SearchPage,
})
사용자 지정 검증 함수
완전히 제어하려면 직접 검증 로직을 구현합니다.
export const Route = createFileRoute('/search')({
validateSearch: (search: Record<string, unknown>) => {
// Custom validation with detailed error handling
const result = {
page: 1,
query: '',
category: 'all',
}
// Validate page number
const pageNum = Number(search.page)
if (isNaN(pageNum) || pageNum < 1) {
throw new Error('Page must be a positive number')
}
result.page = pageNum
// Validate query string
if (typeof search.query === 'string' && search.query.length > 0) {
if (search.query.length > 100) {
throw new Error('Search query too long (max 100 characters)')
}
result.query = search.query
}
// Validate category
const validCategories = ['electronics', 'clothing', 'books', 'all']
if (
typeof search.category === 'string' &&
validCategories.includes(search.category)
) {
result.category = search.category
}
return result
},
component: SearchPage,
})
일반적인 검증 패턴
필수 매개변수와 선택적 매개변수
어떤 검색 매개변수를 필수로 지정할지 제어합니다.
const validationSchema = z.object({
// Required - will throw validation error if missing or invalid
userId: z.number().int().positive(),
action: z.enum(['view', 'edit', 'delete']),
// Optional - can be undefined
sortBy: z.string().optional(),
// Optional with fallback - provides default if missing/invalid
page: fallback(z.number().int().positive(), 1),
limit: fallback(z.number().int().min(1).max(100), 20),
})
복잡한 데이터 타입
배열, 객체, 사용자 지정 타입을 처리합니다.
const advancedSchema = z.object({
// Array of strings
tags: z.array(z.string()).optional(),
// Array of numbers
categoryIds: z.array(z.number().int()).optional(),
// Date validation
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
// Custom validation
email: z.string().email().optional(),
// Refined validation with custom logic
priceRange: z
.object({
min: z.number().min(0),
max: z.number().min(0),
})
.refine((data) => data.max >= data.min, {
message: 'Max price must be greater than or equal to min price',
})
.optional(),
})
입력 변환
검증 중 입력값을 변환하고 정제합니다.
const transformSchema = z.object({
// Transform string to number
page: z
.string()
.transform((val) => parseInt(val, 10))
.pipe(z.number().int().positive()),
// Transform and validate email
email: z.string().toLowerCase().trim().pipe(z.string().email()).optional(),
// Transform comma-separated string to array
tags: z
.string()
.transform((val) => (val ? val.split(',').map((tag) => tag.trim()) : []))
.pipe(z.array(z.string().min(1)))
.optional(),
})
오류 처리 전략
기본 오류 처리
라우트 오류 컴포넌트를 통해 검증 오류를 처리합니다.
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { zodValidator } from '@tanstack/zod-adapter'
import { z } from 'zod'
const searchSchema = z.object({
query: z.string().min(1, 'Search query is required'),
page: z.number().int().positive('Page must be a positive number'),
})
export const Route = createFileRoute('/search')({
validateSearch: zodValidator(searchSchema),
errorComponent: ({ error }) => {
const router = useRouter()
return (
<div className="error">
<h2>Invalid Search Parameters</h2>
<p>{error.message}</p>
<button onClick={() => router.navigate({ to: '/search', search: {} })}>
Reset Search
</button>
<button
onClick={() =>
router.navigate({ to: '/search', search: { query: '', page: 1 } })
}
>
Start Over
</button>
</div>
)
},
component: SearchPage,
})
function SearchPage() {
// Only called when validation succeeds
const search = Route.useSearch()
// ... rest of component
}
사용자 지정 오류 메시지
사용자가 이해하기 쉬운 검증 메시지를 제공합니다.
const userFriendlySchema = z.object({
query: z
.string()
.min(2, 'Search query must be at least 2 characters')
.max(100, 'Search query cannot exceed 100 characters'),
page: fallback(
z
.number()
.int('Page must be a whole number')
.positive('Page must be greater than 0'),
1,
),
category: z
.enum(['electronics', 'clothing', 'books'], {
errorMap: () => ({ message: 'Please select a valid category' }),
})
.optional(),
})
검증 오류 복구
유효하지 않은 매개변수에 대한 대체 동작을 구현합니다.
const resilientSchema = z.object({
// Use .catch() to provide fallback values on validation failure
page: z.number().int().positive().catch(1),
// Use .default() for missing values, .catch() for invalid values
sortBy: z
.enum(['name', 'date', 'relevance'])
.default('relevance')
.catch('relevance'),
// Custom recovery logic
dateRange: z
.object({
start: z.string().datetime(),
end: z.string().datetime(),
})
.catch({
start: new Date().toISOString(),
end: new Date().toISOString(),
})
.optional(),
})
고급 검증 기법
조건부 검증
다른 매개변수에 따라 서로 다른 검증 규칙을 적용합니다.
const conditionalSchema = z
.object({
searchType: z.enum(['basic', 'advanced']),
query: z.string().min(1),
})
.and(
z.discriminatedUnion('searchType', [
z.object({
searchType: z.literal('basic'),
// Basic search requires only query
}),
z.object({
searchType: z.literal('advanced'),
// Advanced search requires additional fields
category: z.string().min(1),
minPrice: z.number().min(0),
maxPrice: z.number().min(0),
}),
]),
)
스키마 조합
재사용할 수 있도록 스키마를 결합하고 확장합니다.
// Base pagination schema
const paginationSchema = z.object({
page: fallback(z.number().int().positive(), 1),
limit: fallback(z.number().int().min(1).max(100), 20),
})
// Base filter schema
const filterSchema = z.object({
sortBy: z.enum(['name', 'date', 'relevance']).optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
})
// Compose schemas for different routes
const productSearchSchema = paginationSchema.extend({
category: z.string().optional(),
inStock: fallback(z.boolean(), true),
})
const userSearchSchema = paginationSchema.merge(filterSchema).extend({
role: z.enum(['admin', 'user', 'moderator']).optional(),
isActive: fallback(z.boolean(), true),
})
성능 최적화
더 나은 성능을 위해 검증을 최적화합니다.
// Pre-compile schemas for better performance
const compiledSchema = zodValidator(
z.object({
query: z.string().min(1),
page: fallback(z.number().int().positive(), 1),
}),
)
export const Route = createFileRoute('/search')({
validateSearch: compiledSchema,
component: SearchPage,
})
// Use selective validation for expensive operations
function SearchPage() {
// Only validate specific fields when needed
const search = Route.useSearch({
select: (search) => ({
query: search.query,
page: search.page,
}),
})
return <div>Search Results</div>
}
검색 매개변수 검증 테스트
스키마에 해당하는 검증 동작을 중점적으로 테스트합니다.
import { render, screen, waitFor } from '@testing-library/react'
import {
createRouter,
createMemoryHistory,
RouterProvider,
} from '@tanstack/react-router'
describe('Search Validation Behavior', () => {
it('should show error component when validation fails', async () => {
const router = createRouter({
routeTree,
history: createMemoryHistory({
initialEntries: ['/search?page=invalid&query='],
}),
})
render(<RouterProvider router={router} />)
await waitFor(() => {
expect(screen.getByText('Invalid Search Parameters')).toBeInTheDocument()
})
})
it('should apply fallback values correctly', async () => {
const router = createRouter({
routeTree,
history: createMemoryHistory({
initialEntries: ['/search?query=laptops'], // page missing
}),
})
render(<RouterProvider router={router} />)
await waitFor(() => {
expect(screen.getByText('Page: 1')).toBeInTheDocument() // Fallback applied
})
})
})
라우트 테스트 패턴 전체는 다음을 참고하세요: 테스트 설정 및 파일 기반 라우팅 테스트
일반적인 문제
문제: 검증 오류로 전체 라우트가 중단됨
증상: URL에 유효하지 않은 검색 매개변수가 있으면 페이지를 로드하지 못합니다.
해결 방법: 대체값과 오류 경계를 사용합니다.
// ❌ Wrong - will throw error and break route
const strictSchema = z.object({
page: z.number().int().positive(), // No fallback
})
// ✅ Correct - provides fallback for invalid values
const resilientSchema = z.object({
page: fallback(z.number().int().positive(), 1),
})
// ✅ Alternative - use errorComponent on route
export const Route = createFileRoute('/search')({
validateSearch: resilientSchema,
errorComponent: ({ error }) => <SearchError error={error} />,
component: SearchPage,
})
function SearchPage() {
// Only called when validation succeeds
const search = Route.useSearch()
return <SearchResults search={search} />
}
문제: 선택적 검색 매개변수에서 TypeScript 오류 발생
증상: TypeScript가 값이 undefined일 수 있다고 보고합니다.
해결 방법: 적절한 선택적 값 처리 또는 대체값을 사용합니다.
// ❌ Wrong - category might be undefined
function FilterBar() {
const { category } = Route.useSearch()
return <span>{category.toUpperCase()}</span> // TypeScript error
}
// ✅ Correct - handle optional values
function FilterBar() {
const { category } = Route.useSearch()
return <span>{category?.toUpperCase() || 'All Categories'}</span>
}
// ✅ Better - use fallback in schema
const schema = z.object({
category: fallback(z.string(), 'all'),
})
문제: 검색 매개변수 배열이 올바르게 파싱되지 않음
증상: 배열 값이 배열이 아닌 문자열로 나타납니다.
해결 방법: 스키마에서 배열을 올바르게 파싱하는지 확인합니다.
// ❌ Wrong - doesn't handle URL array format
const badSchema = z.object({
tags: z.array(z.string()).optional(),
})
// ✅ Correct - parse comma-separated values or multiple params
const goodSchema = z.object({
tags: z
.union([
z.array(z.string()), // Multiple ?tags=a&tags=b
z.string().transform((val) => val.split(',')), // Single ?tags=a,b,c
])
.optional(),
})
// ✅ Alternative - custom preprocessing
const preprocessedSchema = z.preprocess((val) => {
if (typeof val === 'string') return val.split(',')
return val
}, z.array(z.string()).optional())
문제: 스키마 검증이 너무 느림
증상: 복잡한 검색 매개변수로 탐색할 때 눈에 띄는 지연이 발생합니다.
해결 방법: 스키마 복잡도를 최적화하고 선택적 파싱을 사용합니다.
// ❌ Slow - complex validation on every navigation
const complexSchema = z.object({
query: z.string().refine(async (val) => await validateQuery(val)),
// ... many complex validations
})
// ✅ Fast - simplified validation with lazy refinement
const optimizedSchema = z.object({
query: z.string().min(1), // Basic validation only
// ... other simple validations
})
// Perform complex validation separately in component
function SearchPage() {
const search = Route.useSearch()
// Complex validation only when needed
const [complexValidation, setComplexValidation] = useState(null)
useEffect(() => {
validateComplexRules(search).then(setComplexValidation)
}, [search])
return <SearchResults search={search} validation={complexValidation} />
}
일반적인 다음 단계
스키마 검증을 설정한 후 다음 작업을 고려할 수 있습니다.
- 배열, 객체, 날짜 다루기 - 배열, 객체, 날짜 및 중첩 데이터 구조 처리
관련 리소스
- TanStack Zod Adapter 문서
- TanStack Valibot Adapter 문서
- Zod 문서 - 스키마 검증 라이브러리
- Valibot 문서 - 경량 검증 라이브러리
- ArkType 문서 - TypeScript 우선 검증