06.1 클라이언트 검증만 하면 왜 안 될까요?
앞에서 만든 검증은 전부 브라우저 안에서 돌아요. blur에 걸든 input에 걸든, 결국 사용자 컴퓨터에서 실행되는 자바스크립트(JavaScript)죠. 그런데 여기에 큰 함정이 있어요. 브라우저는 사용자 손안에 있다는 거예요.
사용자가 마음만 먹으면 그 자바스크립트를 통째로 건너뛸 수 있어요. 그러면 우리가 공들여 짠 검증은 아예 실행되지 않은 채로 데이터가 서버에 도착해요. 그래서 클라이언트 검증(client validation, 브라우저 쪽 검증)은 UX용이지 보안용이 아니에요. 진짜 방어선은 따로 있어요.
06.2 프론트에서 막았는데, 정말 막힌 걸까요?
말로만 하면 안 와닿으니 직접 뚫어볼게요. 회원가입 폼이 있고, 화면에는 이메일 형식 검사와 나이 검사가 다 걸려 있다고 해봐요. 폼으로는 이상한 값을 넣을 수가 없죠. 그런데 개발자도구(F12 키로 여는 개발자 창) 콘솔을 열고 이렇게 한 줄 치면요.
// 폼을 거치지 않고 서버로 바로 쏘기
fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'xxx', age: -5, role: 'admin' })
});
동작을 보면 폼도, 검증 코드도 전혀 거치지 않고 email: 'xxx', age: -5 같은 엉터리 값이 서버로 그대로 날아가요. 심지어 폼에 있지도 않던 role: 'admin' 같은 필드를 끼워 넣을 수도 있죠. 브라우저가 아예 필요 없는 방법도 있어요. 터미널에서 curl(커맨드라인 요청 도구)로 쏘면 되거든요.
curl -X POST https://example.com/api/signup \
-H "Content-Type: application/json" \
-d '{"email":"xxx","age":-5}'
봇(bot, 자동 프로그램)도 똑같은 방식으로 초당 수백 번씩 쏴요. 우리 화면 검증은 이들에게 아무 의미가 없어요. 클라이언트 검증은 언제나 우회 가능하다. 이 한 문장이 오늘의 전부라고 해도 돼요.
06.3 그래서 서버에서는 뭘 해야 하나요?
답은 간단해요. 서버가 다시 검증하는 거예요. 서버는 사용자가 건드릴 수 없는 우리 영역이니까, 여기서 통과한 값만 진짜로 믿을 수 있어요. 이게 신뢰 경계(trust boundary, 값을 믿기 시작하는 선)예요.
상황: 위에서 뚫렸던 그 /api/signup을 서버에서 이렇게 막아요. 서버 코드라 브라우저가 손댈 수 없어요.
// 서버: 클라이언트가 보낸 값을 절대 믿지 않는다
app.post('/api/signup', (req, res) => {
const { email, age } = req.body;
const errors = {};
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email || '')) errors.email = '이메일 형식이 아니에요';
if (!Number.isInteger(age) || age < 14) errors.age = '14세 이상만 가입돼요';
if (Object.keys(errors).length) {
return res.status(422).json({ errors }); // 422 = 검증 실패
}
// 여기까지 왔으면 이제 값을 믿어도 됨
});
동작을 보면 아까 콘솔로 쏘던 age: -5는 age < 14에 걸려서 서버가 422(Unprocessable Entity, 값은 받았지만 규칙에 안 맞음)로 되돌려보내요. 폼을 건너뛰든 curl로 쏘든 봇으로 쏘든, 여기를 못 넘으면 데이터는 저장되지 않아요. 화면 검증이 뚫려도 이 문이 잠겨 있으면 안전한 거죠.
06.4 검증만 하면 끝일까요, 정규화는요?
서버에서 한 가지를 더 해야 해요. 정규화(normalization, 값을 일정한 모양으로 다듬기)예요. 검증이 틀린 값을 걸러내는 일이라면, 정규화는 맞는 값을 깔끔한 형태로 고쳐 저장하는 일이에요.
예를 들어 사용자가 이메일을 ' [email protected] '처럼 앞뒤 공백과 대문자를 섞어 보냈다고 해봐요. 형식만 보면 유효한 이메일이라 검증은 통과해요. 그런데 이 상태로 저장하면 나중에 [email protected]으로 로그인할 때 같은 사람인데 다른 값으로 취급돼서 조회가 안 되거나 중복 가입이 생겨요.
const email = String(req.body.email || '').trim().toLowerCase();
const name = String(req.body.name || '').trim();
// ' [email protected] ' -> '[email protected]'
동작을 보면 trim()으로 앞뒤 공백을 걷어내고 toLowerCase()로 소문자로 통일해서, 어떤 모양으로 들어오든 저장되는 값은 하나로 모여요. 서버 검증은 이렇게 걸러내기와 다듬기를 함께 한다고 기억해두세요.
06.5 클라와 서버, 규칙을 두 번 쓰긴 아깝지 않나요?
여기까지 오면 이런 생각이 들어요. 같은 이메일 검사 규칙을 브라우저에도 쓰고 서버에도 또 쓰네, 두 번 관리해야 하나? 맞아요, 그게 귀찮고 실수도 나요. 한쪽만 고치면 규칙이 어긋나거든요.
그래서 요즘 정석은 같은 검증 스키마(schema, 값의 규칙을 정의한 것)를 한 곳에 만들어 두고 양쪽이 함께 가져다 쓰는 방식이에요. Zod(조드, 자바스크립트 스키마 라이브러리) 같은 도구를 많이 써요.
// schema/signup.js - 클라이언트와 서버가 같이 가져다 씀
import { z } from 'zod';
export const signupSchema = z.object({
email: z.string().trim().toLowerCase().email('이메일 형식이 아니에요'),
age: z.coerce.number().int().min(14, '14세 이상만'),
});
동작을 보면 규칙을 파일 하나에 적어두고, 브라우저에서는 즉각적인 UX를 위해, 서버에서는 최종 방어를 위해 같은 스키마를 돌려요. 규칙이 바뀌면 이 파일만 고치면 양쪽이 같이 바뀌죠. 그리고 마지막 방어선으로 데이터베이스 제약(DB constraint)도 걸어둬요. 이메일 칸에 UNIQUE(중복 금지), 필수 칸에 NOT NULL(빈 값 금지)을 걸면, 코드가 어쩌다 실수해도 데이터베이스가 마지막에 한 번 더 막아줘요.
06.6 오늘 배운 서버 검증, 이렇게 정리돼요
딱 세 가지만 기억하면 돼요. 첫째, 클라이언트 검증은 UX용일 뿐 언제든 우회돼요. 콘솔이든 curl이든 봇이든 폼을 건너뛰고 서버로 바로 쏠 수 있으니까요. 둘째, 진짜 방어선은 서버 검증이에요. 여기서 통과한 값만 믿고, 실패하면 422로 돌려보내요. 셋째, 서버에서는 검증과 정규화를 같이 하고, 최후엔 데이터베이스 제약으로 한 번 더 막아요.
클라이언트 검증을 하지 말라는 게 아니에요. 사용자에게 빠르게 알려주니 꼭 필요해요. 다만 그걸 믿지는 말라는 거예요. 화면 검증은 친절, 서버 검증은 안전. 이 둘은 역할이 다르고 어느 하나도 다른 쪽을 대신하지 못해요. 직접 여러분 폼의 콘솔을 열고 fetch 한 줄로 쏴보세요. 서버가 그대로 받아버린다면, 지금 그 폼은 뚫려 있는 거예요.