Skip to main content

26장. 입력 폼과 버튼 만들기

[5부] 입력 폼과 버튼 만들기

[5부] 입력 폼과 버튼 만들기

[5부] 입력 폼과 버튼 만들기

화면은 보여주는 곳이 아니라, 반응하는 곳이다

앞 장에서 우리는 화면을 컴포넌트로 나누는 감각을 익혔다. 이제 그 화면에 생명을 넣을 차례다. 웹 서비스는 예쁜 화면만으로는 완성되지 않는다. 사용자가 무언가를 입력하고, 버튼을 누르고, 그 결과가 바뀌어야 비로소 서비스처럼 느껴진다.

생각해보면 우리가 매일 쓰는 서비스는 모두 입력으로 시작한다. 검색창에 단어를 넣고, 로그인 폼에 이메일과 비밀번호를 적고, 글쓰기 화면에 제목과 내용을 입력한다. 그리고 저장, 검색, 수정, 삭제 같은 버튼을 눌러 다음 행동을 만든다. 결국 웹 서비스는 사용자의 입력을 받아 반응하는 시스템이다.

이번 장에서는 이 가장 기본적인 상호작용, 즉 입력 폼과 버튼을 만드는 방법을 익힌다. 여기서 중요한 것은 단지 태그 몇 개를 외우는 일이 아니다. 사용자가 값을 바꾸면 화면 안의 데이터도 함께 바뀐다는 감각, 버튼은 단순한 장식이 아니라 행동의 출발점이라는 감각을 잡는 것이다.

입력 폼은 사용자와 대화하는 창구다

입력 폼은 사용자의 생각을 서비스 안으로 들여오는 통로다. 사용자는 폼을 통해 자신의 의도를 전달한다. 글 제목을 적고, 내용을 쓰고, 검색어를 넣고, 회원가입 정보를 입력한다. 우리가 만드는 서비스가 사용자를 이해하려면 먼저 이 입력을 받을 수 있어야 한다.

프론트엔드에서 자주 쓰는 대표적인 입력 요소는 몇 가지가 있다.

  • input: 한 줄 입력 칸이다. 제목, 이메일, 검색어처럼 짧은 값을 받을 때 많이 쓴다.

  • textarea: 여러 줄 입력 칸이다. 글 내용, 메모, 소개글처럼 긴 텍스트에 적합하다.

  • button: 사용자가 어떤 행동을 시작할 때 누르는 요소다.

아주 간단한 글쓰기 화면의 뼈대는 이렇게 시작할 수 있다.

export default function PostForm() {
  return (
    <form>
      <label htmlFor="title">제목</label>
      <input id="title" name="title" type="text" />

      <label htmlFor="content">내용</label>
      <textarea id="content" name="content" rows={6} />

      <button type="submit">저장하기</button>
    </form>
  )
}

이 코드는 아직 똑똑하지 않다. 칸은 보이지만, 사용자가 무엇을 입력했는지 우리가 기억하지는 못한다. 즉, 대화 창구는 열었지만 아직 대화를 듣고 있지는 않은 상태다.

입력값을 기억하게 만드는 것이 상태다

입력 폼을 만들 때 초보자가 처음 만나는 중요한 개념이 바로 상태(state) 다. 어렵게 들리지만 뜻은 단순하다. 지금 화면이 기억하고 있는 값이라고 생각하면 된다.

예를 들어 제목 입력 칸에 오늘의 회고라고 적었다면, 화면 어딘가에는 지금 제목이 오늘의 회고라는 사실이 저장되어 있어야 한다. 그래야 저장 버튼을 눌렀을 때 그 값을 서버로 보내거나, 미리보기로 보여주거나, 빈칸인지 검사할 수 있다.

React와 Next.js에서는 보통 useState를 이용해 이런 값을 기억한다.

'use client'

import { useState } from 'react'

export default function PostForm() {
  const [title, setTitle] = useState('')
  const [content, setContent] = useState('')

  return (
    <form>
      <label htmlFor="title">제목</label>
      <input
        id="title"
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />

      <label htmlFor="content">내용</label>
      <textarea
        id="content"
        rows={6}
        value={content}
        onChange={(e) => setContent(e.target.value)}
      />

      <button type="submit">저장하기</button>
    </form>
  )
}

여기서 꼭 이해해야 할 흐름은 이렇다.

사용자가 키보드로 값을 입력한다. 그러면 onChange가 실행된다. 그 안에서 setTitle 또는 setContent가 호출된다. 그러면 상태가 바뀐다. 상태가 바뀌면 화면도 다시 그려진다.

처음 보면 조금 빙 돌아가는 것처럼 느껴질 수 있다. 하지만 이 방식 덕분에 화면과 데이터가 따로 놀지 않는다. 사용자가 입력한 값이 현재 무엇인지 프론트엔드가 항상 알고 있게 된다.

쉽게 비유하면, 종이에 적힌 내용을 그냥 바라보는 것이 아니라 옆에서 비서가 실시간으로 받아 적고 있는 상태라고 생각하면 된다. 사용자가 한 글자 바꿀 때마다, 화면도 그 변화를 바로 기억한다.

use client가 필요한 이유

Next.js를 처음 접한 독자는 파일 맨 위의 use client가 낯설 수 있다. 이 한 줄은 "이 컴포넌트는 브라우저에서 동작해야 합니다"라는 선언이다.

입력 폼, 버튼 클릭, 상태 변화처럼 사용자와 직접 상호작용하는 기능은 브라우저에서 즉시 반응해야 한다. 그래서 이런 컴포넌트에는 보통 use client가 필요하다. 반대로 단순히 데이터를 보여주기만 하는 화면은 서버 컴포넌트로도 만들 수 있다.

지금 단계에서는 이렇게 기억하면 충분하다.

폼 입력을 받고, 버튼을 누르고, 상태를 바꾸는 컴포넌트라면 우선 클라이언트 컴포넌트라고 생각하자.

버튼은 "클릭하는 장식"이 아니라 행동의 시작점이다

버튼은 단순히 네모난 UI 요소가 아니다. 버튼은 사용자의 결정을 시스템에 전달하는 스위치다. 저장하기, 취소하기, 삭제하기, 검색하기, 로그인하기 같은 행동은 대부분 버튼에서 시작한다.

가장 간단한 버튼은 이렇게 만들 수 있다.

<button type="button">미리보기</button>

그런데 버튼은 누르는 순간 아무 일도 일어나지 않는다. 우리가 어떤 행동을 연결해줘야 한다. 예를 들어 버튼을 누르면 현재 입력한 내용을 콘솔에 출력하게 만들 수 있다.

<button
  type="button"
  onClick={() => {
    console.log('제목:', title)
    console.log('내용:', content)
  }}
>
  미리보기
</button>

이제 버튼은 단지 보이는 요소가 아니라 실제 행동을 일으키는 장치가 된다.

여기서 type 속성도 눈여겨볼 필요가 있다. 초보자가 자주 헷갈리는 부분이기 때문이다.

  • type="button"은 그냥 일반 버튼이다.

  • type="submit"은 폼 전송 버튼이다.

  • type="reset"은 폼을 초기화하는 버튼이지만 실무에서는 조심해서 쓴다.

폼 안에 버튼을 넣을 때 type을 명확하게 써두는 습관은 정말 중요하다. 아무 생각 없이 버튼을 넣었는데 폼이 갑자기 제출되어 버리는 상황을 많이 겪게 되기 때문이다.

폼은 입력과 제출을 하나의 흐름으로 묶어준다

입력 칸과 버튼을 하나의 form으로 감싸는 이유는 단순히 HTML 문법 때문만이 아니다. form은 "이 입력들은 하나의 작업입니다"라고 브라우저에 알려준다.

예를 들어 글 작성 폼이라면 제목과 내용은 서로 따로따로가 아니라 하나의 게시글을 만드는 정보다. 이때 저장 버튼을 누르면 폼 전체를 제출하는 흐름으로 처리하는 것이 자연스럽다.

다음 코드를 보자.

'use client'

import { useState } from 'react'

export default function PostForm() {
  const [title, setTitle] = useState('')
  const [content, setContent] = useState('')

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()

    console.log({ title, content })
    alert('입력값을 저장할 준비가 되었습니다.')
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="title">제목</label>
      <input
        id="title"
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="제목을 입력하세요"
      />

      <label htmlFor="content">내용</label>
      <textarea
        id="content"
        rows={6}
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="내용을 입력하세요"
      />

      <button type="submit">저장하기</button>
    </form>
  )
}

여기서 핵심은 onSubmit과 e.preventDefault()다.

브라우저는 원래 폼이 제출되면 페이지를 새로고침하거나 다른 페이지로 이동하려고 한다. 하지만 우리가 만드는 현대적인 웹 앱에서는 그 전에 직접 해야 할 일이 많다. 값 검사도 해야 하고, 서버로 요청도 보내야 하고, 에러 처리도 해야 한다. 그래서 preventDefault()로 기본 동작을 막고, 우리가 원하는 흐름을 직접 제어한다.

즉, "저장 버튼을 눌렀다"는 사건을 우리 앱이 주도적으로 처리하게 되는 것이다.

입력값 검사는 사용자를 혼내는 일이 아니라 돕는 일이다

폼을 만들면 곧바로 마주치는 문제가 있다. 사용자가 아무것도 입력하지 않고 저장을 누를 수 있다는 점이다. 서비스 입장에서는 당연히 곤란하다. 제목 없는 글, 내용 없는 글이 계속 쌓이면 데이터도 엉망이 된다.

그래서 입력값 검사가 필요하다. 하지만 이 검사는 사용자를 막기 위한 장벽이 아니라, 실수를 줄여주는 안내에 가깝다.

가장 기본적인 검사는 빈칸 확인이다.

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault()

  if (!title.trim() || !content.trim()) {
    alert('제목과 내용을 모두 입력해주세요.')
    return
  }

  alert('저장되었습니다.')
}

여기서 trim()은 앞뒤 공백을 제거해준다. 사용자가 스페이스만 입력한 경우도 빈칸처럼 처리할 수 있게 해준다.

이 단계에서는 검사를 너무 복잡하게 만들 필요는 없다. 오히려 초보자일수록 "어떤 경우를 막아야 하는가"를 간단히 생각하는 것이 좋다. 예를 들면 이런 정도면 충분하다.

  • 제목은 비어 있으면 안 된다.

  • 내용은 비어 있으면 안 된다.

  • 너무 짧은 입력은 저장 전에 한 번 더 안내한다.

이 정도만 지켜도 폼이 훨씬 서비스답게 보이기 시작한다.

버튼 상태가 바뀌면 사용자는 안심한다

사용자는 버튼을 누른 뒤 아무 반응이 없으면 불안해한다. "눌린 건가?", "또 눌러야 하나?", "멈춘 건가?" 같은 생각이 바로 든다. 그래서 버튼은 클릭만 받는 것이 아니라 현재 상태를 보여주는 역할도 해야 한다.

예를 들어 저장 중일 때 버튼 글자를 바꾸고, 잠시 비활성화할 수 있다.

'use client'

import { useState } from 'react'

export default function PostForm() {
  const [title, setTitle] = useState('')
  const [content, setContent] = useState('')
  const [isSubmitting, setIsSubmitting] = useState(false)

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()

    if (!title.trim() || !content.trim()) {
      alert('제목과 내용을 모두 입력해주세요.')
      return
    }

    setIsSubmitting(true)

    await new Promise((resolve) => setTimeout(resolve, 1000))

    setIsSubmitting(false)
    alert('저장이 완료되었습니다.')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="제목"
      />

      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="내용"
      />

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '저장 중...' : '저장하기'}
      </button>
    </form>
  )
}

아주 작은 변화 같지만 사용자 경험은 크게 달라진다. 서비스가 현재 무슨 일을 하고 있는지 보여주기 때문이다.

초보자가 만들기 쉬운 첫 폼과, 실제로 쓰고 싶은 폼의 차이는 이런 데서 생긴다. 버튼 하나에도 지금 가능한지, 진행 중인지, 완료되었는지를 드러내면 화면이 훨씬 믿음직해진다.

입력 폼은 예쁘기보다 먼저 읽기 쉬워야 한다

폼을 만들다 보면 디자인에 마음이 급해질 수 있다. 색을 넣고 싶고, 모서리를 둥글게 하고 싶고, 그림자도 주고 싶다. 물론 중요하다. 하지만 폼에서 더 중요한 것은 읽기 쉬움과 실수하지 않게 하는 구조다.

좋은 폼은 사용자가 고민하지 않게 만든다. 제목 칸인지, 내용 칸인지, 무엇을 입력해야 하는지, 저장은 어디서 하는지 한눈에 이해되어야 한다.

예를 들어 이런 요소들이 기본기를 만든다.

  • 레이블이 분명한가

  • placeholder가 힌트를 주는가

  • 입력 간격이 답답하지 않은가

  • 버튼 문구가 행동을 정확히 설명하는가

확인 같은 모호한 버튼보다 저장하기, 글 등록하기, 수정 완료처럼 결과가 보이는 문구가 더 좋다. 사용자는 버튼을 누르기 전에 그 결과를 예상할 수 있어야 한다.

작은 미리보기만 있어도 폼이 훨씬 재미있어진다

입력 폼을 연습할 때는 사용자가 입력한 값이 화면에 바로 반영되도록 만들어보면 좋다. 상태의 개념이 눈에 보이기 때문이다.

'use client'

import { useState } from 'react'

export default function PostForm() {
  const [title, setTitle] = useState('')
  const [content, setContent] = useState('')

  return (
    <div>
      <form>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="제목"
        />

        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder="내용"
        />

        <button type="button">저장하기</button>
      </form>

      <hr />

      <section>
        <h3>미리보기</h3>
        <p>제목: {title || '아직 제목이 없습니다.'}</p>
        <p>내용: {content || '아직 내용이 없습니다.'}</p>
      </section>
    </div>
  )
}

이렇게 해보면 상태가 추상적인 개념이 아니라 눈앞에서 움직이는 값이라는 사실이 확실하게 느껴진다. 한 글자 입력할 때마다 화면이 반응한다. 바로 이 감각이 앞으로 검색, 필터, 로그인, 글쓰기, 수정 기능을 만들 때 계속 이어진다.

우리가 만드는 서비스에 맞춰 생각해보자

이 책의 실습 서비스에서는 앞으로 글 작성, 수정, 삭제 기능을 만들게 된다. 그 출발점이 바로 지금 배우는 폼이다.

예를 들어 글 작성 화면에는 보통 이런 요소가 들어간다.

  • 제목 입력 칸

  • 내용 입력 칸

  • 저장 버튼

  • 취소 버튼

조금 더 발전하면 태그 입력, 공개 여부 선택, 이미지 업로드 버튼도 붙을 수 있다. 하지만 핵심 구조는 바뀌지 않는다. 사용자가 값을 입력하고, 상태가 그 값을 기억하고, 버튼이 행동을 시작한다. 이 기본 흐름만 튼튼하면 기능이 늘어나도 겁먹지 않게 된다.

초보자가 실수하는 지점은 대개 "기능이 많아 보여서 어려워 보이는 것"이지, 실제 핵심이 어려운 것은 아니다. 로그인 폼도 결국 입력 두 개와 버튼 하나에서 시작한다. 검색 기능도 입력 하나와 버튼 하나, 또는 입력 변화에 반응하는 상태 하나에서 시작한다. 폼을 이해하면 서비스의 절반이 훨씬 친숙해진다.

AI에게 폼 작업을 맡길 때는 구조를 먼저 말해주자

AI 코딩 도구와 함께 작업할 때 폼은 특히 잘 맡길 수 있는 작업이다. 다만 "예쁜 폼 만들어줘"처럼 너무 넓게 지시하면 결과가 들쭉날쭉해질 수 있다. 초보자일수록 원하는 구조를 먼저 짧고 분명하게 말하는 편이 좋다.

예를 들어 이렇게 요청할 수 있다.

  • 제목과 내용을 입력받는 글 작성 폼 컴포넌트를 만들어줘.

  • React useState를 사용해서 입력값을 관리해줘.

  • 제목이나 내용이 비어 있으면 저장되지 않도록 해줘.

  • 저장 중에는 버튼 텍스트를 저장 중...으로 바꿔줘.

이런 식으로 요청하면 AI도 어디까지 구현해야 하는지 분명히 이해한다.

반대로 결과를 받았을 때는 꼭 확인해야 한다. 상태 이름이 읽기 쉬운지, button의 type이 맞는지, 제출 시 페이지가 새로고침되지 않는지, 빈칸 검사 로직이 들어갔는지를 살펴보면 된다.

AI는 빠르게 초안을 만드는 데 강하지만, "이 폼이 정말 내가 원하는 흐름으로 움직이는가"를 판단하는 것은 결국 사람의 역할이다.

초보자가 자주 겪는 실수

입력 폼과 버튼을 만들 때 자주 만나는 실수는 꽤 비슷하다. 미리 알고 있으면 덜 당황한다.

첫째, value는 넣었는데 onChange를 넣지 않는 경우다. 그러면 입력 칸이 잠겨버린 것처럼 보인다. 값은 연결했지만 바꿀 방법을 주지 않았기 때문이다.

둘째, 폼 안 버튼의 type을 쓰지 않는 경우다. 이때 의도치 않게 제출이 일어날 수 있다. 버튼은 가능한 한 type="button" 또는 type="submit"을 명확히 써주는 습관이 좋다.

셋째, 제출 시 페이지가 새로고침되어 입력값이 사라지는 경우다. 보통 preventDefault()를 빠뜨렸을 때 생긴다.

넷째, 상태는 바뀌는데 화면 문구가 어색한 경우다. 예를 들어 저장 중인데도 버튼이 계속 저장하기라고 적혀 있으면 사용자 입장에서는 헷갈린다. 상태 변화는 가능하면 UI에도 보이게 하자.

이런 문제들은 대단한 버그라기보다, 인터랙션을 처음 만들 때 누구나 한 번쯤 겪는 과정이다. 중요한 것은 "왜 이런 일이 생겼는가"를 이해하는 것이다.

지금은 로컬 상태만, 다음에는 서버와 연결한다

이번 장에서는 사용자가 입력한 값을 화면 안에서 관리하는 데 집중했다. 아직은 저장 버튼을 눌러도 진짜 데이터베이스에 들어가지는 않는다. 말하자면 연습장에 써보는 단계다.

하지만 이 연습은 절대 헛되지 않는다. 나중에 서버와 연결할 때도 흐름은 같다. 사용자가 입력한다. 상태가 값을 기억한다. 제출한다. 검사한다. 서버로 보낸다. 성공하면 화면을 갱신하고, 실패하면 에러를 보여준다.

즉, 지금 배우는 폼은 나중에 만들 회원가입, 로그인, 글 작성, 수정 화면의 공통 뼈대다. 여기서 감각을 잡아두면 뒤로 갈수록 훨씬 편해진다.

이번 장의 핵심 정리

이번 장에서 꼭 붙잡아야 할 것은 문법 몇 줄이 아니라 흐름이다.

입력 폼은 사용자의 의도를 받는 창구다. 상태는 그 입력값을 기억한다. 버튼은 행동을 시작한다. 폼 제출은 여러 입력을 하나의 작업으로 묶어준다. 그리고 좋은 폼은 단지 동작하는 것을 넘어, 사용자가 지금 무엇을 해야 하는지 자연스럽게 이해하게 만든다.

이 감각만 잡히면 웹 서비스가 갑자기 덜 낯설어진다. 서비스는 거대한 마법 상자가 아니라, 입력을 받고 반응을 돌려주는 구조라는 사실이 보이기 시작하기 때문이다.

다음 장에서는 이 입력과 상태의 감각을 바탕으로, 실제 서비스에서 자주 보는 목록 화면과 상세 화면을 만들어볼 것이다. 사용자가 입력한 데이터가 어디에 쌓이고, 어떻게 나열되고, 어떻게 하나를 골라 자세히 보는지까지 연결되면, 이제 정말 서비스다운 화면이 모습을 드러내기 시작한다.

책 목록

바이브 코딩으로 완성하는 첫 풀스택 웹 서비스

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기