폼 검증 — 클라이언트와 서버 양쪽에서 유효성 검사하기
클라이언트에서 검증하고, 서버에서 다시 검증하는 것 — 이 이중 검증이 보안의 기본입니다.
개념 정의
폼 검증(Form Validation) 은 사용자 입력이 유효한지 확인하는 과정입니다. 클라이언트 검증은 UX를 위해, 서버 검증은 보안을 위해 반드시 모두 필요합니다.
기본 검증 — 순수 Svelte
<script>
let form = $state({ email: '', password: '', name: '' });
let errors = $derived.by(() => {
const e = {};
if (form.email && !form.email.includes('@')) e.email = '유효한 이메일을 입력하세요';
if (form.password && form.password.length < 8) e.password = '8자 이상 입력하세요';
if (form.name && form.name.length < 2) e.name = '2자 이상 입력하세요';
return e;
});
let touched = $state({});
let isValid = $derived(
form.email && form.password && form.name &&
Object.keys(errors).length === 0
);
function handleBlur(field) {
touched[field] = true;
}
</script>
<form onsubmit={(e) => { e.preventDefault(); console.log(form); }}>
<div>
<input bind:value={form.email} onblur={() => handleBlur('email')} placeholder="이메일" />
{#if touched.email && errors.email}
<p class="error">{errors.email}</p>
{/if}
</div>
<div>
<input type="password" bind:value={form.password} onblur={() => handleBlur('password')} placeholder="비밀번호" />
{#if touched.password && errors.password}
<p class="error">{errors.password}</p>
{/if}
</div>
<button disabled={!isValid}>가입</button>
</form>
Zod를 활용한 스키마 검증
// src/lib/schemas.js
import { z } from 'zod';
export const registerSchema = z.object({
email: z.string().email('유효한 이메일을 입력하세요'),
password: z.string()
.min(8, '8자 이상 입력하세요')
.regex(/[A-Z]/, '대문자를 포함하세요')
.regex(/[0-9]/, '숫자를 포함하세요'),
name: z.string().min(2, '2자 이상 입력하세요').max(50),
agreed: z.literal(true, { errorMap: () => ({ message: '약관에 동의해주세요' }) }),
});
// src/routes/register/+page.server.js
import { fail, redirect } from '@sveltejs/kit';
import { registerSchema } from '$lib/schemas';
export const actions = {
default: async ({ request }) => {
const formData = await request.formData();
const data = Object.fromEntries(formData);
data.agreed = formData.has('agreed');
const result = registerSchema.safeParse(data);
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors;
return fail(400, { data, errors: fieldErrors });
}
await createUser(result.data);
redirect(303, '/login');
}
};
<!-- src/routes/register/+page.svelte -->
<script>
import { enhance } from '$app/forms';
let { form } = $props();
</script>
<form method="POST" use:enhance>
<input name="email" value={form?.data?.email ?? ''} />
{#if form?.errors?.email}
<p class="error">{form.errors.email[0]}</p>
{/if}
<input type="password" name="password" />
{#if form?.errors?.password}
<p class="error">{form.errors.password[0]}</p>
{/if}
<input name="name" value={form?.data?.name ?? ''} />
{#if form?.errors?.name}
<p class="error">{form.errors.name[0]}</p>
{/if}
<label>
<input type="checkbox" name="agreed" />
약관 동의
</label>
{#if form?.errors?.agreed}
<p class="error">{form.errors.agreed[0]}</p>
{/if}
<button>가입</button>
</form>
실시간 검증 + 서버 검증 조합
<script>
import { enhance } from '$app/forms';
import { registerSchema } from '$lib/schemas';
let { form: serverForm } = $props();
let formData = $state({ email: '', password: '', name: '' });
let touched = $state({});
// 클라이언트 실시간 검증
let clientErrors = $derived.by(() => {
const result = registerSchema.safeParse(formData);
if (result.success) return {};
return result.error.flatten().fieldErrors;
});
function showError(field) {
return touched[field] && (clientErrors[field]?.[0] || serverForm?.errors?.[field]?.[0]);
}
</script>
<form method="POST" use:enhance>
<input
name="email"
bind:value={formData.email}
onblur={() => touched.email = true}
/>
{#if showError('email')}
<p class="error">{showError('email')}</p>
{/if}
<!-- ... 나머지 필드 -->
</form>
면접 포인트
- "클라이언트 검증만으로 충분하지 않은 이유는?": 클라이언트 코드는 수정 가능합니다. 브라우저 개발자 도구나 API 직접 호출로 검증을 우회할 수 있으므로, 서버 측 검증은 보안상 필수입니다.
- "Zod 같은 스키마 라이브러리의 장점은?": 스키마를 한 번 정의하면 클라이언트와 서버 양쪽에서 재사용할 수 있고, TypeScript 타입도 자동 추론됩니다.
정리
폼 검증은 "클라이언트에서 즉각 피드백, 서버에서 최종 보안 검증"이라는 이중 구조가 표준입니다. Zod로 스키마를 한 번 정의하고 양쪽에서 재사용하면, 중복 코드 없이 안전한 폼 처리가 가능합니다.
댓글 로딩 중...