006: 폼 처리 및 바인딩

기본 폼 바인딩

Svelte는 폼 요소와의 양방향 바인딩을 쉽게 구현할 수 있습니다. 다양한 입력 요소들과 함께 기본적인 폼 바인딩을 살펴보겠습니다.

관심사:

컴포넌트 코드:

<script lang="ts">
  let formData = $state({
    username: '',
    email: '',
    age: 0,
    newsletter: false,
    preference: 'light',
    interests: []
  });

  function handleSubmit() {
    alert('폼 데이터: ' + JSON.stringify(formData, null, 2));
  }

  let ids = $state({
    username: crypto.randomUUID(),
    email: crypto.randomUUID(),
    age: crypto.randomUUID(),
    preference: crypto.randomUUID(),
    newsletter: crypto.randomUUID()
  });
</script>

<div class="p-4 border border-orange-500 rounded">
  <form onsubmit={e => { e.preventDefault(); handleSubmit(); }}>
    <div class="space-y-4">
      <div>
        <label for={ids.username} class="block text-gray-200 mb-2">사용자명:</label>
        <input
          id={ids.username}
          type="text"
          bind:value={formData.username}
          class="w-full p-2 rounded bg-zinc-700 text-gray-200 border border-zinc-600"
        />
      </div>

      <div>
        <label for={ids.email} class="block text-gray-200 mb-2">이메일:</label>
        <input
          id={ids.email}
          type="email"
          bind:value={formData.email}
          class="w-full p-2 rounded bg-zinc-700 text-gray-200 border border-zinc-600"
        />
      </div>

      <div>
        <label for={ids.age} class="block text-gray-200 mb-2">나이:</label>
        <input
          id={ids.age}
          type="number"
          bind:value={formData.age}
          class="w-full p-2 rounded bg-zinc-700 text-gray-200 border border-zinc-600"
        />
      </div>

      <div>
        <label for={ids.newsletter} class="flex items-center space-x-2 text-gray-200">
          <input
            id={ids.newsletter}
            type="checkbox"
            bind:checked={formData.newsletter}
            class="form-checkbox text-orange-600"
          />
          <span>뉴스레터 구독</span>
        </label>
      </div>

      <div>
        <label for={ids.preference} class="block text-gray-200 mb-2">테마 선호도:</label>
        <select
          id={ids.preference}
          bind:value={formData.preference}
          class="w-full p-2 rounded bg-zinc-700 text-gray-200 border border-zinc-600"
        >
          <option value="light">라이트 모드</option>
          <option value="dark">다크 모드</option>
          <option value="system">시스템 설정</option>
        </select>
      </div>

      <div>
        <p class="block text-gray-200 mb-2">관심사:</p>
        <div class="space-y-2">
          {#each ['프론트엔드', '백엔드', '모바일', 'DevOps'] as interest}
            <label class="flex items-center space-x-2 text-gray-200">
              <input
                type="checkbox"
                bind:group={formData.interests}
                value={interest}
                class="form-checkbox text-orange-600"
              />
              <span>{interest}</span>
            </label>
          {/each}
        </div>
      </div>
    </div>

    <button
      type="submit"
      class="mt-6 bg-orange-600 text-white py-2 px-4 rounded hover:bg-orange-500"
    >
      제출
    </button>
  </form>
</div>

폼 유효성 검사

사용자 입력의 유효성을 검사하고 적절한 피드백을 제공하는 것은 중요합니다. Svelte에서 폼 유효성 검사를 구현하는 방법을 알아보겠습니다.

컴포넌트 코드:

<script lang="ts">
  let formData = $state({
    username: '',
    email: '',
    password: '',
    confirmPassword: ''
  });

  let errors = $state({
    username: '',
    email: '',
    password: '',
    confirmPassword: ''
  });

  let touched = $state({
    username: false,
    email: false,
    password: false,
    confirmPassword: false
  });

  let ids = $state({
    username: crypto.randomUUID(),
    email: crypto.randomUUID(),
    password: crypto.randomUUID(),
    confirmPassword: crypto.randomUUID()
  });

  function validateUsername() {
    if (!formData.username) {
      errors.username = '사용자명을 입력해주세요.';
    } else if (formData.username.length < 3) {
      errors.username = '사용자명은 3자 이상이어야 합니다.';
    } else {
      errors.username = '';
    }
  }

  function validateEmail() {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!formData.email) {
      errors.email = '이메일을 입력해주세요.';
    } else if (!emailRegex.test(formData.email)) {
      errors.email = '올바른 이메일 형식이 아닙니다.';
    } else {
      errors.email = '';
    }
  }

  function validatePassword() {
    if (!formData.password) {
      errors.password = '비밀번호를 입력해주세요.';
    } else if (formData.password.length < 8) {
      errors.password = '비밀번호는 8자 이상이어야 합니다.';
    } else {
      errors.password = '';
    }
  }

  function validateConfirmPassword() {
    if (!formData.confirmPassword) {
      errors.confirmPassword = '비밀번호를 다시 입력해주세요.';
    } else if (formData.confirmPassword !== formData.password) {
      errors.confirmPassword = '비밀번호가 일치하지 않습니다.';
    } else {
      errors.confirmPassword = '';
    }
  }

  function handleSubmit(e: Event) {
    e.preventDefault();
    touched.username = true;
    touched.email = true;
    touched.password = true;
    touched.confirmPassword = true;

    validateUsername();
    validateEmail();
    validatePassword();
    validateConfirmPassword();

    if (!errors.username && !errors.email && !errors.password && !errors.confirmPassword) {
      alert('폼 검증 성공!');
    }
  }
</script>

<div class="p-4 border border-orange-500 rounded">
  <form onsubmit={handleSubmit}>
    <div class="space-y-4">
      <div>
        <label for={ids.username} class="block text-gray-200 mb-2">사용자명:</label>
        <input
          id={ids.username}
          type="text"
          bind:value={formData.username}
          onblur={() => { touched.username = true; validateUsername(); }}
          class="w-full p-2 rounded bg-zinc-700 text-gray-200 border border-zinc-600 focus:border-orange-500"
        />
        {#if touched.username && errors.username}
          <p class="text-red-500 text-sm mt-1">{errors.username}</p>
        {/if}
      </div>

      <div>
        <label for={ids.email} class="block text-gray-200 mb-2">이메일:</label>
        <input
          id={ids.email}
          type="email"
          bind:value={formData.email}
          onblur={() => { touched.email = true; validateEmail(); }}
          class="w-full p-2 rounded bg-zinc-700 text-gray-200 border border-zinc-600 focus:border-orange-500"
        />
        {#if touched.email && errors.email}
          <p class="text-red-500 text-sm mt-1">{errors.email}</p>
        {/if}
      </div>

      <div>
        <label for={ids.password} class="block text-gray-200 mb-2">비밀번호:</label>
        <input
          id={ids.password}
          type="password"
          bind:value={formData.password}
          onblur={() => { touched.password = true; validatePassword(); }}
          class="w-full p-2 rounded bg-zinc-700 text-gray-200 border border-zinc-600 focus:border-orange-500"
        />
        {#if touched.password && errors.password}
          <p class="text-red-500 text-sm mt-1">{errors.password}</p>
        {/if}
      </div>

      <div>
        <label for={ids.confirmPassword} class="block text-gray-200 mb-2">비밀번호 확인:</label>
        <input
          id={ids.confirmPassword}
          type="password"
          bind:value={formData.confirmPassword}
          onblur={() => { touched.confirmPassword = true; validateConfirmPassword(); }}
          class="w-full p-2 rounded bg-zinc-700 text-gray-200 border border-zinc-600 focus:border-orange-500"
        />
        {#if touched.confirmPassword && errors.confirmPassword}
          <p class="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
        {/if}
      </div>
    </div>

    <button
      type="submit"
      class="mt-6 bg-orange-600 text-white py-2 px-4 rounded hover:bg-orange-500"
    >
      가입하기
    </button>
  </form>
</div>

커스텀 폼 컴포넌트

재사용 가능한 폼 컴포넌트를 만들어 일관된 사용자 경험을 제공하고 코드 중복을 줄일 수 있습니다.

컴포넌트 코드:

<script lang="ts">
  import FormInput from './form/FormInput.svelte';
  import FormSelect from './form/FormSelect.svelte';
  import FormTextarea from './form/FormTextarea.svelte';

  let formData = $state({
    title: '',
    category: 'general',
    description: '',
    priority: 'medium'
  });

  const categories = [
    { value: 'general', label: '일반' },
    { value: 'bug', label: '버그' },
    { value: 'feature', label: '기능 요청' },
    { value: 'enhancement', label: '개선 사항' }
  ];

  const priorities = [
    { value: 'low', label: '낮음' },
    { value: 'medium', label: '중간' },
    { value: 'high', label: '높음' }
  ];

  function handleSubmit() {
    alert('제출된 데이터:\n' + JSON.stringify(formData, null, 2));
  }
</script>

<div class="p-4 border border-orange-500 rounded">
  <form onsubmit={handleSubmit}>
    <div class="space-y-4">
      <FormInput
        label="제목"
        type="text"
        bind:value={formData.title}
        required
      />

      <FormSelect
        label="카테고리"
        options={categories}
        bind:value={formData.category}
      />

      <FormTextarea
        label="설명"
        bind:value={formData.description}
        rows={4}
      />

      <FormSelect
        label="우선순위"
        options={priorities}
        bind:value={formData.priority}
      />
    </div>

    <button
      type="submit"
      class="mt-6 bg-orange-600 text-white py-2 px-4 rounded hover:bg-orange-500"
    >
      이슈 생성
    </button>
  </form>
</div>

파일 업로드 처리

파일 업로드는 웹 애플리케이션의 중요한 기능입니다. Svelte에서 파일 업로드를 처리하는 방법을 살펴보겠습니다.

컴포넌트 코드:

<script lang="ts">
  let files = $state<FileList | null>(null);
  let previewUrl = $state('');
  let uploadStatus = $state('');
  let progress = $state(0);
  let id = $state(crypto.randomUUID());

  function handleFileSelect(event: Event) {
    const input = event.target as HTMLInputElement;
    if (input.files && input.files[0]) {
      files = input.files;
      const file = files[0];
      
      // 이미지 미리보기 생성
      if (file.type.startsWith('image/')) {
        const reader = new FileReader();
        reader.onload = (e) => {
          previewUrl = e.target?.result as string;
        };
        reader.readAsDataURL(file);
      } else {
        previewUrl = '';
      }
    }
  }

  function simulateUpload() {
    if (!files) return;
    
    const file = files[0];
    uploadStatus = '업로드 중...';
    progress = 0;

    // 업로드 시뮬레이션
    const interval = setInterval(() => {
      progress += 10;
      if (progress >= 100) {
        clearInterval(interval);
        uploadStatus = '업로드 완료!';
        setTimeout(() => {
          uploadStatus = '';
          progress = 0;
          files = null;
          previewUrl = '';
        }, 2000);
      }
    }, 500);
  }
</script>

<div class="p-4 border border-orange-500 rounded">
  <div class="space-y-4">
    <div>
      <label for={id} class="block text-gray-200 mb-2">파일 선택:</label>
      <input
        {id}
        type="file"
        accept="image/*"
        onchange={handleFileSelect}
        class="block w-full text-gray-200 
               file:mr-4 file:py-2 file:px-4
               file:rounded file:border-0
               file:text-sm file:font-semibold
               file:bg-orange-600 file:text-white
               hover:file:bg-orange-500"
      />
    </div>

    {#if previewUrl}
      <div>
        <p class="text-gray-200 mb-2">미리보기:</p>
        <img src={previewUrl} alt="Preview" class="max-w-xs rounded" />
      </div>
    {/if}

    {#if files}
      <div>
        <button
          onclick={simulateUpload}
          class="bg-orange-600 text-white py-2 px-4 rounded hover:bg-orange-500"
        >
          업로드
        </button>
      </div>
    {/if}

    {#if uploadStatus}
      <div>
        <p class="text-gray-200">{uploadStatus}</p>
        <div class="w-full bg-zinc-700 rounded h-2 mt-2">
          <div
            class="bg-orange-600 h-2 rounded"
            style="width: {progress}%"
          ></div>
        </div>
      </div>
    {/if}
  </div>
</div>

동적 폼 필드

사용자의 입력에 따라 동적으로 폼 필드를 생성하고 관리하는 방법을 학습합니다.

컴포넌트 코드:

<script lang="ts">
  interface Question {
    id: number;
    type: 'text' | 'radio' | 'checkbox';
    question: string;
    options?: string[];
    answer: string | string[];
  }

  let questions = $state<Question[]>([
    {
      id: 1,
      type: 'text',
      question: '이름을 입력해주세요.',
      answer: ''
    }
  ]);

  let nextId = $state(2);

  function addQuestion(type: Question['type']) {
    const newQuestion: Question = {
      id: nextId,
      type,
      question: '',
      answer: type === 'checkbox' ? [] : '',
      options: type !== 'text' ? ['옵션 1'] : undefined
    };
    
    questions = [...questions, newQuestion];
    nextId++;
  }

  function removeQuestion(id: number) {
    questions = questions.filter(q => q.id !== id);
  }

  function addOption(questionId: number) {
    questions = questions.map(q => {
      if (q.id === questionId && q.options) {
        return {
          ...q,
          options: [...q.options, `옵션 ${q.options.length + 1}`]
        };
      }
      return q;
    });
  }

  function removeOption(questionId: number, optionIndex: number) {
    questions = questions.map(q => {
      if (q.id === questionId && q.options) {
        return {
          ...q,
          options: q.options.filter((_, i) => i !== optionIndex)
        };
      }
      return q;
    });
  }

  function handleSubmit() {
    alert('제출된 답변:\n' + JSON.stringify(questions, null, 2));
  }
</script>

<div class="p-4 border border-orange-500 rounded">
  <form onsubmit={e => { e.preventDefault(); handleSubmit(); }}>
    <div class="space-y-6">
      {#each questions as question (question.id)}
        <div class="p-4 border border-zinc-600 rounded">
          <div class="flex justify-between items-start mb-4">
            <input
              type="text"
              bind:value={question.question}
              placeholder="질문을 입력하세요"
              class="flex-1 p-2 rounded bg-zinc-700 text-gray-200 border border-zinc-600 mr-2"
            />
            <button
              type="button"
              onclick={() => removeQuestion(question.id)}
              class="text-red-500 hover:text-red-400"
            >
              삭제
            </button>
          </div>

          {#if question.type === 'text'}
            <input
              type="text"
              bind:value={question.answer}
              placeholder="답변을 입력하세요"
              class="w-full p-2 rounded bg-zinc-700 text-gray-200 border border-zinc-600"
            />
          {:else}
            <div class="space-y-2">
              {#each question.options || [] as option, i}
                <div class="flex items-center space-x-2">
                  {#if question.type === 'radio'}
                    <input
                      type="radio"
                      bind:group={question.answer}
                      value={option}
                      class="text-orange-600"
                    />
                  {:else}
                    <input
                      type="checkbox"
                      bind:group={question.answer}
                      value={option}
                      class="text-orange-600"
                    />
                  {/if}
                  <input
                    type="text"
                    bind:value={question.options![i]}
                    class="flex-1 p-2 rounded bg-zinc-700 text-gray-200 border border-zinc-600"
                  />
                  <button
                    type="button"
                    onclick={() => removeOption(question.id, i)}
                    class="text-red-500 hover:text-red-400"
                  >
                    삭제
                  </button>
                </div>
              {/each}
              <button
                type="button"
                onclick={() => addOption(question.id)}
                class="text-orange-600 hover:text-orange-500"
              >
                + 옵션 추가
              </button>
            </div>
          {/if}
        </div>
      {/each}
    </div>

    <div class="mt-4 space-x-2">
      <button
        type="button"
        onclick={() => addQuestion('text')}
        class="bg-orange-600 text-white py-2 px-4 rounded hover:bg-orange-500"
      >
        + 텍스트 질문
      </button>
      <button
        type="button"
        onclick={() => addQuestion('radio')}
        class="bg-orange-600 text-white py-2 px-4 rounded hover:bg-orange-500"
      >
        + 단일 선택
      </button>
      <button
        type="button"
        onclick={() => addQuestion('checkbox')}
        class="bg-orange-600 text-white py-2 px-4 rounded hover:bg-orange-500"
      >
        + 다중 선택
      </button>
    </div>

    <button
      type="submit"
      class="mt-6 bg-green-600 text-white py-2 px-4 rounded hover:bg-green-500"
    >
      제출
    </button>
  </form>
</div>

실습 과제

고급 설문조사 폼 만들기

지금까지 배운 폼 처리 개념을 활용하여 다음 기능이 있는 설문조사 폼을 구현해보세요:

  • 다양한 타입의 입력 필드 (텍스트, 체크박스, 라디오 등)
  • 동적으로 추가/제거 가능한 질문
  • 실시간 유효성 검사
  • 진행 상태 표시
  • 데이터 저장 및 불러오기

다음 강의 미리보기

7강: 애니메이션 및 트랜지션

다음 강의에서는 Svelte의 애니메이션과 트랜지션 기능을 자세히 살펴봅니다:

  • 컴포넌트 라이프사이클
  • 트랜지션 및 애니메이션
  • 모션 라이브러리 활용
  • 인터랙티브 UI 구현
  • CSS 애니메이션과 Svelte 통합
  • 커스텀 트랜지션 함수 작성
  • 애니메이션 성능 최적화

© 2024 Coding Stairs. All rights reserved.