[아이티센 부트캠프] React 2

이언덕·2026년 5월 20일

아이티센 부트캠프

목록 보기
103/115
post-thumbnail

React의 Virtual DOM과 key

React에서 화면이 바뀌는 흐름을 이해하려면 먼저 DOM, Virtual DOM, Diff, Patch, key를 순서대로 알아야 한다.
이 개념들은 따로 떨어진 내용이 아니다.
React가 화면을 효율적으로 바꾸기 위해 어떤 구조를 만들고, 어떤 기준으로 비교하고, 어떤 부분만 실제 화면에 반영하는지 설명하는 하나의 흐름이다.


핵심은 React가 실제 화면인 DOM을 바로 고치는 것이 아니라, 메모리 안의 Virtual DOM을 먼저 비교한 뒤 필요한 부분만 실제 DOM에 반영한다는 점이다.
이 흐름을 이해하면 반복 요소에서 왜 key가 필요한지도 자연스럽게 이어진다.


1-1. DOM이란 무엇인가

DOM의 뜻

DOM은 Document Object Model의 줄임말이다.
초보자 기준으로 쉽게 말하면, 브라우저가 HTML을 읽고 만든 실제 화면 구조이다.


우리가 작성한 HTML은 처음에는 코드처럼 보인다.
하지만 브라우저는 HTML을 단순한 글자로만 보관하지 않는다.
브라우저는 HTML 태그를 읽고, 화면에서 관리할 수 있는 구조로 바꾼다.
이렇게 만들어진 구조가 DOM이다.


예를 들어 아래와 같은 HTML이 있다고 하자.

<!-- DomBasicStructure.html -->
<div>
  <h1>Hello</h1>
  <p>React</p>
</div>

브라우저는 이 코드를 읽고 div, h1, p를 서로 연결된 구조로 관리한다.
div 안에 h1과 p가 들어 있으므로, div는 부모가 되고 h1과 p는 자식이 된다.

// 출력결과
// div
// ├─ h1
// └─ p

DOM은 이처럼 화면에 있는 태그들을 부모와 자식 관계로 정리한 구조이다.
그래서 DOM을 이해할 때는 단순히 “화면에 보이는 태그”가 아니라, 브라우저가 화면을 다루기 위해 만든 구조라고 생각하면 된다.

DOM이 화면에 영향을 주는 방식

DOM은 실제 화면과 연결되어 있다.
그래서 DOM이 바뀌면 화면도 바뀔 수 있다.


예를 들어 화면에 있는 문구가 기존 텍스트에서 새 텍스트로 바뀐다고 하자.
겉으로 보기에는 글자 하나가 바뀐 것처럼 보인다.
하지만 브라우저 내부에서는 여러 작업이 이어질 수 있다.

  • DOM에서 바뀐 부분을 수정한다.
  • 수정된 내용 때문에 화면 배치가 달라지는지 계산한다.
  • 계산 결과를 바탕으로 화면을 다시 그린다.

이 흐름을 짧게 정리하면 아래와 같다.

// 출력결과
// DOM 수정
// → 레이아웃 계산
// → 화면 다시 그리기

여기서 레이아웃 계산은 화면 요소가 어디에, 어떤 크기로 배치될지 다시 계산하는 작업이다.
화면 다시 그리기는 계산된 결과를 실제 화면에 다시 표시하는 작업이다.


작은 화면에서는 이 비용이 크게 느껴지지 않을 수 있다.
하지만 입력값이 자주 바뀌거나, 목록이 계속 추가되거나, 화면 일부가 계속 갱신되는 페이지에서는 실제 DOM을 계속 직접 수정하는 작업이 부담이 될 수 있다.


그래서 React는 실제 DOM을 바로 자주 고치는 대신, 중간에 비교용 구조를 하나 둔다.
그 비교용 구조가 Virtual DOM이다.


1-2. Virtual DOM이 필요한 이유

실제 DOM 조작의 비용

Virtual DOM은 React가 메모리 안에 만들어 두는 가짜 화면 구조이다.
여기서 가짜라는 말은 의미 없는 구조라는 뜻이 아니다.
실제 브라우저 화면에 바로 연결된 구조는 아니지만, 실제 DOM과 비슷한 모양으로 만들어 둔 비교용 구조라는 뜻이다.


실제 DOM을 매번 직접 고치면 브라우저가 화면을 다시 계산하고 다시 그리는 일이 자주 발생할 수 있다.
이 작업이 반복되면 성능 비용이 커진다.


예를 들어 장바구니 화면을 생각해 보자.
사용자가 상품 수량을 하나 올릴 때마다 아래 내용이 바뀔 수 있다.

  • 상품 수량
  • 상품별 가격
  • 총 결제 금액
  • 배송비 표시
  • 할인 금액

겉으로 보면 숫자 몇 개만 바뀌는 것처럼 보인다.
하지만 실제 DOM을 무작정 많이 수정하면 브라우저는 여러 화면 요소를 다시 계산해야 할 수 있다.


React는 이런 상황에서 “실제 화면을 바로 고치기 전에, 먼저 가벼운 구조에서 비교해 보자”라는 방식을 사용한다.
이때 사용하는 가벼운 비교용 화면 구조가 Virtual DOM이다.

React가 선택한 방식

React는 상태가 바뀌면 실제 DOM을 바로 수정하지 않는다.
먼저 메모리 안에서 새로운 Virtual DOM을 만든다.
그리고 이전 Virtual DOM과 새로운 Virtual DOM을 비교한다.


비교 결과 실제로 바뀐 부분만 찾은 뒤, 그 부분만 실제 DOM에 반영한다.

// 출력결과
// 상태 변경
// → 새 Virtual DOM 생성
// → 이전 Virtual DOM과 새 Virtual DOM 비교
// → 바뀐 부분 찾기
// → 바뀐 부분만 실제 DOM에 반영

Virtual DOM의 핵심 목적은 실제 DOM 조작 횟수를 줄이는 것이다.
실제 화면을 전부 다시 만드는 것이 아니라, 바뀐 부분만 정확히 찾아서 반영하는 데 목적이 있다.


여기서 중요한 연결점이 있다.
React가 바뀐 부분만 찾으려면 이전 구조와 새 구조를 비교해야 한다.
이 비교 과정이 Diff이다.
그리고 비교 결과를 실제 화면에 반영하는 과정이 Patch이다.


1-3. Virtual DOM 처리 흐름

전체 흐름 먼저 보기

Virtual DOM 처리 흐름은 크게 세 단계로 볼 수 있다.

  • 새 Virtual DOM 생성
  • Diff
  • Patch

이 세 단계는 각각 따로 외우는 개념이 아니다.
상태가 바뀐 뒤 React가 화면을 효율적으로 갱신하는 순서이다.

1단계: 새 Virtual DOM 생성

사용자가 버튼을 클릭하거나 입력창에 값을 입력하면 상태가 바뀔 수 있다.
React에서는 보통 setState() 또는 useState()의 상태 변경 함수가 호출되면서 상태가 바뀐다.


상태가 바뀌면 React는 실제 브라우저 화면을 바로 건드리지 않는다.
먼저 메모리 안에서 새 Virtual DOM 트리를 만든다.


예를 들어 이전 화면 구조가 아래와 같다고 하자.

// 출력결과
// 이전 Virtual DOM
// div
// ├─ h1 "안녕"
// └─ p "기존 텍스트"

상태가 바뀐 뒤 새 화면 구조는 아래처럼 만들어질 수 있다.

// 출력결과
// 새 Virtual DOM
// div
// ├─ h1 "안녕"
// └─ p "새 텍스트"

여기서 실제 브라우저 화면은 아직 바뀌지 않았다.
React는 먼저 메모리 안에서 “새 화면은 이렇게 되어야 한다”라는 설계도를 만든 것이다.


이때 Virtual DOM은 실제 브라우저 DOM보다 가볍게 다룰 수 있는 JavaScript 객체 구조이다.
그래서 실제 화면을 바로 고치기 전에 비교용 구조를 먼저 만드는 방식이 효율적이다.

2단계: Diff

Diff는 이전 Virtual DOM과 새 Virtual DOM을 비교하는 과정이다.
쉽게 말하면, 두 설계도를 나란히 놓고 어디가 달라졌는지 찾는 단계이다.


앞의 예시에서는 h1은 그대로이다.
하지만 p의 텍스트는 기존 텍스트에서 새 텍스트로 바뀌었다.


그러면 React는 아래처럼 판단한다.

// 출력결과
// h1 "안녕" → 변경 없음
// p "기존 텍스트" → p "새 텍스트"로 변경됨
// 변경 대상: p

즉, Diff 단계에서는 모든 것을 다시 바꾸려고 하지 않는다.
어떤 노드가 그대로인지, 어떤 노드가 바뀌었는지 골라낸다.


여기서 node는 화면 구조를 이루는 하나의 요소라고 이해하면 된다.
div, h1, p 같은 태그 하나하나가 노드가 될 수 있다.


Diff는 이전 Virtual DOM과 새 Virtual DOM을 비교해서 달라진 부분을 찾는 과정이다.
두 구조를 비교할 때 모든 노드를 무조건 새로 만드는 것이 아니라, 그대로 유지할 수 있는 부분과 바뀐 부분을 나누어 판단한다.
이 과정을 알면 React가 왜 실제 DOM 전체를 다시 그리지 않고 필요한 부분만 수정하는지 이해할 수 있다.

왼쪽은 이전 상태의 Virtual DOM이고, 오른쪽은 상태 변경 후 새로 만들어진 Virtual DOM이다.
보라색 노드는 이전과 현재가 같기 때문에 그대로 유지할 수 있는 부분이다.
노란색 노드는 값이나 구조가 바뀐 부분이므로 React가 변경 대상으로 판단한다.
즉, Diffing은 전체 화면을 처음부터 다시 만드는 과정이 아니라, 두 Virtual DOM을 비교해서 실제로 달라진 부분만 찾아내는 과정이다.


이때 반복 목록에서는 key가 중요해진다.
목록 안에 비슷한 항목이 여러 개 있으면 React는 각 항목을 구분할 기준이 필요하다.
그 기준이 바로 key이다.

3단계: Patch

Patch는 Diff에서 찾은 변경 부분만 실제 DOM에 반영하는 과정이다.


앞의 예시에서는 h1은 그대로 두면 된다.
바뀐 것은 p의 텍스트뿐이다.
그래서 실제 DOM에서는 p 텍스트만 바꾸면 된다.

// 출력결과
// 브라우저 실제 DOM
// h1 "안녕" 그대로 유지
// p 텍스트만 "새 텍스트"로 교체

이 방식의 장점은 명확하다.
실제 DOM 전체를 다시 만들지 않아도 된다.
바뀐 부분만 찾아서 최소한으로 반영하면 된다.


상태가 변경되면 React는 실제 DOM을 바로 수정하지 않는다.
먼저 메모리 안에서 새 Virtual DOM을 만들고, 이전 Virtual DOM과 비교한 뒤, 실제로 바뀐 노드만 골라낸다.
그 다음 Patch 단계에서 바뀐 부분만 실제 브라우저 DOM에 반영한다.

사용자 이벤트가 발생하면 setState() 같은 상태 변경 함수가 호출되고, React는 새 Virtual DOM을 만든다.
이때 브라우저의 실제 화면은 아직 수정되지 않는다.
그 다음 이전 Virtual DOM과 새 Virtual DOM을 비교하면서 변경된 노드만 찾아낸다.
변경되지 않은 h1 같은 노드는 건너뛰고, 텍스트가 바뀐 p 노드만 실제 DOM 업데이트 대상으로 선택한다.
마지막으로 Patch 단계에서 선택된 변경 사항만 실제 DOM에 반영하므로, 불필요한 리플로우와 리페인트를 줄일 수 있다.


정리하면 Virtual DOM 처리 흐름은 아래와 같다.

  • 사용자의 이벤트로 상태가 바뀐다.
  • React가 새 Virtual DOM을 만든다.
  • 이전 Virtual DOM과 새 Virtual DOM을 비교한다.
  • 달라진 부분만 실제 DOM에 반영한다.

Virtual DOM은 화면을 빠르게 바꾸기 위한 마법이 아니라, 실제 DOM 조작을 줄이기 위한 비교 전략이다.


1-4. Virtual DOM 관련 핵심 용어 정리

DOM

DOM은 브라우저가 관리하는 실제 화면 구조이다.
HTML 태그들이 브라우저 안에서 객체 구조로 바뀐 결과라고 보면 된다.


예를 들어 button 태그가 있으면 브라우저는 그 버튼을 화면에 보여줄 뿐 아니라, 클릭 이벤트를 받을 수 있는 객체로도 관리한다.
그래서 DOM은 단순한 화면 그림이 아니라 브라우저가 조작할 수 있는 구조이다.

Virtual DOM

Virtual DOM은 React가 메모리 안에 만든 가짜 화면 구조이다.
실제 화면을 바로 바꾸기 전에 비교하기 위해 사용하는 구조이다.


여기서 “가짜”는 화면에 직접 보이지 않는다는 뜻이다.
React는 이 구조를 사용해 이전 화면과 새 화면의 차이를 먼저 계산한다.

Diffing

Diffing은 이전 Virtual DOM과 새 Virtual DOM을 비교해서 달라진 부분을 찾는 과정이다.
무엇이 그대로이고, 무엇이 바뀌었는지 찾는 단계이다.


초보자는 Diffing을 틀린 그림 찾기처럼 이해하면 쉽다.
두 그림을 비교해서 달라진 부분만 표시하는 과정과 비슷하다.

Reconciliation

Reconciliation은 Diffing의 비교 결과를 바탕으로 어떤 노드는 유지하고, 어떤 노드는 실제 화면에 반영할지 결정하는 전체 갱신 과정이다.
한국어로는 재조정이라고 이해할 수 있다.


Diffing은 비교에 초점이 있다.
Reconciliation은 비교 결과를 바탕으로 화면을 현재 상태에 맞게 조정하는 과정에 초점이 있다.
그래서 두 단어는 비슷해 보이지만 완전히 같은 뜻은 아니다.

Rendering

Rendering은 컴포넌트를 실행해서 UI 구조를 만드는 과정이다.
쉽게 말하면, 컴포넌트 코드가 실행되어 화면에 어떤 구조가 나와야 하는지 만들어지는 과정이다.


예를 들어 아래 컴포넌트가 있다고 하자.

// Greeting.jsx
function Greeting() {
  // 화면에 보여줄 구조를 반환한다.
  return <h1>안녕하세요</h1>;
}

이 컴포넌트가 실행되어 <h1>안녕하세요</h1>라는 UI 구조를 만드는 과정이 Rendering이다.


용어를 한 번에 정리하면 아래와 같다.

용어의미
DOM브라우저가 관리하는 실제 화면 구조
Virtual DOMReact가 메모리에 만든 가짜 화면 구조
Diffing이전 Virtual DOM과 새 Virtual DOM을 비교해서 차이를 찾는 과정
Reconciliation비교 결과를 바탕으로 유지할 부분과 갱신할 부분을 결정하는 전체 갱신 과정
Rendering컴포넌트를 실행해서 UI 구조를 만드는 과정

이 용어들은 모두 React가 화면을 효율적으로 갱신하는 흐름 안에서 연결된다.
Rendering으로 새 구조를 만들고, Diffing으로 차이를 찾고, Reconciliation 과정에서 실제 화면 갱신 방식이 결정된다.


1-5. 반복 요소에서 key가 필요한 이유

반복 렌더링에서 key가 하는 일

React에서 배열 데이터를 화면에 반복해서 보여줄 때는 보통 .map()을 사용한다.
예를 들어 메뉴 목록, 댓글 목록, 장바구니 목록, 할 일 목록처럼 같은 모양의 항목이 여러 개 반복될 때 사용한다.


이때 각 항목에는 key를 지정해야 한다.
key는 반복되는 항목을 구분하기 위한 고유 이름표이다.


예를 들어 학생 목록이 있다고 하자.

// 출력결과
// 1번 학생: 둘리
// 2번 학생: 도우너
// 3번 학생: 또치

사람은 이름이나 번호를 보고 각 학생을 구분할 수 있다.
React도 마찬가지이다.
반복되는 화면 요소가 여러 개 있으면, 어떤 항목이 어떤 항목인지 구분할 기준이 필요하다.
그 기준이 key이다.

key가 없을 때 생기는 문제

key가 없거나 잘못된 key를 사용하면 React가 항목을 정확히 구분하지 못할 수 있다.
특히 목록 중간이나 앞쪽에 항목이 추가되거나 삭제될 때 문제가 생긴다.


먼저 삭제 상황을 생각해 보자.
아래처럼 다섯 개 항목이 있다고 하자.

// 출력결과
// 1번째 항목
// 2번째 항목
// 3번째 항목
// 4번째 항목
// 5번째 항목

여기서 3번째 항목이 삭제되면 사람은 “3번째 항목 하나만 없어졌구나”라고 이해한다.
하지만 React가 항목을 구분할 안정적인 key를 받지 못하면, 3번째 항목 하나만 삭제한다고 판단하지 못할 수 있다.


그 대신 기존 4번째 항목의 내용을 3번째 위치에 덮어쓰고, 기존 5번째 항목의 내용을 4번째 위치에 덮어쓰는 식으로 비효율적인 업데이트가 발생할 수 있다.
즉, 항목 하나만 삭제하면 되는 상황인데 뒤쪽 항목들이 줄줄이 바뀐 것처럼 처리될 수 있다.


이번에는 추가 상황을 생각해 보자.
아래 목록이 있다고 하자.

// 출력결과
// [딸기 라떼, 치즈 케이크]

여기서 맨 앞에 아메리카노가 추가되면 목록은 이렇게 바뀐다.

// 출력결과
// [아메리카노, 딸기 라떼, 치즈 케이크]

사람은 “아메리카노가 새로 앞에 들어왔구나”라고 쉽게 안다.
하지만 React가 항목을 구분할 안정적인 key를 받지 못하면, 기존 첫 번째 자리에 있던 딸기 라떼가 아메리카노로 바뀐 것처럼 판단할 수 있다.


이렇게 되면 단순히 글자만 바뀌는 문제가 아니다.
각 항목이 내부에 입력 상태를 가지고 있었다면, 그 상태가 엉뚱한 항목에 붙을 수 있다.


예를 들어 딸기 라떼 항목 안의 입력창에 “딸기”라고 입력해 두었다고 하자.
맨 앞에 아메리카노가 추가되었는데 key가 잘못되어 있으면, “딸기”라고 입력된 상태가 아메리카노 항목에 붙어 버릴 수 있다.


이 문제는 초보자가 보기에는 굉장히 이상해 보인다.
화면에서는 항목이 잘 추가된 것처럼 보이는데, 내부 상태가 밀려서 엉뚱한 곳에 붙기 때문이다.

key가 있을 때 달라지는 점

key가 안정적으로 지정되어 있으면 React는 각 항목을 정확히 추적할 수 있다.


예를 들어 각 메뉴에 고유한 id가 있다고 하자.

// MenuDataExample.js
const menuList = [
  { id: 'm1', name: '딸기 라떼' },
  { id: 'm2', name: '치즈 케이크' },
];

여기에 새 메뉴가 추가되면 아래처럼 된다.

// MenuDataAddExample.js
const menuList = [
  { id: 'mXXX', name: '아메리카노' },
  { id: 'm1', name: '딸기 라떼' },
  { id: 'm2', name: '치즈 케이크' },
];

이 경우 React는 m1이 여전히 딸기 라떼이고, m2가 여전히 치즈 케이크라는 것을 알 수 있다.
새로 생긴 것은 mXXX뿐이다.


그래서 기존 항목의 상태는 유지하고, 새 항목만 추가할 수 있다.
반복 요소에서 key는 항목의 위치가 아니라 항목의 정체성을 알려주는 값이다.


1-6. key 값을 지정하는 올바른 규칙

형제 요소 사이에서만 고유하면 된다

key는 앱 전체에서 무조건 유일할 필요는 없다.
같은 부모 안에서 반복되는 형제 요소끼리만 서로 구분되면 된다.


예를 들어 상품 목록과 댓글 목록이 각각 있다고 하자.
상품 목록 안에서 id가 1인 상품이 있고, 댓글 목록 안에서도 id가 1인 댓글이 있을 수 있다.
이것은 문제가 되지 않는다.
두 목록은 서로 다른 부모 아래에서 렌더링되기 때문이다.


중요한 것은 같은 .map() 안에서 반복되는 항목끼리 key가 겹치지 않는 것이다.

// ProductListKeyExample.jsx
function ProductList({ products }) {
  // 같은 목록 안에서는 product.id가 서로 달라야 한다.
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

위 예제에서 product.id는 같은 상품 목록 안에서 항목을 구분하는 값이다.
이 값이 같은 목록 안에서 겹치면 React가 어떤 항목이 어떤 항목인지 헷갈릴 수 있다.

변하지 않는 안정적인 값이어야 한다

key는 고유해야 할 뿐 아니라 안정적이어야 한다.
여기서 안정적이라는 말은 리렌더링되어도 같은 항목은 같은 key를 유지해야 한다는 뜻이다.


아래 값들은 key로 쓰면 위험하다.

  • Math.random()
  • new Date().getTime()
  • 매번 새로 계산되어 바뀌는 값

이런 값을 쓰면 화면이 다시 그려질 때마다 key가 바뀐다.
그러면 React는 같은 항목도 완전히 새로운 항목이라고 오해할 수 있다.


잘못된 예시는 아래와 같다.

// BadRandomKeyExample.jsx
function MenuList({ menuList }) {
  return (
    <div>
      {menuList.map((menu) => (
        <MenuItem
          key={Math.random()}
          name={menu.name}
        />
      ))}
    </div>
  );
}

이 코드는 key가 매번 새로 만들어진다.
그래서 React는 기존 항목을 재사용하기 어렵다.
항목이 유지되는 것이 아니라 매번 새로 만들어지는 것처럼 처리될 수 있다.


또 다른 잘못된 예시는 현재 시간을 사용하는 방식이다.

// BadTimeKeyExample.jsx
function MenuList({ menuList }) {
  return (
    <div>
      {menuList.map((menu) => (
        <MenuItem
          key={new Date().getTime()}
          name={menu.name}
        />
      ))}
    </div>
  );
}

이 방식도 렌더링 시점마다 값이 달라질 수 있다.
key는 항목의 정체성을 알려야 하는데, 시간값은 항목의 정체성을 나타내지 못한다.

가장 추천하는 값

가장 추천하는 key는 데이터 자체가 가진 고유 id이다.
보통 데이터베이스에서 가져온 id를 그대로 쓰는 방식이 가장 안전하다.

// GoodStableKeyExample.jsx
function MenuList({ menuList }) {
  return (
    <div>
      {menuList.map((menu) => (
        <MenuItem
          key={menu.id}
          name={menu.name}
        />
      ))}
    </div>
  );
}

이 코드에서 menu.id는 메뉴 데이터 자체의 고유 값이다.
메뉴 위치가 바뀌어도 id는 그대로 유지된다.
그래서 React가 항목을 정확히 추적할 수 있다.


정리하면 key는 아래 조건을 만족해야 한다.

  • 같은 부모 안의 형제 요소 사이에서 고유해야 한다.
  • 같은 항목이라면 리렌더링되어도 값이 바뀌지 않아야 한다.
  • 가능하면 데이터의 고유 id를 사용해야 한다.

key는 단순히 경고를 없애려고 넣는 값이 아니다.
React가 반복 요소를 정확히 비교하고 상태를 유지하기 위해 사용하는 중요한 기준이다.


1-7. key={index}와 key={menu.id} 비교

잘못된 예시: key={index}

index는 배열에서 몇 번째 위치인지를 나타내는 숫자이다.
첫 번째 항목은 0, 두 번째 항목은 1, 세 번째 항목은 2가 된다.


처음에는 index를 key로 써도 괜찮아 보일 수 있다.
목록이 변하지 않고 단순히 보여주기만 한다면 큰 문제가 드러나지 않을 수도 있다.


하지만 항목이 추가되거나 삭제되면 문제가 생긴다.
특히 맨 앞이나 중간에 항목이 추가되면 기존 항목들의 index가 전부 밀린다.


잘못된 예시는 아래와 같다.

// BadIndexKeyExample.jsx
function MenuList({ menuList }) {
  return (
    <div>
      <h3>Bad key index</h3>
      {menuList.map((menu, index) => (
        <MenuItem
          key={index}
          name={menu.name}
        />
      ))}
    </div>
  );
}

이 코드에서 key={index}는 항목의 고유한 정체성을 나타내지 못한다.
그저 현재 위치만 나타낸다.


예를 들어 처음 목록이 아래와 같다고 하자.

// 출력결과
// key=0 → 딸기 라떼
// key=1 → 치즈 케이크

이제 맨 앞에 아메리카노가 추가되면 실제 데이터는 아래처럼 바뀐다.

// 출력결과
// key=0 → 아메리카노
// key=1 → 딸기 라떼
// key=2 → 치즈 케이크

문제는 기존에 key=0이던 자리가 여전히 key=0이라는 점이다.
React는 “같은 key=0이니까 같은 컴포넌트겠네”라고 판단할 수 있다.
그 결과 기존 딸기 라떼 컴포넌트의 상태가 아메리카노에 붙어 버릴 수 있다.


아메리카노가 맨 앞에 삽입되면 뒤의 항목들이 한 칸씩 밀린다.
이때 key=0, key=1 같은 위치 기준 key는 그대로 남아 있기 때문에 React는 항목 자체가 이동했다고 판단하기보다, 같은 위치의 내용이 바뀌었다고 판단할 수 있다.
그 결과 새 항목 하나만 추가된 상황인데도 기존 항목들의 내용이 줄줄이 바뀐 것처럼 처리될 수 있다.
이 경우 DOM 조작이 3번 발생할 수 있고, 딸기 라떼에 있던 입력 상태가 아메리카노에 붙는 문제도 생길 수 있다.


정리하면 key={index} 방식에서는 새 항목 하나만 추가된 상황이어도 기존 항목들이 밀리면서 불필요한 DOM 조작이 여러 번 발생하고, 내부 상태가 다른 항목으로 이동할 수 있다.

올바른 예시: key={menu.id}

올바른 방식은 데이터 자체의 고유 id를 key로 사용하는 것이다.

// GoodMenuIdKeyExample.jsx
function MenuList({ menuList }) {
  return (
    <div>
      <h3>Good key menu id</h3>
      {menuList.map((menu) => (
        <MenuItem
          key={menu.id}
          name={menu.name}
        />
      ))}
    </div>
  );
}

이 방식에서는 항목의 위치가 바뀌어도 key가 유지된다.
딸기 라떼는 계속 m1이고, 치즈 케이크는 계속 m2이다.


처음 목록이 아래와 같다고 하자.

// 출력결과
// key="m1" → 딸기 라떼
// key="m2" → 치즈 케이크

맨 앞에 아메리카노가 추가되면 아래처럼 된다.

// 출력결과
// key="mXXX" → 아메리카노
// key="m1" → 딸기 라떼
// key="m2" → 치즈 케이크

React는 m1과 m2가 기존 항목이라는 것을 알 수 있다.
그래서 기존 항목은 재사용하고, 새 항목인 mXXX만 새로 추가한다.


이 경우 React는 m1, m2를 정확히 추적한다.
새로 들어온 mXXX만 실제 DOM에 1개 삽입하고, m1, m2는 위치만 이동시키며 내부 상태를 그대로 보존한다.
따라서 불필요한 조작이 줄고, 기존 항목의 입력 상태도 안전하게 유지된다.


key={menu.id}는 항목의 위치가 아니라 항목 자체를 기준으로 비교하게 만든다.
그래서 상태가 밀리지 않고, 불필요한 DOM 조작도 줄일 수 있다.


반복 목록에서 key를 잘못 지정하면 React가 항목의 정체성을 잘못 판단할 수 있다.
특히 key={index}처럼 배열의 위치를 기준으로 삼으면, 맨 앞에 새 항목이 추가될 때 기존 항목들의 위치가 밀리면서 상태가 엉뚱한 항목에 붙을 수 있다.
반대로 데이터 고유 id를 key로 사용하면 항목의 위치가 바뀌어도 같은 항목을 정확히 추적할 수 있다.

Bad 예시는 key={index}를 사용한 경우이다.
처음에는 key=0이 딸기 라떼, key=1이 치즈 케이크를 가리키고 있다.
그런데 맨 앞에 아메리카노가 추가되면 key=0 자리에 아메리카노가 들어오고, 기존 항목들은 뒤로 밀린다.
React는 같은 key=0을 같은 컴포넌트라고 판단할 수 있기 때문에, 딸기 라떼에 있던 입력 상태가 아메리카노에 붙는 문제가 생길 수 있다.
이 경우 새 항목 하나만 추가된 것처럼 보여도 실제로는 뒤쪽 항목들의 내용이 줄줄이 바뀐 것처럼 처리될 수 있어 DOM 조작이 3번 발생할 수 있다.


Good 예시는 key={menu.id}를 사용한 경우이다.
딸기 라떼는 위치가 바뀌어도 계속 m1이고, 치즈 케이크는 계속 m2이다.
새로 추가된 아메리카노만 mXXX라는 새로운 key를 가진다.
그래서 React는 기존 항목을 재사용하고, 새 항목만 추가하면 된다고 정확히 판단할 수 있다.
새 항목만 실제 DOM에 1개 삽입하고, 기존 항목들은 내부 상태를 유지한 채 위치만 이동할 수 있다.
이 방식은 불필요한 DOM 조작을 줄이고, 각 항목의 내부 상태도 안전하게 보존한다.

key 비교 최종 정리

key={index}와 key={menu.id}의 차이는 아래처럼 정리할 수 있다.

비교 기준key={index}key={menu.id}
기준배열의 위치데이터의 고유 id
항목 추가 시기존 항목의 key가 밀릴 수 있음기존 항목의 key가 유지됨
내부 상태다른 항목에 붙을 수 있음원래 항목에 유지됨
DOM 조작3번 발생 가능새 항목 1개 삽입으로 최소화 가능
대표 흐름새 항목 추가 시 뒤 항목들이 줄줄이 바뀐 것처럼 처리될 수 있음새 항목만 추가하고 기존 항목은 재사용 가능
추천 여부변하지 않는 단순 목록에서만 제한적으로 사용가장 추천

마지막으로 한 줄로 정리하면 이렇다.


반복 요소의 key는 React가 항목을 정확히 추적하기 위한 이름표이며, 가능하면 데이터의 고유 id를 사용해야 한다.


1-8. EduApp5.jsx로 확인하는 key 속성 테스트

예제 목적

EduApp5.jsx는 반복 목록에서 key를 어떻게 주느냐에 따라 React가 항목을 어떻게 추적하는지 확인하는 예제이다.
같은 메뉴 목록을 왼쪽과 오른쪽에 동시에 출력하고, 왼쪽은 key={index}를 사용하고 오른쪽은 key={menu.id}를 사용한다.


이 예제에서 중요한 점은 각 메뉴 항목 안에 체크박스가 있다는 것이다.
체크박스는 사용자가 직접 선택할 수 있는 실제 input 요소이다.
체크 여부는 React state로 따로 관리하지 않아도, 브라우저가 실제 input DOM 안에 선택 상태로 가지고 있을 수 있다.


그래서 key가 잘못되면 새 항목을 추가했을 때 기존 체크 상태가 엉뚱한 메뉴에 붙는 문제를 눈으로 확인할 수 있다.
이 예제의 핵심은 key가 단순히 경고를 없애기 위한 값이 아니라, React가 항목의 정체성을 판단하는 기준이라는 점을 직접 확인하는 것이다.


전체 코드

// EduApp5.jsx
import { useState } from "react";

// 개별 메뉴 아이템 컴포넌트이다.
function MenuItem({ name }) {
  return (
    <div style={{
      border: "1px solid #ccc",
      padding: "10px",
      margin: "5px 0",
      display: "flex",
      justifyContent: "space-between",
      width: "300px"
    }}>
      <strong>{name}</strong>
      <label>
        <input type="checkbox" /> 선택하기
      </label>
    </div>
  );
}

export default function KeyTest() {
  // 기본 메뉴 리스트를 상태로 관리한다.
  const [menuList, setMenuList] = useState([
    { id: "m1", name: "딸기 라떼" },
    { id: "m2", name: "치즈 케이크" }
  ]);

  // 새 메뉴를 맨 위에 추가한다.
  const addAtTop = () => {
    const newItem = { id: `m${Date.now()}`, name: "아메리카노" };
    setMenuList([newItem, ...menuList]);
  };

  return (
    <div style={{ padding: "20px" }}>
      <h1>React Key 속성 테스트</h1>
      <button onClick={addAtTop} style={{ padding: "8px 12px", marginBottom: "15px" }}>
        맨 위에 [아메리카노] 추가하기
      </button>

      <div style={{ display: "flex", gap: "40px" }}>
        <div>
          <h3>❌ Bad (key={"{index}"})</h3>
          {menuList.map((menu, index) => (
            <MenuItem key={index} name={menu.name} />
          ))}
        </div>

        <div>
          <h3>⭕ Good (key={"{menu.id}"})</h3>
          {menuList.map((menu) => (
            <MenuItem key={menu.id} name={menu.name} />
          ))}
        </div>
      </div>
    </div>
  );
}

이 코드는 같은 menuList 데이터를 두 영역에 동시에 출력한다.
왼쪽 Bad 영역은 배열 위치인 index를 key로 사용한다.
오른쪽 Good 영역은 메뉴 데이터의 고유 id를 key로 사용한다.


화면 모양은 비슷하지만 React가 항목을 구분하는 기준은 완전히 다르다.
이 차이 때문에 맨 앞에 새 메뉴를 추가했을 때 체크 상태가 다르게 움직일 수 있다.


코드 구조 먼저 보기

이 예제는 크게 세 부분으로 나눌 수 있다.

  • MenuItem
  • menuList 상태
  • Bad 영역과 Good 영역

MenuItem은 메뉴 하나를 화면에 보여주는 컴포넌트이다.
메뉴 이름과 체크박스를 함께 보여준다.


menuList는 화면에 출력할 메뉴 목록이다.
처음에는 딸기 라떼와 치즈 케이크 두 개가 들어 있다.


Bad 영역은 key={index}를 사용한다.
Good 영역은 key={menu.id}를 사용한다.
두 영역은 같은 menuList 데이터를 사용하지만, key를 다르게 주기 때문에 React가 항목을 추적하는 기준이 달라진다.


처음 상태를 한 번에 보면 아래와 같다.

// 출력결과
// menuList 초기값
// ├─ { id: "m1", name: "딸기 라떼" }
// └─ { id: "m2", name: "치즈 케이크" }
//
// Bad 영역의 key 기준
// key=0 → 딸기 라떼
// key=1 → 치즈 케이크
//
// Good 영역의 key 기준
// key="m1" → 딸기 라떼
// key="m2" → 치즈 케이크

처음에는 두 영역 모두 화면에 같은 메뉴를 보여준다.
하지만 왼쪽은 위치로 항목을 구분하고, 오른쪽은 메뉴 자체의 고유 id로 항목을 구분한다.


MenuItem은 메뉴 하나를 화면에 보여주는 컴포넌트이다.

// MenuItemFlow.jsx
function MenuItem({ name }) {
  // name을 받아 메뉴 이름과 체크박스를 출력한다.
  return (
    <div>
      <strong>{name}</strong>
      <label>
        <input type="checkbox" /> 선택하기
      </label>
    </div>
  );
}

MenuItem은 name을 props로 받는다.
그리고 그 값을 <strong>{name}</strong> 위치에 출력한다.


여기서 체크박스가 중요하다.
체크박스는 사용자가 직접 선택할 수 있는 실제 input 요소이다.
이 예제에서는 체크 여부를 따로 state로 관리하지 않는다.
그래도 브라우저는 실제 input DOM 안에 체크 상태를 가지고 있을 수 있다.


예를 들어 딸기 라떼의 체크박스를 체크하면, 브라우저는 해당 input DOM이 체크되었다는 상태를 가지고 있다.
그런데 React가 그 input DOM을 다른 메뉴 항목에 재사용하면 체크 상태도 다른 메뉴에 붙은 것처럼 보일 수 있다.


즉, 이 예제에서 확인하려는 문제는 단순히 메뉴 이름이 바뀌는 문제가 아니다.
React가 같은 항목이라고 판단한 실제 DOM을 재사용하면서, 체크 상태가 엉뚱한 항목에 붙을 수 있다는 점이다.


KeyTest 컴포넌트는 menuList를 상태로 관리한다.

// MenuListStateFlow.jsx
const [menuList, setMenuList] = useState([
  { id: "m1", name: "딸기 라떼" },
  { id: "m2", name: "치즈 케이크" }
]);

menuList에는 메뉴 객체가 배열로 들어 있다.
각 객체는 id와 name을 가진다.


id는 메뉴를 구분하는 고유 값이다.
name은 화면에 보여줄 메뉴 이름이다.


처음 상태를 흐름으로 보면 아래와 같다.

// 출력결과
// menuList
// ├─ { id: "m1", name: "딸기 라떼" }
// └─ { id: "m2", name: "치즈 케이크" }

이 상태가 왼쪽 Bad 영역과 오른쪽 Good 영역에 동시에 사용된다.
같은 데이터를 사용하므로 처음 화면은 비슷하게 보인다.
하지만 항목을 구분하는 key 기준이 다르기 때문에, 목록이 변경될 때 결과가 달라질 수 있다.


addAtTop 함수 흐름

addAtTop 함수는 새 메뉴인 아메리카노를 목록 맨 위에 추가한다.

// AddAtTopFlow.jsx
const addAtTop = () => {
  // 새 메뉴 객체를 만든다.
  const newItem = { id: `m${Date.now()}`, name: "아메리카노" };

  // 새 메뉴를 기존 목록 맨 앞에 추가한다.
  setMenuList([newItem, ...menuList]);
};

newItem은 새로 추가할 메뉴 객체이다.
id에는 m${Date.now()}가 들어간다.
Date.now()는 현재 시간을 숫자로 만들어 주기 때문에, 새 메뉴마다 다른 id를 만들 수 있다.


여기서 주의할 점이 있다.
앞에서 new Date().getTime()처럼 매번 바뀌는 값을 key에 직접 쓰면 위험하다고 했다.
하지만 이 예제의 Date.now()는 렌더링할 때마다 key를 새로 만드는 용도가 아니다.
버튼을 클릭해서 새 메뉴 객체를 만들 때 한 번 실행되고, 그 결과가 새 메뉴의 id로 저장된다.


즉, 이미 만들어진 메뉴의 id는 리렌더링되어도 계속 유지된다.
그래서 이 예제에서는 새 항목을 구분하기 위한 고유 id를 만드는 용도로 이해하면 된다.


setMenuList([newItem, ...menuList])는 새 배열을 만든다.
newItem을 맨 앞에 넣고, 기존 menuList를 뒤에 붙인다.


데이터 흐름을 순서대로 보면 아래와 같다.

// 출력결과
// 1. 버튼 클릭
// 2. addAtTop 실행
// 3. 새 메뉴 { id: "m시간값", name: "아메리카노" } 생성
// 4. setMenuList([newItem, ...menuList]) 실행
// 5. 아메리카노가 기존 메뉴 목록 맨 앞에 추가됨

이제 중요한 점은 새 항목이 맨 뒤가 아니라 맨 앞에 추가된다는 것이다.
맨 앞에 추가되면 기존 항목들의 위치가 뒤로 밀린다.
이 상황에서 key={index}와 key={menu.id}의 차이가 드러난다.


Bad 영역: key={index}

왼쪽 Bad 영역은 index를 key로 사용한다.

// BadIndexKeyFlow.jsx
<div>
  <h3>❌ Bad (key={"{index}"})</h3>
  {menuList.map((menu, index) => (
    <MenuItem key={index} name={menu.name} />
  ))}
</div>

index는 배열에서 몇 번째 위치인지를 나타내는 숫자이다.
첫 번째 항목은 0, 두 번째 항목은 1이 된다.


처음에는 아래처럼 연결된다.

// 출력결과
// 처음 Bad 영역
// key=0 → 딸기 라떼
// key=1 → 치즈 케이크

이 상태에서 딸기 라떼의 체크박스를 체크했다고 하자.
그러면 실제 input DOM의 체크 상태는 첫 번째 위치에 있는 key=0 항목 쪽에 남아 있을 수 있다.

// 출력결과
// 딸기 라떼 체크 후 Bad 영역
// key=0 → 딸기 라떼 → 체크됨
// key=1 → 치즈 케이크 → 체크 안 됨

이제 버튼을 눌러 아메리카노를 맨 위에 추가하면 데이터는 아래처럼 바뀐다.

// 출력결과
// 아메리카노 추가 후 Bad 영역
// key=0 → 아메리카노
// key=1 → 딸기 라떼
// key=2 → 치즈 케이크

문제는 key=0이 계속 첫 번째 위치를 가리킨다는 점이다.
처음에 key=0은 딸기 라떼였다.
그런데 새 항목이 앞에 들어오면 key=0은 아메리카노가 된다.


React는 key를 보고 같은 컴포넌트인지 판단한다.
그래서 key=0이 계속 유지되면, 기존 첫 번째 항목의 실제 input DOM이 새 첫 번째 항목에 재사용될 수 있다.


체크 상태 흐름을 더 직관적으로 보면 아래와 같다.

// 출력결과
// 1. 처음에는 key=0이 딸기 라떼를 가리킴
// 2. 딸기 라떼 체크박스를 체크함
// 3. key=0 위치의 input DOM이 체크 상태를 가짐
// 4. 아메리카노를 맨 위에 추가함
// 5. key=0이 이제 아메리카노를 가리킴
// 6. React는 key=0을 같은 항목으로 볼 수 있음
// 7. 기존 key=0의 체크 상태가 아메리카노에 붙은 것처럼 보일 수 있음

즉, 사용자는 딸기 라떼를 체크했는데, 새 항목을 추가한 뒤에는 아메리카노가 체크된 것처럼 보일 수 있다.
이 문제가 key={index}의 위험한 점이다.


key={index}는 항목 자체가 아니라 위치를 기준으로 삼기 때문에, 목록 앞쪽에 항목이 추가되거나 삭제될 때 상태가 밀릴 수 있다.


Good 영역: key={menu.id}

오른쪽 Good 영역은 메뉴의 고유 id를 key로 사용한다.

// GoodMenuIdKeyFlow.jsx
<div>
  <h3>⭕ Good (key={"{menu.id}"})</h3>
  {menuList.map((menu) => (
    <MenuItem key={menu.id} name={menu.name} />
  ))}
</div>

처음에는 아래처럼 연결된다.

// 출력결과
// 처음 Good 영역
// key="m1" → 딸기 라떼
// key="m2" → 치즈 케이크

이 상태에서 딸기 라떼의 체크박스를 체크했다고 하자.
그러면 체크 상태는 key="m1" 항목에 연결된 실제 input DOM 쪽에 남아 있을 수 있다.

// 출력결과
// 딸기 라떼 체크 후 Good 영역
// key="m1" → 딸기 라떼 → 체크됨
// key="m2" → 치즈 케이크 → 체크 안 됨

이제 아메리카노가 맨 앞에 추가되면 아래처럼 된다.

// 출력결과
// 아메리카노 추가 후 Good 영역
// key="m시간값" → 아메리카노
// key="m1" → 딸기 라떼
// key="m2" → 치즈 케이크

딸기 라떼는 위치가 두 번째로 밀려도 여전히 key="m1"이다.
치즈 케이크도 위치가 세 번째로 밀려도 여전히 key="m2"이다.


그래서 React는 기존 항목을 정확히 추적할 수 있다.
새로 추가된 아메리카노만 새로운 항목으로 판단하고, 기존 메뉴들은 내부 상태를 유지한 채 위치만 이동할 수 있다.


체크 상태 흐름을 더 직관적으로 보면 아래와 같다.

// 출력결과
// 1. 처음에는 key="m1"이 딸기 라떼를 가리킴
// 2. 딸기 라떼 체크박스를 체크함
// 3. key="m1" 항목의 input DOM이 체크 상태를 가짐
// 4. 아메리카노를 맨 위에 추가함
// 5. 새 항목은 key="m시간값"을 가짐
// 6. 딸기 라떼는 위치가 밀려도 계속 key="m1"을 유지함
// 7. React는 m1이 여전히 딸기 라떼라는 것을 알 수 있음
// 8. 체크 상태도 딸기 라떼에 유지될 수 있음

즉, Good 영역에서는 새 항목이 맨 앞에 추가되어도 기존 항목의 정체성이 바뀌지 않는다.
React는 m1을 계속 딸기 라떼로 보고, m2를 계속 치즈 케이크로 본다.


key={menu.id}는 항목의 위치가 아니라 항목 자체의 고유 값을 기준으로 삼기 때문에, 목록이 바뀌어도 기존 항목을 안정적으로 추적할 수 있다.


Bad와 Good의 차이 한 번에 보기

Bad와 Good의 차이는 화면 모양이 아니라 React가 항목을 구분하는 기준이다.


Bad는 위치를 기준으로 삼는다.
그래서 앞쪽에 새 항목이 들어오면 기존 항목들의 위치가 밀리면서 key와 실제 메뉴의 연결이 바뀔 수 있다.


Good은 데이터의 고유 id를 기준으로 삼는다.
그래서 항목의 위치가 바뀌어도 key와 실제 메뉴의 연결이 유지된다.


흐름을 비교하면 아래와 같다.

// 출력결과
// Bad: key={index}
//
// 처음
// key=0 → 딸기 라떼 → 체크됨
// key=1 → 치즈 케이크
//
// 아메리카노 추가 후
// key=0 → 아메리카노 → 체크 상태가 붙을 수 있음
// key=1 → 딸기 라떼
// key=2 → 치즈 케이크
// 출력결과
// Good: key={menu.id}
//
// 처음
// key="m1" → 딸기 라떼 → 체크됨
// key="m2" → 치즈 케이크
//
// 아메리카노 추가 후
// key="m시간값" → 아메리카노
// key="m1" → 딸기 라떼 → 체크 상태 유지 가능
// key="m2" → 치즈 케이크

이 비교를 보면 key={index}가 왜 위험한지 더 분명해진다.
index는 항목의 정체성이 아니라 현재 위치일 뿐이다.
위치가 바뀌면 React가 같은 항목을 정확히 구분하기 어렵다.


반대로 menu.id는 항목 자체의 고유 값이다.
위치가 바뀌어도 id는 유지된다.
그래서 React가 기존 항목을 재사용할지, 새 항목을 추가할지 더 정확히 판단할 수 있다.


EduApp5.jsx 데이터 흐름 한 번에 보기

전체 흐름은 아래처럼 정리할 수 있다.

// 출력결과
// 1. menuList 초기값 생성
//    ├─ m1: 딸기 라떼
//    └─ m2: 치즈 케이크
// 2. 왼쪽 Bad 영역은 key={index}로 출력
// 3. 오른쪽 Good 영역은 key={menu.id}로 출력
// 4. 딸기 라떼 체크박스 선택
// 5. Bad 영역에서는 key=0 위치의 input DOM이 체크 상태를 가질 수 있음
// 6. Good 영역에서는 key="m1" 항목의 input DOM이 체크 상태를 가질 수 있음
// 7. 맨 위에 아메리카노 추가 버튼 클릭
// 8. addAtTop 실행
// 9. 아메리카노가 배열 맨 앞에 추가됨
// 10. Bad 영역은 위치 기준 key라 key=0이 아메리카노를 가리키게 됨
// 11. 그래서 체크 상태가 아메리카노에 붙은 것처럼 보일 수 있음
// 12. Good 영역은 id 기준 key라 m1이 계속 딸기 라떼를 가리킴
// 13. 그래서 체크 상태가 딸기 라떼에 유지될 수 있음

이 예제는 같은 데이터라도 key를 무엇으로 주느냐에 따라 결과가 달라질 수 있다는 점을 보여준다.
왼쪽과 오른쪽의 차이는 화면 모양이 아니라 key 기준이다.


정리하면 아래 흐름이 핵심이다.

// 출력결과
// key={index}
// → 위치를 기준으로 항목을 구분
// → 앞에 새 항목이 들어오면 기존 항목의 위치가 밀림
// → 체크 상태가 다른 항목에 붙을 수 있음
//
// key={menu.id}
// → 데이터 고유 id를 기준으로 항목을 구분
// → 앞에 새 항목이 들어와도 기존 항목의 id는 유지됨
// → 체크 상태가 원래 항목에 유지될 수 있음



EduApp5.jsx 최종 정리

EduApp5.jsx는 반복 목록에서 key가 왜 중요한지 직접 확인하는 예제이다.
key={index}는 배열 위치를 기준으로 항목을 구분한다.
그래서 새 항목이 앞에 추가되면 기존 항목의 위치가 밀리면서 체크 상태도 함께 밀릴 수 있다.


반대로 key={menu.id}는 데이터 고유 id를 기준으로 항목을 구분한다.
항목의 위치가 바뀌어도 id는 그대로 유지되므로, React가 기존 항목을 정확히 추적할 수 있다.


핵심만 정리하면 아래와 같다.

구분key={index}key={menu.id}
기준배열 위치데이터 고유 id
항목 추가 시기존 항목의 위치 기준이 밀림기존 항목의 고유 값이 유지됨
체크박스 상태다른 메뉴에 붙을 수 있음원래 메뉴에 유지될 수 있음
항목 추적위치 중심항목 정체성 중심
추천 여부목록이 변하지 않을 때만 제한적으로 사용반복 목록에서 가장 추천

반복 목록에서는 항목의 위치보다 항목 자체를 구분할 수 있는 고유 id를 key로 사용하는 것이 안전하다.




컴포넌트의 종류와 역할 분리

React에서 컴포넌트는 화면을 만드는 작은 조각이다.
하나의 웹 페이지를 한 덩어리로 만들면 코드가 금방 복잡해진다.
그래서 React에서는 화면을 여러 조각으로 나누고, 각 조각에 역할을 맡긴다.


컴포넌트를 나누는 핵심 이유는 화면 출력, 데이터 처리, 공통 구조, 입력 처리 같은 역할을 분리해서 코드를 더 이해하기 쉽고 재사용하기 쉽게 만들기 위해서이다.
컴포넌트를 단순히 종류별로 외우는 것이 아니라, 각각이 어떤 책임을 맡는지 기준으로 이해해야 한다.


2-1. 컴포넌트는 UI를 만드는 조각이다

컴포넌트의 기본 의미

컴포넌트는 웹 페이지를 구성하는 작은 UI 조각이다.
UI는 User Interface의 줄임말이고, 사용자가 화면에서 보고 클릭하고 입력하는 부분을 의미한다.


예를 들어 쇼핑몰 화면을 생각해 보자.
하나의 페이지 안에는 여러 부분이 있다.

  • 상단 메뉴
  • 상품 목록
  • 상품 카드
  • 검색창
  • 장바구니 버튼
  • 로딩 화면
  • 에러 메시지

이 모든 것을 하나의 파일에 한꺼번에 작성하면 코드가 길고 복잡해진다.
어디가 상품 카드 코드인지, 어디가 검색창 코드인지, 어디가 데이터 처리 코드인지 찾기 어렵다.


그래서 React에서는 화면을 여러 컴포넌트로 나눈다.
각 컴포넌트는 화면의 특정 부분이나 특정 기능을 담당한다.
작은 조각을 여러 개 만든 뒤, 그 조각을 조립해서 하나의 페이지를 만든다.


화면을 컴포넌트로 나눈다는 것은 단순히 파일을 쪼개는 일이 아니다.
각 조각이 맡을 역할을 분명히 나누는 일이다.
예를 들어 상품 카드 컴포넌트는 상품 정보를 보여주는 역할에 집중하고, 상품 목록 컴포넌트는 여러 상품 카드를 반복해서 보여주는 역할에 집중할 수 있다.


하나의 화면은 여러 컴포넌트가 조립되어 만들어진다.
화면을 통째로 하나의 코드로 보는 것이 아니라, 각각의 영역을 담당하는 조각으로 나누면 구조를 훨씬 쉽게 이해할 수 있다.

화면을 구성하는 각 영역이 <Network />, <Line />, <Predictions />, <DepartureBoard />, <Trains /> 같은 컴포넌트로 나뉘어 있다.
이 구조는 하나의 큰 화면을 여러 작은 컴포넌트로 분리할 수 있다는 점을 보여준다.
각 컴포넌트는 화면 전체를 모두 책임지지 않고, 자신에게 맡겨진 영역만 담당한다.
이렇게 나누면 특정 영역을 수정할 때 전체 화면 코드를 뒤지지 않아도 되고, 필요한 조각만 고치거나 재사용할 수 있다.


컴포넌트는 퍼즐 조각처럼 생각할 수도 있다.
퍼즐 조각 하나만으로는 전체 그림이 완성되지 않는다.
하지만 조각을 알맞게 조립하면 하나의 완성된 화면이 만들어진다.

작은 UI 조각들이 모여 하나의 큰 화면을 구성한다.
각 조각은 버튼, 입력창, 카드, 목록 같은 독립적인 화면 요소가 될 수 있다.
중요한 점은 각 컴포넌트가 자신만의 역할을 가진다는 것이다.
조각을 잘 나누면 화면을 만들 때 필요한 부분만 가져와 조립할 수 있고, 같은 모양이나 기능을 여러 곳에서 다시 사용할 수 있다.

왜 컴포넌트를 나누는가

컴포넌트를 나누는 이유는 크게 세 가지로 볼 수 있다.

  • 코드를 읽기 쉽게 만들기 위해서이다.
  • 같은 UI를 여러 곳에서 재사용하기 위해서이다.
  • 화면 출력과 데이터 처리 같은 역할을 분리하기 위해서이다.

예를 들어 로그인 화면을 만든다고 하자.
로그인 화면에는 제목, 아이디 입력창, 비밀번호 입력창, 로그인 버튼, 에러 메시지가 있을 수 있다.
이 모든 코드를 한 컴포넌트에 몰아넣으면 처음에는 괜찮아 보인다.
하지만 기능이 늘어나면 코드가 빠르게 복잡해진다.


반대로 입력창 컴포넌트, 버튼 컴포넌트, 에러 메시지 컴포넌트처럼 나누면 각 부분을 따로 이해할 수 있다.
또 버튼 컴포넌트는 로그인 화면뿐 아니라 회원가입 화면이나 검색 화면에서도 다시 사용할 수 있다.


즉, 컴포넌트를 나누는 목적은 단순히 코드를 짧게 만드는 것이 아니다.
각 코드가 맡은 일을 분명하게 만드는 것이다.


2-2. 역할 기준으로 보는 컴포넌트 종류

전체 분류 먼저 보기

컴포넌트는 역할과 기능 기준으로 여러 종류로 나눌 수 있다.
이 분류는 반드시 정답처럼 고정되는 것은 아니지만, 프로젝트 구조를 이해할 때 매우 도움이 된다.


대표적인 컴포넌트 종류는 아래와 같다.

종류역할
Page Component페이지 하나 담당
Layout Component공통 구조 담당
Container Component데이터, 상태, 로직 담당
Presentational Component화면 출력 담당
Form Component입력 처리 담당
List / Item Component목록 출력 담당
Provider Component전역 상태 제공
Loading / Error Component상태 화면 처리

이 표에서 중요한 것은 이름을 그대로 외우는 것이 아니다.
각 컴포넌트가 무엇을 책임지는지 이해하는 것이다.


예를 들어 Container Component는 데이터를 가져오고 상태를 관리하는 쪽에 가깝다.
Presentational Component는 받은 데이터를 화면에 보여주는 쪽에 가깝다.
둘 다 컴포넌트지만, 맡은 책임이 다르다.

이 분류를 먼저 보는 이유

초보자는 컴포넌트를 처음 배울 때 “그냥 함수로 화면을 반환하면 다 컴포넌트 아닌가?”라고 생각할 수 있다.
맞다.
기본적으로는 화면을 구성하는 함수나 구조를 컴포넌트라고 볼 수 있다.


하지만 프로젝트가 커지면 컴포넌트마다 하는 일이 달라진다.
어떤 컴포넌트는 페이지 전체를 담당한다.
어떤 컴포넌트는 공통 레이아웃을 담당한다.
어떤 컴포넌트는 데이터를 가져오고, 어떤 컴포넌트는 화면 출력만 담당한다.


컴포넌트 분류는 이름을 외우기 위한 것이 아니라, 어떤 책임을 어느 컴포넌트에 둘지 판단하기 위한 기준이다.
이 기준이 있으면 코드 구조를 잡을 때 훨씬 덜 헷갈린다.


2-3. Presentational Component

역할

Presentational Component는 화면을 보여주는 역할에 집중하는 컴포넌트이다.
이름 그대로 presentation, 즉 보여주기에 가까운 역할을 담당한다.


초보자 기준으로 쉽게 말하면, 데이터를 받아서 화면에 예쁘게 출력하는 컴포넌트이다.
직접 데이터를 가져오거나 복잡한 상태 관리를 하기보다는, 부모나 다른 컴포넌트가 넘겨준 값을 받아 화면에 표시한다.


대표적인 특징은 아래와 같다.

  • props를 받아서 화면에 출력한다.
  • 복잡한 로직을 거의 가지지 않는다.
  • 재사용하기 좋다.
  • 버튼, 카드, 배지, 상품 항목, 프로필 이미지처럼 화면 요소에 많이 사용된다.

여기서 props는 부모 컴포넌트가 자식 컴포넌트에게 전달하는 값이다.
초보자 기준으로는 “컴포넌트에 넣어 주는 재료”라고 생각하면 된다.
ProductCard 컴포넌트에 상품 이름과 가격을 넘기면, ProductCard는 그 값을 화면에 보여준다.

코드 예제

아래 예제는 상품 이름과 가격을 받아 화면에 보여주는 Presentational Component이다.

// ProductCard.jsx
function ProductCard({ name, price }) {
  // 부모에게 받은 name과 price를 화면에 출력한다.
  return (
    <div>
      <h3>{name}</h3>
      <p>{price}원</p>
    </div>
  );
}

ProductCard는 상품 데이터를 직접 가져오지 않는다.
API를 호출하지도 않고, 복잡한 상태를 관리하지도 않는다.
그저 name과 price를 받아서 화면에 출력한다.


이런 컴포넌트는 재사용하기 좋다.
노트북 상품을 보여줄 때도 사용할 수 있고, 마우스 상품을 보여줄 때도 사용할 수 있다.
값만 다르게 넘기면 같은 구조로 다른 상품을 보여줄 수 있기 때문이다.


예를 들어 부모 컴포넌트에서 아래처럼 사용할 수 있다.

// ProductCardUseExample.jsx
function ProductCardUseExample() {
  // ProductCard에 보여줄 값을 전달한다.
  return (
    <div>
      <ProductCard name="노트북" price={1500000} />
      <ProductCard name="마우스" price={30000} />
    </div>
  );
}

같은 ProductCard 컴포넌트를 두 번 사용했지만, 전달한 값이 다르기 때문에 화면에는 서로 다른 상품이 표시된다.
이것이 Presentational Component의 장점이다.

초보자가 헷갈릴 수 있는 점

화면을 그린다고 해서 모든 컴포넌트가 Presentational Component는 아니다.
React 컴포넌트는 대부분 화면을 반환하므로, 단순히 JSX를 반환한다는 이유만으로 Presentational Component라고 판단하면 헷갈릴 수 있다.


핵심은 그 컴포넌트가 무엇에 집중하는지이다.

  • 데이터를 가져오고 상태를 관리하는가?
  • 아니면 받은 값을 화면에 보여주는 데 집중하는가?

Presentational Component는 두 번째에 가깝다.
즉, 로직보다 출력에 집중하는 컴포넌트이다.


Presentational Component는 데이터를 받아 화면에 보여주는 역할에 집중한다.
상태나 API 호출 같은 복잡한 로직은 거의 가지지 않고, 화면 표시와 재사용성에 초점을 둔다.

Presentational Component는 주로 props로 받은 값을 화면에 출력한다.
상품 카드, 버튼, 배지, 목록 아이템처럼 반복해서 사용할 수 있는 UI 조각에 적합하다.
데이터를 어디서 가져오는지, 어떤 이벤트로 상태가 바뀌는지 같은 복잡한 흐름은 이 컴포넌트의 주된 책임이 아니다.
이렇게 출력 중심 컴포넌트를 분리하면 같은 화면 조각을 여러 곳에서 재사용하기 쉬워지고, 화면 구조를 더 깔끔하게 유지할 수 있다.


2-4. Container Component

역할

Container Component는 데이터, 상태, 이벤트 처리, API 호출 같은 로직을 담당하는 컴포넌트이다.
Presentational Component가 “어떻게 보여줄지”에 가깝다면, Container Component는 “무엇을 가져오고 어떻게 동작할지”에 가깝다.


초보자 기준으로 쉽게 말하면, 데이터를 준비하고 필요한 동작을 처리한 뒤, 화면 출력 컴포넌트에 값을 넘겨주는 컴포넌트이다.


대표적인 특징은 아래와 같다.

  • state를 관리할 수 있다.
  • API를 호출해서 데이터를 가져올 수 있다.
  • 이벤트 처리 함수를 만들 수 있다.
  • 하위 컴포넌트에 props로 데이터를 전달한다.
  • 화면을 직접 예쁘게 꾸미기보다는, 하위 컴포넌트를 조합하는 경우가 많다.

여기서 state는 컴포넌트가 기억하고 있는 값이다.
예를 들어 상품 목록, 로그인 여부, 검색어, 선택된 탭 같은 값이 state가 될 수 있다.

코드 예제

아래 예제는 상품 목록 데이터를 준비한 뒤, 각 상품을 ProductCard로 출력하는 Container Component이다.

// ProductListContainer.jsx
function ProductListContainer() {
  // 화면에 보여줄 상품 데이터를 준비한다.
  const products = [
    { id: 1, name: "노트북", price: 1500000 },
    { id: 2, name: "마우스", price: 30000 },
  ];

  // 상품 데이터를 ProductCard에 하나씩 전달한다.
  return (
    <div>
      {products.map((p) => (
        <ProductCard
          key={p.id}
          name={p.name}
          price={p.price}
        />
      ))}
    </div>
  );
}

이 예제에서 ProductListContainer는 상품 목록 데이터를 가지고 있다.
그리고 .map()을 사용해서 상품 하나마다 ProductCard를 만든다.


중요한 점은 ProductListContainer가 직접 상품 카드의 모양을 자세히 그리지 않는다는 것이다.
상품 카드의 실제 출력은 ProductCard가 담당한다.
ProductListContainer는 어떤 데이터를 보여줄지 준비하고, 그 데이터를 ProductCard에 넘겨준다.

Presentational Component와 비교

Container Component와 Presentational Component는 함께 자주 사용된다.
둘의 차이를 쉽게 정리하면 아래와 같다.

구분Container ComponentPresentational Component
관심사데이터, 상태, 로직화면 출력
하는 일데이터를 준비하고 전달받은 값을 보여줌
예시상품 목록 데이터 관리상품 카드 출력
복잡한 로직가질 수 있음거의 없음

예를 들어 식당으로 비유하면 Container Component는 주방에서 재료를 준비하는 역할에 가깝다.
Presentational Component는 준비된 음식을 손님에게 보기 좋게 담아 내는 역할에 가깝다.


둘을 분리하면 화면 출력 코드는 깔끔해지고, 데이터 처리 코드는 한 곳에서 관리하기 쉬워진다.


Container Component는 데이터와 로직을 관리하고, 필요한 값을 자식 컴포넌트에 전달한다.
화면을 직접 모두 그리기보다는, 출력 전용 컴포넌트를 감싸고 필요한 데이터를 내려주는 구조로 많이 사용된다.

Container Component는 state, useEffect, API 호출처럼 동작과 데이터 흐름에 관련된 책임을 맡을 수 있다.
반대로 화면을 예쁘게 표시하는 세부 구조는 하위 Presentational Component에 맡길 수 있다.
이렇게 역할을 나누면 데이터를 가져오는 코드와 화면을 출력하는 코드가 섞이지 않는다.
그 결과 어떤 데이터가 어디서 준비되고, 어떤 컴포넌트가 그 데이터를 화면에 보여주는지 더 쉽게 추적할 수 있다.


2-5. Layout Component

역할

Layout Component는 여러 페이지에서 반복되는 공통 화면 구조를 담당하는 컴포넌트이다.
웹 사이트를 만들다 보면 여러 페이지에 공통으로 들어가는 부분이 있다.


예를 들면 아래와 같다.

  • Header
  • Footer
  • Sidebar
  • Navbar
  • 전체 페이지 여백
  • 공통 배경

이런 공통 구조를 매 페이지마다 반복해서 작성하면 중복 코드가 많아진다.
또 Header 구조를 바꾸고 싶을 때 모든 페이지 파일을 수정해야 할 수 있다.


그래서 공통 틀은 Layout Component로 분리한다.
각 페이지는 Layout 안에 자신만의 내용을 넣는다.

코드 예제

아래 예제는 header, main, footer 구조를 가진 Layout Component이다.

// Layout.jsx
function Layout({ children }) {
  // children은 Layout 안에 들어올 실제 페이지 내용이다.
  return (
    <div>
      <header>헤더</header>
      <main>{children}</main>
      <footer>푸터</footer>
    </div>
  );
}

여기서 children은 컴포넌트 태그 사이에 들어온 내용을 의미한다.
초보자 기준으로 쉽게 말하면, Layout이라는 틀 안에 끼워 넣을 실제 내용이다.


예를 들어 아래처럼 사용할 수 있다.

// LayoutUseExample.jsx
function LayoutUseExample() {
  // Layout 안에 들어간 h1과 p가 children이 된다.
  return (
    <Layout>
      <h1>상품 목록</h1>
      <p>오늘의 추천 상품을 확인하세요.</p>
    </Layout>
  );
}

이 코드에서 Layout은 공통 구조를 담당한다.
h1과 p는 main 영역에 들어갈 실제 페이지 내용이다.

Page Component와 비교

Layout Component와 Page Component는 헷갈릴 수 있다.
둘 다 화면을 구성하지만 역할이 다르다.


Layout Component는 여러 페이지에서 반복되는 공통 틀이다.
Page Component는 특정 주소나 특정 화면 하나를 담당한다.


예를 들어 쇼핑몰에서 상품 목록 페이지와 마이페이지가 있다고 하자.
두 페이지는 내용은 다르지만, 상단 메뉴와 하단 푸터는 같을 수 있다.
이때 공통 메뉴와 푸터를 Layout이 담당하고, 상품 목록 페이지와 마이페이지는 각각 Page Component가 담당한다.


Layout Component는 페이지마다 반복되는 공통 구조를 만들고, 실제 내용은 children으로 채운다.
공통 틀과 페이지별 내용을 분리하면 여러 페이지에서 같은 구조를 반복해서 작성하지 않아도 된다.

Layout Component는 Header, main, Footer처럼 반복되는 화면 구조를 감싼다.
children은 그 구조 안에 들어올 실제 페이지 내용이다.
즉, Layout은 빈 틀을 만들고, 각 페이지는 그 틀 안에 자신에게 필요한 내용을 넣는다.
이 방식을 사용하면 공통 구조를 한 곳에서 관리할 수 있고, 페이지별 내용은 따로 분리할 수 있다.


2-6. Page Component

역할

Page Component는 하나의 페이지 화면을 담당하는 컴포넌트이다.
사용자가 특정 주소로 들어왔을 때 보여줄 화면 단위라고 볼 수 있다.


예를 들어 아래와 같은 페이지들이 있을 수 있다.

  • HomePage
  • LoginPage
  • ProductPage
  • MyPage

Page Component는 보통 여러 컴포넌트를 조합해서 만든다.
공통 구조는 Layout Component를 사용하고, 데이터 처리는 Container Component를 사용하고, 실제 화면 조각은 Presentational Component를 사용할 수 있다.

코드 예제

아래 예제는 상품 목록 페이지를 담당하는 Page Component이다.

// ProductPage.jsx
function ProductPage() {
  // 상품 목록 페이지 전체를 구성한다.
  return (
    <Layout>
      <h1>상품 목록</h1>
      <ProductListContainer />
    </Layout>
  );
}

이 코드에서 ProductPage는 하나의 페이지를 담당한다.
Layout으로 공통 구조를 만들고, 그 안에 제목과 ProductListContainer를 넣는다.


ProductListContainer는 상품 데이터를 준비하고 상품 카드를 반복 출력한다.
즉, ProductPage는 페이지 전체 조립을 담당하고, 세부 데이터 처리는 다른 컴포넌트에 맡긴다.

라우트와의 연결

React Router를 사용하면 Page Component는 보통 라우트에 직접 연결된다.
여기서 라우트는 주소와 컴포넌트를 연결하는 규칙이라고 이해하면 된다.


예를 들어 /products라는 주소에 접속하면 ProductPage를 보여주도록 연결할 수 있다.
이 경우 ProductPage는 상품 목록 주소에 해당하는 화면 단위가 된다.


Page Component는 하나의 주소나 라우트에 연결되는 화면 단위 컴포넌트이다.
여러 하위 컴포넌트를 조합해서 실제 페이지를 구성한다.

Page Component는 화면의 최상위 조립 지점에 가깝다.
라우트와 연결되어 사용자가 특정 주소로 들어왔을 때 보여줄 화면을 담당한다.
이 안에서 Layout을 사용해 공통 구조를 만들고, Container를 사용해 데이터 흐름을 연결하고, 여러 UI 컴포넌트를 배치할 수 있다.
따라서 Page Component는 직접 모든 세부 로직을 처리하기보다, 페이지에 필요한 컴포넌트들을 조합하는 역할이 크다.


2-7. Form Component

역할

Form Component는 사용자 입력을 담당하는 컴포넌트이다.
form은 사용자가 값을 입력하고 제출할 수 있는 화면 구조이다.


대표적인 예시는 아래와 같다.

  • 로그인 폼
  • 회원가입 폼
  • 검색 폼
  • 댓글 작성 폼

사용자 입력이 있는 화면에서는 입력값을 받고, 버튼을 누르면 그 값을 처리해야 한다.
이런 입력 관련 구조를 담당하는 컴포넌트를 Form Component라고 볼 수 있다.

코드 예제

아래 예제는 로그인 입력 화면을 담당하는 Form Component이다.

// LoginForm.jsx
function LoginForm() {
  // 사용자가 아이디와 비밀번호를 입력하는 폼이다.
  return (
    <form>
      <input placeholder="아이디" />
      <input placeholder="비밀번호" />
      <button>로그인</button>
    </form>
  );
}

이 코드에서 form은 입력 영역 전체를 감싼다.
input은 사용자가 값을 입력하는 칸이다.
button은 입력한 값을 제출하거나 동작을 실행하는 버튼이다.


현재 예제는 가장 단순한 구조이다.
실제 프로젝트에서는 입력값을 state로 관리하거나, 제출 이벤트를 처리하거나, 유효성 검사를 추가할 수 있다.
하지만 기본 역할은 같다.
Form Component는 사용자의 입력을 받는 화면을 담당한다.

Form Component를 분리하는 이유

입력 화면은 코드가 복잡해지기 쉽다.
입력값 관리, 에러 메시지, 제출 처리, 검증 로직이 함께 들어갈 수 있기 때문이다.


그래서 입력 영역을 별도 컴포넌트로 분리하면 관리하기 쉽다.
로그인 페이지는 전체 페이지 구성을 담당하고, LoginForm은 로그인 입력 처리에 집중하게 만들 수 있다.


2-8. List / Item Component

역할

List / Item Component는 목록 전체와 항목 하나를 분리할 때 사용하는 구조이다.
반복되는 데이터를 화면에 보여줄 때 자주 사용한다.


예를 들어 할 일 목록을 생각해 보자.
할 일 목록 전체를 담당하는 컴포넌트가 있고, 할 일 하나를 담당하는 컴포넌트가 있을 수 있다.

  • TodoList → 목록 전체 담당
  • TodoItem → 항목 하나 담당

이렇게 나누면 목록 전체 구조와 항목 하나의 구조를 분리해서 생각할 수 있다.

코드 예제

아래 예제는 할 일 목록 전체와 할 일 항목 하나를 분리한 코드이다.

// TodoList.jsx
function TodoList({ todos }) {
  // todos 배열을 반복해서 TodoItem으로 출력한다.
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem key={todo.id} text={todo.text} />
      ))}
    </ul>
  );
}

function TodoItem({ text }) {
  // 할 일 하나를 li 태그로 출력한다.
  return <li>{text}</li>;
}

TodoList는 목록 전체를 담당한다.
그래서 ul 태그를 만들고, todos 배열을 반복한다.


TodoItem은 항목 하나만 담당한다.
그래서 text를 받아 li 태그로 출력한다.


여기서 앞에서 배운 key도 다시 등장한다.
todos.map()으로 반복 출력할 때 각 TodoItem에는 key={todo.id}가 들어간다.
React가 항목을 정확히 추적하려면 반복 요소마다 안정적인 key가 필요하기 때문이다.

List와 Item을 나누는 이유

목록 전체와 항목 하나를 나누면 코드가 읽기 쉬워진다.
목록을 어떻게 반복할지는 TodoList에서 보면 되고, 항목 하나가 어떻게 생겼는지는 TodoItem에서 보면 된다.


또 항목 모양이 복잡해져도 TodoItem만 수정하면 된다.
예를 들어 체크박스, 삭제 버튼, 수정 버튼이 추가되어도 목록 전체 구조와 분리해서 관리할 수 있다.


2-9. Provider Component

역할

Provider Component는 전역 상태나 공통 기능을 하위 컴포넌트에 공급하는 컴포넌트이다.
Provider는 제공자라는 뜻이다.
즉, 아래에 있는 컴포넌트들이 사용할 값을 제공하는 역할을 한다.


Context API를 사용할 때 자주 등장한다.
Context API는 멀리 떨어진 컴포넌트에게 공통 데이터를 전달할 때 사용하는 기능이다.
이때 Provider가 값을 공급하고, 하위 컴포넌트가 그 값을 꺼내 쓴다.


대표적인 예시는 아래와 같다.

  • ThemeProvider
  • AuthProvider
  • CartProvider
  • UserProvider

예를 들어 ThemeProvider는 다크 모드인지 라이트 모드인지 같은 테마 정보를 하위 컴포넌트에 제공할 수 있다.
AuthProvider는 로그인 사용자 정보를 하위 컴포넌트에 제공할 수 있다.

코드 예제

아래 예제는 테마 값을 하위 컴포넌트에 제공하는 Provider Component이다.

// ThemeProvider.jsx
function ThemeProvider({ children }) {
  // 하위 컴포넌트에 dark 값을 제공한다.
  return (
    <ThemeContext.Provider value="dark">
      {children}
    </ThemeContext.Provider>
  );
}

ThemeContext.Provider는 값을 공급하는 역할을 한다.
value="dark"는 하위 컴포넌트에 제공할 값이다.
children은 이 값을 사용할 수 있는 하위 컴포넌트 영역이다.


초보자 기준으로 쉽게 말하면, ThemeProvider는 “이 안에 있는 컴포넌트들은 테마 값을 사용할 수 있다”라고 범위를 정해 주는 컴포넌트이다.

Provider Component가 필요한 이유

컴포넌트 구조가 깊어지면 부모에서 자식으로 계속 props를 내려주는 일이 번거로워질 수 있다.
예를 들어 로그인 사용자 정보를 가장 아래의 프로필 컴포넌트에서만 쓰는데, 중간 컴포넌트들이 계속 그 값을 전달해야 한다면 코드가 복잡해진다.


이때 Provider Component를 사용하면 공통 데이터를 필요한 범위에 공급할 수 있다.
그러면 중간 컴포넌트가 불필요한 값을 계속 전달하지 않아도 된다.


2-10. Loading / Error Component

역할

Loading / Error Component는 로딩 중 화면이나 에러 화면을 담당하는 컴포넌트이다.
화면에는 정상적인 데이터만 보여주는 것이 아니다.
데이터를 기다리는 중일 수도 있고, 문제가 발생했을 수도 있고, 보여줄 데이터가 없을 수도 있다.


이런 상태를 사용자에게 알려주는 컴포넌트가 필요하다.
대표적인 예시는 아래와 같다.

  • LoadingSpinner
  • ErrorMessage
  • EmptyState
  • NotFoundPage

사용자는 화면이 멈춘 것인지, 데이터를 불러오는 중인지, 문제가 생긴 것인지 알 수 있어야 한다.
그래서 로딩 화면과 에러 화면은 사용자 경험에서 중요하다.

코드 예제

아래 예제는 로딩 상태와 에러 상태를 보여주는 가장 단순한 컴포넌트이다.

// StatusComponents.jsx
function Loading() {
  // 데이터를 기다리는 중임을 알려준다.
  return <p>로딩 중...</p>;
}

function ErrorMessage() {
  // 문제가 발생했음을 알려준다.
  return <p>문제가 발생했습니다.</p>;
}

Loading은 데이터를 불러오는 중이라는 상태를 보여준다.
ErrorMessage는 요청이나 처리 과정에서 문제가 발생했음을 알려준다.


실제 프로젝트에서는 로딩 아이콘을 보여주거나, 다시 시도 버튼을 넣거나, 에러 원인을 함께 보여줄 수도 있다.
하지만 기본 목적은 같다.
사용자가 현재 상태를 이해할 수 있게 만드는 것이다.

Loading / Error Component를 분리하는 이유

로딩과 에러 화면을 매번 페이지 안에 직접 쓰면 중복이 많아진다.
여러 페이지에서 같은 로딩 화면이나 같은 에러 메시지를 사용할 수 있기 때문이다.


따라서 공통 로딩 컴포넌트와 공통 에러 컴포넌트를 만들어 두면 여러 곳에서 재사용할 수 있다.
이렇게 하면 화면 상태 처리도 더 일관성 있게 만들 수 있다.


2-11. 컴포넌트 분류를 한 번에 정리하기

실용적인 질문으로 분류하기

컴포넌트 종류를 외우려고 하면 헷갈릴 수 있다.
그럴 때는 이름보다 질문으로 구분하는 것이 좋다.

  • 어떤 화면인가? → Page
  • 화면 구조는 어떻게 생겼나? → Layout
  • 데이터와 로직은 누가 관리하나? → Container
  • 실제 UI는 누가 보여주나? → Presentational
  • 입력은 누가 받나? → Form
  • 전역 데이터는 누가 공급하나? → Provider

이 질문을 기준으로 보면 컴포넌트 역할을 더 쉽게 나눌 수 있다.
예를 들어 “이 컴포넌트는 데이터를 가져오는가?”라고 물었을 때 그렇다면 Container Component에 가까울 수 있다.
“이 컴포넌트는 받은 값을 화면에 보여주기만 하는가?”라고 물었을 때 그렇다면 Presentational Component에 가까울 수 있다.

비교표로 정리

컴포넌트는 역할에 따라 state를 가질 수도 있고, 거의 가지지 않을 수도 있다.
또 어떤 컴포넌트는 JSX/UI를 직접 많이 가지고, 어떤 컴포넌트는 하위 컴포넌트를 감싸는 역할이 더 클 수 있다.


여기서 state는 컴포넌트가 기억하고 관리하는 값이다.
JSX/UI는 화면에 어떤 구조를 보여줄지 작성하는 부분이다.


컴포넌트는 역할에 따라 상태를 가지는지, 화면을 직접 그리는지, 어떤 책임을 맡는지가 달라진다.
분류표를 보면 각 컴포넌트가 담당하는 일이 한눈에 정리된다.

Presentational Component는 보통 state를 거의 가지지 않고, props로 받은 값을 화면에 보여주는 데 집중한다.
Container Component는 state와 데이터 처리 로직을 가질 수 있고, 하위 컴포넌트에 필요한 값을 전달한다.
Layout Component는 공통 구조를 담당하므로 Header, Footer, main 같은 틀을 만든다.
Page Component는 라우트와 연결되는 화면 단위로, 여러 컴포넌트를 조합해서 하나의 페이지를 구성한다.
이 표는 컴포넌트를 이름만으로 구분하지 않고, 상태를 가지는지, 화면을 직접 그리는지, 어떤 역할을 맡는지 기준으로 비교하게 도와준다.

2세트 최종 정리

컴포넌트를 역할별로 나누면 화면 구조를 훨씬 쉽게 이해할 수 있다.
모든 컴포넌트가 같은 일을 하는 것이 아니라, 각각 맡는 책임이 다르다.


핵심만 정리하면 아래와 같다.

컴포넌트핵심 역할
Presentational Component받은 데이터를 화면에 출력한다
Container Component데이터, 상태, 로직을 관리한다
Layout Component여러 페이지의 공통 구조를 만든다
Page Component하나의 페이지 화면을 담당한다
Form Component사용자 입력을 담당한다
List / Item Component목록 전체와 항목 하나를 분리한다
Provider Component전역 상태나 공통 기능을 공급한다
Loading / Error Component로딩, 에러, 빈 상태 같은 화면 상태를 보여준다

컴포넌트를 잘 나눈다는 것은 파일을 많이 만드는 것이 아니라, 각 컴포넌트가 맡을 책임을 분명하게 나누는 것이다.




Props Drilling과 Context API

React에서 데이터는 기본적으로 부모 컴포넌트에서 자식 컴포넌트로 내려간다.
이 흐름은 단순하고 예측하기 쉽다.
하지만 컴포넌트 구조가 깊어지면 같은 데이터를 여러 단계에 걸쳐 계속 전달해야 하는 문제가 생길 수 있다.


Props Drilling은 데이터를 실제로 쓰지 않는 중간 컴포넌트까지 props를 계속 전달해야 하는 상황을 말한다.
이 문제를 이해하면 Context API, 전역 상태 관리, Component Composition, TanStack Query 같은 해결 방법이 왜 필요한지도 자연스럽게 이해할 수 있다.


3-1. Props Drilling이란 무엇인가

기본 의미

Props Drilling은 부모 컴포넌트에서 만든 데이터를 깊은 자식 컴포넌트까지 계속 props로 전달하는 구조를 말한다.
여기서 props는 부모가 자식에게 넘겨주는 값이다.
초보자 기준으로 쉽게 말하면, 부모가 자식에게 건네주는 데이터 꾸러미라고 생각하면 된다.


React에서는 부모가 자식에게 데이터를 전달할 때 props를 사용한다.
예를 들어 App이 user 정보를 가지고 있고, 가장 아래에 있는 UserProfile 컴포넌트가 그 정보를 필요로 한다고 하자.
그러면 기본 방식에서는 중간 컴포넌트를 계속 거쳐서 user를 내려줘야 한다.


흐름은 아래처럼 볼 수 있다.

// 출력결과
// App
// → Page
// → Layout
// → Sidebar
// → UserProfile

실제로 user 정보가 필요한 컴포넌트는 UserProfile뿐일 수 있다.
그런데 중간에 있는 Page, Layout, Sidebar도 user를 받아서 다시 아래로 넘겨야 한다.


이처럼 데이터를 쓰지도 않는 컴포넌트가 단지 전달만 하기 위해 props를 받는 상황이 Props Drilling이다.
Drilling은 땅을 뚫고 아래로 내려가는 느낌을 가진 단어이다.
데이터가 컴포넌트 트리를 따라 계속 아래로 뚫고 내려가는 모습과 비슷하다고 이해하면 된다.

왜 문제가 되는가

Props Drilling 자체가 무조건 잘못된 방식은 아니다.
부모에서 자식으로 데이터를 내려주는 것은 React의 기본 흐름이다.
문제는 데이터 전달 단계가 너무 많아질 때 생긴다.


컴포넌트 깊이가 얕을 때는 props 전달이 오히려 이해하기 쉽다.
예를 들어 부모 → 자식 → 손자 정도라면 데이터가 어디서 와서 어디로 가는지 눈으로 추적하기 쉽다.


하지만 깊이가 5단계, 10단계처럼 길어지면 상황이 달라진다.
중간 컴포넌트들이 실제로는 데이터를 쓰지 않는데도 계속 전달만 해야 한다.
이러면 코드가 복잡해지고, 수정할 때 실수할 가능성이 커진다.


Props Drilling은 상위 컴포넌트의 데이터를 여러 중간 컴포넌트를 거쳐 하위 컴포넌트에 전달하는 구조이다.
중간 컴포넌트가 그 데이터를 직접 사용하지 않아도 전달자 역할을 해야 할 수 있다.

App에서 시작한 데이터가 Page, Layout, Sidebar를 지나 UserProfile까지 내려간다.
핵심은 중간에 있는 컴포넌트들이 반드시 그 데이터를 필요로 하는 것은 아니라는 점이다.
Page나 Layout은 user 정보를 화면에 사용하지 않을 수 있다.
하지만 가장 아래의 UserProfile에게 전달하려면 중간에서 계속 props를 받아 다시 넘겨야 한다.
이 구조가 깊어질수록 데이터 흐름을 추적하기 어려워지고, 중간 컴포넌트의 코드도 불필요하게 복잡해질 수 있다.


3-2. React의 단방향 데이터 흐름과 Props Drilling

단방향 데이터 흐름

React의 데이터는 기본적으로 부모에서 자식으로 내려간다.
이것을 단방향 데이터 흐름이라고 한다.
단방향이라는 말은 데이터가 한 방향으로 흐른다는 뜻이다.


예를 들어 부모 컴포넌트가 user라는 값을 가지고 있으면, 자식 컴포넌트는 그 값을 props로 받을 수 있다.
하지만 자식이 부모의 값을 직접 마음대로 바꾸는 구조는 아니다.


이 흐름은 장점이 있다.
데이터가 어디서 시작해서 어디로 내려가는지 비교적 예측하기 쉽다.
부모가 값을 가지고 있고, 자식은 그 값을 받아서 화면에 사용하는 구조이기 때문이다.


간단한 예시는 아래와 같다.

// PropsBasicFlow.jsx
function Parent() {
  // 부모가 user 데이터를 가지고 있다.
  const user = { name: "둘리" };

  // 자식에게 user를 props로 전달한다.
  return <Child user={user} />;
}

function Child({ user }) {
  // 자식은 받은 user를 화면에 출력한다.
  return <p>{user.name}</p>;
}

이 예제에서는 Parent가 user를 가지고 있고, Child는 user를 받아서 화면에 보여준다.
데이터가 부모에서 자식으로 내려가는 흐름이 분명하다.

깊은 자식에게 데이터를 전달하는 문제

문제는 자식이 한 단계가 아닐 때 생긴다.
데이터를 실제로 사용하는 컴포넌트가 매우 깊은 곳에 있으면 중간 컴포넌트를 계속 거쳐야 한다.


예를 들어 Profile만 user 정보가 필요하다고 하자.
그런데 구조가 아래처럼 되어 있다.

// 출력결과
// App
// → Layout
// → Header
// → UserMenu
// → Profile

Profile에게 user를 전달하려면 App에서 Layout, Header, UserMenu를 거쳐야 한다.
이 중간 컴포넌트들이 user를 실제로 사용하지 않아도, 단지 아래로 넘기기 위해 props를 받아야 한다.


이때 코드가 아래처럼 길어질 수 있다.

// PropsDrillingExample.jsx
function App() {
  // 최상위 컴포넌트가 user 데이터를 가지고 있다.
  const user = { name: "둘리" };

  // Layout에게 user를 전달한다.
  return <Layout user={user} />;
}

function Layout({ user }) {
  // Layout은 user를 사용하지 않고 Header에게 넘긴다.
  return <Header user={user} />;
}

function Header({ user }) {
  // Header도 user를 사용하지 않고 UserMenu에게 넘긴다.
  return <UserMenu user={user} />;
}

function UserMenu({ user }) {
  // UserMenu도 user를 사용하지 않고 Profile에게 넘긴다.
  return <Profile user={user} />;
}

function Profile({ user }) {
  // 실제로 user를 사용하는 컴포넌트이다.
  return <p>{user.name}</p>;
}

이 코드에서 실제로 user를 사용하는 곳은 Profile이다.
하지만 Layout, Header, UserMenu가 모두 user를 받아서 다시 넘긴다.
이런 구조가 길어지면 Props Drilling 문제가 커진다.

얕은 구조에서는 나쁜 방식이 아니다

Props Drilling은 무조건 나쁜 방식이 아니다.
컴포넌트 깊이가 2~3단계 정도로 얕다면 props 전달은 가장 단순하고 직관적인 방법이다.


오히려 작은 구조에서는 Context API나 전역 상태 관리 도구를 쓰는 것이 더 복잡할 수 있다.
데이터가 어디서 내려오는지 바로 보이는 props 방식이 더 읽기 쉬울 때도 많다.


따라서 중요한 기준은 “props를 쓰면 안 된다”가 아니다.
중요한 기준은 데이터를 쓰지도 않는 중간 컴포넌트가 기계적으로 props를 전달하고 있는지 확인하는 것이다.
그런 상황이 반복되면 다른 해결 방법을 고민할 수 있다.


3-3. Props Drilling이 문제가 되는 상황

가독성 저하

Props Drilling이 깊어지면 코드 가독성이 떨어진다.
가독성은 코드를 읽고 이해하기 쉬운 정도를 말한다.


예를 들어 어떤 컴포넌트에서 user라는 props를 받고 있다고 하자.
그런데 그 컴포넌트 안에서는 user를 사용하지 않고, 다시 자식 컴포넌트로 넘기기만 한다.
이런 컴포넌트가 여러 개 있으면 user가 어디서 시작했고 어디에서 실제로 쓰이는지 추적하기 어려워진다.


초보자 입장에서는 아래와 같은 의문이 생길 수 있다.

  • 이 props는 어디서 온 값인가?
  • 이 컴포넌트에서 실제로 쓰이는 값인가?
  • 그냥 아래로 넘기기만 하는 값인가?
  • 최종적으로 어느 컴포넌트에서 쓰이는가?

이 질문에 답하려면 컴포넌트 파일을 여러 개 열어 봐야 한다.
프로젝트가 커질수록 이런 추적 비용이 커진다.

유지보수 어려움

Props Drilling은 유지보수도 어렵게 만든다.
유지보수는 코드를 수정하거나 기능을 바꾸는 작업을 말한다.


예를 들어 최상위 컴포넌트에서 userInfo라는 이름으로 전달하던 값을 userAccount로 바꾼다고 하자.
그 값이 여러 중간 컴포넌트를 거쳐 전달되고 있었다면, 중간 컴포넌트의 props 이름도 함께 수정해야 할 수 있다.


흐름은 아래처럼 번거로워진다.

// 출력결과
// App의 userInfo 이름 변경
// → Layout의 props 이름 수정
// → Header의 props 이름 수정
// → UserMenu의 props 이름 수정
// → Profile의 props 이름 수정

중간에 하나라도 수정하지 않으면 화면이 깨지거나 값이 전달되지 않는 문제가 생길 수 있다.


데이터 구조가 바뀔 때도 마찬가지이다.
예를 들어 user.name을 쓰던 구조가 user.profile.name으로 바뀌면, 관련된 여러 컴포넌트를 같이 확인해야 한다.

불필요한 리렌더링 가능성

Props Drilling 구조에서는 데이터를 직접 쓰지 않는 중간 컴포넌트도 props를 받는다.
이때 설계를 잘못하면 중간 컴포넌트들까지 불필요하게 다시 렌더링될 수 있다.


렌더링은 컴포넌트가 실행되어 화면 구조를 다시 만드는 과정이다.
데이터가 바뀌면 그 데이터를 받는 컴포넌트가 다시 렌더링될 수 있다.


물론 React는 여러 최적화 방법을 가지고 있다.
하지만 데이터 전달 구조가 불필요하게 길면, 변경 영향 범위를 파악하기 어려워진다.
중간 컴포넌트가 실제로 데이터를 사용하지 않는데도 props를 전달한다면 구조 자체를 다시 생각해 볼 필요가 있다.

문제가 되는 순간 정리

Props Drilling이 문제가 되는 대표 상황은 아래와 같다.

  • 중간 컴포넌트가 데이터를 쓰지 않고 전달만 한다.
  • 같은 props가 여러 단계에 걸쳐 반복해서 전달된다.
  • props 이름이나 구조가 바뀔 때 수정해야 할 컴포넌트가 너무 많다.
  • 실제로 어디에서 데이터가 사용되는지 추적하기 어렵다.

이런 상황이 반복되면 Context API, 전역 상태 관리, Component Composition 같은 해결 방법을 고려할 수 있다.


3-4. 해결 방법 1: Context API와 전역 상태 관리

Context API

Context API는 React에 내장된 기능이다.
깊은 하위 컴포넌트에 공통 데이터를 전달할 때 사용할 수 있다.


기본 props 방식은 부모에서 자식으로 한 단계씩 값을 넘긴다.
반면 Context API를 사용하면 중간 컴포넌트를 계속 거치지 않고, 필요한 하위 컴포넌트가 값을 직접 꺼내 쓸 수 있다.


초보자 기준으로 쉽게 말하면, Context API는 공용 보관함을 만드는 방식에 가깝다.
상위에서 보관함에 값을 넣어 두면, 아래에 있는 필요한 컴포넌트가 그 값을 꺼내 쓰는 구조이다.


흐름은 아래처럼 볼 수 있다.

// 출력결과
// Provider가 공통 데이터 제공
// → 중간 컴포넌트는 전달할 필요 없음
// → 필요한 하위 컴포넌트가 useContext()로 직접 사용

여기서 Provider는 값을 공급하는 역할이다.
useContext()는 공급된 값을 꺼내 쓰는 역할이다.

전역 상태 관리 도구

프로젝트가 더 커지면 Context API만으로도 복잡해질 수 있다.
특히 로그인 사용자 정보, 장바구니, 권한, 알림, 다크모드처럼 여러 페이지에서 공유되는 상태가 많아질 수 있다.


이런 경우에는 전역 상태 관리 도구를 사용할 수 있다.
전역 상태는 앱 전체에서 여러 컴포넌트가 함께 사용하는 상태를 말한다.


대표적인 도구는 아래와 같다.

  • Zustand
  • Jotai
  • Redux Toolkit

이런 도구들은 데이터를 컴포넌트 트리 밖의 저장 공간에 보관하는 방식으로 사용할 수 있다.
이 저장 공간을 흔히 store라고 부른다.
store는 여러 컴포넌트가 함께 접근할 수 있는 상태 저장소라고 이해하면 된다.


Context API와 전역 상태 관리 도구의 공통 목적은 중간 컴포넌트가 불필요하게 props를 계속 전달하지 않도록 돕는 것이다.
하지만 모든 상황에 바로 도입할 필요는 없다.
작은 구조에서는 단순한 props 전달이 더 좋은 선택일 수 있다.


3-5. 해결 방법 2: Component Composition

기본 의미

Component Composition은 컴포넌트 합성이라고 한다.
컴포넌트를 조립해서 원하는 구조를 만드는 방식이다.


Props Drilling을 줄이는 방법 중 하나는 데이터를 중간 컴포넌트에 계속 넘기지 않고, 상위에서 필요한 구조를 미리 조립해서 내려보내는 것이다.
이때 자주 사용하는 것이 children이다.


children은 컴포넌트 태그 사이에 들어오는 내용이다.
초보자 기준으로 쉽게 말하면, 부모 컴포넌트가 감싸고 있는 안쪽 내용이다.

기존 Drilling 방식

먼저 Props Drilling이 발생하는 기존 방식부터 보자.

// PropsDrillingCompositionBefore.jsx
function Parent({ user }) {
  // Parent는 user가 필요 없지만 Child에게 넘기기 위해 받는다.
  return <Child user={user} />;
}

function Child({ user }) {
  // Child도 user가 필요 없지만 GrandChild에게 넘기기 위해 받는다.
  return <GrandChild user={user} />;
}

function GrandChild({ user }) {
  // 실제로 user를 사용하는 컴포넌트이다.
  return <p>{user.name}</p>;
}

이 구조에서는 Parent와 Child가 user를 실제로 사용하지 않는다.
하지만 GrandChild에게 전달하기 위해 어쩔 수 없이 user를 받아야 한다.


이런 구조가 길어질수록 중간 컴포넌트는 전달자 역할만 하게 된다.

Component Composition 방식

Component Composition을 사용하면 최상위에서 필요한 구조를 직접 조립해서 내려보낼 수 있다.

// PropsDrillingCompositionAfter.jsx
function Parent({ children }) {
  // Parent는 user의 존재를 몰라도 된다.
  return <div className="parent-box">{children}</div>;
}

function Child({ children }) {
  // Child도 user의 존재를 몰라도 된다.
  return <div className="child-box">{children}</div>;
}

function GrandChild({ user }) {
  // 실제로 user를 사용하는 컴포넌트이다.
  return <p>{user.name}</p>;
}

function App() {
  // 최상위에서 필요한 구조를 조립한다.
  const user = { name: "둘리" };

  return (
    <Parent>
      <Child>
        <GrandChild user={user} />
      </Child>
    </Parent>
  );
}

이 방식에서는 Parent와 Child가 user를 받을 필요가 없다.
user는 실제로 필요한 GrandChild에만 전달된다.


즉, 중간 컴포넌트는 데이터를 전달하는 역할에서 벗어난다.
중간 컴포넌트는 자신이 맡은 레이아웃이나 감싸는 구조에만 집중할 수 있다.


Component Composition을 사용하면 중간 컴포넌트가 필요 없는 props를 전달하지 않아도 된다.
상위 컴포넌트가 필요한 구조를 미리 조립하고, 중간 컴포넌트는 children을 화면에 배치하는 역할만 맡을 수 있다.

기존 방식에서는 Parent와 Child가 user를 실제로 사용하지 않아도 계속 받아서 아래로 넘긴다.
그래서 중간 컴포넌트가 데이터 전달자 역할을 하게 된다.
반면 Component Composition 방식에서는 최상위에서 <GrandChild user={user} />를 직접 조립해 <Child>와 <Parent> 안에 넣는다.
이렇게 하면 Parent와 Child는 user라는 데이터가 있는지도 몰라도 된다.
중간 컴포넌트는 불필요한 props 전달에서 벗어나고, 실제 데이터를 쓰는 컴포넌트만 필요한 값을 받는다.

Component Composition을 쓰면 좋은 상황

Component Composition은 아래 상황에서 유용하다.

  • 중간 컴포넌트가 데이터를 사용하지 않고 전달만 할 때
  • 레이아웃 컴포넌트가 단순히 화면을 감싸는 역할만 할 때
  • 상위에서 화면 구조를 조립하는 편이 더 읽기 쉬울 때

다만 모든 Props Drilling을 무조건 Component Composition으로 해결해야 하는 것은 아니다.
데이터가 여러 곳에서 자주 공유되는 상태라면 Context API나 전역 상태 관리가 더 적합할 수 있다.


3-6. 해결 방법 3: 서버 상태 관리 라이브러리

서버 데이터라면 다르게 생각하기

모든 데이터가 클라이언트 내부에서만 만들어지는 것은 아니다.
많은 데이터는 서버에서 가져온다.
예를 들어 아래와 같은 데이터가 있다.

  • 로그인 사용자 정보
  • 상품 목록
  • 게시글 목록
  • 댓글 목록
  • 알림 목록

이런 데이터는 서버에서 받아오는 데이터이므로 서버 상태라고 볼 수 있다.
서버 상태는 클라이언트 컴포넌트 안에서만 관리하는 일반 state와 성격이 다르다.


예를 들어 상품 목록은 서버에 저장되어 있고, 여러 컴포넌트에서 같은 상품 목록이 필요할 수 있다.
이때 컴포넌트마다 직접 요청을 보내면 같은 데이터를 여러 번 요청하게 될 수 있다.

TanStack Query

TanStack Query는 서버 상태를 관리할 때 자주 사용하는 라이브러리이다.
예전 이름인 React Query로도 많이 알려져 있다.


TanStack Query는 서버에서 가져온 데이터를 캐싱할 수 있다.
cache는 한 번 가져온 데이터를 잠시 저장해 두는 공간이라고 이해하면 된다.


예를 들어 상품 목록을 한 번 요청했다면, 다른 컴포넌트에서 같은 상품 목록이 필요할 때 다시 처음부터 요청하지 않고 저장된 데이터를 활용할 수 있다.
이렇게 하면 여러 컴포넌트가 같은 데이터를 더 효율적으로 공유할 수 있다.


흐름은 아래처럼 이해할 수 있다.

// 출력결과
// 서버에서 상품 목록 요청
// → TanStack Query가 데이터 캐싱
// → 다른 컴포넌트도 같은 쿼리로 데이터 사용
// → 불필요한 중복 요청 감소

Props Drilling되는 데이터가 서버에서 가져온 데이터라면, 무조건 Context API로 감싸기보다 서버 상태 관리 도구를 고려할 수 있다.
특히 같은 서버 데이터를 여러 화면이나 여러 컴포넌트에서 반복해서 사용한다면 TanStack Query 같은 도구가 더 적합할 수 있다.

Context API와 TanStack Query의 차이

Context API는 공통 값을 하위 컴포넌트에 전달하는 데 초점이 있다.
TanStack Query는 서버에서 데이터를 가져오고, 캐싱하고, 요청 상태를 관리하는 데 초점이 있다.


쉽게 비교하면 아래와 같다.

구분Context APITanStack Query
중심 역할공통 데이터 전달서버 데이터 관리
주 사용 상황테마, 로그인 정보, 전역 설정상품 목록, 게시글, 댓글 등 서버 데이터
핵심 기능Provider로 값 제공데이터 요청, 캐싱, 갱신
해결하는 문제깊은 props 전달 줄이기서버 데이터 중복 요청과 상태 관리

둘 다 Props Drilling을 줄이는 데 도움이 될 수 있다.
하지만 해결하려는 문제의 성격이 다르므로 데이터가 어디서 오는지 먼저 확인해야 한다.


3-7. Props Drilling 정리와 도입 기준

Props Drilling은 무조건 나쁜 것이 아니다

Props Drilling은 무조건 나쁜 방식이 아니다.
컴포넌트 깊이가 얕을 때는 오히려 가장 단순하고 좋은 방법일 수 있다.


예를 들어 부모에서 자식으로 한두 단계 데이터를 내려주는 상황이라면 props가 가장 명확하다.
코드를 읽는 사람도 “부모가 이 값을 자식에게 내려주는구나”라고 바로 이해할 수 있다.


문제는 깊이가 깊어지고, 중간 컴포넌트가 데이터를 사용하지 않는데도 전달만 하게 될 때이다.
그때부터 Props Drilling이 불편한 구조가 된다.

도구 도입을 고민해야 하는 순간

아래 상황이 반복되면 Context API나 전역 상태 관리 도구를 고민할 수 있다.

  • 데이터를 사용하지 않는 컴포넌트가 props를 계속 전달한다.
  • 같은 데이터가 여러 깊은 컴포넌트에서 필요하다.
  • props 이름을 바꾸면 여러 중간 컴포넌트를 함께 수정해야 한다.
  • 데이터 흐름을 추적하기 위해 너무 많은 파일을 열어야 한다.

“데이터를 쓰지도 않는 컴포넌트에 기계적으로 props를 복사해서 넘기고 있다”는 느낌이 들면 구조를 다시 볼 타이밍이다.
그때 Context API, Zustand, Jotai, Redux Toolkit, Component Composition, TanStack Query 같은 방법을 상황에 맞게 선택할 수 있다.

해결 방법 선택 기준

상황별로 대략 아래처럼 생각할 수 있다.

상황고려할 방법
한두 단계만 내려가면 됨그냥 props 사용
중간 컴포넌트가 전달만 함Context API 또는 Component Composition
앱 전체에서 공유하는 상태가 많음Zustand, Jotai, Redux Toolkit
서버에서 가져온 데이터를 여러 곳에서 씀TanStack Query

중요한 것은 도구 이름을 외우는 것이 아니다.
데이터가 어디서 만들어지고, 누가 쓰고, 얼마나 멀리 전달되는지 먼저 판단해야 한다.


3-8. Context API 활용 예제

예제 목표

이번 예제는 Context API로 테마 상태를 공유하는 흐름을 보여준다.
isDarkMode는 현재 화면이 다크 모드인지 라이트 모드인지 저장하는 state이다.
처음 값은 false이고, 이 예제에서는 false가 라이트 모드를 의미한다.


toggleTheme은 isDarkMode 값을 반대로 바꾸는 함수이다.
처음 값이 false이면 버튼 클릭 후 true가 되고, 다시 클릭하면 true가 false로 바뀐다.


핵심 목표는 아래와 같다.

  • 상위 컴포넌트에서 isDarkMode 상태를 만든다.
  • Provider가 { isDarkMode, toggleTheme }를 하위 컴포넌트에 제공한다.
  • 중간 컴포넌트는 테마 관련 props를 받지 않는다.
  • 가장 아래 컴포넌트가 useContext()로 isDarkMode와 toggleTheme을 직접 꺼낸다.
  • 버튼 클릭으로 false → true 또는 true → false 값 변경 흐름을 확인한다.

이 예제의 핵심은 Context API가 값을 대신 바꿔 주는 것이 아니라, Provider가 제공한 값을 필요한 컴포넌트가 직접 꺼내 쓰게 해 준다는 점이다.


전체 코드

// EduApp6.jsx
import React, { createContext, useContext, useState } from "react";

// 테마 데이터를 담을 Context를 만든다.
const ThemeContext = createContext();

export default function ContextTest() {
  // 처음에는 false이므로 라이트 모드 상태이다.
  const [isDarkMode, setIsDarkMode] = useState(false);

  // 이전 값을 반대로 바꾼다.
  const toggleTheme = () => {
    setIsDarkMode((prev) => !prev);
  };

  return (
    <ThemeContext.Provider value={{ isDarkMode, toggleTheme }}>
      <div style={{
        padding: "20px",
        minHeight: "100vh",
        backgroundColor: isDarkMode ? "#222" : "#fff",
        color: isDarkMode ? "#fff" : "#000",
        transition: "all 0.3s ease"
      }}>
        <h1>🌓 Context API 테마 테스트</h1>
        <Header />
        <Content />
      </div>
    </ThemeContext.Provider>
  );
}

function Header() {
  // 테마 props를 받지 않는다.
  return (
    <header style={{ borderBottom: "1px solid #ccc", padding: "10px 0" }}>
      <h3>상단 네비게이션 바 (Props Drilling 없음)</h3>
    </header>
  );
}

function Content() {
  // ThemeButton만 렌더링한다.
  return (
    <div style={{ marginTop: "20px" }}>
      <p>본문 내용 영역입니다. 아래 컴포넌트에서 전역 상태를 제어합니다.</p>
      <ThemeButton />
    </div>
  );
}

function ThemeButton() {
  // Context에서 현재 테마 상태와 변경 함수를 꺼낸다.
  const { isDarkMode, toggleTheme } = useContext(ThemeContext);

  return (
    <div style={{
      padding: "15px",
      border: "1px dashed #66dbf6",
      borderRadius: "8px",
      marginTop: "20px",
      display: "inline-block"
    }}>
      <p>현재 설정된 테마: <strong>{isDarkMode ? "다크 모드 🌙" : "라이트 모드 ☀️"}</strong></p>

      <button
        onClick={toggleTheme}
        style={{
          padding: "8px 16px",
          cursor: "pointer",
          backgroundColor: isDarkMode ? "#fff" : "#222",
          color: isDarkMode ? "#222" : "#fff",
          border: "none",
          borderRadius: "4px",
          fontWeight: "bold"
        }}

        테마 변경하기
      </button>
    </div>
  );
}

이 전체 코드는 상위 컴포넌트인 ContextTest에서 테마 상태를 만들고, ThemeContext.Provider로 하위 컴포넌트에 값을 제공하는 구조이다.
Header와 Content는 중간 컴포넌트이지만 테마 값을 전달하지 않는다.
실제로 테마 값을 사용하는 컴포넌트는 ThemeButton이다.


코드 구조 먼저 보기

이 예제는 크게 네 부분으로 나눌 수 있다.

  • ThemeContext
  • ContextTest
  • Header와 Content
  • ThemeButton

ThemeContext는 테마 데이터를 담을 공통 상자이다.
ContextTest는 isDarkMode 상태와 toggleTheme 함수를 만든다.
Header와 Content는 중간 컴포넌트이다.
ThemeButton은 실제로 Context 값을 꺼내 쓰고, 버튼 클릭으로 테마 상태를 바꾸는 컴포넌트이다.


여기서 가장 중요한 값 흐름은 아래와 같다.

// 출력결과
// 처음 상태
// isDarkMode = false
// false → 라이트 모드
//
// Provider가 제공하는 값
// { isDarkMode: false, toggleTheme }
//
// ThemeButton이 읽는 값
// isDarkMode = false
//
// 화면 출력
// 현재 설정된 테마: 라이트 모드 ☀️

처음에는 isDarkMode가 false이다.
그래서 Provider는 { isDarkMode: false, toggleTheme }를 하위 컴포넌트에 제공한다.
ThemeButton은 useContext()로 이 값을 꺼내고, 화면에는 라이트 모드 ☀️가 출력된다.


1단계: Context 만들기

먼저 공통 데이터를 담을 Context를 만든다.
createContext()는 공통 데이터를 담을 빈 상자를 만드는 함수라고 이해하면 된다.

// ContextCreateFlow.jsx
import React, { createContext } from "react";

// 테마 데이터를 담을 Context를 만든다.
const ThemeContext = createContext();

ThemeContext는 테마 값을 담을 공용 상자이다.
아직 실제 테마 값이 들어간 것은 아니다.
실제 값은 나중에 Provider의 value를 통해 공급된다.


이 단계의 흐름은 아래처럼 볼 수 있다.

// 출력결과
// 1. createContext() 실행
// 2. ThemeContext 생성
// 3. 아직 isDarkMode 값은 들어 있지 않음
// 4. 나중에 Provider가 실제 값을 넣어 줌

즉, ThemeContext는 값을 직접 만드는 곳이 아니다.
Context는 값을 담을 통로이고, 실제 값은 Provider가 공급한다.


2단계: 상위 컴포넌트에서 상태 만들기

이제 상위 컴포넌트인 ContextTest에서 isDarkMode 상태를 만든다.

// ContextStateFlow.jsx
function ContextTest() {
  // 처음 값은 false이다.
  const [isDarkMode, setIsDarkMode] = useState(false);

  // 실제 화면 코드는 아래에서 작성한다.
  return null;
}

useState(false)는 isDarkMode의 처음 값을 false로 정한다.
이 예제에서 false는 다크 모드가 꺼진 상태이다.
따라서 처음 화면은 라이트 모드로 시작한다.


값 흐름은 아래와 같다.

// 출력결과
// useState(false) 실행
// ↓
// isDarkMode = false
// ↓
// false는 라이트 모드 상태

즉, 처음에는 아래처럼 이해하면 된다.

값의미
isDarkMode = false라이트 모드
isDarkMode = true다크 모드

이 기준을 먼저 잡아야 뒤에서 toggleTheme이 값을 어떻게 바꾸는지 이해하기 쉽다.


3단계: toggleTheme 함수 만들기

toggleTheme은 현재 테마 상태를 반대로 바꾸는 함수이다.

// ToggleThemeFlow.jsx
function ContextTest() {
  // 처음에는 false이다.
  const [isDarkMode, setIsDarkMode] = useState(false);

  // 이전 값을 반대로 바꾼다.
  const toggleTheme = () => {
    setIsDarkMode((prev) => !prev);
  };

  return null;
}

여기서 prev는 상태가 바뀌기 전의 이전 값이다.
처음 isDarkMode가 false라면, 첫 번째 클릭 시 prev도 false이다.


!prev는 prev의 반대값을 만든다.
따라서 prev가 false이면 !prev는 true가 된다.
반대로 prev가 true이면 !prev는 false가 된다.


첫 번째 클릭 흐름은 아래와 같다.

// 출력결과
// 클릭 전
// isDarkMode = false
//
// 버튼 클릭
// toggleTheme 실행
//
// setIsDarkMode((prev) => !prev)
// prev = false
// !prev = true
//
// 클릭 후
// isDarkMode = true

두 번째 클릭 흐름은 아래와 같다.

// 출력결과
// 클릭 전
// isDarkMode = true
//
// 버튼 클릭
// toggleTheme 실행
//
// setIsDarkMode((prev) => !prev)
// prev = true
// !prev = false
//
// 클릭 후
// isDarkMode = false

정리하면 toggleTheme은 값을 아래처럼 반복해서 바꾼다.

// 출력결과
// false → true → false → true
// 라이트 모드 → 다크 모드 → 라이트 모드 → 다크 모드

toggleTheme의 핵심은 현재 값을 직접 외워서 바꾸는 것이 아니라, 이전 값 prev를 기준으로 반대값을 만드는 것이다.


4단계: Provider로 값 공급하기

상태와 함수가 준비되면 Provider로 하위 컴포넌트에 값을 공급한다.

// ContextProviderFlow.jsx
<ThemeContext.Provider value={{ isDarkMode, toggleTheme }}>
  <Header />
  <Content />
</ThemeContext.Provider>

value={{ isDarkMode, toggleTheme }}는 하위 컴포넌트에 두 가지를 제공한다는 뜻이다.
하나는 현재 테마 상태인 isDarkMode이고, 다른 하나는 테마를 바꾸는 함수인 toggleTheme이다.


처음 렌더링될 때 isDarkMode는 false이다.
그래서 Provider가 제공하는 값은 아래처럼 볼 수 있다.

// 출력결과
// 처음 Provider value
// {
//   isDarkMode: false,
//   toggleTheme: 테마를 바꾸는 함수
// }

버튼을 클릭해서 isDarkMode가 true로 바뀌면 Provider가 제공하는 값도 함께 바뀐다.

// 출력결과
// 버튼 클릭 후 Provider value
// {
//   isDarkMode: true,
//   toggleTheme: 테마를 바꾸는 함수
// }

여기서 중요한 점은 Provider가 값을 한 번만 주고 끝나는 것이 아니라는 점이다.
상태가 바뀌면 ContextTest가 다시 렌더링되고, Provider의 value도 현재 상태에 맞게 다시 제공된다.


Provider는 현재 state 값을 하위 컴포넌트가 읽을 수 있게 공급하는 역할을 한다.


5단계: 중간 컴포넌트는 props를 받지 않는다

Header와 Content는 중간 컴포넌트 역할을 한다.
하지만 이 컴포넌트들은 테마 값을 직접 사용하지 않는다.
그래서 테마 관련 props를 받을 필요가 없다.

// ContextMiddleComponentsFlow.jsx
function Header() {
  // 테마 관련 props를 받지 않는다.
  return (
    <header>
      <h3>상단 네비게이션 바 (Props Drilling 없음)</h3>
    </header>
  );
}

function Content() {
  // ThemeButton만 렌더링하고 테마 props는 전달하지 않는다.
  return (
    <div>
      <p>본문 내용 영역입니다. 아래 컴포넌트에서 전역 상태를 제어합니다.</p>
      <ThemeButton />
    </div>
  );
}

Header는 단순히 상단 영역을 보여준다.
Content는 본문 영역을 보여주고, 그 안에 ThemeButton을 배치한다.


여기서 Content가 ThemeButton에게 isDarkMode나 toggleTheme을 넘기지 않는다는 점이 중요하다.
ThemeButton은 props로 값을 받는 것이 아니라, 직접 Context에서 값을 꺼낸다.


기존 props 전달 방식이라면 흐름이 아래처럼 될 수 있다.

// 출력결과
// ContextTest
// → Content에게 isDarkMode 전달
// → Content가 ThemeButton에게 isDarkMode 다시 전달
// → ThemeButton이 사용

하지만 Context API를 사용하면 흐름이 아래처럼 바뀐다.

// 출력결과
// ContextTest의 Provider가 값 제공
// → Content는 전달하지 않음
// → ThemeButton이 useContext()로 직접 꺼냄

이 구조 덕분에 중간 컴포넌트가 단순 전달자 역할을 하지 않아도 된다.


6단계: useContext로 값 꺼내기

ThemeButton은 실제로 테마 상태를 읽고 버튼을 눌러 테마를 바꾸는 컴포넌트이다.
이 컴포넌트는 useContext()를 사용해서 ThemeContext에 들어 있는 값을 꺼낸다.

// ContextUseContextFlow.jsx
function ThemeButton() {
  // Provider가 공급한 값 중 필요한 값을 꺼낸다.
  const { isDarkMode, toggleTheme } = useContext(ThemeContext);

  // 버튼 클릭 시 toggleTheme이 실행된다.
  return (
    <button onClick={toggleTheme}>
      테마 변경하기
    </button>
  );
}

useContext(ThemeContext)는 ThemeContext.Provider가 공급한 값을 꺼낸다.
처음에는 Provider가 { isDarkMode: false, toggleTheme }를 제공한다.
그래서 ThemeButton은 처음에 isDarkMode = false를 읽는다.


처음 화면 흐름은 아래와 같다.

// 출력결과
// Provider value
// { isDarkMode: false, toggleTheme }
//
// ThemeButton이 useContext()로 읽은 값
// isDarkMode = false
//
// 화면 출력
// 현재 설정된 테마: 라이트 모드 ☀️

이후 버튼을 클릭하면 toggleTheme이 실행된다.
그러면 isDarkMode가 false에서 true로 바뀐다.


버튼 클릭 후 흐름은 아래와 같다.

// 출력결과
// 버튼 클릭
// ↓
// toggleTheme 실행
// ↓
// prev = false
// ↓
// !prev = true
// ↓
// isDarkMode = true
// ↓
// Provider value 변경
// { isDarkMode: true, toggleTheme }
// ↓
// ThemeButton이 변경된 값을 다시 읽음
// ↓
// 화면 출력
// 현재 설정된 테마: 다크 모드 🌙

즉, ThemeButton은 값을 직접 만드는 컴포넌트가 아니다.
Provider가 공급한 현재 값을 읽고, 버튼 클릭 시 함께 공급받은 toggleTheme 함수를 실행한다.


7단계: 화면 색상이 바뀌는 이유

화면의 배경색과 글자색은 isDarkMode 값에 따라 달라진다.

// ThemeStyleFlow.jsx
<div style={{
  backgroundColor: isDarkMode ? "#222" : "#fff",
  color: isDarkMode ? "#fff" : "#000"
}}>
  <Header />
  <Content />
</div>

isDarkMode가 false이면 조건식은 라이트 모드 색상을 선택한다.

// 출력결과
// isDarkMode = false
// backgroundColor = "#fff"
// color = "#000"
// 화면 상태 = 라이트 모드

isDarkMode가 true이면 조건식은 다크 모드 색상을 선택한다.

// 출력결과
// isDarkMode = true
// backgroundColor = "#222"
// color = "#fff"
// 화면 상태 = 다크 모드

버튼 색상도 같은 기준으로 바뀐다.

// ThemeButtonStyleFlow.jsx
<button
  style={{
    backgroundColor: isDarkMode ? "#fff" : "#222",
    color: isDarkMode ? "#222" : "#fff"
  }}

  테마 변경하기
</button>

여기서 중요한 점은 색상을 직접 바꾸는 코드가 따로 실행되는 것이 아니라는 점이다.
isDarkMode 값이 바뀌면 컴포넌트가 다시 렌더링되고, 조건식이 현재 값에 맞는 색상을 다시 선택한다.


화면 색상 변경은 toggleTheme이 직접 색을 바꾸는 것이 아니라, isDarkMode 값이 바뀌면서 조건식 결과가 달라지기 때문에 발생한다.


Context API 예제 전체 흐름

전체 흐름을 한 번에 정리하면 아래와 같다.

// 출력결과
// 1. createContext()로 ThemeContext 생성
// 2. ContextTest에서 isDarkMode 상태 생성
// 3. isDarkMode 초기값은 false
// 4. false는 라이트 모드 상태
// 5. toggleTheme 함수 생성
// 6. Provider가 { isDarkMode: false, toggleTheme } 제공
// 7. Header와 Content는 테마 props를 받지 않음
// 8. ThemeButton이 useContext()로 isDarkMode와 toggleTheme을 꺼냄
// 9. ThemeButton은 isDarkMode=false를 읽고 라이트 모드 출력
// 10. 사용자가 테마 변경하기 버튼 클릭
// 11. toggleTheme 실행
// 12. setIsDarkMode((prev) => !prev) 실행
// 13. prev는 false
// 14. !prev는 true
// 15. isDarkMode가 true로 변경
// 16. ContextTest가 다시 렌더링됨
// 17. Provider가 { isDarkMode: true, toggleTheme } 제공
// 18. ThemeButton이 isDarkMode=true를 다시 읽음
// 19. 화면에 다크 모드 출력
// 20. 배경색, 글자색, 버튼 색이 true 기준으로 변경

이 예제에서 가장 중요한 점은 중간 컴포넌트가 테마 관련 값을 전달하지 않는다는 것이다.
Header와 Content는 isDarkMode를 받지도 않고, toggleTheme을 넘기지도 않는다.


그런데 가장 아래에 있는 ThemeButton은 useContext()로 필요한 값을 직접 꺼내 쓴다.
이것이 Context API가 Props Drilling을 줄이는 방식이다.


최종 흐름은 아래처럼 정리할 수 있다.

// 출력결과
// isDarkMode=false
// → Provider가 false 제공
// → ThemeButton이 false 읽음
// → 라이트 모드 출력
// → 버튼 클릭
// → toggleTheme 실행
// → false가 true로 변경
// → Provider가 true 제공
// → ThemeButton이 true 읽음
// → 다크 모드 출력

Context API의 핵심은 중간 컴포넌트를 거치지 않고, Provider가 제공한 값을 필요한 컴포넌트가 직접 읽게 만드는 것이다.


3세트 최종 정리

Props Drilling은 데이터를 깊은 자식에게 전달하기 위해 중간 컴포넌트들이 계속 props를 전달해야 하는 구조이다.
작은 구조에서는 문제가 되지 않지만, 깊이가 깊어지면 가독성과 유지보수가 어려워진다.


핵심만 정리하면 아래와 같다.

개념핵심 의미
Props Drilling중간 컴포넌트를 거쳐 props를 계속 전달하는 구조
단방향 데이터 흐름React 데이터는 기본적으로 부모에서 자식으로 내려감
Context API중간 전달 없이 하위 컴포넌트가 공통 값을 꺼내 쓰게 하는 기능
Provider하위 컴포넌트가 읽을 값을 공급하는 역할
useContext()Provider가 공급한 값을 꺼내 쓰는 함수
Component Composition상위에서 컴포넌트 구조를 조립해 불필요한 전달을 줄이는 방식
TanStack Query서버 데이터를 가져오고 캐싱해서 여러 컴포넌트에서 공유하는 도구
전역 상태 관리앱 전체에서 공유하는 상태를 별도 저장소로 관리하는 방식

처음에는 props로 단순하게 전달하고, 구조가 깊어져 중간 컴포넌트가 전달만 하게 될 때 Context API나 다른 해결 방법을 고려하는 흐름이 가장 자연스럽다.




React 컴포넌트 통신 패턴

React 컴포넌트는 혼자만 동작하지 않는다.
부모 컴포넌트가 자식 컴포넌트에게 데이터를 내려주기도 하고, 자식 컴포넌트가 부모에게 어떤 일이 일어났는지 알려주기도 한다.
또 서로 멀리 떨어진 컴포넌트가 같은 값을 사용해야 할 때도 있고, 형제 컴포넌트끼리 같은 상태를 함께 써야 할 때도 있다.


컴포넌트 통신은 컴포넌트 사이에서 데이터와 이벤트가 어떤 방향으로 이동하는지 이해하는 개념이다.
이 흐름을 알아야 컴포넌트를 단순히 나누는 것에서 끝나지 않고, 나뉜 컴포넌트들이 어떻게 함께 동작하는지 이해할 수 있다.


여기서 useRef()는 엄밀히 말하면 컴포넌트끼리 데이터를 주고받는 통신 방식은 아니다.
하지만 컴포넌트가 실제 DOM 요소와 연결되어 브라우저 기능을 직접 사용하는 패턴이므로, 컴포넌트가 외부 대상과 연결되는 흐름으로 함께 정리한다.


4-1. 컴포넌트 통신이 필요한 이유

컴포넌트는 나누면 끝이 아니다

React에서는 화면을 여러 컴포넌트로 나눈다.
하지만 컴포넌트를 나누기만 하면 화면이 완성되는 것은 아니다.
나뉜 컴포넌트들이 서로 필요한 데이터를 주고받아야 한다.


예를 들어 쇼핑몰 화면을 생각해 보자.
상품 목록 컴포넌트가 있고, 상품 카드 컴포넌트가 있다고 하자.
상품 목록은 여러 상품 데이터를 가지고 있고, 상품 카드는 상품 하나의 이름과 가격을 화면에 보여준다.


이 경우 상품 목록 컴포넌트는 상품 카드 컴포넌트에게 상품 이름과 가격을 전달해야 한다.
이런 흐름이 바로 컴포넌트 통신이다.

통신 방향을 먼저 봐야 한다

컴포넌트 통신을 이해할 때는 “어떤 문법을 쓰는가”보다 먼저 “데이터가 어느 방향으로 움직이는가”를 봐야 한다.
대표적인 흐름은 아래와 같다.

  • 부모에서 자식으로 데이터를 내려준다.
  • 자식이 부모에게 이벤트 발생을 알려준다.
  • 멀리 떨어진 컴포넌트가 같은 값을 공유한다.
  • 형제 컴포넌트가 공통 부모의 상태를 함께 사용한다.
  • 컴포넌트가 실제 DOM 요소를 직접 제어한다.

이 다섯 가지 흐름을 이해하면 대부분의 기본 컴포넌트 연결 구조를 읽을 수 있다.


핵심은 아래처럼 정리할 수 있다.

통신 패턴핵심 흐름
부모 → 자식props로 데이터 전달
자식 → 부모부모가 내려준 콜백 함수 호출
멀리 떨어진 컴포넌트Context API로 값 공유
형제 컴포넌트공통 부모로 상태 끌어올리기
실제 요소 제어useRef()로 실제 DOM 접근

아래에서 보는 Demo1부터 Demo5 코드는 전체 EduApp7.jsx 안에 들어가는 부분 코드이다.
Section, Btn, Tag 같은 공통 컴포넌트는 뒤의 전체 코드에서 함께 정의된다.
먼저 각 통신 패턴의 흐름을 나누어 이해한 뒤, 마지막에 전체 코드를 한 번에 보면 구조가 더 잘 보인다.


4-2. 부모에서 자식으로 데이터 전달하기

props의 역할

부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달할 때는 props를 사용한다.
props는 부모가 자식에게 넘겨주는 값이다.
초보자 기준으로는 “부모가 자식에게 건네주는 재료”라고 이해하면 된다.


부모는 값을 가지고 있고, 자식은 그 값을 받아 화면에 출력한다.
이 흐름은 React에서 가장 기본적인 데이터 전달 방식이다.


예를 들어 부모가 이름과 나이를 가지고 있고, 자식이 그 값을 화면에 보여준다고 하자.
데이터 흐름은 아래처럼 된다.

// 출력결과
// 부모 컴포넌트
// → name, age 전달
// → 자식 컴포넌트
// → 화면에 이름과 나이 출력

코드 예제

// EduApp7Demo1.jsx
function ChildA({ name, age }) {
  // 부모에게 받은 props를 화면에 출력한다.
  return (
    <div style={{ fontSize: 13, color: "#333" }}>
      👤 이름: <strong>{name}</strong> / 나이: <strong>{age}세</strong>
    </div>
  );
}

function Demo1() {
  // ChildA에게 name과 age를 전달한다.
  return (
    <Section
      num="1"
      color="purple"
      title="Props — 부모 → 자식"
      desc="부모가 name, age를 props로 내려주고 자식이 표시합니다."

      <ChildA name="홍길동" age={25} />
    </Section>
  );
}

Demo1은 부모 역할을 한다.
ChildA는 자식 역할을 한다.


Demo1에서 <ChildA name="홍길동" age={25} />라고 작성했기 때문에 ChildA는 name과 age를 받을 수 있다.
ChildA는 받은 값을 직접 만들지 않는다.
부모가 전달한 값을 받아서 화면에 출력한다.

데이터 흐름

// 출력결과
// 1. Demo1 실행
// 2. ChildA에게 name="홍길동" 전달
// 3. ChildA에게 age={25} 전달
// 4. ChildA가 props를 받음
// 5. 화면에 이름과 나이 출력

이 흐름에서 중요한 점은 데이터 방향이다.
데이터는 부모인 Demo1에서 자식인 ChildA로 내려간다.
자식은 받은 값을 화면에 사용할 수 있지만, 이 구조만으로 부모의 값을 직접 바꾸지는 않는다.

이 화면은 부모 컴포넌트가 자식 컴포넌트에게 props로 데이터를 내려주는 구조를 보여준다.
Demo1은 name과 age 값을 가지고 있고, ChildA는 그 값을 받아 화면에 출력한다.
자식 컴포넌트는 데이터를 직접 만들지 않고, 부모가 내려준 값을 사용한다.
따라서 이 패턴은 부모가 데이터를 가지고 있고 자식이 그 데이터를 보여주는 상황에 적합하다.


부모에서 자식으로 데이터를 보낼 때는 props를 사용한다.


4-3. 자식에서 부모로 이벤트 전달하기

콜백 함수의 역할

React에서는 자식 컴포넌트가 부모의 상태를 직접 바꾸지 않는다.
대신 부모가 함수를 만들어 자식에게 내려준다.
자식은 어떤 일이 일어났을 때 그 함수를 호출한다.


이때 부모가 자식에게 내려주는 함수를 콜백 함수라고 한다.
콜백 함수는 나중에 호출될 함수라는 뜻이다.
초보자 기준으로는 “자식이 부모에게 알려야 할 때 호출하는 부모의 함수”라고 이해하면 된다.


자식에서 부모로 데이터가 올라가는 것처럼 보이지만, 실제 흐름은 아래와 같다.

// 출력결과
// 부모가 state와 state 변경 함수를 가짐
// → 부모가 함수를 자식에게 props로 전달
// → 자식이 버튼 클릭 시 함수 호출
// → 실제로는 부모의 state 변경 함수가 실행됨
// → 부모의 state 값이 변경됨

이 흐름에서 중요한 점은 자식이 부모의 state를 직접 수정하지 않는다는 것이다.
자식은 부모가 내려준 함수를 실행할 뿐이다.
실제 state를 가지고 있고, 실제 값을 바꾸는 곳은 부모 컴포넌트이다.


코드 예제

// EduApp7Demo2.jsx
function ChildB({ onSay }) {
  // 버튼을 누르면 부모가 내려준 함수를 호출한다.
  return (
    <Btn
      color="teal"
      onClick={() => onSay("안녕하세요! 👋")}

      자식 버튼 클릭
    </Btn>
  );
}

function Demo2() {
  // 부모가 메시지 상태를 가진다.
  const [msg, setMsg] = useState("(아직 없음)");

  return (
    <Section
      num="2"
      color="teal"
      title="콜백 함수 — 자식 → 부모"
      desc="자식 버튼을 누르면 onSay가 setMsg를 실행해 부모 msg 값이 바뀝니다."

      <ChildB onSay={setMsg} />
      <div style={{ marginTop: 8, fontSize: 13, color: "#333" }}>
        부모가 받은 메시지: <strong>{msg}</strong>
      </div>
    </Section>
  );
}

Demo2는 부모 역할을 한다.
msg라는 state를 가지고 있고, 처음 값은 "(아직 없음)"이다.


setMsg는 msg 값을 바꾸는 함수이다.
Demo2는 이 setMsg 함수를 ChildB에게 onSay라는 이름으로 내려준다.


즉, 아래 두 이름은 연결되어 있다.

// 출력결과
// onSay = setMsg

ChildB 입장에서는 onSay라는 이름으로 함수를 받는다.
하지만 실제로 실행되는 함수는 부모가 가진 setMsg이다.


처음 화면 상태

처음에는 Demo2의 msg 값이 아래와 같다.

// 출력결과
// msg = "(아직 없음)"

그래서 화면에는 아래처럼 보인다.

// 출력결과
// 부모가 받은 메시지: (아직 없음)

이때 ChildB는 아직 버튼만 보여주고 있다.
부모의 msg 값을 직접 바꾸지는 않는다.


버튼 클릭 후 값이 바뀌는 흐름

자식 버튼을 클릭하면 ChildB 안에서 아래 코드가 실행된다.

// ChildBClickFlow.jsx
onClick={() => onSay("안녕하세요! 👋")}

여기서 onSay는 부모가 내려준 setMsg이다.
따라서 실제 실행 흐름은 아래처럼 볼 수 있다.

// 출력결과
// 1. 자식 버튼 클릭
// 2. onSay("안녕하세요! 👋") 실행
// 3. onSay는 사실 setMsg
// 4. setMsg("안녕하세요! 👋") 실행
// 5. 부모의 msg 값 변경
// 6. msg = "안녕하세요! 👋"

msg 값이 바뀌면 부모 컴포넌트인 Demo2가 다시 렌더링된다.
그 결과 화면에 출력되는 메시지도 바뀐다.

// 출력결과
// 변경 전
// 부모가 받은 메시지: (아직 없음)
//
// 변경 후
// 부모가 받은 메시지: 안녕하세요! 👋

이 흐름을 한 줄로 정리하면 아래와 같다.

// 출력결과
// ChildB 버튼 클릭
// → onSay("안녕하세요! 👋") 실행
// → 실제로는 setMsg("안녕하세요! 👋") 실행
// → Demo2의 msg 값 변경
// → 부모 화면 다시 렌더링

이 화면은 자식 컴포넌트의 버튼 클릭이 부모 컴포넌트의 상태를 바꾸는 흐름을 보여준다.
자식이 부모의 상태를 직접 수정하는 것이 아니라, 부모가 내려준 setMsg 함수를 onSay라는 이름으로 호출한다.
처음에는 부모의 msg 값이 "(아직 없음)"이다.
버튼을 클릭하면 onSay("안녕하세요! 👋")가 실행되고, 실제로는 setMsg("안녕하세요! 👋")가 실행된다.
그 결과 부모의 msg 값이 바뀌고, 부모 영역에 변경된 메시지가 출력된다.


자식에서 부모에게 알릴 때는 부모가 내려준 콜백 함수를 호출하고, 실제 상태 변경은 부모 안에서 일어난다.


4-4. 멀리 떨어진 컴포넌트에서 값 공유하기

Context API가 필요한 상황

부모와 자식이 바로 연결되어 있으면 props로 데이터를 전달하면 된다.
하지만 컴포넌트 사이가 멀리 떨어져 있으면 중간 컴포넌트를 계속 거쳐야 한다.
이때 Props Drilling 문제가 생길 수 있다.


Context API는 이런 상황에서 공통 값을 필요한 컴포넌트가 직접 꺼내 쓸 수 있게 해 준다.
상위에서 Provider로 값을 공급하고, 하위 컴포넌트는 useContext()로 값을 꺼내 쓴다.


여기서 “전역”이라는 말은 앱 전체 어디에서나 무조건 쓸 수 있다는 뜻이 아니다.
Context 값은 Provider가 감싼 하위 범위 안에서 공유된다.
Provider 바깥에 있는 컴포넌트는 그 값을 그대로 사용할 수 없다.


여기서 보는 예제는 구조를 쉽게 보기 위해 단순하게 작성되어 있다.
중요한 것은 컴포넌트가 얼마나 깊이 들어갔는지가 아니라, 중간 컴포넌트가 props를 전달하지 않아도 필요한 컴포넌트가 Context에서 값을 직접 꺼내 쓸 수 있다는 점이다.


코드 예제

// EduApp7Demo3.jsx
const UserContext = createContext(null);

function ChildC() {
  // Context에서 user 값을 직접 꺼낸다.
  const user = useContext(UserContext); // React 19에서는 use(UserContext)도 가능하다.

  return (
    <div style={{ fontSize: 13, color: "#333" }}>
      🌐 Context에서 읽음: <strong>{user.name}</strong> ({user.role})
    </div>
  );
}

function Demo3() {
  // Provider가 공급할 user 상태를 만든다.
  const [user, setUser] = useState({
    name: "유니코",
    role: "일반 사용자",
  });

  return (
    <UserContext.Provider value={user}>
      <Section
        num="3"
        color="blue"
        title="Context — 전역 상태 공유"
        desc="Provider가 감싼 하위 컴포넌트는 props 없이 값을 꺼낼 수 있습니다."

        <ChildC />
        <div style={{ marginTop: 8 }}>
          <Btn
            color="blue"
            onClick={() => setUser({ name: "듀크", role: "관리자" })}

            사용자 변경
          </Btn>
        </div>
      </Section>
    </UserContext.Provider>
  );
}

UserContext는 사용자 정보를 담을 공통 상자이다.
Demo3은 user 상태를 만들고, UserContext.Provider의 value로 공급한다.


ChildC는 props를 받지 않는다.
대신 useContext(UserContext)를 사용해서 Provider가 공급한 user 값을 직접 꺼낸다.
React 19에서는 use(UserContext)처럼 use()로 Context 값을 읽는 방식도 가능하지만, 이 예제에서는 useContext()를 사용한다.
처음 정리할 때는 useContext() 흐름을 기준으로 이해하면 된다.


처음 화면 상태

처음 user 상태는 아래와 같다.

// 출력결과
// user = {
//   name: "유니코",
//   role: "일반 사용자"
// }

Demo3은 이 값을 Provider의 value로 공급한다.

// 출력결과
// Provider value
// {
//   name: "유니코",
//   role: "일반 사용자"
// }

ChildC는 useContext(UserContext)로 이 값을 직접 꺼낸다.
그래서 처음 화면에는 아래처럼 출력된다.

// 출력결과
// Context에서 읽음: 유니코 (일반 사용자)

이때 중간 컴포넌트는 user를 props로 전달하지 않는다.
ChildC가 Context에서 직접 값을 읽는다.


사용자 변경 버튼 클릭 후 흐름

버튼을 클릭하면 아래 코드가 실행된다.

// UserChangeFlow.jsx
onClick={() => setUser({ name: "듀크", role: "관리자" })}

이 코드는 부모 컴포넌트인 Demo3의 user 상태를 새 객체로 바꾼다.


값이 바뀌는 흐름은 아래와 같다.

// 출력결과
// 버튼 클릭 전
// user = { name: "유니코", role: "일반 사용자" }
//
// 사용자 변경 버튼 클릭
// setUser({ name: "듀크", role: "관리자" }) 실행
//
// 버튼 클릭 후
// user = { name: "듀크", role: "관리자" }

user 상태가 바뀌면 Demo3이 다시 렌더링된다.
그러면 Provider가 공급하는 value도 변경된다.

// 출력결과
// 변경 전 Provider value
// { name: "유니코", role: "일반 사용자" }
//
// 변경 후 Provider value
// { name: "듀크", role: "관리자" }

ChildC는 변경된 Provider value를 다시 읽는다.
그래서 화면 출력도 바뀐다.

// 출력결과
// 변경 전 화면
// Context에서 읽음: 유니코 (일반 사용자)
//
// 변경 후 화면
// Context에서 읽음: 듀크 (관리자)

최종 흐름은 아래처럼 정리할 수 있다.

// 출력결과
// user = 유니코 / 일반 사용자
// → Provider가 user 제공
// → ChildC가 useContext()로 user 읽음
// → 화면에 유니코 출력
// → 사용자 변경 버튼 클릭
// → setUser({ name: "듀크", role: "관리자" }) 실행
// → user 값 변경
// → Provider value 변경
// → ChildC가 변경된 user 다시 읽음
// → 화면에 듀크 출력

이 구조에서는 중간 컴포넌트가 user를 계속 전달하지 않아도 된다.
필요한 컴포넌트가 Context에서 값을 직접 꺼내기 때문이다.

이 화면은 Context API로 사용자 정보를 공유하는 흐름을 보여준다.
처음에는 Provider가 { name: "유니코", role: "일반 사용자" } 값을 공급한다.
ChildC는 props 없이 useContext()로 이 값을 읽고, 화면에 유니코 / 일반 사용자를 출력한다.
버튼을 누르면 setUser({ name: "듀크", role: "관리자" })가 실행되어 user 상태가 바뀐다.
상태가 바뀌면 Provider value도 바뀌고, ChildC는 변경된 값을 다시 읽어 듀크 / 관리자를 화면에 출력한다.
단, 이 값은 Provider가 감싼 범위 안에서 공유된다는 점을 함께 기억해야 한다.


멀리 떨어진 컴포넌트가 같은 값을 써야 할 때는 Provider가 값을 공급하고, 필요한 컴포넌트가 useContext()로 직접 꺼내 쓸 수 있다.


4-5. 형제 컴포넌트 연결하기

상태 끌어올리기의 의미

형제 컴포넌트는 서로 직접 부모와 자식 관계가 아니다.
그래서 한 형제 컴포넌트가 다른 형제 컴포넌트의 상태를 직접 바꾸는 구조는 자연스럽지 않다.


이럴 때는 두 형제의 공통 부모가 상태를 가진다.
그리고 한 형제는 상태를 바꾸는 역할을 하고, 다른 형제는 그 상태를 보여주는 역할을 한다.
이 방식을 상태 끌어올리기라고 한다.


상태 끌어올리기는 상태를 더 위쪽 부모 컴포넌트로 올려서 여러 자식이 함께 쓰게 만드는 방식이다.
형제 컴포넌트끼리 직접 통신하는 것이 아니라, 공통 부모의 state를 통해 함께 연결되는 구조이다.


코드 예제

// EduApp7Demo4.jsx
function SiblingA({ selected, onSelect }) {
  // 과일 버튼을 클릭하면 부모의 상태 변경 함수를 호출한다.
  return (
    <div style={{ display: "flex", gap: 6 }}>
      {["사과", "바나나", "포도"].map((item) => (
        <Btn
          key={item}
          color="amber"
          onClick={() => onSelect(item)}

          {selected === item ? "✅ " : ""}
          {item}
        </Btn>
      ))}
    </div>
  );
}

function SiblingB({ selected }) {
  // 부모에게 받은 selected 값을 표시한다.
  return (
    <div style={{ marginTop: 8, fontSize: 13, color: "#333" }}>
      🛒 선택된 과일: <strong>{selected}</strong>
    </div>
  );
}

function Demo4() {
  // 공통 부모가 selected 상태를 가진다.
  const [selected, setSelected] = useState("사과");

  return (
    <Section
      num="4"
      color="amber"
      title="상태 끌어올리기 — 형제 연동"
      desc="형제A가 부모 함수를 호출하면 공통 부모 state가 바뀌고 형제B가 다시 표시합니다."

      <SiblingA selected={selected} onSelect={setSelected} />
      <SiblingB selected={selected} />
    </Section>
  );
}

Demo4는 공통 부모이다.
selected 상태를 가지고 있고, 처음 값은 "사과"이다.


SiblingA는 과일 버튼을 보여준다.
SiblingA는 현재 선택된 값인 selected도 받고, 선택 값을 바꾸는 함수인 setSelected도 onSelect라는 이름으로 받는다.
버튼을 클릭하면 onSelect(item)을 호출한다.


SiblingB는 선택된 과일을 화면에 보여준다.
SiblingA와 SiblingB는 서로 직접 데이터를 주고받지 않는다.
공통 부모인 Demo4를 통해 연결된다.


처음 화면 상태

처음에는 공통 부모인 Demo4가 아래 상태를 가진다.

// 출력결과
// selected = "사과"

Demo4는 이 값을 두 형제 컴포넌트에 각각 내려준다.

// 출력결과
// Demo4
// → SiblingA에게 selected="사과" 전달
// → SiblingA에게 onSelect={setSelected} 전달
// → SiblingB에게 selected="사과" 전달

처음 화면은 아래처럼 볼 수 있다.

// 출력결과
// SiblingA
// 사과 버튼에 ✅ 표시
//
// SiblingB
// 선택된 과일: 사과

즉, 처음에는 selected 값이 "사과"이기 때문에 SiblingA에서는 사과 버튼이 선택된 것처럼 표시되고, SiblingB에서는 선택된 과일로 사과가 출력된다.


바나나 버튼 클릭 후 흐름

이제 SiblingA에서 "바나나" 버튼을 클릭한다고 하자.
그러면 아래 코드가 실행된다.

// SiblingSelectFlow.jsx
onClick={() => onSelect(item)}

바나나 버튼을 클릭했으므로 item은 "바나나"이다.
따라서 실행 흐름은 아래처럼 볼 수 있다.

// 출력결과
// 바나나 버튼 클릭
// → onSelect("바나나") 실행

여기서 onSelect는 부모가 내려준 setSelected이다.
그래서 실제로는 아래 코드가 실행되는 것과 같다.

// 출력결과
// onSelect("바나나")
// = setSelected("바나나")

결과적으로 공통 부모인 Demo4의 selected 값이 바뀐다.

// 출력결과
// 변경 전
// selected = "사과"
//
// 변경 후
// selected = "바나나"

selected 값이 바뀌면 Demo4가 다시 렌더링된다.
그 다음 바뀐 selected 값이 SiblingA와 SiblingB에 다시 전달된다.

// 출력결과
// Demo4 다시 렌더링
// → SiblingA에게 selected="바나나" 전달
// → SiblingB에게 selected="바나나" 전달

그래서 화면도 함께 바뀐다.

// 출력결과
// 변경 전 화면
// SiblingA: 사과 버튼에 ✅ 표시
// SiblingB: 선택된 과일: 사과
//
// 변경 후 화면
// SiblingA: 바나나 버튼에 ✅ 표시
// SiblingB: 선택된 과일: 바나나

최종 흐름은 아래처럼 정리할 수 있다.

// 출력결과
// 1. Demo4가 selected="사과" 상태를 가짐
// 2. SiblingA와 SiblingB가 selected="사과"를 받음
// 3. SiblingA에서 바나나 버튼 클릭
// 4. onSelect("바나나") 실행
// 5. 실제로는 setSelected("바나나") 실행
// 6. Demo4의 selected 값이 "바나나"로 변경
// 7. Demo4가 다시 렌더링됨
// 8. SiblingA와 SiblingB가 selected="바나나"를 다시 받음
// 9. SiblingA는 바나나 버튼에 ✅ 표시
// 10. SiblingB는 선택된 과일: 바나나 출력

형제 컴포넌트끼리 직접 연결하면 구조가 복잡해질 수 있다.
공통 부모가 상태를 가지면 데이터 흐름이 더 명확해진다.

이 화면은 형제 컴포넌트가 공통 부모의 상태를 통해 연결되는 구조를 보여준다.
처음에는 공통 부모인 Demo4가 selected="사과" 상태를 가지고 있다.
그래서 SiblingA는 사과 버튼에 ✅를 표시하고, SiblingB는 선택된 과일: 사과를 출력한다.
SiblingA에서 바나나 버튼을 클릭하면 onSelect("바나나")가 실행되고, 실제로는 부모의 setSelected("바나나")가 실행된다.
그 결과 공통 부모의 selected 값이 "바나나"로 바뀌고, 두 형제 컴포넌트가 바뀐 값을 다시 받아 화면을 갱신한다.


형제 컴포넌트가 같은 상태를 써야 할 때는 형제끼리 직접 연결하지 않고, 공통 부모로 상태를 끌어올린다.


4-6. 실제 DOM 직접 제어하기

useRef가 필요한 상황

React에서는 화면에 보여줄 값은 보통 state로 관리한다.
state 값이 바뀌면 React가 컴포넌트를 다시 렌더링하고, 바뀐 화면을 실제 DOM에 반영한다.
이 방식은 개발자가 실제 DOM을 직접 고치지 않아도 화면을 관리할 수 있게 해 준다.


이런 방식을 선언적 방식이라고 한다.
선언적 방식은 “어떻게 직접 고칠지”보다 “현재 화면이 어떤 상태여야 하는지”를 코드로 표현하는 방식이다.
예를 들어 isOpen이 true이면 모달을 보여주고, false이면 모달을 숨기는 식이다.


하지만 모든 작업을 state만으로 처리할 수 있는 것은 아니다.
입력창에 커서를 강제로 이동시키거나, 동영상을 재생하거나, 스크롤 위치를 직접 내리거나, 외부 라이브러리에 실제 div 요소를 넘겨야 할 때가 있다.
이런 작업은 브라우저의 실제 DOM 요소가 가진 기능을 직접 사용해야 한다.


useRef()는 실제 DOM 요소에 직접 접근해야 할 때 사용하는 React의 탈출구이다.
여기서 탈출구라는 말은 React의 일반적인 state 기반 화면 갱신 흐름을 잠깐 벗어나, 브라우저의 실제 요소를 직접 다룬다는 뜻이다.

ref 객체의 기본 구조

useRef()를 호출하면 current라는 공간을 가진 객체가 만들어진다.
처음에는 보통 null을 넣어 둔다.

// UseRefBasicExample.jsx
const inputRef = useRef(null);

처음에는 아직 실제 input이 화면에 만들어지기 전이므로 inputRef.current는 null이다.
이후 input 태그에 ref={inputRef}를 연결하면, 렌더링 후 inputRef.current에 실제 input DOM 요소가 들어간다.


흐름은 아래처럼 볼 수 있다.

// 출력결과
// 1. useRef(null) 실행
// 2. ref 객체 생성
// 3. input 태그에 ref 연결
// 4. 화면 렌더링 후 ref.current에 실제 input DOM 저장
// 5. ref.current로 브라우저 기능 직접 호출 가능

여기서 중요한 점은 ref.current가 바뀌어도 그 자체로는 리렌더링이 발생하지 않는다는 것이다.
state는 바뀌면 화면을 다시 그리지만, ref.current는 값을 기억하거나 실제 DOM에 접근하는 용도에 가깝다.

사용 상황 1: 입력창에 포커스 주기

입력창에 자동으로 커서를 이동시켜야 하는 상황이 있다.
예를 들어 로그인 화면에서 아이디 입력칸이 비어 있는데 로그인 버튼을 눌렀다면, 아이디 입력칸으로 커서를 보내는 것이 자연스럽다.
이때 실제 input DOM 요소의 focus() 메서드를 사용한다.

// FocusTest.jsx
import { useRef } from "react";

export default function FocusTest() {
  // 실제 input DOM 요소를 담을 ref이다.
  const inputRef = useRef(null);

  // 입력값이 없으면 input에 포커스를 준다.
  const handleLogin = () => {
    if (!inputRef.current.value) {
      inputRef.current.focus();
    }
  };

  return (
    <div>
      <input
        ref={inputRef}
        type="text"
        placeholder="아이디를 입력하세요"
      />
      <button onClick={handleLogin}>
        로그인
      </button>
    </div>
  );
}

focus()는 React가 만든 기능이 아니라 브라우저의 실제 input DOM 요소가 가진 기능이다.
따라서 inputRef.current.focus()처럼 실제 요소에 접근해서 호출해야 한다.


데이터 흐름은 아래와 같다.

// 출력결과
// 1. inputRef 생성
// 2. input 태그에 ref={inputRef} 연결
// 3. 로그인 버튼 클릭
// 4. inputRef.current.value 확인
// 5. 입력값이 비어 있으면 inputRef.current.focus() 실행
// 6. 입력창에 커서 이동

이 작업은 화면에 보여줄 값을 바꾸는 것이 아니라, 브라우저 입력창의 기능을 실행하는 작업이다.
그래서 state보다 useRef()가 더 적합하다.

사용 상황 2: video와 audio 제어

video와 audio 태그는 브라우저가 제공하는 미디어 요소이다.
이 요소들은 play(), pause() 같은 메서드를 가지고 있다.


버튼을 눌러 영상을 재생하거나 멈추려면 실제 video DOM 요소에 접근해야 한다.
이때도 useRef()를 사용할 수 있다.

// VideoPlayer.jsx
import { useRef } from "react";

export default function VideoPlayer() {
  // 실제 video DOM 요소를 담을 ref이다.
  const videoRef = useRef(null);

  return (
    <div>
      <video
        ref={videoRef}
        src="/videos/sample.mp4"
        width="400"
      />
      <button onClick={() => videoRef.current.play()}>
        재생
      </button>
      <button onClick={() => videoRef.current.pause()}>
        일시정지
      </button>
    </div>
  );
}

play()와 pause()는 React state를 바꿔서 실행하는 기능이 아니다.
실제 video DOM 요소가 가진 브라우저 메서드이다.
따라서 videoRef.current.play()와 videoRef.current.pause()처럼 직접 호출한다.


흐름은 아래와 같다.

// 출력결과
// 1. videoRef 생성
// 2. video 태그에 ref 연결
// 3. 재생 버튼 클릭
// 4. videoRef.current.play() 실행
// 5. 영상 재생
// 6. 일시정지 버튼 클릭
// 7. videoRef.current.pause() 실행
// 8. 영상 일시정지

audio도 같은 방식으로 제어할 수 있다.
audio 태그에 ref를 연결하고, audioRef.current.play() 또는 audioRef.current.pause()를 호출하면 된다.

사용 상황 3: 스크롤 위치 조작과 크기 측정

채팅방 화면을 생각해 보자.
새 메시지가 추가되면 사용자는 보통 가장 아래의 최신 메시지를 보고 싶어 한다.
이때 채팅창 스크롤을 맨 아래로 내려야 한다.


스크롤 위치는 실제 DOM 요소가 가지고 있는 값이다.
scrollTop은 현재 스크롤 위치이고, scrollHeight는 스크롤 영역 안의 전체 높이이다.

// ChatRoom.jsx
import { useState, useRef } from "react";

export default function ChatRoom() {
  // 메시지 목록 상태이다.
  const [messages, setMessages] = useState([
    { id: 1, text: "안녕하세요!" }
  ]);

  // 채팅창 DOM 요소를 담을 ref이다.
  const chatWindowRef = useRef(null);

  // 새 메시지를 추가하고 스크롤을 맨 아래로 내린다.
  const sendMessage = () => {
    const newMessage = {
      id: Date.now(),
      text: "새로운 채팅 메시지입니다!"
    };

    setMessages((prevMessages) => [
      ...prevMessages,
      newMessage
    ]);

    setTimeout(() => {
      if (chatWindowRef.current) {
        chatWindowRef.current.scrollTop = chatWindowRef.current.scrollHeight;
      }
    }, 0);
  };

  return (
    <div>
      <div
        ref={chatWindowRef}
        style={{
          height: "200px",
          overflowY: "scroll",
          border: "1px solid #ccc"
        }}

        {messages.map((message) => (
          <p key={message.id}>{message.text}</p>
        ))}
      </div>
      <button onClick={sendMessage}>
        전송
      </button>
    </div>
  );
}

이 예제에서는 메시지를 추가할 때마다 새 메시지 객체를 만든다.
각 메시지 객체는 id와 text를 가진다.
id는 메시지를 구분하기 위한 고유 값이고, text는 화면에 보여줄 메시지 내용이다.


messages.map()으로 메시지를 반복 출력할 때는 key={message.id}를 사용한다.
앞에서 정리한 것처럼 반복 요소의 key는 가능하면 위치값인 index보다 데이터 자체의 고유 id를 사용하는 것이 안전하다.
이렇게 하면 메시지 순서가 바뀌거나 중간에 메시지가 추가되어도 React가 각 메시지를 더 정확하게 추적할 수 있다.


setMessages((prevMessages) => [...prevMessages, newMessage])는 이전 메시지 목록을 기준으로 새 메시지를 뒤에 추가한다.
여기서 prevMessages는 상태 변경 직전의 최신 메시지 목록이다.
그래서 기존 messages 값을 직접 가져다 쓰는 것보다 상태 흐름이 더 안전하다.


이 예제에서는 메시지를 추가한 뒤 setTimeout() 안에서 스크롤을 조정한다.
setMessages() 직후에는 새 메시지가 아직 실제 화면에 반영되기 전일 수 있기 때문이다.
그래서 화면 반영이 끝난 뒤 스크롤을 맨 아래로 내리도록 실행 시점을 살짝 뒤로 미룬다.


흐름은 아래와 같다.

// 출력결과
// 1. 전송 버튼 클릭
// 2. 새 메시지 객체 생성
// 3. setMessages로 새 메시지 추가
// 4. messages 배열이 변경되면서 화면에 새 메시지 렌더링
// 5. chatWindowRef.current 확인
// 6. scrollTop 값을 scrollHeight로 변경
// 7. 스크롤이 맨 아래로 이동

요소의 실제 크기나 위치를 측정할 때도 useRef()를 사용할 수 있다.
예를 들어 모달이나 팝업의 크기를 기준으로 화면 중앙에 배치해야 한다면 실제 요소 크기가 필요하다.
이때 getBoundingClientRect()를 사용할 수 있다.

// MeasureBox.jsx
import { useRef } from "react";

function MeasureBox() {
  // 측정할 div DOM 요소를 담을 ref이다.
  const boxRef = useRef(null);

  // 실제 요소의 크기와 위치를 확인한다.
  const checkSize = () => {
    if (!boxRef.current) return;

    const rect = boxRef.current.getBoundingClientRect();
    console.log(rect.width);
    console.log(rect.height);
  };

  return (
    <div>
      <div ref={boxRef}>
        크기를 측정할 박스
      </div>
      <button onClick={checkSize}>
        크기 확인
      </button>
    </div>
  );
}

getBoundingClientRect()는 실제 화면에 렌더링된 요소의 크기와 위치 정보를 반환한다.
이 정보는 실제 DOM 요소가 있어야 알 수 있으므로 useRef()로 접근한다.

사용 상황 4: 외부 라이브러리 연동

모든 라이브러리가 React 방식으로 만들어진 것은 아니다.
D3.js, Chart.js, Quill, Editor.js처럼 실제 HTML 요소를 직접 요구하는 라이브러리도 있다.


이런 라이브러리는 “차트를 그릴 실제 div를 넘겨 달라”는 방식으로 동작할 수 있다.
이때 useRef()로 빈 div를 잡아 외부 라이브러리에 전달한다.

// ChartComponent.jsx
import { useEffect, useRef } from "react";
import SomeVanillaChartLibrary from "some-chart-library";

export default function ChartComponent() {
  // 차트를 그릴 실제 div DOM 요소를 담을 ref이다.
  const chartContainerRef = useRef(null);

  // 컴포넌트가 화면에 나타난 뒤 외부 라이브러리를 연결한다.
  useEffect(() => {
    const chart = new SomeVanillaChartLibrary(chartContainerRef.current);

    chart.render({ data: [10, 20, 30] });

    return () => chart.destroy();
  }, []);

  return (
    <div
      ref={chartContainerRef}
      style={{ width: "500px", height: "300px" }}
    />
  );
}

useEffect()는 컴포넌트가 화면에 렌더링된 뒤 실행된다.
외부 라이브러리는 실제 DOM 요소가 만들어진 뒤 연결해야 하므로 useEffect() 안에서 chartContainerRef.current를 넘기는 흐름이 자연스럽다.


마지막의 return () => chart.destroy()는 컴포넌트가 사라질 때 차트도 정리하는 코드이다.
외부 라이브러리가 만든 요소나 이벤트가 남아 있으면 메모리 누수나 이상 동작이 생길 수 있으므로 정리 작업이 필요하다.

EduApp7 Demo5 코드

EduApp7.jsx의 Demo5는 useRef()로 실제 input DOM 요소를 잡고, 버튼 클릭으로 입력창을 직접 제어하는 예제이다.

// EduApp7Demo5.jsx
function Demo5() {
  // 실제 input DOM 요소를 담을 ref이다.
  const inputRef = useRef(null);

  return (
    <Section
      num="5"
      color="red"
      title="useRef — DOM 직접 접근"
      desc="ref를 input에 연결하면 state 없이 포커스·값을 직접 제어합니다."

      <div style={{ display: "flex", gap: 8 }}>
        <input
          ref={inputRef}
          placeholder="여기에 포커스됩니다"
          style={{
            flex: 1,
            padding: "6px 10px",
            borderRadius: 7,
            border: "1.5px solid #DC262644",
            fontSize: 13,
            outline: "none",
            background: "#fff",
          }}
        />
        <Btn
          color="red"
          onClick={() => inputRef.current?.focus()}

          포커스
        </Btn>
        <Btn
          color="red"
          onClick={() => {
            if (inputRef.current) inputRef.current.value = "";
          }}

          초기화
        </Btn>
      </div>
    </Section>
  );
}

inputRef는 실제 input 요소를 담기 위한 참조 객체이다.
처음에는 null이지만, 화면에 input이 만들어진 뒤에는 inputRef.current에 실제 input DOM 요소가 들어간다.


포커스 버튼은 inputRef.current?.focus()를 실행한다.
focus()는 입력창에 커서를 이동시키는 브라우저 기능이다.


초기화 버튼은 inputRef.current.value = ""를 실행한다.
이 코드는 입력창의 실제 값을 빈 문자열로 바꾼다.

EduApp7 Demo5 데이터 흐름

// 출력결과
// 1. Demo5 실행
// 2. useRef(null)로 inputRef 생성
// 3. input 태그에 ref={inputRef} 연결
// 4. 화면 렌더링 후 inputRef.current에 실제 input DOM 저장
// 5. 포커스 버튼 클릭
// 6. inputRef.current?.focus() 실행
// 7. 입력창에 커서 이동
// 8. 초기화 버튼 클릭
// 9. inputRef.current.value = "" 실행
// 10. 입력창 값 초기화

여기서 중요한 점은 state를 바꾸는 것이 아니라 실제 DOM 요소에 직접 접근한다는 점이다.
입력값을 화면 상태로 관리해야 한다면 state가 더 적합할 수 있다.
하지만 포커스 이동이나 단순 초기화처럼 실제 입력창 기능을 직접 쓰는 흐름은 useRef()로 확인할 수 있다.


또 하나 중요한 차이가 있다.
ref.current를 바꿔도 그 자체로는 컴포넌트가 다시 렌더링되지 않는다.
즉, inputRef.current.value = ""는 실제 입력창 값을 직접 비우는 동작이지, React state를 바꿔서 화면을 다시 그리는 동작이 아니다.


그래서 입력값을 다른 문구로 화면에 표시하거나, 입력값이 바뀔 때마다 검증 메시지를 띄워야 한다면 state로 관리하는 편이 더 적합하다.
반대로 단순히 입력창에 커서를 주거나, 실제 입력창 값을 직접 비우는 정도라면 useRef()로 처리할 수 있다.

이 화면은 useRef()로 실제 입력창을 직접 제어하는 흐름을 보여준다.
포커스 버튼을 누르면 inputRef.current?.focus()가 실행되어 입력창에 커서가 이동한다.
초기화 버튼을 누르면 inputRef.current.value = ""가 실행되어 입력창의 실제 값이 지워진다.
이 동작은 입력값을 state로 관리해서 화면을 다시 그리는 방식이 아니라, 실제 input DOM의 메서드와 속성을 직접 사용하는 방식이다.

useRef를 남용하면 안 되는 이유

useRef()는 실제 DOM 요소에 직접 접근할 수 있기 때문에 강력하다.
하지만 강력하다고 해서 모든 화면 변경을 useRef()로 처리하면 안 된다.


예를 들어 아래와 같은 작업은 조심해야 한다.

// BadUseRefExample.jsx
boxRef.current.style.backgroundColor = "red";
boxRef.current.remove();

이런 방식은 실제 DOM을 직접 수정하거나 제거한다.
문제는 React가 생각하는 화면 구조와 실제 브라우저 화면 구조가 어긋날 수 있다는 점이다.
React는 여전히 그 요소가 있다고 생각하는데, 개발자가 직접 remove()로 없애 버리면 이후 렌더링 과정에서 예상하지 못한 문제가 생길 수 있다.


글자를 바꾸거나, 박스를 숨기거나 보여주거나, 목록을 추가하거나 삭제하는 작업은 보통 state로 처리하는 것이 맞다.
그런 작업은 화면 상태와 직접 연결되기 때문이다.


반대로 아래 작업들은 useRef()를 사용할 수 있다.

  • 입력창에 focus() 주기
  • video, audio의 play(), pause() 호출
  • 스크롤 위치 조작
  • 요소의 실제 크기와 위치 측정
  • 외부 라이브러리에 실제 DOM 요소 전달

useRef()는 화면 상태를 관리하는 기본 도구가 아니라, React state만으로 처리하기 어려운 브라우저 고유 기능을 사용할 때 제한적으로 쓰는 도구이다.


4-7. EduApp7.jsx 전체 코드 흐름

전체 코드

아래 코드는 다섯 가지 컴포넌트 통신 패턴을 한 화면에서 확인하는 예제이다.
공통 화면 구조를 만드는 Section, 버튼을 만드는 Btn, 짧은 표시용 Tag를 먼저 정의하고, 메인 컴포넌트인 InterCompTest에서 Demo1부터 Demo5까지 차례대로 렌더링한다.
Tag는 공통 표시용 컴포넌트로 정의되어 있지만, 현재 메인 화면에서는 직접 렌더링하지 않는다.

// EduApp7.jsx
import { useState, createContext, useContext, useRef } from "react";

// 색상 정보를 모아 둔 객체이다.
const COLORS = {
  purple: { main: "#6C63FF", light: "#EEEDFE", dark: "#3C3489" },
  teal: { main: "#1D9E75", light: "#E1F5EE", dark: "#085041" },
  blue: { main: "#2563EB", light: "#EFF6FF", dark: "#1E40AF" },
  amber: { main: "#D97706", light: "#FFFBEB", dark: "#92400E" },
  red: { main: "#DC2626", light: "#FEF2F2", dark: "#991B1B" },
  green: { main: "#16A34A", light: "#F0FDF4", dark: "#14532D" },
};

function Section({ color, num, title, desc, children }) {
  // color 값으로 사용할 색상 묶음을 꺼낸다.
  const c = COLORS[color];

  return (
    <div style={{
      border: `1px solid ${c.main}44`,
      borderLeft: `4px solid ${c.main}`,
      borderRadius: 10,
      padding: "1rem 1.1rem",
      marginBottom: 12,
      background: "#fff",
    }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
        <span style={{
          width: 22,
          height: 22,
          borderRadius: "50%",
          background: c.main,
          color: "#fff",
          fontSize: 11,
          fontWeight: 700,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}>
          {num}
        </span>
        <strong style={{ fontSize: 14, color: c.dark }}>{title}</strong>
      </div>
      <p style={{ fontSize: 12, color: "#888", marginBottom: 10, lineHeight: 1.6 }}>
        {desc}
      </p>
      <div style={{ background: c.light, borderRadius: 8, padding: "10px 12px" }}>
        {children}
      </div>
    </div>
  );
}

function Btn({ color = "purple", onClick, children }) {
  // 버튼 색상 정보를 꺼낸다.
  const c = COLORS[color];

  return (
    <button onClick={onClick} style={{
      padding: "6px 14px",
      borderRadius: 7,
      border: `1.5px solid ${c.main}`,
      background: c.light,
      color: c.dark,
      fontSize: 12.5,
      fontWeight: 600,
      cursor: "pointer",
    }}>
      {children}
    </button>
  );
}

function Tag({ color = "purple", children }) {
  // 짧은 표시용 태그 컴포넌트이다.
  const c = COLORS[color];

  return (
    <span style={{
      display: "inline-block",
      padding: "3px 10px",
      borderRadius: 20,
      background: c.light,
      color: c.dark,
      fontSize: 12,
      fontWeight: 500,
      border: `1px solid ${c.main}44`,
    }}>
      {children}
    </span>
  );
}

function ChildA({ name, age }) {
  // 부모에게 받은 props를 화면에 출력한다.
  return (
    <div style={{ fontSize: 13, color: "#333" }}>
      👤 이름: <strong>{name}</strong> / 나이: <strong>{age}세</strong>
    </div>
  );
}

function Demo1() {
  // 부모에서 자식으로 props를 전달한다.
  return (
    <Section
      num="1"
      color="purple"
      title="Props — 부모 → 자식"
      desc="부모가 name, age를 props로 내려주고 자식이 표시합니다."

      <ChildA name="홍길동" age={25} />
    </Section>
  );
}

function ChildB({ onSay }) {
  // 자식 버튼 클릭 시 부모가 내려준 함수를 호출한다.
  return (
    <Btn
      color="teal"
      onClick={() => onSay("안녕하세요! 👋")}

      자식 버튼 클릭
    </Btn>
  );
}

function Demo2() {
  // 부모가 메시지 상태를 가진다.
  const [msg, setMsg] = useState("(아직 없음)");

  return (
    <Section
      num="2"
      color="teal"
      title="콜백 함수 — 자식 → 부모"
      desc="자식 버튼을 누르면 onSay 콜백으로 부모 state가 바뀝니다."

      <ChildB onSay={setMsg} />
      <div style={{ marginTop: 8, fontSize: 13, color: "#333" }}>
        부모가 받은 메시지: <strong>{msg}</strong>
      </div>
    </Section>
  );
}

const UserContext = createContext(null);

function ChildC() {
  // Context에서 user 값을 직접 꺼낸다.
  const user = useContext(UserContext); // React 19에서는 use(UserContext)도 가능하다.

  return (
    <div style={{ fontSize: 13, color: "#333" }}>
      🌐 Context에서 읽음: <strong>{user.name}</strong> ({user.role})
    </div>
  );
}

function Demo3() {
  // Provider가 공급할 user 상태를 만든다.
  const [user, setUser] = useState({
    name: "유니코",
    role: "일반 사용자",
  });

  return (
    <UserContext.Provider value={user}>
      <Section
        num="3"
        color="blue"
        title="Context — 전역 상태 공유"
        desc="Provider가 값을 제공하면 하위 어디서든 props 없이 꺼냅니다."

        <ChildC />
        <div style={{ marginTop: 8 }}>
          <Btn
            color="blue"
            onClick={() => setUser({ name: "듀크", role: "관리자" })}

            사용자 변경
          </Btn>
        </div>
      </Section>
    </UserContext.Provider>
  );
}

function SiblingA({ selected, onSelect }) {
  // 과일 버튼을 클릭하면 부모의 상태 변경 함수를 호출한다.
  return (
    <div style={{ display: "flex", gap: 6 }}>
      {["사과", "바나나", "포도"].map((item) => (
        <Btn
          key={item}
          color="amber"
          onClick={() => onSelect(item)}

          {selected === item ? "✅ " : ""}
          {item}
        </Btn>
      ))}
    </div>
  );
}

function SiblingB({ selected }) {
  // 부모에게 받은 selected 값을 표시한다.
  return (
    <div style={{ marginTop: 8, fontSize: 13, color: "#333" }}>
      🛒 선택된 과일: <strong>{selected}</strong>
    </div>
  );
}

function Demo4() {
  // 공통 부모가 selected 상태를 가진다.
  const [selected, setSelected] = useState("사과");

  return (
    <Section
      num="4"
      color="amber"
      title="상태 끌어올리기 — 형제 연동"
      desc="형제A(선택) → 공통 부모 state 변경 → 형제B(표시). 형제끼리 직접 통신하지 않습니다."

      <SiblingA selected={selected} onSelect={setSelected} />
      <SiblingB selected={selected} />
    </Section>
  );
}

function Demo5() {
  // 실제 input DOM 요소를 담을 ref이다.
  const inputRef = useRef(null);

  return (
    <Section
      num="5"
      color="red"
      title="useRef — DOM 직접 접근"
      desc="ref를 input에 연결하면 state 없이 포커스·값을 직접 제어합니다."

      <div style={{ display: "flex", gap: 8 }}>
        <input
          ref={inputRef}
          placeholder="여기에 포커스됩니다"
          style={{
            flex: 1,
            padding: "6px 10px",
            borderRadius: 7,
            border: "1.5px solid #DC262644",
            fontSize: 13,
            outline: "none",
            background: "#fff",
          }}
        />
        <Btn
          color="red"
          onClick={() => inputRef.current?.focus()}

          포커스
        </Btn>
        <Btn
          color="red"
          onClick={() => {
            if (inputRef.current) inputRef.current.value = "";
          }}

          초기화
        </Btn>
      </div>
    </Section>
  );
}

export default function InterCompTest() {
  // 다섯 가지 컴포넌트 통신 패턴을 한 화면에 출력한다.
  return (
    <div style={{
      maxWidth: 600,
      margin: "0 auto",
      padding: "1.5rem 1rem",
      fontFamily: "'Pretendard', 'Noto Sans KR', sans-serif",
      background: "#F8F7F4",
      minHeight: "100vh",
    }}>
      <h1 style={{
        fontSize: 18,
        fontWeight: 800,
        marginBottom: 4,
        color: "#1C1C1A",
      }}>
        React 19 컴포넌트 통신 패턴
      </h1>
      <p style={{ fontSize: 12.5, color: "#888", marginBottom: 16 }}>
        5가지 패턴을 심플하게 구현한 예제입니다.
      </p>

      <Demo1 />
      <Demo2 />
      <Demo3 />
      <Demo4 />
      <Demo5 />
    </div>
  );
}

이 전체 코드는 다섯 가지 통신 패턴을 하나의 화면에 모아 둔 예제이다.
Demo1은 부모에서 자식으로 데이터를 내려주는 흐름을 보여준다.
Demo2는 자식이 부모가 내려준 함수를 호출해 부모 상태를 바꾸는 흐름을 보여준다.
Demo3은 Context API로 Provider가 감싼 범위 안에서 값을 공유하는 흐름을 보여준다.
Demo4는 형제 컴포넌트가 공통 부모의 상태를 함께 사용하는 흐름을 보여준다.
Demo5는 useRef()로 실제 DOM 요소를 직접 제어하는 흐름을 보여준다.

EduApp7.jsx 전체 흐름

// 출력결과
// EduApp7.jsx
// ├─ InterCompTest
// │  ├─ Demo1: props로 부모 → 자식 데이터 전달
// │  ├─ Demo2: callback으로 자식 → 부모 이벤트 전달
// │  ├─ Demo3: Context API로 Provider 범위 안의 값 공유
// │  ├─ Demo4: 상태 끌어올리기로 형제 컴포넌트 연결
// │  └─ Demo5: useRef로 실제 DOM 직접 제어

이 예제에서 중요한 것은 문법 이름을 외우는 것이 아니다.
각 Demo에서 데이터가 어느 방향으로 움직이는지 보는 것이다.


4-8. 컴포넌트 통신 패턴 최종 정리

다섯 가지 패턴 정리

컴포넌트 통신은 상황에 따라 사용하는 방법이 달라진다.
부모가 자식에게 값을 줄 때와 자식에서 발생한 일을 부모에게 알려야 할 때는 흐름이 다르다.
멀리 떨어진 컴포넌트가 같은 값을 써야 할 때와 형제 컴포넌트가 같은 상태를 공유해야 할 때도 접근 방식이 다르다.


핵심은 아래와 같다.

상황사용하는 방식데이터 흐름
부모가 자식에게 값 전달props부모 → 자식
자식이 부모에게 알림콜백 함수자식 → 부모처럼 보이지만 실제로는 부모 함수 호출
멀리 떨어진 컴포넌트가 값 공유Context APIProvider → 필요한 하위 컴포넌트
형제 컴포넌트가 상태 공유상태 끌어올리기형제 → 공통 부모 → 형제
실제 DOM 직접 제어useRef()컴포넌트 → 실제 DOM

어떤 기준으로 선택해야 하는가

무조건 복잡한 방법을 먼저 쓰면 안 된다.
가까운 부모와 자식 관계라면 props가 가장 단순하다.
자식에서 부모에게 알려야 한다면 콜백 함수를 내려주면 된다.


컴포넌트가 멀리 떨어져 있고 중간 컴포넌트가 값을 전달만 한다면 Context API를 고려할 수 있다.
단, Context API는 Provider가 감싼 범위 안에서 값이 공유된다는 점을 기억해야 한다.
형제 컴포넌트가 같은 값을 써야 한다면 공통 부모로 상태를 끌어올리는 것이 자연스럽다.
실제 입력창이나 브라우저 요소를 직접 제어해야 한다면 useRef()를 사용한다.


컴포넌트 통신을 고를 때는 문법 이름보다 데이터가 어디에서 시작해서 어디로 이동하는지 먼저 봐야 한다.

0개의 댓글