deep-dive2025-05-14·10·65/348

Express.js Router 파라미터 검증 완벽 가이드

Express.js 라우터에서 파라미터 검증을 효과적으로 구현하는 방법과 검증 실패 처리 전략을 다룹니다.

Introduction

Express.js 라우터에서 파라미터 검증은 보안과 데이터 무결성에 필수적입니다. 많은 개발자들이 검증 로직을 라우터 핸들러 안에 직접 작성하는 실수를 합니다. 이번 포스트에서는 검증 미들웨어를 분리하고 재사용 가능한 패턴을 구현하는 방법을 살펴보겠습니다.

Environment

# 프로젝트 의존성
node --version
# v20.11.0

npm list express joi zod
# express@4.18.2
# joi@17.12.0
# zod@3.22.4

Problem

다음과 같은 라우터 코드가 있었는데, 파라미터 검증이 일관되지 않아 버그가 발생했습니다:

// 검증이 불완전한 라우터
app.get('/users/:id', async (req, res) => {
    const userId = req.params.id; // 문자열 그대로 사용
    const user = await db.users.findById(userId);
    if (!user) {
        return res.status(404).json({ error: 'User not found' });
    }
    res.json(user);
});

app.post('/users', async (req, res) => {
    const { name, email } = req.body;
    // 이름이 빈 문자열이거나 이메일 형식이 잘못된 경우 검증 없음
    await db.users.create({ name, email });
    res.status(201).json({ success: true });
});

발생한 문제들

// 쿼리 파라미터로 SQL 인젝션 시도
GET /users/1' OR '1'='1

// 이름에 스크립트 삽입
POST /users
{
    "name": "",
    "email": "not-an-email"
}

// 응답으로 전체 사용자 데이터 노출
GET /users/../../admin/users

Analysis

문제점 분석

  1. URL 파라미터 검증 부재: req.params.id가 숫자인지 확인하지 않음
  2. req.body 검증 없음: 사용자 입력을 그대로 데이터베이스에 저장
  3. 에러 메시지 비일관: 각 핸들러마다 다른 형식의 에러 응답

검증 전략 비교

접근 방식장점단점
핸들러 내 검증간단함코드 중복, 유지보수 어려움
미들웨어 검증재사용 가능설정 복잡도 증가
라우터 레벨 검증모듈화됨라우터 정의 복잡

Solution

1. Joi를 사용한 검증 미들웨어

const Joi = require('joi');

// 검증 스키마 정의
const schemas = {
    createUser: Joi.object({
        name: Joi.string()
            .min(2)
            .max(50)
            .pattern(/^[a-zA-Z가-힣\s]+$/)
            .required()
            .messages({
                'string.pattern.base': '이름은 한글, 영문, 공백만 허용됩니다.',
                'string.min': '이름은 최소 2자 이상이어야 합니다.',
            }),
        email: Joi.string()
            .email()
            .required()
            .messages({
                'string.email': '올바른 이메일 형식이 아닙니다.',
            }),
        age: Joi.number()
            .integer()
            .min(0)
            .max(150)
            .optional(),
    }),

    userId: Joi.object({
        id: Joi.number()
            .integer()
            .positive()
            .required(),
    }),

    pagination: Joi.object({
        page: Joi.number()
            .integer()
            .min(1)
            .default(1),
        limit: Joi.number()
            .integer()
            .min(1)
            .max(100)
            .default(10),
    }),
};

// 검증 미들웨어 생성 함수
function validate(schema, property = 'body') {
    return (req, res, next) => {
        const { error, value } = schema.validate(req[property], {
            abortEarly: false,
            stripUnknown: true,
        });

        if (error) {
            const errors = error.details.map(detail => ({
                field: detail.path.join('.'),
                message: detail.message,
                type: detail.type,
            }));

            return res.status(400).json({
                error: 'Validation failed',
                details: errors,
            });
        }

        // 검증된 값으로 교체
        req[property] = value;
        next();
    };
}

2. Zod를 사용한 타입 안전 검증

const { z } = require('zod');

// Zod 스키마 정의
const CreateUserSchema = z.object({
    name: z.string()
        .min(2, '이름은 최소 2자 이상이어야 합니다.')
        .max(50, '이름은 최대 50자까지 가능합니다.')
        .regex(/^[a-zA-Z가-힣\s]+$/, '이름은 한글, 영문, 공백만 허용됩니다.'),
    email: z.string()
        .email('올바른 이메일 형식이 아닙니다.'),
    age: z.number()
        .int()
        .min(0)
        .max(150)
        .optional(),
});

const UserIdSchema = z.object({
    id: z.string()
        .regex(/^\d+$/, 'ID는 숫자만 가능합니다.')
        .transform(Number),
});

// Zod 미들웨어
function validateZod(schema, property = 'body') {
    return (req, res, next) => {
        try {
            const result = schema.parse(req[property]);
            req[property] = result;
            next();
        } catch (error) {
            if (error instanceof z.ZodError) {
                return res.status(400).json({
                    error: 'Validation failed',
                    details: error.errors.map(e => ({
                        field: e.path.join('.'),
                        message: e.message,
                    })),
                });
            }
            next(error);
        }
    };
}

3. 라우터 적용

const express = require('express');
const router = express.Router();

// 파라미터 검증应用于 모든 라우트
router.get('/users',
    validate(schemas.pagination, 'query'),
    async (req, res, next) => {
        try {
            const { page, limit } = req.query;
            const users = await db.users.findMany({
                skip: (page - 1) * limit,
                take: limit,
            });
            res.json(users);
        } catch (err) {
            next(err);
        }
    }
);

router.get('/users/:id',
    validate(schemas.userId, 'params'),
    async (req, res, next) => {
        try {
            const user = await db.users.findById(req.params.id);
            if (!user) {
                return res.status(404).json({ error: 'User not found' });
            }
            res.json(user);
        } catch (err) {
            next(err);
        }
    }
);

router.post('/users',
    validate(schemas.createUser, 'body'),
    async (req, res, next) => {
        try {
            const user = await db.users.create({ data: req.body });
            res.status(201).json(user);
        } catch (err) {
            next(err);
        }
    }
);

app.use('/api', router);

4. 커스텀 검증 데코레이터

// 검증 스키마를 라우터에 바인딩하는 헬퍼
function createValidatedRoute(method, path, schema, handler) {
    const route = router[method](path);

    if (schema.params) {
        route.use(validate(schema.params, 'params'));
    }
    if (schema.query) {
        route.use(validate(schema.query, 'query'));
    }
    if (schema.body) {
        route.use(validate(schema.body, 'body'));
    }

    route.use(async (req, res, next) => {
        try {
            await handler(req, res, next);
        } catch (err) {
            next(err);
        }
    });

    return route;
}

// 사용 예시
createValidatedRoute('get', '/users/:id', {
    params: schemas.userId,
}, async (req, res) => {
    const user = await db.users.findById(req.params.id);
    res.json(user);
});

Lessons Learned

  1. 검증은 미들웨어로 분리: 라우터 핸들러에서 검증 로직을 분리하여 재사용성을 높이세요
  2. ** Joi vs Zod 선택**: TypeScript 프로젝트에서는 Zod, JavaScript에서는 Joi를 추천합니다
  3. 에러 메시지 표준화: 모든 검증 에러를 일관된 형식으로 반환하세요
  4. stripUnknown 옵션: 검증 시 알 수 없는 필드를 제거하여 보안을 강화하세요
  5. 파라미터 타입 변환: URL 파라미터는 항상 문자열이므로 명시적 타입 변환이 필요합니다

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.