폼은 웹 애플리케이션에서 사용자 입력을 받는 가장 기본적인 방법입니다. Vue의 v-model과 반응형 시스템을 활용하면 복잡한 폼도 깔끔하게 관리할 수 있습니다.

공부하다 보니 폼 처리는 단순해 보이지만, 유효성 검사, 에러 핸들링, 동적 필드 추가까지 고려하면 상당히 복잡해집니다. 실전에서 자주 쓰는 패턴을 정리했습니다.


기본 폼 구성

VUE
<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>

실시간 유효성 검사

VUE
<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>

동적 폼 필드

VUE
<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>

파일 업로드

VUE
<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를 사용합니다.

댓글 로딩 중...