React useRef 실습: 입력 포커스와 state의 역할 나누기

2026.09.12·수정 2026.09.14·약 24분·작성: 해비·블로그 소개

이번 실습의 목표

입력값과 저장 결과는 state로 표시하고, 실제 input 노드에는 ref로 접근하는 한 줄 메모 폼을 만듭니다. 버튼으로 입력창에 초점을 옮기고, 빈 제출을 안내하며, 다음 작성으로 돌아가는 동작까지 구현합니다.

사용자가 버튼을 누르면 ref.current가 가리키는 입력 요소의 focus()를 호출합니다. ref 값의 변경 자체는 화면을 다시 렌더링하지 않습니다.
사용자가 버튼을 누르면 ref.current가 가리키는 입력 요소의 focus()를 호출합니다. ref 값의 변경 자체는 화면을 다시 렌더링하지 않습니다.

어떤 값을 어디에 둘까

이 실습의 출발점은 제어 입력입니다. 타이핑한 문자를 화면과 함께 관리하는 방법은 useState와 제어 컴포넌트 폼에서 이미 배웠다고 가정합니다. 여기서는 입력값 저장 방식을 바꾸지 않고, 사용자의 요청에 따라 브라우저 입력 요소의 focus 메서드를 호출하는 통로만 추가합니다. DOM 전체를 직접 그리는 방식으로 전환하는 것이 아닙니다.

화면에는 입력 중인 문장, 글자 수, 오류 안내, 마지막으로 저장한 문장이 나타납니다. 이 값들은 바뀌면 JSX 결과도 바뀌어야 하므로 state입니다. 반면 input 노드의 참조는 그 자체를 화면에 출력할 필요가 없습니다. 브라우저에게 초점을 옮기라고 요청할 때만 사용하므로 useRef로 보관합니다. 저장 버튼은 예제의 메모리 상태만 변경하며 서버나 브라우저 저장소에 기록하지 않습니다.

위치 사용 이유
draft useState 입력과 글자 수를 즉시 표시
saved useState 제출된 마지막 내용을 표시
error useState 검증 오류와 ARIA 속성을 표시
inputRef useRef 이벤트에서 input.focus() 호출

draft와 saved가 모두 문자열이어도 같은 정보를 중복해서 저장한 것은 아닙니다. 하나는 편집 중인 값이고 다른 하나는 제출한 시점의 값이므로 사용자가 타이핑할 때 서로 달라도 정상입니다. 입력 길이는 draft.length로 계산합니다. 길이를 별도 state로 저장하면 입력값을 지울 때 길이도 따로 맞춰야 하므로 불필요한 동기화가 생깁니다.

파일 준비와 실행

다운로드 파일은 HTML, CSS, JSX, 테스트를 분리한 완성 프로젝트입니다. 빈 폴더에 압축을 풀고 package.json이 있는 위치에서 명령을 실행합니다. Node.js는 Vite 8을 실행할 수 있는 22.12 이상 버전을 사용하세요. 이 실습에서는 React 19.3.0, Vite 8.3.0, Vitest 4.1.11을 사용했습니다. 다른 기존 프로젝트에 합칠 때에는 기존 의존성을 무작정 교체하지 말고 실습 폴더를 별도로 실행하세요.

실습 전체 코드 ZIP 다운로드

npm install
npm run dev
npm test
npm run build

개발 서버가 출력한 주소를 브라우저로 열면 됩니다. npm test는 테스트를 한 번 실행하고 종료하며 npm run build는 배포용 dist 폴더를 생성합니다. 빌드는 서버 배포나 데이터 영구 저장까지 수행하지 않습니다. 의존성 설치에는 패키지 레지스트리 접근이 필요합니다.

package.json

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

전체 코드

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React 실습</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

package.json

{
  "name": "react-useref-dom-focus-practice",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7"
  }
}

src/App.jsx

import React, { useRef, useState } from 'react';

export default function App() {
  const inputRef = useRef(null);
  const [draft, setDraft] = useState('');
  const [saved, setSaved] = useState('');
  const [error, setError] = useState('');
  function focusInput() {
    inputRef.current?.focus();
  }
  function handleSubmit(event) {
    event.preventDefault();
    const text = draft.trim();
    if (!text) {
      setError('메모를 한 글자 이상 입력하세요.');
      focusInput();
      return;
    }
    setSaved(text);
    setError('');
  }
  function startNext() {
    setDraft('');
    setError('');
    focusInput();
  }
  return (
    <main>
      <h1>한 줄 메모</h1>
      <p>메모를 저장한 뒤 다음 메모 작성을 눌러 입력을 이어 가세요.</p>
      <form onSubmit={handleSubmit} noValidate>
        <label htmlFor="memo">메모 내용</label>
        <input
          id="memo"
          ref={inputRef}
          value={draft}
          aria-invalid={Boolean(error)}
          aria-describedby={error ? 'memo-help memo-error' : 'memo-help'}
          onChange={(event) => {
            setDraft(event.target.value);
            setError('');
          }}
        />
        <p id="memo-help">앞뒤 공백을 제외한 내용을 저장합니다.</p>
        <p>입력 길이: {draft.length}자</p>
        {error && (
          <p id="memo-error" className="error" role="alert">
            {error}
          </p>
        )}
        <button type="submit">메모 저장</button>
        <button type="button" onClick={focusInput}>
          입력으로 이동
        </button>
        <button type="button" onClick={startNext}>
          다음 메모 작성
        </button>
      </form>
      <p role="status">{saved ? `저장된 메모: ${saved}` : '저장된 메모가 없습니다.'}</p>
    </main>
  );
}

src/App.test.jsx

import React, { StrictMode } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
function setup() {
  render(
    <StrictMode>
      <App />
    </StrictMode>,
  );
  return {
    user: userEvent.setup(),
    input: screen.getByRole('textbox', { name: '메모 내용' }),
  };
}
test('명시적인 이동 버튼은 내용을 유지하며 입력에 초점을 준다', async () => {
  const { user, input } = setup();
  await user.type(input, '회의');
  await user.click(screen.getByRole('button', { name: '입력으로 이동' }));
  expect(document.activeElement).toBe(input);
  expect(input.value).toBe('회의');
  expect(screen.getByText('입력 길이: 2자')).toBeTruthy();
});
test('공백 제출은 저장하지 않고 오류와 입력 초점을 제공한다', async () => {
  const { user, input } = setup();
  await user.type(input, '   ');
  await user.click(screen.getByRole('button', { name: '메모 저장' }));
  expect(screen.getByRole('alert').textContent).toContain('한 글자');
  expect(input.getAttribute('aria-invalid')).toBe('true');
  expect(document.activeElement).toBe(input);
  expect(screen.getByRole('status').textContent).toContain('없습니다');
});
test('저장 후 다음 작성은 입력만 지우고 저장된 내용을 유지한다', async () => {
  const { user, input } = setup();
  await user.type(input, '  회의 준비  ');
  await user.click(screen.getByRole('button', { name: '메모 저장' }));
  expect(screen.getByRole('status').textContent).toBe('저장된 메모: 회의 준비');
  await user.click(screen.getByRole('button', { name: '다음 메모 작성' }));
  expect(input.value).toBe('');
  expect(document.activeElement).toBe(input);
  expect(screen.getByRole('status').textContent).toBe('저장된 메모: 회의 준비');
});
test('키보드로 이동 버튼을 실행할 수 있다', async () => {
  const { user, input } = setup();
  await user.tab();
  expect(document.activeElement).toBe(input);
  await user.tab();
  await user.tab();
  await user.keyboard('{Enter}');
  expect(document.activeElement).toBe(input);
});

src/main.jsx

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

src/styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: sans-serif;
}
main {
  width: min(100% - 32px, 720px);
  margin: 40px auto;
}
form,
.actions {
  display: grid;
  gap: 12px;
}
input,
button {
  min-height: 44px;
  padding: 10px;
  font: inherit;
  border: 1px solid #777;
  background: #fff;
  color: #222;
}
button {
  cursor: pointer;
}
input:not([type='checkbox']) {
  width: 100%;
}
input:focus-visible,
button:focus-visible {
  outline: 3px solid #333;
  outline-offset: 3px;
}
ul {
  padding: 0;
  list-style: none;
  display: grid;
  gap: 12px;
}
li {
  border: 1px solid #aaa;
  padding: 16px;
  display: flex;
  gap: 12px;
  align-items: center;
  flex-wrap: wrap;
}
li label {
  flex: 1;
  display: flex;
  gap: 12px;
  align-items: center;
  overflow-wrap: anywhere;
}
p {
  line-height: 1.7;
}
.error {
  font-weight: 700;
}

src/test-setup.js

import { cleanup } from '@testing-library/react';
afterEach(cleanup);

vite.config.js

import { defineConfig } from 'vite';
export default defineConfig({
  test: { environment: 'jsdom', globals: true, setupFiles: './src/test-setup.js' },
});

package.json 전체 코드 보기

index.html

index.html 전체 코드 보기

src/main.jsx

src/main.jsx 전체 코드 보기

완성 컴포넌트: 입력과 DOM 참조 연결

src/App.jsx

src/App.jsx 전체 코드 보기

useRef(null)의 초기값은 아직 연결된 DOM 노드가 없다는 뜻입니다. JSX의 ref={inputRef}로 실제 HTML input에 객체를 연결하면 React가 DOM 반영 단계에서 current를 채웁니다. 입력 요소가 사라지면 다시 null이 될 수 있습니다. focusInput 함수의 선택적 연결 연산자는 노드가 없는 경우 메서드 호출을 건너뛰게 합니다. 이것만으로 나중에 나타날 입력 요소에 자동으로 초점이 이동하는 것은 아닙니다.

컴포넌트 본문에서는 ref.current를 읽어 화면을 결정하거나 ref.current에 값을 대입하지 않습니다. 이번 코드에서 current를 읽는 곳은 버튼 또는 제출에서 호출되는 focusInput뿐입니다. inputRef 객체를 ref 속성에 전달하는 것은 React가 노드를 연결하도록 지정하는 과정이며, 렌더링 중 current를 직접 읽는 코드와 구별해야 합니다.

각 이벤트의 결과를 순서대로 확인하기

첫 번째 동작은 입력으로 이동입니다. 사용자가 버튼을 누르면 focusInput이 이미 화면에 있는 input을 찾고 focus를 호출합니다. 입력 문자열은 변경하지 않습니다. 먼저 메모에 회의를 입력하고 다른 버튼으로 이동한 다음 입력으로 이동을 실행하세요. 커서가 입력창으로 돌아오고 회의라는 내용과 2자라는 표시가 유지되어야 합니다.

두 번째 동작은 빈 제출입니다. handleSubmit은 기본 폼 제출을 막고 draft.trim()으로 검사합니다. 공백만 입력했다면 error를 설정한 뒤 같은 입력창으로 초점을 돌립니다. HTML required만으로는 공백 문자열을 이번 규칙대로 거절할 수 없으므로 이 예제는 noValidate와 명시적 검증을 사용합니다. 오류 문구는 화면에 보이며 input의 aria-invalid와 설명 연결도 함께 갱신됩니다.

세 번째 동작은 정상 저장입니다. 앞뒤에 공백이 있는 문장을 제출하면 saved에는 공백을 정리한 값이 들어갑니다. 편집 중인 draft는 유지하므로 입력창이 즉시 사라지거나 비워지지 않습니다. 저장 버튼을 눌렀다는 이유만으로 포커스를 강제로 옮기지는 않습니다. 키보드 사용자는 다음 메모 작성 버튼으로 이동해 후속 동작을 선택할 수 있습니다.

네 번째 동작은 다음 메모 작성입니다. draft와 error를 비우고 기존 input에 초점을 옮깁니다. saved는 바꾸지 않으므로 이전 저장 결과가 계속 보입니다. setDraft가 DOM을 즉시 바꾸는 것은 아니지만 포커스 대상 input은 계속 마운트된 같은 노드입니다. 그래서 이 동작에 타이머나 강제 동기 갱신이 필요하지 않습니다. 다음 렌더가 반영되면 입력값만 빈 문자열로 바뀝니다.

만약 다음 작성 버튼이 input을 조건부로 처음 표시하는 설계라면 상황이 달라집니다. 노드가 없는 시점에 ref.current?.focus()를 실행하면 아무 일도 하지 않습니다. 조건부 마운트 뒤 DOM이 연결되는 시점을 고려해야 하며, 콜백 ref나 외부 DOM과의 동기화가 필요한 Effect를 검토할 수 있습니다. 현재 폼에서는 input을 항상 유지하므로 그 문제를 만들지 않습니다. 입력값이 바뀔 때마다 실행되는 Effect로 초점을 빼앗는 구현은 사용자의 이동을 방해합니다.

보이는 레이블과 키보드 초점 유지

src/styles.css

src/styles.css 전체 코드 보기

label의 htmlFor와 input의 id를 맞추면 레이블을 눌러 입력창으로 이동할 수 있고 보조 기술에도 입력 이름이 전달됩니다. placeholder는 여기서 레이블을 대신하지 않습니다. 오류가 없을 때는 도움말만 aria-describedby에 연결하고, 오류가 있을 때는 도움말과 오류의 id를 함께 연결합니다. 오류를 수정하려고 타이핑하면 오래된 오류 상태가 해제됩니다.

버튼은 form 안에서 기본적으로 제출할 수 있으므로 이동과 다음 작성 버튼에 type=”button”을 명시했습니다. 이 속성이 빠지면 초점만 옮기려다가 검증도 실행되는 문제가 생깁니다. 눈에 보이는 버튼 이름과 실제 동작을 일치시켜 두면 자동 테스트에서 버튼을 이름으로 찾기도 쉽습니다. 순서를 바꾸기 위한 양수 tabIndex는 사용하지 않았습니다.

스타일은 회색 계열과 전체 테두리, 충분한 입력 높이로 구성했습니다. focus-visible의 외곽선은 Tab으로 이동하는 위치를 보여 줍니다. outline을 없애면 document.activeElement가 바뀌어도 사용자는 초점을 찾기 어려울 수 있습니다. 자동 테스트는 DOM의 초점 대상을 확인하지만 외곽선이 실제 화면에서 또렷한지까지 판정하지 않으므로 브라우저에서 별도 확인할 항목입니다.

사용자 동작을 기준으로 테스트하기

vite.config.js

vite.config.js 전체 코드 보기

src/test-setup.js

src/test-setup.js 전체 코드 보기

src/App.test.jsx

src/App.test.jsx 전체 코드 보기

첫 테스트는 포커스 이동과 값 보존을 함께 검증합니다. focus 함수가 몇 번 호출됐는지 대신 document.activeElement가 실제 입력 요소인지 검사합니다. 두 번째 테스트는 공백 입력이 저장되지 않는지, 오류가 표시되는지, ARIA 상태가 바뀌는지까지 확인합니다. 초점만 성공하고 잘못된 데이터가 저장되는 구현도 잡을 수 있습니다.

세 번째 테스트는 정상 저장과 다음 작성 사이의 상태 경계를 확인합니다. 입력이 지워졌다는 사실만으로 성공을 판단하지 않고 이전 저장 문장이 남는지도 봅니다. 네 번째 테스트는 Tab 두 번으로 이동 버튼에 도착한 뒤 Enter로 실행합니다. 마우스 클릭 경로와 키보드 경로를 모두 확인하되 테스트가 실제 화면의 모든 접근성 문제를 보장한다고 해석하지 않습니다.

제공 런타임에서 Vitest의 jsdom 환경으로 4개 테스트가 통과했고 Vite 프로덕션 빌드도 통과했습니다. 실제 브라우저 시각 검수는 실행하지 않았습니다. 직접 실행할 때는 레이블 클릭, Tab 초점 외곽선, 모바일 폭의 버튼 표시, 긴 한글 문장 입력을 추가로 확인하세요. 한글 자모 조합이나 이모지의 사용자 인식 글자 수와 JavaScript 문자열 length는 항상 같지 않으며 이 예제의 길이는 문자열 길이입니다.

자주 생기는 오해와 다음 단계

입력값을 ref.current.value에서만 읽으면 이 예제의 실시간 길이 표시와 검증 상태를 함께 다루기 어려워집니다. 제어 입력의 값은 state를 통해 변경하고, ref는 focus처럼 React의 선언적 값으로 표현하지 않는 DOM 동작에 한정했습니다. 직접 input.value를 대입하면 React의 state와 브라우저 값이 어긋날 수 있으므로 다음 입력 이벤트에서 예상과 다른 값이 돌아오는 원인이 됩니다.

ref가 다시 렌더링되어도 같은 객체라는 사실은 컴포넌트가 없어져도 영원히 유지된다는 뜻이 아닙니다. 컴포넌트를 제거했다가 새로 만들면 새로운 생명 주기가 시작됩니다. 새로고침하면 saved와 draft 역시 초기화됩니다. 장기 보관이 필요하다면 저장소 읽기, 쓰기 실패, 초기 로딩과 같은 별도 요구사항을 먼저 정한 뒤 확장해야 합니다.

useRef는 성능 최적화를 위해 모든 state를 대체하는 도구가 아닙니다. 화면에 보여 줄 정보를 ref에 넣으면 값이 바뀌어도 화면 갱신을 예약하지 않아 예전 표시가 남습니다. 반대로 단순 DOM 참조를 state에 저장하면 화면에 필요하지 않은 참조 변경 때문에 갱신 구조가 복잡해집니다. 값이 바뀌었을 때 사용자가 새 화면을 봐야 하는지부터 판단하세요.

마지막 연습으로 저장된 메모 삭제 버튼을 추가해 보세요. saved만 빈 문자열로 바꾸고 draft는 유지하면 편집 내용과 저장 결과를 나눈 이유를 다시 확인할 수 있습니다. 다음으로 textarea로 바꿔도 같은 ref 연결과 초점 호출 원리를 적용할 수 있습니다. 자동 초점이나 복잡한 Effect를 추가하기 전에 사용자가 언제 초점 이동을 요청하는지 한 문장으로 정리하는 것이 좋습니다.

공식 문서와 다음 실습

이 글이 도움이 되었나요?

조회 중

React 학습 순서

필수 19개 · 전체 23개

읽음 기록 관리

전체 과정 목차 (23개)
  1. 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
  2. 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
  3. 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
  4. 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
  5. 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
  6. 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
  7. 선택 참고 · React state 업데이트 안됨 문제 해결
  8. 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
  9. 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
  10. 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기 현재 글
  11. 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
  12. 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
  13. 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
  14. 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
  15. 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
  16. 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
  17. 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
  18. 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
  19. 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
  20. 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
  21. 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
  22. 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
  23. 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기