superRefine - Zod

Odyssey·2025년 3월 24일
0

Next.js_study

목록 보기
41/58
post-thumbnail

2025.3.24 월요일의 공부기록

Zod의 superRefine으로 유효성 검사와 오류 처리하기

이 글에서는 Zod 라이브러리에서 제공하는 고급 유효성 검사 메서드인 superRefine의 활용법을 설명하고, Zod의 다양한 오류 처리 방식을 깊이 있게 다룬다.


superRefine이란?

Zod의 superRefine 메서드는 정교한 유효성 검사를 할 때 유용하게 쓰인다. 기존의 refine 메서드보다 더 세부적이고 정밀한 검증이 가능하며, 한 번의 유효성 검사에서 여러 개의 오류 메시지를 동시에 반환할 수도 있다.

기본 사용법

superRefine 메서드는 함수 실행 중에 ctx.addIssue() 메서드를 호출하여 유효성 검사에 문제가 있음을 알릴 수 있다. 만약 실행 과정에서 단 한 번이라도 ctx.addIssue()가 호출되지 않는다면, 유효성 검사는 통과된다.

import { z } from 'zod';

const schema = z.object({
  username: z.string(),
  password: z.string(),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "비밀번호가 일치하지 않습니다.",
      path: ['confirmPassword'],
    });
  }

  if (data.password.length < 8) {
    ctx.addIssue({
      code: z.ZodIssueCode.too_small,
      minimum: 8,
      message: "비밀번호는 최소 8글자 이상이어야 합니다.",
      path: ['password'],
    });
  }
});

이렇게 작성하면 여러 조건을 동시에 검사할 수 있고, 각각의 오류 메시지를 개별적으로 전달할 수 있다.

📌 Zod superRefine 공식 문서


fatal 옵션 (fatal: true)

superRefine에서 오류 발생 시 더 이상의 검증이 의미 없다고 판단할 때, fatal 옵션을 설정하여 추가적인 검사를 중단할 수 있다.

schema.superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "비밀번호가 일치하지 않습니다.",
      path: ['confirmPassword'],
      fatal: true, // 이 에러가 발생하면 이후의 refine 작업을 중단함
    });
  }

  // 위에서 fatal 오류가 발생했다면 이 검사는 실행되지 않음
  if (data.password.length < 8) {
    ctx.addIssue({
      code: z.ZodIssueCode.too_small,
      minimum: 8,
      message: "비밀번호는 최소 8글자 이상이어야 합니다.",
      path: ['password'],
    });
  }
});
  • fatal: true 설정이 있으면 해당 조건에서 오류가 발생하는 순간 즉시 이후 검증 로직을 중단한다.

z.NEVER 사용하기

z.NEVER는 반환 값 자체를 활용하지 않으면서 타입 시스템을 맞추기 위해 사용하는 특별한 용도의 Zod 스키마이다. 주로 유효성 검사는 수행하지만 반환 값을 절대 사용하지 않을 때 유용하게 사용된다.

예시:

const neverSchema = z.NEVER;

const mySchema = z.object({
  action: z.literal('delete'),
  result: neverSchema, // result 값은 사용되지 않음
});

이러한 설정은 반환 값 없이 타입 검증만을 위한 설정으로 사용된다.


Zod의 오류 처리 시스템 이해하기

Zod는 기본적으로 유효성 검사가 실패하면 예외를 던지거나 오류를 반환한다. Zod의 오류는 상세한 정보를 포함하고 있으며, 다양한 형태로 커스터마이징 할 수 있다.

  • 기본 오류 처리 (parse 메서드 사용 시)
try {
  schema.parse(input);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.flatten());
  }
}
  • 오류 세부 정보 얻기 (safeParse 메서드 사용 시)
const result = schema.safeParse(input);
if (!result.success) {
  console.log(result.error.flatten());
}

flatten() 메서드 활용

flatten() 메서드를 사용하면 Zod 오류 객체에서 필드별로 정리된 오류 메시지를 얻을 수 있다.

const result = schema.safeParse(input);
if (!result.success) {
  const errors = result.error.flatten();
  console.log(errors.fieldErrors);
  /*
  {
    password: ["비밀번호는 최소 8글자 이상이어야 합니다."],
    confirmPassword: ["비밀번호가 일치하지 않습니다."]
  }
  */
}

📌 Zod의 오류 처리 공식 문서


Zod superRefine의 활용 예제

구현된 주요 기능

  • Zod를 활용한 데이터 유효성 검사 및 정밀한 에러 처리
  • 사용자 이름과 이메일의 중복 검사
  • bcrypt로 비밀번호 해시 처리
  • 세션 관리 및 자동 로그인 처리(iron-session)
  • 회원가입 성공 시 프로필 페이지로 자동 리다이렉트

< 실습 코드 >

actions.ts

"use server";

import {
  PASSWORD_MIN_LENGTH,
  PASSWORD_REGEX,
  PASSWORD_REGEX_ERROR,
} from "@/lib/constants";
import db from "@/lib/db";
import { z } from "zod";
import bcrypt from "bcrypt";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";

// username에서 특정 단어 제한 (예: "admin")
function checkUsername(username: string) {
  return !username.includes("admin");
}

// 비밀번호 확인 검사 함수
const checkPasswords = ({
  password,
  confirm_password,
}: {
  password: string;
  confirm_password: string;
}) => password === confirm_password;

// Zod 스키마 정의
const formSchema = z
  .object({
    username: z
      .string({
        invalid_type_error: "Username must be a string",
        required_error: "Username is required",
      })
      .toLowerCase()
      .trim()
      .refine(checkUsername, "Username cannot contain 'admin'"),

    email: z.string().email().toLowerCase(),

    password: z
      .string()
      .min(PASSWORD_MIN_LENGTH, `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`)
      .regex(PASSWORD_REGEX, PASSWORD_REGEX_ERROR),

    confirm_password: z.string().min(PASSWORD_MIN_LENGTH),
  })
  .superRefine(async ({ username }, ctx) => {
    // 사용자 이름 중복 검사
    const user = await db.user.findUnique({
      where: { username },
      select: { id: true },
    });

    if (user) {
      ctx.addIssue({
        code: "custom",
        message: "This username is already taken",
        path: ["username"],
        fatal: true,
      });
      return z.NEVER; // 추가 검증 중단
    }
  })
  .superRefine(async ({ email }, ctx) => {
    // 이메일 중복 검사
    const user = await db.user.findUnique({
      where: { email },
      select: { id: true },
    });

    if (user) {
      ctx.addIssue({
        code: "custom",
        message: "This email is already taken",
        path: ["email"],
        fatal: true,
      });
      return z.NEVER; // 추가 검증 중단
    }
  })
  .refine(checkPasswords, {
    message: "Passwords do not match",
    path: ["confirm_password"],
  });

export async function createAccount(prevState: unknown, formData: FormData) {
  const data = {
    username: formData.get("username"),
    email: formData.get("email"),
    password: formData.get("password"),
    confirm_password: formData.get("confirm_password"),
  };

  // Zod 유효성 검사 수행 (safeParseAsync 사용)
  const result = await formSchema.safeParseAsync(data);

  if (!result.success) {
    // 에러가 있을 경우 에러 객체 반환
    return result.error.flatten();
  } else {
    // bcrypt를 통해 비밀번호 해시 처리
    const hashedPassword = await bcrypt.hash(result.data.password, 12);

    // 데이터베이스에 사용자 정보 저장
    const user = await db.user.create({
      data: {
        username: result.data.username,
        email: result.data.email,
        password: hashedPassword, // 안전한 비밀번호 저장
      },
      select: {
        id: true,
      },
    });

    // 세션 생성 및 사용자 자동 로그인 처리
    const session = await getSession();
    session.userId = user.id;
    await session.save();

    // 프로필 페이지로 자동 리다이렉트
    redirect("/profile");
  }
}

코드 포인트

1. Zod를 활용한 세밀한 데이터 검증

  • username은 공백 제거 및 소문자로 변환하며, "admin"과 같은 금지 단어 포함 여부를 검사한다.
  • email은 이메일 형식을 엄격히 검증한다.
  • password는 길이 및 복잡성(정규 표현식)을 검증한다.
  • confirm_password는 별도의 refine을 통해 password와 일치하는지 검사한다.

2. 비동기 중복 검사 (superRefine 활용)

  • 사용자 이름과 이메일의 중복 여부를 각각 별도의 비동기 함수로 검사한다.
  • 중복이 발견되면 즉시 검사 프로세스를 중단하고(fatal: true), 사용자에게 정확한 오류 메시지를 제공한다.

3. bcrypt를 통한 비밀번호 해싱

  • 사용자 비밀번호는 평문으로 절대 저장하지 않고, bcrypt를 사용해 안전한 해시 값으로 변환한다.
  • saltRounds12로 설정하여 보안성과 성능을 적절히 유지한다.

4. 세션 관리 및 자동 로그인 처리

  • 회원가입이 완료되면 즉시 사용자를 로그인 상태로 유지하기 위해 세션을 생성한다.
  • getSession()은 서버 측에서 안전한 세션을 생성하고 관리한다.
  • 성공적으로 회원가입을 마치면 사용자는 자동으로 /profile 페이지로 리다이렉트된다.

정리

  • superRefine 메서드는 더 세부적인 조건을 검사하고 여러 개의 오류를 동시에 전달할 수 있다.
  • fatal: true 옵션으로 중요한 오류 발생 시 즉시 검증을 중단할 수 있다.
  • z.NEVER는 타입 검사만을 목적으로 사용할 때 유용하다.
  • Zod의 오류 처리는 parse, safeParse, flatten() 등의 메서드를 통해 다양하게 활용할 수 있다.

📚 사용된 주요 라이브러리와 문서 링크

0개의 댓글