React 입문(a)
React 기초 개념
웹 개발을 위한 React 기초 학습
React의 필수적인 기초를 탐구하고, 첫 번째 React 프로젝트를 생성하는 과정을 안내합니다. 과정이 진행됨에 따라 클라이언트 및 서버 사이드 검색, 원격 데이터 가져오기(fetching), 고급 상태 관리(state management)와 같은 실용적인 기능을 구현하며 React의 기능을 더 깊이 파고들 것입니다. 이러한 실습 중심의 접근 방식은 실제 웹 애플리케이션 개발 과정을 반영합니다. 이 과정이 끝날 때쯤이면 실제 데이터와 원활하게 상호 작용하는 완전한 기능의 React 애플리케이션을 확인하실 수 있습니다.
리액트(React) 소개
싱글 페이지 애플리케이션(SPA, Single Page Application)은 Angular(Google), Ember, Knockout, Backbone과 같은 1세대 SPA 프레임워크와 함께 인기를 얻었습니다. 이러한 프레임워크들은 바닐라 자바스크립트(Vanilla JavaScript)나 제이쿼리(jQuery)의 한계를 넘어 웹 애플리케이션을 더 쉽게 구축할 수 있게 해주었습니다. 2013년 페이스북이 소개한 React는 또 다른 SPA 솔루션으로 등장하여, 자바스크립트로 현대적인 웹 애플리케이션을 구축하는 강력한 방법을 제공했습니다.
SPA가 존재하기 전인 과거로 잠시 돌아가 보겠습니다. 예전에는 웹사이트와 웹 애플리케이션이 서버에서 렌더링(server-rendered)되었습니다. 사용자가 브라우저에서 URL에 접근하면 웹 서버로 요청이 전송되고, 서버는 HTML 파일과 그에 연관된 CSS, 자바스크립트 파일을 반환했습니다. 약간의 네트워크 지연 후, 사용자는 렌더링 된 HTML을 보고 상호작용을 시작할 수 있었습니다. 이후 페이지를 이동할 때마다 이 과정이 반복되었습니다. 이 모델에서는 서버가 대부분의 핵심 작업을 처리했고, 클라이언트는 주로 페이지를 렌더링하는 최소한의 역할만 수행했습니다. 기본적인 HTML과 CSS가 애플리케이션의 구조와 스타일을 잡았고, 자바스크립트(주로 jQuery 형태)는 드롭다운 토글 같은 상호작용이나 툴팁 위치 조정 같은 고급 스타일링을 가능하게 했습니다.
이와 대조적으로, SPA 프레임워크는 중점을 서버에서 클라이언트로 옮겼습니다. SPA에서 서버는 주로 자바스크립트를 최소한의 HTML 파일과 함께 네트워크를 통해 전달합니다. 브라우저는 연결된 자바스크립트 파일을 실행하여 HTML과 CSS를 사용해 전체 애플리케이션을 동적으로 렌더링하며, 상호작용은 자바스크립트에 의존합니다. 가장 극단적인 형태로는, 사용자가 URL을 방문할 때 작은 HTML 파일 하나와 큰 자바스크립트 파일을 받게 됩니다. 짧은 네트워크 및 렌더링 지연 후, 자바스크립트가 브라우저에 콘텐츠를 렌더링합니다. 이후의 페이지 전환은 새로운 파일을 위한 추가적인 서버 요청을 필요로 하지 않습니다. 대신 초기에 로드된 자바스크립트가 동적으로 새 페이지를 렌더링합니다.
React는 다른 SPA 솔루션들과 함께 이러한 변화에 중추적인 역할을 했습니다. 본질적으로 SPA는 폴더와 파일로 구성된 구조화된 자바스크립트 번들로, 하나의 완전한 애플리케이션을 형성합니다. React와 같은 SPA 프레임워크는 이러한 자바스크립트 기반 애플리케이션을 설계할 수 있는 도구를 제공합니다. 사용자가 웹 애플리케이션의 URL을 방문하면 자바스크립트 기반 애플리케이션이 네트워크를 통해 한 번 전송됩니다. 그 시점부터 React(혹은 다른 SPA 프레임워크)가 주도권을 잡고 브라우저에서 모든 것을 HTML로 렌더링하며 자바스크립트로 사용자 상호작용을 처리합니다.
React의 부상과 함께 ’컴포넌트(Component)’라는 개념이 핵심이 되었습니다. 각 컴포넌트는 HTML, CSS, 자바스크립트를 사용하여 시각적 측면과 기능적 측면을 캡슐화합니다. 정의된 컴포넌트들은 계층 구조로 결합되어 전체 애플리케이션을 구축할 수 있습니다. React는 주로 라이브러리로서 컴포넌트에 집중하지만, 유연한 생태계를 통해 프레임워크로서의 기능도 수행할 수 있습니다.
요구 사항
Node.js와 코드 편집기는 이미 준비되어있다고 가정하겠습니다.
React 프로젝트 설정
Vite를 사용하여 React 애플리케이션을 설정할 것입니다. Vite(빠르다는 뜻의 프랑스어)는 최신 웹 프레임워크(React)를 위한 현대적인 빌드 도구입니다. 합리적인 기본값(내장 설정)을 제공하면서도 특정 사용 사례(SVG, 린팅, TypeScript, 서버 사이드 렌더링)에 맞게 매우 유연하게 확장할 수 있습니다.
Vite의 핵심은 다음과 같습니다.
- 개발 서버: React 애플리케이션을 로컬에서 실행(개발 환경)
- 번들러: 프로덕션 배포를 위해 최적화된 파일을 생성(프로덕션 환경)
React 초보자에게 Vite의 주요 이점은 복잡한 도구 설정에 신경 쓰지 않고 오직 React 학습에만 집중할 수 있게 해 준다는 점입니다. 이는 Vite를 React 시작을 위한 완벽한 파트너로 만들어 줍니다.
Vite로 React 프로젝트를 생성하는 방법은 두 가지가 있습니다.
- 온라인 템플릿 사용: React(이 노트에서 권장) 또는 React with TypeScript(타입을 직접 구현해야 하므로 고급 사용자용)를 선택할 수 있습니다. 이 옵션은 로컬 환경 설정 없이 온라인에서 작업할 수 있게 해 줍니다.
- 로컬에 Vite 설정 (권장): 이 방법은 로컬 머신에 Vite로 React 프로젝트를 생성하고 선호하는 IDE(VSCode)에서 작업하는 방식입니다.
온라인 템플릿은 별다른 설정 없이 바로 작동하므로, 로컬 머신에 Vite를 설정하는 방법에 집중하겠습니다. Node와 npm을 설치되어 있다고 가정했습니다. 따라서, npm을 사용하면 커맨드 라인에서 서드파티 의존성(라이브러리, 프레임워크 등)을 설치할 수 있습니다. 시작하려면 커맨드 라인 도구를 열고 React 프로젝트를 생성할 폴더로 이동하세요. React 프로젝트 이름은 react-stories로 하겠지만, 원하는 이름으로 자유롭게 선택해도 됩니다.
TypeScript 프로젝트를 선택하세요. 커맨드 라인은 브라우저에서 프로젝트가 실행되는 URL을 출력할 것입니다. 브라우저를 열고 제공된 URL로 이동하여 React 프로젝트가 올바르게 표시되는지 확인하면 됩니다.
PS C:\temp> npx create-vite@latest react-stories
│
◇ Select a framework:
│ React
│
◇ Select a variant:
│ TypeScript
│
◇ Use rolldown-vite (Experimental)?:
│ Yes
│
◇ Install with npm and start now?
│ Yes
│
◇ Scaffolding project in C:\temp\react-stories...
│
◇ Installing dependencies with npm..
ROLLDOWN-VITE v7.2.5 ready in 1525 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help프로젝트 구조
먼저 에디터/IDE에서 애플리케이션을 열어봅시다. 가장 중요한 폴더와 파일들에 대한 분석은 다음과 같습니다.
- package.json: 이 파일은 모든 서드파티 의존성(
node_modules/폴더에 위치한 노드 패키지들)의 목록과 Node/npm과 관련된 기타 필수 프로젝트 구성을 보여줌 - vite.config.js: Vite를 구성하는 파일, 열어보면 Vite의 React 플러그인이 표시되어 있는데, 다른 웹 프레임워크로 Vite를 실행한다면 해당 프레임워크의 플러그인이 표시됨
초기에는 필요한 모든 것이 src/ 폴더에 위치해 있습니다.
- src/App.jsx: 이곳에 React 컴포넌트가 구현되며 React 컴포넌트를 여러 파일로 분할하여 각 파일이 하나 이상의 컴포넌트를 관리하도록 할 수 있음
- src/main.jsx: React 세계로 진입하는 진입점(entry point) 역할을 함
- src/index.css와 src/App.css 파일이 있어 전체 애플리케이션과 컴포넌트의 스타일을 지정할 수 있으며, 파일을 열면 기본 스타일이 포함되어 있음
npm 스크립트 (npm Scripts)
프로젝트별 명령어는 package.json 파일의 scripts 속성 아래에서 찾을 수 있습니다. Vite 버전에 따라 다음과 비슷할 것입니다.
이 스크립트들은 IDE 통합 터미널이나 독립형 커맨드 라인 도구에서 npm run <script> 명령어로 실행됩니다.
npm run dev: 브라우저용 애플리케이션을 로컬에서 실행(개발 서버)npm run lint: 코드 스타일 오류를 확인하기 위해 로컬에서 애플리케이션을 린트(lint)npm run build: 프로덕션을 위해 애플리케이션을 빌드npm run preview: 프로덕션 준비가 된 빌드를 테스트 목적으로 로컬 머신에서 실행 이 기능이 작동하려면 먼저npm run build를 실행해야 함
npm run dev와 npm run preview(빌드 후)는 브라우저에서 동일한 결과를 보여주어야 합니다. 하지만 전자는 프로덕션에 최적화되어 있지 않으므로 로컬 개발 용도로만 사용해야 합니다.
React 컴포넌트
모든 React 애플리케이션은 React 컴포넌트를 기반으로 구축됩니다. src/App.jsx 파일에 위치한 첫 번째 React 컴포넌트를 확인해보겠습니다. Vite 버전에 따라 파일 내용은 약간 다를 수 있지만, 아래 예시와 비슷할 것입니다.
// src/App.jsx
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App처음부터 여러 파일로 나누지 않기 때문에 이 파일의 크기는 커지겠지만, 모든 내용이 한곳에 있어 초보자가 이해하기에는 더 간단할 것입니다. React에 더 익숙해지면 프로젝트와 컴포넌트를 여러 파일로 분할하는 방법을 적용해야 합니다. 먼저, 방해가 되는 보일러플레이트(boilerplate) 코드를 줄여서 이 React 컴포넌트를 가볍게 만들어 보겠습니다.
스타일을 깔끔하게 시작하기 위해 src/index.css와 src/App.css 파일의 내용을 비우는 것을 권장합니다.
그 다음, 커맨드 라인에서 npm run dev로 애플리케이션을 시작하고 브라우저에 무엇이 표시되는지 확인하세요. “Hello React”라는 헤드라인이 보여야 합니다. 현재 코드에 무엇이 있는지, 그리고 무엇을 다룰지 간략히 살펴보겠습니다.
App 컴포넌트라고 불리는 이 React 컴포넌트는 단지 자바스크립트 함수일 뿐입니다. 일반적인 자바스크립트 함수와 달리 파스칼 케이스(PascalCase)로 정의됩니다. 컴포넌트는 대문자로 시작해야 하며, 그렇지 않으면 React는 이를 컴포넌트로 취급하지 않습니다. 이러한 종류의 App 컴포넌트를 흔히 함수형 컴포넌트(function component)라고 부릅니다. 함수형 컴포넌트는 React에서 컴포넌트를 사용하는 현대적인 방식입니다. (다른 종류의 React 컴포넌트도 존재하며, 이는 나중에 다룹니다.)
App 컴포넌트는 아직 함수 시그니처(signature)에 매개변수가 없습니다. 다음 섹션에서는 한 컴포넌트에서 다른 컴포넌트로 정보를 전달하는 방법(Props)을 배우게 될 것입니다. 이때 Props는 함수의 매개변수로 접근할 수 있게 됩니다.
App 컴포넌트는 HTML과 유사한 코드를 반환합니다. 이 새로운 문법(JSX)을 사용하면 자바스크립트와 HTML을 결합하여 브라우저에 동적이고 상호작용 가능한 콘텐츠를 표시할 수 있습니다.
다른 자바스크립트 함수와 마찬가지로, 함수형 컴포넌트도 함수 시그니처와 반환문(return statement) 사이에 구현 세부 사항을 포함할 수 있습니다. React 여정 전반에 걸쳐 이를 실제로 보게 될 것입니다.
함수 본문 내에 정의된 변수는 함수가 실행될 때마다 다시 정의됩니다. 자바스크립트와 함수에 익숙하다면 이는 새로운 사실이 아닐 것입니다.
컴포넌트의 함수는 컴포넌트가 브라우저에 표시될 때마다 실행됩니다. 이는 컴포넌트가 처음 표시될 때(렌더링) 뿐만 아니라, 변경 사항으로 인해 다른 내용을 표시해야 해서 컴포넌트가 업데이트될 때(리렌더링)도 발생합니다. 함수가 실행될 때마다 변수를 다시 정의하고 싶지 않다면, 변수를 컴포넌트 외부에서 정의할 수도 있습니다. 이 경우 title 변수는 함수형 컴포넌트 내부의 정보(매개변수)에 의존하지 않으므로 밖으로 옮겨도 괜찮습니다. 따라서 함수가 호출될 때마다가 아니라 한 번만 정의됩니다.
React 개발자로서의 여정에서, 그리고 이 학습 과정에서 여러분은 변수(및 함수)가 컴포넌트 외부에 정의되는 경우와 내부에 정의되는 경우 두 가지 시나리오를 모두 접하게 될 것입니다.
변수가 함수형 컴포넌트 본문 내부의 정보(예를 들어, 매개변수)를 필요로 하지 않는다면, 컴포넌트 외부에 정의하여 함수 호출 때마다 다시 정의되는 것을 방지해야 합니다.
React JSX
React 컴포넌트에서 반환된 모든 것은 브라우저에 표시됩니다. 지금까지 우리는 App 컴포넌트에서 HTML만 반환했습니다. 하지만 앞서 언급했듯이 App 컴포넌트의 반환 결과는 HTML과 유사할 뿐만 아니라 자바스크립트와 혼합될 수도 있습니다. 사실 이 출력물은 JSX(JavaScript XML)라고 불리며, HTML과 자바스크립트를 강력하게 결합합니다. 변수를 표시하기 위해 이것이 어떻게 작동하는지 살펴보겠습니다.
npm run dev로 애플리케이션을 다시 시작하거나 이미 실행 중인지 확인하고 브라우저에 표시된(렌더링 된) 제목을 확인하세요. 출력은 “Hello React”여야 합니다. 소스 코드에서 변수를 변경하면 브라우저에 그 변경 사항이 반영될 것입니다.
소스 코드에서 변수를 변경했을 때 브라우저에 반영되는 것은 React만의 기능이 아니라, 우리가 커맨드 라인에서 애플리케이션을 시작할 때 사용하는 기본 개발 서버와도 관련이 있습니다. 파일 중 하나가 변경될 때마다 개발 서버는 이를 감지하고 브라우저를 위해 영향을 받은 모든 파일을 다시 로드합니다. React와 개발 서버 사이에서 이러한 동작을 가능하게 하는 다리를 React 측에서는 React Fast Refresh(이전에는 React Hot Loader)라고 하며, 개발 서버 측에서는 Hot Module Replacement(HMR)라고 합니다.
다음으로, JSX 안에 HTML <input> 태그와 <label> 태그를 직접 정의해 보세요. 라벨을 클릭했을 때 입력 필드에 포커스가 가도록 해야 합니다. 라벨 안에 입력 필드를 중첩하거나 두 요소에 전용 HTML 속성을 사용하여 구현할 수 있습니다. 다음 코드 스니펫은 이 작업에 대한 책의 구현을 보여주며, JSX에서 사용되는 HTML이 약간 다르다는 것에 놀랄 수도 있습니다.
입력 필드와 라벨 조합을 위해 htmlFor, id, type 세 가지 HTML 속성을 지정했습니다. type 속성은 필수적인 편이지만 라벨 클릭 시 입력 필드 포커스와는 관련이 없습니다. 하지만 id와 type은 네이티브 HTML에서 익숙하겠지만, htmlFor는 생소할 수 있습니다. htmlFor는 바닐라 HTML의 for 속성을 반영합니다. 왜 이 속성이 네이티브 HTML과 다른지 궁금할 것입니다.
HTML에서 for 속성 값은 연결하고 싶은 폼 요소의 id 값과 같아야 합니다. 이렇게 연결하면 라벨 텍스트를 클릭해도 해당 input(텍스트 필드, 체크박스, 라디오 버튼 등)에 포커스가 가거나 토글됩니다. 체크박스나 라디오 버튼처럼 클릭 영역이 작은 요소의 사용성을 크게 높여줍니다. 스크린 리더가 라벨 텍스트와 컨트롤을 정확히 매칭할 수 있어 접근성(웹 접근성 표준) 측면에서 필수에 가깝습니다.
JSX는 React 자체의 내부 구현 세부 사항으로 인해 몇 가지 내부 HTML 속성을 대체합니다. 하지만 지원되는 모든 HTML 속성은 React 문서에서 찾을 수 있습니다. JSX는 HTML보다 자바스크립트에 더 가깝기 때문에 React는 카멜 케이스(camelCase) 명명 규칙을 사용합니다. React를 더 배우면서 class 대신 className, onclick 대신 onClick과 같은 JSX 전용 속성들을 더 많이 접하게 될 것입니다.
JSX에서 HTML을 사용할 때, React는 내부적으로 모든 HTML 속성을 자바스크립트로 변환하는데, 이때 class나 for 같은 특정 단어는 렌더링 과정에서 예약어로 취급됩니다. 따라서 React는 이를 위해 className과 htmlFor 같은 대체어를 만들었습니다. 하지만 실제 HTML이 브라우저에 렌더링 될 때는 속성들이 다시 네이티브 변형으로 변환됩니다.
이제 HTML 입력 필드와 라벨에 대한 자바스크립트 구현 세부 사항은 나중에 다시 다루겠습니다. 지금은 JSX에서 HTML과 자바스크립트가 어떻게 사용되는지 대조하기 위해, 더 복잡한 자바스크립트 데이터 타입을 JSX에 사용해 보겠습니다. title과 같은 문자열 원시값 대신, title(‘React’)과 greeting(‘Hey’)을 속성으로 가진 welcome이라는 자바스크립트 객체를 정의하세요. 그런 다음 <h1> 태그 안에서 객체의 두 속성을 나란히 렌더링해 보세요.
HTML은 JSX에서 (속성을 제외하고) 거의 네이티브 방식대로 사용할 수 있는 반면, 중괄호 {} 안의 모든 것은 자바스크립트를 보간(interpolate)하는 데 사용할 수 있습니다. 예를 들어, 제목을 반환하는 함수를 정의하고 중괄호 안에서 실행할 수 있습니다.
JSX는 자바스크립트의 문법 확장입니다. 과거에는 JSX를 사용하는 자바스크립트 파일은 .js 대신 .jsx 확장자를 사용해야 했습니다. 하지만 요즘에는 여러 빌드 도구(컴파일러/번들러)가 .js 파일 내의 JSX를 인식하도록 설정할 수 있습니다. 이렇게 설정되면 도구들이 JSX를 자바스크립트로 트랜스파일(transpile)합니다. Vite와 같은 도구는 개발자에게 더 명시적으로 보여주기 위해 .jsx 확장자를 채택하고 있습니다.
JSX는 개발자가 HTML과 자바스크립트를 섞어서 렌더링 되어야 할 내용을 표현할 수 있게 해 줍니다. 이전의 사고방식이 마크업(HTML)과 로직(자바스크립트)을 분리하는 것이었다면, React는 이 모든 것을 React 컴포넌트라는 하나의 단위로 묶습니다. 위 코드 스니펫에서 볼 수 있듯이, React가 반드시 JSX를 사용해야 하는 것은 아니며 createElement()와 같은 메서드를 사용할 수도 있습니다. 하지만 대부분의 사람은 UI를 명령형으로만 표현할 수 있는 자바스크립트 메서드(여기서는 React가 제공하는 메서드)를 사용하는 것보다, 선언적인 특성을 가진 JSX를 사용하는 것을 더 직관적이라고 느낍니다.
React의 리스트
자바스크립트에서 데이터로 작업할 때, 데이터는 대개 객체 배열(array of objects) 형태로 제공됩니다. 따라서 우리는 React에서 리스트 아이템을 렌더링하는 방법을 다음에 배울 것입니다. React에서 리스트 렌더링을 준비하기 위해, 가장 일반적인 데이터 조작 메서드 중 하나인 배열의 내장 메서드 map()을 복습해 봅시다. 이 메서드는 리스트의 각 아이템을 순회하며 각 아이템의 새로운 버전을 반환하는 데 사용됩니다.
React에서는 배열의 내장 map() 메서드를 사용하여 각 아이템에 대한 JSX를 반환함으로써 리스트 아이템들을 JSX로 변환합니다. 다음 예제에서는 React에서 아이템 리스트(여기서는 자바스크립트 객체)를 표시하고자 합니다. 먼저 컴포넌트 외부에 배열을 정의합니다. 그 후, JSX 내에서 배열의 map() 메서드를 인라인으로 사용하여 각 객체의 title 속성을 렌더링해 보세요.
// src/App.jsx
const list = [
{
title: 'React',
url: 'https://react.dev/',
author: 'Jordan Walke',
num_comments: 3,
points: 4,
objectID: 0,
},
{
title: 'Redux',
url: 'https://redux.js.org/',
author: 'Dan Abramov, Andrew Clark',
num_comments: 2,
points: 5,
objectID: 1,
},
];
//...
export default App;리스트의 각 아이템은 제목(title), URL, 작성자(author), 식별자(objectID), 인기도를 나타내는 포인트(points), 그리고 댓글 수(num_comments)를 가지고 있습니다. 속성 이름들은 나중에 사용할 실제 데이터를 반영하여 선택되었습니다. (참고로 num_comments의 밑줄 사용 등은 자바스크립트 명명 규칙에 완벽히 부합하지 않을 수 있습니다.)
이제 JSX 내에서 배열의 map() 메서드를 인라인으로 사용하여 리스트를 렌더링하겠습니다. 즉, 하나의 자바스크립트 데이터 타입에서 다른 것으로 매핑하는 것이 아니라, 리스트의 각 아이템을 렌더링하는 JSX를 반환할 것입니다.
사실, React에서 리스트 아이템을 렌더링하는 것은 개인적으로 JSX의 “아하(Aha)” 모멘트 중 하나였습니다. 별도의 템플릿 문법 없이 자바스크립트를 사용하여 객체 배열을 HTML 요소 리스트로 매핑할 수 있습니다. 이것이 결국 개발자에게 JSX가 의미하는 바입니다. “단지 HTML이 섞인 자바스크립트일 뿐입니다.”
이제 React가 각 아이템을 표시합니다. 하지만 중요한 부분이 하나 빠져 있습니다. 브라우저의 개발자 도구를 확인해 보면, 콘솔 탭에 “리스트의 각 자식은 고유한 ‘key’ prop을 가져야 합니다”라는 경고가 표시될 것입니다. key는 HTML 속성으로, 안정적인 식별자여야 합니다. 다행히 우리 아이템들은 objectID라는 ID를 가지고 있으므로 이를 안정적인 식별자로 사용할 수 있습니다.
key 속성은 특정 이유로 사용됩니다. React가 리스트를 리렌더링해야 할 때, 아이템이 변경되었는지 확인합니다. 키를 사용하면 React는 변경된 아이템만 효율적으로 교체할 수 있습니다. 키를 사용하지 않으면 React는 리스트를 비효율적으로 업데이트할 수 있습니다. 예를 들어 리스트의 시작 부분에 새 아이템이 추가되는 경우를 생각해 보세요. 키가 없으면 전체 리스트가 다시 그려질 수 있지만, 키가 있으면 새 아이템만 추가하고 나머지는 유지할 수 있습니다.
키는 찾기 어렵지 않습니다. 보통 배열 형태의 데이터를 다룰 때는 각 아이템의 안정적인 식별자(id 속성)를 사용할 수 있기 때문입니다. 하지만 ID가 없는 경우에는 다른 식별자(변경되지 않고 배열 내에서 고유한 title)를 고안해야 합니다. 최후의 수단으로 리스트 아이템의 인덱스를 사용할 수도 있습니다.
하지만 인덱스를 사용하는 것은 피하는 것이 좋습니다. 위에서 언급한 렌더링 성능 이슈가 발생할 수 있기 때문입니다. 게다가 아이템의 순서가 변경될 때(정렬, 추가, 삭제) 실제 UI 버그를 유발할 수 있습니다. 하지만 리스트의 순서가 절대 변경되지 않는다면 인덱스를 사용하는 것도 괜찮습니다.
지금까지는 각 아이템의 제목만 표시했습니다. 이제 아이템의 url, author, num_comments, points도 렌더링해 보세요. url의 경우 제목을 감싸는 HTML 앵커 요소(<a> 태그)를 사용하세요.
리스트를 렌더링하기 위해 배열의 map() 메서드가 JSX 안에 간결하게 인라인되었습니다. map() 메서드 내에서는 각 객체와 그 속성에 접근할 수 있습니다. 각 아이템의 url 속성은 앵커 HTML 요소의 href 속성으로 사용됩니다. JSX 내의 자바스크립트는 요소를 표시하는 데 사용될 뿐만 아니라 HTML 속성을 동적으로 할당하는 데도 사용될 수 있습니다.
const list = [
{
title: 'React',
url: 'https://react.dev/',
author: 'Jordan Walke',
num_comments: 3,
points: 4,
objectID: 0,
},
{
title: 'Redux',
url: 'https://redux.js.org/',
author: 'Dan Abramov, Andrew Clark',
num_comments: 2,
points: 5,
objectID: 1,
},
];
function App() {
return (
<div>
<h1>My React Stories</h1>
<label htmlFor="search">Search: </label>
<input id="search" type="text" />
<hr />
<ul>
{list.map(function (item) {
return (
<li key={item.objectID}>
<span>
<a href={item.url}>{item.title}</a>
</span>
<span>{item.author}</span>
<span>{item.num_comments}</span>
<span>{item.points}</span>
</li>
);
})}
</ul>
</div>
);
}
export default App;컴포넌트는 모든 React 애플리케이션의 토대입니다. React 프로젝트가 커지면서 관리해야 할 컴포넌트는 점점 많아질 것입니다. 각 컴포넌트는 기능(리스트 아이템 렌더링)을 캡슐화합니다. 지금까지는 App 컴포넌트만 사용했습니다. 이렇게 하면 좋지 않은 결과가 초래될 수 있습니다. 컴포넌트는 애플리케이션의 크기에 따라 확장되어야 하기 때문입니다. 따라서 하나의 컴포넌트를 시간이 지남에 따라 더 크고 복잡하게 만드는 대신, 결국 하나의 컴포넌트를 여러 컴포넌트로 분할해야 합니다.
그러므로 App 컴포넌트에서 기능을 추출하여 새로운 List 컴포넌트를 만드는 것부터 시작해 보겠습니다.
// src/App.jsx
const list = [ /* ... */ ];
function App() {
return (
<div>
<h1>My React Stories</h1>
<label htmlFor="search">Search: </label>
<input id="search" type="text" />
<hr />
<List />
</div>
);
}
function List() {
return (
<ul>
{list.map(function (item) {
return (
<li key={item.objectID}>
<span>
<a href={item.url}>{item.title}</a>
</span>
<span>{item.author}</span>
<span>{item.num_comments}</span>
<span>{item.points}</span>
</li>
);
})}
</ul>
);
}
export default App;이제 새로운 List 컴포넌트는 이전에 인라인 HTML 요소를 사용했던 App 컴포넌트에서 사용할 수 있습니다. 여러분은 방금 첫 번째 React 컴포넌트를 만들었습니다. 이 예제를 통해 컴포넌트가 의미 있는 작업을 캡슐화하면서 더 큰 React 프로젝트에 기여하는 방식을 볼 수 있습니다.
컴포넌트 추출은 React 개발자로서 매우 자주 수행하게 될 작업입니다. 컴포넌트의 크기와 복잡성은 항상 증가하기 때문입니다. 계속해서 라벨과 입력 요소를 App 컴포넌트에서 Search 컴포넌트로 추출해 보세요.
마침내 우리 애플리케이션에는 App, List, Search 세 개의 컴포넌트가 생겼습니다. 일반적으로 React 애플리케이션은 많은 계층적 컴포넌트로 구성됩니다. React 애플리케이션은 컴포넌트 계층 구조(component hierarchy) 또는 컴포넌트 트리(component tree)를 가집니다. 보통 그 아래에 컴포넌트 트리를 펼치는 최상위 진입점 컴포넌트(App)가 있습니다. App은 List와 Search의 부모 컴포넌트(parent component)이며, List와 Search는 App 컴포넌트의 자식 컴포넌트(child component)이자 서로에게는 형제 컴포넌트(sibling component)입니다.
컴포넌트 트리에는 항상 루트 컴포넌트(root component)(App)가 있으며, 다른 컴포넌트를 렌더링하지 않는 컴포넌트를 리프 컴포넌트(leaf component)(현재 소스 코드의 List/Search 컴포넌트)라고 합니다. 모든 컴포넌트는 0개, 1개 또는 다수의 자식 컴포넌트를 가질 수 있습니다.
초보자에게는 언제 새로운 컴포넌트를 만들어야 할지, 언제 다른 컴포넌트에서 컴포넌트를 추출해야 할지 알기 어려울 수 있습니다. 대개 컴포넌트의 크기나 복잡성이 너무 커지거나, 도메인/기능의 자연스러운 경계(리스트를 렌더링하는 List 컴포넌트, 검색 폼을 렌더링하는 Search 컴포넌트)가 보일 때 자연스럽게 발생합니다. 결국 각 컴포넌트는 애플리케이션에서 단일 단위를 나타내어 애플리케이션을 유지 보수하고 예측 가능하게 만듭니다.
const list = [
{
title: 'React',
url: 'https://react.dev/',
author: 'Jordan Walke',
num_comments: 3,
points: 4,
objectID: 0,
},
{
title: 'Redux',
url: 'https://redux.js.org/',
author: 'Dan Abramov, Andrew Clark',
num_comments: 2,
points: 5,
objectID: 1,
},
];
function App() {
return (
<div>
<h1>My React Stories</h1>
<Search />
<hr />
<List />
</div>
);
}
function Search() {
return (
<div>
<label htmlFor="search">Search: </label>
<input id="search" type="text" />
</div>
);
}
function List() {
return (
<ul>
{list.map(function (item) {
return (
<li key={item.objectID}>
<span>
<a href={item.url}>{item.title}</a>
</span>
<span>{item.author}</span>
<span>{item.num_comments}</span>
<span>{item.points}</span>
</li>
);
})}
</ul>
);
}
export default App;React 컴포넌트 인스턴스화
우리는 컴포넌트를 선언하는 방법(function List() { ... })과 인스턴스화하는 방법(<List />)을 배웠습니다. 자바스크립트 클래스를 이용한 비유부터 시작해 보겠습니다. 기술적으로 자바스크립트 클래스와 React 컴포넌트는 관련이 없다는 점을 유의해야 하지만(중요!), 과거에 사용해 본 적이 있을 수 있는 개념을 사용하여 컴포넌트의 개념을 이해하는 데 적합한 비유입니다.
클래스는 선언과 인스턴스화가 있습니다. 클래스 선언은 그 기능에 대한 청사진이며, 사용은 new 문을 사용하여 인스턴스가 생성될 때 발생합니다.
class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
getName() {
return this.firstName + ' ' + this.lastName;
}
}
const john = new Person('John', 'Doe');
console.log(john.getName()); // "John Doe"
const jane = new Person('Jane', 'Doe');
console.log(jane.getName()); // "Jane Doe"선언과 인스턴스화가 있는 자바스크립트 클래스의 개념은 React 컴포넌트와 유사합니다. React 컴포넌트 역시 하나의 컴포넌트 선언만 있지만 여러 개의 컴포넌트 인스턴스를 가질 수 있습니다.
컴포넌트를 정의하고 나면 JSX 어디에서나 엘리먼트로 사용할 수 있습니다. 엘리먼트는 컴포넌트의 인스턴스를 생성합니다. 다른 말로 하면 컴포넌트가 인스턴스화(instantiated)됩니다. 컴포넌트 선언만 있다면 원하는 만큼 많은 컴포넌트 인스턴스를 생성할 수 있습니다. 자바스크립트 클래스 선언 및 인스턴스화와 크게 다르지 않지만, 앞서 언급했듯이 기술적으로 자바스크립트 클래스와 React 컴포넌트는 같지 않습니다. 단지 그 사용법이 유사성을 보여주기에 편리할 뿐입니다.
React DOM
우리는 컴포넌트 선언/인스턴스화에 대해 배웠고 List와 Search 컴포넌트에서 실제로 작동하는 것을 보았습니다. 하지만 맨 처음에 App 컴포넌트의 선언으로 시작했음에도 불구하고 App 컴포넌트의 인스턴스화는 접해보지 못했습니다. App 컴포넌트와 컴포넌트 계층 구조 내의 모든 하위 컴포넌트가 렌더링되려면 어딘가에 인스턴스화가 있어야 합니다.
src/main.jsx 파일을 열어 <App /> 엘리먼트로 App 컴포넌트가 인스턴스화된 것을 확인하세요. 파일 내용은 조금 다를 수 있지만, 다음 스니펫은 핵심적인 측면을 보여줍니다.
파일 시작 부분에 react와 react-dom 두 개의 라이브러리가 임포트되어 있습니다. React는 React 개발자의 일상적인 업무에 사용되는 반면, React DOM은 React 애플리케이션을 네이티브 HTML 세계에 연결(hook)하기 위해 React 애플리케이션에서 보통 한 번 사용됩니다.
index.html 파일을 열어 id 속성이 “root”인 HTML 요소를 찾아보세요. 바로 그곳이 React가 HTML에 자신을 삽입하여 전체 React 애플리케이션을 부트스트랩(bootstrap)하는 지점이며, App 컴포넌트부터 시작합니다.
자바스크립트 파일에서 createRoot() 메서드는 React를 인스턴스화할 HTML 요소를 기대합니다. 우리는 index.html 파일에서 본 HTML 요소를 반환하기 위해 자바스크립트 내장 메서드인 getElementById()를 사용하고 있습니다. 루트 객체를 얻으면, JSX를 매개변수로 하여 render()를 호출합니다. 이 JSX는 보통 진입점 컴포넌트(또는 루트 컴포넌트)를 나타냅니다. 일반적으로 진입점 컴포넌트는 App 컴포넌트의 인스턴스이지만, 다른 JSX일 수도 있습니다.
본질적으로 React DOM은 HTML을 사용하는 모든 웹사이트에 React를 통합하는 데 필요한 모든 것입니다. 처음부터 React 애플리케이션을 시작한다면 애플리케이션 내에 ReactDOM.createRoot() 호출이 단 하나만 있는 것이 일반적입니다. 하지만 React 이외의 것을 사용했던 레거시 애플리케이션에서 작업하는 경우, 애플리케이션의 특정 영역만 React로 구동되기 때문에 여러 개의 ReactDOM.createRoot() 호출을 볼 수도 있습니다.
아무튼, 작은 HTML 파일 하나와 큰 자바스크립트 파일 하나로 구동되는 싱글 페이지 애플리케이션(SPA)의 부상에 대한 소개를 기억하시나요? 이제 모든 것이 어떻게 맞아떨어지는지 알 수 있습니다. 하나의 작은 HTML 파일(index.html)과 하나의 큰 자바스크립트 파일(컴파일되고 번들링된 src/main.jsx 및 src/App.jsx 파일)이 웹 서버에서 브라우저로 전송되는 동안, 자바스크립트 파일(들)은 브라우저에서 모든 HTML을 렌더링하는 책임을 맡습니다. HTML 파일은 단지 자바스크립트 파일을 요청하고 React가 자신을 삽입할 HTML 요소를 렌더링하기 위해 존재할 뿐입니다. 거기서부터 React는 필요한 모든 함수형 컴포넌트를 호출하여 컴포넌트 계층 구조로 자신을 렌더링합니다.
React 컴포넌트 선언
지금까지 우리는 여러 React 컴포넌트를 선언했습니다. 이 컴포넌트들은 소위 함수형 컴포넌트(function components)이므로, 자바스크립트에서 함수를 선언하는 다양한 방식을 활용할 수 있습니다. 지금까지는 표준 함수 선언(function declaration)을 사용했지만, 화살표 함수(arrow function)를 사용하면 더 간결하게 표현할 수 있어 함수형 컴포넌트 선언의 새로운 표준으로 자리 잡을 수 있습니다.
이 지식을 바탕으로 React 프로젝트를 살펴보고 모든 함수 선언식을 화살표 함수 표현식으로 리팩터링해 보세요. 이 리팩터링은 함수형 컴포넌트뿐만 아니라 프로젝트에서 사용되는 다른 함수들에도 적용할 수 있습니다. 우리도 진행하면서 모든 함수형 컴포넌트의 선언을 화살표 함수 표현식으로 리팩터링하겠습니다.
앞서 언급했듯이 함수형 컴포넌트뿐만 아니라 배열의 map() 메서드에 사용했던 콜백 함수와 같은 다른 함수들도 리팩터링할 수 있습니다.
게다가, 화살표 함수의 유일한 목적이 값을 반환하는 것이고 중간에 비즈니스 로직이 없다면, 함수의 블록 본문(중괄호)을 제거할 수 있습니다. 간결한 본문(concise body)에서는 암시적 반환문(implicit return statement)이 적용되므로 return 문을 제거할 수 있습니다. 다음 예시를 확인해 보세요.
Search 컴포넌트에 이를 실제로 적용해 봅시다.
App과 List 컴포넌트 역시 중간에 어떤 작업도 수행하지 않고 JSX만 반환하므로 동일하게 적용할 수 있습니다. 또한 map() 메서드 내에서 사용되는 화살표 함수에도 적용됩니다.
// src/App.jsx
const App = () => (
<div>
{/* ... */}
</div>
);
function List() {
return (
<ul>
{list.map((item) => (
<li key={item.objectID}>
<span>
<a href={item.url}>{item.title}</a>
</span>
<span>{item.author}</span>
<span>{item.num_comments}</span>
<span>{item.points}</span>
</li>
))}
</ul>
);
}함수 문(statement), 중괄호, return 문을 생략했기 때문에 모든 JSX가 훨씬 간결해졌습니다. 하지만 이것은 선택 사항이며, 화살표 함수 표현식보다 함수 선언식을 사용하거나 암시적 반환을 가진 간결한 본문보다 중괄호가 있는 블록 본문을 사용하는 것도 허용된다는 점을 기억하는 것이 중요합니다. 함수 시그니처와 반환문 사이에 비즈니스 로직을 추가해야 할 때는 블록 본문이 필요할 때가 많습니다. 우리는 진행하면서 블록 본문이 있는 화살표 함수 컴포넌트와 없는 컴포넌트 사이를 빠르게 오갈 것이므로, 이 리팩터링 개념을 확실히 이해해 두어야 합니다. 어떤 것을 사용할지는 컴포넌트의 요구 사항에 따라 달라집니다.
애플리케이션 전반에 걸쳐 컴포넌트 선언 시 함수 선언식이나 화살표 함수 표현식 중 하나를 선택하여 사용하세요. 두 버전 모두 사용해도 괜찮지만, 프로젝트를 함께하는 팀원들과 동일한 구현 스타일을 공유하도록 하세요.
또한, 화살표 함수 표현식에서 암시적 반환문을 사용하면 컴포넌트 선언이 간결해지지만, 함수 시그니처와 반환문 사이에 작업을 수행해야 할 때 간결한 본문에서 블록 본문으로 리팩터링해야 하는 번거로움이 발생할 수 있습니다. 따라서 (마지막 코드 스니펫처럼) 항상 블록 본문이 있는 화살표 함수 표현식을 유지하고 싶을 수도 있습니다.
JSX에서의 핸들러 함수
우리는 React 컴포넌트에 대해 많이 배웠지만, 아직 상호작용(interaction)은 없습니다. React로 애플리케이션을 개발하다 보면 사용자 상호작용을 구현해야 할 때가 옵니다. 우리 프로젝트에서 시작하기 가장 좋은 곳은 이미 입력 필드 요소가 있는 Search 컴포넌트입니다.
네이티브 HTML에서는 DOM 노드에 addEventListener() 메서드를 사용하여 프로그래밍 방식으로 이벤트 핸들러를 추가할 수 있습니다. React에서는 JSX에서 선언적인 방식으로 핸들러를 추가하는 방법을 알아볼 것입니다.
먼저, return 문 이전에 구현 세부 사항을 추가할 수 있도록 Search 컴포넌트의 함수를 간결한 본문에서 블록 본문으로 리팩터링합니다.
다음으로, 입력 필드의 change 이벤트를 위한 함수(함수 선언식 또는 화살표 함수 표현식)를 정의합니다. React에서 이 함수는 (이벤트) 핸들러라고 불립니다. 그 후, 이 함수를 HTML 입력 필드의 onChange 속성(JSX 명명 속성)에 전달할 수 있습니다.
//...
const Search = () => {
const handleChange = (event) => {
// 합성 이벤트 (synthetic event)
console.log(event);
// 타겟(여기서는 input HTML 요소)의 값
console.log(event.target.value);
};
return (
<div>
<label htmlFor="search">Search: </label>
<input id="search" type="text" onChange={handleChange} />
</div>
);
};
//...
export default App;웹 브라우저에서 애플리케이션을 연 후, 브라우저의 개발자 도구 “Console” 탭을 열어 입력 필드에 타이핑할 때 로그가 발생하는지 확인하세요. 여러분이 보는 것은 자바스크립트 객체인 합성 이벤트(synthetic event)와 입력 필드의 내부 값입니다.
React의 합성 이벤트는 본질적으로 브라우저의 네이티브 이벤트를 감싸는 래퍼(wrapper)입니다. React가 싱글 페이지 애플리케이션을 위한 라이브러리로 시작했을 때, 네이티브 브라우저 동작을 방지하기 위해 이벤트에 대한 향상된 기능이 필요했습니다. 예를 들어, 네이티브 HTML에서 폼(form)을 제출하면 페이지 새로고침이 발생합니다. 하지만 React에서는 개발자가 그다음 일어날 일을 제어해야 하므로 페이지 새로고침을 방지해야 합니다. 어쨌든 네이티브 HTML 이벤트에 접근해야 한다면 event.nativeEvent를 통해 접근할 수 있지만, 수년간 React를 개발하면서 저도 그런 경우를 겪은 적은 없습니다.
이것이 사용자 상호작용을 위한 리스너를 추가하기 위해 JSX 핸들러 함수에 HTML 요소를 전달하는 방법입니다. 반환 값이 함수인 경우를 제외하고는, 항상 함수의 반환 값이 아니라 함수 자체를 핸들러에 전달하세요. 이는 React 초보자들의 애플리케이션에서 버그가 발생하는 잘 알려진 원인이므로 확실히 이해하는 것이 중요합니다.
보시다시피, JSX에서 HTML과 자바스크립트는 잘 어우러집니다. HTML 안의 자바스크립트는 자바스크립트 변수를 표시할 수 있고(<span>{title}</span>), 자바스크립트 원시값을 HTML 속성에 전달할 수 있으며(<a href={url}>), 사용자 상호작용 처리를 위해 함수를 HTML 요소의 속성에 전달할 수도 있습니다(<input onChange={handleChange} />). React 애플리케이션을 개발할 때 JSX에서 HTML과 자바스크립트를 섞는 것은 일상적인 일이 될 것입니다.
const list = [
{
title: 'React',
url: 'https://react.dev/',
author: 'Jordan Walke',
num_comments: 3,
points: 4,
objectID: 0,
},
{
title: 'Redux',
url: 'https://redux.js.org/',
author: 'Dan Abramov, Andrew Clark',
num_comments: 2,
points: 5,
objectID: 1,
},
];
function App() {
return (
<div>
<h1>My React Stories</h1>
<Search />
<hr />
<List />
</div>
);
}
const Search = () => {
const handleChange = (event) => {
// 합성 이벤트 (synthetic event)
console.log(event);
// 타겟(여기서는 input HTML 요소)의 값
console.log(event.target.value);
};
return (
<div>
<label htmlFor="search">Search: </label>
<input id="search" type="text" onChange={handleChange} />
</div>
);
};
function List() {
return (
<ul>
{list.map((item) => (
<li key={item.objectID}>
<span>
<a href={item.url}>{item.title}</a>
</span>
<span>{item.author}</span>
<span>{item.num_comments}</span>
<span>{item.points}</span>
</li>
))}
</ul>
);
}
export default App;React Props
현재 우리는 프로젝트에서 list를 전역 변수로 사용하고 있습니다. 처음에는 App 컴포넌트의 전역 스코프에서 직접 사용했고, 나중에는 List 컴포넌트에서 사용했습니다. 전역 변수가 하나뿐이고 모든 컴포넌트가 하나의 파일에 있다면 작동할 수 있지만, 여러 변수가 여러 컴포넌트(여러 폴더/파일 내)에 걸쳐 있다면 유지 보수하기 어렵습니다.
React에서 Props를 사용하면 컴포넌트가 서로 다른 파일에 위치하게 되더라도 한 컴포넌트에서 다른 컴포넌트로 변수를 정보로서 전달할 수 있습니다. 이것이 어떻게 작동하는지 살펴보겠습니다.
Props를 처음 사용하기 전에, list를 전역 스코프에서 App 컴포넌트로 옮기고 더 설명적인 이름으로 변경하겠습니다. return 문 이전에 리스트를 선언하기 위해 App 컴포넌트의 함수를 간결한 본문에서 블록 본문으로 리팩터링하는 것을 잊지 마세요.
// src/App.jsx
const App = () => {
const stories = [
{
title: 'React',
url: 'https://react.dev/',
author: 'Jordan Walke',
num_comments: 3,
points: 4,
objectID: 0,
},
{
title: 'Redux',
url: 'https://redux.js.org/',
author: 'Dan Abramov, Andrew Clark',
num_comments: 2,
points: 5,
objectID: 1,
},
];
return ( ... );
};다음으로, React Props를 사용하여 아이템 리스트를 List 컴포넌트로 전달하겠습니다. App 컴포넌트에서 변수 이름은 stories이며, 이 이름으로 List 컴포넌트에 전달합니다. 하지만 List 컴포넌트의 인스턴스화 부분에서는 이를 list라는 새로운 HTML 속성에 할당합니다.
이제 List 컴포넌트의 함수 시그니처에 매개변수를 도입하여 리스트를 받아오는 것을 직접 시도해 보세요. 스스로 해결책을 찾았다면 첫 번째 정보를 한 컴포넌트에서 다른 컴포넌트로 전달한 것을 축하합니다! 그렇지 않다면 다음 코드 스니펫이 작동 방식을 보여줍니다.
부모 컴포넌트에서 자식 컴포넌트로 컴포넌트 엘리먼트의 HTML 속성을 통해 전달된 모든 것은 자식 컴포넌트에서 접근할 수 있습니다. 자식 컴포넌트는 함수 시그니처에서 props라는 객체를 매개변수로 받으며, 여기에는 전달된 모든 속성이 프로퍼티(줄여서 props)로 포함되어 있습니다.
(ESLint를 사용하는 경우 “error ‘list’ is missing in props validation”이라는 오류가 발생할 수 있습니다. React를 TypeScript 없이 사용하는 이상적인 시나리오에서는 prop-types를 사용하여 컴포넌트가 받는 props에 대한 더 나은 통찰력을 제공하는 것이 해결책입니다. 하지만 prop-types는 TypeScript와 비슷한 목적을 수행하지만 덜 강력합니다. 이 목표를 달성하는 것이 프로젝트의 필수 조건이라면 React 개발에 TypeScript를 도입하는 것이 좋습니다. 자바스크립트만 사용하는 경우라면 설정 파일에서 이 특정 ESLint 규칙을 비활성화하여 문제를 해결할 것을 제안합니다.)
React Props의 또 다른 사용 사례는 List 컴포넌트와 그 잠재적인 자식 컴포넌트입니다. 이전에는 각 아이템을 추출된 Item 컴포넌트에 전달하는 방법을 몰랐기 때문에 List 컴포넌트에서 Item 컴포넌트를 추출할 수 없었습니다. React Props에 대한 새로운 지식을 바탕으로 컴포넌트 추출을 수행하고 List 컴포넌트의 새로운 자식 컴포넌트에 각 아이템을 전달할 수 있습니다.
List 컴포넌트에서 Item 컴포넌트를 추출하고 map() 메서드의 콜백 함수에 있는 각 아이템을 이 새로운 컴포넌트에 전달하세요.
// src/App.jsx
const List = (props) => (
<ul>
{props.list.map((item) => (
<Item key={item.objectID} item={item} />
))}
</ul>
);
const Item = (props) => (
<li>
<span>
<a href={props.item.url}>{props.item.title}</a>
</span>
<span>{props.item.author}</span>
<span>{props.item.num_comments}</span>
<span>{props.item.points}</span>
</li>
);이전 섹션에서 소개했던 key 속성을 잊지 마세요. React 애플리케이션의 JSX 내에서 리스트를 다룰 때 key 속성의 중요성을 기억하는 것은 필수적입니다. key 속성은 리스트 내 아이템의 효율적인 렌더링과 업데이트에 결정적인 역할을 합니다. 각 리스트 아이템에 고유한 키를 할당함으로써 React는 엘리먼트를 정확하게 추적하고 관리할 수 있습니다.
결과적으로 리스트가 다시 렌더링 되는 것을 볼 수 있습니다. Props에 대한 가장 중요한 사실은 Props를 변경하는 것이 허용되지 않는다는 것입니다. Props는 불변(immutable) 데이터 구조로 취급되어야 합니다. Props는 부모에서 자식 컴포넌트로 정보를 전달하는 데만 사용됩니다. 이 생각을 이어가자면, 정보(Props)는 부모에서 자식 컴포넌트로만 전달될 수 있으며 그 반대는 불가능합니다. 나중에 이 제한을 극복하는 방법을 배우겠지만, 지금은 컴포넌트 트리에서 위에서 아래로 정보를 공유하는 수단을 찾았습니다.
React State
개발자가 React Props를 변경(mutate)하는 것은 허용되지 않습니다. Props는 부모 컴포넌트에서 자식 컴포넌트로 정보를 전달하기 위해서만 존재하기 때문입니다. 반면 React State는 변경 가능한(mutable) 데이터 구조(상태 값)를 도입합니다. 이러한 상태 값은 React 컴포넌트에서 state로 인스턴스화되며, props를 통해 자식 컴포넌트로 전달될 수도 있고, 함수를 사용하여 state를 수정(mutate)할 수도 있습니다. state가 변경되면 해당 state를 가진 컴포넌트와 모든 자식 컴포넌트가 다시 렌더링(re-render)됩니다.
- State와 Props
- App 컴포넌트:
stories라는 state를 가짐.<List list={stories} />를 통해 전달 - List 컴포넌트:
list라는 props를 받음.<Item item={item} />을 통해 전달 - Item 컴포넌트:
item이라는 props를 받음
- App 컴포넌트:
Props와 State라는 두 개념은 명확히 정의된 목적을 가지고 있습니다. Props는 컴포넌트 계층 구조 아래로 정보를 전달하는 데 사용되는 반면, State는 시간이 지남에 따라 정보를 수정하는 데 사용됩니다.
다음 사용 사례를 통해 React의 State를 시작해 보겠습니다. 사용자가 Search 컴포넌트의 HTML 입력 필드에 텍스트를 입력할 때마다, 사용자는 이 정보(State)가 그 옆에 표시되기를 원합니다. 직관적이지만 작동하지 않는 접근 방식은 다음과 같습니다.
// src/App.jsx (작동하지 않는 예시)
const Search = () => {
let searchTerm = '';
const handleChange = (event) => {
searchTerm = event.target.value;
};
return (
<div>
<label htmlFor="search">Search: </label>
<input id="search" type="text" onChange={handleChange} />
<p>
Searching for <strong>{searchTerm}</strong>.
</p>
</div>
);
};브라우저에서 이것을 시도해 보면, 입력 필드에 타이핑한 후에도 출력이 HTML 입력 필드 아래에 나타나지 않는 것을 볼 수 있습니다. 하지만 이 접근 방식이 실제 해결책과 아주 동떨어진 것은 아닙니다. 빠진 것은 React에게 searchTerm이 상태 값(stateful value)이라는 것을 알려주는 것입니다. 다행히 React는 이를 위해 useState라는 메서드를 제공합니다.
useState를 사용함으로써 우리는 시간이 지남에 따라 변하는 상태 값을 갖고 싶다고 React에 알리는 것입니다. 그리고 이 상태 값이 변경될 때마다 영향을 받는 컴포넌트(여기서는 Search 컴포넌트)는 이를 사용하기 위해(여기서는 최신 값을 표시하기 위해) 다시 렌더링 될 것입니다.
React의 useState 메서드는 초기 상태(initial state)를 인자로 받습니다. 우리 예제의 경우 빈 문자열입니다. 또한, 이 메서드를 호출하면 두 개의 항목이 있는 배열을 반환합니다. 첫 번째 항목(searchTerm)은 현재 상태를 나타냅니다. 두 번째 항목(setSearchTerm)은 이 상태를 업데이트하는 함수입니다. 책에서는 이 함수를 상태 업데이트 함수(state updater function)라고 부를 것입니다. 이 두 항목은 현재 상태를 표시(읽기)하고 업데이트(쓰기)하는 데 필요한 모든 것입니다.
사용자가 입력 필드에 타이핑하면 입력 필드의 change 이벤트가 이벤트 핸들러를 사용합니다. 핸들러의 로직은 이벤트 타겟의 값과 상태 업데이트 함수를 사용하여 새로운 상태를 설정합니다. 그 후 컴포넌트가 다시 렌더링 됩니다(즉, 컴포넌트 함수가 실행됩니다). 업데이트된 상태는 현재 상태(여기서는 searchTerm)가 되어 컴포넌트의 JSX에 표시됩니다.
연습 삼아 각 컴포넌트에 console.log()를 넣어보세요. 예를 들어 App 컴포넌트에는 console.log('App renders'), List 컴포넌트에는 console.log('List renders') 등을 넣습니다. 이제 브라우저를 확인해 보세요. 첫 번째 렌더링 시에는 모든 로그가 나타나지만, HTML 입력 필드에 타이핑을 시작하면 Search 컴포넌트의 로그만 나타날 것입니다. React는 상태가 변경된 컴포넌트(그리고 잠재적으로 그 자식 컴포넌트들)만 다시 렌더링 합니다.
이제 기술적인 맥락에서 렌더링(rendering)과 리렌더링(re-rendering)이라는 용어를 듣게 되었습니다. 본질적으로 React 애플리케이션의 모든 컴포넌트는 한 번의 초기 렌더링과 잠재적인 후속 리렌더링을 거칩니다. 보통 초기 렌더링은 React 컴포넌트가 브라우저에 표시될 때 발생합니다. 그 후 사용자 상호작용(입력 필드 타이핑)과 같은 사이드 이펙트(side-effect)가 발생하면, 그 변경 사항이 React의 state에 캡처되어 이 변경에 영향을 받는 모든 컴포넌트의 리렌더링을 강제합니다. 이는 state를 관리하는 컴포넌트와 그 모든 하위 컴포넌트를 의미합니다.
useState 함수는 React Hook(리액트 훅)이라고 불립니다. 이것은 React가 제공하는 여러 훅 중 하나일 뿐이며, React의 훅에 대해 겉핥기만 했습니다. 다음 섹션들에서 더 자세히 배우게 될 것입니다. 현재로서는 하나의 컴포넌트나 여러 컴포넌트에 원하는 만큼 useState 훅을 가질 수 있으며, state는 자바스크립트 문자열(이 경우처럼)부터 배열이나 객체 같은 더 복잡한 데이터 구조까지 무엇이든 될 수 있다는 것을 알아두어야 합니다.
또한 브라우저 개발자 도구에서 로그가 두 번씩 나타나는 것을 볼 수 있는데, 이는 src/main.jsx 파일의 React StrictMode 때문입니다. 개발 환경에서만 작동하는 StrictMode는 잠재적인 문제를 식별하기 위해 추가적인 검사와 경고를 수행합니다. 루트 컴포넌트에 적용되면 발생할 수 있는 문제를 효과적으로 강조해 줍니다. 나중에 프로덕션(배포) 환경에서는 렌더링이 한 번만 발생합니다.
UI가 처음 렌더링 될 때, 렌더링 된 모든 컴포넌트의 useState 훅은 초기 상태로 초기화되어 현재 상태로 반환됩니다. 상태 변경으로 인해 UI가 다시 렌더링 될 때, useState 훅은 내부 클로저(closure)에서 가장 최근의 상태를 사용합니다. 컴포넌트 함수가 실행될 때마다 useState가 처음부터 선언된다고 가정할 수 있기 때문에 이는 이상하게 보일 수 있습니다. 하지만 React는 각 컴포넌트 옆에 상태와 같은 정보를 메모리에 저장하는 객체를 할당합니다. 결국 컴포넌트가 더 이상 렌더링 되지 않으면 자바스크립트의 가비지 컬렉션을 통해 메모리가 정리됩니다.
JSX에서의 콜백 핸들러
Props는 부모 컴포넌트에서 자식 컴포넌트로 정보를 전달하는 반면, State는 시간이 지남에 따라 정보를 변경하는 데 사용될 수 있습니다. 하지만 컴포넌트끼리 서로 대화하게 만드는 퍼즐 조각은 아직 다 찾지 못했습니다. Props를 정보 전달 수단으로 사용할 때는 자손 컴포넌트(descendant components)하고만 대화할 수 있습니다. State를 사용하면 정보를 상태 값으로 만들 수 있지만, 이 정보 역시 Props를 통해서만 아래로 전달될 수 있습니다.
예를 들어, 현재 Search 컴포넌트는 자신의 state를 다른 컴포넌트와 공유하지 않으므로, 오직 Search 컴포넌트 내에서만 사용(표시)되고 업데이트됩니다. Search 컴포넌트에서 최신 state를 표시하는 데는 문제가 없지만, 결국 우리는 이 state를 다른 곳에서 사용하고 싶을 것입니다.
이번 섹션에서는 App 컴포넌트에서 Search 컴포넌트의 state를 사용하여, 리스트가 List 컴포넌트로 전달되기 전에 searchTerm을 기준으로 필터링하려고 합니다. 우리는 자식 컴포넌트와 소통하기 위해 Props를 사용할 수 있다는 것은 알지만, 부모 컴포넌트(여기서는 Search에서 App으로)로 정보를 전달하는 방법은 모릅니다.
컴포넌트 트리 위쪽으로 정보를 전달할 방법은 없습니다. Props는 자연스럽게 아래쪽으로만 전달되기 때문입니다. 하지만 우리는 콜백 핸들러(callback handler)를 도입할 수 있습니다. 콜백 핸들러는 이벤트 핸들러(A)로 도입되어, Props를 통해 다른 컴포넌트(B)로 함수로서 전달되고, 그곳에서 핸들러(C)로 실행되어, 처음 도입된 곳(D)으로 다시 호출(call back)됩니다.
// src/App.jsx
const App = () => {
const stories = [ ... ];
// A
const handleSearch = (event) => {
// D
console.log(event.target.value);
};
return (
<div>
<h1>My React Stories</h1>
{/* B */}
<Search onSearch={handleSearch} />
<hr />
<List list={stories} />
</div>
);
};
const Search = (props) => {
const [searchTerm, setSearchTerm] = React.useState('');
const handleChange = (event) => {
setSearchTerm(event.target.value);
// C
props.onSearch(event);
};
return (
<div>
<label htmlFor="search">Search: </label>
<input id="search" type="text" onChange={handleChange} />
</div>
);
};이제 사용자가 입력 필드에 타이핑할 때마다, App 컴포넌트에서 Search 컴포넌트로 전달된 함수가 실행됩니다. 이런 방식으로 우리는 사용자가 Search 컴포넌트의 입력 필드에 타이핑할 때 App 컴포넌트에 알릴 수 있습니다. 본질적으로 더 구체적인 형태의 이벤트 핸들러인 콜백 핸들러는 컴포넌트 트리 위쪽으로 소통하기 위한 암시적인 수단이 됩니다.
요약하자면 콜백 핸들러의 개념은 다음과 같습니다. 우리는 부모 컴포넌트(App)에서 자식 컴포넌트(Search)로 Props를 통해 함수를 전달합니다. 자식 컴포넌트(Search)에서 이 함수를 호출하지만, 호출된 함수의 실제 구현은 부모 컴포넌트(App)에 있습니다. 다시 말해, (이벤트) 핸들러가 부모 컴포넌트에서 자식 컴포넌트로 Props로서 전달되면, 이는 콜백 핸들러가 됩니다. React Props는 항상 컴포넌트 트리 아래로 전달되므로, 콜백 핸들러로 전달된 함수는 컴포넌트 트리 위쪽으로 소통하는 데 사용될 수 있습니다.
React 상태 끌어올리기 (Lifting State in React)
다음과 같은 과제에 직면하게 됩니다. Search 컴포넌트의 상태 값인 searchTerm을 사용하여 App 컴포넌트에서 stories를 제목(title) 속성 기준으로 필터링한 후, List 컴포넌트에 Props로 전달해야 합니다.
지금까지 우리는 Props를 통해 정보를 명시적으로 아래로 전달하는 방법과 콜백 핸들러를 통해 정보를 암시적으로 위로 전달하는 방법을 배웠습니다. 하지만 App 컴포넌트의 콜백 핸들러를 보면, handleSearch() 핸들러에서 받은 searchTerm을 stories 필터링에 어떻게 적용해야 할지 자연스럽게 떠오르지 않습니다.
한 가지 해결책은 App 컴포넌트에 또 다른 state를 설정하여, 도착하는 searchTerm을 캡처하고 이를 사용해 List 컴포넌트로 전달하기 전에 stories를 필터링하는 것입니다. 하지만 이렇게 하면 searchTerm이 Search 컴포넌트와 App 컴포넌트 모두에 state로 존재하게 되어 중복이 발생하므로 좋지 않은 관행입니다.
다른 방식으로 생각해 봅시다. App 컴포넌트가 stories를 필터링하기 위해 searchTerm state에 관심이 있다면, 애초에 Search 컴포넌트가 아닌 App 컴포넌트에서 state를 인스턴스화하면 어떨까요?
우리는 useState 훅을 Search 컴포넌트에서 App 컴포넌트로 이동시키고, 제공된 콜백 핸들러에서 상태 업데이트 함수를 사용할 것입니다. 그리고 Search 컴포넌트에서는 콜백 핸들러를 사용하여 이벤트를 부모 컴포넌트로 전달합니다. 그러면 사용자가 입력 필드에 타이핑할 때마다 App 컴포넌트의 state가 업데이트될 것입니다. 그 후 App 컴포넌트의 새로운 state를 사용하여 List 컴포넌트에 전달하기 전에 stories를 filter() 할 것입니다.
다음 구현은 첫 번째 부분을 보여줍니다.
// src/App.jsx
const App = () => {
const stories = [ ... ];
const [searchTerm, setSearchTerm] = React.useState('');
const handleSearch = (event) => {
setSearchTerm(event.target.value);
};
return (
<div>
<h1>My React Stories</h1>
<Search onSearch={handleSearch} />
<hr />
<List list={stories} />
</div>
);
};
const Search = (props) => (
<div>
<label htmlFor="search">Search: </label>
<input id="search" type="text" onChange={props.onSearch} />
</div>
);우리는 이전에 콜백 핸들러에 대해 배웠는데, 이는 자식 컴포넌트(여기서는 Search)에서 부모 컴포넌트(여기서는 App)로 열린 소통 채널을 유지하는 데 도움을 줍니다. 이제 Search 컴포넌트는 더 이상 state를 관리하지 않고, 텍스트가 HTML 입력 필드에 입력된 후 콜백 핸들러를 통해 이벤트를 App 컴포넌트로 전달하기만 합니다.
거기서 App 컴포넌트는 자신의 state를 업데이트합니다. 지난 코드 스니펫에서 했던 것처럼 한 컴포넌트에서 다른 컴포넌트로 state를 이동시키는 과정을 상태 끌어올리기(Lifting State)라고 합니다.
다음으로, searchTerm을 App 컴포넌트에서 다시 표시하거나(state에서 직접 사용), Search 컴포넌트에서 표시(state를 props로 내려받아 사용)할 수도 있습니다.
경험적 규칙(Rule of thumb): State는 항상 그 State에 관심이 있는 모든 컴포넌트가 State를 직접 관리하거나(State에서 직접 정보 사용, App), State를 관리하는 컴포넌트의 하위 컴포넌트로서 정보를 Props로 받아 사용하는(List 또는 Search) 위치에서 관리해야 합니다. 하위 컴포넌트가 State를 업데이트해야 한다면(Search), 부모 컴포넌트의 State를 업데이트할 수 있도록 콜백 핸들러를 아래로 전달하세요. 하위 컴포넌트가 State를 사용해야 한다면(표시), Props로 전달하세요.
마지막으로, 검색 state를 App 컴포넌트에서 관리함으로써 stories를 List 컴포넌트에 list prop으로 전달하기 전에 searchTerm 상태 값으로 필터링할 수 있습니다. 다음 구현을 참고하기 전에 배열의 내장 filter() 메서드와 stories, searchTerm을 조합하여 직접 시도해 보세요.
// src/App.jsx
const App = () => {
const stories = [ ... ];
const [searchTerm, setSearchTerm] = React.useState('');
const handleSearch = (event) => {
setSearchTerm(event.target.value);
};
const searchedStories = stories.filter(function (story) {
return story.title.includes(searchTerm);
});
return (
<div>
<h1>My React Stories</h1>
<Search onSearch={handleSearch} />
<hr />
<List list={searchedStories} />
</div>
);
};여기서 자바스크립트 배열의 내장 filter 메서드는 새로운 필터링 된 배열을 생성하는 데 사용됩니다. filter() 메서드는 함수를 인자로 받는데, 이 함수는 배열의 각 아이템에 접근하여 true 또는 false를 반환합니다. 함수가 true를 반환하면(조건 충족) 아이템은 새로 생성된 배열에 남고, false를 반환하면 제거됩니다.
filter() 메서드는 화살표 함수와 즉시 반환(immediate return)을 사용하여 더 간결하게 만들 수 있습니다.
이것이 filter() 메서드의 인라인 함수에 대한 리팩터링 단계의 전부입니다. 가독성과 간결함 사이의 균형을 맞추는 것이 항상 쉽지는 않지만, 가능한 한 간결하게 유지하는 것이 대부분의 경우 가독성도 좋게 만듭니다.
아직 제대로 작동하지 않는 부분이 있습니다. filter() 메서드는 searchTerm이 각 이야기의 title 속성에 문자열로 존재하는지 확인하지만, 대소문자를 구분합니다. “react”를 검색하면 렌더링 된 리스트에 “React” 이야기가 필터링 되지 않습니다. 자바스크립트의 힘을 빌려 filter() 메서드의 조건을 대소문자를 구분하지 않도록(case insensitive) 수정해 보세요. 다음 코드 스니펫은 searchTerm과 이야기의 title을 모두 소문자로 변환하여 이를 달성하는 방법을 보여줍니다.
이제 “eact”, “React”, “react”를 검색하면 표시된 두 가지 이야기 중 하나를 볼 수 있을 것입니다. 축하합니다! 여러분은 필터링 된 이야기 목록을 도출하기 위해 State를 활용하고, Search 컴포넌트에 콜백 핸들러를 전달하기 위해 Props를 활용하여 첫 번째 실제 상호작용 기능을 애플리케이션에 추가했습니다.
결국, React에서 State를 어디에 인스턴스화해야 하는지 아는 것은 모든 React 개발자의 경력에서 중요한 기술이 됩니다. State는 그 State에 의존하는 모든 컴포넌트가 (Props를 통해) 읽고, (콜백 핸들러를 통해) 업데이트할 수 있는 위치에 있어야 합니다. 이들은 모두 State를 인스턴스화하는 컴포넌트의 하위 컴포넌트들입니다.
React 제어 컴포넌트
HTML 요소들은 React와 결합되지 않은 고유한 내부 상태(internal state)를 가지고 있습니다. Search 컴포넌트에 구현된 HTML 입력 필드를 확인하여 이 가설을 직접 검증해 보세요. 우리는 id와 type 같은 필수 속성과 핸들러(여기서는 onChange)를 제공하지만, 요소에게 값을 알려주지는 않습니다. 하지만 사용자가 입력할 때 올바른 값을 보여줍니다.
이제 다음을 시도해 보세요. App 컴포넌트에서 searchTerm state를 초기화할 때, 빈 문자열 대신 ’React’를 초기 상태로 사용해 보세요. 그런 다음 브라우저에서 애플리케이션을 열어보세요. 문제를 발견하셨나요? 여기서 무슨 일이 일어나는지, 그리고 이 문제를 어떻게 해결할지 스스로 파악하는 시간을 가져보세요.
새로운 초기 searchTerm에 따라 이야기들이 필터링 되었음에도 불구하고, HTML 입력 필드에는 브라우저상에서 그 값이 보이지 않습니다. 입력 필드에 타이핑을 시작해야만 변경 사항이 반영되는 것을 볼 수 있습니다.
이는 입력 필드가 React의 state(여기서는 searchTerm)에 대해 아무것도 모르기 때문입니다. 입력 필드는 오직 핸들러를 사용하여 자신의 내부 상태를 React state에 전달할 뿐입니다(handleSearch 참조). 사용자가 입력 필드에 타이핑을 시작하면, HTML 요소가 변경 사항을 자체적으로 추적합니다. 하지만 제대로 하려면 HTML이 React state에 대해 알고 있어야 합니다. 따라서 현재 state를 값(value)으로 제공해야 합니다.
// src/App.jsx
const App = () => {
const stories = [ ... ];
const [searchTerm, setSearchTerm] = React.useState('React');
// ...
return (
<div>
<h1>My React Stories</h1>
<Search search={searchTerm} onSearch={handleSearch} />
{/* ... */}
</div>
);
};
const Search = (props) => (
<div>
<label htmlFor="search">Search: </label>
<input
id="search"
type="text"
value={props.search}
onChange={props.onSearch}
/>
</div>
);이제 두 상태가 동기화되었습니다. HTML 요소가 자체 내부 상태를 추적할 자유를 주는 대신, 요소의 value 속성을 활용하여 React의 state를 사용하도록 합니다. HTML 요소가 변경 이벤트를 발생시킬 때마다 새로운 값이 React의 state에 기록되고 컴포넌트들을 리렌더링 합니다. 그러면 HTML 요소는 최신 state를 다시 값으로 사용합니다.
이전에는 HTML 요소가 독자적으로 동작했지만, 이제는 React의 state를 주입하여 우리가 제어하게 되었습니다. 입력 필드가 명시적으로 제어된 요소(controlled element)가 되면서, Search 컴포넌트는 암묵적으로 제어 컴포넌트(controlled component)가 되었습니다. React 초보자에게는 제어 컴포넌트를 사용하는 것이 중요한데, 예측 가능한 동작을 강제할 수 있기 때문입니다. 나중에는 비제어 컴포넌트(uncontrolled components)를 사용할 경우도 있을 것입니다.
Props는 컴포넌트 트리를 따라 부모에서 자식으로 전달됩니다. 우리는 Props를 사용하여 한 컴포넌트에서 다른 컴포넌트로 정보를 자주 전달하며, 때로는 중간에 있는 다른 컴포넌트들을 거치기도 하므로, Props 전달을 더 편리하게 만드는 몇 가지 요령을 알아두는 것이 유용합니다.
다음 리팩터링들은 다양한 자바스크립트/React 패턴을 배우기 위해 권장되지만, 이것들 없이도 완전한 React 애플리케이션을 구축할 수 있습니다. 특정 시나리오에서 React 코드를 더 간결하고, 읽기 쉽고, 유지 보수하기 쉽게 만들어 주는 고급 Props 기술이라고 생각하세요.
객체 구조 분해 할당을 통한 Props 구조 분해
React props는 단지 자바스크립트 객체일 뿐입니다. 그렇지 않다면 React 컴포넌트에서 props.list나 props.onSearch에 접근할 수 없었을 것입니다. props는 한 컴포넌트에서 다른 컴포넌트로 정보를 전달하는 객체이므로, 몇 가지 자바스크립트 요령을 적용할 수 있습니다. 예를 들어, 최신 자바스크립트의 객체 구조 분해 할당(object destructuring)을 사용하여 객체의 속성에 접근할 수 있습니다.
객체의 다양한 속성에 접근해야 할 때, 여러 줄 대신 한 줄의 코드를 사용하는 것이 종종 더 직관적이고 우아한 접근 방식입니다. 이것이 자바스크립트에서 객체 구조 분해 할당이 널리 사용되는 이유입니다. 다음 코드 스니펫을 살펴보기 전에, 이 이해를 Search 컴포넌트의 React props에 적용해 보세요.
이제 props 구조 분해를 어떻게 적용할 수 있는지 살펴보겠습니다. 먼저 Search 컴포넌트의 화살표 함수를 간결한 본문에서 블록 본문으로 리팩터링해야 합니다. 그 후 함수 본문 내에서 props 객체의 구조 분해를 구현할 수 있습니다.
이것은 React 컴포넌트에서 props 객체를 기본적으로 구조 분해하여 컴포넌트 내에서 속성들을 편리하게 사용할 수 있게 한 것입니다. 하지만 컴포넌트 함수 본문에서 객체 구조 분해로 props의 속성에 접근하기 위해 Search 컴포넌트의 화살표 함수를 간결한 본문에서 블록 본문으로 리팩터링해야 했습니다. 이 패턴을 따른다면 매번 컴포넌트를 리팩터링해야 하므로 일이 더 쉬워지지는 않을 것입니다.
여기서 한 단계 더 나아가 컴포넌트의 함수 시그니처에서 바로 props 객체를 구조 분해하고, 컴포넌트 함수의 블록 본문을 다시 생략할 수 있습니다.
React의 props는 그 자체로 사용되는 경우는 드물고, props 객체에 포함된 정보가 사용됩니다. 컴포넌트의 함수 시그니처에서 바로 props 객체를 구조 분해함으로써, props 컨테이너를 다루지 않고도 모든 정보에 편리하게 접근할 수 있습니다.
List와 Item 컴포넌트에서도 동일한 props 구조 분해를 수행할 수 있습니다.
// src/App.jsx
const List = ({ list }) => (
<ul>
{list.map((item) => (
<Item key={item.objectID} item={item} />
))}
</ul>
);
const Item = ({ item }) => (
<li>
<span>
<a href={item.url}>{item.title}</a>
</span>
<span>{item.author}</span>
<span>{item.num_comments}</span>
<span>{item.points}</span>
</li>
);객체 구조 분해 할당의 사용은 자바스크립트의 모범 사례와 일치하며 더 깔끔하고 효율적인 React 컴포넌트 구조를 장려합니다. 이는 필요한 속성을 더 직관적으로 추출할 수 있게 하여 코드의 명확성과 React 애플리케이션의 전반적인 개발 경험을 향상합니다. 하지만 몇 가지 선택적인 고급 레슨을 통해 한 단계 더 나아갈 수 있습니다.
중첩 구조 분해 (Nested Destructuring)
Item 컴포넌트로 들어오는 item 매개변수는 앞서 논의한 props 매개변수와 공통점이 있습니다. 둘 다 자바스크립트 객체라는 점입니다. 또한 item 객체는 Item 컴포넌트의 함수 시그니처에서 이미 props로부터 구조 분해 되었지만, Item 컴포넌트에서 직접 사용되지는 않습니다. item 객체는 오직 그 정보(객체 속성)를 엘리먼트에 전달할 뿐입니다.
현재의 해결책도 앞으로의 논의에서 보겠지만 괜찮습니다. 하지만 React에서 자바스크립트 객체에 대해 배울 것이 많으므로 두 가지 변형을 더 보여드리고 싶습니다.
중첩 구조 분해(nested destructuring)가 어떻게 작동하는지 시작해 봅시다.
중첩 구조 분해는 item 객체의 필요한 모든 정보를 함수 시그니처에서 모아 컴포넌트의 엘리먼트에서 즉시 사용할 수 있게 해 줍니다.
요약하자면, React에서의 중첩 구조 분해는 복잡한 데이터 구조, 특히 props나 state 내의 중첩된 객체나 배열을 다룰 때 강력하고 효율적인 기술임이 입증되었습니다. 이 접근 방식은 깊게 중첩된 값을 추출하는 것을 단순화하여 코드를 더 간결하고 읽기 쉽게 만듭니다. 하지만 중첩 구조 분해는 함수 시그니처에 들여쓰기를 통한 많은 잡동사니(clutter)를 유발합니다. 여기서는 가장 읽기 쉬운 옵션은 아니지만, 다른 시나리오에서는 유용할 수 있습니다.
전개 연산자와 나머지 연산자 (Spread and Rest Operators)
자바스크립트의 전개 연산자(spread operator)와 나머지 연산자(rest operator)를 사용하는 또 다른 접근 방식을 취해 봅시다. 이를 준비하기 위해, List와 Item 컴포넌트를 다음 구현으로 리팩터링하겠습니다. item을 객체로 List에서 Item 컴포넌트로 전달하는 대신, item 객체의 모든 속성을 전달합니다.
// src/App.jsx
// 변형 2: 전개 연산자와 나머지 연산자
// 1단계
const List = ({ list }) => (
<ul>
{list.map((item) => (
<Item
key={item.objectID}
title={item.title}
url={item.url}
author={item.author}
num_comments={item.num_comments}
points={item.points}
/>
))}
</ul>
);
const Item = ({ title, url, author, num_comments, points }) => (
<li>
<span>
<a href={url}>{title}</a>
</span>
<span>{author}</span>
<span>{num_comments}</span>
<span>{points}</span>
</li>
);이제 Item 컴포넌트의 함수 시그니처는 더 간결해졌지만, 잡동사니들이 List 컴포넌트로 옮겨갔습니다. 모든 속성을 Item 컴포넌트에 개별적으로 전달하고 있기 때문입니다. 자바스크립트의 전개 연산자를 사용하여 이 접근 방식을 개선할 수 있습니다.
자바스크립트의 전개 연산자는 객체의 모든 키/값 쌍을 말 그대로 다른 객체로 펼쳐 넣을 수 있게 해 줍니다. 이는 React의 JSX에서도 가능합니다. List에서 Item 컴포넌트로 이전처럼 props를 통해 각 속성을 하나씩 전달하는 대신, 자바스크립트의 전개 연산자를 사용하여 객체의 모든 키/값 쌍을 속성/값 쌍으로 JSX 엘리먼트에 전달할 수 있습니다.
이 리팩터링으로 List에서 Item 컴포넌트로 정보를 전달하는 과정이 더 간결해졌습니다. 마지막으로, 자바스크립트의 나머지 구조 분해(rest destructuring)를 금상첨화로 사용해 보겠습니다. 자바스크립트 나머지 연산은 항상 객체 구조 분해의 마지막 부분에서 발생합니다.
문법은 같지만(점 세 개 ...), 나머지 연산자는 전개 연산자와 혼동해서는 안 됩니다. 나머지 연산자는 할당의 왼쪽에서 발생하고, 전개 연산자는 오른쪽에서 발생합니다. 나머지 연산자는 항상 객체를 일부 속성과 분리하는 데 사용됩니다.
이제 이것을 List 컴포넌트에서 objectID를 item에서 분리하는 데 사용할 수 있습니다. objectID는 키로만 사용되고 Item 컴포넌트 내부에서는 사용되지 않기 때문입니다. 남은(rest) item만 이전처럼 Item 컴포넌트로 속성/값 쌍으로 전개됩니다.
이 최종 변형에서는 objectID를 나머지 item 객체에서 구조 분해하기 위해 나머지 연산자를 사용했습니다. 그 후 item은 키/값 쌍과 함께 Item 컴포넌트로 전개됩니다. 이 최종 변형은 매우 간결하지만, 일부 사람들에게는 생소할 수 있는 고급 자바스크립트 기능을 동반합니다.
props 객체뿐만 아니라 props 내에 중첩된 item 객체와 같은 다른 객체에도 일반적으로 사용될 수 있는 자바스크립트 객체 구조 분해 할당에 대해 배웠습니다. 또한 중첩 구조 분해를 사용하는 방법(변형 1)도 보았지만, 이 경우에는 컴포넌트만 더 비대해질 뿐 큰 이점이 없었습니다.
마지막으로, 서로 혼동해서는 안 되는 자바스크립트의 전개 연산자와 나머지 연산자에 대해 배웠으며, 이를 통해 자바스크립트 객체에 대한 연산을 수행하고 한 컴포넌트에서 다른 컴포넌트로 props 객체를 가장 간결한 방식으로 전달하는 방법을 알게 되었습니다.
결국, 다음 섹션에서 계속 사용할 초기 버전을 다시 짚고 넘어가고 싶습니다.
가장 간결하지는 않을 수 있지만, 가장 이해하기 쉽습니다. 중첩 구조 분해를 사용한 변형 1은 큰 이점을 주지 못했고, 변형 2는 모든 사람에게 익숙하지 않은 너무 많은 고급 자바스크립트 기능(전개 연산자, 나머지 연산자)을 추가했습니다. 결국 모든 변형에는 장단점이 있습니다. 컴포넌트를 리팩터링할 때는 항상 가독성을 목표로 하고, 특히 팀 프로젝트에서는 모두가 공통된 React 코드 스타일을 사용하도록 하세요.
함수형 컴포넌트의 함수 시그니처에서 props는 거의 항상 객체 구조 분해를 사용하세요. props 자체가 사용되는 경우는 드물기 때문입니다. 단, 예외가 있는데 props가 단순히 다음 자식 컴포넌트로 전달되기만 할 때 (전개 연산자 사용 시점 참조).
객체의 모든 키/값 쌍을 JSX의 자식 컴포넌트로 전달하고 싶을 때 전개 연산자를 사용하세요. 예를 들어, props 자체가 컴포넌트에서 사용되지 않고 다음 컴포넌트로 전달만 될 때 유용합니다. 그럴 때는
...props를 다음 컴포넌트로 전개하는 것이 합리적입니다.
props 객체에서 특정 속성만 분리하고 싶을 때 나머지 연산자를 사용하세요.
중첩 구조 분해는 가독성을 향상시킬 때만 사용하세요.
import * as React from 'react';
function App() {
const stories = [
{
title: 'React',
url: 'https://react.dev/',
author: 'Jordan Walke',
num_comments: 3,
points: 4,
objectID: 0,
},
{
title: 'Redux',
url: 'https://redux.js.org/',
author: 'Dan Abramov, Andrew Clark',
num_comments: 2,
points: 5,
objectID: 1,
},
];
const [searchTerm, setSearchTerm] = React.useState('React');
const handleSearch = (event) => {
setSearchTerm(event.target.value);
};
const searchedStories = stories.filter((story) =>
story.title.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
<h1>My React Stories</h1>
<Search search={searchTerm} onSearch={handleSearch} />
<hr />
<List list={searchedStories} />
</div>
);
}
const Search = ({ search, onSearch }) => (
<div>
<label htmlFor="search">Search: </label>
<input
id="search"
type="text"
value={search}
onChange={onSearch}
/>
</div>
);
const List = ({ list }) => (
<ul>
{list.map((item) => (
<Item key={item.objectID} item={item} />
))}
</ul>
);
const Item = ({ item }) => (
<li>
<span>
<a href={item.url}>{item.title}</a>
</span>
</li>
);
export default App;React 사이드 이펙트
React 컴포넌트의 반환 결과는 props와 state에 의해 정의됩니다. 사이드 이펙트(Side-effects, 부수 효과) 또한 이 출력에 영향을 줄 수 있습니다. 사이드 이펙트는 서드파티 API(브라우저의 localStorage API, 데이터 가져오기를 위한 원격 API)와의 상호작용, 너비나 높이 측정을 위한 HTML 엘리먼트와의 상호작용, 또는 타이머나 인터벌 같은 내장 자바스크립트 함수와의 상호작용 등에 사용되기 때문입니다. 이것들은 React 컴포넌트에서 발생하는 사이드 이펙트의 몇 가지 예시일 뿐이며, 우리는 이 중 하나를 다음에 적용해 볼 것입니다.
현재는 애플리케이션에서 검색어를 검색하면 결과를 얻습니다. 하지만 브라우저를 닫았다가 다시 열면 검색어가 사라집니다. Search 컴포넌트가 가장 최근의 검색어를 기억해서 애플리케이션이 다시 시작될 때 브라우저에 표시해 준다면 훌륭한 사용자 경험이 되지 않을까요?
브라우저의 로컬 스토리지(local storage)에 최근 검색어를 저장하는 사이드 이펙트를 사용하고, 컴포넌트 초기화 시 이를 가져오도록 이 기능을 구현해 보겠습니다.
먼저, 사용자가 HTML 입력 필드에 타이핑할 때마다 식별자와 함께 searchTerm을 로컬 스토리지에 저장합니다.
둘째, 저장된 값이 있다면 그 값을 사용하여 React의 useState 훅에서 searchTerm의 초기 상태를 설정합니다. 그렇지 않으면 이전처럼 초기 상태를 기본값(여기서는 “React”)으로 설정합니다.
알아두면 좋은 점: 자바스크립트의 논리 OR 연산자(||)는 표현식에서 ’참 같은 값(truthy)’을 반환하며, localStorage.getItem('search')가 참 같은 값을 반환하면 단락 평가(short-circuited)됩니다. 이는 기본값을 설정하기 위한 다음 구현의 단축 표현입니다.
입력 필드를 사용하고 브라우저 탭을 새로고침하면 브라우저가 최신 검색어를 기억해야 합니다. 본질적으로 우리는 브라우저의 로컬 스토리지와 React의 state를 동기화했습니다. 브라우저의 로컬 스토리지 값(또는 폴백 값)으로 state를 초기화하고, 핸들러가 호출될 때마다 새로운 값을 브라우저 스토리지와 컴포넌트 state에 씁니다.
기능은 완성되었지만, 장기적으로 버그를 유발할 수 있는 결함이 하나 있습니다. 핸들러 함수는 주로 state 업데이트에 관심을 두어야 하는데, 지금은 사이드 이펙트까지 가지고 있습니다. 결함의 내용은 이렇습니다. 만약 우리가 애플리케이션의 다른 곳에서 setSearchTerm 상태 업데이트 함수를 사용한다면, 로컬 스토리지는 이벤트 핸들러에서만 업데이트되므로 기능이 깨지게 됩니다.
특정 핸들러가 아닌 중앙화된 장소에서 사이드 이펙트를 처리하여 이 문제를 해결해 봅시다. 우리는 React의 useEffect 훅을 사용하여 searchTerm이 변경될 때마다 원하는 사이드 이펙트를 트리거할 것입니다.
// src/App.jsx
const App = () => {
const [searchTerm, setSearchTerm] = React.useState(
localStorage.getItem('search') || 'React'
);
React.useEffect(() => {
localStorage.setItem('search', searchTerm);
}, [searchTerm]);
const handleSearch = (event) => {
setSearchTerm(event.target.value);
};
return ( ... );
};React의 useEffect 훅은 두 개의 인자를 받습니다. 첫 번째 인자는 사이드 이펙트를 실행하는 함수입니다. 우리 예시의 경우, 사이드 이펙트는 searchTerm을 브라우저의 로컬 스토리지에 저장합니다. 두 번째 인자는 변수들의 의존성 배열(dependency array)입니다. 이 변수 중 하나라도 변경되면 사이드 이펙트 함수가 호출됩니다. 우리 예시의 경우, searchTerm이 변경될 때마다(사용자가 HTML 입력 필드에 타이핑할 때) 함수가 호출됩니다. 또한 컴포넌트가 처음 렌더링 될 때도 초기에 호출됩니다.
두 번째 인자(의존성 배열)를 생략하면 사이드 이펙트 함수는 컴포넌트의 모든 렌더링(초기 렌더링 및 업데이트 렌더링)마다 실행됩니다. React useEffect의 의존성 배열이 빈 배열([])이라면, 사이드 이펙트 함수는 컴포넌트가 처음 렌더링 될 때만 딱 한 번 호출됩니다.
결국 이 훅은 컴포넌트가 마운트(mount), 업데이트(update), 언마운트(unmount)될 때 React의 컴포넌트 생명주기(lifecycle)에 관여할 수 있게 해 줍니다. 컴포넌트가 처음 마운트 될 때뿐만 아니라, 값(state, props, state/props에서 파생된 값) 중 하나가 업데이트될 때도 트리거 될 수 있습니다.
결론적으로 (이벤트) 핸들러에서 사이드 이펙트를 관리하는 대신 React useEffect 훅을 사용함으로써 애플리케이션이 더 견고해졌습니다. 언제 어디서든 setSearchTerm을 통해 searchTerm state가 업데이트되면, 브라우저의 로컬 스토리지는 항상 그와 동기화될 것입니다.
React 커스텀 훅
지금까지 우리는 React의 가장 인기 있는 두 가지 훅인 useState와 useEffect를 깊이 파고들었습니다. 전자는 변경되는 값을 관리하는 데 유용하고, 후자는 React 컴포넌트의 생명주기에 사이드 이펙트를 포함하는 것을 용이하게 합니다. React가 제공하는 훅은 더 있지만, 이제 우리는 특정 요구사항에 맞춰 자체적인 훅을 만드는 React 커스텀 훅(Custom Hooks)에 초점을 맞출 것입니다.
이 개념을 설명하기 위해, 우리는 useState와 useEffect에 대한 이해를 바탕으로 useStorageState라는 새로운 커스텀 훅을 만들어 볼 것입니다. 이 커스텀 훅의 주요 목표는 컴포넌트의 state를 브라우저의 로컬 스토리지와 동기화하는 것입니다.
먼저 App 컴포넌트 내에서 이 훅을 어떻게 사용할지 개요를 잡는 것부터 시작하겠습니다.
// src/App.jsx
const App = () => {
const stories = [ ... ];
const [searchTerm, setSearchTerm] = useStorageState('React');
const handleSearch = (event) => {
setSearchTerm(event.target.value);
};
const searchedStories = stories.filter((story) =>
story.title.toLowerCase().includes(searchTerm.toLowerCase())
);
return ( ... );
};이 커스텀 훅을 사용하면 React의 네이티브 useState 훅과 유사한 방식으로 사용할 수 있습니다. 초기 상태를 인자로 받아 상태 변수와 상태 업데이트 함수를 제공합니다. 이 훅의 기본 기능은 state와 브라우저의 로컬 스토리지 간의 동기화를 보장하도록 설계될 것입니다.
이전 코드 스니펫의 App 컴포넌트를 자세히 보면, 이전에 도입했던 로컬 스토리지 기능이 더 이상 존재하지 않는 것을 볼 수 있습니다. 대신 우리는 이 기능을 복사하여 새로운 커스텀 훅으로 옮길 것입니다.
지금까지 이 커스텀 훅은 이전에 App 컴포넌트에서 사용했던 useState와 useEffect 훅을 감싼 함수일 뿐입니다. 빠진 것은 초기 상태를 제공하는 것과 App 컴포넌트에서 필요한 값들을 배열로 반환하는 것입니다.
여기서 우리는 React 내장 훅의 두 가지 관례를 따르고 있습니다. 첫째, 모든 훅 이름 앞에 “use” 접두사를 붙이는 명명 규칙입니다. 둘째, 반환되는 값들이 배열로 반환됩니다.
커스텀 훅의 또 다른 목표는 재사용성(reusability)이어야 합니다. 이 커스텀 훅의 모든 내부 로직은 특정 검색 도메인에 관한 것이지만, 커스텀 훅을 재사용 가능하고 일반적(generic)으로 만들기 위해서는 내부 이름을 조정해야 합니다.
이제 우리는 커스텀 훅 내부에서 추상화된 “value”를 다룹니다. App 컴포넌트에서 이를 사용할 때, 배열 구조 분해 할당을 통해 반환된 현재 상태와 상태 업데이트 함수의 이름을 도메인과 관련된 이름(searchTerm, setSearchTerm)으로 지정할 수 있습니다.
이 커스텀 훅에는 아직 한 가지 문제가 있습니다. React 애플리케이션에서 이 커스텀 훅을 두 번 이상 사용하면 로컬 스토리지에서 “value”로 할당된 아이템을 덮어쓰게 됩니다. 로컬 스토리지에서 같은 키를 사용하기 때문입니다. 이를 해결하기 위해 유연한 키(key)를 전달해야 합니다. 키는 외부에서 오므로 커스텀 훅은 키가 변경될 수 있다고 가정해야 하며, 따라서 useEffect 훅의 의존성 배열에도 포함되어야 합니다. 그렇지 않으면 렌더링 사이에 키가 변경될 경우 사이드 이펙트가 오래된(stale) 키로 실행될 수 있습니다.
// src/App.jsx
const useStorageState = (key, initialState) => {
const [value, setValue] = React.useState(
localStorage.getItem(key) || initialState
);
React.useEffect(() => {
localStorage.setItem(key, value);
}, [value, key]);
return [value, setValue];
};
const App = () => {
const [searchTerm, setSearchTerm] = useStorageState(
'search',
'React'
);
// ...
};키가 제자리를 잡으면서 이제 애플리케이션에서 이 새로운 커스텀 훅을 두 번 이상 사용할 수 있게 되었습니다. 첫 번째 인자로 전달하는 키가 고유한 식별자인지 확인하여 브라우저의 로컬 스토리지에 고유한 키로 state를 할당하기만 하면 됩니다.
여러분은 방금 첫 번째 커스텀 훅을 만들었습니다! 커스텀 훅이 익숙하지 않다면 변경 사항을 되돌리고 이전처럼 App 컴포넌트에서 useState와 useEffect 훅을 사용해도 됩니다. 하지만 커스텀 훅에 대해 아는 것은 많은 새로운 옵션을 제공합니다. 커스텀 훅은 사소하지 않은 구현 세부 사항을 컴포넌트에서 분리하여 캡슐화할 수 있고, 하나 이상의 React 컴포넌트에서 사용될 수 있으며, 다른 훅들의 조합으로 이루어질 수 있고, 심지어 외부 라이브러리로 오픈 소스화할 수도 있습니다. 좋아하는 검색 엔진을 사용해 보면 구현 세부 사항에 대한 걱정 없이 애플리케이션에서 사용할 수 있는 수백 개의 React 훅이 있다는 것을 알게 될 것입니다.
import * as React from 'react';
function App() {
const stories = [
{
title: 'React',
url: 'https://react.dev/',
author: 'Jordan Walke',
num_comments: 3,
points: 4,
objectID: 0,
},
{
title: 'Redux',
url: 'https://redux.js.org/',
author: 'Dan Abramov, Andrew Clark',
num_comments: 2,
points: 5,
objectID: 1,
},
];
const [searchTerm, setSearchTerm] = useStorageState('search','React');
React.useEffect(() => {
localStorage.setItem('search', searchTerm);
}, [searchTerm]);
const handleSearch = (event) => {
setSearchTerm(event.target.value);
};
const searchedStories = stories.filter((story) =>
story.title.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
<h1>My React Stories</h1>
<Search search={searchTerm} onSearch={handleSearch} />
<hr />
<List list={searchedStories} />
</div>
);
}
const useStorageState = (key, initialState) => {
const [value, setValue] = React.useState(
localStorage.getItem(key) || initialState
);
React.useEffect(() => {
localStorage.setItem(key, value);
}, [value, key]);
return [value, setValue];
};
const Search = ({ search, onSearch }) => (
<div>
<label htmlFor="search">Search: </label>
<input
id="search"
type="text"
value={search}
onChange={onSearch}
/>
</div>
);
const List = ({ list }) => (
<ul>
{list.map((item) => (
<Item key={item.objectID} item={item} />
))}
</ul>
);
const Item = ({ item }) => (
<li>
<span>
<a href={item.url}>{item.title}</a>
</span>
</li>
);
export default App;React 프래그먼트
모든 React 컴포넌트가 하나의 최상위 HTML 엘리먼트로 JSX를 반환한다는 것을 눈치채셨을 것입니다. 얼마 전 Search 컴포넌트를 도입했을 때, 우리는 <div> 태그(컨테이너 엘리먼트)를 추가해야 했습니다. 그렇지 않으면 최상위 엘리먼트로 감싸지 않은 채 라벨과 입력 요소를 나란히 반환할 수 없었기 때문입니다.
하지만 여러 최상위 엘리먼트를 나란히 렌더링하는 방법들이 있습니다. 드물게 사용되는 접근 방식은 모든 형제 엘리먼트를 배열로 반환하는 것입니다. 이는 엘리먼트 리스트와 유사하므로 각 리스트 아이템에 필수적인 key 속성을 부여해야 합니다.
다행히 최상위 엘리먼트 없이 형제 엘리먼트들을 나란히 반환하는 또 다른 방법이 있습니다. 배열을 사용하는 지난 접근 방식은 가독성이 떨어지고 추가적인 key 속성 때문에 코드가 장황해지기 때문입니다. 또 다른 해결책은 React 프래그먼트(React Fragment)를 사용하는 것입니다.
프래그먼트는 렌더링 된 출력에 추가하지 않고 형제 엘리먼트들을 하나의 최상위 엘리먼트로 감쌉니다. React 컴포넌트에서 프래그먼트를 사용한 후 브라우저의 개발자 도구에서 엘리먼트를 검사해 보면 직접 확인할 수 있습니다. 요즘 더 인기 있는 대안은 프래그먼트의 단축 문법(shorthand version)을 사용하는 것입니다.
Search 컴포넌트의 두 엘리먼트(입력 필드와 라벨)는 여전히 브라우저에 표시될 것입니다. 결국 React를 만족시키기 위한 중간 엘리먼트를 도입하고 싶지 않을 때마다 프래그먼트를 도우미 “엘리먼트”로 사용할 수 있습니다.
재사용 가능한 React 컴포넌트
Search 컴포넌트를 좀 더 자세히 살펴보세요. 구현의 모든 세부 사항이 검색 기능과 밀접하게 연결되어 있습니다. 하지만 내부적으로 이 컴포넌트는 단지 라벨과 입력 필드로 구성되어 있을 뿐입니다. 왜 단일 도메인에만 그렇게 단단히 묶여 있어야 할까요? 이러한 좁은 연관성은 애플리케이션 내 다른 기능을 위해 컴포넌트를 조정하는 것을 어렵게 만듭니다. 결과적으로 Search 컴포넌트는 검색과 관련 없는 작업에는 실용적이지 않습니다.
게다가 Search 컴포넌트는 버그를 유발할 위험이 있습니다. 만약 이 Search 컴포넌트의 인스턴스가 같은 페이지에 여러 개 렌더링 된다면, htmlFor/id 조합이 중복됩니다. 이러한 중복은 사용자가 라벨 중 하나를 클릭했을 때 포커스를 방해합니다. 이러한 문제를 해결하기 위해 Search 컴포넌트의 재사용성을 향상시켜 봅시다.
Search 컴포넌트가 실제 “검색” 기능을 가지고 있지 않다는 점을 감안할 때, 이를 다양한 애플리케이션 기능에 재사용할 수 있도록 만드는 것은 최소한의 노력으로 가능합니다. 동적인 id와 label props를 Search 컴포넌트에 도입하고, 특정 값과 콜백 핸들러의 이름을 더 일반적인 용어로 변경하고, 결과적으로 컴포넌트 자체의 이름을 변경함으로써 이를 달성할 수 있습니다.
// src/App.jsx
const App = () => {
return (
<div>
<h1>My React Stories</h1>
<InputWithLabel
id="search"
label="Search"
value={searchTerm}
onInputChange={handleSearch}
/>
{/* ... */}
</div>
);
};
const InputWithLabel = ({ id, label, value, onInputChange }) => (
<>
<label htmlFor={id}>{label}</label>
<input
id={id}
type="text"
value={value}
onChange={onInputChange}
/>
</>
);완전히 재사용 가능하지만, 적용 범위는 텍스트 입력(input with text)을 사용하는 것으로 제한됩니다. 범위를 넓혀 숫자(number)나 전화번호(tel) 같은 추가적인 입력 타입을 지원하려면 type 속성 또한 외부에서 접근할 수 있어야 합니다.
App 컴포넌트에서 InputWithLabel 컴포넌트로 type prop을 전달하지 않으므로, 함수 시그니처의 기본 매개변수(default parameter)가 type을 대신합니다. 따라서 InputWithLabel 컴포넌트가 type prop 없이 사용될 때마다 기본 타입은 “text”가 됩니다.
몇 가지 변경만으로 우리는 특화된 Search 컴포넌트를 더 재사용 가능한 InputWithLabel 컴포넌트로 바꿨습니다. 내부 구현 세부 사항의 명칭을 일반화했고, 외부에서 필요한 모든 정보를 제공할 수 있도록 새로운 컴포넌트에 더 넓은 API 표면(API surface)을 제공했습니다. 우리는 아직 이 컴포넌트를 다른 곳에서 사용하고 있지는 않지만, 그렇게 해야 할 경우 작업을 처리할 수 있는 능력을 키웠습니다.
컴포넌트의 일반화(generalization)와 특수화(specialization) 사이에는 항상 트레이드오프(trade-off)가 있습니다. 이 경우, 우리는 고도로 특수화된 컴포넌트를 일반화된 컴포넌트로 바꿨습니다. 일반화된 컴포넌트는 애플리케이션에서 재사용될 가능성이 더 높은 반면, 특수화된 컴포넌트는 특정 사용 사례를 위한 비즈니스 로직을 구현하므로 전혀 재사용할 수 없을 것입니다.
React 컴포넌트 합성
본질적으로 React 애플리케이션은 트리 모양으로 배열된 React 컴포넌트들의 묶음입니다. JSX에서 엘리먼트로 컴포넌트를 초기화하는 것에 대해 배웠을 때, JSX의 다른 HTML 엘리먼트처럼 사용되는 것을 보았습니다. 하지만 지금까지는 스스로 닫는 태그(self-closing tags)로만 사용했습니다. React 엘리먼트에도 여는 태그와 닫는 태그가 있을 수 있다면 어떨까요?
컴포넌트 합성(Component Composition)의 개념으로 들어가 봅시다.
컴포넌트 합성은 React의 강력한 기능 중 하나입니다. 본질적으로 우리는 여는 태그와 닫는 태그를 활용하여 React 엘리먼트를 HTML 엘리먼트와 같은 방식으로 사용하는 방법을 발견할 것입니다. 이전 예제에서는 이전에 사용했던 label prop 대신, 컴포넌트 엘리먼트 태그 사이에 “Search:”라는 텍스트를 삽입했습니다. InputWithLabel 컴포넌트에서는 이제 React의 children prop을 통해 이 정보에 접근할 수 있습니다. label prop을 사용하는 대신, children prop을 사용하여 <InputWithLabel> 여는 태그와 닫는 태그 사이에 렌더링 된 모든 것을 렌더링 하세요.
이제 React 컴포넌트의 엘리먼트는 네이티브 HTML과 유사하게 동작합니다. 컴포넌트의 엘리먼트 사이에 전달된 모든 것은 컴포넌트 내에서 children으로 접근하여 렌더링 될 수 있습니다. 때로는 React 컴포넌트를 사용할 때, 컴포넌트 내부에서 무엇을 렌더링 할지에 대해 외부에서 더 많은 자유를 원할 때가 있습니다.
React children prop을 사용하면 React 컴포넌트들을 서로 합성할 수 있습니다. 우리는 문자열과 HTML <strong> 엘리먼트로 감싸진 문자열을 사용해 보았지만, 여기서 끝이 아닙니다. React 엘리먼트도 React children을 통해 전달할 수 있으며, 이는 연습 삼아 꼭 더 탐구해 보시기 바랍니다.
명령형 React
명령형 프로그래밍(Imperative programming)은 프로그램이 작업을 수행하는 방법을 자세히 설명하는 단계별 지침을 제공하는 것을 포함합니다. 대조적으로, 선언형 프로그래밍(Declarative programming)은 모든 절차적 단계를 지정하지 않고 원하는 결과를 지정하는 데 중점을 둡니다. 선언형 코드는 종종 더 간결하고, 읽기 쉽고, 유지 보수하기 쉬운 것으로 간주됩니다.
React는 선언형 프로그래밍 접근 방식을 사용합니다. UI 업데이트를 위해 문서 객체 모델(DOM)을 수동으로 조작하는 대신, 개발자는 원하는 UI 상태를 선언하고 React가 렌더링 프로세스를 관리합니다. 이러한 선언적 접근 방식은 코드 가독성과 확장성을 향상시키고, DOM 조작의 복잡성을 추상화하여 더 높은 수준의 추상화로 동적인 사용자 인터페이스를 생성할 수 있게 합니다.
JSX를 구현할 때, React에게 요소를 어떻게 생성할지가 아니라 어떤 요소를 보고 싶은지 말해줍니다. state를 위한 훅을 구현할 때, React에게 상태를 어떻게 관리할지가 아니라 무엇을 상태 값으로 관리하고 싶은지 말해줍니다. 그리고 이벤트 핸들러를 구현할 때, 명령형으로 리스너를 할당할 필요가 없습니다.
하지만 모든 것을 선언형으로 처리하고 싶지 않은 경우가 있습니다. 예를 들어, 사이드 이펙트와 같은 경우 렌더링 된 엘리먼트에 명령형으로 접근하고 싶을 때가 있습니다.
- DOM API를 통한 엘리먼트 읽기/쓰기 접근:
- 엘리먼트의 너비나 높이 읽기(측정)
- 입력 필드의 포커스 상태 쓰기(설정)
- 더 복잡한 애니메이션 구현
- 서드파티 라이브러리 통합 (D3는 인기 있는 명령형 차트 라이브러리)
React에서 명령형 프로그래밍의 장황함과 직관적이지 않은 특성 때문에, 입력 필드의 포커스를 명령형으로 설정하는 간단한 예제만 살펴보겠습니다. 반대로, 선언형 접근 방식으로는 입력 필드의 autoFocus 속성을 설정하여 동일한 결과를 얻을 수 있습니다.
이것은 작동하지만, 재사용 가능한 컴포넌트 중 하나만 렌더링 될 때만 작동합니다. 예를 들어 App 컴포넌트가 두 개의 InputWithLabel 컴포넌트를 렌더링 한다면, 마지막으로 렌더링 된 컴포넌트만 렌더링 시 autoFocus 플래그를 받습니다. 하지만 여기서는 재사용 가능한 React 컴포넌트가 있으므로, 입력 필드가 활성 autoFocus를 가질지 여부를 개발자가 결정할 수 있도록 전용 prop을 전달할 수 있습니다.
다시 말하지만, isFocused를 속성으로 사용하는 것은 isFocused={true}와 동일합니다. 컴포넌트 내에서 입력 필드의 autoFocus 속성에 새로운 prop을 사용하세요.
기능은 작동하지만, 여전히 선언형 구현입니다. 우리는 React에게 무엇을 할지 말하고 있고 어떻게 할지는 말하고 있지 않습니다. (권장되는 방식인) 선언형 접근 방식으로도 가능하지만, 이 시나리오를 명령형 접근 방식으로 리팩터링 해봅시다. 렌더링 된 후 DOM API를 통해 프로그래밍 방식으로 입력 필드 엘리먼트의 focus() 메서드를 실행하고 싶습니다.
// src/App.jsx
const InputWithLabel = ({
id,
value,
type = 'text',
onInputChange,
isFocused,
children,
}) => {
// A
const inputRef = React.useRef();
// C
React.useEffect(() => {
if (isFocused && inputRef.current) {
// D
inputRef.current.focus();
}
}, [isFocused]);
return (
<>
<label htmlFor={id}>{children}</label>
{/* B */}
<input
ref={inputRef}
id={id}
type={type}
value={value}
onChange={onInputChange}
/>
</>
);
};모든 필수 단계는 주석으로 표시되어 있으며 단계별로 설명됩니다.
- 먼저, React의
useRef훅으로ref를 생성합니다. 이 ref 객체는 React 컴포넌트의 수명 동안 유지되는 지속적인 값입니다. ref 객체와 달리 변경 가능한current라는 속성을 가지고 있습니다. - 둘째,
ref는 엘리먼트의 JSX 예약 속성인ref에 전달되며, 따라서 엘리먼트 인스턴스가 변경 가능한current속성에 할당됩니다. - 셋째, React의
useEffect훅으로 React의 생명주기에 관여하여, 컴포넌트가 렌더링 될 때(또는 의존성이 변경될 때) 엘리먼트에 포커스를 수행합니다. - 넷째,
ref가 엘리먼트의ref속성에 전달되었으므로,current속성을 통해 엘리먼트에 접근할 수 있습니다.isFocused가 설정되어 있고current속성이 존재하는 경우에만 사이드 이펙트로focus를 프로그래밍 방식으로 실행합니다.
본질적으로 이것이 React에서 선언형 프로그래밍에서 명령형 프로그래밍으로 이동하는 전체 예시입니다. 이 경우 직접 경험했듯이 선언형 또는 명령형 접근 방식을 모두 사용할 수 있습니다. 하지만 항상 선언형 접근 방식을 사용할 수 있는 것은 아니므로, 필요할 때는 명령형 접근 방식을 수행할 수 있습니다.
최종 코드(a)
import * as React from 'react';
const useStorageState = (key, initialState) => {
const [value, setValue] = React.useState(
localStorage.getItem(key) || initialState
);
React.useEffect(() => {
localStorage.setItem(key, value);
}, [value, key]);
return [value, setValue];
};
const App = () => {
const stories = [
{
title: 'React',
url: 'https://reactjs.org/',
author: 'Jordan Walke',
num_comments: 3,
points: 4,
objectID: 0,
},
{
title: 'Redux',
url: 'https://redux.js.org/',
author: 'Dan Abramov, Andrew Clark',
num_comments: 2,
points: 5,
objectID: 1,
},
];
const [searchTerm, setSearchTerm] = useStorageState(
'search',
'React'
);
const handleSearch = (event) => {
setSearchTerm(event.target.value);
};
const searchedStories = stories.filter((story) =>
story.title.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
<h1>My Hacker Stories</h1>
<InputWithLabel
id="search"
value={searchTerm}
isFocused
onInputChange={handleSearch}
>
<strong>Search:</strong>
</InputWithLabel>
<hr />
<List list={searchedStories} />
</div>
);
};
const InputWithLabel = ({
id,
value,
type = 'text',
onInputChange,
isFocused,
children,
}) => {
const inputRef = React.useRef();
React.useEffect(() => {
if (isFocused && inputRef.current) {
inputRef.current.focus();
}
}, [isFocused]);
return (
<>
<label htmlFor={id}>{children}</label>
<input
ref={inputRef}
id={id}
type={type}
value={value}
onChange={onInputChange}
/>
</>
);
};
const List = ({ list }) => (
<ul>
{list.map((item) => (
<Item key={item.objectID} item={item} />
))}
</ul>
);
const Item = ({ item }) => (
<li>
<span>
<a href={item.url}>{item.title}</a>
</span>
<span>{item.author}</span>
<span>{item.num_comments}</span>
<span>{item.points}</span>
</li>
);
export default App;