폼 처리
폼은 웹 애플리케이션에서 사용자 입력을 받는 가장 기본적인 방법입니다. Vue의 v-model과 반응형 시스템을 활용하면 복잡한 폼도 깔끔하게 관리할 수 있습니다.
공부하다 보니 폼 처리는 단순해 보이지만, 유효성 검사, 에러 핸들링, 동적 필드 추가까지 고려하면 상당히 복잡해집니다. 실전에서 자주 쓰는 패턴을 정리했습니다.
기본 폼 구성
<script setup lang="ts">
import { reactive, ref } from 'vue'
interface FormData {
username: string
email: string
password: string
confirmPassword: string
role: string
skills: string[]
newsletter: boolean
bio: string
}
const form = reactive<FormData>({
username: '',
email: '',
password: '',
confirmPassword: '',
role: 'developer',
skills: [],
newsletter: false,
bio: ''
})
const isSubmitting = ref(false)
const handleSubmit = async () => {
isSubmitting.value = true
try {
await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
alert('등록 완료!')
} catch (error) {
console.error('등록 실패:', error)
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<label for="username">사용자명</label>
<input id="username" v-model.trim="form.username" required />
</div>
<div>
<label for="email">이메일</label>
<input id="email" v-model.trim="form.email" type="email" required />
</div>
<div>
<label for="password">비밀번호</label>
<input id="password" v-model="form.password" type="password" required />
</div>
<div>
<label for="role">역할</label>
<select id="role" v-model="form.role">
<option value="developer">개발자</option>
<option value="designer">디자이너</option>
<option value="manager">매니저</option>
</select>
</div>
<fieldset>
<legend>기술 스택</legend>
<label><input type="checkbox" v-model="form.skills" value="vue" /> Vue</label>
<label><input type="checkbox" v-model="form.skills" value="react" /> React</label>
<label><input type="checkbox" v-model="form.skills" value="typescript" /> TypeScript</label>
</fieldset>
<div>
<label>
<input type="checkbox" v-model="form.newsletter" />
뉴스레터 구독
</label>
</div>
<div>
<label for="bio">자기소개</label>
<textarea id="bio" v-model="form.bio" rows="4"></textarea>
</div>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '처리 중...' : '등록' }}
</button>
</form>
</template>
실시간 유효성 검사
<script setup lang="ts">
import { reactive, computed } from 'vue'
const form = reactive({
email: '',
password: '',
confirmPassword: ''
})
const errors = reactive({
email: '',
password: '',
confirmPassword: ''
})
// 실시간 검사 규칙
const validateEmail = () => {
if (!form.email) {
errors.email = '이메일을 입력하세요'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errors.email = '유효한 이메일 형식이 아닙니다'
} else {
errors.email = ''
}
}
const validatePassword = () => {
if (!form.password) {
errors.password = '비밀번호를 입력하세요'
} else if (form.password.length < 8) {
errors.password = '8자 이상 입력하세요'
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(form.password)) {
errors.password = '대소문자와 숫자를 포함해야 합니다'
} else {
errors.password = ''
}
}
const validateConfirmPassword = () => {
if (form.confirmPassword !== form.password) {
errors.confirmPassword = '비밀번호가 일치하지 않습니다'
} else {
errors.confirmPassword = ''
}
}
const isFormValid = computed(() =>
!errors.email && !errors.password && !errors.confirmPassword &&
form.email && form.password && form.confirmPassword
)
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<input v-model="form.email" @blur="validateEmail" placeholder="이메일" />
<p v-if="errors.email" class="error">{{ errors.email }}</p>
</div>
<div>
<input v-model="form.password" @blur="validatePassword" type="password" placeholder="비밀번호" />
<p v-if="errors.password" class="error">{{ errors.password }}</p>
</div>
<div>
<input v-model="form.confirmPassword" @blur="validateConfirmPassword" type="password" placeholder="비밀번호 확인" />
<p v-if="errors.confirmPassword" class="error">{{ errors.confirmPassword }}</p>
</div>
<button :disabled="!isFormValid">제출</button>
</form>
</template>
동적 폼 필드
<script setup lang="ts">
import { reactive } from 'vue'
interface Education {
school: string
degree: string
year: number
}
const educations = reactive<Education[]>([
{ school: '', degree: '', year: 2024 }
])
const addEducation = () => {
educations.push({ school: '', degree: '', year: 2024 })
}
const removeEducation = (index: number) => {
if (educations.length > 1) {
educations.splice(index, 1)
}
}
</script>
<template>
<div v-for="(edu, index) in educations" :key="index" class="education-item">
<h4>학력 {{ index + 1 }}</h4>
<input v-model="edu.school" placeholder="학교명" />
<input v-model="edu.degree" placeholder="학위" />
<input v-model.number="edu.year" type="number" placeholder="졸업년도" />
<button @click="removeEducation(index)" :disabled="educations.length <= 1">삭제</button>
</div>
<button @click="addEducation">학력 추가</button>
</template>
파일 업로드
<script setup lang="ts">
import { ref } from 'vue'
const selectedFile = ref<File | null>(null)
const preview = ref<string>('')
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
selectedFile.value = file
// 이미지 미리보기
if (file.type.startsWith('image/')) {
const reader = new FileReader()
reader.onload = (e) => {
preview.value = e.target?.result as string
}
reader.readAsDataURL(file)
}
}
}
const uploadFile = async () => {
if (!selectedFile.value) return
const formData = new FormData()
formData.append('file', selectedFile.value)
await fetch('/api/upload', {
method: 'POST',
body: formData
})
}
</script>
<template>
<input type="file" accept="image/*" @change="handleFileChange" />
<img v-if="preview" :src="preview" alt="미리보기" style="max-width: 200px" />
<button @click="uploadFile" :disabled="!selectedFile">업로드</button>
</template>
면접 팁
- 폼 유효성 검사를 블러(blur) 시점 에 하는 이유: 사용자가 아직 입력 중인데 에러를 보여주면 UX가 나쁩니다
- 제어 컴포넌트 vs 비제어 컴포넌트 개념을 Vue에서 설명할 수 있으면 React 경험자와의 대화에서도 유용합니다
- FormData와 JSON 전송의 차이를 알고, 파일 업로드 시 FormData를 사용하는 이유를 설명할 수 있어야 합니다
요약
Vue의 폼 처리는 v-model로 양방향 바인딩하고, reactive/ref로 상태를 관리하며, blur 이벤트에서 유효성을 검사하는 것이 기본 패턴입니다. 동적 필드는 배열로 관리하고, 파일 업로드는 FormData를 사용합니다.
댓글 로딩 중...