🧩 νšŒμ›κ°€μž… 폼 λ¦¬νŒ©ν„°λ§ & Zod 검증 νŠΈλŸ¬λΈ”μŠˆνŒ… (곡톡 Input의 ν•œκ³„μ™€ 우회 μ „λž΅)

μ‘°μ€€ν˜•Β·2025λ…„ 10μ›” 24일

읡λͺ… 고민함

λͺ©λ‘ 보기
3/6

πŸ’¬ λ°°κ²½

λ‹€λ₯Έ νŒ€μ—μ„œ λ§Œλ“  νšŒμ›κ°€μž… 폼을 λ¦¬νŒ©ν„°λ§ν•΄λ‹¬λΌλŠ” μš”μ²­μ„ λ°›μ•˜λ‹€.

μ½”λ“œλ₯Ό λΆ„μ„ν•΄λ³΄λ‹ˆ λ‹€μŒκ³Ό 같은 μ΄μŠˆκ°€ μžˆμ—ˆλ‹€:

  • λΉ„λ°€λ²ˆν˜Έ 검증이 .regex()둜 μ€‘μ²©λ˜μ–΄ μœ μ§€λ³΄μˆ˜ 어렀움
  • μ—¬λŸ¬ 쑰건을 μœ„λ°˜ν•΄λ„ 첫 번째 μ—λŸ¬λ§Œ ν‘œμ‹œ(슀무고개 UX)
  • μ—¬λŸ¬ μ—λŸ¬ λ©”μ‹œμ§€κ°€ μ€„λ°”κΏˆ 없이 ν•œ μ€„λ‘œ 좜λ ₯
  • ν”„λ‘œμ νŠΈ Zod 버전 ν˜Έν™˜ 문제 (μ΅œμ‹  μ˜΅μ…˜ μ‚¬μš© λΆˆκ°€)
  • 곡톡 Input μ»΄ν¬λ„ŒνŠΈκ°€ error: string만 지원 β†’ 배열을 직접 넣을 수 μ—†μŒ

πŸ” κΈ°μ‘΄ μ½”λ“œ (Before)

Zod μŠ€ν‚€λ§ˆ

import { z } from 'zod'

const passwordSchema = z
  .string({
    error: (issue) =>
      issue.input === undefined
        ? 'λΉ„λ°€λ²ˆν˜Έλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”'
        : 'μ˜¬λ°”λ₯Έ λΉ„λ°€λ²ˆν˜Έ ν˜•μ‹μœΌλ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”',
  })
  .min(8, { error: 'λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ 8자 이상이어야 ν•©λ‹ˆλ‹€.' })
  .max(50, { error: 'λΉ„λ°€λ²ˆν˜ΈλŠ” 50자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.' })

export const registerSchema = z
  .object({
    email: z.string().trim(),
    password: passwordSchema
      .regex(/[a-z]/, { error: 'λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ ν•˜λ‚˜μ˜ μ†Œλ¬Έμžλ₯Ό 포함해야 ν•©λ‹ˆλ‹€.' })
      .regex(/[A-Z]/, { error: 'λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ ν•˜λ‚˜μ˜ λŒ€λ¬Έμžλ₯Ό 포함해야 ν•©λ‹ˆλ‹€.' })
      .regex(/\d/,    { error: 'λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ ν•˜λ‚˜μ˜ 숫자λ₯Ό 포함해야 ν•©λ‹ˆλ‹€.' })
      .regex(/[!@#$%^&*(),.?":{}|<>]/, { error: 'λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ ν•˜λ‚˜μ˜ 특수문자λ₯Ό 포함해야 ν•©λ‹ˆλ‹€.' }),
    passwordConfirm: z.string({
      error: (issue) =>
        issue.input === undefined ? 'λΉ„λ°€λ²ˆν˜Έ 확인 값을 μž…λ €ν•΄μ£Όμ„Έμš”.' : 'λ¬Έμžμ—΄μ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€',
    }),
    nickname: z.union([z.literal(''), z.string().trim().min(2).max(12)]).optional(),
  })
  .refine((data) => data.password === data.passwordConfirm, {
    message: 'λΉ„λ°€λ²ˆν˜Έ 값이 μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€',
    path: ['passwordConfirm'],
  })

Form μ»΄ν¬λ„ŒνŠΈ

// RegisterForm.tsx (Before)
<Input
  label="λΉ„λ°€λ²ˆν˜Έ"
  type="password"
  name="password"
  value={formData.password || ''}
  onChange={handleChange}
  placeholder="μ΅œμ†Œ 8자 이상 (영문, 숫자, 특수문자 포함)"
  error={error.includes('λΉ„λ°€λ²ˆν˜Έ') && !error.includes('확인') ? error : undefined}
  required
/>
  • μ—λŸ¬ μƒνƒœκ°€ 단일 λ¬Έμžμ—΄(error: string)이라 μ—¬λŸ¬ 쑰건을 λ™μ‹œμ— 보여쀄 수 μ—†μŒ

🎯 λ¦¬νŒ©ν„°λ§ λͺ©ν‘œ

  1. μ—¬λŸ¬ 쑰건 μœ„λ°˜μ„ λ™μ‹œμ— 보여주기
  2. .regex() λ‚˜μ—΄ λŒ€μ‹  superRefine둜 μœ„λ°˜λœ ν•­λͺ©λ§Œ λˆ„μ 
  3. 곡톡 Input은 가급적 큰 λ³€κ²½ 없이 ν™•μž₯/우회
  4. 가독성 μžˆλŠ” μ—λŸ¬ 리슀트둜 UX κ°œμ„ 

πŸš€ λ¦¬νŒ©ν„°λ§ (After)

1) Zod: superRefine둜 μœ„λ°˜λ§Œ λˆ„μ 

import { z } from 'zod'

export const registerSchema = z
  .object({
    email: z.string().min(1, { message: '이메일을 μž…λ ₯ν•΄μ£Όμ„Έμš”.' }).email(),
    password: z.string().min(8).max(50),
    passwordConfirm: z.string().min(1, { message: 'λΉ„λ°€λ²ˆν˜Έ 확인 값을 μž…λ ₯ν•΄μ£Όμ„Έμš”.' }),
    nickname: z.union([z.literal(''), z.string().trim().min(2).max(12)]).optional(),
  })
  .superRefine((data, ctx) => {
    const pwd = data.password ?? ''

    if (pwd.length < 8)
      ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['password'], message: 'λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ 8자 이상이어야 ν•©λ‹ˆλ‹€.' })
    if (!/[A-Z]/.test(pwd))
      ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['password'], message: 'λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ ν•˜λ‚˜μ˜ λŒ€λ¬Έμžλ₯Ό 포함해야 ν•©λ‹ˆλ‹€.' })
    if (!/\d/.test(pwd))
      ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['password'], message: 'λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ ν•˜λ‚˜μ˜ 숫자λ₯Ό 포함해야 ν•©λ‹ˆλ‹€.' })
    if (!/[!@#$%^&*(),.?":{}|<>]/.test(pwd))
      ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['password'], message: 'λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ ν•˜λ‚˜μ˜ 특수문자λ₯Ό 포함해야 ν•©λ‹ˆλ‹€.' })

    if (data.password !== data.passwordConfirm)
      ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['passwordConfirm'], message: 'λΉ„λ°€λ²ˆν˜Έ 값이 μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€' })
  })
  • μΆ©μ‘±ν•œ 쑰건은 μ—λŸ¬μ— ν¬ν•¨λ˜μ§€ μ•ŠμŒ
  • μœ„λ°˜λœ 쑰건만 λ°°μ—΄λ‘œ μˆ˜μ§‘λ˜μ–΄ UIμ—μ„œ λͺ¨λ‘ ν‘œμ‹œ κ°€λŠ₯

2) μƒμœ„ μƒνƒœ: ν•„λ“œλ³„ λ°°μ—΄λ‘œ μ—λŸ¬ 관리

type FormDataState = z.infer<typeof registerSchema>
type FieldErrors = Partial<Record<keyof FormDataState, string[]>>

const [errors, setErrors] = useState<FieldErrors>({})

const parsed = registerSchema.safeParse(formData)
if (!parsed.success) {
  const { fieldErrors } = parsed.error.flatten()
  setErrors(fieldErrors as FieldErrors) // βœ… 각 ν•„λ“œμ˜ μ—λŸ¬κ°€ string[] ν˜•νƒœ
  return
}
  • fieldErrors.passwordλŠ” string[]
  • μ—¬λŸ¬ μœ„λ°˜ λ©”μ‹œμ§€λ₯Ό λ™μ‹œμ— λ“€κ³  μžˆμ„ 수 있음

3) μ™œ 곡톡 Inputμ—λŠ” 배열을 직접 λ„£μ§€ μ•Šμ•˜λ‚˜?

곡톡 Input ν˜„μž¬ νƒ€μž…

interface InputProps extends ComponentPropsWithRef<'input'> {
  label?: string
  error?: string          // ❌ string만 ν—ˆμš©
  helperText?: string
}
  • errorκ°€ string으둜 κ³ μ •λ˜μ–΄ μžˆμ–΄ string[]을 κ·ΈλŒ€λ‘œ λ„£μœΌλ©΄ νƒ€μž… μ—λŸ¬
  • λ˜ν•œ λ‚΄λΆ€ λ Œλ”λ§λ„ <p>{error}</p> ꡬ쑰라 리슀트 좜λ ₯ λΆˆκ°€
  • 곡용 μ»΄ν¬λ„ŒνŠΈλ₯Ό κ΄‘λ²”μœ„ν•˜κ²Œ μˆ˜μ •ν•˜λ©΄ λ‹€λ₯Έ νŒ€ 화면듀에 영ν–₯이 갈 수 있음

κ·Έλž˜μ„œ νƒν•œ μ „λž΅: μ™ΈλΆ€μ—μ„œ FieldErrors둜 λ Œλ”λ§

function FieldErrors({ messages }: { messages?: string[] }) {
  if (!messages?.length) return null
  return (
    <ul className="mt-1 space-y-1 text-sm text-destructive" role="alert">
      {messages.map((m, i) => <li key={i}>β€’ {m}</li>)}
    </ul>
  )
}
<Input
  label="λΉ„λ°€λ²ˆν˜Έ"
  type="password"
  name="password"
  value={formData.password || ''}
  onChange={handleChange}
  placeholder="μ΅œμ†Œ 8자 이상 (영문, 숫자, 특수문자 포함)"
  // error prop은 μƒλž΅ν•˜κ±°λ‚˜ 첫 μ—λŸ¬λ§Œ 넣을 μˆ˜λ„ 있음
  required
/>
<FieldErrors messages={errors.password} />
  • 곡톡 Input은 κ·ΈλŒ€λ‘œ 두고, ν•„λ“œ μ•„λž˜μ—μ„œ 리슀트둜 ν‘œμ‹œ
  • ν˜‘μ—…/μ•ˆμ •μ„± μΈ‘λ©΄μ—μ„œ κ°€μž₯ μ•ˆμ „ν•œ 우회 μ „λž΅

μ°Έκ³ : μž₯κΈ°μ μœΌλ‘œλŠ” 곡톡 Input의 error νƒ€μž…μ„ string | string[]둜 ν™•μž₯ν•˜κ³ , λ‚΄λΆ€μ—μ„œ <ul>을 λ Œλ”λ§ν•˜λ„λ‘ κ°œμ„ ν•˜λ©΄ 폼 μͺ½ μ½”λ“œκ°€ 더 깔끔해진닀. 단, μ΄λŠ” μ»΄ν¬λ„ŒνŠΈ μ‚¬μš©ν•˜λŠ” 전체에 λ―ΈμΉ˜λŠ” 영ν–₯ μ²΄ν¬ν•˜κ³  μ§„ν–‰ν•΄μ•Ό ν•œλ‹€.


πŸ“Š Before / After 비ꡐ

ν•­λͺ©BeforeAfter
검증 방식.regex() 쀑첩superRefine둜 톡합
μ—λŸ¬ ꡬ쑰error: string (단일)errors[field]: string[] (닀쀑)
μ—λŸ¬ ν‘œμ‹œν•œ 쀄<ul><li> 리슀트
Input μ‚¬μš©error에 λ¬Έμžμ—΄FieldErrors둜 λ°°μ—΄ 직접 λ Œλ”
곡톡 μ»΄ν¬λ„ŒνŠΈ 영ν–₯μ—†μŒμ—†μŒ (μ™ΈλΆ€ ν™•μž₯ 방식)
UXμŠ€λ¬΄κ³ κ°œν˜•μœ„λ°˜ ν•­λͺ© λ™μ‹œ λ…ΈμΆœ

🧠 배운 점

  1. νƒ€μΈμ˜ μ½”λ“œλ₯Ό λ¦¬νŒ©ν„°λ§ν•  땐 뢄석이 λ¨Όμ €.

    μˆ˜μ •ν•  λΆ€λΆ„κ³Ό κ°μ‹Έμ„œ ν•΄κ²°ν•  뢀뢄을 ꡬ뢄해야 ν•œλ‹€.

  2. 검증은 둜직이 μ•„λ‹ˆλΌ UX.

    μ™œ ν‹€λ ΈλŠ”μ§€ ν•œλˆˆμ— 보이게 ν•΄μ•Ό ν•œλ‹€.

  3. 곡톡 μ»΄ν¬λ„ŒνŠΈλŠ” λ³€κ²½ 영ν–₯도λ₯Ό κ³ λ €.

    μš°μ„ μ€ μ™ΈλΆ€μ—μ„œ ν™•μž₯ν•΄ μ•ˆμ „ν•˜κ²Œ κ°œμ„ ν•˜κ³ ,

    ν•„μš”ν•  λ•Œ νŒ€ ν•©μ˜λ‘œ 곡톡 μ»΄ν¬λ„ŒνŠΈλ₯Ό λ°œμ „μ‹œν‚€μž.

  4. Zod superRefine은 κ°•λ ₯ν•œ 도ꡬ.

    λ³΅μž‘ν•œ 쑰건을 ν•œ λ²ˆμ— μ œμ–΄ν•˜κ³ , β€œμœ„λ°˜λ§Œβ€ λͺ¨μ•„μ„œ 보여쀄 수 μžˆλ‹€.


✍️ 마무리

이번 λ¦¬νŒ©ν„°λ§μ€ λ‹¨μˆœνžˆ μ—λŸ¬ 문ꡬλ₯Ό κ³ μΉ˜λŠ” μž‘μ—…μ΄ μ•„λ‹ˆλΌ,

λ‹€λ₯Έ νŒ€μ΄ λ§Œλ“  곡톡 μ»΄ν¬λ„ŒνŠΈμ˜ μ œμ•½μ„ μ΄ν•΄ν•˜κ³ ,

κ·Έ μ•ˆμ—μ„œ UXλ₯Ό μ΅œλŒ€ν•œ λŒμ–΄μ˜¬λ¦¬λŠ” ꡬ쑰적 κ°œμ„ μ΄μ—ˆλ‹€.

ν•„μš”ν•˜λ‹€λ©΄ 곡톡 Input을 error: string | string[]둜 ν™•μž₯ν•˜λŠ” λ‹€μŒ 단계도 κ°€λŠ₯ν•˜μ§€λ§Œ,

μ΄λ²ˆμ—λŠ” μ•ˆμ „ν•˜κ³  영ν–₯이 적은 우회 μ „λž΅(FieldErrors) 으둜 λͺ©ν‘œλ₯Ό λ‹¬μ„±ν–ˆλ‹€.

profile
코린이

0개의 λŒ“κΈ€