
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생성DiffPatch이 세 단계는 각각 따로 외우는 개념이 아니다.
상태가 바뀐 뒤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가 항목을 구분하는 기준은 완전히 다르다.
이 차이 때문에 맨 앞에 새 메뉴를 추가했을 때 체크 상태가 다르게 움직일 수 있다.
코드 구조 먼저 보기
이 예제는 크게 세 부분으로 나눌 수 있다.
MenuItemmenuList상태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 컴포넌트 흐름
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을 재사용하면서, 체크 상태가 엉뚱한 항목에 붙을 수 있다는 점이다.
menuList 상태 흐름
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는 여러 페이지에서 반복되는 공통 화면 구조를 담당하는 컴포넌트이다.
웹 사이트를 만들다 보면 여러 페이지에 공통으로 들어가는 부분이 있다.
예를 들면 아래와 같다.
HeaderFooterSidebarNavbar- 전체 페이지 여백
- 공통 배경
이런 공통 구조를 매 페이지마다 반복해서 작성하면 중복 코드가 많아진다.
또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는 하나의 페이지 화면을 담당하는 컴포넌트이다.
사용자가 특정 주소로 들어왔을 때 보여줄 화면 단위라고 볼 수 있다.
예를 들어 아래와 같은 페이지들이 있을 수 있다.
HomePageLoginPageProductPageMyPage
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가 값을 공급하고, 하위 컴포넌트가 그 값을 꺼내 쓴다.
대표적인 예시는 아래와 같다.
ThemeProviderAuthProviderCartProviderUserProvider예를 들어
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는 로딩 중 화면이나 에러 화면을 담당하는 컴포넌트이다.
화면에는 정상적인 데이터만 보여주는 것이 아니다.
데이터를 기다리는 중일 수도 있고, 문제가 발생했을 수도 있고, 보여줄 데이터가 없을 수도 있다.
이런 상태를 사용자에게 알려주는 컴포넌트가 필요하다.
대표적인 예시는 아래와 같다.
LoadingSpinnerErrorMessageEmptyStateNotFoundPage사용자는 화면이 멈춘 것인지, 데이터를 불러오는 중인지, 문제가 생긴 것인지 알 수 있어야 한다.
그래서 로딩 화면과 에러 화면은 사용자 경험에서 중요하다.
코드 예제
아래 예제는 로딩 상태와 에러 상태를 보여주는 가장 단순한 컴포넌트이다.
// 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로딩, 에러, 빈 상태 같은 화면 상태를 보여준다 컴포넌트를 잘 나눈다는 것은 파일을 많이 만드는 것이 아니라, 각 컴포넌트가 맡을 책임을 분명하게 나누는 것이다.
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만으로도 복잡해질 수 있다.
특히 로그인 사용자 정보, 장바구니, 권한, 알림, 다크모드처럼 여러 페이지에서 공유되는 상태가 많아질 수 있다.
이런 경우에는 전역 상태 관리 도구를 사용할 수 있다.
전역 상태는 앱 전체에서 여러 컴포넌트가 함께 사용하는 상태를 말한다.
대표적인 도구는 아래와 같다.
ZustandJotaiRedux 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이다.
코드 구조 먼저 보기
이 예제는 크게 네 부분으로 나눌 수 있다.
ThemeContextContextTestHeader와ContentThemeButton
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컴포넌트는 혼자만 동작하지 않는다.
부모 컴포넌트가 자식 컴포넌트에게 데이터를 내려주기도 하고, 자식 컴포넌트가 부모에게 어떤 일이 일어났는지 알려주기도 한다.
또 서로 멀리 떨어진 컴포넌트가 같은 값을 사용해야 할 때도 있고, 형제 컴포넌트끼리 같은 상태를 함께 써야 할 때도 있다.
컴포넌트 통신은 컴포넌트 사이에서 데이터와 이벤트가 어떤 방향으로 이동하는지 이해하는 개념이다.
이 흐름을 알아야 컴포넌트를 단순히 나누는 것에서 끝나지 않고, 나뉜 컴포넌트들이 어떻게 함께 동작하는지 이해할 수 있다.
여기서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()를 사용한다.
컴포넌트 통신을 고를 때는 문법 이름보다 데이터가 어디에서 시작해서 어디로 이동하는지 먼저 봐야 한다.