문서 읽는 데 77분 · B5

B-5: 폼 & 입력 — 값을 누가 들고 있나

목차 35
전체 13강 중 11강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구입니다. 지난 시간까지 여섯 번에 걸쳐 TypeScript 를 붙였죠. 오늘은 다시 화면으로 돌아옵니다.

사실 우리는 이미 폼을 하나 만들어 뒀습니다. 댓글 입력창이요. 그 폼이 하는 검사가 뭐였는지 떠올려 보세요.

tsx
const isEmpty = content.trim() === '';

빈 값만 막았습니다. 그게 전부였어요. 댓글 하나에는 그걸로 충분했고요.

그런데 회원가입 폼이라면 어떨까요. 사용자 이름은 영문 소문자와 숫자로 4자 이상이어야 하고, 이메일은 이메일 모양이어야 하고, 비밀번호는 여덟 자를 넘어야 하고, 비밀번호 확인은 위 칸과 같아야 합니다. 규칙이 넷이고 필드도 넷이에요.

여기에 조건이 더 붙습니다. 틀리면 그 칸 아래에 메시지가 떠야 하고, 사용자가 고치면 메시지가 사라져야 하고, 아직 건드리지도 않은 칸에 빨간 글씨를 미리 띄우면 안 됩니다. 제출 버튼을 두 번 누르면 두 번 가입되면 안 되고요.

이걸 useState 로 하나씩 만들면 어떻게 될까요. 오늘 직접 만들어 보고, 그다음 그 코드를 통째로 걷어낼 겁니다.

텍스트
 오늘의 여정

 Step 1     값을 누가 드나 — 상태냐 DOM 이냐
    │
 Step 2     useRef 로 DOM 에 직접 명령하기
    │
 Step 3     손으로 만든 회원가입 폼과 그 한계
    │
 Step 4     React Hook Form 으로 다시 쓰기
    │
 Step 5     규칙을 달고 메시지를 띄우기
    
 검증이 붙은 인스타그램 회원가입 폼

💡 오늘 수업의 핵심 — "폼은 값을 누가 드느냐로 갈린다"

지금까지 우리가 만든 입력창은 값을 React 상태가 들고 있었습니다. 값을 DOM 에 맡기는 방법도 있어요. 오늘 배울 React Hook Form 이 바로 그 방법 위에 서 있습니다.

🎯 학습 목표

  • Controlled 와 Uncontrolled 입력창을 구분하고, 값을 상태가 들 때와 DOM 이 들 때의 차이를 설명할 수 있다
  • useRef 로 DOM 에 직접 명령을 내리고, React 의 선언적 방식으로는 표현할 수 없는 일을 알아본다
  • React Hook Form 으로 회원가입 폼을 만들고, 필드마다 검증 규칙과 에러 메시지를 붙일 수 있다

Step 1: "값을 누가 들고 있나 — Controlled 와 Uncontrolled"

B-2 에서 처음 입력창을 만들 때 우리는 이렇게 썼습니다. 지금 CommentInput 도 같은 모습이에요.

tsx
// apps/web-spa/src/components/CommentInput.tsx
<input
  className="comment-input"
  ref={ref}
  value={value}
  onChange={onChange}
  placeholder="댓글 달기..."
  aria-label="댓글 입력"
/>

valueonChange 가 짝을 이루고 있죠. 이 방식에는 이름이 있습니다. Controlled(제어 컴포넌트)라고 불러요. 화면에 보이는 글자를 React 상태가 결정한다는 뜻입니다.

동작 순서를 따라가 봅시다. 사용자가 을 칩니다. 브라우저가 onChange 를 부르고, 우리가 setContent 를 부르고, 상태가 바뀌니 컴포넌트가 다시 그려지고, 그제야 value 이 들어가서 화면에 보입니다.

한 글자 칠 때마다 이 한 바퀴가 통째로 돕니다. 글자가 화면에 나타나는 것조차 React 를 거쳐요.

반대쪽 방식도 있습니다. 값을 DOM 에게 맡기고, 우리는 필요할 때만 꺼내 오는 겁니다. 이걸 Uncontrolled(비제어 컴포넌트)라고 합니다.

두 방식을 나란히 놓고 직접 비교해 봅시다.

tsx
// apps/web-spa/src/components/InputValueDemo.tsx
import { useRef, useState } from 'react';
import { Button } from './Button';

// 값을 상태가 들고 있는 입력창. 화면에 보이는 글자는 언제나 nickname 이다.
// 한 글자 칠 때마다 이 함수가 다시 불리는지 콘솔로 확인한다.
export function ControlledInput() {
  console.log('상태가 값을 든 쪽이 다시 그려짐');

  const [nickname, setNickname] = useState('');

  return (
    <section className="input-demo" aria-label="상태가 값을 든 입력창">
      <h3>상태가 값을 든다</h3>
      <input
        className="input-demo-field"
        aria-label="controlled 닉네임"
        value={nickname}
        onChange={(event) => setNickname(event.target.value)}
      />
      <p>지금 값: {nickname}</p>
    </section>
  );
}

Uncontrolled 쪽은 이렇게 씁니다. value 대신 defaultValue 를 주고, onChange 를 아예 달지 않아요.

tsx
// 값을 DOM 이 들고 있는 입력창. 우리는 필요할 때 손잡이로 꺼내 온다.
export function UncontrolledInput() {
  console.log('DOM 이 값을 든 쪽이 다시 그려짐');

  const nicknameRef = useRef<HTMLInputElement>(null);
  const [readValue, setReadValue] = useState('');

  return (
    <section className="input-demo" aria-label="DOM 이 값을 든 입력창">
      <h3>DOM 이 값을 든다</h3>
      <input
        className="input-demo-field"
        aria-label="uncontrolled 닉네임"
        defaultValue=""
        ref={nicknameRef}
      />
      <Button onClick={() => setReadValue(nicknameRef.current?.value ?? '')}>값 읽기</Button>
      <p>읽어온 값: {readValue}</p>
    </section>
  );
}

defaultValue처음 한 번만 쓰이는 시작값입니다. 그 뒤로 사용자가 무엇을 치든 React 는 관여하지 않아요. 값은 DOM 안에 쌓이고, 우리는 nicknameRef.current.value 로 꺼내 옵니다.

두 컴포넌트를 화면에 올려서 직접 확인해 보세요. App.tsx<Section title="피드"> 위에 이렇게 잠깐 넣으면 됩니다.

tsx
// apps/web-spa/src/App.tsx — 확인이 끝나면 지워주세요
import { InputValueDemo } from './components/InputValueDemo';

// ... App 안 return 의 <Section title="피드"> 바로 위
<InputValueDemo />

개발자 도구 콘솔을 열고 왼쪽 칸에 abc 세 글자를 쳐 보세요.

콘솔에 찍히는 줄 수

새로고침하면 상태가 값을 든 쪽이 다시 그려짐두 줄 찍힙니다. 개발 모드에서 React 가 일부러 두 번 돌린다는 건 B-4 에서 배웠죠. 여기서도 같습니다.

그다음 abc 를 치면 어떻게 될까요.

무엇을 했나 상태가 든 쪽 DOM 이 든 쪽
새로고침(처음 붙을 때) 2줄 2줄
abc 세 글자를 친 뒤 (누적) 8줄 2줄

왼쪽은 한 글자마다 두 줄씩 늘어 여덟 줄이 됩니다. 오른쪽은 세 글자를 쳐도 처음 두 줄 그대로예요. 아예 다시 그려지지 않습니다.

그런데 오른쪽 화면에는 글자가 잘 보이죠? 우리가 안 그렸는데도요. 브라우저가 그린 겁니다. 원래 <input> 은 React 없이도 글자를 받아 보여주는 요소니까요.

값 읽기 버튼을 눌러 보세요. 그제야 읽어온 값: abc 가 뜹니다. 값은 처음부터 DOM 안에 있었고, 우리가 꺼내지 않았을 뿐이에요.

그럼 어느 쪽을 써야 하나

⚠️ 오해하기 쉬운데, Uncontrolled 가 더 좋은 방식이라는 뜻이 아닙니다. 둘은 할 수 있는 일이 다릅니다.

값을 상태가 든다 (Controlled) 값을 DOM 이 든다 (Uncontrolled)
화면에 보이는 글자를 정하는 것 React 상태 브라우저
타이핑할 때 다시 그리나 글자마다 그린다 안 그린다
입력 중에 값을 가공하기 쉽다 (대문자 변환, 자릿수 제한) 어렵다
입력 중에 다른 화면을 바꾸기 쉽다 (글자 수 표시, 버튼 잠금) 어렵다
값을 꺼내는 시점 언제든 우리가 꺼낼 때

댓글 입력창을 떠올려 보세요. 글자가 하나라도 있어야 게시 버튼이 켜집니다. 이건 입력하는 중에 다른 것을 바꿔야 하는 일이라 Controlled 여야 해요. 지금 코드가 옳습니다.

반대로 회원가입 폼은 어떨까요. 네 칸을 다 채우고 가입 버튼을 누르는 순간에만 값이 필요합니다. 치는 동안에는 아무것도 안 바뀌어도 돼요. 이런 폼은 값을 DOM 에 맡겨도 아무 손해가 없습니다.

💡 한 줄 정리

valueonChange 를 짝으로 주면 값을 상태가 들고(Controlled), defaultValueref 를 주면 값을 DOM 이 듭니다(Uncontrolled). 입력하는 도중에 무언가 바뀌어야 하면 앞쪽, 제출할 때만 값이 필요하면 뒤쪽이 편합니다.

🙋 학생 질문 — "튜터님, value 만 주고 onChange 를 깜빡하면 어떻게 되나요?"

글자가 아예 안 들어갑니다. 키를 아무리 눌러도 화면이 꿈쩍도 안 해요.

당연한 결과예요. 화면에 보이는 글자는 value 가 정하는데, value 에 준 값을 바꿀 방법이 없으니까요. 상태를 바꾸는 유일한 통로였던 onChange 를 안 달았으니 값은 영원히 그대로입니다.

이때 콘솔에 경고가 하나 뜹니다.

텍스트
You provided a `value` prop to a form field without an `onChange` handler.
This will render a read-only field.

읽기 전용 칸이 된다고 알려주는 거예요. 진짜로 읽기 전용을 원한 거라면 readOnly 를 붙이라고도 덧붙입니다.

⚠️ 이건 B-2 에서 한 번 만났던 경고입니다. 그때도 댓글 입력창(CommentForm)을 만들다 마주쳤죠. 같은 실수가 폼에서 훨씬 자주 나옵니다. 칸이 넷이면 넷 다 짝을 맞춰야 하니까요.


Step 2: "시키는 일 — useRef 로 DOM 에 명령하기"

Step 1 에서 ref 로 값을 꺼내 왔습니다. 그런데 ref 로 할 수 있는 일이 하나 더 있어요. 값을 읽는 게 아니라 시키는 겁니다.

사실 우리는 이미 하나 쓰고 있습니다. useCommentInput 을 다시 보세요.

tsx
// apps/web-spa/src/hooks/useCommentInput.ts
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault();
  if (isEmpty) {
    return;
  }
  onSubmit(content.trim());
  setContent('');
  inputRef.current?.focus();
}

마지막 줄이요. 댓글을 올리고 나면 입력창에 커서를 다시 갖다 놓습니다. 연달아 댓글을 달 때 매번 칸을 클릭하지 않아도 되게요.

이 손잡이가 어떻게 자식 입력창까지 닿는지는 A-4 에서 배웠습니다. CommentInputref: React.Ref<HTMLInputElement> 를 props 로 받고, 부모가 만든 손잡이를 그대로 넘겨받죠. 그때는 "손잡이를 넘기는 법" 만 다뤘고, 그 손잡이로 무엇을 하는지 는 오늘 이야기입니다.

이 한 줄을 상태로 표현할 수 있을까요? 없습니다. 커서가 어디 있는지는 화면에 그려지는 값이 아니거든요. <input focused={true} /> 같은 속성은 존재하지 않습니다.

React 는 "화면이 이렇게 생겨야 한다" 를 적는 도구입니다. 이런 방식을 선언적(declarative)이라고 부르죠. 그런데 세상에는 그 문장으로 표현되지 않는 일이 있어요.

  • 커서를 특정 칸에 갖다 놓기 (focus())
  • 칸 안의 글자를 전부 선택하기 (select())
  • 특정 위치로 화면을 굴려 보내기 (scrollIntoView())
  • 동영상을 재생하거나 멈추기 (play(), pause())

이런 건 상태가 아니라 동작입니다. 그래서 우리가 직접 시켜야 하고, 시키려면 그 요소를 붙잡을 손잡이가 필요합니다. 그게 useRef 예요. 이런 방식을 선언적의 반대말인 명령형(imperative)이라고 부릅니다.

인스타그램의 프로필 사진 버튼

명령형이 필요한 자리를 하나 만들어 봅시다. 회원가입할 때 프로필 사진을 고르는 버튼이요.

파일을 고르려면 <input type="file"> 이 필요합니다. 그런데 이 요소를 브라우저에서 그냥 띄워 보면 생김새가 제각각이에요. 크롬, 사파리, 파이어폭스가 다 다르게 그립니다. 인스타그램처럼 예쁜 버튼을 만들려면 CSS 로 손볼 수도 없어요.

실무에서 쓰는 방법은 이겁니다. 원래 파일 입력은 숨기고, 우리가 만든 버튼을 눌렀을 때 숨은 입력을 대신 클릭해 주는 것.

tsx
// apps/web-spa/src/components/ProfileImagePicker.tsx
import { useRef, useState } from 'react';
import { Button } from './Button';

interface ProfileImagePickerProps {
  // 고른 파일 이름을 위로 올려준다 — 없으면 혼자 보여주기만 한다
  onPick?: (fileName: string) => void;
}

export function ProfileImagePicker({ onPick }: ProfileImagePickerProps) {
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [pickedName, setPickedName] = useState('');

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0];
    if (!file) {
      return;
    }
    setPickedName(file.name);
    onPick?.(file.name);
  }

  return (
    <div className="profile-picker">
      {/* 파일 선택 창을 여는 건 React 로 표현할 수 없다. 손잡이를 잡고 직접 시킨다. */}
      <input
        className="profile-picker-input"
        type="file"
        accept="image/*"
        ref={fileInputRef}
        onChange={handleChange}
        aria-label="프로필 사진 파일"
      />
      <Button
        className="profile-picker-button"
        onClick={() => fileInputRef.current?.click()}
      >
        프로필 사진 고르기
      </Button>
      <span className="profile-picker-name">{pickedName || '아직 고르지 않았어요'}</span>
    </div>
  );
}

핵심은 이 한 줄입니다.

tsx
onClick={() => fileInputRef.current?.click()}

사용자가 누른 건 우리 버튼인데, 클릭이 실제로 도달하는 곳은 숨겨둔 파일 입력이에요. 그래서 브라우저의 파일 선택 창이 열립니다.

숨기는 건 CSS 한 줄로 합니다.

CSS
/* apps/web-spa/src/styles/globals.css */
.profile-picker-input {
  display: none;
}

물음표가 붙은 이유는 A-5 에서 배웠죠. useRef<HTMLInputElement>(null) 은 처음 렌더에서 null 이라 물음표 없이 부르면 타입 검사가 막습니다.

파일 입력은 Controlled 로 만들 수 없다

여기서 재밌는 걸 하나 짚고 갑시다. Step 1 에서 Controlled 와 Uncontrolled 를 골라 쓸 수 있다고 했는데, 파일 입력만은 고를 수가 없습니다. 무조건 Uncontrolled 예요.

시작값을 주는 두 가지 방법을 다 시도해 보세요.

tsx
<input type="file" value="hangang.png" onChange={() => {}} />
<input type="file" defaultValue="hangang.png" />

둘 다 화면이 뜨지 않고 그 자리에서 터집니다. React 가 막는 게 아니라 브라우저가 거부합니다. 파일 입력의 값은 빈 문자열로만 바꿀 수 있다는 규칙이 있거든요.

왜 이런 규칙이 있을까요. 만약 웹페이지가 파일 경로를 마음대로 채워 넣을 수 있다면, 여러분이 모르는 사이에 /Users/여러분/개인문서.pdf 같은 값을 심어두고 자동으로 제출시킬 수 있겠죠. 그래서 파일을 고르는 건 오직 사용자만 할 수 있게 막아둔 겁니다.

그래서 파일 입력의 값은 언제나 refevent.target.files 로 꺼내 옵니다. 선택의 여지가 없어요.

💡 한 줄 정리

useRef 는 값을 읽는 데도 쓰지만, DOM 에 직접 명령을 내리는 데도 씁니다. 커서 옮기기나 파일 선택 창 열기처럼 화면 모양으로 표현되지 않는 동작이 그런 경우예요.

🙋 학생 질문 — "튜터님, 그럼 ref 를 많이 쓸수록 편한 거 아닌가요?"

반대예요. ref 는 되도록 적게 쓰는 게 좋습니다.

React 를 쓰는 이유를 떠올려 보세요. "상태가 이러면 화면은 이렇게 생긴다" 한 문장만 적어두면 나머지는 React 가 알아서 맞춰 주는 것, 그게 B-1 에서 배운 선언적 렌더링이었죠.

ref 로 DOM 을 직접 건드리는 순간 그 약속에서 빠져나오게 됩니다. React 가 화면을 다시 그릴 때 우리가 손으로 바꿔둔 걸 알 리가 없으니까요. 둘이 서로 다른 이야기를 하기 시작하면 원인을 찾기가 아주 어려워집니다.

그래서 기준은 이렇습니다. 상태로 표현할 수 있으면 상태로 하고, 도저히 표현이 안 되는 것만 ref 로 합니다.

  • 버튼을 잠그기 → disabled={isEmpty} 로 충분. ref 필요 없음
  • 글자를 지우기 → setContent('') 로 충분. ref 필요 없음
  • 커서를 옮기기 → 표현할 속성이 없음. ref 가 필요
  • 파일 창을 열기 → 표현할 속성이 없음. ref 가 필요

앞의 둘을 ref 로 하면 React 와 싸우는 코드가 되고, 뒤의 둘은 ref 말고 방법이 없습니다.


Step 3: "손으로 만드는 회원가입 폼 — 규칙이 넷이면"

이제 진짜 회원가입 폼을 만들어 봅시다. 지금까지 배운 것만으로요.

받을 값은 넷입니다. 사용자 이름, 이메일, 비밀번호, 비밀번호 확인. 백엔드가 실제로 받는 규칙에 맞춰서 조건을 정하겠습니다.

tsx
// apps/web-spa/src/components/SignUpForm.tsx
export interface SignUpValues {
  username: string;
  email: string;
  password: string;
  passwordConfirm: string;
}

// 필드마다 메시지가 하나씩 붙을 수 있다. 아직 안 걸린 필드는 비어 있다.
type SignUpErrors = Partial<Record<'username' | 'email' | 'password' | 'passwordConfirm', string>>;
type SignUpTouched = Partial<Record<'username' | 'email' | 'password' | 'passwordConfirm', boolean>>;

interface SignUpFormProps {
  onSubmit: (values: SignUpValues) => void;
}

PartialRecord 는 A-3 에서 배운 것들이죠. Record<'username' | 'email' | ..., string> 은 네 이름 각각에 문자열이 하나씩 붙은 객체이고, Partial 을 씌우면 전부 있어도 되고 없어도 되는 것이 됩니다. 아직 안 걸린 필드는 아예 없으니까요.

⚠️ 여기서 눈에 걸리는 게 있죠. 필드 이름 네 개를 세 번 적었습니다. SignUpValues 에 한 번, SignUpErrors 에 한 번, SignUpTouched 에 한 번. 이걸 기억해 두세요. 오늘 끝에서 다시 이야기합니다.

검사는 함수 하나로 모읍니다.

tsx
const EMPTY_VALUES: SignUpValues = {
  username: '',
  email: '',
  password: '',
  passwordConfirm: '',
};

// 백엔드가 받는 규칙과 같은 모양으로 맞췄다
const USERNAME_PATTERN = /^[a-z0-9._]{4,20}$/;

// 규칙이 넷이면 검사도 넷이다. 필드가 늘면 이 함수도 같이 길어진다.
function validate(values: SignUpValues): SignUpErrors {
  const errors: SignUpErrors = {};

  if (!USERNAME_PATTERN.test(values.username)) {
    errors.username = '영문 소문자·숫자·마침표·밑줄로 4~20자여야 해요';
  }
  if (!values.email.includes('@')) {
    errors.email = '이메일 모양이 아니에요';
  }
  if (values.password.length < 8) {
    errors.password = '8자 이상이어야 해요';
  }
  if (values.password !== values.passwordConfirm) {
    errors.passwordConfirm = '비밀번호가 일치하지 않아요';
  }

  return errors;
}

이메일 검사가 좀 엉성하죠. @ 가 들어 있는지만 봅니다. a@ 도 통과하고 @@@ 도 통과해요. 제대로 하려면 정규식을 써야 하는데, 이메일 정규식은 생각보다 훨씬 길고 까다롭습니다. 일단 이대로 두고 넘어갈게요.

이제 컴포넌트 본체입니다.

tsx
export function SignUpForm({ onSubmit }: SignUpFormProps) {
  const [values, setValues] = useState(EMPTY_VALUES);
  const [touched, setTouched] = useState<SignUpTouched>({});

  // 값에서 바로 나오는 것이라 상태로 따로 두지 않는다
  const errors = validate(values);

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setValues({ ...values, [event.target.name]: event.target.value });
  }

  function handleBlur(event: React.FocusEvent<HTMLInputElement>) {
    setTouched({ ...touched, [event.target.name]: true });
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();

    setTouched({ username: true, email: true, password: true, passwordConfirm: true });

    if (Object.keys(errors).length > 0) {
      return;
    }

    onSubmit(values);
    setValues(EMPTY_VALUES);
    setTouched({});
  }

  return (
    <form className="signup-form" onSubmit={handleSubmit}>
      <ProfileImagePicker />
      {/* 입력 칸 넷이 여기 들어갑니다 — 바로 아래에서 봅니다 */}
      <Button className="signup-submit" type="submit">
        가입하기
      </Button>
    </form>
  );
}

몇 가지 짚고 갑시다.

handleChange 하나로 네 칸을 다 받습니다. event.target.name 에 어느 칸에서 온 이벤트인지 들어 있어서, 그 이름으로 값을 갈아 끼우면 돼요. 칸마다 핸들러를 따로 만들지 않아도 됩니다.

errorsuseState 가 아닙니다. values 만 있으면 계산할 수 있는 값이라 상태로 두지 않았어요. B-4 Step 7 에서 배운 것 그대로입니다. 상태로 두면 값이 바뀔 때마다 따라서 맞춰줘야 하는데, 그러다 한 군데 빠뜨리면 화면과 어긋납니다.

touched 는 왜 필요할까요. 페이지를 열자마자 네 칸이 전부 비어 있으니 규칙에는 이미 다 어긋나 있습니다. 그렇다고 아무것도 안 한 사용자에게 빨간 글씨 넷을 들이밀 수는 없죠. "이 칸을 건드린 적이 있는가" 를 따로 기억해야 합니다.

JSX 는 이런 모양입니다. 한 칸만 보여드릴게요.

tsx
      <div className="signup-field">
        <label className="signup-label" htmlFor="signup-username">
          사용자 이름
        </label>
        <input
          className="signup-input"
          id="signup-username"
          name="username"
          value={values.username}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.username && errors.username && <p className="signup-error">{errors.username}</p>}
      </div>

그리고 이 덩어리가 네 번 반복됩니다. 이름과 라벨만 바뀌고 구조는 똑같아요. 비밀번호 칸 둘은 type="password" 가 하나 더 붙는 정도 차이입니다.

그래서 몇 개가 늘어났나

동작은 잘 합니다. 규칙을 어기면 제출이 막히고, 칸을 건드린 뒤에야 메시지가 뜨고, 고치면 사라져요. 우리가 원한 대로입니다.

그런데 뭐가 들었는지 세어 봅시다.

항목 개수
상태 2개 (values, touched)
손으로 쓴 함수 4개 (validate, handleChange, handleBlur, handleSubmit)
타입 선언 3개 (같은 필드 이름을 세 번)
거의 똑같은 JSX 덩어리 4개

그리고 이건 필드가 넷일 때 이야기입니다. 인스타그램 실제 회원가입에는 이름 칸이 하나 더 있고, 서비스에 따라 생년월일이나 약관 동의가 붙기도 하죠. 필드가 늘어날 때마다 validateif 가 늘고, 타입 세 곳에 이름을 추가하고, JSX 덩어리를 하나 더 복사해야 합니다.

여기에 아직 만들지도 않은 것들이 남아 있어요.

  • 제출 버튼을 연타하면 두 번 가입됩니다. 보내는 중인지 알려주는 상태가 또 필요해요
  • 지금은 칸을 벗어날 때(onBlur)만 메시지를 띄우는데, "처음엔 벗어날 때, 한 번 틀린 뒤부터는 칠 때마다" 로 하려면 조건이 더 붙습니다
  • 서버가 "이미 쓰는 아이디입니다" 라고 답하면 그 메시지도 같은 자리에 띄워야 합니다

이걸 다 만들면 어떻게 될지 짐작이 가시죠. 그래서 폼은 라이브러리를 쓰는 게 실무 표준입니다.

💡 한 줄 정리

필드가 넷이면 값·건드림 여부·검사 함수·반복되는 JSX 가 함께 늘어납니다. 동작은 하지만 필드가 하나 늘 때마다 고칠 곳이 네 군데씩 생겨요.

🙋 학생 질문 — "튜터님, 이 정도면 그냥 라이브러리 없이 써도 되지 않나요?"

폼이 이거 하나뿐이라면 그래도 됩니다. 정말이에요. 라이브러리를 하나 더 얹는 것도 비용이거든요.

문제는 폼이 하나로 끝나지 않는다는 겁니다. 회원가입, 로그인, 프로필 수정, 비밀번호 변경, 게시물 작성, 신고하기. 인스타그램 하나에도 폼이 열 개 넘게 들어갑니다.

그때 두 가지 중 하나가 벌어져요. 방금 만든 코드를 열 번 복사하거나, 아니면 공통으로 묶으려고 직접 폼 라이브러리를 만들기 시작합니다. 후자를 끝까지 하면 나오는 게 오늘 배울 React Hook Form 입니다. 이미 그걸 만들어서 수백만 명이 검증해 준 것을 쓰는 편이 낫겠죠.

기준을 하나 드릴게요. 폼이 한두 개고 칸이 두어 개면 손으로 써도 괜찮습니다. 그보다 커지거나, 같은 검증을 여러 폼에서 쓰게 되면 라이브러리를 얹으세요.


Step 4: "React Hook Form — register 가 펼치는 네 가지"

이제 방금 만든 걸 걷어내고 다시 씁니다. 설치부터 할게요.

Bash
npm install react-hook-form

React Hook Form 은 폼 라이브러리 중에서 가장 많이 쓰이는 것입니다. 이름 그대로 훅 하나로 폼을 다뤄요.

tsx
// apps/web-spa/src/components/SignUpForm.tsx
import { useForm } from 'react-hook-form';

export function SignUpForm({ onSubmit }: SignUpFormProps) {
  const { register, handleSubmit, reset } = useForm<SignUpValues>({
    defaultValues: EMPTY_VALUES,
  });

  // 검사를 다 통과했을 때만 이 함수가 불린다
  function handleValid(values: SignUpValues) {
    onSubmit(values);
    reset();
  }

  return (
    <form className="signup-form" onSubmit={handleSubmit(handleValid)}>
      <ProfileImagePicker />
      {/* 입력 칸 넷이 여기 들어갑니다 */}
      <Button className="signup-submit" type="submit">
        가입하기
      </Button>
    </form>
  );
}

useForm 하나를 부르면 폼에 필요한 도구가 한 묶음으로 나옵니다. 지금은 셋만 꺼내 썼어요.

  • register — 입력창 하나를 폼에 등록한다
  • handleSubmit — 제출을 가로채서 검사한 뒤, 통과했을 때만 우리 함수를 부른다
  • reset — 폼을 처음 상태로 되돌린다

꺾쇠 안의 <SignUpValues> 를 눈여겨보세요. A-6 에서 배운 타입 매개변수입니다. 이 폼이 다루는 값의 모양을 알려주는 거예요. 이게 뒤에서 큰 일을 합니다.

JSX 는 이렇게 짧아집니다.

tsx
      <div className="signup-field">
        <label className="signup-label" htmlFor="signup-username">
          사용자 이름
        </label>
        <input className="signup-input" id="signup-username" {...register('username')} />
      </div>

valueonChangeonBlur 도 없습니다. {...register('username')} 하나뿐이에요.

register 가 도대체 뭘 펼치는 걸까

스프레드 문법이 쓰였으니 register('username') 이 객체를 돌려준다는 뜻이죠. 무엇이 들었는지 직접 확인해 봅시다.

tsx
const field = register('username');
console.log(Object.keys(field));

찍히는 건 이겁니다.

텍스트
['name', 'onChange', 'onBlur', 'ref']

네 개입니다. 그리고 이 넷이 정확히 우리가 Step 1 과 Step 2 에서 본 것들이에요.

펼쳐지는 것 하는 일
name 어느 칸인지 이름표. 우리가 손으로 적던 name="username"
onChange 값이 바뀔 때 라이브러리가 받아 간다
onBlur 칸을 벗어났다는 신호를 받아 간다. 우리가 손으로 챙기던 touched 를 대신해 준다
ref 입력창 손잡이. 값을 여기로 꺼내 간다

value 가 없다는 것을 눈치채셨나요. React Hook Form 은 Uncontrolled 방식으로 동작합니다. 값은 DOM 이 들고 있고, 라이브러리는 ref 로 붙잡아 뒀다가 제출할 때 꺼내 갑니다.

Step 1 과 Step 2 에서 배운 두 가지가 여기서 만난 거예요. 값을 DOM 에 맡기고(Step 1), 손잡이로 꺼내 온다(Step 2).

그래서 얼마나 조용해졌나

React 가 화면을 실제로 고쳐 그린 횟수를 재 봤습니다. 네 글자를 쳤을 때입니다. (React DevTools 의 Profiler 탭에서 같은 값을 볼 수 있어요.)

처음 붙을 때 네 글자를 친 뒤 (누적)
손으로 만든 판 1회 5회
React Hook Form 판 2회 2회

손으로 만든 쪽은 글자마다 한 번씩 늘어 다섯 번이 됩니다. React Hook Form 쪽은 네 글자를 쳐도 처음 그대로예요.

⚠️ 다만 정직하게 짚고 갈 게 있습니다. 처음 붙을 때는 오히려 한 번 더 그립니다. 라이브러리가 자리를 잡는 비용이에요. 대신 그 뒤로 사용자가 무엇을 치든 조용합니다. 칸이 넷이 아니라 열이어도 마찬가지고요.

handleSubmit 쪽도 봅시다.

tsx
<form className="signup-form" onSubmit={handleSubmit(handleValid)}>

handleSubmit 은 우리 함수를 감싸서 새 함수를 돌려줍니다. 그 안에서 event.preventDefault() 도 대신 불러주고, 검사도 돌려보고, 다 통과했을 때만 handleValid 를 부릅니다. 그래서 handleValid 는 받는 게 이벤트가 아니라 값 객체예요.

우리가 Step 3 에서 손으로 쓰던 이 부분이 통째로 사라진 겁니다.

tsx
event.preventDefault();
if (Object.keys(errors).length > 0) {
  return;
}

💡 한 줄 정리

useForm 이 돌려주는 register 를 입력창에 펼치면 name·onChange·onBlur·ref 네 가지가 붙습니다. 값은 DOM 이 들고 라이브러리가 ref 로 꺼내 가기 때문에, 타이핑할 때 화면을 다시 그리지 않아요.

🙋 학생 질문 — "튜터님, register 를 펼치지 않고 하나씩 붙여도 되나요?"

됩니다. 스프레드는 짧게 쓰려는 것뿐이에요. 이렇게 써도 똑같이 동작합니다.

tsx
const field = register('username');

<input
  name={field.name}
  onChange={field.onChange}
  onBlur={field.onBlur}
  ref={field.ref}
/>

그런데 여기서 하나를 빠뜨리면 재밌는 일이 벌어집니다. ref 만 빼고 나머지 셋을 붙여 보세요.

화면은 멀쩡합니다. 글자도 잘 쳐지고요. 그런데 제출해 보면 값이 이렇게 넘어옵니다.

텍스트
{}

빈 객체예요. 분명히 글자를 쳤는데 아무것도 안 담겨 있습니다.

이유는 방금 배운 그대로입니다. React Hook Form 이 값을 읽는 통로가 ref 하나뿐이거든요. onChange 는 "이 칸에 뭔가 벌어졌다" 는 신호를 받는 용도고, 실제 값은 손잡이를 통해 DOM 에서 꺼내 옵니다. 손잡이를 안 주면 꺼낼 곳이 없어요.

이 실험을 과제에 넣어뒀으니 직접 확인해 보세요. register 가 왜 ref 를 돌려주는지 몸으로 알게 됩니다.


Step 5: "규칙을 달고 메시지를 띄우기"

Step 4 에서 폼이 짧아졌는데, 대신 검사가 통째로 사라졌습니다. 아무거나 넣어도 그냥 제출돼요. 이제 규칙을 다시 붙입니다.

register 는 두 번째 인자로 규칙을 받습니다.

tsx
// apps/web-spa/src/components/SignUpForm.tsx
{...register('username', {
  required: '사용자 이름을 입력해 주세요',
  pattern: {
    value: USERNAME_PATTERN,
    message: '영문 소문자·숫자·마침표·밑줄로 4~20자여야 해요',
  },
})}

규칙 이름이 곧 조건이고, 값으로 준 문자열이 틀렸을 때 보여줄 메시지입니다. 자주 쓰는 규칙은 이 정도예요.

규칙
required 반드시 채워야 한다
minLength / maxLength 글자 수 하한 / 상한
min / max 숫자 하한 / 상한
pattern 정규식에 맞아야 한다
validate 우리가 직접 쓴 함수로 판단한다

required 는 메시지만 주면 되지만, 나머지는 조건과 메시지를 함께 줘야 해서 { value, message } 모양이 됩니다.

이메일 칸에는 Step 3 에서 미뤄뒀던 숙제가 있죠. @ 가 들어 있는지만 보던 그 검사요. 이번엔 제대로 붙입니다.

tsx
const EMAIL_PATTERN = /^\S+@\S+\.\S+$/;
tsx
{...register('email', {
  required: '이메일을 입력해 주세요',
  pattern: { value: EMAIL_PATTERN, message: '이메일 모양이 아니에요' },
})}

\S 는 공백이 아닌 글자, + 는 하나 이상이라는 뜻입니다. 그러니 공백 없는 글자 뭉치 · @ · 공백 없는 글자 뭉치 · . · 공백 없는 글자 뭉치 순서를 요구하는 거예요. 이제 a@@@@ 는 통과하지 못합니다.

⚠️ 다만 이것도 완벽한 이메일 검사는 아닙니다. 사실 완벽한 이메일 정규식이라는 건 실무에서 추구하지 않아요. 표준을 다 지킨 정규식은 수백 자가 넘고, 그렇게 만들어도 그 주소가 진짜 존재하는지는 알 수 없거든요. 실제로 확인하는 유일한 방법은 그 주소로 메일을 보내보는 것입니다. 그래서 프론트에서는 오타를 걸러낼 정도만 보고, 진짜 확인은 인증 메일에 맡깁니다.

비밀번호 칸은 길이만 봅니다.

tsx
{...register('password', {
  required: '비밀번호를 입력해 주세요',
  minLength: { value: 8, message: '8자 이상이어야 해요' },
})}

비밀번호 확인 칸은 조금 다릅니다.

tsx
{...register('passwordConfirm', {
  required: '비밀번호를 한 번 더 입력해 주세요',
  // 이 필드 하나만 봐서는 알 수 없다 — 옆 필드를 같이 봐야 한다
  validate: (value, values) =>
    value === values.password || '비밀번호가 일치하지 않아요',
})}

requiredminLength 는 그 칸의 값만 보면 판단이 됩니다. 그런데 "위 칸과 같은가" 는 혼자서는 알 수 없어요. 그래서 validate 를 씁니다. 두 번째 인자로 폼 전체 값이 들어와서 옆 칸을 볼 수 있어요.

돌려주는 값의 규칙이 특이합니다. 통과하면 true, 틀리면 메시지 문자열을 돌려줘요. 그래서 A || B 로 짧게 쓰는 관용구가 자주 보입니다. 조건이 맞으면 true 가 나가고, 아니면 뒤의 문자열이 나가니까요.

에러 메시지를 화면에 띄우기

규칙을 달면 검사 결과가 formState 에 담깁니다.

tsx
const {
  register,
  handleSubmit,
  reset,
  formState: { errors, isSubmitting },
} = useForm<SignUpValues>({ defaultValues: EMPTY_VALUES });

errors.username 에는 그 칸이 어떤 규칙에 걸렸는지가 객체로 들어 있고, 우리가 쓸 건 그 안의 message 입니다.

tsx
{errors.username?.message}

물음표가 붙은 이유는 아시겠죠. 통과한 칸에는 errors.username 이 아예 없습니다. 물음표를 빼면 타입 검사가 이렇게 막아요.

텍스트
'errors.username' is possibly 'undefined'.

A-5 에서 inputRef.current 에 붙였던 물음표와 같은 이야기입니다. 그때는 null 이었고 지금은 undefined 라는 것만 달라요.

반복되던 네 덩어리를 하나로

Step 3 에서 라벨·입력창·에러 메시지 묶음이 네 번 반복됐죠. 이제 이걸 컴포넌트로 뽑을 때가 됐습니다. B-3 에서 배운 합성이에요.

tsx
// apps/web-spa/src/components/TextField.tsx
import type { ComponentProps } from 'react';

// input 이 원래 받는 것 전부에 라벨과 에러 메시지를 더한다.
// register 가 펼쳐주는 name·onChange·onBlur·ref 도 여기로 그대로 흘러 들어간다.
type TextFieldProps = ComponentProps<'input'> & {
  id: string;
  label: string;
  error?: string;
};

export function TextField({ id, label, error, ...inputProps }: TextFieldProps) {
  return (
    <div className="signup-field">
      <label className="signup-label" htmlFor={id}>
        {label}
      </label>
      <input className="signup-input" id={id} {...inputProps} />
      {error && <p className="signup-error">{error}</p>}
    </div>
  );
}

ComponentProps 는 A-6 에서 썼던 것입니다. 그때는 ComponentProps<typeof Button> 처럼 우리가 만든 컴포넌트의 props 를 물려받았죠. 이번엔 꺾쇠 안에 문자열 'input' 을 넣었습니다. 브라우저의 <input> 이 원래 받는 것 전부를 뜻해요.

덕분에 type="password" 도, register 가 펼치는 네 가지도, 나중에 placeholder 를 붙이고 싶어도 전부 그냥 통과합니다. 우리가 일일이 적어둘 필요가 없어요.

이제 한 칸이 이렇게 됩니다.

tsx
      <TextField
        id="signup-username"
        label="사용자 이름"
        error={errors.username?.message}
        {...register('username', {
          required: '사용자 이름을 입력해 주세요',
          pattern: {
            value: USERNAME_PATTERN,
            message: '영문 소문자·숫자·마침표·밑줄로 4~20자여야 해요',
          },
        })}
      />

<div><label><p> 가 사라지고, 이 칸이 무엇인지와 어떤 규칙을 지켜야 하는지만 남았습니다.

두 번 눌러도 한 번만 보내기

마지막 하나입니다. formState 에서 함께 꺼낸 isSubmitting 이요.

tsx
      <Button className="signup-submit" type="submit" disabled={isSubmitting}>
        {isSubmitting ? '보내는 중...' : '가입하기'}
      </Button>

제출 처리 함수가 시간이 걸리는 일을 하는 동안 isSubmittingtrue 가 됩니다. 그동안 버튼이 잠기니까 연타해도 두 번 가입되지 않아요.

시간이 걸리는 일이라는 걸 어떻게 알까요. 제출 함수가 Promise 를 돌려주면 라이브러리가 그게 끝날 때까지 기다립니다.

tsx
async function handleValid(values: SignUpValues) {
  await onSubmit(values);
  reset();
}

지금 우리 앱에는 아직 서버가 없어서 onSubmit 이 곧바로 끝납니다. 그래서 보내는 중... 을 눈으로 보기는 어려워요. C-3 에서 이 폼을 진짜 백엔드에 연결하면 그때 제 역할을 하게 됩니다.

화면에 올리기

여기까지 만든 폼을 App.tsx 에 올려 둡시다. 아직 라우팅을 안 배웠으니 피드 위에 나란히 놓으면 됩니다.

tsx
// apps/web-spa/src/App.tsx
<Section title="회원가입">
  <SignUpForm onSubmit={(values) => console.log('가입 요청', values.username)} />
</Section>

서버가 아직 없어서 지금은 콘솔에 찍기만 합니다. 이 자리에 진짜 요청을 넣는 건 C-3 에서 해요.

언제 검사할지 정하기

완성된 폼을 직접 눌러 보면 Step 3 과 다른 점이 하나 보입니다.

손으로 만든 폼은 칸을 벗어나는 순간 메시지가 떴죠. touched 를 그렇게 만들었으니까요. 그런데 지금 폼은 이메일 칸에 아무거나 치고 다른 칸으로 옮겨도 조용합니다. 가입 버튼을 눌러야 그제야 넷이 한꺼번에 뜹니다.

React Hook Form 의 기본값이 그렇기 때문이에요. 정리하면 이렇습니다.

언제 처음 검사하나 그 뒤에는
Step 3 손으로 만든 폼 칸을 벗어날 때 칠 때마다
React Hook Form 기본값 제출할 때 칠 때마다

둘 다 뒷부분은 같습니다. 한 번 틀렸다고 알려준 뒤에는 고치는 즉시 메시지가 사라져요. 사용자가 고쳤는지 확인하려고 다른 칸을 클릭할 필요가 없게요.

바꾸고 싶으면 useForm 에 옵션을 주면 됩니다.

tsx
useForm<SignUpValues>({
  defaultValues: EMPTY_VALUES,
  mode: 'onBlur',
});

mode 가 처음 검사 시점이고, reValidateMode 가 한 번 틀린 뒤의 시점입니다. 어느 쪽이 나은지는 폼마다 다릅니다. 오늘 마지막 생각해볼 주제에서 이 이야기를 이어갈게요.

아직 남은 문제 세 가지

폼이 완성됐습니다. 규칙도 돌고, 메시지도 뜨고, 연타도 막혔어요. 그런데 다시 코드를 보면 마음에 걸리는 게 셋 있습니다.

하나. 규칙이 JSX 안에 흩어져 있습니다. "사용자 이름 규칙이 뭐였지?" 를 확인하려면 화면 그리는 코드를 뒤져야 해요. 규칙만 따로 모아서 볼 수가 없습니다.

둘. 타입과 규칙이 따로 놉니다. SignUpValuesusername: string 이라고 적어두고, register 에 4~20자 규칙을 또 적었죠. 둘은 서로를 모릅니다. 타입에 필드를 추가해도 규칙은 안 따라오고, 규칙을 바꿔도 타입은 그대로예요.

셋. 이 규칙은 폼에서만 씁니다. 서버가 보내준 데이터가 우리가 기대한 모양인지 확인하고 싶어도, register 의 규칙은 거기에 쓸 수가 없어요. 폼 입력창에만 붙는 것이니까요.

셋째가 특히 큽니다. A-1 첫 시간에 이런 질문을 받았던 것 기억하시나요.

서버가 실제로 이 모양대로 준다는 보장이 있나요?

그때 "폼 검증을 다루는 모듈에서 그 도구를 만나게 될 거예요" 라고 답을 미뤄뒀습니다. 다음 시간이 바로 그 시간입니다.

💡 한 줄 정리

register 의 두 번째 인자에 규칙을, formState.errors 에서 메시지를 꺼내 씁니다. 반복되는 라벨·입력창·에러 묶음은 ComponentProps<'input'> 로 받는 컴포넌트 하나로 합칠 수 있어요.

🙋 학생 질문 — "튜터님, errors.username 이 아니라 errors.usernam 이라고 오타를 내면요?"

타입 검사에서 걸립니다. 그리고 register 에 이름을 잘못 적어도 걸려요.

tsx
<input {...register('usernam')} />

이렇게 쓰면 이런 메시지가 뜹니다.

텍스트
Argument of type '"usernam"' is not assignable to parameter of type
'"username" | "email" | "password" | "passwordConfirm"'.

우리가 쓸 수 있는 이름 넷을 그대로 나열해서 알려줍니다. 이게 가능한 이유는 useForm<SignUpValues> 라고 꺾쇠 안에 타입을 넘겼기 때문이에요. A-6 에서 배운 타입 매개변수가 여기서 일하고 있습니다.

반대로 확인해 보면 더 분명해집니다. 꺾쇠를 빼고 그냥 useForm() 이라고 쓰면요.

tsx
const { register } = useForm();

<input {...register('아무거나')} />

아무 에러도 안 납니다. 어떤 필드가 있는지 알려주지 않았으니 무엇이든 받아주는 거예요. 그러면 오타를 내도 조용히 넘어가고, 폼은 아무 값도 못 받게 됩니다.

그래서 useForm 을 쓸 때는 꺾쇠 안에 값 타입을 꼭 넘기세요. 한 글자 더 적는 대가로 필드 이름 오타를 전부 막아줍니다.


마무리

오늘 배운 것을 한 줄로 줄이면 이렇습니다. 폼은 값을 누가 드느냐로 갈린다.

앞의 두 Step 은 값의 주인이 누구냐는 이야기였습니다. 상태가 들면 입력하는 도중에도 개입할 수 있고, DOM 이 들면 조용한 대신 꺼내 와야 하죠. 뒤의 세 Step 은 그 선택 위에 서 있는 라이브러리를 쓰는 법이었고요.

오늘 배운 핵심 세 가지

💡 하나 — value + onChange 면 Controlled, defaultValue + ref 면 Uncontrolled 입니다. 입력하는 중에 다른 것이 바뀌어야 하면 앞쪽을, 제출할 때만 값이 필요하면 뒤쪽을 씁니다. 파일 입력만은 고를 수 없이 언제나 Uncontrolled 예요.

💡 둘 — useRef 는 값을 읽는 데도 쓰지만 DOM 에 명령을 내리는 데도 씁니다. 커서를 옮기거나 파일 선택 창을 여는 일은 화면 모양으로 표현되지 않아서 직접 시켜야 합니다. 다만 상태로 표현할 수 있는 일까지 ref 로 하면 React 와 싸우게 돼요.

💡 셋 — register 가 펼치는 것은 name·onChange·onBlur·ref 넷입니다. React Hook Form 은 값을 DOM 에 맡기고 ref 로 꺼내 가기 때문에 타이핑할 때 화면을 다시 그리지 않습니다. 규칙은 register 의 두 번째 인자에, 메시지는 formState.errors 에 담깁니다.

다음 시간 예고

폼은 만들었는데 마음에 걸리는 게 셋 남았죠. 규칙이 JSX 안에 흩어져 있고, 타입과 규칙이 서로를 모르고, 그 규칙을 폼 바깥에서는 쓸 수 없다는 것.

다음 시간에는 이 셋을 한꺼번에 푸는 도구를 만납니다. Zod 라는 스키마 검증 라이브러리예요.

스키마라는 건 "이 데이터는 이렇게 생겼다" 를 적어둔 것입니다. 그런데 재밌는 건, 그 스키마 하나에서 타입이 저절로 나온다는 거예요. SignUpValues 를 손으로 적지 않아도 됩니다. 규칙을 바꾸면 타입이 따라 바뀌고요.

그리고 스키마는 폼 바깥에서도 쓸 수 있습니다. 타입은 검사가 끝나면 사라지지만 스키마는 코드로 남아서, 배포된 앱에서도 실행되거든요. A-1 에서 미뤄둔 그 질문에 다음 시간에 제대로 답할 겁니다.


과제

[구현] 로그인 폼을 React Hook Form 으로

오늘 만든 회원가입 폼과 같은 방식으로 로그인 폼을 만들어 봅시다. 회원가입보다 훨씬 짧아요.

  • apps/web-spa/src/components/LoginForm.tsx 를 만들고, 사용자 이름과 비밀번호 두 칸을 받으세요.
  • 오늘 만든 TextField 를 그대로 쓰면 됩니다. 새로 만들지 마세요.
  • 두 칸 모두 required 규칙을 달고, 비어 있으면 칸 아래에 메시지가 뜨게 해주세요.
  • 제출 함수는 회원가입 폼처럼 onSubmit props 로 받고, 버튼은 isSubmitting 일 때 잠기게 합니다.
  • App.tsx<Section title="로그인"> 을 만들어 넣고 브라우저에서 직접 눌러 보세요.

여기서 한 가지 더 생각해 볼 게 있습니다. 로그인 폼에는 비밀번호 규칙을 넣어야 할까요? 회원가입에는 "8자 이상" 을 넣었죠. 로그인에도 넣는 게 맞는지, 넣으면 어떤 일이 생길지 두세 줄로 적어 주세요. 정답이 하나는 아닙니다.

다 만들고 나면 npm run typecheck -w web-spanpm run lint -w web-spa 를 돌려 둘 다 통과하는지 확인해 주세요.

[탐구] 입력창이 어떻게 동작하는지 직접 확인하기

오늘 배운 것을 손으로 확인해 봅시다. 각 단계에서 무슨 일이 일어났는지 한두 줄씩 적어 주세요. 확인이 끝나면 전부 원래대로 되돌립니다.

  • InputValueDemo 를 화면에 올리고 콘솔을 연 채로 양쪽에 같은 글자를 쳐 보세요. 줄 수가 어떻게 달라지나요? 그리고 오른쪽 칸은 우리가 다시 그리지 않았는데 왜 글자가 보이는지 설명해 보세요.
  • ControlledInput 에서 onChange 를 지워 보세요. 화면과 콘솔이 어떻게 되나요?
  • 아무 입력창에나 valuedefaultValue동시에 줘 보세요. 콘솔에 뭐라고 뜨나요? React 가 왜 이걸 문제 삼는지 Step 1 로 설명해 보세요.
  • SignUpForm 에서 {...register('username')} 을 펼치지 말고 name·onChange·onBlur·ref 를 하나씩 손으로 붙여 보세요. 똑같이 동작하나요?
  • 그 상태에서 ref 만 지우고 폼을 제출해 보세요. 제출 처리 함수에 들어오는 값을 console.log 로 찍어 확인하세요. 무엇이 넘어오나요? 왜 그런지 Step 4 로 설명해 보세요.
  • register('username')register('usernam') 으로 오타를 내 보세요. 에디터가 뭐라고 하나요? 그다음 useForm<SignUpValues>() 에서 꺾쇠 부분을 지우고 같은 오타를 다시 내 보세요. 이번엔 어떻게 되나요?

생각해볼 주제

1. 라이브러리를 언제 얹을 것인가

오늘 폼 라이브러리를 하나 얹었습니다. 코드가 짧아졌고 다시 그리는 횟수도 줄었죠.

그런데 얻은 것만 있는 건 아닙니다. 번들에 코드가 늘었고, 팀에 새로 들어온 사람이 배워야 할 게 하나 늘었고, 그 라이브러리가 관리를 멈추면 갈아탈 일이 생깁니다. Step 3 에서 만든 코드는 아무에게도 의존하지 않았어요.

여러분이 팀에서 "이거 라이브러리 쓸까요, 직접 만들까요" 를 물어봐야 하는 상황이라면 무엇을 기준으로 판단하시겠어요. 그리고 그 기준은 폼 말고 다른 것(날짜 처리, 상태 관리, 차트)에도 그대로 적용될까요?

2. 검증은 어디에서 해야 하나

오늘 우리는 브라우저에서 이메일 모양과 비밀번호 길이를 검사했습니다. 그런데 백엔드에도 같은 검사가 이미 들어 있어요. 사용자 이름 규칙 4~20자 도 서버가 똑같이 확인합니다.

같은 규칙을 두 곳에 적어두는 셈인데, 그러면 한쪽만 고쳤을 때 어긋나겠죠. 그렇다고 한쪽을 없애면 어떻게 될까요. 프론트에서만 검사하면, 또는 서버에서만 검사하면 각각 무엇이 문제가 될지 생각해 보세요.

그리고 이 중복을 줄이는 방법이 실무에 있다면 어떤 모양일지도 상상해 보세요.

3. 사용자에게 언제 틀렸다고 말할 것인가

우리 폼은 제출 버튼을 눌러야 메시지를 띄우고, 한 번 틀린 뒤에는 고치는 즉시 사라집니다. Step 3 에서 손으로 만든 폼은 칸을 벗어날 때 띄웠고요. 선택지가 더 있습니다.

한 글자 칠 때마다 검사할 수도 있죠. 그러면 이메일을 j 까지만 쳤는데 벌써 빨간 글씨가 뜹니다. 반대로 지금 우리 폼처럼 제출할 때까지 기다리면, 다 채우고 눌렀더니 네 칸이 한꺼번에 빨개지고요.

여러분이 자주 쓰는 서비스의 회원가입 폼은 어떤 방식인가요. 직접 열어서 확인해 보고, 왜 그렇게 만들었을지 생각해 보세요. 그리고 어떤 폼에는 어떤 방식이 맞을지도요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 로그인 폼을 React Hook Form 으로

채점 포인트

항목 확인할 것 배점
TextField 재사용 라벨·입력·에러 묶음을 새로 만들지 않고 그대로 썼는가 ★★★
제네릭 인자 useForm<LoginValues>() 처럼 값 타입을 넘겼는가 ★★★
에러 메시지 표시 errors.username?.message 로 물음표를 붙여 꺼냈는가 ★★☆
제출 잠금 isSubmitting 으로 버튼을 잠갔는가 ★★☆
비밀번호 규칙 판단 로그인에 길이 규칙을 넣을지 이유와 함께 적었는가 ★★★

풀이 예시

회원가입 폼과 구조가 같습니다. 칸이 둘로 줄었을 뿐이에요.

tsx
// apps/web-spa/src/components/LoginForm.tsx
import { useForm } from 'react-hook-form';
import { Button } from './Button';
import { TextField } from './TextField';

export interface LoginValues {
  username: string;
  password: string;
}

interface LoginFormProps {
  onSubmit: (values: LoginValues) => void | Promise<void>;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginValues>({ defaultValues: { username: '', password: '' } });

  return (
    <form className="signup-form" onSubmit={handleSubmit(onSubmit)}>
      <TextField
        id="login-username"
        label="사용자 이름"
        error={errors.username?.message}
        {...register('username', { required: '사용자 이름을 입력해 주세요' })}
      />

      <TextField
        id="login-password"
        label="비밀번호"
        type="password"
        error={errors.password?.message}
        {...register('password', { required: '비밀번호를 입력해 주세요' })}
      />

      <Button className="signup-submit" type="submit" disabled={isSubmitting}>
        {isSubmitting ? '들어가는 중...' : '로그인'}
      </Button>
    </form>
  );
}

App.tsx 에는 이렇게 넣습니다.

tsx
// apps/web-spa/src/App.tsx
<Section title="로그인">
  <LoginForm onSubmit={(values) => console.log('로그인 요청', values.username)} />
</Section>

여기서 눈여겨볼 게 하나 있어요. 회원가입 폼에서는 handleSubmit(handleValid) 처럼 중간 함수를 하나 뒀는데, 여기서는 handleSubmit(onSubmit) 으로 곧장 넘겼습니다. 제출한 뒤에 reset() 을 부를 일이 없어서예요.

로그인 성공은 폼이 비워지는 게 아니라 다른 화면으로 넘어가는 것이니까요. 그 화면 이동은 라우팅을 배우는 C-1 에서 붙입니다.

비밀번호 규칙을 넣어야 할까

넣지 않는 쪽을 권합니다. 이유가 셋 있어요.

하나. 도움이 안 됩니다. 규칙은 새 비밀번호를 만들 때 지키라고 있는 것이지, 이미 만든 걸 확인할 때 쓰는 게 아닙니다. 이미 가입한 사람의 비밀번호는 이미 규칙을 통과했어요.

둘. 오히려 막습니다. 규칙이 8자였다가 나중에 10자로 바뀌었다고 해봅시다. 예전에 9자로 가입한 사람은 자기 비밀번호를 정확히 쳤는데도 로그인 버튼을 못 누르게 됩니다. 비밀번호가 틀린 것도 아닌데요.

셋. 정보를 흘립니다. 로그인 화면이 8자 이상이어야 해요 라고 알려주면, 공격자에게 이 서비스 비밀번호는 최소 8자라는 힌트를 주는 셈입니다. 시도해 볼 후보를 줄여주는 거죠.

그래서 로그인 폼의 검증은 비어 있는지만 봅니다. 나머지 판단은 서버가 하고, 틀리면 아이디 또는 비밀번호가 올바르지 않습니다 라는 한 문장만 돌려줍니다.

⚠️ 이 문장이 뭉뚱그려져 있는 것도 일부러 그런 겁니다. 그런 아이디는 없습니다 라고 정확히 알려주면, 공격자가 어떤 아이디가 실제로 있는지 하나씩 확인할 수 있게 되거든요.

자주 나오는 실수

TextField 를 새로 만드는 것. 로그인용으로 따로 만들 이유가 없습니다. 오늘 만든 것이 ComponentProps<'input'> 를 받고 있어서 type="password" 도 그냥 통과해요.

제출 함수를 handleSubmit 없이 붙이는 것. onSubmit={onSubmit} 이라고 쓰면 타입 검사가 막습니다. handleSubmit 이 감싸줘야 preventDefault 와 검사가 돌아요.

useForm() 을 꺾쇠 없이 쓰는 것. 동작은 합니다. 그런데 register('usernam') 같은 오타를 아무도 안 잡아줍니다.

💡 튜터의 한마디 — 같은 구조를 두 번째로 쓰면서 뭐가 반복되는지 눈에 들어왔을 겁니다. defaultValues 를 적는 것, 필드 이름을 타입에 한 번 register 에 한 번 적는 것. 다음 시간에 그 반복이 어떻게 사라지는지 보게 됩니다.

🎯 [과제 2 예시답안] 입력창이 어떻게 동작하는지 직접 확인하기

채점 포인트

항목 확인할 것 배점
줄 수 비교 양쪽 콘솔 줄 수를 세고 왜 다른지 설명했는가 ★★★
ref 를 뺀 결과 제출 값이 빈 객체라는 것을 확인하고 이유를 적었는가 ★★★
제네릭 인자 유무 오타가 잡히는 경우와 안 잡히는 경우를 갈라 적었는가 ★★★
value + defaultValue 경고 문구를 확인하고 왜 문제인지 적었는가 ★★☆
되돌리기 확인이 끝나고 원래 코드로 되돌렸는가 ★☆☆

풀이 예시

하나. 양쪽에 같은 글자를 쳤을 때

abc 세 글자를 쳤다면 이렇게 나옵니다.

상태가 든 쪽 DOM 이 든 쪽
새로고침 직후 2줄 2줄
abc 를 친 뒤 (누적) 8줄 2줄

왼쪽은 글자마다 두 줄씩 늘었습니다. 한 글자를 칠 때마다 setNickname 이 불리고, 상태가 바뀌니 컴포넌트가 다시 그려지기 때문이에요. 두 줄씩인 건 개발 모드에서 React 가 일부러 두 번 돌리기 때문이고요.

오른쪽은 처음 두 줄에서 멈춰 있습니다. onChange 도 없고 상태도 안 바뀌니 다시 그릴 이유가 없어요.

그런데도 오른쪽 화면에 글자가 보이는 이유가 핵심입니다. 그건 React 가 그린 게 아니라 브라우저가 그린 것이에요. <input> 은 원래 사용자가 친 글자를 스스로 보여주는 요소입니다. React 없이 HTML 만 있어도 그렇게 동작해요. Controlled 방식은 오히려 그 기본 동작을 value 로 덮어써서 React 가 대신 정하게 만든 겁니다.

둘. onChange 를 지웠을 때

글자가 아예 안 들어갑니다. 그리고 콘솔에 이 경고가 뜹니다.

텍스트
You provided a `value` prop to a form field without an `onChange` handler.
This will render a read-only field.

value 는 화면에 보일 글자를 정하는데 그 값을 바꿀 통로가 없으니, 영원히 처음 값에 고정됩니다. 콘솔 줄 수도 안 늘어요. 다시 그릴 일 자체가 없으니까요.

셋. valuedefaultValue 를 같이 줬을 때

텍스트
SignUpForm contains an input of type text with both value and defaultValue props.
Input elements must be either controlled or uncontrolled (specify either the value
prop, or the defaultValue prop, but not both). Decide between using a controlled or
uncontrolled input element and remove one of these props.

맨 앞에는 여러분이 고친 컴포넌트 이름이 들어갑니다. 그리고 두 번째 문장이 핵심이에요. 입력창은 Controlled 이거나 Uncontrolled 이거나 둘 중 하나여야 합니다.

둘을 같이 주면 React 로서는 판단할 수가 없습니다. defaultValue 는 "처음 한 번만 넣고 그다음은 DOM 에 맡겨라" 는 뜻이고, value 는 "매번 이 값으로 맞춰라" 는 뜻이니 정반대 지시니까요. 실제로는 value 가 이기고 defaultValue 는 무시됩니다. 화면에는 value 에 준 값이 뜹니다.

넷. register 를 하나씩 붙였을 때

똑같이 동작합니다.

tsx
const field = register('username');

<input
  className="signup-input"
  id="signup-username"
  name={field.name}
  onChange={field.onChange}
  onBlur={field.onBlur}
  ref={field.ref}
/>

스프레드는 이 넷을 짧게 적는 방법일 뿐이에요. 값 제출도, 검증도, 에러 메시지도 전부 그대로 돕니다.

다섯. ref 만 뺐을 때 (이 과제의 핵심)

화면은 멀쩡합니다. 글자도 잘 쳐져요. 그런데 제출하면 이렇게 넘어옵니다.

텍스트
{}

빈 객체입니다. 분명히 jaehoon 이라고 쳤는데도요.

이유는 React Hook Form 이 Uncontrolled 방식으로 동작하기 때문입니다. 값은 DOM 안에 들어 있고, 라이브러리는 그걸 꺼내 오려고 ref 로 입력창을 붙잡아 둡니다. 손잡이를 안 주면 어느 요소에서 값을 꺼내야 할지 모릅니다.

그럼 onChange 는 왜 있느냐고 물으실 텐데, 그건 "이 칸에 무슨 일이 있었다" 는 신호를 받는 용도예요. 검증을 다시 돌리거나 dirty 여부를 기록하는 데 씁니다. 값 자체를 나르는 건 아닙니다.

⚠️ 이게 Controlled 방식과의 결정적 차이입니다. Controlled 는 onChange 가 값을 나르니 ref 가 없어도 되지만, Uncontrolled 는 ref 가 유일한 통로예요.

여섯. 필드 이름에 오타를 냈을 때

useForm<SignUpValues>() 인 상태에서 register('usernam') 이라고 쓰면 이렇게 걸립니다.

텍스트
Argument of type '"usernam"' is not assignable to parameter of type
'"username" | "email" | "password" | "passwordConfirm"'.

쓸 수 있는 이름 넷을 그대로 나열해서 알려줍니다.

그런데 꺾쇠를 지우고 useForm() 이라고 쓰면 아무 에러도 안 납니다. register('아무거나') 도 통과해요.

이 차이가 중요합니다. 타입 검사는 우리가 알려준 만큼만 도와줍니다. 어떤 필드가 있는지 안 알려주면 무엇이든 받아주고, 오타는 조용히 실행 시점까지 갑니다. 그러면 그 칸의 값이 폼에 안 담기는데 화면상으로는 멀쩡해서 찾기가 어려워요.

자주 나오는 실수

ref 를 뺐는데 값이 빈 객체인 걸 "폼이 고장났다" 고 결론짓는 것. 고장이 아니라 설계대로 동작한 겁니다. 왜 그런지까지 적어야 이 실험의 의미가 살아요.

되돌리기를 잊는 것. 특히 ref 를 뺀 채로 두면 그 칸이 조용히 안 담깁니다. 확인이 끝나면 원래대로 돌려주세요.

💡 튜터의 한마디 — 다섯 번째 항목을 꼭 직접 해보세요. register 가 왜 네 가지를 돌려주는지, 그중 ref 가 왜 특별한지는 글로 읽는 것보다 빈 객체를 한 번 보는 게 훨씬 오래 남습니다.

🤔 [생각해볼 주제 1] 라이브러리를 언제 얹을 것인가

문제 상황 요약

오늘 폼 라이브러리를 하나 얹어 코드가 짧아졌습니다. 그런데 Step 3 에서 만든 코드는 아무에게도 의존하지 않았죠. 번들 크기, 팀원의 학습 비용, 관리 중단 위험은 라이브러리를 쓸 때만 생기는 비용입니다.

무엇을 기준으로 판단해야 할까요. 그리고 그 기준은 폼 말고 다른 영역에도 적용될까요.

튜터의 가이드 및 해설

먼저 잘못된 기준부터 짚고 갈게요. "직접 만들 수 있느냐"는 기준이 아닙니다. 우리는 Step 3 에서 실제로 만들었잖아요. 만들 수 있어도 안 만드는 게 나은 경우가 있고, 반대도 있습니다.

실무에서 쓸 만한 기준은 셋입니다.

하나. 이 문제가 얼마나 깊은가. 폼은 겉보기보다 훨씬 깊습니다. 검증 시점(칠 때마다냐 벗어날 때냐 제출할 때냐), 중첩된 필드, 배열 필드(주소를 여러 개 추가하는 폼), 조건부 필드, 서버 에러 병합, 접근성. 우리가 오늘 만든 건 이 중 두어 개였어요. 나머지를 만나면 결국 하나씩 만들게 됩니다.

반대로 얕은 문제도 있습니다. 문자열 앞뒤 공백을 지우는 함수 때문에 라이브러리를 얹지는 않죠.

둘. 몇 번 쓸 것인가. 폼이 하나면 손으로 써도 됩니다. 열 개면 이야기가 달라져요. 두 번째 폼을 만들면서 첫 번째를 복사하고 있다면 그게 신호입니다.

셋. 틀렸을 때 누가 아는가. 폼 검증이 어긋나면 사용자가 가입을 못 하고, 우리는 그 사실을 모릅니다. 이렇게 조용히 실패하는 영역일수록 검증된 것을 쓰는 게 낫습니다.

이 셋을 다른 영역에 대보면 판단이 갈립니다.

영역 문제의 깊이 판단
날짜·시간 처리 매우 깊다 (시간대·서머타임·달력) 🌟 반드시 라이브러리
깊다 라이브러리 권장
상태 관리 중간 (규모에 따라) 규모를 보고 결정
문자열 유틸 얕다 직접

날짜가 가장 분명한 예입니다. "이 두 날짜 사이가 며칠인가" 는 쉬워 보이지만 시간대와 서머타임이 끼는 순간 직접 만든 코드는 거의 반드시 틀립니다. 그런데 틀려도 대부분의 날에는 잘 돌아서 몇 달 뒤에야 발견돼요.

마지막으로 비용을 줄이는 방법 하나. 라이브러리를 우리 코드로 감싸두는 것입니다. 오늘 만든 TextField 가 그런 예예요. 나중에 폼 라이브러리를 갈아타도 고칠 곳이 TextField 와 폼 컴포넌트들로 한정됩니다.

🎯 면접관을 홀리는 핵심 멘트

"직접 만들 수 있느냐가 아니라 그 문제가 얼마나 깊은지, 몇 번 쓸 것인지, 틀렸을 때 조용히 실패하는지를 봅니다. 폼과 날짜처럼 겉보기보다 깊고 조용히 실패하는 영역은 검증된 것을 쓰고, 대신 우리 컴포넌트로 한 겹 감싸서 갈아탈 여지를 남겨둡니다."

🤔 [생각해볼 주제 2] 검증은 어디에서 해야 하나

문제 상황 요약

브라우저에서 이메일 모양과 비밀번호 길이를 검사했는데, 백엔드에도 같은 검사가 이미 들어 있습니다. 사용자 이름 규칙 4~20자 도 서버가 똑같이 확인해요.

같은 규칙을 두 곳에 적어두면 한쪽만 고쳤을 때 어긋납니다. 그렇다고 한쪽을 없애면 어떻게 될까요.

튜터의 가이드 및 해설

결론부터 말하면 둘 다 해야 하고, 이 중복은 없앨 수 없습니다. 두 검사가 하는 일이 아예 다르기 때문이에요.

프론트엔드 검증만 하면 검증이 아예 없는 것과 같습니다. 브라우저는 사용자 컴퓨터에서 돌아가는 코드예요. 개발자 도구를 열어 우리 코드를 고칠 수도 있고, 아예 브라우저 없이 서버에 요청을 직접 보낼 수도 있습니다.

Bash
curl -X POST http://localhost:8090/api/auth/signup \
  -H 'Content-Type: application/json' \
  -d '{"username":"A","password":"1"}'

이 한 줄 앞에서 우리가 만든 폼은 아무 역할도 못 합니다. 그래서 서버 검증은 선택이 아니라 필수예요.

서버 검증만 하면 안전하긴 한데 쓰기 괴로운 앱이 됩니다. 비밀번호가 짧다는 걸 알려면 네 칸을 다 채우고 버튼을 누르고 응답을 기다려야 해요. 오타 하나에 왕복이 한 번씩 생깁니다. 네트워크가 느리면 몇 초씩 걸리고요.

정리하면 이렇습니다.

프론트엔드 검증 서버 검증
목적 사용자 경험 안전성·데이터 정합성
없으면 불편하다 뚫린다
우회 가능한가 쉽게 가능 불가능
반응 속도 즉시 왕복 필요

그래서 실무의 원칙은 이겁니다. 프론트는 빠른 안내, 서버는 진짜 방어. 프론트 검증은 사용자를 돕는 편의 기능이지 보안 장치가 아니라는 걸 분명히 알고 있어야 합니다.

그럼 중복은 어떻게 줄일까요. 완전히 없앨 수는 없지만 방법은 있습니다.

하나. 서버 응답을 프론트가 그대로 보여주기. 서버가 { field: 'username', message: '이미 사용 중입니다' } 처럼 어느 칸이 왜 틀렸는지 돌려주면, 프론트는 그 메시지를 해당 칸 아래에 띄우기만 하면 됩니다. 규칙을 옮겨 적지 않아도 돼요.

둘. 규칙을 한 곳에 적어두고 양쪽이 읽기. API 명세에서 검증 규칙을 뽑아 프론트 코드로 만들어내는 방식입니다. 손이 많이 가서 팀 규모가 커야 값을 합니다.

셋. 같은 언어를 쓰면 규칙을 공유하기. 프론트와 백엔드가 둘 다 TypeScript 라면 검증 규칙을 패키지 하나로 만들어 양쪽이 가져다 쓸 수 있습니다. 다만 우리 프로젝트는 백엔드가 Java 라 이 방법은 못 씁니다.

현실적으로는 첫 번째가 가장 자주 쓰입니다. 그리고 이때 서버 에러를 폼의 에러 메시지 자리에 합쳐 넣는 일을 폼 라이브러리가 도와줘요. 실제 연결은 C-3 에서 합니다.

🎯 면접관을 홀리는 핵심 멘트

"프론트 검증은 사용자 경험이고 서버 검증은 보안입니다. 프론트 코드는 사용자 손에 있어서 언제든 우회되니까요. 그래서 둘 다 두되, 중복을 줄이려면 규칙을 옮겨 적는 대신 서버가 어느 필드가 왜 틀렸는지 돌려주고 프론트는 그걸 표시만 하게 만듭니다."

🤔 [생각해볼 주제 3] 사용자에게 언제 틀렸다고 말할 것인가

문제 상황 요약

우리 폼은 제출 버튼을 눌러야 메시지를 띄우고, 한 번 틀린 뒤에는 고치는 즉시 사라집니다. Step 3 에서 손으로 만든 폼은 칸을 벗어날 때 띄웠고요. 한 글자마다 검사하는 방식도 있습니다.

어떤 폼에 어떤 방식이 맞을까요.

튜터의 가이드 및 해설

세 가지를 각각 사용자 입장에서 따라가 봅시다. 이메일 칸에 jaehoon@example.com 을 치는 상황이에요.

한 글자마다 검사하면 j 를 치는 순간 이메일 모양이 아니에요 가 뜹니다. 당연하죠, 아직 다 안 쳤으니까요. 그리고 @ 를 칠 때까지 빨간 글씨가 계속 따라다닙니다. 아직 틀리지도 않았는데 틀렸다고 말하는 셈이에요.

제출할 때만 검사하면 네 칸을 다 채우고 버튼을 눌렀더니 네 칸이 한꺼번에 빨개집니다. 어디부터 고쳐야 할지 한눈에 안 들어오고, 첫 칸이 틀렸다는 걸 마지막에야 알게 돼요. 이게 React Hook Form 의 기본값이고 오늘 우리 폼이 이렇게 동작합니다.

칸을 벗어날 때 검사하면 사용자가 그 칸을 다 썼다고 판단한 순간에 알려줍니다. 자연스러워요. Step 3 에서 손으로 만든 폼이 이 방식이었죠.

그래서 많은 팀이 쓰는 절충안이 이겁니다.

처음에는 칸을 벗어날 때, 한 번 틀린 뒤부터는 칠 때마다.

한 번 틀리기 전에는 조용히 있다가, 틀렸다고 말한 뒤부터는 고쳐지는 걸 즉시 보여주는 거예요. 우리 폼도 뒷부분은 이미 이렇게 동작합니다. 앞부분만 바꾸면 됩니다.

tsx
useForm<SignUpValues>({
  defaultValues: EMPTY_VALUES,
  mode: 'onBlur',
});

mode 가 처음 검사 시점, reValidateMode 가 한 번 틀린 뒤의 시점입니다. reValidateMode 는 기본이 이미 'onChange' 라 따로 안 적어도 돼요.

⚠️ 라이브러리의 기본값이 곧 가장 좋은 선택이라는 뜻은 아닙니다. React Hook Form 이 제출 시점을 기본으로 둔 건 검사를 가장 적게 돌리는 값이기 때문이에요. 어떤 시점이 맞는지는 우리가 정해야 합니다.

그리고 이 결정은 필드마다 갈리기도 합니다.

필드 성격 언제 알려주나 이유
이메일·전화번호 벗어날 때 치는 도중에는 늘 미완성 상태
비밀번호 만들기 칠 때마다 조건 여러 개를 채워가는 과정을 보여줘야 함
사용자 이름 중복 확인 멈췄을 때 서버에 물어야 해서 글자마다 보내면 안 됨
약관 동의 제출할 때 안 누른 걸 미리 지적할 이유가 없음

비밀번호 만들기가 재밌는 예외입니다. 8자 이상, 영문 포함, 숫자 포함 을 목록으로 띄워두고 조건이 채워질 때마다 하나씩 체크되게 만들죠. 이건 틀렸다고 지적하는 게 아니라 진행 상황을 보여주는 것이라 칠 때마다 갱신되는 게 맞습니다.

같은 "칠 때마다" 인데 하나는 잔소리가 되고 하나는 도움이 됩니다. 차이는 말투가 부정이냐 긍정이냐예요. 이메일 모양이 아니에요 는 지적이고, 8자 이상 ✅ 은 안내입니다.

마지막으로 사용자 이름 중복 확인처럼 서버에 물어야 하는 검증은 시점이 하나 더 갈립니다. 글자마다 요청을 보내면 서버가 감당을 못 해요. 사용자가 타이핑을 멈춘 뒤 잠깐 기다렸다가 한 번만 보내는 방식을 씁니다. 이 기법은 C-3 에서 다룹니다.

🎯 면접관을 홀리는 핵심 멘트

"처음에는 칸을 벗어날 때, 한 번 틀린 뒤부터는 칠 때마다 를 기본으로 잡습니다. 아직 다 치지도 않았는데 틀렸다고 말하면 잔소리가 되고, 제출할 때 한꺼번에 알려주면 고칠 곳을 찾기 어려우니까요. React Hook Form 의 기본값은 제출 시점이라 mode: 'onBlur' 로 앞당깁니다. 다만 비밀번호 만들기처럼 조건을 채워가는 과정을 보여주는 필드는 예외로 칠 때마다 갱신합니다. 지적이 아니라 안내니까요."

전체 목록 리액트