36. Zod 사용 이유
2026년 1월 15일
도입이유
Zod 도입 가이드 도입 이유 1. 런타임 안정성 확보 현재 프로젝트는 TypeScript 타입 정의만으로 API 응답을 검증하고 있어, 컴파일 타임에는 타입 안정성을 보장하지만 런타임에서는 데이터 무결성을 보장할 수 없습니다. 현재 문제점 - 서버가 예상과 다른 형식의 응답을 반환해도 런타임에서만 발견 가능 - 필드가 `null`이거나 `undefined`인 경우, 타입은 통과하지만 실제 사용 시 에러 발생 - API 스펙 변경 시 프론트엔드에서 즉시 감지 어려움 Zod 도입 후 개선 - API 응답을 실제로 검증하여 서버 응답 형식 문제를 즉시 발견 - 예상치 못한 데이터 형식으로 인한 런타임 에러 사전 방지 - 개발 단계에서 API 스펙 불일치를 빠르게 감지 --- 2. 폼 검증 로직의 재사용성 및 유지보수성 향상 현재 폼 검증 로직이 컴포넌트 내부에 하드코딩되어 있어, 검증 규칙 변경 시 여러 곳을 수정해야 하고 코드 중복이 발생합니다 현재 문제점 - `NameInput` 컴포넌트에 검증 로직이 직접 구현되어 있음 - 닉네임 검증 규칙 변경 시 컴포넌트 코드 수정 필요 - 다른 곳에서 동일한 검증이 필요할 때 코드 복사/붙여넣기 발생 - 에러 메시지가 하드코딩되어 있어 일관성 유지 어려움 Zod 도입 후 개선 - 검증 로직을 스키마로 분리하여 재사용 가능 - 검증 규칙 변경 시 스키마만 수정하면 모든 사용처에 반영 - 에러 메시지를 스키마에서 중앙 관리하여 일관성 확보 - 컴포넌트는 검증 로직과 분리되어 단순화 --- 3. 개발 생산성 향상 Zod를 사용하면 타입 정의와 검증 로직을 한 곳에서 관리할 수 있어 개발 효율이 크게 향상됩니다. 현재 문제점 - 타입 정의(`types.ts`)와 검증 로직이 분리되어 있음 - API 스펙 변경 시 타입과 검증 로직을 각각 수정해야 함 - 검증 실패 시 어떤 필드가 문제인지 파악하기 어려움 Zod 도입 후 개선 - 스키마 정의 시 자동으로 TypeScript 타입 추론 (`z.infer`) - 타입과 검증 로직을 한 곳에서 관리하여 일관성 유지 - 검증 실패 시 구체적인 에러 메시지로 디버깅 용이 - 스키마 기반으로 자동 완성 및 타입 체크 지원 --- 4. 사용자 경험 개선 클라이언트 측에서 데이터를 검증함으로써 잘못된 데이터를 서버로 전송하기 전에 차단하고, 사용자에게 즉시 피드백을 제공할 수 있습니다. 현재 문제점 - 잘못된 데이터가 서버로 전송된 후에야 에러 확인 가능 - 네트워크 요청이 불필요하게 발생 - 사용자가 서버 응답을 기다려야 에러 메시지 확인 가능 Zod 도입 후 개선 - 요청 전 클라이언트에서 데이터 검증하여 불필요한 네트워크 요청 방지 - 즉시 피드백 제공으로 사용자 경험 향상 - 명확한 에러 메시지로 사용자가 문제를 빠르게 파악 가능 --- 5. API Contract 명확화 Zod 스키마는 프론트엔드와 백엔드 간의 API 계약을 명확하게 정의하여, API 스펙 변경을 빠르게 감지하고 팀 간 커뮤니케이션을 원활하게 합니다. 현재 문제점 - API 스펙이 TypeScript 타입으로만 정의되어 있어 런타임 검증 불가 - 백엔드 API 변경 시 프론트엔드에서 즉시 감지 어려움 - API 문서와 실제 구현 간 불일치 가능성 Zod 도입 후 개선 - 스키마가 API 계약의 단일 소스(Single Source of Truth) 역할 - API 스펙 변경 시 스키마 검증 실패로 즉시 감지 - 스키마를 문서화하여 팀 간 공유 용이 --- 6. 기존 로직과의 호환성 보장 중요한 점은 Zod 도입이 기존 에러 처리 로직을 해치지 않는다는 것입니다. 현재 프로젝트는 4xx 에러 코드로 회원가입/로그인 플로우를 판단하는 로직이 있습니다: - `isNewUserError`: 401 에러로 신규 사용자 판단 - `isDuplicateNicknameError`: 400 에러로 닉네임 중복 판단 Zod 검증은 성공 응답(200)에만 적용하고, 에러 응답(4xx, 5xx)은 그대로 통과시켜 기존 로직을 유지합니다. ```typescript // 에러 응답은 Zod 검증을 거치지 않고 그대로 통과 try { const result = await loginWithCode(code); // 성공 시에만 Zod 검증 } catch (error) { // 401, 400 등 에러는 그대로 catch 블록으로 이동 if (isNewUserError(error)) { // 기존 로직 그대로 작동 // 신규 사용자 처리 } }
결론
Zod 도입을 통해 다음과 같은 이점을 얻을 수 있습니다:
- 런타임 안정성: 서버 응답 형식 문제를 즉시 감지
- 코드 재사용성: 검증 로직을 스키마로 분리하여 재사용
- 개발 생산성: 타입과 검증을 한 곳에서 관리
- 사용자 경험: 즉시 피드백 및 불필요한 네트워크 요청 방지
- API 계약 명확화: 프론트엔드-백엔드 간 스펙 일치 보장
- 기존 로직 보존: 에러 처리 로직과 완벽하게 호환
이러한 이유로 Zod를 도입하여 프로젝트의 안정성과 유지보수성을 크게 향상시킬 수 있습니다.
Todo
우선순위별 작업 순서
Phase 1: 기반 작업
- Zod 패키지 설치
- 스키마 디렉토리 구조 생성
- useQueryApi, useMutationApi에 검증 로직 추가
Phase 2: 핵심 API 스키마
- 카카오 인증 스키마 (로그인/회원가입)
- 홈 페이지 스키마
- 퀴즈 스키마
Phase 3: 폼 검증
- 닉네임 검증 스키마
- NameInput 컴포넌트 수정
Phase 4: 나머지 API 스키마
- 랭킹 스키마
- 마이페이지 스키마
- 테스트 페이지 스키마
주의사항
- 에러 응답은 검증하지 않기
- 4xx, 5xx 에러는 Zod 검증을 거치지 않고 그대로 통과
- 기존 isNewUserError, isDuplicateNicknameError 로직 유지
- 점진적 적용
- 한 번에 모든 스키마를 작성하지 말고, 하나씩 적용하며 테스트
- 기존 타입과의 호환성
- 스키마에서 추론한 타입이 기존 타입과 호환되는지 확인
- 에러 처리
- ZodError와 AxiosError를 구분하여 처리
Phase 1 구현
작업 개요
Zod를 프로젝트에 통합하기 위한 기반을 마련했습니다. 핵심은 API 훅에 선택적 검증을 추가하고, 기존 에러 처리 로직을 유지하는 것입니다.
1단계: Zod 패키지 설치
Zod는 TypeScript-first 스키마 검증 라이브러리입니다.
- 런타임 검증: TypeScript 타입만으로는 런타임에서 보장되지 않는 데이터를 검증
- 타입 추론: 스키마에서 TypeScript 타입 자동 생성 (
z.infer) - 에러 메시지: 검증 실패 시 구체적인 에러 정보 제공
npm install zod
- 프로젝트에 Zod 의존성 추가
- TypeScript 타입 정의와 런타임 검증을 함께 사용 가능
2단계: 스키마 디렉토리 구조 생성
스키마를 체계적으로 관리하기 위해 디렉토리 구조를 설계했습니다.
- 관심사 분리: API 스키마와 폼 검증 스키마 분리
- 확장성: 새로운 스키마 추가가 쉬움
- 중앙 관리:
index.ts에서 통합 export
2.1 디렉토리 구조 생성
src/
schemas/
api/ # API 응답/요청 스키마
.gitkeep
forms/ # 폼 검증 스키마
.gitkeep
index.ts # 통합 export 파일
2.2 통합 Export 파일 생성
/** * Zod 스키마 통합 export 파일 * 모든 스키마를 여기서 export하여 중앙 관리 */ // API 스키마는 Phase 2에서 추가 예정 // export * from './api/kakao'; // export * from './api/home'; // export * from './api/quiz'; // 폼 검증 스키마는 Phase 3에서 추가 예정 // export * from './forms/nickname'; // export * from './forms/quiz';
- 스키마 관리 구조 확립
- 향후 스키마 추가 시 일관된 구조 유지
- 중앙 export로 import 경로 단순화
3단계: 타입 정의 확장 (src/Apis/types.ts)
기존 타입 정의에 Zod 스키마 옵션을 추가했습니다.
- 선택적 검증: 스키마를 전달하지 않으면 기존처럼 동작
- 타입 안정성: 제네릭과 Zod 스키마 타입을 연결
- 하위 호환성: 기존 코드 수정 없이 사용 가능
3.1 Zod 타입 Import 추가
import type { UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'; import type { AxiosError } from 'axios'; import type { z } from 'zod'; // ← 추가
3.2 QueryApiOptions에 schema 옵션 추가
export interface QueryApiOptions<TData> extends Omit<UseQueryOptions<TData, AxiosError>, 'queryKey' | 'queryFn'> { headers?: Record<string, string>; /** * API 응답 검증을 위한 Zod 스키마 * 성공 응답(200)에만 적용되며, 에러 응답(4xx, 5xx)은 검증하지 않음 */ schema?: z.ZodSchema<TData>; // ← 추가 }
schema는 선택적(?)이므로 기존 코드에 영향 없음z.ZodSchema<TData>로 타입 안정성 보장- 주석으로 사용법과 제약 명시
3.3 MutationApiOptions에 requestSchema, responseSchema 추가
export type MutationApiOptions<TData, TVariables> = Omit< UseMutationOptions<TData, AxiosError, TVariables>, 'mutationFn' > & { /** * 요청 데이터 검증을 위한 Zod 스키마 * API 호출 전 클라이언트에서 검증 */ requestSchema?: z.ZodSchema<TVariables>; // ← 추가 /** * 응답 데이터 검증을 위한 Zod 스키마 * 성공 응답(200)에만 적용되며, 에러 응답(4xx, 5xx)은 검증하지 않음 */ responseSchema?: z.ZodSchema<TData>; // ← 추가 };
- 요청/응답을 각각 검증 가능
- 요청은 API 호출 전, 응답은 성공 시에만 검증
효과
- 타입 안정성 확보
- 기존 코드와 호환
- 명확한 API 계약 정의
4단계: useQueryApi에 Zod 검증 로직 추가
useQueryApi는 GET 요청을 처리합니다. 성공 응답(200)만 검증하고, 에러 응답(4xx, 5xx)은 검증하지 않습니다.
핵심 원칙:
- 성공 응답만 검증: 200 응답에만 스키마 적용
- 에러 응답 보존: 4xx, 5xx는 그대로 통과하여 기존 에러 처리 유지
- 선택적 검증:
schema옵션이 있을 때만 검증
4.1 Zod Import 추가
import { useQuery } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { z } from 'zod'; // ← 추가 import { api } from './axios'; import { processApiError } from './queryClient'; import type { QueryApiOptions } from './types';
4.2 검증 로직 추가
const queryFnFactory = <TData>(url: string, options?: QueryApiOptions<TData>) => async () => { try { const response = await api.get<TData>(url, { headers: options?.headers, }); // 성공 응답(200)에만 Zod 검증 적용 // 에러 응답(4xx, 5xx)은 catch 블록으로 이동하여 검증하지 않음 if (options?.schema) { try { return options.schema.parse(response.data); } catch (validationError: unknown) { // Zod 검증 실패 시 에러 로깅 및 재throw if (validationError instanceof z.ZodError) { console.error('API 응답 검증 실패:', { url, errors: validationError.issues, data: response.data, }); throw new Error( `서버 응답 형식이 올바르지 않습니다: ${validationError.issues.map((issue) => issue.message).join(', ')}`, ); } throw validationError; } } return response.data; } catch (error) { // AxiosError(4xx, 5xx)는 그대로 통과하여 기존 에러 처리 로직 유지 // ZodError는 위에서 처리되므로 여기서는 AxiosError만 처리 processApiError(error); throw error; } };
-
API 호출
const response = await api.get<TData>(url, { headers: options?.headers, });- 성공 시
response.data반환 - 실패 시 catch로 이동
- 성공 시
-
성공 응답 검증 (스키마가 있을 때만)
if (options?.schema) { try { return options.schema.parse(response.data); } catch (validationError: unknown) { // ZodError 처리 } }schema.parse()로 검증- 통과 시 검증된 데이터 반환
- 실패 시 ZodError 발생
-
ZodError 처리
if (validationError instanceof z.ZodError) { console.error('API 응답 검증 실패:', { url, errors: validationError.issues, data: response.data, }); throw new Error(...); }issues배열에서 에러 정보 추출- 로깅 후 사용자 친화적 에러 메시지로 변환
-
에러 응답 처리 (기존 로직 유지)
} catch (error) { processApiError(error); throw error; }- AxiosError(4xx, 5xx)는 그대로 통과
- 기존 에러 처리 로직 유지
효과
- 성공 응답의 데이터 무결성 보장
- 기존 에러 처리 로직 유지
- 검증 실패 시 명확한 에러 메시지 제공
5단계: useMutationApi에 Zod 검증 로직 추가
useMutationApi는 POST, PUT, PATCH, DELETE를 처리합니다. 요청 데이터와 응답 데이터를 각각 검증할 수 있습니다.
- 요청 데이터 사전 검증: API 호출 전 클라이언트에서 검증
- 응답 데이터 검증: 성공 응답(200)만 검증
- 에러 응답 보존: 4xx, 5xx는 그대로 통과
작업 내용
5.1 Zod Import 추가
import { useMutation } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { z } from 'zod'; // ← 추가 import { api } from './axios'; import { processApiError } from './queryClient'; import type { HttpMethod, MutationApiOptions } from './types';
5.2 요청 데이터 검증 로직 추가
mutationFn: async (variables: TVariables) => { // 요청 데이터 검증 (클라이언트 측 사전 검증) if (options?.requestSchema) { try { options.requestSchema.parse(variables); } catch (validationError: unknown) { if (validationError instanceof z.ZodError) { console.error('요청 데이터 검증 실패:', { url, errors: validationError.issues, variables, }); throw new Error( `요청 데이터가 올바르지 않습니다: ${validationError.issues.map((issue) => issue.message).join(', ')}`, ); } throw validationError; } }
- API 호출 전에 검증하여 불필요한 요청 방지
- 검증 실패 시 즉시 에러 발생
5.3 응답 데이터 검증 로직 추가
try { const res = method === 'delete' ? await api[method]<TData>(url, { data: variables }) : await api[method]<TData>(url, variables); // 성공 응답(200)에만 Zod 검증 적용 // 에러 응답(4xx, 5xx)은 catch 블록으로 이동하여 검증하지 않음 if (options?.responseSchema) { try { return options.responseSchema.parse(res.data); } catch (validationError: unknown) { // Zod 검증 실패 시 에러 로깅 및 재throw if (validationError instanceof z.ZodError) { console.error('API 응답 검증 실패:', { url, errors: validationError.issues, data: res.data, }); throw new Error( `서버 응답 형식이 올바르지 않습니다: ${validationError.issues.map((issue) => issue.message).join(', ')}`, ); } throw validationError; } } return res.data; } catch (e: unknown) { // AxiosError(4xx, 5xx)는 그대로 통과하여 기존 에러 처리 로직 유지 // ZodError는 위에서 처리되므로 여기서는 AxiosError만 처리 throw processApiError(e); }
-
요청 데이터 검증 (사전 검증)
if (options?.requestSchema) { options.requestSchema.parse(variables); }- 통과 시 다음 단계 진행
- 실패 시 즉시 에러 발생
-
API 호출
const res = await api[method]<TData>(url, variables);- 성공 시
res.data반환 - 실패 시 catch로 이동
- 성공 시
-
응답 데이터 검증 (성공 시에만)
if (options?.responseSchema) { return options.responseSchema.parse(res.data); }- 통과 시 검증된 데이터 반환
- 실패 시 ZodError 발생
-
에러 처리
} catch (e: unknown) { throw processApiError(e); }- AxiosError는 그대로 통과
- 기존 에러 처리 로직 유지
효과
- 요청 데이터 사전 검증으로 불필요한 네트워크 요청 방지
- 응답 데이터 무결성 보장
- 기존 에러 처리 로직 유지
설계 원칙
1. 선택적 검증 (Opt-in)
// 스키마 없이 사용 → 기존처럼 동작 const { data } = useQueryApi(['home'], '/page/home'); // 스키마와 함께 사용 → 검증 활성화 const { data } = useQueryApi(['home'], '/page/home', { schema: HomeResponseSchema, });
2. 에러 응답 보존
일반적으로는 성공/에러 응답 모두 검증합니다.
다만 그렇게 해버리면, 현재 에러코드(status)로 분기처리를 하는 로직이 깨질 수 있다고 판단했습니다. 에러 응답을 검증해버리면 에러코드 사용이 어렵습니다.
일반적으로는 에러 응답도 검증하고 그것이 타입 안정성이 높고 모든 응답 형식을 보장하지만, 우리 프로젝트의 특수한 요구사항 때문에 성공 응답만 검증하는 방식을 사용했습니다.
// 401 에러 발생 시 try { await loginWithCode(code); } catch (error) { // Zod 검증을 거치지 않고 그대로 catch 블록으로 이동 if (isNewUserError(error)) { // 기존 로직 그대로 작동 // 신규 사용자 처리 } }
3. 명확한 에러 메시지
// ZodError 발생 시 validationError.issues.map((issue) => issue.message).join(', ') // 예: "nickname: 최소 1자 이상이어야 합니다, tierName: 필수 필드입니다"
Phase 1 완료
완료된 작업
- Zod 패키지 설치
- 스키마 디렉토리 구조 생성
- 타입 정의 확장 (
QueryApiOptions,MutationApiOptions) useQueryApi에 응답 검증 로직 추가useMutationApi에 요청/응답 검증 로직 추가
달성한 목표
- 기존 코드와 호환되는 선택적 검증 시스템 구축
- 에러 응답 처리 로직 유지
- 타입 안정성 확보
- 확장 가능한 구조 확립
Phase 2 구현
Phase 2: Zod 스키마로 API 응답 검증 구현하기
Phase 1에서 Zod 기반 검증 인프라를 구축했습니다. Phase 2에서는 핵심 API 스키마를 정의하고 실제 코드에 적용했습니다.
1. 핵심 API 스키마 생성
카카오 인증 스키마 (src/schemas/api/kakao.ts)
// 로그인 요청/응답 스키마 export const kakaoLoginRequestSchema = z.object({ code: z.string().min(1, '인증 코드는 필수입니다'), }); export const kakaoLoginResponseSchema = z.object({ accessToken: z.string().min(1, '액세스 토큰은 필수입니다'), }); // 회원가입 요청/응답 스키마 export const kakaoRegisterRequestSchema = z.object({ code: z.string().min(1, '인증 코드는 필수입니다'), nickname: z.string() .min(1, '닉네임은 필수입니다') .max(20, '닉네임은 20자 이하여야 합니다') .regex(/^[가-힣a-zA-Z0-9\\s]+$/, '닉네임은 한글, 영문, 숫자만 사용 가능합니다'), });
- 요청/응답 데이터 검증
- 닉네임 형식 검증 (길이, 문자 제한)
- 타입 안정성 확보
홈 페이지 스키마 (src/schemas/api/home.ts)
export const homeResponseSchema = z.object({ characterUri: z.string().min(1, '캐릭터 URI는 필수입니다'), nickname: z.string().min(1, '닉네임은 필수입니다'), tierName: z.string().min(1, '티어 이름은 필수입니다'), testResult: z.string().min(1, '테스트 결과는 필수입니다'), });
characterUri는 상대 경로를 받아toAbsoluteUrl()로 변환하므로 문자열만 검증
퀴즈 스키마 (src/schemas/api/quiz.ts)
// 퀴즈 상세 데이터 스키마 export const quizDataSchema = z.object({ quizId: z.number().int().positive(), questionTitle: z.string().min(1), questionType: z.enum(['OX', 'MULTIPLE_CHOICE', 'SHORT_ANSWER']), difficultyLevel: z.enum(['EASY', 'MEDIUM', 'HARD']), correctRate: z.number().min(0).max(100), // ... }); // 퀴즈 제출 요청 스키마 export const quizSubmitRequestSchema = z.object({ isCorrect: z.boolean(), });
- 문제 타입, 난이도 등 enum 검증
- 정답률 범위 검증
- 요청 데이터 사전 검증
2. 실제 API 호출 코드에 스키마 적용
Before: 스키마 없이 사용
// 검증 없이 사용 export const useKakaoLogin = () => { return usePostApi<KakaoLoginResponse, KakaoLoginRequest>('/user/login'); }; const { data } = useQueryApi<HomeResponse>(['page', 'home'], '/page/home');
After: 스키마 적용
// 스키마 검증 추가 export const useKakaoLogin = () => { return usePostApi<KakaoLoginResponse, KakaoLoginRequest>('/user/login', { requestSchema: kakaoLoginRequestSchema, responseSchema: kakaoLoginResponseSchema, }); }; const { data } = useQueryApi<HomeResponse>(['page', 'home'], '/page/home', { schema: homeResponseSchema, });
3. 적용된 위치
- 카카오 인증:
useKakaoLogin,useKakaoRegister - 토큰 갱신:
useRefreshToken - 홈 페이지: 홈 데이터, 투자 성향, 복습 퀴즈 조회
- 퀴즈: 퀴즈 상세, 퀴즈 목록, 퀴즈 제출, 퀴즈 결과
효과
1. 런타임 에러 조기 발견
API 응답 검증 실패: {
url: '/page/home',
errors: [{ message: '캐릭터 URI는 필수입니다' }],
data: { characterUri: null, ... }
}
- 서버 응답 형식 불일치를 즉시 감지
- 개발 단계에서 문제 발견 가능
2. 타입 안정성 향상
// 스키마에서 타입 자동 추론 export type HomeResponse = z.infer<typeof homeResponseSchema>; export type KakaoLoginResponse = z.infer<typeof kakaoLoginResponseSchema>;
- TypeScript 타입과 런타임 검증 일치
- 타입 변경 시 스키마도 함께 수정
3. 명확한 에러 메시지
// 사용자 친화적인 에러 메시지 z.string().min(1, '닉네임은 필수입니다') z.string().max(20, '닉네임은 20자 이하여야 합니다')
- 검증 실패 시 구체적인 메시지 제공
- 디버깅 시간 단축
4. 요청 데이터 사전 검증
// 클라이언트에서 먼저 검증 requestSchema: kakaoRegisterRequestSchema
- 잘못된 요청을 서버로 보내기 전에 차단
- 네트워크 요청 감소
실제 문제 해결 사례
문제: characterUri URL 검증 오류
처음에는 characterUri를 URL로 검증했습니다:
characterUri: z.string().url('캐릭터 URI는 유효한 URL이어야 합니다')
하지만 서버는 상대 경로를 반환했습니다:
{ "characterUri": "/costumes/costume_default_on.png" }
해결: 상대 경로를 허용하도록 수정
// 상대 경로도 허용 (toAbsoluteUrl()이 변환) characterUri: z.string().min(1, '캐릭터 URI는 필수입니다')
이를 통해 실제 사용 패턴에 맞게 스키마를 조정했습니다.
Phase 2를 통해 핵심 API에 스키마 검증을 적용했습니다. 이를 통해:
- 런타임 에러를 조기에 발견
- 타입 안정성 향상
- 명확한 에러 메시지 제공
- 요청 데이터 사전 검증
이제 API 응답이 예상과 다를 때 즉시 알 수 있어, 안정적인 개발에 도움이 됩니다.
Phase 3 구현
Phase 3: Zod 스키마로 폼 검증 구현하기
Phase 3에서 구현한 내용
1. 닉네임 검증 스키마 생성 (src/schemas/forms/nickname.ts)
닉네임 입력 폼 검증 스키마를 생성했습니다:
import { z } from 'zod'; import Filter from 'badwords-ko'; const badWordsFilter = new Filter(); export const nicknameSchema = z .string() .min(1, '닉네임을 입력해주세요') .max(20, '닉네임은 20자 이하여야 합니다') .regex(/^[가-힣a-zA-Z0-9\\s]+$/, '닉네임은 한글, 영문, 숫자만 사용 가능합니다') .refine((val) => val.trim().length > 0, { message: '닉네임은 공백만으로 구성될 수 없습니다', }) .refine((val) => !badWordsFilter.isProfane(val), { message: '부적절한 단어가 포함되어 있습니다', });
검증 규칙
- 길이 검증: 최소 1자, 최대 20자
- 문자 제한: 한글, 영문, 숫자, 공백만 허용
- 공백 체크: 공백만으로 구성 불가
- 비속어 체크: badwords-ko로 부적절한 단어 필터링
2. NameInput 컴포넌트에 스키마 적용
Before: 수동 검증 로직
// Before: 검증 로직이 컴포넌트에 분산 const filter = useMemo(() => new Filter(), []); useEffect(() => { if (value.trim() === '') { setIsValid(false); return; } // Zod 검증 const schemaResult = nicknameSchema.safeParse(value); if (!schemaResult.success) { setErrorMessage(firstError.message); return; } // 비속어 체크 (별도 로직) const hasBadWords = filter.isProfane(value); if (hasBadWords) { setErrorMessage('부적절한 단어가 포함되어 있습니다'); return; } setIsValid(true); }, [value, filter]);
After: 스키마 중심 검증
// After: 스키마에서 모든 검증 처리 useEffect(() => { if (value.trim() === '') { setIsValid(false); setErrorMessage(''); onValidationChange?.(false); return; } // 스키마 검증 (비속어 체크 포함) const schemaResult = nicknameSchema.safeParse(value); if (!schemaResult.success) { const firstError = schemaResult.error.issues[0]; setIsValid(false); setErrorMessage(firstError.message); onValidationChange?.(false); return; } // 모든 검증 통과 setIsValid(true); setErrorMessage(''); onValidationChange?.(true); }, [value, onValidationChange]);
3. 비속어 체크를 스키마에 통합
비속어 체크를 스키마의 .refine()으로 포함했습니다.
장점
- 검증 로직 집중: 모든 검증이 스키마에 위치
- 컴포넌트 단순화: 검증 로직 제거로 코드 간결화
- 재사용성: 스키마를 다른 곳에서도 사용 가능
- 일관성: 검증 규칙이 한 곳에서 관리됨
적용 효과
1. 코드 품질 향상
Before:
- 검증 로직이 컴포넌트에 분산
- 비속어 체크가 별도 로직으로 분리
useMemo,filter등 추가 의존성 관리 필요
After:
- 검증 로직이 스키마에 집중
- 컴포넌트는 UI 렌더링과 상태 관리에 집중
- 불필요한 의존성 제거
2. 명확한 에러 메시지
스키마에서 각 검증 규칙에 맞는 메시지를 제공합니다:
- "닉네임을 입력해주세요" (빈 값)
- "닉네임은 20자 이하여야 합니다" (길이 초과)
- "닉네임은 한글, 영문, 숫자만 사용 가능합니다" (문자 제한)
- "부적절한 단어가 포함되어 있습니다" (비속어)
3. 타입 안정성
export type NicknameInput = z.infer<typeof nicknameSchema>;
스키마에서 타입을 추론해 TypeScript와 런타임 검증이 일치합니다.
4. 유지보수성 향상
- 검증 규칙 변경 시 스키마만 수정
- 새로운 검증 규칙 추가가 쉬움
- 테스트 작성이 단순해짐
실제 동작
버튼 비활성화
// 부모 컴포넌트에서 const isButtonDisabled = !isNameValid || isPending;
스키마 검증 결과(isValid)로 버튼 상태를 제어합니다.
에러 메시지 표시
{!isValid && errorMessage && ( <ErrorMessage>{errorMessage}</ErrorMessage> )}
스키마 검증 실패 시 첫 번째 에러 메시지를 표시합니다.
개선 과정
초기 구현
처음에는 비속어 체크를 컴포넌트에서 처리했습니다:
// 컴포넌트에 검증 로직 분산 const hasBadWords = filter.isProfane(value);
개선
비속어 체크를 스키마로 이동해 검증 로직을 통합했습니다:
// 스키마에 모든 검증 통합 .refine((val) => !badWordsFilter.isProfane(val), { message: '부적절한 단어가 포함되어 있습니다', })
결과:
- 검증 로직이 스키마에 집중
- 컴포넌트 코드가 단순해짐
- 명확한 에러 메시지 제공
- 유지보수성 향상
Phase 4 구현
Phase 4: 나머지 API 스키마 구현 및 적용
1. 랭킹 스키마 생성 (src/schemas/api/ranking.ts)
const rankingUserSchema = z.object({ nickname: z.string().min(1).nullable(), point: z.number().int().nonnegative(), rank: z.number().int().positive(), kongSkinUrl: z.string().min(1).nullable(), }); export const rankingResponseSchema = z.object({ currentUser: rankingUserSchema, topRankingUsers: z.array(rankingUserSchema), aboveUsers: z.array(rankingUserSchema), belowUsers: z.array(rankingUserSchema), });
- 점수 랭킹/성실 랭킹 응답 검증
- 사용자 정보(닉네임, 점수, 순위, 캐릭터 스킨) 검증
nickname,kongSkinUrl은null허용
2. 마이페이지 스키마 생성 (src/schemas/api/mypage.ts)
export const myPageResponseSchema = z.object({ characterUri: z.string().min(1), nickname: z.string().min(1), tierName: z.string().min(1), ratingPoint: z.number().int().nonnegative(), testResult: z.string(), // 빈 문자열 허용 testResultDescription: z.string(), // 빈 문자열 허용 }); export const testResultSchema = z.object({ propensity: z.string().optional(), propensityKoreanName: z.string().optional(), isTested: z.boolean(), });
- 마이페이지 데이터 검증
- 투자 성향 결과 검증
- 테스트 미완료 시 빈 문자열 허용
3. 테스트 페이지 스키마 생성 (src/schemas/api/test.ts)
export const diagnoseRequestSchema = z.object({ totalScore: z.number().int().nonnegative(), }); export const diagnoseResponseSchema = z.object({ propensityKoreanName: z.string().min(1), });
- 투자 성향 진단 요청/응답 검증
- 총 점수 범위 검증
4. 실제 API 호출에 스키마 적용
RankPage
const { data: rankingData } = useQueryApi<RankingResponse>( queryKey, endpoint, { schema: rankingResponseSchema, } );
MyPage & SharingPage
const { data: myPageData } = useQueryApi<MyPageResponse>( ['page', 'mypage'], '/page/mypage', { schema: myPageResponseSchema, } ); const { data: testResultData } = useQueryApi<TestResult>( ['users', 'me', 'propensity'], '/users/me/propensity', { schema: testResultSchema, } );
TestPage
const diagnoseMutation = usePostApi<DiagnoseRes, DiagnoseReq>( '/propensity/diagnose', { requestSchema: diagnoseRequestSchema, responseSchema: diagnoseResponseSchema, } );
적용 과정에서 발견한 문제와 해결
문제 1: 서버 응답의 null 값
랭킹 API에서 nickname과 kongSkinUrl이 null로 올 수 있었습니다.
해결:
// 스키마에서 nullable 허용 nickname: z.string().min(1).nullable(), kongSkinUrl: z.string().min(1).nullable(), // 컴포넌트에서 null 처리 name={user.nickname || '익명'} kongSkinUrl={toAbsoluteUrl(user.kongSkinUrl)}
문제 2: 빈 문자열 처리
마이페이지에서 테스트 미완료 시 testResult와 testResultDescription이 빈 문자열("")로 왔습니다.
해결:
// 최소 길이 제약 제거 testResult: z.string(), // 빈 문자열 허용 testResultDescription: z.string(),
문제 3: 타입 불일치
types.ts의 인터페이스와 스키마에서 추론한 타입이 불일치했습니다.
해결:
// Before: 인터페이스로 정의 export interface RankingUser { nickname: string; // null 불가 } // After: 스키마에서 추론한 타입 사용 export type { RankingUser, RankingResponse } from '@/schemas'; // nickname: string | null
문제 4: null 값 처리
toAbsoluteUrl 함수가 null을 처리하지 못했습니다.
해결:
// Before export const toAbsoluteUrl = (u?: string) => { ... } // After export const toAbsoluteUrl = (u?: string | null) => { ... }
적용 효과
1. 타입 안정성 향상
- 스키마에서 타입 추론으로 런타임 검증과 TypeScript 타입이 일치
null가능성 명시로 null 체크 강제
2. 런타임 에러 조기 발견
API 응답 검증 실패: {
url: '/user/ranking/ratingPoint',
errors: [{ message: '순위는 양수여야 합니다' }]
}
- 서버 응답 형식 불일치를 즉시 감지
- 개발 단계에서 문제 발견
3. 코드 일관성
- 모든 API에 동일한 검증 패턴 적용
- 타입 정의를 스키마에서 중앙 관리
4. 유지보수성 향상
- 검증 규칙 변경 시 스키마만 수정
- 타입과 검증 로직이 한 곳에 집중
실제 개선 사례
Before: 타입 불일치로 인한 런타임 오류 가능성
// types.ts interface RankingUser { nickname: string; // null 불가 } // 실제 서버 응답 { nickname: null } // 타입 오류 발생 가능
After: 스키마 기반 타입으로 안전하게 처리
// 스키마에서 추론 type RankingUser = { nickname: string | null; // null 허용 } // 컴포넌트에서 안전하게 처리 name={user.nickname || '익명'} // null 처리
Phase 4를 통해 주요 API에 스키마 검증을 적용했습니다.
결과:
- 모든 주요 API에 스키마 검증 적용
- 타입 안정성 향상
- 런타임 에러 조기 발견
- 코드 일관성 및 유지보수성 향상
이제 프로젝트 전반에서 일관된 API 검증과 타입 안정성을 확보했습니다.