React의 핵심 개념과 장점
선언적 UI, 재사용 가능한 컴포넌트, 가상 DOM, 단방향 데이터 흐름이 React 개발을 단순화하는 원리를 익힙니다.
리액트를 처음 접하면 컴포넌트를 잘게 나누면 되는 도구 정도로만 이해하고 넘어가기 쉽습니다.
그런데 실제로는 컴포넌트보다 먼저, 리액트가 문제를 바라보는 방식부터 이해해야 시행착오를 줄일 수 있습니다.
같은 UI를 만들어도 어떤 팀은 빠르게 확장하고, 어떤 팀은 수정할 때마다 예기치 않은 버그를 만나는 이유가 여기에 있습니다.
초보 단계에서는 문법과 API를 외우는 데 집중하기 쉽지만, 실무에서 더 오래 남는 것은 왜 이 구조를 택했는가에 대한 판단 기준입니다.
이 절은 리액트의 핵심 철학을 기능 목록처럼 나열하지 않고, 개발 현장에서 자주 마주치는 고민과 연결해 설명합니다.
선언적 UI, 컴포넌트 구조, 가상 DOM, 단방향 데이터 흐름이 각각 무엇인지뿐 아니라 언제 강점이 되고 언제 주의가 필요한지도 함께 다룹니다.
이 관점을 잡아두면 이후의 상태 관리, 성능 최적화, 아키텍처 설계가 훨씬 덜 막히게 됩니다.
UI 개발을 위한 선언적 방식
리액트의 가장 핵심적인 특징 중 하나는 바로 선언적(Declarative) 방식의 UI 개발을 지향한다는 점입니다.
이는 명령형(Imperative) 방식과 대비되는 개념이므로, 두 방식의 차이를 간단히 비교해보겠습니다.
-
명령형(Imperative) 방식: 어떻게(How) 할 것인가에 초점을 맞춥니다. 개발자가 직접 단계별로 DOM을 조작하여 UI의 변화를 지시합니다. 예를 들어, 이 버튼을 찾아 클릭 이벤트를 추가하고, 클릭 시 텍스트를 클릭됨으로 변경하고, 글자색을 빨간색으로 바꿔라와 같이 상세한 지시를 내리는 방식입니다. 코드가 복잡해질수록 상태 관리와 버그 추적이 어려워지는 경향이 있습니다.
-
선언적(Declarative) 방식: 무엇을(What) 보여줄 것인가에 초점을 맞춥니다. 개발자는 원하는 UI의 최종 상태를 선언하고, 리액트가 렌더 결과를 비교해 필요한 DOM 변경을 반영합니다. 예를 들어, "버튼의
isActive상태가true이면 활성이라는 텍스트를 파란색으로 보여주고,false이면 비활성이라는 텍스트를 회색으로 보여줘"와 같이 선언합니다. 그러면isActive값이 변경될 때마다 리액트가 알아서 해당 상태에 맞는 UI를 그려줍니다.
리액트 개발자는 원하는 UI의 모습만 선언하면 됩니다.
state의 setter로 갱신을 요청하면, 리액트는 새 입력으로 컴포넌트를 호출해 UI를 계산하고 이전 결과와 다른 DOM 부분을 반영합니다. 일반 변수나 객체 속성에 값을 대입하는 것만으로 React가 그 변경을 감지하여 렌더링하는 것은 아닙니다.
이러한 선언적 방식은 관련 DOM을 일일이 찾아 수정하는 부담을 줄입니다. 개발자는 상태의 소유권, 갱신 조건, 이벤트와 외부 시스템의 연결을 설계합니다. DOM을 직접 다뤄야 하는 초점 이동 같은 작업은 해당 경계를 따로 둡니다.
이는 코드의 가독성을 높이고, 예측 가능한 UI 업데이트를 가능하게 하여 개발 생산성과 유지보수성을 크게 향상시킵니다.
물론 선언적 방식이 항상 정답은 아닙니다.
캔버스, 웹GL, 복잡한 애니메이션처럼 아주 세밀한 그리기 제어가 필요한 영역은 명령형 접근이 더 직접적일 때도 있습니다.
실무에서는 모든 것을 선언형으로 밀어붙이기보다, UI 상태 동기화가 핵심인 영역에 선언형을 우선 적용하고 저수준 제어가 필요한 부분만 분리하는 전략이 더 안정적입니다.
컴포넌트 기반 아키텍처
리액트의 또 다른 핵심 특징은 UI를 컴포넌트(Component)라는 조각으로 나누어 개발한다는 점입니다.
컴포넌트는 UI를 구성하는 독립적이고 재사용 가능한 단위이며, 작은 컴포넌트를 조합해 복잡한 UI를 구성할 수 있습니다.
예를 들어, 웹페이지를 만든다고 상상해 보세요.
헤더, 내비게이션 바, 사이드바, 게시물 목록, 댓글 입력창 등 각각의 UI 요소를 하나의 컴포넌트로 만들 수 있습니다.
- 재사용성(Reusability): 한 번 만든 컴포넌트는 다른 페이지나 다른 프로젝트에서도 쉽게 재사용할 수 있습니다. 예를 들어 버튼 컴포넌트를 만들어 두면, 웹사이트의 모든 버튼에 동일한 스타일과 기능을 적용하면서도, 필요에 따라 다양한 종류의 버튼을 쉽게 만들어낼 수 있습니다.
- 유지보수성(Maintainability): 입력과 역할이 명확한 컴포넌트는 변경 이유와 확인 범위를 좁혀 줍니다. 다만 부모의 props나 공유 상태가 잘못되면 여러 자식도 영향을 받으므로, 컴포넌트를 나누는 것만으로 오류가 격리되지는 않습니다.
- 모듈성(Modularity): 복잡한 UI를 작은 단위로 분리하여 개발함으로써, 여러 개발자가 동시에 다른 컴포넌트를 개발할 수 있어 협업 효율성을 높일 수 있습니다.
이러한 컴포넌트 기반의 접근 방식은 대규모 애플리케이션 개발에 특히 유리하며, 코드의 응집도를 높이고 결합도를 낮춰 소프트웨어의 전반적인 품질을 향상시킵니다.
컴포넌트를 나눌 때는 화면 조각만 보는 것이 아니라 상태를 누가 소유하고 어떤 값이 props로 내려가는지도 함께 정해야 합니다.
컴포넌트 경계는 UI 책임과 state 소유권을 함께 드러내도록 정합니다.
가상 DOM
데이터가 변경되면 UI를 효율적으로 업데이트한다는 리액트의 선언적 방식을 가능하게 하는 핵심 기술 중 하나가 바로 가상 DOM (Virtual DOM)입니다.
일반적으로 웹 브라우저는 HTML 문서를 파싱하여 화면에 보여주기 위한 트리 형태의 객체 모델을 만듭니다.
이것을 DOM(Document Object Model)이라고 부릅니다.
DOM 작업의 비용은 변경 대상과 읽기·쓰기 순서에 따라 달라집니다. 텍스트 하나를 바꾸는 작업과 많은 요소의 크기를 반복해서 읽고 바꾸는 작업을 같은 비용으로 볼 수는 없습니다.
DOM 변경은 변경 종류와 브라우저 처리에 따라 스타일 계산·레이아웃·페인트를 유발할 수 있으며, 매 변경마다 이 모든 과정이 즉시 실행되는 것은 아닙니다.
잦은 DOM 조작은 웹 애플리케이션의 성능 저하로 이어질 수 있습니다.
리액트는 이러한 문제를 해결하기 위해 가상 DOM이라는 개념을 도입했습니다.
가상 DOM은 다음 UI를 기술하는 JavaScript 표현으로 이해할 수 있습니다. 실제 DOM 노드·이벤트·브라우저 상태를 완전히 복제한 사본은 아닙니다.
리액트는 실제 DOM을 직접 조작하는 대신, 다음과 같은 과정을 거칩니다.
React 렌더 데이터 흐름 — 상태 추적
본문 카운터 1→2에서 현재 렌더 입력·다음 UI 계산·필요 DOM commit이 다르다. 같은 위치의 입력과 텍스트를 구분해 DOM 전부 재생성 오해를 줄일 가치가 있다.
React · UI와 실행의 관계
Render는 UI를 계산하고 commit은 DOM에 반영합니다
상태의 setter는 렌더를 요청합니다. 컴포넌트 계산과 DOM 반영을 구분하고, 결과가 그대로인 DOM은 바꾸지 않는 경계를 읽습니다.
본문 장바구니의 qty는 부모에서 자식 입력으로 내려갑니다. 자식의 이벤트는 전달받은 함수를 호출해 부모에 갱신을 요청하며, 새 입력은 다음 렌더에서 다시 내려옵니다.
카운터1→2이면 제목 텍스트가1→2로 바뀔 수 있지만, 같은 위치의 독립된 입력을 매번 새로 만들지는 않습니다. 가상 DOM은 실제 DOM·브라우저 상태의 완전한 복사본이 아닙니다. 렌더 계산도 비용을 가지므로 모든 구현보다 빠르다는 뜻은 아닙니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 캡션 | 카운터 1→2이면 제목 텍스트가 1→2로 바뀔 수 있지만, 같은 위치의 독립된 입력을 매번 새로 만들지는 않습니다. 가상 DOM은 실제 DOM·브라우저 상태의 완전한 복사본이 아닙니다. 렌더 계산도 비용을 가지므로 모든 구현보다 빠르다는 뜻은 아닙니다. |
| 전체 관계·조건 설명 | 현재 state·props→컴포넌트 render→이전과 다음 결과 비교→필요한 DOM commit의 노드 4개·간선 3개다. render 중 기존 객체/DOM을 바꾸지 않으며 결과가 같으면 해당 DOM을 변경하지 않는다. commit→state 자동 변경의 순환 간선은 없다. |
| 전체 관계·조건 설명 | 현재 state·props→컴포넌트 render→이전과 다음 결과 비교→필요한 DOM commit의 노드 4개·간선 3개다. render 중 기존 객체/DOM을 바꾸지 않으며 결과가 같으면 해당 DOM을 변경하지 않는다. commit→state 자동 변경의 순환 간선은 없다. 모바일에서도 같은 연결과 분기·되돌림 대상을 유지한다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 commit | state: 1 / DOM: count 1·기존 input | 카운터 1이 표시되고 정해진 위치의 입력 DOM이 있다 | 입력 DOM의 브라우저 상태와 count 텍스트를 구분한다 |
| render2 | render input: 2 / DOM: 아직 1 | state 2로 다음 UI를 계산하되 DOM은 아직 이전 commit이다 | 컴포넌트 호출이 DOM 전체 교체를 뜻하지는 않는다 |
| commit2 | 텍스트: 2 / input: 같은 위치/종류 유지 조건 | 달라진 count 텍스트는 2가 되고 필요한 입력 정체성은 유지된다 | element 설명은 실제 DOM 상태 전체의 복사본이 아니다 |
React 렌더 데이터 흐름 — 상태 추적
본문 카운터 1→2에서 현재 렌더 입력·다음 UI 계산·필요 DOM commit이 다르다. 같은 위치의 입력과 텍스트를 구분해 DOM 전부 재생성 오해를 줄일 가치가 있다.
React · UI와 실행의 관계
Render는 UI를 계산하고 commit은 DOM에 반영합니다
상태의 setter는 렌더를 요청합니다. 컴포넌트 계산과 DOM 반영을 구분하고, 결과가 그대로인 DOM은 바꾸지 않는 경계를 읽습니다.
본문 장바구니의 qty는 부모에서 자식 입력으로 내려갑니다. 자식의 이벤트는 전달받은 함수를 호출해 부모에 갱신을 요청하며, 새 입력은 다음 렌더에서 다시 내려옵니다.
카운터1→2이면 제목 텍스트가1→2로 바뀔 수 있지만, 같은 위치의 독립된 입력을 매번 새로 만들지는 않습니다. 가상 DOM은 실제 DOM·브라우저 상태의 완전한 복사본이 아닙니다. 렌더 계산도 비용을 가지므로 모든 구현보다 빠르다는 뜻은 아닙니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 캡션 | 카운터 1→2이면 제목 텍스트가 1→2로 바뀔 수 있지만, 같은 위치의 독립된 입력을 매번 새로 만들지는 않습니다. 가상 DOM은 실제 DOM·브라우저 상태의 완전한 복사본이 아닙니다. 렌더 계산도 비용을 가지므로 모든 구현보다 빠르다는 뜻은 아닙니다. |
| 전체 관계·조건 설명 | 현재 state·props→컴포넌트 render→이전과 다음 결과 비교→필요한 DOM commit의 노드 4개·간선 3개다. render 중 기존 객체/DOM을 바꾸지 않으며 결과가 같으면 해당 DOM을 변경하지 않는다. commit→state 자동 변경의 순환 간선은 없다. |
| 전체 관계·조건 설명 | 현재 state·props→컴포넌트 render→이전과 다음 결과 비교→필요한 DOM commit의 노드 4개·간선 3개다. render 중 기존 객체/DOM을 바꾸지 않으며 결과가 같으면 해당 DOM을 변경하지 않는다. commit→state 자동 변경의 순환 간선은 없다. 모바일에서도 같은 연결과 분기·되돌림 대상을 유지한다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 commit | state: 1 / DOM: count 1·기존 input | 카운터 1이 표시되고 정해진 위치의 입력 DOM이 있다 | 입력 DOM의 브라우저 상태와 count 텍스트를 구분한다 |
| render2 | render input: 2 / DOM: 아직 1 | state 2로 다음 UI를 계산하되 DOM은 아직 이전 commit이다 | 컴포넌트 호출이 DOM 전체 교체를 뜻하지는 않는다 |
| commit2 | 텍스트: 2 / input: 같은 위치/종류 유지 조건 | 달라진 count 텍스트는 2가 되고 필요한 입력 정체성은 유지된다 | element 설명은 실제 DOM 상태 전체의 복사본이 아니다 |
데이터 변경: 컴포넌트의 상태(데이터)가 변경됩니다.
가상 DOM 생성: 리액트는 변경된 데이터를 기반으로 새로운 가상 DOM을 생성합니다.
이전 가상 DOM과 비교: 새로 생성된 가상 DOM을 이전 가상 DOM과 효율적인 알고리즘(Diffing Algorithm)을 사용하여 비교합니다.
최소한의 변경사항만 실제 DOM에 적용: 두 가상 DOM 간의 차이점(변경사항)만을 감지하여, 실제 DOM에 해당 부분만 최소한으로 업데이트합니다.
이 과정을 통해 리액트는 불필요한 DOM 조작을 최소화하고, 화면 렌더링 성능을 최적화할 수 있습니다.
개발자가 실제 DOM 조작 명령을 일일이 작성하지 않고 state를 갱신하면, React가 필요한 DOM 변경을 계산합니다. 실제 성능은 컴포넌트 계산량과 DOM 작업 등에도 달려 있습니다.
필요한 DOM 변경을 계산하는 구조가 성능 관리에 도움을 줄 수 있지만, 컴포넌트 계산량과 브라우저의 스타일·레이아웃·페인트도 비용을 갖습니다. 가상 DOM이 있다는 사실만으로 직접 DOM을 다루는 모든 구현보다 빠르다고 판정하지 않습니다.
단방향 데이터 흐름
리액트는 단방향 데이터 흐름 (Unidirectional Data Flow)을 지향합니다.
여기서 다루는 props 흐름은 부모 컴포넌트가 자식에게 입력을 전달하는 방향입니다. 자식의 클릭 이벤트와 갱신 요청까지 부모로 전달될 수 없다는 뜻은 아닙니다.
자식 컴포넌트는 부모로부터 전달받은 props (프로퍼티)를 통해 데이터를 읽을 수만 있고, 직접적으로 부모의 데이터를 변경할 수는 없습니다.
props는 읽기 전용 입력으로 다룹니다. 객체를 전달했다고 JavaScript가 내부 속성까지 자동으로 잠그는 것은 아니지만, 자식이 전달받은 객체를 직접 수정하는 방식은 이 계약을 깨므로 사용하지 않습니다.
만약 자식 컴포넌트가 부모의 데이터를 변경해야 할 필요가 있다면, 부모가 전달해준 함수를 호출하여 부모 컴포넌트 내부에서 상태를 변경하도록 요청해야 합니다.
이러한 단방향 데이터 흐름은 데이터의 변화를 추적하기 매우 용이하게 만듭니다.
데이터가 어느 방향으로든 자유롭게 흐를 수 있다면, 예측 불가능한 버그가 발생하기 쉽고, 문제의 원인을 파악하기 어려워집니다.
하지만 단방향 데이터 흐름은 데이터의 흐름을 명확하게 제한함으로써 애플리케이션의 안정성을 높이고, 디버깅을 훨씬 수월하게 만듭니다.
반대로 컴포넌트 깊이가 깊어질수록 props 전달이 길어지는 부담도 생깁니다.
그래서 일정 규모 이상에서는 Context, 전역 상태 관리 도구, 서버 상태 도구를 상황에 맞게 도입해 흐름을 단순화해야 합니다.
핵심은 단방향 원칙을 버리는 것이 아니라, 원칙을 유지한 채 전달 경로를 관리 가능한 형태로 재구성하는 데 있습니다.
예시
아래 코드는 환경 설정과 첫 앱을 마친 뒤 Vite 프로젝트의 src/App.jsx를 교체하여 실행할 수 있습니다. 지금은 부모 App이 수량을 기억하고 자식 CartLine이 읽는 순서를 따라가세요. useState의 두 결과를 배열 분해로 받으며, qty는 현재 수량, setQty는 다음 수량을 요청하는 함수입니다.
import { useState } from 'react';
function CartLine({ qty, onAdd }) {
return (
<section>
<p>상품 수량: {qty}</p>
<button type="button" onClick={onAdd}>하나 추가</button>
</section>
);
}
export default function App() {
const [qty, setQty] = useState(0);
const total = 1200 * qty;
return (
<>
<p>장바구니 배지: {qty}</p>
<CartLine qty={qty} onAdd={() => setQty((previous) => previous + 1)} />
<p>합계: {total}원</p>
<button type="button" disabled={qty === 0}>결제</button>
</>
);
}처음에는 수량·배지·합계가 모두 0이고 결제 버튼은 비활성입니다. 한 번 누르면 자식이 onAdd를 호출하고 부모의 setter가 실행됩니다. 다음 렌더에서 qty는 1, total은 1,200이고 세 표시와 버튼이 같은 입력을 사용합니다. total은 별도의 state가 아니라 매 렌더에서 계산한 값입니다. onClick={onAdd}는 함수를 전달하며 화면을 계산하는 동안 호출하지 않습니다.
독립 연습
가격 상수만 1,500으로, 시작 수량만 1로 바꾼 새 실행에서 두 번 누르면 수량·배지·합계·결제 상태는 어떻게 되나요? 원래 코드로 돌아온 다음 onAdd만 () => qty + 1로 바꾸고 한 번 누르면 무엇이 달라질까요?
입력 방향과 렌더 요청의 풀이
첫 조건은 수량과 배지가 3, 합계는 1,500 × 3 = 4,500원이고 결제는 활성입니다. 둘째 조건은 계산한 1을 반환할 뿐 setter를 호출하지 않아 수량·배지·합계는 0, 결제는 비활성으로 남습니다. 값을 계산하는 것과 React에 상태 갱신을 요청하는 것을 구분합니다. 클릭 요청은 자식에서 부모 함수로 전달되지만, 다음 qty 입력은 다시 부모에서 자식으로 내려갑니다.
앞에서 살펴본 네 가지 개념은 리액트를 선택할 때의 판단 기준으로도 연결됩니다.
상태 변화, 재사용성, 변경 추적이 중요한 화면일수록 리액트의 구조적 장점이 더 분명해집니다.
React의 장점 요약
지금까지 살펴본 리액트의 핵심 개념들을 바탕으로, 리액트가 가진 주요 장점들을 다시 한번 정리해 보겠습니다.
- 높은 생산성: 선언적 UI, 컴포넌트 재사용성, 가상 DOM을 통한 효율적인 업데이트는 개발자가 UI 구현에 드는 노력을 줄이고 핵심 비즈니스 로직에 집중할 수 있도록 하여 생산성을 크게 향상시킵니다.
- 성능 관리의 구조: UI 계산과 DOM 반영을 구분해 불필요한 작업을 살필 수 있습니다. 실제 응답 속도는 컴포넌트 계산량과 브라우저 처리 등을 측정해 판단합니다.
- 쉬운 유지보수: 컴포넌트 기반의 모듈화된 구조와 단방향 데이터 흐름은 코드의 가독성을 높이고, 복잡성을 줄여 장기적인 유지보수를 용이하게 합니다.
- 활발한 생태계와 커뮤니티: 기여자와 유지 관리자가 React를 발전시키며, 전 세계 수많은 개발자들이 사용하고 있는 만큼 방대한 자료와 라이브러리, 그리고 활발한 커뮤니티를 자랑합니다. 문제 발생 시 도움을 받기 용이하며, 끊임없이 새로운 기술과 도구들이 개발되고 있습니다.
- 크로스 플랫폼 개발 가능성: 리액트의 핵심 개념은 웹뿐만 아니라, 모바일 앱(React Native)이나 데스크톱 앱(Electron과 연동) 개발에도 확장될 수 있어 학습의 시너지가 큽니다.
이처럼 리액트는 현대 웹 애플리케이션 개발에 있어 강력하고 효율적인 도구로 자리매김했습니다.
정리하면 React의 핵심 개념은 모두 상태 변화가 어떤 화면 결과로 이어지는지 더 읽기 쉽게 만들기 위한 장치입니다.
다음 절부터는 이 관점을 실제 개발 환경과 첫 프로젝트 실행 흐름에 적용해 봅니다.
렌더 요청과 DOM 반영의 구분은 React의 render와 commit, 일반 변수와 state의 차이는 컴포넌트의 기억, props 계약은 props 전달에서 확인할 수 있습니다.