문서 읽는 데 73분 · B6

B-6: 스키마 검증 — 검사에서 타입이 나온다

목차 45
전체 13강 중 12강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구입니다. 지난 시간에 회원가입 폼을 완성했죠. 규칙도 돌고, 메시지도 뜨고, 연타도 막혔습니다.

그런데 끝내면서 마음에 걸리는 걸 셋 남겨뒀습니다. 기억나시나요.

하나. 규칙이 JSX 안에 흩어져 있다. 둘. 타입과 규칙이 서로를 모른다. 셋. 이 규칙은 폼 바깥에서는 쓸 수 없다.

셋째가 특히 컸어요. 그리고 그건 사실 훨씬 오래된 질문이었습니다. A-1, 그러니까 이 과목 첫 시간에 이런 질문이 나왔거든요.

서버가 실제로 이 모양대로 준다는 보장이 있나요?

그때 저는 "없습니다" 라고 답하고, 답을 미뤘습니다. 폼 검증을 다루는 모듈에서 그 도구를 만나게 될 거라고요. 오늘이 그 시간입니다.

오늘 우리가 쓸 도구는 Zod 라는 스키마 검증 라이브러리입니다. 스키마라는 건 "이 데이터는 이렇게 생겼다" 를 적어둔 것이에요. 그런데 재밌는 게 있습니다. 그 스키마 하나에서 타입이 저절로 나옵니다.

텍스트
 오늘의 여정

 Step 1     타입이 사라진 그곳에 스키마를 남긴다
    │
 Step 2     흩어진 규칙을 스키마 한곳으로
    │
 Step 3     스키마에서 타입을 꺼낸다
    │
 Step 4     폼과 연결하고 JSX 를 비운다
    │
 Step 5     두 칸을 같이 보는 검사
    │
 Step 6     같은 도구를 서버 응답에
    │
 Step 7     손으로 쓴 검사와 나란히 놓고
    
 타입과 실행 시점 검사가 하나가 된 회원가입 폼

💡 오늘 수업의 핵심 — "타입에서 검사를 만들지 말고, 검사에서 타입을 만든다"

지금까지 우리는 타입을 먼저 적고, 그다음 검사를 따로 적었습니다. 두 벌이니까 어긋날 수 있었죠. 오늘은 방향을 뒤집습니다. 검사를 한 번 적으면 타입이 거기서 따라 나옵니다.

🎯 학습 목표

  • 타입 검사와 실행 시점 검사가 각각 어느 구간을 맡는지 설명하고, 왜 둘 다 필요한지 말할 수 있다
  • Zod 스키마로 폼 규칙을 선언하고 safeParse 로 값을 확인할 수 있다
  • z.infer 로 스키마에서 타입을 뽑아내 타입과 규칙을 한 벌로 만들 수 있다
  • zodResolver 로 React Hook Form 과 연결하고, 같은 스키마를 서버 응답 확인에도 쓸 수 있다

Step 1: "타입이 사라진 그곳에 — z.objectsafeParse"

먼저 왜 타입만으로는 안 되는지부터 확실히 하고 갑시다. 말로만 하면 안 믿기니까 직접 확인해 볼 거예요.

우리가 A-1 에서 선언한 Post, 지난 시간에 만든 SignUpValues, A-1 의 MediaKind. 이것들은 지금 어디에 있을까요.

텍스트
 [ 코드를 쓸 때 ]
   tsc 가 타입을 검사한다
   Post · SignUpValues · MediaKind · TextFieldProps
        │
        │  빌드하면 전부 지워진다
        
 [ 배포한 뒤 브라우저에서 ]
   남아 있는 건 자바스크립트뿐
   서버가 무엇을 보내든 확인해 줄 코드가 없다

빌드해서 직접 찾아봅시다.

Bash
npm run build -w web-spa
grep -o "SignUpValues" apps/web-spa/dist/assets/*.js | wc -l
grep -o "MediaKind" apps/web-spa/dist/assets/*.js | wc -l

둘 다 이렇게 나옵니다.

텍스트
0

한 글자도 안 남았습니다. 타입은 검사가 끝나면 지워지고 브라우저까지 따라오지 않아요. 그래서 배포된 앱에서 서버가 이상한 값을 보내면, 아무도 그걸 막지 못합니다.

A-6 마지막에 "번들러는 타입을 검사하지 않습니다" 를 확인했었죠. 타입 에러가 있어도 빌드가 성공하는 걸 직접 봤고요. 그때는 검사를 안 한다는 게 요점이었는데, 오늘은 그 뒷면을 봅니다. 검사를 안 할 뿐 아니라 아예 들고 가지도 않는다는 것.

그럼 남는 검사를 만들자

Zod 를 설치합시다. 폼과 연결할 도구도 같이 받습니다.

Bash
npm install zod @hookform/resolvers

스키마가 어떻게 생겼는지부터 눈으로 보고 갑시다. 아직 파일을 만들지는 않을 거예요. 회원가입 폼에 넣는 건 Step 2 에서 합니다.

TypeScript
import { z } from 'zod';

const DemoSchema = z.object({
  username: z.string(),
  likeCount: z.number(),
});

생긴 게 타입 선언과 비슷하죠. 그런데 결정적으로 다릅니다. 이건 타입이 아니라 값입니다. const 로 선언했잖아요. 값이니까 지워지지 않고, 실행할 수 있어요.

확인할 때는 safeParse 를 씁니다.

TypeScript
const result = DemoSchema.safeParse({ username: 'jaehoon', likeCount: 12 });
console.log(result);
텍스트
{ success: true, data: { username: 'jaehoon', likeCount: 12 } }

값이 규칙에 맞으면 successtruedata 에 값이 담겨 나옵니다. 틀리면요?

TypeScript
const result = DemoSchema.safeParse({ username: 'jaehoon', likeCount: '12' });
console.log(result.success);
console.log(result.error.issues);

likeCount 를 숫자가 아니라 문자열 '12' 로 줬습니다.

텍스트
false
[
  {
    expected: 'number',
    code: 'invalid_type',
    path: [ 'likeCount' ],
    message: 'Invalid input: expected number, received string'
  }
]

어느 칸이 왜 틀렸는지까지 알려줍니다. path 가 어느 칸인지, message 가 무엇이 문제인지예요. 이게 타입 검사와 결정적으로 다른 부분입니다. 타입 검사는 코드를 쓰는 동안에만 도와주지만, 이건 실행 중에 실제 값을 보고 판단해요.

스키마는 정말 남는지는 나중에 확인합니다

"그럼 스키마는 남는다는 것도 눈으로 보여주세요" 하실 텐데, 지금은 아직 확인할 게 없습니다. 위 코드는 눈으로 본 것뿐이고 우리 앱에는 아직 스키마가 한 줄도 안 들어갔거든요.

스키마를 폼에 제대로 연결한 다음에 봅시다. Step 4 에서 지금과 똑같은 방법으로 다시 세어 볼게요.

💡 한 줄 정리

타입은 빌드하면 사라지지만 스키마는 값이라 코드로 남습니다. safeParse 는 통과 여부와 함께 어느 칸이 왜 틀렸는지를 돌려줍니다.

🙋 학생 질문 — "튜터님, safeParse 말고 parse 도 있던데 뭐가 달라요?"

parse 는 통과하면 값을 그대로 돌려주고, 틀리면 던집니다.

TypeScript
DemoSchema.parse({ username: 'jaehoon', likeCount: '12' });

이러면 ZodError 가 튀어나와요. 잡지 않으면 그 자리에서 코드가 멈춥니다.

둘 중 무엇을 쓸지는 틀렸을 때 화면이 어떻게 되어야 하는가로 정합니다.

  • 폼처럼 틀린 게 흔하고, 틀려도 화면은 멀쩡히 살아 있어야 한다면 → safeParse
  • 서버 응답처럼 틀리면 어차피 화면을 못 그린다면 → parse 로 일찍 멈추는 게 나을 때가 많습니다

오늘 폼에서는 safeParse 쪽을 쓰게 됩니다. 정확히는 우리가 직접 부르지 않고 라이브러리가 대신 불러줘요. Step 4 에서 보게 됩니다.


Step 2: "흩어진 규칙을 한곳으로 — .min · .regex · z.email"

이제 진짜 회원가입 폼의 규칙을 옮겨봅시다. 지난 시간에 우리가 쓴 규칙이 뭐였는지 다시 볼게요.

tsx
// 지난 시간의 SignUpForm.tsx — 규칙이 화면 그리는 코드 사이에 흩어져 있다
{...register('username', {
  required: '사용자 이름을 입력해 주세요',
  pattern: {
    value: USERNAME_PATTERN,
    message: '영문 소문자·숫자·마침표·밑줄로 4~20자여야 해요',
  },
})}

이런 덩어리가 JSX 안에 네 군데 있었습니다. "사용자 이름 규칙이 뭐였지?" 를 확인하려면 화면 코드를 뒤져야 했죠.

규칙만 모아둘 파일을 새로 만듭니다. 오늘 처음으로 코드베이스에 손을 댑니다.

TypeScript
// apps/web-spa/src/lib/schemas.ts
import { z } from 'zod';

// 백엔드가 받는 규칙과 같은 모양으로 맞췄다
const USERNAME_PATTERN = /^[a-z0-9._]{4,20}$/;

export const SignUpSchema = z.object({
  username: z
    .string()
    .min(1, { error: '사용자 이름을 입력해 주세요' })
    .regex(USERNAME_PATTERN, { error: '영문 소문자·숫자·마침표·밑줄로 4~20자여야 해요' }),
  email: z
    .string()
    .min(1, { error: '이메일을 입력해 주세요' })
    .pipe(z.email({ error: '이메일 모양이 아니에요' })),
  password: z
    .string()
    .min(1, { error: '비밀번호를 입력해 주세요' })
    .min(8, { error: '8자 이상이어야 해요' }),
  passwordConfirm: z.string().min(1, { error: '비밀번호를 한 번 더 입력해 주세요' }),
});

이 파일 하나만 열면 회원가입에 어떤 규칙이 걸려 있는지 전부 보입니다. 첫째 문제가 여기서 풀렸어요.

읽는 법은 어렵지 않습니다. 점을 찍어 규칙을 이어 붙이면 됩니다. z.string() 은 "문자열이어야 한다", .min(8) 은 "여덟 자 이상", .regex(...) 는 "이 모양이어야 한다" 예요.

메시지를 안 주면 어떻게 되나

error 옵션을 빼면 어떻게 되는지 보고 갑시다. 이건 그냥 넘어갈 문제가 아니거든요.

TypeScript
z.string().min(8).safeParse('short').error.issues[0].message;
텍스트
Too small: expected string to have >=8 characters

영어가 나옵니다. 이메일도 마찬가지로 Invalid email address 예요. Zod 는 국제적으로 쓰는 라이브러리라 기본 메시지가 영어입니다.

그래서 우리 폼에서는 규칙마다 error 를 빠짐없이 줬습니다. 이건 선택이 아니라 필수예요. 하나라도 빼면 그 칸만 영어로 뜹니다.

규칙을 두 개 붙이면 어느 쪽이 먼저 뜨나

password 를 보면 .min(1).min(8) 이 둘 다 붙어 있죠. 왜 이렇게 했을까요.

지난 시간 폼은 빈 칸일 때 "비밀번호를 입력해 주세요" 를 띄웠습니다. 그런데 .min(8) 만 달아두면 빈 칸에도 "8자 이상이어야 해요" 가 떠요. 아직 아무것도 안 쳤는데 길이 얘기부터 하는 건 불친절하죠.

규칙은 적은 순서대로 검사하고, 화면에는 앞에서 걸린 것이 뜹니다. 그래서 .min(1) 을 앞에 뒀어요.

입력한 값 화면에 뜨는 메시지
(빈 칸) 비밀번호를 입력해 주세요
sparta1 8자 이상이어야 해요
sparta1234 (통과)

이메일만 생김새가 다릅니다. .pipe() 가 붙어 있죠.

TypeScript
// apps/web-spa/src/lib/schemas.ts — 방금 만든 스키마의 email 부분
  email: z
    .string()
    .min(1, { error: '이메일을 입력해 주세요' })
    .pipe(z.email({ error: '이메일 모양이 아니에요' })),

z.email() 은 "이메일 모양" 자체를 하나의 타입으로 다루는 도구라, 길이 규칙처럼 뒤에 이어 붙는 게 아니라 별도로 서 있습니다. .pipe()앞 검사를 통과한 값만 뒤 검사로 넘긴다는 뜻이에요. 빈 칸이면 앞에서 걸려 멈추니까 이메일 모양 얘기는 아예 안 나옵니다.

z.email() 은 우리가 쓰던 정규식보다 엄격합니다

지난 시간에 이메일을 이렇게 검사했었죠.

TypeScript
const EMAIL_PATTERN = /^\S+@\S+\.\S+$/;

"골뱅이 있고 점 있으면 통과" 정도였습니다. 급할 때 흔히들 이렇게 씁니다. z.email() 로 바꾸면 뭐가 달라지는지 나란히 놓고 보겠습니다.

입력 지난 시간 정규식 z.email()
jaehoon@spartaclub.kr 통과 통과
a@b.c 통과 막힘
jaehoon@@spartaclub.kr 통과 막힘
.jae@x.kr 통과 막힘
jae..hoon@x.kr 통과 막힘

우리 정규식은 네 개나 통과시키고 있었습니다. 골뱅이가 두 개여도, 점으로 시작해도, 점이 연달아 있어도 그냥 넘어갔어요.

이게 라이브러리를 쓰는 이유 중 하나입니다. 이메일 규칙은 생각보다 까다롭고, 직접 쓴 정규식은 거의 항상 헐겁습니다. 우리가 놓친 경우를 이미 다 겪어본 사람들이 만들어 둔 걸 쓰는 거예요.

💡 한 줄 정리

규칙을 스키마 파일 한곳에 모으면 화면 코드를 뒤지지 않고도 전부 볼 수 있습니다. 기본 메시지가 영어라 error 옵션은 매번 챙기고, 규칙은 적은 순서대로 검사합니다.

🙋 학생 질문 — "튜터님, 검색해 보니 z.string().email() 이라고 쓰던데요?"

예전 버전(Zod 3)의 방식입니다. 지금 버전에서는 z.email() 로 바뀌었어요. 이메일·URL·UUID 같은 "형식" 검사들이 전부 최상위로 올라왔습니다.

무서운 건 이겁니다. z.string().email() 이라고 써도 에러가 안 납니다. 아직 함수가 남아 있어서 그대로 동작해요.

그래서 검색해서 나온 예전 코드를 붙여 넣어도 당장은 아무 문제가 없어 보입니다. 그러다 다음 큰 버전에서 사라지면 그때 한꺼번에 터지죠.

라이브러리를 쓸 때는 검색 결과보다 그 버전의 공식 문서를 먼저 보는 습관을 들이세요. 특히 메이저 버전이 올라간 직후에는 인터넷에 옛날 코드가 훨씬 많습니다.


Step 3: "스키마에서 타입을 꺼낸다 — z.infer"

이제 둘째 문제입니다. 타입과 규칙이 서로를 모른다는 것.

지난 시간 Step 3 에서 제가 이런 얘길 했었죠. 손으로 폼을 만들 때 필드 이름 네 개를 세 번 적었다고요. 값 타입에 한 번, 에러 타입에 한 번, 건드렸는지 기록하는 타입에 한 번.

라이브러리를 쓰면서 두 번은 없어졌지만 하나는 남아 있었습니다.

TypeScript
// 지난 시간까지 손으로 적고 있던 것
export interface SignUpValues {
  username: string;
  email: string;
  password: string;
  passwordConfirm: string;
}

그리고 방금 Step 2 에서 같은 필드 이름 넷을 스키마에도 적었어요. 또 두 벌이 된 거죠. 스키마에 필드를 하나 추가해도 이 인터페이스는 안 따라옵니다.

이런 일을 A-3 에서 이미 해봤습니다. Post 에서 PostCardProps 를 뽑을 때 필드를 다시 적지 않고 Omit 으로 깎아냈죠. 이미 있는 것에서 새 타입을 뽑아내는 방식이었어요.

지금 필요한 것도 같은 발상입니다. 다만 뽑아낼 원본이 타입이 아니라 스키마일 뿐이에요. z.infer 가 그 일을 합니다.

TypeScript
// apps/web-spa/src/lib/schemas.ts
// 스키마에서 타입이 나온다 — 필드 이름을 다시 적지 않는다
export type SignUpValues = z.infer<typeof SignUpSchema>;

한 줄입니다. 그리고 이 한 줄이 만들어내는 타입은 우리가 손으로 적었던 것과 정확히 같아요.

텍스트
 [ 지난 시간까지 ]
   interface SignUpValues { ... }       손으로 적는다
   SignUpSchema 의 규칙들               또 손으로 적는다
   둘은 서로를 모른다

 [ 지금부터 ]
   SignUpSchema                         여기만 적는다
        │
        └─ z.infer ─ SignUpValues      저절로 따라 나온다

typeof 가 왜 붙는지

z.infer<SignUpSchema> 가 아니라 z.infer<typeof SignUpSchema> 입니다. 헷갈리기 쉬운 부분이라 짚고 갈게요.

SignUpSchema이에요. Step 1 에서 const 로 선언했다고 강조했죠. 그런데 꺾쇠 안은 타입이 들어가는 곳입니다. A-6 에서 직접 만들어 본 그 타입 매개변수 자리요. 값을 그대로 넣을 수 없어요.

typeof값을 가리키며 "쟤의 타입" 이라고 말하는 도구입니다. A-1 에서 typeof 를 값이 무엇인지 알아보는 데 썼는데, 타입을 적는 곳에서 쓰면 이렇게 동작합니다.

TypeScript
const SignUpSchema = z.object({ ... });   // 값
type SchemaType = typeof SignUpSchema;   // 그 값의 타입
type SignUpValues = z.infer<typeof SignUpSchema>;   // 그 스키마가 통과시키는 값의 타입

이제 필드 이름이 한 곳에서만 온다

확인해 봅시다. 폼에서 필드 이름을 잘못 적으면요?

tsx
<input {...register('usernam')} />
텍스트
Argument of type '"usernam"' is not assignable to parameter of type
'"username" | "email" | "password" | "passwordConfirm"'.

쓸 수 있는 이름 넷을 그대로 나열해 줍니다. 지난 시간에도 이게 됐죠. 그런데 이 목록의 출처가 달라졌습니다. 예전엔 우리가 손으로 적은 인터페이스에서 왔고, 지금은 스키마에서 옵니다.

차이가 드러나는 건 여기예요. 폼에 닉네임 칸을 하나 더 만들고 싶다고 해봅시다.

tsx
<input {...register('nickname')} />
텍스트
Argument of type '"nickname"' is not assignable to parameter of type
'"username" | "email" | "password" | "passwordConfirm"'.

스키마에 규칙을 추가하지 않는 한 폼에도 만들 수 없습니다. 규칙 없는 칸이 몰래 생기는 걸 타입 검사가 막아주는 거예요. 반대로 스키마에 nickname 을 추가하는 순간 타입이 따라 넓어지고, 이 에러는 사라집니다.

💡 한 줄 정리

z.infer<typeof 스키마> 로 타입을 뽑으면 필드 이름을 한 곳에만 적게 됩니다. 규칙을 고치면 타입이 따라 바뀌고, 규칙 없는 칸은 아예 만들 수 없어요.

🙋 학생 질문 — "튜터님, 그럼 타입은 이제 안 적어도 되는 건가요?"

폼처럼 값이 앱 바깥에서 들어온다면 그렇습니다. 스키마가 원본이 되고 타입이 따라 나오는 게 자연스러워요.

그런데 앱 안에서만 도는 것들은 다릅니다. 화면 컴포넌트가 받는 props, 훅이 돌려주는 모양 같은 건 검사할 필요가 없어요. 그건 우리가 만들어서 우리가 쓰는 값이니까, 이상한 게 들어올 일이 없거든요. 이런 건 그냥 타입으로 적는 게 짧고 가볍습니다.

기준을 잡자면 이렇습니다.

  • 값이 앱 바깥에서 들어온다 — 폼 입력, 서버 응답, 주소창 파라미터 → 스키마
  • 앱 안에서만 돈다 — props, 훅 반환값, 내부 함수 → 타입

스키마는 코드로 남아서 번들에 실리니까, 검사가 필요 없는 곳까지 스키마로 만들면 앱만 무거워집니다.


Step 4: "폼과 연결하고 JSX 를 비운다 — zodResolver"

스키마는 다 만들었습니다. 이제 폼에 연결할 차례예요.

React Hook Form 은 자기 나름의 검사 규칙을 갖고 있죠. 지난 시간에 register 두 번째 인자로 준 것들이요. 여기에 "검사는 내가 안 하고 저 스키마한테 맡길게" 라고 알려주는 통로가 있습니다. resolver 라고 불러요.

Step 1 에서 같이 설치한 @hookform/resolvers 가 그 다리 역할을 합니다.

tsx
// apps/web-spa/src/components/SignUpForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { SignUpSchema, type SignUpValues } from '../lib/schemas';

export function SignUpForm({ onSubmit }: SignUpFormProps) {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<SignUpValues>({
    // 검사는 전부 스키마가 한다 — 이 화면에는 규칙이 없다
    resolver: zodResolver(SignUpSchema),
    defaultValues: EMPTY_VALUES,
  });
  // ...
}

바뀐 건 두 줄입니다. zodResolver 를 가져오고, useForm 에 넘겼어요.

useForm<SignUpValues> 의 꺾쇠는 그대로 뒀습니다. A-5 에서 훅에 타입을 넘기는 연습을 했었죠. useState 에 빈 배열을 줄 때 무엇이 담길지 알려줘야 했던 것과 같습니다. 여기서도 이 폼이 다루는 값의 모양을 알려주는 거예요. 다만 그 타입이 이제 스키마에서 나온다는 게 달라졌습니다.

그리고 이제 JSX 에서 규칙을 전부 걷어낼 수 있습니다.

tsx
// apps/web-spa/src/components/SignUpForm.tsx
      <TextField
        id="signup-username"
        label="사용자 이름"
        error={errors.username?.message}
        {...register('username')}
      />

      <TextField
        id="signup-email"
        label="이메일"
        error={errors.email?.message}
        {...register('email')}
      />

register('username') 뒤에 아무것도 없죠. 지난 시간에는 여기 규칙 덩어리가 다섯 줄씩 붙어 있었습니다.

메시지 꺼내 쓰는 곳은 하나도 안 바뀌었어요. 여전히 errors.username?.message 입니다. 스키마가 만든 메시지가 그대로 그리로 흘러 들어옵니다.

TextField 도 한 글자 안 건드렸습니다. B-3 에서 "반복되는 묶음은 컴포넌트로 뽑는다" 를 연습하고 지난 시간에 이 폼에 적용해 뒀는데, 그 덕을 오늘 봅니다. 검사 방식을 통째로 갈아치웠는데 화면 컴포넌트는 그대로예요. 잘 나눠둔 경계는 이런 순간에 값을 합니다.

화면에서 확인하기

앱을 띄워서 직접 눌러 봅시다.

Bash
npm run dev -w web-spa

회원가입 폼에서 아무것도 안 쓴 채로 가입하기를 누르면 네 칸 아래에 메시지가 뜹니다.

텍스트
 사용자 이름     사용자 이름을 입력해 주세요
 이메일          이메일을 입력해 주세요
 비밀번호        비밀번호를 입력해 주세요
 비밀번호 확인   비밀번호를 한 번 더 입력해 주세요

지난 시간과 글자 하나 안 다릅니다. 이게 중요해요. 우리는 규칙이 사는 곳을 옮겼을 뿐, 사용자가 보는 화면은 건드리지 않았습니다.

달라진 것도 하나 확인해 봅시다. 이메일 칸에 이렇게 쳐 보세요.

텍스트
jae..hoon@spartaclub.kr
텍스트
이메일 모양이 아니에요

지난 시간이었으면 이건 통과했습니다. Step 2 표에서 봤던 그 주소예요. 규칙을 옮기면서 검사가 촘촘해진 거죠.

이제 스키마가 남는 걸 확인할 수 있습니다

Step 1 에서 미뤄뒀던 걸 이제 봅시다. 스키마가 폼에 연결됐으니 빌드에도 실려요.

Bash
npm run build -w web-spa
grep -o "ZodError" apps/web-spa/dist/assets/*.js | wc -l
grep -o "too_small" apps/web-spa/dist/assets/*.js | wc -l

둘 다 0 이 아닌 수가 나옵니다. 정확히 몇인지는 중요하지 않아요. 우리가 볼 건 0 이냐 아니냐 하나뿐입니다.

too_small 은 "너무 짧다" 를 뜻하는 Zod 내부 이름입니다. 우리가 쓴 적 없는 단어인데 번들에 있죠. 검사하는 코드가 통째로 브라우저까지 따라왔다는 뜻입니다.

Step 1 에서 세어 본 타입 이름들과 나란히 놓아 봅시다.

세어 본 것 정체 번들에서
SignUpValues · MediaKind 타입 0건
ZodError · too_small 스키마가 쓰는 코드 있음

이 차이가 오늘의 전부입니다. 타입은 검사가 끝나면 할 일이 없어 사라지고, 스키마는 브라우저에서 계속 일해야 하니 남습니다.

⚠️ 세어 볼 문자열은 조심해서 고르세요. 예를 들어 8자 이상이어야 해요 를 세면 0 이 아닌 수가 나옵니다. 그런데 지난 시간 코드로 빌드해도 똑같이 나와요. 그때는 register 에 같은 메시지를 적어뒀으니까요. 그러니 이 숫자로는 스키마 덕분인지 원래 있던 건지 가릴 수가 없습니다.

가려내려면 한쪽에만 있는 이름을 세야 합니다. ZodError 처럼 Zod 만 쓰는 이름이요. 비교 실험을 할 때는 늘 이걸 먼저 확인하세요. "비교 대상 쪽에서도 같은 숫자가 나오지는 않는가."

그런데 아직 안 되는 게 하나 있습니다

폼을 전부 제대로 채우되, 비밀번호와 비밀번호 확인만 다르게 넣어 보세요.

텍스트
 사용자 이름     jaehoon
 이메일          jaehoon@spartaclub.kr
 비밀번호        sparta1234
 비밀번호 확인   sparta9999

그냥 통과합니다. 지난 시간에는 막혔던 건데요.

당연합니다. 우리 스키마는 passwordConfirm 에 "한 글자 이상" 이라는 규칙만 걸어뒀거든요. 옆 칸과 같아야 한다는 말은 아직 아무 데도 안 적었습니다.

이건 규칙의 성격이 다릅니다. 지금까지의 규칙은 전부 그 칸 하나만 보면 판단할 수 있는 것이었어요. 그런데 이건 옆 칸을 같이 봐야 압니다. 다음 Step 에서 그걸 해결합니다.

💡 한 줄 정리

resolver: zodResolver(스키마) 한 줄로 폼의 검사를 스키마에 맡깁니다. register 의 두 번째 인자는 전부 사라지고, 메시지는 formState.errors 로 똑같이 흘러 들어옵니다.

🙋 학생 질문 — "튜터님, 검사하는 시점도 그대로인가요?"

그대로입니다. 제출할 때 처음 검사하고, 한 번 걸린 뒤에는 칠 때마다 다시 봅니다.

검사 시점을 정하는 건 스키마가 아니라 useFormmode 예요. 지난 시간에 배운 그대로고, resolver 를 붙였다고 달라지지 않습니다.

역할이 이렇게 나뉜다고 생각하시면 됩니다.

  • 언제 검사할지 — React Hook Form 이 정한다 (mode)
  • 무엇을 검사할지 — 스키마가 정한다

둘이 각자 자기 일만 하니까, 검사 시점을 바꾸고 싶으면 스키마는 그대로 두고 mode 만 고치면 됩니다.


Step 5: "두 칸을 같이 보는 검사 — .refine"

비밀번호 확인 문제를 풀어봅시다.

지난 시간에는 validate 에 함수를 넣어서 옆 칸을 봤었죠. 스키마에서는 .refine 이 그 일을 합니다.

중요한 건 붙는 위치예요. z.object({...}) 전체에 붙입니다. 필드 하나에 붙는 게 아니에요.

TypeScript
// apps/web-spa/src/lib/schemas.ts
export const SignUpSchema = z
  .object({
    // ... 필드 넷의 규칙 ...
  })
  // 이 칸 하나만 봐서는 알 수 없다 — 옆 칸을 같이 봐야 한다
  .refine((values) => values.password === values.passwordConfirm, {
    error: '비밀번호가 일치하지 않아요',
    path: ['passwordConfirm'],
  });

당연하죠. 두 칸을 비교하려면 두 칸을 다 볼 수 있는 곳에 서야 하니까요. 필드 안쪽에서는 자기 값밖에 못 봅니다.

Step 2 에서는 z.object({ 로 시작했는데 여기서는 z 뒤에서 줄이 바뀌었죠. 뒤에 .refine 이 더 붙으니 점을 세로로 나란히 놓은 것뿐이고, 동작은 같습니다.

.refine 이 받는 건 둘입니다.

  • 첫째, 함수 — 값 전체를 받아서 괜찮으면 true, 아니면 false 를 돌려준다
  • 둘째, 메시지와 path — 틀렸을 때 무슨 메시지를 어느 칸에 붙일지

path 를 빼면 어떻게 되나

이게 처음 쓸 때 가장 많이 걸리는 부분이라 짚고 갑니다. path 를 안 주면 이 메시지는 어느 칸에도 속하지 않습니다.

텍스트
 path 를 줬을 때                       path 를 뺐을 때

 검사에 걸린다                         검사에 걸린다 (똑같이)
 제출이 막힌다                         제출이 막힌다 (똑같이)
 errors.passwordConfirm 에 담긴다      formState.errors 가 통째로 비어 있다
 확인 칸 아래에 메시지가 뜬다          화면에 아무것도 안 뜬다

마지막 두 줄이 무섭습니다. 검사는 제대로 걸리는데 화면에는 아무 흔적도 안 남아요.

우리 TextFielderrors.passwordConfirm?.message 를 읽어서 메시지를 그립니다. 그런데 path 가 없으면 이 에러는 어느 칸에도 속하지 않아서, formState.errors 에 키 자체가 안 생겨요. 빈 객체입니다.

사용자 입장에서는 가입하기를 눌렀는데 버튼이 먹통인 것처럼 보입니다. 아무 반응도, 아무 메시지도 없이 그냥 안 넘어가요. 개발자도 콘솔에 에러가 없으니 한참 헤매게 됩니다.

그래서 여러 칸을 보는 검사일수록 path 로 "누구 탓인지" 를 지목해 주는 게 중요합니다. 여기서는 사용자가 고쳐야 할 칸이 확인 칸이니 그쪽을 지목했어요.

확인해 봅시다

아까 통과해 버렸던 값을 다시 넣어 보세요.

텍스트
 사용자 이름     jaehoon
 이메일          jaehoon@spartaclub.kr
 비밀번호        sparta1234
 비밀번호 확인   sparta9999

이번엔 확인 칸 아래에 뜹니다.

텍스트
비밀번호가 일치하지 않아요

이걸로 폼 쪽 일은 끝났습니다. 지난 시간에 남겨둔 세 가지 중 둘을 풀었어요. 규칙은 한곳에 모였고, 타입은 스키마에서 나옵니다.

💡 한 줄 정리

한 칸만 봐서 알 수 없는 규칙은 z.object 전체에 .refine 으로 붙입니다. path 로 어느 칸의 문제인지 지목해야 그 칸 아래에 메시지가 뜹니다.

🙋 학생 질문 — "튜터님, .refine 을 여러 개 붙여도 되나요?"

됩니다. 점을 찍어 계속 이어 붙이면 돼요. 약관 동의와 나이 제한을 같이 본다든가, 시작일이 종료일보다 앞서야 한다든가 하는 검사들이 이렇게 붙습니다.

다만 한 가지 알아두실 게 있어요. .refine 이 건너뛰어지는 경우가 딱 하나 있습니다. 값의 타입 자체가 어긋났을 때입니다.

생각해 보면 당연합니다. password 자리에 문자열이 아니라 숫자가 들어와 있다면, 그걸 passwordConfirm 과 비교해 봐야 의미가 없잖아요. 그래서 Zod 는 타입이 안 맞으면 거기서 멈추고 .refine 까지 가지 않습니다.

반대로 길이나 모양 규칙이 틀린 건 .refine 을 막지 못합니다. 사용자 이름과 이메일은 제대로 채워 둔 채로, 여덟 자가 안 되는 비밀번호를 넣으면서 확인 칸도 다르게 채워 보세요.

텍스트
 비밀번호        short
 비밀번호 확인   nomatch

두 메시지가 같이 뜹니다.

텍스트
 비밀번호        8자 이상이어야 해요
 비밀번호 확인   비밀번호가 일치하지 않아요

password 가 길이 규칙에 걸렸는데도 .refine 은 그대로 돌아서 확인 칸에 메시지를 붙였어요. 둘은 별개의 검사입니다.

그럼 빈 폼을 제출했을 때는 왜 "비밀번호가 일치하지 않아요" 가 안 떴을까요. 건너뛰어서가 아닙니다. .refine 은 그때도 돌았어요. 다만 빈 칸과 빈 칸은 서로 같으니까 통과한 것뿐입니다.


Step 6: "같은 도구를 서버 응답에 — PostSchema"

이제 셋째 문제입니다. 그리고 오늘의 진짜 목적지예요.

지난 시간까지 우리 규칙은 폼 입력창에만 붙어 있었습니다. register 에 딸려 있으니 폼 밖으로 가져갈 수가 없었죠.

그런데 방금 만든 스키마는 다릅니다. 그냥 값이잖아요. 파일에서 꺼내다 아무 데나 쓸 수 있습니다.

서버가 보내주는 게시물이 우리가 기대한 모양인지 확인해 봅시다. A-1 에서 선언한 Post 를 스키마로 다시 써볼게요.

TypeScript
// apps/web-spa/src/lib/schemas.ts
// 서버가 내려주는 게시물 한 장 — 폼에 쓰던 도구를 그대로 응답에 쓴다
export const PostSchema = z.object({
  id: z.number(),
  username: z.string(),
  profileImageUrl: z.string(),
  imageUrl: z.string(),
  mediaKind: z.enum(['image', 'video', 'carousel']),
  content: z.string(),
  hashtagNames: z.array(z.string()),
  likeCount: z.number(),
  commentCount: z.number(),
  liked: z.boolean(),
  createdAt: z.string(),
});

// 피드는 그 게시물이 여러 장 든 배열이다
export const FeedSchema = z.array(PostSchema);

새로 나온 도구가 셋입니다.

도구 하는 일 A-1 의 무엇에 해당하나
z.enum([...]) 나열한 값 중 하나여야 한다 type MediaKind = 'image' | 'video' | 'carousel'
z.array(...) 배열이고 모든 요소가 이 모양이어야 한다 string[]
z.boolean() 참/거짓이어야 한다 boolean

A-1 에서 타입으로 적었던 것들이 그대로 값이 됐죠. 왼쪽은 실행되고 오른쪽은 사라진다는 것만 다릅니다.

흔한 사고를 잡아봅시다

백엔드에서 숫자를 문자열로 내려보내는 일, 생각보다 자주 있습니다. 큰 수를 다루다 정밀도 문제로 문자열로 바꿔 보내거나, 직렬화 설정이 바뀌거나 해서요.

TypeScript
PostSchema.safeParse({ ...healthyPost, likeCount: '1240' });
텍스트
success: false
path: [ 'likeCount' ]
message: 'Invalid input: expected number, received string'

'1240' 은 사람 눈에는 1240 처럼 보입니다. 화면에도 1240 이라고 잘 뜰 거예요. 그런데 좋아요를 누르는 순간 likeCount + 1'12401' 이 됩니다. 문자열에 숫자를 더하면 이어붙기가 되니까요.

이런 건 화면만 봐서는 절대 못 찾습니다. 경계에서 걸러야 해요.

mediaKind 도 마찬가지입니다. 백엔드에 새 종류가 생겼는데 프론트가 모르고 있다면요.

TypeScript
PostSchema.safeParse({ ...healthyPost, mediaKind: 'gif' });
텍스트
message: 'Invalid option: expected one of "image"|"video"|"carousel"'

우리가 아는 셋 말고 다른 게 오면 그 자리에서 알려줍니다. 화면이 이상하게 깨진 뒤에 원인을 찾는 것보다 훨씬 낫죠.

💡 한 줄 정리

스키마는 값이라 폼 바깥으로 들고 나갈 수 있습니다. 같은 도구로 서버 응답을 확인하면 숫자가 문자열로 오는 것 같은 사고를 경계에서 잡아냅니다.

🙋 학생 질문 — "튜터님, 서버가 우리가 모르는 필드를 더 보내면 막히나요?"

안 막힙니다. 그리고 이건 의도된 동작이에요.

TypeScript
z.object({ a: z.string() }).safeParse({ a: 'x', b: 'y' });
텍스트
{ success: true, data: { a: 'x' } }

통과하되 모르는 필드는 떼어냅니다. 거부가 아니라 제거예요. b 가 사라진 걸 보세요.

이렇게 만든 이유가 있습니다. 백엔드가 새 필드를 추가하는 건 흔한 일인데, 그때마다 프론트가 터지면 배포를 같이 맞춰야 하잖아요. 모르는 건 그냥 흘려보내면 프론트를 안 고쳐도 계속 돌아갑니다.

다만 떼어낸다는 걸 모르면 헷갈릴 수 있어요. "분명히 서버가 보냈는데 왜 없지?" 하는 상황이 생깁니다. 그럴 땐 스키마에 그 필드를 추가하는 게 답이에요.


Step 7: "손으로 쓴 검사와 나란히 놓고"

A-2 를 기억하시나요. 그때도 우리는 서버 응답을 확인하는 코드를 만들었습니다. 손으로요.

TypeScript
// apps/web-spa/src/types/guards.ts
export function isPost(value: unknown): value is Post {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'username' in value &&
    'imageUrl' in value &&
    'likeCount' in value &&
    typeof value.id === 'number' &&
    typeof value.username === 'string' &&
    typeof value.imageUrl === 'string' &&
    typeof value.likeCount === 'number'
  );
}

그때 제가 이런 경고를 달았었죠. 이건 우리가 손으로 쓴 검사라, Post 에 필드를 추가해도 이 함수는 안 따라온다고요. 그 문제를 근본적으로 푸는 도구를 폼 검증 모듈에서 만나게 될 거라고 했습니다.

이제 두 검사를 나란히 놓고 같은 값을 넣어봅시다.

같은 응답, 두 판정

이런 응답이 왔다고 해봅시다. 필드 이름은 다 맞는데 값의 모양이 어긋나 있어요.

TypeScript
{
  id: 1,
  username: 'jaehoon',
  imageUrl: 'https://picsum.photos/seed/post1/640/640',
  likeCount: 1240,
  profileImageUrl: null,        // 문자열이어야 하는데 null
  mediaKind: 'gif',             // 우리가 아는 셋이 아님
  content: 42,                  // 문자열이어야 하는데 숫자
  hashtagNames: '한강',          // 배열이어야 하는데 문자열
  commentCount: '32',           // 숫자여야 하는데 문자열
  liked: 'false',               // 참/거짓이어야 하는데 문자열
  createdAt: 0,                 // 문자열이어야 하는데 숫자
}
텍스트
 같은 응답 하나를 두 검사에 넣으면

   isPost        통과   네 항목만 보고 있어서
   PostSchema    막힘   일곱 군데를 짚어낸다

isPostid · username · imageUrl · likeCount 넷만 봅니다. 그리고 그 넷은 실제로 멀쩡해요. 그러니 통과시킵니다. 통과시키고 나면 그 뒤 코드는 이 값을 Post 라고 믿고 다뤄요.

나머지 일곱 개는 아무도 안 봤습니다. 화면은 그대로 그려지고, 어딘가에서 이상하게 깨질 겁니다.

화면으로 보기

말로만 하면 와닿지 않으니 직접 보여주는 화면을 만들어 뒀습니다.

tsx
// apps/web-spa/src/components/ResponseCheckDemo.tsx
function complaints(payload: unknown): string[] {
  const result = PostSchema.safeParse(payload);

  if (result.success) {
    return [];
  }

  return result.error.issues.map((issue) => `${issue.path.join('.')} — ${issue.message}`);
}

issues 를 돌면서 "어느 칸 — 무슨 문제" 로 한 줄씩 만드는 게 전부입니다. 이걸 성한 응답과 망가진 응답 양쪽에 물려서 나란히 그려요.

App.tsx 에 잠깐 올려서 확인해 보세요.

tsx
// apps/web-spa/src/App.tsx — 확인용이라 보고 나면 지웁니다
<Section title="응답 검사">
  <ResponseCheckDemo />
</Section>

화면에 이렇게 뜹니다.

텍스트
 성한 응답                    망가진 응답
 ─────────                    ───────────
 isPost: 통과                 isPost: 통과
 스키마: 통과                 스키마: 막힘

                              profileImageUrl — Invalid input: expected string, received null
                              mediaKind — Invalid option: expected one of "image"|"video"|"carousel"
                              content — Invalid input: expected string, received number
                              hashtagNames — Invalid input: expected array, received string
                              commentCount — Invalid input: expected number, received string
                              liked — Invalid input: expected boolean, received string
                              createdAt — Invalid input: expected string, received number

왼쪽 칸은 양쪽 다 조용합니다. 오른쪽 칸에서 갈리죠. isPost 는 여전히 통과라고 말하고 있습니다.

컴파일러는 이걸 못 잡습니다

여기서 한 가지 더 확인하고 갈 게 있어요. 오늘 내용 중에 가장 중요한 부분입니다.

TypeScript
function buildPost(): Post {
  return PostSchema.parse({ id: 1 });
}

{ id: 1 } 은 게시물이 아니죠. 필드가 열한 개 중 하나뿐이에요. 그런데 이 함수, 컴파일이 됩니다. 에러가 하나도 안 나요.

왜냐하면 PostSchema.parse 가 돌려주기로 약속한 타입이 Post 거든요. 타입 검사는 "이 함수가 Post 를 돌려주기로 되어 있네, 좋아" 하고 넘어갑니다. 안에 넣은 값이 뭔지는 관심 밖이에요.

그럼 실행하면요? 던집니다. 열 개 필드가 없다고 그 자리에서 멈춰요.

텍스트
 tsc 를 돌릴 때           아무 말 없음
 브라우저에서 실행할 때   ZodError

이게 A-1 첫 시간에 드렸던 답, "타입은 컴파일 시점의 약속이지 실행 시점의 보증이 아니다" 의 실물입니다. 컴파일러가 게을러서가 아니에요. 실행해 보기 전엔 알 수 없는 걸 알 수 없다고 말하는 것뿐입니다.

그래서 A-1 의 질문에 답하면

서버가 실제로 이 모양대로 준다는 보장이 있나요?

타입으로는 없습니다. 스키마로는 있습니다.

정확히 말하면 이렇습니다. 서버가 이상한 걸 보내는 것 자체를 막을 방법은 없어요. 서버는 우리 통제 밖이니까요. 우리가 할 수 있는 건 이상한 게 들어왔을 때 알아차리는 것입니다.

텍스트
 [ 앱 바깥 ]  서버 응답 · 주소창 · 로컬 저장소
                     │
                     │  여기서 한 번 확인한다   스키마
                     
 [ 앱 안쪽 ]  타입을 믿고 마음껏 쓴다           타입

경계에서 한 번 확인하고, 통과한 뒤로는 타입을 믿습니다. 안쪽 코드마다 방어 코드를 넣는 게 아니라요. 확인은 한 곳에서만 하고 나머지는 가벼워지는 거예요.

이제 마지막 문제도 풀렸습니다. 규칙이 폼 바깥으로 나갔어요.

여러 개일 때 한눈에 보기

issues 배열을 직접 도는 게 번거로우면 칸별로 묶어주는 도구도 있습니다.

TypeScript
z.flattenError(result.error);

칸 이름을 열쇠로 삼은 객체가 나옵니다. 일곱 칸 중 앞의 둘만 보면 이렇게 생겼어요.

텍스트
{
  formErrors: [],
  fieldErrors: {
    profileImageUrl: [ 'Invalid input: expected string, received null' ],
    mediaKind: [ 'Invalid option: expected one of "image"|"video"|"carousel"' ]
  }
}

fieldErrors 는 칸 이름으로 바로 꺼낼 수 있고, formErrors 에는 path 없이 만든 에러가 담깁니다. Step 5 에서 path 를 빼면 여기로 간다고 했었죠.

배열을 검사하면 몇 번째가 틀렸는지까지 알려줍니다.

텍스트
path: [ 1, 'profileImageUrl' ]

두 번째 게시물(0부터 세니까)의 profileImageUrl 이라는 뜻입니다.

💡 한 줄 정리

손으로 쓴 검사는 우리가 적은 만큼만 봅니다. 스키마는 선언한 전부를 보고, 타입까지 같은 곳에서 나오니 어긋날 수가 없어요. 경계에서 한 번 확인하고 안쪽은 타입을 믿습니다.

🙋 학생 질문 — "튜터님, 그럼 isPost 같은 건 이제 안 쓰나요?"

스키마를 쓸 수 있는 상황이라면 거의 안 씁니다. 손으로 쓴 검사는 항목을 빠뜨리기 쉽고, 타입을 고쳐도 따라오지 않으니까요.

다만 A-2 에서 배운 게 쓸모없어진 건 아닙니다. 두 가지 이유가 있어요.

하나. isPost 의 반환 타입에 붙은 value is Post 문법은 스키마 없이 좁혀야 할 때 여전히 필요합니다. 라이브러리를 얹기 애매한 작은 유틸에서요.

둘. 더 중요한 건, 스키마가 하는 일이 결국 그거라는 걸 알게 된 겁니다. Zod 도 안에서는 typeof 로 하나씩 확인하고 있어요. 우리가 A-2 에서 손으로 해본 걸 자동으로 해주는 것뿐입니다.

도구 안에서 무슨 일이 일어나는지 아는 사람과, 그냥 쓰는 사람은 문제가 생겼을 때 갈립니다.


마무리

오늘 배운 것을 한 줄로 줄이면 이렇습니다. 검사를 한 번 적으면 타입이 따라 나온다.

B-5 에서 만든 폼은 그대로입니다. 사용자가 보는 화면도, 누를 때의 반응도 안 바뀌었어요. 바뀐 건 규칙이 어디 사는가 하나입니다.

지금까지 우리는 타입을 적고 검사를 또 적었습니다. 두 벌이라 어긋날 수 있었고, 실제로 isPost 가 그렇게 어긋나 있었죠. 오늘 방향을 뒤집었습니다.

오늘 배운 핵심 세 가지

💡 하나 — 타입은 빌드하면 사라지고 스키마는 남습니다. 그래서 타입은 코드를 쓰는 동안 우리를 돕고, 스키마는 배포된 앱에서 실제 값을 확인합니다. 둘은 대체재가 아니라 서로 다른 구간을 맡는 짝이에요.

💡 둘 — z.infer<typeof 스키마> 로 타입을 뽑으면 필드 이름을 한 곳에만 적습니다. 규칙을 고치면 타입이 따라 바뀌고, 스키마에 없는 칸은 폼에도 만들 수 없어요.

💡 셋 — 검사는 앱의 경계에서 한 번 합니다. 서버 응답이 들어오는 그곳에서 확인하고, 통과한 뒤로는 안쪽 코드가 타입을 믿게 만듭니다. 방어 코드를 여기저기 흩뿌리는 것보다 훨씬 가볍습니다.

시작할 때 내걸었던 것들

오프닝에서 넷을 꺼냈죠. 어디서 닫혔는지 확인하고 갑시다.

내걸었던 것 닫힌 곳
규칙이 JSX 안에 흩어져 있다 Step 2 에서 모으고 Step 4 에서 걷어냄
타입과 규칙이 서로를 모른다 Step 3 z.infer
이 규칙은 폼 바깥에서는 못 쓴다 Step 6 PostSchema
"서버가 이 모양대로 준다는 보장이 있나요?" Step 7

다음 시간 예고

오늘로 폼은 완성됐습니다. 그런데 이 폼, 생긴 걸 한번 보세요.

signup-form, signup-field, signup-label, signup-input, signup-error, profile-picker-button... 클래스 이름을 우리가 전부 손으로 지었습니다. 그리고 그 이름마다 CSS 를 또 손으로 썼어요. 입력창 하나 만들 때마다 이름 짓고, 스타일 파일 열고, 규칙 추가하고.

지금은 폼 하나라 견딜 만합니다. 화면이 열 개, 스무 개가 되면요? 이름이 겹치기 시작하고, 어떤 스타일이 어디서 오는지 못 찾게 되고, 안 쓰는 CSS 가 쌓여도 지울 엄두가 안 납니다.

다음 시간에는 이 문제를 다루는 다른 길을 봅니다. Tailwind CSS 예요. 클래스 이름을 짓지 않고 스타일을 입히는 방식입니다.

그리고 조금 더 뒤에 두 가지가 기다리고 있어요. 오늘 만든 PostSchema 는 아직 진짜 서버 응답을 받아본 적이 없죠. 데이터를 실제로 가져오는 이야기는 C-3 에서 합니다. 폼 제출을 React 19 의 새 방식으로 다루는 건 D-4 고요. 오늘 만든 스키마가 그 두 곳에서 다시 등장합니다.


과제

[구현] 로그인 폼과 댓글 응답을 스키마로

지난 시간 과제로 만든 로그인 폼을 오늘 배운 방식으로 옮기고, 스키마를 하나 더 만들어 봅시다.

첫째, 로그인 폼을 스키마로 옮기세요.

  • apps/web-spa/src/lib/schemas.tsLoginSchema 를 추가하세요.
  • 사용자 이름과 비밀번호 두 칸이고, 둘 다 빈 칸이면 메시지가 떠야 합니다.
  • LoginValues 는 손으로 적지 말고 z.infer 로 뽑으세요.
  • LoginForm.tsxzodResolver 로 바꾸고, register 의 두 번째 인자를 전부 지우세요.
  • 옮긴 뒤에도 화면 동작이 지난 시간과 같은지 브라우저에서 확인하세요.

여기서 한 가지 더 생각해 볼 게 있습니다. 지난 시간 과제에서 로그인 폼에 비밀번호 8자 규칙을 넣어야 할지 물었죠. 이번엔 그 판단을 코드로 옮겨야 합니다. 넣었는지 뺐는지, 왜 그렇게 정했는지 두세 줄로 적어 주세요.

둘째, 댓글 응답 스키마를 만드세요.

types/instagram.tsComment 가 선언돼 있습니다. 이걸 CommentSchema 로 옮기고, 댓글 목록용 배열 스키마도 만드세요.

그리고 일부러 망가진 응답을 하나 만들어서 safeParse 에 넣고, 몇 개가 걸리는지 확인해 보세요. console.logissues 를 찍어 보면 됩니다.

다 만들고 나면 npm run typecheck -w web-spanpm run lint -w web-spa 를 돌려 둘 다 통과하는지 확인해 주세요.

[탐구] 스키마를 손으로 건드려 보기

오늘 배운 것을 직접 확인해 봅시다. 각 단계에서 무슨 일이 일어났는지 한두 줄씩 적어 주세요. 확인이 끝나면 전부 원래대로 되돌립니다.

  • SignUpSchemanickname: z.string() 을 추가해 보세요. SignUpValues 는 손대지 않았는데 폼에서 register('nickname') 이 되나요? 왜 그런지 Step 3 으로 설명해 보세요.
  • password 규칙에서 .min(1, ...) 을 지우고 빈 칸으로 제출해 보세요. 메시지가 어떻게 바뀌나요?
  • 아무 규칙에서나 { error: '...' } 를 통째로 지우고 그 칸을 틀리게 채워 보세요. 화면에 뭐가 뜨나요?
  • .refinepath 를 지우고 비밀번호를 다르게 넣어 제출해 보세요. 화면에 무슨 일이 일어나나요? 그리고 그때 formState.errorsconsole.log 로 찍어서 메시지가 어디로 갔는지 찾아보세요.
  • PostSchema 에서 mediaKindz.enum([...]) 대신 z.string() 으로 바꾸고, 망가진 응답을 다시 넣어 보세요. 걸리는 항목이 몇 개로 줄어드나요?
  • 빌드한 뒤 dist 에서 TextFieldPropsZodError 가 각각 몇 번 나오는지 세어 보세요. 왜 다른지 Step 1 로 설명해 보세요. 그다음 이메일 모양이 아니에요 도 세어 보고, 이 숫자로는 왜 아무것도 증명할 수 없는지 생각해 보세요. Step 4 끝의 경고가 힌트입니다.

생각해볼 주제

1. 같은 규칙이 두 곳에 있다는 문제

지난 시간에 이 얘기를 한 번 했었죠. 프론트에서 검사하는 규칙을 백엔드도 똑같이 갖고 있다고요. 사용자 이름 4~20자 규칙은 서버에도 있습니다.

오늘 우리는 그 규칙을 프론트 쪽에서 한곳에 모았습니다. 프론트 안에서의 중복은 없앴어요. 그런데 프론트와 백엔드 사이의 중복은 그대로입니다. 오히려 이제 규칙이 어디 있는지 선명해져서, 두 벌이라는 게 더 눈에 밟히죠.

실무에서는 이걸 줄이려는 시도가 여럿 있습니다. 백엔드가 API 명세를 내주면 거기서 프론트 타입과 검사를 자동으로 만들어내기도 하고, 아예 한 저장소 안에서 규칙 파일 하나를 양쪽이 같이 쓰기도 해요.

여러분이 팀에 있다면 어떤 방향을 택하시겠어요. 그리고 그렇게 해서 규칙을 하나로 합치면, 대신 무엇을 포기하게 될까요.

2. 어디까지 검사할 것인가

오늘 우리 앱은 커졌습니다. Zod 를 넣기 전 빌드 결과가 234 kB 였는데 지금은 302 kB 예요. 68 kB 가 늘었습니다.

그런데 우리가 지금 검사하는 건 회원가입 폼 하나뿐입니다. 앱이 커져서 화면이 스무 개가 되고 API 가 서른 개가 되면, 응답마다 스키마를 만들어야 할까요.

전부 검사하면 사고를 다 잡겠지만 코드도 늘고 앱도 무거워집니다. 반대로 몇 개만 검사하면 가볍지만 안 본 곳에서 사고가 나겠죠.

어떤 응답은 반드시 검사해야 하고 어떤 응답은 넘어가도 될까요. 가르는 기준을 세워 보세요. 그리고 그 기준이 서비스의 성격에 따라 달라질지도 생각해 보세요.

3. 스키마가 원본이 되면 무엇이 불편해지나

오늘 우리는 타입을 스키마에서 뽑았습니다. 정의가 하나니까 어긋날 수 없다는 게 장점이었죠.

그런데 이 방향에는 대가도 있습니다. 예를 들어 우리 코드베이스의 Post 는 A-1 에서 손으로 선언했고, 지금 열 개 파일이 그걸 가져다 씁니다. 이걸 스키마에서 뽑는 걸로 바꾸면 타입 하나를 쓰기 위해 검증 라이브러리를 딸려 들여오게 돼요.

타입만 필요한 곳에서도 라이브러리가 따라오는 셈입니다. 앱 안에서만 도는 값에까지 이 방식을 밀어붙이면 어떤 일이 생길까요.

여러분이라면 어디까지를 스키마에서 뽑고 어디부터는 그냥 타입으로 적으시겠어요. 그 경계를 어떻게 설명하시겠어요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 로그인 폼과 댓글 응답을 스키마로

채점 포인트

항목 확인할 것 배점
z.infer 사용 LoginValues 를 손으로 적지 않고 스키마에서 뽑았는가 ★★★
규칙 걷어내기 register 의 두 번째 인자를 전부 지웠는가 ★★★
한국어 메시지 규칙마다 error 를 줘서 영어가 안 뜨게 했는가 ★★☆
비밀번호 규칙 판단 로그인에 길이 규칙을 넣을지 이유와 함께 적었는가 ★★★
배열 스키마 댓글 목록을 z.array 로 감쌌는가 ★★☆
망가진 응답 확인 몇 개가 걸리는지 직접 돌려서 확인했는가 ★★☆

풀이 예시 — 로그인 스키마

칸이 둘로 줄었을 뿐 회원가입과 구조가 같습니다.

TypeScript
// apps/web-spa/src/lib/schemas.ts
export const LoginSchema = z.object({
  username: z.string().min(1, { error: '사용자 이름을 입력해 주세요' }),
  password: z.string().min(1, { error: '비밀번호를 입력해 주세요' }),
});

export type LoginValues = z.infer<typeof LoginSchema>;

LoginValues 를 손으로 적지 않은 게 핵심입니다. 지난 시간 답안에서는 인터페이스를 직접 선언했었죠.

폼 쪽은 두 줄만 바뀝니다.

tsx
// apps/web-spa/src/components/LoginForm.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { LoginSchema, type LoginValues } from '../lib/schemas';

export function LoginForm({ onSubmit }: LoginFormProps) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginValues>({
    resolver: zodResolver(LoginSchema),
    defaultValues: { username: '', password: '' },
  });
  // 이하 JSX 는 register 의 두 번째 인자만 지우면 그대로
}

JSX 에서는 이렇게 바뀌고요.

tsx
// 지난 시간
{...register('username', { required: '사용자 이름을 입력해 주세요' })}

// 이번 시간
{...register('username')}

로그인에 비밀번호 길이 규칙을 넣어야 할까

정답은 하나가 아니지만, 빼는 쪽이 실무 기본값입니다. 이유가 셋 있어요.

하나, 규칙은 바뀝니다. 서비스를 오래 하면 비밀번호 정책이 바뀝니다. 예전에 6자로 가입한 사람이 아직 그 비밀번호를 쓰고 있는데 로그인 폼이 8자를 요구하면, 그 사람은 자기 계정에 못 들어갑니다. 비밀번호는 맞는데 폼이 막아버리는 거예요.

둘, 알려줄 필요가 없는 정보입니다. "8자 이상이어야 해요" 는 공격자에게 이 서비스의 비밀번호 최소 길이를 알려주는 셈입니다. 크지 않은 정보지만 로그인 화면에서 굳이 내줄 이유도 없어요.

셋, 어차피 서버가 판단합니다. 로그인의 정답은 "이 비밀번호가 맞느냐" 하나뿐이고 그건 서버만 압니다. 프론트가 길이를 재봐야 얻는 게 없어요.

그래서 로그인 폼의 검사는 빈 칸만 막는 것으로 충분합니다. 회원가입은 반대예요. 거기서는 새 비밀번호를 만드는 중이라 규칙을 미리 알려주는 게 친절합니다.

정리하면 이렇게 갈립니다.

회원가입 로그인
하는 일 새 값을 만든다 기존 값을 확인한다
규칙 검사 필요하다 (미리 알려주는 게 친절) 불필요 (옛 계정을 막는다)
프론트 검사 범위 형식 · 길이 · 교차 검증 빈 칸만

반대로 "넣겠다" 를 골랐다면, 옛 계정이 잠기는 문제를 어떻게 처리할지까지 적었어야 만점입니다.

풀이 예시 — 댓글 응답 스키마

TypeScript
// apps/web-spa/src/lib/schemas.ts
export const CommentSchema = z.object({
  id: z.number(),
  postId: z.number(),
  username: z.string(),
  content: z.string(),
  createdAt: z.string(),
});

export const CommentListSchema = z.array(CommentSchema);

망가진 응답을 넣어 확인해 봅시다.

TypeScript
CommentSchema.safeParse({
  id: 1,
  postId: '1',      // 숫자여야 하는데 문자열
  username: 'minji',
  content: null,    // 문자열이어야 하는데 null
  createdAt: 0,     // 문자열이어야 하는데 숫자
});

세 개가 걸립니다. 걸린 칸은 postId · content · createdAt 이고, 메시지는 이렇게 나옵니다.

텍스트
postId — Invalid input: expected number, received string
content — Invalid input: expected string, received null
createdAt — Invalid input: expected string, received number

postId 가 문자열로 오는 건 특히 흔한 사고예요. 이 값으로 게시물을 찾을 때 comment.postId === post.id 로 비교하면 '1' === 1 이라 항상 거짓이 됩니다. 댓글이 하나도 안 보이는데 응답에는 분명히 들어 있는, 찾기 어려운 상황이 되죠.

자주 나오는 실수

하나. LoginValues 를 그대로 두는 것. 폼만 zodResolver 로 바꾸고 인터페이스를 안 지우면 오늘 배운 게 반쪽입니다. 필드 이름이 여전히 두 곳에 있어요.

둘. error 옵션을 빼먹는 것. 로그인 폼은 규칙이 단순해서 그냥 z.string().min(1) 이라고 쓰기 쉬운데, 그러면 빈 칸에 Too small: expected string to have >=1 characters 가 뜹니다.

셋. 배열을 감싸는 걸 잊는 것. CommentSchema.safeParse(댓글목록) 이라고 쓰면 배열을 객체로 보고 통째로 막습니다. 목록은 CommentListSchema 로 확인해야 해요.

넷. handleSubmit(onSubmit) 이 넘기는 인자를 착각하는 것. 이 함수는 값과 함께 제출 이벤트도 두 번째 인자로 넘깁니다. A-4 에서 다룬 그 제출 이벤트인데, 타입은 FormEvent 보다 한 단계 넓은 React.BaseSyntheticEvent 로 선언돼 있어요. 값만 받는다고 생각하고 두 번째 인자를 쓰면 예상과 다른 게 들어옵니다.

💡 튜터의 한마디 — 로그인 폼은 규칙이 둘뿐이라 "굳이 스키마까지?" 싶을 수 있습니다. 그런데 이 과제의 진짜 목적은 규칙을 옮기는 게 아니라 LoginValues 를 지우는 것이에요. 필드가 둘이든 스무 개든, 이름을 두 번 적는 순간 어긋날 여지가 생깁니다. 작은 폼에서 미리 익혀 두면 큰 폼에서 고민하지 않게 됩니다.

🎯 [과제 2 예시답안] 스키마를 손으로 건드려 보기

채점 포인트

항목 확인할 것 배점
실제 실행 짐작이 아니라 직접 돌려서 확인했는가 ★★★
path 실험 화면에 아무것도 안 뜬다는 것을 발견했는가 ★★★
규칙 순서 앞에 적은 규칙이 먼저 뜬다는 것을 설명했는가 ★★☆
번들 대조 타입과 스키마의 차이를 설명했는가 (Step 1 · Step 4) ★★☆
지표 선택 메시지 문자열로는 왜 가릴 수 없는지 짚었는가 ★★★

1. 스키마에 nickname 을 추가하면 register('nickname') 이 되나

됩니다. SignUpValues 는 한 글자도 안 건드렸는데도요.

SignUpValuesz.infer<typeof SignUpSchema> 이기 때문입니다. 스키마가 원본이고 타입은 거기서 따라 나오니까, 스키마에 필드가 늘면 타입도 같이 넓어져요. register 가 받아주는 이름 목록도 다섯 개로 늘어납니다.

이게 Step 3 에서 말한 정의가 하나라 어긋날 수 없다는 것의 실물입니다. 지난 시간처럼 인터페이스를 손으로 적고 있었다면, 스키마에 추가하고 인터페이스에 또 추가해야 했겠죠.

2. password 에서 .min(1, ...) 을 지우고 빈 칸으로 제출하면

메시지가 이렇게 바뀝니다.

텍스트
비밀번호를 입력해 주세요      8자 이상이어야 해요

빈 문자열은 길이가 0 이니 .min(8) 에도 걸립니다. 남은 규칙이 그것뿐이라 그 메시지가 뜨는 거예요.

규칙은 적은 순서대로 검사하고 화면에는 앞에서 걸린 것이 뜹니다. .min(1) 을 앞에 두는 건 순전히 사용자에게 더 알맞은 말을 먼저 보여주기 위해서예요.

3. { error: '...' } 를 통째로 지우면

영어 기본 메시지가 뜹니다.

텍스트
Too small: expected string to have >=8 characters
Invalid email address

Zod 는 전 세계가 쓰는 라이브러리라 기본값이 영어입니다. 그래서 한국어 서비스에서는 규칙마다 error 를 챙겨야 하고, 하나라도 빼먹으면 그 칸만 영어로 떠서 눈에 확 띕니다.

4. .refinepath 를 지우면 (가장 중요한 실험)

화면에 아무 일도 안 일어납니다. 비밀번호를 다르게 넣고 가입하기를 눌러도 메시지가 안 뜨고, 그렇다고 제출되지도 않아요. 버튼이 먹통이 된 것처럼 보입니다.

formState.errors 를 찍어 보면 이유가 보입니다.

텍스트
{}

빈 객체입니다. 검사에는 분명히 걸렸는데(제출 함수가 안 불렸으니까요), 그릴 메시지가 어디에도 안 담긴 거예요.

path 는 "이 에러가 어느 칸 소속인지" 를 정하는 표지입니다. 그게 없으면 이 에러는 어느 칸에도 속하지 않고, errors.passwordConfirm 같은 키 자체가 안 생깁니다. 우리 TextField 는 그 키를 읽어서 메시지를 그리니까 그릴 게 없는 거죠.

스키마 쪽에서 보면 조금 다르게 보입니다. z.flattenError 로 뜯어 보면요.

텍스트
 path 를 줬을 때    fieldErrors.passwordConfirm 에 담긴다
 path 를 뺐을 때    formErrors 에 담긴다 (fieldErrors 는 비어 있다)

에러가 사라진 게 아닙니다. 어느 칸에도 속하지 않는 것으로 분류됐을 뿐이에요. 폼 라이브러리는 칸 단위로만 메시지를 찾으니 그걸 못 보는 거고요.

여기서 얻어갈 교훈이 있습니다. 에러가 안 보인다고 검사가 안 도는 게 아닙니다. 제출이 막히는데 메시지가 없다면 path 부터 의심하세요.

5. mediaKindz.string() 으로 바꾸면

걸리는 항목이 일곱에서 여섯으로 줍니다. 'gif' 는 문자열이 맞으니 통과해 버리거든요.

z.enum(['image', 'video', 'carousel']) 은 "문자열이면서 이 셋 중 하나" 를 요구하지만, z.string() 은 "문자열이기만 하면" 됩니다. 백엔드에 새 미디어 종류가 생겼는데 프론트가 모르고 있는 상황을 잡아내려면 앞의 것이어야 해요.

이게 A-1 에서 배운 리터럴 타입과 같은 이야기입니다. 그때 type MediaKind = 'image' | 'video' | 'carousel' 로 좁혀둔 이유와 똑같아요. 다만 그건 코드를 쓸 때만 도와주고, 이건 실행 중에도 도와줍니다.

6. 번들에서 세어 보면

텍스트
grep -o "TextFieldProps" dist/assets/*.js | wc -l     0
grep -o "ZodError"       dist/assets/*.js | wc -l     0 이 아닌 수

TextFieldProps 는 타입이라 빌드하면 지워집니다. 검사가 끝나면 할 일이 없으니 브라우저까지 따라갈 이유가 없어요. 반대로 ZodError 는 검사하는 코드의 일부라 남습니다.

오른쪽에 정확한 수를 적지 않은 이유가 있습니다. 이 실험에서 의미 있는 건 0 이냐 아니냐뿐이에요. 몇 번 나오는지는 볼 필요가 없습니다.

⚠️ 무엇을 세느냐가 이 실험의 전부입니다. 세는 문자열을 잘못 고르면 숫자는 나오는데 아무것도 증명하지 못해요. 대표적인 함정이 둘 있습니다.

하나, 화면에 뜨는 메시지로 세는 것. 이메일 모양이 아니에요 를 세면 1 이 나옵니다. 그런데 지난 시간 코드에서도 1 이 나와요. 그때는 register 에 같은 메시지를 적어뒀으니까요. 스키마 덕분에 남은 건지 원래 있던 건지 구분이 안 됩니다.

둘, 데이터에도 있는 이름으로 세는 것. hashtagNames 를 세면 1 이 나오는데, 타입이 남아서가 아니라 data/feed.ts 의 실제 데이터에 그 이름의 키가 들어 있어서입니다.

그래서 세어야 할 것은 한쪽에만 있는 이름입니다. 타입 쪽은 TextFieldProps·SignUpValues·MediaKind 처럼 타입으로만 쓰이는 이름, 스키마 쪽은 ZodError·too_small 처럼 Zod 만 쓰는 이름이요.

💡 튜터의 한마디 — 네 번째 실험을 꼭 직접 해보세요. path 하나 뺐을 뿐인데 화면이 조용히 죽는 걸 한 번 겪어두면, 나중에 실무에서 "제출 버튼이 안 먹어요" 하는 상황을 만났을 때 어디부터 볼지 바로 떠오릅니다. 글로 읽는 것과 직접 당해보는 건 다릅니다.

🤔 [생각해볼 주제 1] 같은 규칙이 두 곳에 있다는 문제

문제 상황 요약

프론트의 SignUpSchema 에 사용자 이름 4~20자 규칙이 있고, 백엔드에도 같은 규칙이 있습니다. 오늘 프론트 안에서의 중복은 없앴지만 프론트와 백엔드 사이의 중복은 그대로예요. 한쪽만 고치면 어긋납니다.

튜터의 가이드 및 해설

먼저 짚고 갈 게 있습니다. 이 중복은 완전히 없앨 수 없고, 없애려고 해서도 안 됩니다.

이유는 두 검사의 목적이 다르기 때문이에요.

텍스트
 프론트의 검사     사용자에게 빨리 알려주기 위한 것
                   서버에 다녀오지 않고 즉시 반응한다
                   브라우저에서 도니까 얼마든지 우회할 수 있다

 백엔드의 검사     데이터를 지키기 위한 것
                   우회할 수 없는 마지막 방어선
                   여기가 뚫리면 잘못된 데이터가 저장된다

프론트 검사는 편의고 백엔드 검사는 보안입니다. 프론트 검사를 없애면 사용자 경험이 나빠지고, 백엔드 검사를 없애면 서비스가 뚫립니다. 둘 다 있어야 해요.

그러니 진짜 질문은 "중복을 없앨까" 가 아니라 "두 벌이 어긋나는 걸 어떻게 막을까" 입니다. 실무의 접근은 대략 셋입니다.

하나, 명세에서 생성하기. 백엔드가 API 명세를 내주면 거기서 프론트 타입과 스키마를 자동으로 만들어냅니다. 백엔드가 규칙을 바꾸면 명세가 바뀌고, 다시 생성하면 프론트가 따라옵니다. 가장 널리 쓰이는 방식이에요.

둘, 한 저장소에서 규칙을 공유하기. 프론트와 백엔드가 같은 저장소에 있고 언어도 같다면, 스키마 파일 하나를 양쪽이 함께 가져다 쓸 수 있습니다. 어긋날 여지가 아예 없어요. 다만 언어가 다르면 못 쓰고, 배포도 같이 묶이게 됩니다.

셋, 그냥 두고 테스트로 지키기. 두 벌을 유지하되 "같아야 한다" 를 검사하는 장치를 둡니다. 가장 단순하고, 팀이 작을 때는 이게 제일 현실적일 때도 많습니다.

무엇을 고르든 대가가 있습니다. 첫째는 도구와 빌드 단계가 늘고, 둘째는 프론트와 백엔드가 한 몸이 되어 따로 움직이기 어려워지고, 셋째는 결국 사람이 챙겨야 합니다. 팀 크기와 배포 주기를 보고 정할 문제예요.

🎯 면접관을 홀리는 핵심 멘트

"프론트와 백엔드의 검증은 중복이 아니라 역할이 다른 두 겹입니다. 프론트는 사용자에게 빨리 알려주기 위한 것이고 우회가 가능하니, 백엔드 검증은 어떤 경우에도 뺄 수 없습니다. 그래서 저는 중복을 없애려 하기보다 두 벌이 어긋나는 걸 막는 데 집중합니다. 실무에서는 API 명세에서 프론트 스키마를 생성해 백엔드를 단일 원본으로 두는 방식을 선호합니다."

🤔 [생각해볼 주제 2] 어디까지 검사할 것인가

문제 상황 요약

Zod 를 넣으면서 빌드 결과가 234 kB 에서 302 kB 로 늘었습니다. 그런데 지금 검사하는 건 회원가입 폼 하나뿐이에요. 앱이 커져서 API 가 서른 개가 되면 응답마다 스키마를 만들어야 할까요.

튜터의 가이드 및 해설

먼저 비용을 정확히 봐야 합니다. 68 kB 는 라이브러리를 처음 들일 때 한 번 내는 값입니다. 스키마를 하나 더 만드는 비용은 그것과 비교가 안 될 만큼 작아요. 스키마 하나는 몇백 바이트 수준입니다.

그러니 "전부 검사하면 앱이 무거워진다" 는 걱정은 생각만큼 크지 않습니다. 진짜 비용은 다른 데 있어요. 사람이 쓰고 관리하는 시간입니다. 스키마가 서른 개면 백엔드가 필드를 바꿀 때마다 따라 고쳐야 하고, 안 고치면 멀쩡한 응답이 막혀서 화면이 안 뜹니다.

그래서 기준은 이렇게 잡습니다.

반드시 검사 넘어가도 되는 곳
틀리면 조용히 잘못 동작하는 데이터 (돈·수량·날짜·상태값) 화면에 글자로만 뜨고 마는 값
여러 곳으로 퍼져 나가는 값 (전역 상태·저장소에 넣는 것) 그 화면에서 쓰고 버리는 값
우리가 통제하지 못하는 출처 (외부 API·주소창·로컬 저장소) 우리 서버가 주는 안정된 응답
자주 바뀌는 API 오래 굳어 안 바뀌는 API

맨 윗줄이 특히 중요합니다. likeCount'1240' 으로 오면 화면에는 멀쩡히 1240 이라고 뜹니다. 그런데 좋아요를 누르는 순간 '12401' 이 돼요. 틀린 게 티가 안 나는 값일수록 검사할 가치가 큽니다. 반대로 사용자 이름이 이상하면 화면만 봐도 바로 알죠.

서비스 성격에 따라서도 갈립니다. 금융이나 커머스처럼 숫자가 곧 돈인 서비스는 촘촘하게 봐야 하고, 콘텐츠를 보여주기만 하는 서비스는 느슨해도 됩니다. 사내 도구처럼 사용자가 정해져 있고 백엔드와 같은 팀이 만드는 경우도 상대적으로 느슨하게 가도 괜찮아요.

🎯 면접관을 홀리는 핵심 멘트

"런타임 검증의 비용은 번들 크기보다 유지보수 부담이 큽니다. 라이브러리 자체는 한 번만 들이면 되고 스키마 하나가 늘어나는 비용은 미미하니까요. 그래서 저는 전부 검사할지가 아니라 어디를 검사할지로 판단합니다. 기준은 틀렸을 때 티가 나는가입니다. 숫자가 문자열로 오는 것처럼 조용히 잘못 동작하는 값은 반드시 경계에서 걸러내고, 화면에 글자로만 뜨고 마는 값은 넘어갑니다."

🤔 [생각해볼 주제 3] 스키마가 원본이 되면 무엇이 불편해지나

문제 상황 요약

오늘 SignUpValues 를 스키마에서 뽑았습니다. 정의가 하나니 어긋날 수 없다는 게 장점이었죠. 그런데 우리 Post 는 여전히 손으로 선언한 인터페이스고, 열 개 파일이 그걸 가져다 씁니다. 이것도 스키마에서 뽑는 게 나을까요.

튜터의 가이드 및 해설

타입만 필요한 곳까지 스키마로 바꾸면 손해입니다. 이유가 셋 있어요.

하나, 라이브러리가 따라옵니다. Postz.infer 로 바꾸면 타입 하나 쓰려고 검증 라이브러리를 가져오게 됩니다. 타입은 빌드하면 사라지는데 스키마는 안 사라지니까, 검사할 생각이 없는 곳에서도 코드가 실려요.

둘, 읽기 어려워집니다. 인터페이스는 한눈에 모양이 보입니다. 스키마는 z. 가 계속 붙어서 필드가 스무 개쯤 되면 눈이 피로해져요. 남이 읽을 문서 역할은 인터페이스가 더 잘합니다.

셋, 타입으로만 할 수 있는 게 있습니다. A-3 에서 배운 Pick·Omit 같은 것들이요. 스키마에도 비슷한 도구가 있지만 표현력이 타입 쪽이 넓습니다.

그래서 경계를 이렇게 긋습니다.

텍스트
 값이 앱 바깥에서 들어온다
   서버 응답 · 폼 입력 · 주소창 · 로컬 저장소
        │
        └── 스키마로 선언하고 z.infer 로 타입을 뽑는다

 값이 앱 안에서만 돈다
   props · 훅 반환값 · 내부 함수의 인자
        │
        └── 그냥 타입으로 적는다

한 문장으로 줄이면 "검사할 값만 스키마로" 입니다. 검사할 생각이 없는데 스키마로 만드는 건 얻는 것 없이 무게만 늘리는 셈이에요.

우리 Post 는 어느 쪽일까요. 사실 양쪽 다입니다. 서버에서 올 때는 바깥에서 들어오는 값이고, 화면 사이를 돌아다닐 때는 안에서만 도는 값이에요.

그래서 오늘 우리가 한 것처럼 PostSchemaPost 를 나란히 두는 것도 충분히 합리적인 선택입니다. 경계에서는 스키마로 확인하고, 안쪽에서는 가벼운 타입을 씁니다. 둘이 어긋나지 않는지는 "서로 대입되는가" 를 컴파일러가 확인해 주게 만들면 되고요.

물론 규모가 커지면 판단이 달라질 수 있습니다. 타입이 수십 개가 되면 두 벌을 눈으로 맞추는 게 부담이라, 스키마 하나로 통일하는 쪽이 나을 때가 오죠. 정답이 정해진 문제가 아니라 팀의 규모와 취향에 따라 옮겨 가는 선입니다.

🎯 면접관을 홀리는 핵심 멘트

"스키마에서 타입을 뽑는 방식은 정의를 하나로 만들어 준다는 게 가장 큰 장점이지만, 타입과 달리 런타임 코드가 남는다는 대가가 따릅니다. 그래서 저는 검증이 필요한 경계에서만 스키마를 원본으로 두고, 앱 내부에서만 도는 값은 가벼운 타입으로 유지합니다. 둘을 함께 둘 때는 서로 대입 가능한지를 컴파일 시점에 확인하게 만들어서, 두 벌이 조용히 어긋나는 것만 막아 둡니다."

전체 목록 리액트