React 개념 및 원리 가이드

React는 컴포넌트 기반 구조와 선언형 프로그래밍, 가상 DOM, 단방향 데이터 흐름을 통해 복잡한 UI를 효율적으로 구성할 수 있게 해주는 자바스크립트 라이브러리로, 이를 이해하면 현대 웹 개발의 핵심 원리를 파악할 수 있다.

현대 웹 애플리케이션은 복잡한 사용자 인터페이스(UI)와 빠른 반응성을 요구합니다. 이러한 요구를 만족시키기 위해 등장한 것이 프론트엔드 프레임워크입니다. 그중에서도 React는 가장 널리 사용되는 라이브러리 중 하나로, 페이스북이 개발하고 관리하고 있으며, 다양한 웹 서비스에서 핵심 기술로 채택되고 있습니다.

React를 이해하는 것은 현대 웹 개발에서 필수적인 요소가 되었습니다. 이 절에서는 React가 무엇인지, 왜 중요한지, 그리고 핵심 원리에 대해 친근하면서도 체계적으로 살펴보겠습니다.

React란 무엇인가?

React는 사용자 인터페이스(User Interface, UI)를 만들기 위한 자바스크립트 라이브러리입니다. 공식 문서에서는 "React는 UI를 구축하기 위한 라이브러리입니다"라고 간결하게 정의하고 있습니다.

여기서 중요한 점은 React가 라이브러리라는 것입니다. 즉, 특정 목적(여기서는 UI 구축)을 위한 기능 모음으로서, 프레임워크가 아닌 라이브러리의 특성을 가집니다. 다시 말해, React는 애플리케이션 전체 구조를 강제하지 않고 UI 구축에만 집중한다는 의미입니다. 이는 개발자에게 더 많은 자유도를 제공하면서도, 다른 라이브러리들과의 조합을 통해 유연한 개발 환경을 만들어줍니다.

핵심 개념 및 원리

React의 강력함은 몇 가지 핵심 개념에서 나옵니다. 이러한 개념들이 어떻게 작동하고 왜 중요한지 차례대로 살펴보겠습니다.

1. 컴포넌트(Component) 기반 구조

React의 가장 중요한 특징 중 하나는 컴포넌트 기반 구조입니다. React의 UI는 작은 단위의 컴포넌트로 구성되며, 각 컴포넌트는 독립적인 기능을 가진 UI 조각이라고 할 수 있습니다. 예를 들어 버튼, 목록, 카드 같은 UI 요소를 하나하나 컴포넌트로 정의하여 필요한 곳에서 재사용할 수 있습니다.

이를 이해하기 쉽게 비유하면 레고 블록을 조립하는 방식과 같습니다. 각각의 블록(컴포넌트)은 고유한 모양과 기능을 가지고 있지만, 이들을 조합하면 복잡하고 다양한 구조물(애플리케이션)을 만들 수 있습니다.

컴포넌트 기반 구조의 주요 장점은 다음과 같습니다.

  • 유지보수성 향상: 문제가 발생했을 때 해당 컴포넌트만 수정하면 됨
  • 재사용성 극대화: 한 번 만든 컴포넌트를 여러 곳에서 활용 가능
  • 개발 효율성 증대: 팀 단위 개발 시 각자 다른 컴포넌트를 담당하여 병렬 작업 가능

2. 선언형 프로그래밍 (Declarative)

React는 선언형 프로그래밍 패러다임을 채택하고 있습니다. 이는 개발자가 "어떻게 할 것인가"보다는 "무엇을 보여줄 것인가"에 집중할 수 있도록 해줍니다.

과거의 명령형 방식에서는 버튼을 클릭했을 때 일련의 DOM 조작 코드를 직접 작성해야 했습니다. 반면 React의 선언형 방식에서는 상태(state)만 변경하면 UI가 자동으로 해당 상태에 맞게 변화합니다. 즉, React가 개발자 대신 복잡한 DOM 조작을 처리해주는 것입니다.

이러한 접근 방식은 코드의 가독성을 높이고, 버그 발생 가능성을 줄이며, 개발자가 비즈니스 로직에 더 집중할 수 있게 해줍니다.

3. 가상 DOM(Virtual DOM)

React의 성능 최적화 핵심 기술이 바로 가상 DOM입니다. React는 실제 브라우저의 DOM(Document Object Model)을 직접 조작하지 않고, 메모리 상의 가상 DOM을 사용하여 변경 사항을 추적하고 최소화합니다.

작동 원리를 간단히 설명하면 다음과 같습니다.

  1. 변경 감지: 상태 변화가 발생하면 새로운 가상 DOM 트리 생성
  2. 비교 과정: 이전 가상 DOM과 새로운 가상 DOM을 비교 (Diffing)
  3. 최적화된 업데이트: 실제로 변경된 부분만 실제 DOM에 반영 (Reconciliation)

이 과정을 통해 불필요한 DOM 조작을 피하고 성능을 크게 향상시킬 수 있습니다. 특히 복잡한 UI에서 일부분만 변경될 때 그 효과가 두드러집니다.

4. 단방향 데이터 흐름 (Unidirectional Data Flow)

React에서 데이터는 단방향으로만 흐릅니다. 즉, 상위 컴포넌트에서 하위 컴포넌트로만 데이터가 전달되며, 이를 통해 애플리케이션의 데이터 흐름을 예측 가능하게 만듭니다.

데이터 전달 방식의 특징

  • Props를 통한 전달: 부모 컴포넌트에서 자식 컴포넌트로 데이터 전달
  • 명확한 데이터 소유권: 각 데이터가 어디서 왔는지 추적이 용이
  • 디버깅 편의성: 문제 발생 시 데이터 흐름을 따라 원인 파악 가능

이러한 단방향 데이터 흐름은 애플리케이션의 복잡성이 증가해도 버그 발생 가능성을 현저히 줄여주며, 코드의 예측 가능성을 높여줍니다.

실제 예시로 이해하기

이론적 설명만으로는 React의 매력을 완전히 이해하기 어려울 수 있습니다. 다음의 간단한 예시를 통해 React 컴포넌트가 어떻게 작동하는지 살펴보겠습니다.

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

이 작은 코드 조각은 React의 핵심 개념들을 모두 보여줍니다. Welcome이라는 이름의 컴포넌트는 props를 통해 이름을 받아 사용자에게 인사하는 UI를 반환합니다. 간단해 보이지만, 이 컴포넌트는 언제든지 재사용이 가능하며, 다른 이름을 전달받아 다양한 상황에서 활용할 수 있습니다.


React를 배우는 데 있어 가장 핵심이 되는 요소는 바로 컴포넌트(Component), props, 그리고 state입니다. 이 세 가지는 React의 모든 UI가 구성되고 동작하는 기초이자 중심 개념입니다.

초보자에게는 이들 개념이 다소 추상적으로 느껴질 수 있으나, 실제로는 매우 명확한 역할과 관계를 가집니다. 마치 집을 짓는 데 필요한 벽돌(컴포넌트), 설계도(props), 그리고 전기나 수도 같은 내부 시설(state)과 같다고 할 수 있습니다. 이 장에서는 각 개념의 정의와 원리를 하나씩 차근히 살펴보며, React의 동작 방식을 보다 깊이 이해하는 데 초점을 맞추겠습니다.

컴포넌트(Component) – UI의 기본 단위

컴포넌트란 무엇인가?

컴포넌트는 React에서 UI를 구성하는 가장 작은 단위의 블록입니다. 웹 페이지의 특정 영역(예: 버튼, 헤더, 목록 등)을 하나의 컴포넌트로 정의하고, 이를 조합하여 전체 화면을 구성합니다.

컴포넌트를 이해하기 쉽게 비유하면, 완성된 레고 블록과 같습니다. 각각의 블록은 독립적인 기능을 가지고 있으며, 이들을 조합하여 더 큰 구조물을 만들 수 있습니다. 예를 들어, 버튼 컴포넌트 하나를 만들어 놓으면 웹사이트의 어디든 필요한 곳에 재사용할 수 있습니다.

컴포넌트의 종류

현재 React에서는 두 가지 방식으로 컴포넌트를 정의할 수 있습니다.

함수형 컴포넌트 (Functional Component)는 현재 표준이 된 방식입니다. JavaScript 함수처럼 정의하며, 간결하고 직관적인 구조를 가집니다.

function Greeting() {
  return <h1>Hello!</h1>;
}

클래스형 컴포넌트 (Class Component)는 예전 방식으로, ES6 클래스 문법을 사용합니다. 요즘은 함수형 컴포넌트와 Hooks의 등장으로 거의 사용되지 않지만, 기존 코드를 이해하기 위해 알아두면 좋습니다.

컴포넌트의 핵심 특징

컴포넌트가 React를 강력하게 만드는 이유는 다음과 같은 특징들 때문입니다.

  • 독립성과 재사용성: 한 번 만든 컴포넌트는 언제든지 다시 사용할 수 있습니다.
  • 동적 동작: propsstate를 사용하여 상황에 따라 다르게 표시됩니다.
  • JSX 문법: HTML과 유사한 구조로 작성하여 직관적입니다.

Props (속성) – 부모 컴포넌트로부터 받은 데이터

Props의 개념과 역할

Props는 부모 컴포넌트가 자식 컴포넌트에 전달하는 데이터입니다. "properties"의 줄임말이며, 컴포넌트 간의 소통을 담당하는 중요한 메커니즘입니다.

Props를 이해하기 쉽게 비유하면, 부모가 자식에게 전달하는 선물상자와 같습니다. 자식은 상자를 열어 내용을 확인할 수는 있지만, 그 안의 내용을 바꿀 수는 없습니다. 즉, props는 읽기 전용입니다.

다음은 props를 사용하는 간단한 예시입니다.

function Greeting(props) {
  return <h1>Hello, {props.name}!</h1>;
}

// 사용 예시
<Greeting name="James" />

이 예시에서 name이라는 prop을 통해 "James"라는 값을 전달하고, Greeting 컴포넌트는 이를 받아 화면에 표시합니다.

Props의 주요 특징

Props의 동작 방식을 이해하기 위해 다음 특징들을 살펴보겠습니다.

  • 단방향 데이터 흐름: 상위 컴포넌트에서 하위 컴포넌트로만 전달됩니다.
  • 불변성(Immutable): 한번 전달된 props는 자식 컴포넌트에서 수정할 수 없습니다.
  • 다양한 데이터 타입 지원: 문자열, 숫자, 배열, 객체, 함수 등 모든 JavaScript 데이터 타입을 전달할 수 있습니다.

이러한 특징들은 React 애플리케이션의 데이터 흐름을 예측 가능하게 만들고, 디버깅을 쉽게 해줍니다.

State – 컴포넌트 내부의 변화하는 데이터

State의 정의와 중요성

State는 컴포넌트 내부에서 정의하고 관리하는 상태값입니다. 사용자 입력, 네트워크 요청 결과, 시간의 흐름 등으로 인해 바뀌는 데이터를 다룹니다. Props와 달리 state는 변경 가능하며, 값이 변경되면 해당 컴포넌트가 자동으로 다시 렌더링됩니다.

State를 비유하면 컴포넌트의 현재 상태를 저장한 일기장과 같습니다. 상황에 따라 그날그날 내용을 바꾸고, 그에 따라 화면의 모습도 달라집니다.

State 사용 예시

다음은 버튼 클릭 횟수를 세는 간단한 카운터 컴포넌트입니다.

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </>
  );
}

이 예시에서 count는 현재 클릭 횟수를 저장하는 state이고, setCount는 이 값을 변경하는 함수입니다. 버튼을 클릭할 때마다 state가 변경되고, 화면이 자동으로 업데이트됩니다.

State의 핵심 특징

State가 React를 동적으로 만드는 이유는 다음과 같은 특징들 때문입니다.

  • 변경 가능성: Props와 달리 컴포넌트 내부에서 값을 변경할 수 있습니다.
  • 자동 렌더링: State 값이 변경되면 컴포넌트가 자동으로 다시 렌더링됩니다.
  • 지역 범위: 각 컴포넌트는 자신만의 독립적인 state를 가집니다.
  • Hooks 사용: 함수형 컴포넌트에서는 useState와 같은 훅을 통해 state를 관리합니다.

Props와 State의 비교 분석

Props와 State는 서로 다른 역할을 하지만 함께 작동하여 React 애플리케이션을 동적으로 만듭니다. 다음 표를 통해 두 개념의 차이점을 명확히 이해해보겠습니다.

구분 Props State
정의 부모 → 자식으로 전달되는 데이터 컴포넌트 내부에서 선언된 상태 데이터
수정 가능 여부 ❌ 읽기 전용 ✅ 변경 가능
사용 목적 외부로부터 전달된 설정값이나 데이터 내부 동작이나 상태 관리
소유 주체 부모 컴포넌트 해당 컴포넌트 자신
데이터 흐름 단방향 (위 → 아래) 컴포넌트 내부 순환

실제 적용 사례

실제 개발에서 props와 state가 어떻게 함께 사용되는지 살펴보겠습니다.

// 부모 컴포넌트
function App() {
  const [users, setUsers] = useState([]);
  
  return (
    <div>
      <UserList users={users} />  {/* props로 데이터 전달 */}
      <AddUserForm onAddUser={setUsers} />  {/* props로 함수 전달 */}
    </div>
  );
}

// 자식 컴포넌트
function UserList(props) {
  return (
    <ul>
      {props.users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

이 예시에서 App 컴포넌트는 users라는 state를 가지고 있고, 이를 UserList 컴포넌트에 props로 전달합니다. 이처럼 state와 props가 조화롭게 작동하여 동적인 사용자 인터페이스를 만들어냅니다.


React 컴포넌트 생명주기

React에서 사용자 인터페이스는 컴포넌트 단위로 구성되며, 각 컴포넌트는 화면에 나타나고 사라지는 일정한 생명주기(lifecycle)를 가집니다. 마치 사람이 태어나서 성장하고 죽음을 맞이하는 것처럼, React 컴포넌트도 생성, 변화, 소멸의 과정을 거칩니다.

컴포넌트가 처음 만들어질 때부터, 상태나 속성이 바뀔 때, 그리고 화면에서 사라질 때까지의 일련의 과정은 React의 동작을 이해하는 데 매우 중요합니다. 이러한 생명주기를 이해하면 언제 데이터를 불러올지, 언제 정리 작업을 할지, 언제 성능을 최적화할지를 정확히 알 수 있게 됩니다.

본 절에서는 React 컴포넌트의 라이프사이클이 어떤 구조로 되어 있는지, 그리고 각 단계에서 어떤 작업을 수행할 수 있는지를 체계적으로 알아보겠습니다.

React 컴포넌트 생명주기란?

컴포넌트 생명주기(Lifecycle)란, 컴포넌트가 생성(Mount)되고, 업데이트(Update)되고, 제거(Unmount)되는 전 과정을 뜻합니다. 이 과정에서 React는 특정 시점에 코드를 실행할 수 있는 기회를 제공하며, 이를 통해 초기 설정, 리소스 정리, 외부 데이터 요청 등을 수행할 수 있습니다.

생명주기를 이해하는 것은 마치 연극의 무대 뒤편을 보는 것과 같습니다. 관객은 배우가 무대에 등장하고 연기하고 퇴장하는 것만 보지만, 실제로는 그 뒤에 조명 설정, 소품 준비, 무대 정리 등의 복잡한 과정이 있습니다. React 컴포넌트도 마찬가지로 사용자에게는 단순히 화면의 변화로 보이지만, 그 뒤에는 체계적인 생명주기 과정이 있습니다.

중요한 변화: 생명주기 개념은 원래 클래스형 컴포넌트에서 도입되었지만, 최근에는 함수형 컴포넌트에서 Hooks(특히 useEffect)를 사용해 동일한 기능을 더 간결하고 직관적으로 구현합니다. 따라서 이 가이드에서는 현재 표준인 함수형 컴포넌트와 Hooks를 중심으로 설명하겠습니다.

컴포넌트 생명주기의 3단계 구조

React의 라이프사이클은 명확한 세 단계로 나뉩니다. 각 단계는 서로 다른 목적과 특징을 가지고 있습니다.

1. 마운트(Mount) – 컴포넌트가 DOM에 삽입되는 순간

2. 업데이트(Update) – props나 state가 변경되어 컴포넌트가 다시 렌더링되는 순간

3. 언마운트(Unmount) – 컴포넌트가 DOM에서 제거되는 순간

이 세 단계는 순환적으로 작동합니다. 컴포넌트는 한 번 마운트된 후 여러 번의 업데이트를 거치다가 최종적으로 언마운트됩니다.

[Mount] → [Update] ⟷ [Update] ⟷ [Update] → [Unmount]
    │         ↑                                    │
    └─────────┘ ← 상태/속성 변경 시 반복 ─────────────┘

각 단계에서의 동작과 활용

Mount (처음 화면에 나타날 때)

마운트 단계는 컴포넌트가 최초로 화면에 나타나는 순간입니다. 이는 웹 페이지에서 새로운 섹션이 처음 로드되거나, 사용자가 새로운 화면으로 이동했을 때 발생합니다.

이 단계에서 주로 수행하는 작업들은 다음과 같습니다.

  • 초기 데이터 요청: API에서 데이터를 불러오기
  • 이벤트 리스너 등록: 스크롤, 키보드 입력 등의 이벤트 감지 설정
  • 애니메이션 시작: 컴포넌트가 나타나는 효과 실행
  • 타이머나 인터벌 설정: 주기적으로 실행되어야 하는 작업 시작

함수형 컴포넌트에서는 useEffect Hook을 사용하여 마운트 시점을 처리합니다.

useEffect(() => {
  console.log("컴포넌트가 마운트되었습니다.");
  
  // 예시: 사용자 데이터 불러오기
  fetchUserData();
  
  // 예시: 페이지 제목 설정
  document.title = "새로운 페이지";
}, []); // 빈 배열이 핵심!

핵심 포인트: 빈 배열([])을 두 번째 인자로 넘기면 이 코드는 컴포넌트가 처음 화면에 나타날 때 단 한 번만 실행됩니다. 이것이 마운트 단계의 특징입니다.

Update (props, state가 변경될 때)

업데이트 단계는 컴포넌트가 이미 화면에 있는 상태에서 변화가 일어날 때 발생합니다. 사용자가 버튼을 클릭하거나, 입력 필드에 텍스트를 입력하거나, 부모 컴포넌트에서 새로운 데이터를 전달받을 때마다 이 단계가 실행됩니다.

업데이트가 발생하는 주요 상황들

  • State 변경: setStateuseState의 setter 함수 호출
  • Props 변경: 부모 컴포넌트에서 전달하는 데이터가 바뀜
  • 부모 컴포넌트 리렌더링: 부모가 리렌더링되면 자식도 함께 리렌더링

함수형 컴포넌트에서 특정 값의 변화를 감지하는 방법

const [count, setCount] = useState(0);
const [name, setName] = useState('');

useEffect(() => {
  console.log("count가 변경되어 업데이트되었습니다:", count);
  
  // 예시: count가 10의 배수일 때 특별한 작업 수행
  if (count % 10 === 0 && count > 0) {
    showCelebration();
  }
}, [count]); // count가 변경될 때마다 실행

useEffect(() => {
  console.log("name 또는 count가 변경되었습니다.");
}, [name, count]); // 여러 값을 동시에 감지 가능

실용적 활용: 특정 상태 변화에 따라 다른 작업을 수행하고 싶을 때, 의존성 배열에 해당 상태를 포함시키면 됩니다. 이를 통해 불필요한 재실행을 방지하고 성능을 최적화할 수 있습니다.

Unmount (화면에서 사라질 때)

언마운트 단계는 컴포넌트가 화면에서 완전히 제거될 때 발생합니다. 사용자가 다른 페이지로 이동하거나, 조건부 렌더링에 의해 컴포넌트가 숨겨질 때 실행됩니다.

이 단계에서 반드시 수행해야 하는 정리 작업(Cleanup)

  • 타이머 해제: setInterval, setTimeout 정리
  • 이벤트 리스너 제거: 메모리 누수 방지
  • 외부 구독 해제: WebSocket 연결, API 구독 등
  • 진행 중인 네트워크 요청 취소: 불필요한 API 호출 방지

함수형 컴포넌트에서 정리 작업을 수행하는 방법

useEffect(() => {
  // 마운트 시 실행되는 코드
  console.log("컴포넌트가 생성됨");
  
  const handleScroll = () => {
    console.log("스크롤 이벤트 발생");
  };
  
  // 이벤트 리스너 등록
  window.addEventListener('scroll', handleScroll);
  
  // 정리 함수 (cleanup function)
  return () => {
    console.log("컴포넌트가 제거됨 - 정리 작업 실행");
    
    // 이벤트 리스너 제거
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

중요한 개념: useEffect에서 함수를 반환하면, 그 함수는 컴포넌트가 언마운트될 때 자동으로 실행됩니다. 이를 cleanup function이라고 하며, 메모리 누수를 방지하는 핵심 메커니즘입니다.

생명주기 활용의 실제 예시

생명주기의 모든 단계를 활용하는 실제적인 예시를 살펴보겠습니다.

function Timer({ isActive }) {
  const [seconds, setSeconds] = useState(0);
  const [status, setStatus] = useState('stopped');
  
  // 마운트 시: 초기 설정
  useEffect(() => {
    console.log("타이머 컴포넌트가 생성되었습니다.");
    setStatus('ready');
    
    // 언마운트 시: 정리 작업
    return () => {
      console.log("타이머 컴포넌트가 제거됩니다.");
      setStatus('destroyed');
    };
  }, []);
  
  // 업데이트 시: isActive 상태 변화 감지
  useEffect(() => {
    let intervalId = null;
    
    if (isActive) {
      setStatus('running');
      intervalId = setInterval(() => {
        setSeconds(prev => prev + 1);
      }, 1000);
    } else {
      setStatus('paused');
    }
    
    // 이 effect의 정리: 인터벌 해제
    return () => {
      if (intervalId) {
        clearInterval(intervalId);
      }
    };
  }, [isActive]); // isActive가 변경될 때마다 실행
  
  // 업데이트 시: seconds 변화에 따른 특별한 작업
  useEffect(() => {
    if (seconds > 0 && seconds % 10 === 0) {
      console.log(`10초 단위 도달: ${seconds}초`);
    }
  }, [seconds]);
  
  return (
    <div>
      <h2>타이머: {seconds}초</h2>
      <p>상태: {status}</p>
    </div>
  );
}

이 예시에서 볼 수 있는 생명주기 활용

  • 마운트: 컴포넌트 생성 시 초기 상태 설정
  • 업데이트: isActive prop 변화에 따른 타이머 시작/정지
  • 업데이트: seconds 상태 변화에 따른 추가 로직 실행
  • 언마운트: 컴포넌트 제거 시 타이머 정리

생명주기 패턴과 모범 사례

1. 데이터 페칭 패턴

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    let cancelled = false;
    
    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);
        
        const response = await api.getUser(userId);
        
        // 컴포넌트가 언마운트되었다면 상태 업데이트 하지 않음
        if (!cancelled) {
          setUser(response.data);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    };
    
    fetchUser();
    
    // 정리: API 요청 취소 플래그 설정
    return () => {
      cancelled = true;
    };
  }, [userId]); // userId가 변경될 때마다 새로운 사용자 데이터 요청
  
  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>오류: {error}</div>;
  if (!user) return <div>사용자를 찾을 수 없습니다.</div>;
  
  return <div>안녕하세요, {user.name}님!</div>;
}

2. 이벤트 리스너 관리 패턴

function WindowSizeTracker() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    const handleResize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    
    // 마운트 시: 이벤트 리스너 등록
    window.addEventListener('resize', handleResize);
    
    // 언마운트 시: 이벤트 리스너 제거
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // 빈 배열: 한 번만 실행
  
  return (
    <div>
      창 크기: {windowSize.width} x {windowSize.height}
    </div>
  );
}

주의사항 및 모범 사례

Do's (권장사항)

  • 의존성 배열을 정확히 명시하세요: useEffect의 두 번째 인자에 사용하는 모든 값을 포함
  • 정리 함수를 항상 작성하세요: 타이머, 이벤트 리스너, 구독 등은 반드시 정리
  • 조건부 상태 업데이트를 고려하세요: 비동기 작업 중 컴포넌트가 언마운트될 수 있음

Don'ts (피해야 할 사항)

  • 무한 루프를 만들지 마세요: 의존성 배열 없이 상태를 변경하는 useEffect 사용 금지
  • 과도한 리렌더링을 유발하지 마세요: 불필요한 의존성을 배열에 포함하지 말 것
  • 정리 작업을 빠뜨리지 마세요: 메모리 누수의 주요 원인

React 상태 관리 전략 - Context, useReducer, Redux

React에서는 propsstate를 통해 컴포넌트 내부와 컴포넌트 간 데이터를 주고받을 수 있습니다. 하지만 애플리케이션이 커질수록 데이터 전달이 복잡해지고 관리가 어려워지는 문제가 발생합니다. 마치 작은 마을에서는 모든 사람이 서로를 알고 직접 소통할 수 있지만, 대도시가 되면 체계적인 교통망과 통신 시스템이 필요한 것과 같습니다.

특히 여러 컴포넌트가 동일한 데이터를 공유하거나, 깊게 중첩된 구조에서 상위 데이터를 하위로 계속 전달해야 할 때, 보다 정교한 상태 관리 전략이 필요해집니다. 이를 해결하기 위해 React는 Context, useReducer, 그리고 외부 상태 관리 도구인 Redux 등의 방식을 제공합니다.

이 절에서는 React에서의 데이터 흐름의 확장과 그에 따른 주요 상태 관리 전략들을 단계적으로 살펴보며, 각각이 언제, 왜 필요한지를 명확히 이해해보겠습니다.

React의 기본 데이터 흐름 복습

React 상태 관리 전략을 이해하기 위해서는 먼저 React의 기본적인 데이터 흐름을 명확히 해야 합니다.

React의 핵심 데이터 흐름 원칙

  • 데이터는 항상 단방향으로 흐릅니다. (부모 → 자식)
  • 상위 컴포넌트의 데이터를 자식에게 props로 전달합니다.
  • 하위에서 상위로 데이터를 보내려면 콜백 함수를 props로 전달하는 방식을 사용합니다.

이러한 방식은 소규모 애플리케이션에서는 충분하지만, 규모가 커지면 몇 가지 문제점이 나타납니다.

Props Drilling 문제

가장 대표적인 문제는 Props Drilling입니다. 이는 데이터가 필요한 컴포넌트까지 도달하기 위해 중간에 있는 여러 컴포넌트들을 거쳐야 하는 현상입니다.

// 문제 상황 예시
function App() {
  const [user, setUser] = useState({ name: 'Alice', theme: 'dark' });
  
  return <Header user={user} />;
}

function Header({ user }) {
  // Header에서는 user를 사용하지 않지만 Navbar로 전달해야 함
  return <Navbar user={user} />;
}

function Navbar({ user }) {
  // Navbar에서도 user를 사용하지 않지만 UserProfile로 전달해야 함
  return <UserProfile user={user} />;
}

function UserProfile({ user }) {
  // 실제로 user 데이터를 사용하는 곳
  return <div>안녕하세요, {user.name}님!</div>;
}

이런 상황에서는 중간 컴포넌트들이 불필요하게 많은 props를 받아야 하고, 코드가 복잡해지며 유지보수가 어려워집니다.

Context API – 전역처럼 데이터를 공유하는 방법

Context API의 개념과 목적

Context는 React에서 컴포넌트 트리 전체에 걸쳐 값을 전역처럼 공유할 수 있는 기능입니다. Props drilling 없이도 데이터를 필요한 하위 컴포넌트에 직접 전달할 수 있어, 앞서 살펴본 문제를 우아하게 해결합니다.

Context를 사용하면 마치 방송국에서 라디오 전파를 송출하는 것처럼, 특정 영역 내의 모든 컴포넌트가 동일한 데이터에 접근할 수 있습니다. 라디오를 가진 사람이라면 누구든 방송을 들을 수 있는 것처럼, Context를 구독한 컴포넌트라면 어디서든 해당 데이터를 사용할 수 있습니다.

Context API의 주요 용도

Context는 다음과 같은 상황에서 특히 유용합니다.

  • 전역 설정 값: 로그인 사용자 정보, 다크 모드 여부, 언어 설정 등
  • 테마 정보: 색상 테마, 폰트 크기 등의 UI 설정
  • 인증 상태: 로그인 상태, 권한 정보 등
  • 여러 컴포넌트에서 공통으로 사용하는 값: 쇼핑카트 데이터, 알림 설정 등

Context API 사용 방법

Context의 기본 사용 패턴을 살펴보겠습니다.

// 1. Context 생성
const ThemeContext = React.createContext('light'); // 기본값 설정

// 2. Provider로 값 제공
function App() {
  const [theme, setTheme] = useState('dark');
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Toolbar />
      <MainContent />
    </ThemeContext.Provider>
  );
}

// 3. 중간 컴포넌트는 props 전달 불필요
function Toolbar() {
  return (
    <div>
      <ThemedButton />
      <ThemeToggle />
    </div>
  );
}

// 4. 필요한 곳에서 직접 사용
function ThemedButton() {
  const { theme } = React.useContext(ThemeContext);
  
  return (
    <button className={`btn-${theme}`}>
      테마가 적용된 버튼
    </button>
  );
}

function ThemeToggle() {
  const { theme, setTheme } = React.useContext(ThemeContext);
  
  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };
  
  return (
    <button onClick={toggleTheme}>
      {theme === 'light' ? '🌙' : '☀️'} 테마 변경
    </button>
  );
}

Context의 장점과 한계

장점

  • Props drilling 문제를 근본적으로 해결합니다.
  • 필요한 곳에서 바로 데이터에 접근할 수 있습니다.
  • 특정 트리 안에서만 영향을 미치도록 범위를 지정할 수 있습니다.
  • React에 내장되어 있어 별도의 라이브러리가 필요하지 않습니다.

한계

  • 상태 업데이트 로직이 복잡해지면 Context만으로는 관리가 어려워집니다.
  • Context 값이 변경되면 해당 Context를 구독하는 모든 컴포넌트가 재렌더링됩니다.
  • 남용하면 성능 저하가 발생할 수 있습니다.

useReducer – 복잡한 상태 전환을 함수로 분리

useReducer의 개념과 필요성

useReduceruseState의 대안으로, 상태가 복잡하거나 상태 변경이 명확한 규칙에 따라 처리되어야 할 때 사용하는 React Hook입니다.

일반적인 useState는 간단한 상태 관리에는 충분하지만, 다음과 같은 상황에서는 한계가 있습니다.

  • 여러 상태값이 서로 연관되어 있을 때
  • 상태 변경 로직이 복잡할 때
  • 상태 변경이 일정한 패턴을 따를 때

useReducer를 사용하면 상태 전환 로직을 컴포넌트 밖으로 분리하여 더 예측 가능하고 테스트하기 쉬운 코드를 작성할 수 있습니다.

useReducer의 구성 요소

useReducer는 세 가지 핵심 요소로 구성됩니다.

  • state: 현재 상태값
  • dispatch: 액션을 보내는 함수
  • reducer: 상태와 액션을 받아 새로운 상태를 반환하는 순수 함수

실제 사용 예시

간단한 카운터에서부터 복잡한 상태 관리까지 단계적으로 살펴보겠습니다.

// 기본적인 카운터 예시
function counterReducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    case 'set':
      return { count: action.payload };
    default:
      throw new Error(`알 수 없는 액션 타입: ${action.type}`);
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });
  
  return (
    <div>
      <p>현재 카운트: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
      <button onClick={() => dispatch({ type: 'reset' })}>리셋</button>
      <button onClick={() => dispatch({ type: 'set', payload: 10 })}>10으로 설정</button>
    </div>
  );
}

복잡한 상태 관리 예시

실제 애플리케이션에서는 더 복잡한 상태를 다뤄야 합니다.

// 할 일 관리 애플리케이션 예시
const todoReducer = (state, action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: Date.now(),
            text: action.payload,
            completed: false
          }
        ]
      };
    
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
    
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload)
      };
    
    case 'SET_FILTER':
      return {
        ...state,
        filter: action.payload
      };
    
    default:
      return state;
  }
};

function TodoApp() {
  const [state, dispatch] = useReducer(todoReducer, {
    todos: [],
    filter: 'all' // 'all', 'active', 'completed'
  });
  
  const addTodo = (text) => {
    dispatch({ type: 'ADD_TODO', payload: text });
  };
  
  const toggleTodo = (id) => {
    dispatch({ type: 'TOGGLE_TODO', payload: id });
  };
  
  const deleteTodo = (id) => {
    dispatch({ type: 'DELETE_TODO', payload: id });
  };
  
  // 필터링된 할 일 목록
  const filteredTodos = state.todos.filter(todo => {
    if (state.filter === 'active') return !todo.completed;
    if (state.filter === 'completed') return todo.completed;
    return true;
  });
  
  return (
    <div>
      <TodoForm onAddTodo={addTodo} />
      <TodoFilter 
        currentFilter={state.filter}
        onFilterChange={(filter) => dispatch({ type: 'SET_FILTER', payload: filter })}
      />
      <TodoList 
        todos={filteredTodos}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />
    </div>
  );
}

useReducer의 장점

  • 상태 로직 분리: 복잡한 상태 변경 로직을 컴포넌트에서 분리하여 가독성 향상
  • 예측 가능성: 모든 상태 변경이 reducer 함수를 통해 이루어져 디버깅이 쉬움
  • 테스트 용이성: Reducer는 순수 함수이므로 단위 테스트가 간단함
  • 재사용성: 동일한 reducer를 여러 컴포넌트에서 재사용 가능

useReducer 사용 시기

다음과 같은 상황에서 useReducer를 고려해보세요.

  • 여러 상태가 서로 관련되어 있을 때
  • 상태 전환 로직이 복잡하고 명확한 규칙을 따를 때
  • 상태 변경이 여러 단계를 거쳐야 할 때
  • 컴포넌트의 로직을 더 선언적으로 만들고 싶을 때

Redux – 복잡한 앱 전역 상태 관리 도구

Redux의 개념과 철학

Redux는 JavaScript 애플리케이션에서 예측 가능한 전역 상태 관리를 제공하는 라이브러리입니다. React 외에도 다양한 프론트엔드 프레임워크와 함께 사용할 수 있지만, React와 가장 많이 함께 사용됩니다.

Redux는 Flux 아키텍처에서 영감을 받아 만들어졌으며, 다음 세 가지 핵심 원칙을 따릅니다.

  1. Single Source of Truth: 애플리케이션의 모든 상태는 하나의 스토어에 저장됩니다.
  2. State is Read-Only: 상태를 변경하는 유일한 방법은 액션을 dispatch하는 것입니다.
  3. Changes are Made with Pure Functions: 상태 변경은 순수 함수인 reducer를 통해서만 이루어집니다.

Redux의 핵심 개념

Redux는 다음과 같은 주요 요소들로 구성됩니다.

요소 역할 설명
Store 상태 저장소 전체 애플리케이션의 상태가 저장되는 전역 저장소
Action 상태 변경 지시 상태 변경을 일으키는 지시 객체 (type과 payload 포함)
Reducer 상태 전환 함수 상태와 액션을 받아 새로운 상태를 반환하는 순수 함수
Dispatch 액션 전송 액션을 스토어에 전송하는 함수

Redux 기본 사용 구조

Redux의 기본적인 사용 패턴을 살펴보겠습니다.

// 1. 액션 타입 정의
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';

// 2. 액션 생성자 함수
const increment = () => ({
  type: INCREMENT
});

const decrement = () => ({
  type: DECREMENT
});

const addTodo = (text) => ({
  type: ADD_TODO,
  payload: {
    id: Date.now(),
    text,
    completed: false
  }
});

// 3. 초기 상태 정의
const initialState = {
  counter: 0,
  todos: []
};

// 4. Reducer 함수 정의
function appReducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return {
        ...state,
        counter: state.counter + 1
      };
    
    case DECREMENT:
      return {
        ...state,
        counter: state.counter - 1
      };
    
    case ADD_TODO:
      return {
        ...state,
        todos: [...state.todos, action.payload]
      };
    
    case TOGGLE_TODO:
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
    
    default:
      return state;
  }
}

// 5. 스토어 생성
const store = createStore(appReducer);

// 6. 상태 변경
store.dispatch(increment());
store.dispatch(addTodo('Redux 배우기'));

// 7. 상태 조회
console.log(store.getState());

React와 Redux 연결

실제 React 애플리케이션에서 Redux를 사용할 때는 react-redux 라이브러리를 함께 사용합니다.

import { Provider, useSelector, useDispatch } from 'react-redux';

// App 컴포넌트를 Provider로 감싸기
function App() {
  return (
    <Provider store={store}>
      <Counter />
      <TodoList />
    </Provider>
  );
}

// 컴포넌트에서 Redux 상태 사용
function Counter() {
  const counter = useSelector(state => state.counter);
  const dispatch = useDispatch();
  
  return (
    <div>
      <p>카운트: {counter}</p>
      <button onClick={() => dispatch(increment())}>+</button>
      <button onClick={() => dispatch(decrement())}>-</button>
    </div>
  );
}

function TodoList() {
  const todos = useSelector(state => state.todos);
  const dispatch = useDispatch();
  
  const handleAddTodo = (text) => {
    dispatch(addTodo(text));
  };
  
  return (
    <div>
      {todos.map(todo => (
        <div key={todo.id}>
          <span style={{ 
            textDecoration: todo.completed ? 'line-through' : 'none' 
          }}>
            {todo.text}
          </span>
          <button onClick={() => dispatch(toggleTodo(todo.id))}>
            완료 토글
          </button>
        </div>
      ))}
      <TodoForm onAddTodo={handleAddTodo} />
    </div>
  );
}

Redux의 장점

  • 중앙 집중식 상태 관리: 애플리케이션 전반의 상태를 한 곳에서 일관되게 관리
  • 예측 가능성: 모든 상태 변경이 명확한 패턴을 따르므로 디버깅이 쉬움
  • 시간 여행 디버깅: Redux DevTools를 통해 상태 변화를 추적하고 되돌릴 수 있음
  • 미들웨어 지원: 로깅, 비동기 처리 등을 위한 확장 가능
  • Hot Reloading: 개발 중 상태를 유지하면서 코드 변경 가능

Redux의 단점

  • 학습 곡선: 개념이 많고 보일러플레이트 코드가 많아 초기 학습이 어려움
  • 복잡성: 간단한 상태 관리에도 많은 코드가 필요할 수 있음
  • 과도함: 소규모 프로젝트에서는 오버엔지니어링이 될 수 있음

Context vs useReducer vs Redux 종합 비교

각 상태 관리 방식의 특징을 종합적으로 비교해보겠습니다.

측면 Context useReducer Redux
적용 범위 컴포넌트 트리 내부 컴포넌트 내부 (Context와 결합 가능) 애플리케이션 전체
상태 복잡도 단순한 전역 값 중간 정도의 복잡한 상태 매우 복잡한 전역 상태
학습 난이도 낮음 중간 높음
보일러플레이트 적음 적음 많음
디버깅 도구 기본 React DevTools 기본 React DevTools Redux DevTools (강력함)
성능 최적화 제한적 제한적 다양한 최적화 기법 지원
외부 의존성 없음 없음 redux, react-redux 필요
타입 안정성 TypeScript와 잘 호환 TypeScript와 잘 호환 추가 설정 필요

언제 어떤 방식을 선택할까?

Context를 선택하는 경우

  • 전역 설정값 (테마, 언어, 인증 상태 등)을 공유할 때
  • Props drilling을 피하고 싶을 때
  • 상태 변경 로직이 단순할 때
  • 외부 라이브러리 의존성을 피하고 싶을 때

useReducer를 선택하는 경우

  • 컴포넌트 내부 상태가 복잡할 때
  • 여러 상태가 서로 연관되어 있을 때
  • 상태 전환 로직을 컴포넌트에서 분리하고 싶을 때
  • Context와 결합하여 중간 규모의 상태 관리가 필요할 때

Redux를 선택하는 경우

  • 대규모 애플리케이션에서 복잡한 전역 상태 관리가 필요할 때
  • 여러 컴포넌트에서 동일한 상태를 자주 변경할 때
  • 상태 변화의 추적과 디버깅이 중요할 때
  • 미들웨어를 통한 비동기 처리나 로깅이 필요할 때

실제 조합 예시

실제 프로젝트에서는 이들을 조합하여 사용하는 경우가 많습니다.

// Context + useReducer 조합 예시
const AppStateContext = createContext();

function appReducer(state, action) {
  switch (action.type) {
    case 'SET_USER':
      return { ...state, user: action.payload };
    case 'SET_THEME':
      return { ...state, theme: action.payload };
    case 'ADD_NOTIFICATION':
      return { 
        ...state, 
        notifications: [...state.notifications, action.payload] 
      };
    default:
      return state;
  }
}

function AppStateProvider({ children }) {
  const [state, dispatch] = useReducer(appReducer, {
    user: null,
    theme: 'light',
    notifications: []
  });
  
  return (
    <AppStateContext.Provider value={{ state, dispatch }}>
      {children}
    </AppStateContext.Provider>
  );
}

// 사용하는 컴포넌트
function UserProfile() {
  const { state, dispatch } = useContext(AppStateContext);
  
  const handleThemeChange = (newTheme) => {
    dispatch({ type: 'SET_THEME', payload: newTheme });
  };
  
  return (
    <div className={`profile-${state.theme}`}>
      <h2>{state.user?.name}</h2>
      <button onClick={() => handleThemeChange('dark')}>
        다크 모드
      </button>
    </div>
  );
}

React 핵심 개념 정리

지금까지 우리는 React의 가장 핵심적인 개념들을 단계적으로 살펴보았습니다. React는 단순한 UI 라이브러리가 아니라, 현대적인 웹 애플리케이션 개발에 적합한 설계 철학과 구조적 접근 방식을 담고 있는 도구입니다.

마치 언어를 배울 때 알파벳부터 시작하여 단어, 문법, 그리고 문장을 만드는 법을 익히는 것처럼, React 학습도 기본 개념부터 차근차근 쌓아 올린 지식이 하나의 완전한 그림을 만들어냅니다. 이제 그 전체적인 그림을 함께 정리해보며, React 개발자로서의 다음 여정을 준비해보겠습니다.

React의 주요 개념 요약

1. React란 무엇인가?

React는 사용자 인터페이스(UI)를 구성하는 JavaScript 라이브러리입니다. 하지만 단순히 UI를 만드는 도구를 넘어서, React는 다음과 같은 현대적 설계 원칙을 채택하고 있습니다.

핵심 설계 원칙

  • 컴포넌트 기반 구조: 작은 단위의 재사용 가능한 블록으로 UI 구성
  • 선언형 프로그래밍: "무엇을 보여줄지"에 집중하여 직관적인 코드 작성
  • 가상 DOM: 성능 최적화를 통한 빠르고 효율적인 렌더링
  • 단방향 데이터 흐름: 예측 가능하고 디버깅하기 쉬운 데이터 관리

이러한 원칙들은 React를 사용하는 모든 순간에 일관성 있게 적용되며, 복잡한 애플리케이션에서도 코드의 가독성과 유지보수성을 보장해줍니다.

2. 컴포넌트, Props, State - React의 삼대 요소

React의 모든 것은 이 세 가지 개념 위에 구축됩니다.

컴포넌트 (Component)

  • 정의: UI의 최소 단위이자 재사용 가능한 블록
  • 특징: 독립적이며 조합 가능한 구조
  • 비유: 레고 블록처럼 조립하여 복잡한 구조 생성
  • 실무 적용: 버튼, 카드, 폼 등 반복되는 UI 요소를 컴포넌트로 제작

Props (속성)

  • 정의: 부모에서 자식으로 전달되는 읽기 전용 데이터
  • 특징: 단방향 데이터 흐름의 핵심 메커니즘
  • 비유: 부모가 자식에게 주는 선물상자 (열어볼 수는 있지만 내용 변경 불가)
  • 실무 적용: 컴포넌트 간 데이터 전달과 설정값 공유

State (상태)

  • 정의: 컴포넌트 내부에서 정의되고 변경 가능한 상태값
  • 특징: 변경 시 자동 리렌더링을 통한 동적 UI 구현
  • 비유: 컴포넌트의 현재 상태를 기록하는 일기장
  • 실무 적용: 사용자 입력, 서버 데이터, UI 상태 등 동적 요소 관리

이 세 요소는 서로 독립적이면서도 조화롭게 작동하여 React의 강력한 컴포넌트 생태계를 만들어냅니다.

3. 컴포넌트의 생명주기 - 시간의 흐름 속에서

React 컴포넌트는 살아있는 개체처럼 생성(Mount), 변경(Update), 제거(Unmount)의 생명주기를 가집니다.

생명주기의 의미

  • Mount: 컴포넌트가 처음 화면에 나타나는 순간 (초기 설정, 데이터 로딩)
  • Update: Props나 State 변화로 컴포넌트가 다시 렌더링되는 순간 (동적 반응)
  • Unmount: 컴포넌트가 화면에서 사라지는 순간 (정리 작업, 리소스 해제)

현대적 접근 - useEffect Hook

useEffect(() => {
  // Mount & Update 시 실행될 코드
  console.log("컴포넌트가 렌더링되었습니다");
  
  return () => {
    // Unmount 시 실행될 정리 코드
    console.log("컴포넌트가 정리됩니다");
  };
}, []); // 의존성 배열로 실행 조건 제어

생명주기를 이해하면 언제 데이터를 불러올지, 언제 정리 작업을 할지, 언제 성능을 최적화할지를 정확히 알 수 있게 됩니다.

4. 정교한 상태 관리 전략 - 복잡성을 다루는 지혜

애플리케이션이 커질수록 상태 관리의 복잡성도 증가합니다. React는 이를 위한 단계적 해결책을 제공합니다.

Context API

  • 목적: Props drilling 없이 전역처럼 데이터를 컴포넌트 트리에 전달
  • 적용: 테마, 언어, 인증 상태 등 앱 전반의 설정값
  • 장점: React 내장 기능, 간단한 구현

useReducer

  • 목적: 복잡한 상태 전환을 함수 기반으로 체계적 설계
  • 적용: 여러 상태가 연관된 복잡한 컴포넌트 로직
  • 장점: 예측 가능한 상태 변경, 로직과 UI 분리

Redux

  • 목적: 애플리케이션 전역의 상태를 예측 가능하고 일관되게 관리
  • 적용: 대규모 애플리케이션의 복잡한 상태 관리
  • 장점: 강력한 디버깅 도구, 미들웨어 생태계

이 세 가지 접근법은 서로 대체재가 아니라 상황에 따른 선택지입니다. 프로젝트의 규모와 복잡성에 맞는 적절한 조합을 선택하는 것이 중요합니다.