정의:
규칙:
생성 방법:
새 컴포넌트 파일 추가:
자바스크립트 함수로 생성:
function Header() {
return (
<header>
<h1>제목</h1>
<img src="logo.png" alt="로고" />
</header>
);
}
컴포넌트 이름 확인:
JSX 문법 준수:
// Header.jsx
import React from 'react';
import reactImg from './assets/react-core-concepts.png';
function Header() {
return (
<header>
<h1>React 동적 이미지 예제</h1>
<img src={reactImg} alt="React Core Concepts" />
</header>
);
}
export default Header;
커스텀 속성 추가:
컴포넌트를 사용할 때 원하는 속성을 추가
<CoreConcept
title="Components"
description="핵심 UI 빌딩블록"
image={componentsImg}
/>
다양한 데이터 전달:
컴포넌트 함수 매개변수로 props 사용:
일반적으로 props라는 이름 사용
function CoreConcept(props) {
return (
<li>
<img src={props.image} alt={props.title} />
<h3>{props.title}</h3>
<p>{props.description}</p>
</li>
);
}
객체로 전달된 Props 사용:
props.속성명
으로 접근여러 컴포넌트를 리스트로 렌더링할 때 고유 key 속성 필수
<CoreConcept
key={uniqueId}
title="Components"
description="핵심 UI 빌딩블록"
image={componentsImg}
/>
<CoreConcept
title={CORE_CONCEPTS[0].title}
description={CORE_CONCEPTS[0].description}
image={CORE_CONCEPTS[0].image}
/>
{CORE_CONCEPTS.map((concept, index) => (
<CoreConcept key={index} {...concept} />
))}
// CoreConcept.jsx
function CoreConcept({ title, description, image }) {
return (
<li>
<img src={image} alt={title} />
<h3>{title}</h3>
<p>{description}</p>
</li>
);
}
export default CoreConcept;
{CORE_CONCEPTS.map((concept, index) => (
<CoreConcept key={index} {...concept} />
))}
<CoreConcept
title={CORE_CONCEPTS[0].title}
description={CORE_CONCEPTS[0].description}
image={CORE_CONCEPTS[0].image}
/>
<CoreConcept {...CORE_CONCEPTS[0]} />
<CoreConcept concept={CORE_CONCEPTS[0]} />
export default function CoreConcept({ ...concept }) {
const { title, description, image } = concept;
return (
<li>
<img src={image} alt={title} />
<h3>{title}</h3>
<p>{description}</p>
</li>
);
}
export default function Button({ caption, type = "submit" }) {
return <button type={type}>{caption}</button>;
}
export default function CoreConcept({ title, description, image }) {
return (
<li>
<img src={image} alt={title} />
<h3>{title}</h3>
<p>{description}</p>
</li>
);
}
export default function CoreConcept({ concept }) {
const { title, description, image } = concept;
return (
<li>
<img src={image} alt={title} />
<h3>{title}</h3>
<p>{description}</p>
</li>
);
}
// TabButton.jsx
export default function TabButton({ children }) {
return (
<li>
<button>{children}</button>
</li>
);
}
// App.jsx
<TabButton>Components</TabButton>
<TabButton>Props</TabButton>
<TabButton>State</TabButton>
<TabButton>Lifecycle</TabButton>
// App.jsx
<ul>
<TabButton>Components</TabButton>
<TabButton>Props</TabButton>
<TabButton>State</TabButton>
<TabButton>Lifecycle</TabButton>
</ul>
// TabButton.jsx
export default function TabButton({ children }) {
return (
<li>
<button>{children}</button>
</li>
);
}
// TabButton.jsx
import React from 'react';
export default function TabButton({ children, onClick }) {
return (
<li>
<button onClick={onClick}>{children}</button>
</li>
);
}
// App.jsx
import React from 'react';
import { CORE_CONCEPTS } from './data';
import CoreConcept from './CoreConcept';
import TabButton from './TabButton';
function App() {
const handleClick = (buttonName) => {
console.log(`Hello World! Button ${buttonName} clicked.`);
};
return (
<div>
<Header />
<section id="core-concepts">
<h2>Core Concepts</h2>
<ul>
{CORE_CONCEPTS.map((concept, index) => (
<CoreConcept key={index} {...concept} />
))}
</ul>
</section>
<section id="examples">
<h2>Examples</h2>
<menu>
<TabButton onClick={() => handleClick('Components')}>Components</TabButton>
<TabButton onClick={() => handleClick('Props')}>Props</TabButton>
<TabButton onClick={() => handleClick('State')}>State</TabButton>
<TabButton onClick={() => handleClick('Lifecycle')}>Lifecycle</TabButton>
</menu>
</section>
</div>
);
}
export default App;
명령형 vs 선언형 코드
React의 선언적 이벤트 처리
<button onClick={handleClick}>Click Me</button>
<button onClick={handleClick()}>Click Me</button> // 즉시 실행됨
<button onClick={handleClick}>Click Me</button> // 클릭 시 실행
<TabButton onClick={() => handleClick('Components')}>Components</TabButton>
// App.jsx
<section id="dynamic-content">
<h2>Dynamic Content</h2>
<p>{dynamicContent}</p>
</section>
// App.jsx
import React, { useState } from 'react';
import TabButton from './TabButton';
function App() {
const [dynamicContent, setDynamicContent] = useState('Select a button');
const handleSelect = (content) => {
setDynamicContent(content);
};
return (
<div>
<section id="examples">
<menu>
<TabButton onSelect={() => handleSelect('Components Content')}>Components</TabButton>
<TabButton onSelect={() => handleSelect('Props Content')}>Props</TabButton>
<TabButton onSelect={() => handleSelect('State Content')}>State</TabButton>
<TabButton onSelect={() => handleSelect('Lifecycle Content')}>Lifecycle</TabButton>
</menu>
</section>
<section id="dynamic-content">
<h2>Dynamic Content</h2>
<p>{dynamicContent}</p>
</section>
</div>
);
}
export default App;
[컴포넌트 트리]
App
├── TabButton (x4)
│ └── onClick 이벤트 발생
│ ↓
│ handleSelect 함수 호출
│ └── 화살표 함수로 클릭된 버튼 식별자 전달
│
└── Dynamic Content
└── 버튼에 따라 동적으로 업데이트
1. TabButton 컴포넌트 클릭
(사용자가 버튼을 클릭)
└── 이벤트 발생: onClick
↓
2. 화살표 함수 실행
└── onSelect={() => handleSelect('버튼명')}
↓
3. App 컴포넌트의 handleSelect 함수 실행
└── handleSelect('버튼명')
↓
4. 버튼명(selectedButton)에 따라 if 조건문 처리
└── if (selectedButton === 'Components') { ... }
↓
5. setDynamicContent 함수 호출
└── setDynamicContent('선택된 콘텐츠')
↓
6. App 컴포넌트의 Dynamic Content 영역 업데이트
└── <p>{dynamicContent}</p>
App 컴포넌트
├── <menu> 요소
│ ├── TabButton 컴포넌트 ('Components')
│ ├── TabButton 컴포넌트 ('JSX')
│ ├── TabButton 컴포넌트 ('Props')
│ └── TabButton 컴포넌트 ('State')
│
└── Dynamic Content 영역
└── 클릭된 버튼에 따라 업데이트되는 <p> 태그
let tabContent = 'Please click a button';
const [tabContent, setTabContent] = useState('Please click a button');
const handleSelect = (selectedButton) => {
setTabContent(selectedButton);
};
const [selectedTopic, setSelectedTopic] = useState('Please click a button');
const handleSelect = (selectedButton) => {
setSelectedTopic(selectedButton);
};
// 올바른 사용
const [state, setState] = useState('초기값');
// 잘못된 사용
if (true) {
const [state, setState] = useState('초기값'); // 오류 발생
}
const handleSelect = (selectedButton) => {
setSelectedTopic(selectedButton);
console.log(selectedTopic); // 이전 상태 출력
};
const [selectedTopic, setSelectedTopic] = useState('components'); // 유효한 초기 값 설정
{EXAMPLES[selectedTopic] && (
<div id="tab-content">
<h3>{EXAMPLES[selectedTopic].title}</h3>
<p>{EXAMPLES[selectedTopic].description}</p>
<pre>{EXAMPLES[selectedTopic].code}</pre>
</div>
)}
{selectedTopic ? (
<div id="tab-content">
<h3>{EXAMPLES[selectedTopic].title}</h3>
<p>{EXAMPLES[selectedTopic].description}</p>
<pre>{EXAMPLES[selectedTopic].code}</pre>
</div>
) : (
<p>Please select a topic</p>
)}
{selectedTopic && (
<div id="tab-content">
<h3>{EXAMPLES[selectedTopic].title}</h3>
<p>{EXAMPLES[selectedTopic].description}</p>
<pre>{EXAMPLES[selectedTopic].code}</pre>
</div>
)}
let tabContent = <p>Please select a topic</p>;
if (selectedTopic) {
tabContent = (
<div id="tab-content">
<h3>{EXAMPLES[selectedTopic].title}</h3>
<p>{EXAMPLES[selectedTopic].description}</p>
<pre>{EXAMPLES[selectedTopic].code}</pre>
</div>
);
}
<button className="active">Tab</button>
<button className={isSelected ? 'active' : ''}>Tab</button>
<button className={isSelected ? 'active' : ''}>Tab</button>
const coreConcepts = ['Components', 'JSX', 'Props'];
return (
<ul>
{coreConcepts.map(concept => <CoreConcept key={concept.id} title={concept} />)}
</ul>
);
const coreConcepts = [{title: 'Components', description: '...'}, {title: 'JSX', description: '...'}];
return (
<div>
{coreConcepts.map(concept => (
<CoreConcept key={concept.title} title={concept.title} description={concept.description} />
))}
</div>
);
{coreConcepts.map(concept => (
<CoreConcept key={concept.title} title={concept.title} />
))}
// 고유한 값인 title을 key로 설정
<CoreConcept key={concept.title} title={concept.title} />
const element = <h1>Hello, World!</h1>;
const element = React.createElement('h1', null, 'Hello, World!');
const app = <App />;
const app = React.createElement(App, null);
const element = React.createElement('div', { className: 'container' }, 'Hello, World!');
JSX 코드의 빌드 과정
설명: JSX는 브라우저에서 바로 실행되지 않기 때문에, 빌드 과정에서 createElement로 변환되어 실행됨.
빌드 전 예시 (JSX):
const element = <h1>Hello, World!</h1>;
빌드 후 예시 (createElement로 변환):
const element = React.createElement('h1', null, 'Hello, World!');
빌드 과정 없이 리액트 사용
const element = <h1 className="header">Hello, World!</h1>;
const element = React.createElement('h1', { className: 'header' }, 'Hello, World!');
설명: JSX에서 여러 형제 요소를 반환하려면 하나의 부모 요소로 감싸야 함.
예시 (잘못된 코드):
return (
<h1>Header</h1>
<p>Main Content</p>
);
예시 (수정된 코드):
return (
<div>
<h1>Header</h1>
<p>Main Content</p>
</div>
);
div
태그 없이 형제 요소를 반환하고 싶을 때, React.Fragment 또는 빈 태그(<> </>
)를 사용.return (
<React.Fragment>
<h1>Header</h1>
<p>Main Content</p>
</React.Fragment>
);
<> </>
)로 Fragment 대체<> </>
)를 사용 가능.return (
<>
<h1>Header</h1>
<p>Main Content</p>
</>
);
root 컴포넌트가 너무 많은 역할을 하는 경우
컴포넌트를 쪼개는 이유
상태 관리(state management)
컴포넌트 재실행의 영향
상호작용이 있는 부분을 별도의 컴포넌트로 분리
상태를 사용하지 않는 부분도 컴포넌트로 분리
// 데이터 흐름 도식화
[컴포넌트 트리]
App
├── Header
│ └── selectedTopic 상태에 따라 텍스트 업데이트
│
├── TabButtons
│ └── 탭 버튼 클릭 시 selectedTopic 상태 변경
│
└── InteractiveSection
└── 상호작용 요소 관리
// 상태 관리 및 컴포넌트 재실행 흐름
1. 탭 버튼 클릭
└── selectedTopic 상태 업데이트
↓
2. App 컴포넌트 재실행
└── selectedTopic 상태가 변경되면 전체 App 컴포넌트가 재실행됨
↓
3. Header 컴포넌트도 재실행
└── Header 컴포넌트는 selectedTopic 상태를 사용하므로 재실행됨
↓
4. InteractiveSection은 상태 변경에 영향을 받지 않으면 재실행되지 않음
상태 관리 분리
상태 변경과 재실행 방지
// 파일 구조 예시
/components
├── CoreConcepts.jsx
├── Examples.jsx
├── TabButton.jsx
└── CoreConcept.jsx
/data.js
App.jsx
// 데이터 흐름 도식화
[컴포넌트 트리]
App
├── CoreConcepts
│ └── CORE_CONCEPTS 데이터를 렌더링
│
└── Examples
├── TabButton
│ └── 탭 버튼 클릭 시 handleSelect 함수로 selectedTopic 상태 업데이트
└── selectedTopic 상태에 따라 EXAMPLES 데이터 출력
// 상태 관리 및 컴포넌트 재실행 흐름
1. 탭 버튼 클릭
└── selectedTopic 상태 업데이트 (Examples 컴포넌트 내부)
↓
2. Examples 컴포넌트 재실행
└── selectedTopic 상태가 변경되어 해당 부분만 재실행됨
↓
3. App 컴포넌트는 재실행되지 않음
└── App 컴포넌트는 상태 관리에서 제외되었기 때문
// Section.jsx
export default function Section({ title, children, ...props }) {
return (
<section {...props}>
<h2>{title}</h2>
{children}
</section>
);
}
// Tabs.jsx
function Tabs({ buttons, children }) {
return (
<>
<menu>
{buttons}
</menu>
<div>
{children}
</div>
</>
);
}
// Examples.jsx
<Tabs buttons={
<>
<TabButton onClick={() => handleSelect('Components')}>Components</TabButton>
<TabButton onClick={() => handleSelect('JSX')}>JSX</TabButton>
</>
}>
{tabContent}
</Tabs>
<img src="/game-logo.png" alt="Hand-drawn Tic-Tac-Toe" />
import someImage from './assets/some-image.jpg';
<img src={someImage} alt="Example" />;
setState((prevState) => !prevState);
const [state, setState] = useState(initialState);
setPlayerName(newName);
input
필드에 값을 입력하면 onChange 이벤트가 실행됨.const handleChange = (event) => {
setPlayerName(event.target.value);
};
<input value={playerName} onChange={handleChange} />
<input value={editablePlayerName} onChange={handleChange} />
const rows = [
[null, null, null],
[null, null, null],
[null, null, null]
];
rows.map((row, rowIndex) => (
<li key={rowIndex}>
{row.map((col, colIndex) => (
<li key={colIndex}>
<button>{playerSymbol}</button>
</li>
))}
</li>
));
const [activePlayer, setActivePlayer] = useState('X'); // App 컴포넌트에서 상태 관리
1. App 컴포넌트: activePlayer 상태 설정 및 관리
↓
2. GameBoard 컴포넌트와 Player 컴포넌트에 activePlayer 상태 전달
↓
3. Player 컴포넌트: activePlayer가 'X' 또는 'O'인지에 따라 CSS 클래스 추가 (highlight-player)
↓
4. GameBoard 컴포넌트: activePlayer 상태를 받아 버튼 클릭 시 해당 기호(X 또는 O) 표시
↓
5. handleSelectSquare 함수 실행: 버튼 클릭 시 게임판 상태와 activePlayer 상태 업데이트
↓
6. setActivePlayer 함수: 현재 차례의 플레이어를 번갈아가며 설정
↓
7. UI 업데이트: 현재 차례의 플레이어에 강조 표시 및 게임판에 X 또는 O 기호 표시
// 데이터 흐름 도식화:
1. App 컴포넌트에서 gameTurns와 activePlayer 상태 관리
↓
2. GameBoard 컴포넌트에서 onSelectSquare 호출
↓
3. handleSelectSquare 함수 실행
↓
4. 클릭된 버튼의 정보 (rowIndex, colIndex)와 currentPlayer 계산
↓
5. gameTurns 배열에 새로운 턴 정보 추가
↓
6. 상태 업데이트 후 로그와 게임판에 반영
// 데이터 흐름 도식화:
1. App 컴포넌트에서 gameTurns 상태 관리
↓
2. GameBoard 컴포넌트에서 전달받은 gameTurns Prop으로 gameBoard 파생 후 게임보드 렌더
↓
3. GameBoard 컴포넌트에 turns 배열과 onSelectSquare 전달
↓
4. GameBoard 컴포넌트에서 각 버튼 클릭 시 onSelectSquare 호출
↓
5. handleSelectSquare 함수에서 클릭된 버튼의 row, col 정보와 player 정보를 gameTurns에 업데이트
↓
6. gameTurns 배열을 기반으로 gameBoard 파생 및 업데이트
↓
7. 업데이트된 gameBoard는 화면에 다시 렌더링
상태 끌어올리기 (State Lifting)
파생 상태 (Derived State)
이벤트 처리 (Event Handling)
데이터 불변성 (Data Immutability)
// 데이터 흐름 요약
데이터 흐름 순서 (텍스트로 요약)
1. App 컴포넌트에서 gameTurns 상태를 관리
- 상태는 각 컴포넌트 간에 공유될 수 있도록 끌어올려 관리함.
2. GameBoard 컴포넌트는 gameTurns 속성(prop)을 받아, 이를 기반으로 gameBoard 파생 상태를 계산
- gameBoard에는 각 플레이어의 기호가 나타나며, 이를 통해 게임 보드를 렌더링함.
3. App 컴포넌트는 GameBoard 컴포넌트에 turns 배열과 onSelectSquare 함수를 전달
- onSelectSquare는 각 버튼 클릭 시 GameBoard에서 호출될 콜백 함수임.
4. GameBoard 컴포넌트에서 각 버튼 클릭 시 onSelectSquare 호출
- 각 버튼은 클릭 시 row와 col 정보를 handleSelectSquare로 전달함.
5. handleSelectSquare 함수는 클릭된 버튼의 row, col 정보와 플레이어 기호를 gameTurns에 업데이트
- 업데이트된 gameTurns는 새로운 턴을 반영하여 최신 상태로 유지됨.
6. gameTurns 배열을 기반으로 gameBoard를 업데이트
- gameBoard는 최신 턴 정보를 반영해 변경됨.
7. 업데이트된 gameBoard는 화면에 렌더링
- 상태 변화에 따라 보드가 다시 그려지며 UI에 즉시 반영됨.
GameBoard 컴포넌트:
비활성화 조건:
상태에 따른 조건부 렌더링:
기능 확인:
// 데이터 흐름 요약 (텍스트로 설명)
1. App 컴포넌트에서 gameTurns와 GameBoard 상태 관리.
- gameTurns 상태는 각 플레이어의 선택을 기록하며, 버튼 상태를 결정하는 기준이 됨.
2. GameBoard 컴포넌트는 gameTurns와 onSelectSquare 콜백을 props로 받음.
- gameTurns 속성으로 각 버튼의 playerSymbol 상태를 파생하여 버튼 상태를 결정함.
3. 각 버튼에 조건부 disabled 설정:
- GameBoard의 각 버튼은 playerSymbol 상태에 따라 disabled 속성을 가짐
- playerSymbol이 X나 O면 버튼을 비활성화하고, null이면 활성화 상태로 설정됨.
4. 버튼 클릭 시 이벤트 처리:
- 클릭된 버튼의 row와 col 정보가 handleSelectSquare 함수로 전달됨.
- 선택된 정보는 gameTurns 상태에 추가되어 업데이트됨.
5. 업데이트된 상태로 UI 재렌더링:
- 버튼이 클릭됨에 따라 gameTurns 상태가 변경되고, 이에 따라 버튼 비활성화 상태가 유지됨.
- 이후 동일 버튼을 클릭해도 게임 진행에 영향을 미치지 않음.
틱택토 게임 승리 조건을 확인하는 로직을 구현하는 방법. 가능한 모든 우승 조합을 배열로 정의하고, 매 차례마다 현재 게임 상태와 비교하여 승리 여부를 판단한다. 우승 조건은 행과 열의 인덱스로 표현되며, 별도의 헬퍼 파일(winning-combinations.js)에 저장된다. 이 파일을 메인 앱 컴포넌트(App.jsx)에서 가져와서 사용하며, 승리 시 게임판 위에 승리 화면을 표시한다.
[1] 게임 시작
|
v
[2] 플레이어의 턴
|
v
[플레이어가 버튼 클릭]
|
v
[3] 게임 상태 업데이트]
|
v
[4] 우승 조건 확인]
|
v
+-----------------------+
| 우승 조건 일치? |
+-----------------------+
| |
예 아니오
| |
v v
[5] 게임 종료 처리 +----------------------+
| | 무승부인가? |
v +----------------------+
[승리 화면 표시] | |
| 예 아니오
v | |
[게임 종료] v v
[무승부 화면 표시] [다음 플레이어로 전환]
| |
v v
[게임 종료] [2] 플레이어의 턴으로 이동
이번 설명에서는 게임(예: 틱택토)의 우승 조건을 매 차례마다 동적으로 확인하는 로직을 최적화하는 방법을 다룹니다. 기존에는 handleSelectSquare 함수 내에서 hasWinner 상태를 별도로 관리했으나, 이는 중복된 로직으로 인해 불필요하다고 판단됩니다. 대신, gameTurns 상태에서 우승 여부를 파생시켜 관리하는 것이 효율적입니다.
이를 위해 gameBoard 상태를 App 컴포넌트로 끌어올려 상태 관리와 우승 조건 확인 로직을 중앙화합니다. GameBoard 컴포넌트는 이제 board 속성을 받아 게임판을 렌더링하며, 게임판의 기호는 App 컴포넌트에서 관리됩니다. 이러한 구조 변경을 통해 코드의 중복을 제거하고, 상태 관리의 일관성을 유지할 수 있습니다.
또한, WINNING_COMBINATIONS를 매 차례마다 재실행하여 모든 우승 조합을 검토함으로써 우승 여부를 신속하게 판단할 수 있습니다. 게임판의 각 버튼 기호를 gameBoard 상태에서 추출하여 우승 조건을 충족하는지 확인합니다. 이러한 최적화는 코드의 효율성과 가독성을 높이며, 유지보수를 용이하게 합니다.
[게임 시작]
|
v
[App 컴포넌트 초기화]
|
v
[gameBoard 상태 생성 및 초기화]
|
v
[GameBoard 컴포넌트 렌더링]
|
v
[플레이어가 버튼 클릭]
|
v
[handleSelectSquare 함수 실행]
|
v
[gameBoard 업데이트]
|
v
[우승 조건 검토]
|
v
+-------------------------+
| 우승 조건 충족 여부 확인 |
+-------------------------+
| |
예 아니오
| |
v v
[게임 종료 처리] [gameBoard 상태 업데이트]
| |
v v
[승리 화면 표시] [다음 플레이어 턴]
| |
v v
[게임 종료] [플레이어의 턴으로 돌아감]
이 설명은 틱택토 게임에서 우승자를 결정하는 로직을 구현하는 과정에 대한 것입니다.
우승 조건 검사:
우승자 처리 및 메시지 표시:
게임 진행 제어 필요성:
다음 단계:
[게임 시작]
↓
[우승 조합 반복문 시작]
↓
[첫 번째 버튼 기호 가져오기]
↓
[firstSymbol이 null인가?]
↙ ↘
예 아니오
↓ ↓
[다음 우승 조합으로] [두 번째, 세 번째 버튼 기호 가져오기]
↓
[기호들이 모두 동일한가?]
↙ ↘
예 아니오
↓ ↓
[우승자 결정 및 메시지 표시] [다음 우승 조합으로]
↓
[게임 종료 처리 필요]
↓
[게임 오버 화면 개선 작업]
// 게임 진행 및 종료 조건 확인:
[게임 시작]
↓
[플레이어의 턴 진행]
↓
[gameTurns 업데이트]
↓
[우승자 확인 로직]
↓
┌───────────────────────┐
│ 우승자가 있는가? │
└─────────┬─────────────┘
│
예 ▼ 아니오
[winner 변수 설정] │
│ ↓
│ [gameTurns.length === 9 인가?]
│ │
▼ 예▼ 아니오
[GameOver 컴포넌트 렌더링] [hasDraw = true] [게임 진행]
│ │
▼ ▼
[승리자 메시지] [GameOver 컴포넌트 렌더링]
│ │
▼ ▼
[Rematch 버튼] [무승부 메시지]
│ │
▼ ▼
[게임 재시작 기능] [Rematch 버튼]
// 컴포넌트 간 데이터 흐름:
[App 컴포넌트]
│
│-- (우승자 또는 무승부 확인)
│
│
│
▼
[GameOver 컴포넌트]
│
│-- (winner 속성 전달)
│
▼
[조건부 렌더링]
│
┌───────────────┐
│ winner가 있는가? │
└──────┬──────────┘
│
예▼ 아니오▼
[승리자 메시지] [무승부 메시지]
│ │
▼ ▼
[Rematch 버튼] [Rematch 버튼]
// 사용자 상호작용 및 게임 재시작 (추후 구현):
[사용자가 Rematch 버튼 클릭]
│
▼
[게임 상태 초기화]
│
▼
[게임 재시작]
이 설명은 React로 만든 게임(틱택토 등)에서 재대결(Rematch) 버튼을 통해 게임을 재시작하는 기능을 구현하는 과정과, 그 과정에서 발생한 버그의 원인과 해결 방법에 대한 것입니다.
게임 재시작 기능 구현:
버그 발생 및 원인 분석:
버그 해결:
// 게임 재시작 기능 흐름:
[Rematch 버튼 클릭]
│
▼
[handleRestart 함수 호출]
│
▼
[gameTurns 상태를 빈 배열로 재설정]
│
▼
[App 컴포넌트 재렌더링]
│
▼
[파생 상태들 업데이트]
├─ gameBoard 재생성
├─ activePlayer 재설정
└─ winner 상태 초기화
│
▼
[게임판 초기화 및 게임 재시작]
// 버그 발생 및 해결 흐름:
[초기 버그 상황]
│
[gameBoard 생성 시 initialGameBoard를 직접 수정]
│
▼
[게임 재시작 후에도 initialGameBoard가 수정된 상태]
│
▼
[게임판이 초기화되지 않고 이전 상태 유지]
│
▼
[버그 원인 파악]
└─ 배열이 참조 타입이므로 원본이 변경됨
│
▼
[해결책 적용]
└─ initialGameBoard의 깊은 복사본으로 gameBoard 생성
│
▼
[gameBoard가 새로운 배열로 생성됨]
│
▼
[게임 재시작 시 게임판이 올바르게 초기화]
// 컴포넌트 간 데이터 흐름:
[App 컴포넌트]
│
├─ handleRestart 함수 정의
│
├─ GameOver 컴포넌트에 onRestart 속성 전달
│ │
[GameOver 컴포넌트]
│
├─ Rematch 버튼에 onClick 핸들러로 onRestart 설정
│ │
[사용자 인터랙션]
│
└─ Rematch 버튼 클릭 시 onRestart 호출
│
▼
[App 컴포넌트의 handleRestart 실행]
// 게임 재시작 기능 흐름도
[사용자]
│
├─── 클릭 ───▶ [Rematch 버튼]
│
▼
[onRestart 호출]
│
▼
[handleRestart 실행]
│
▼
[gameTurns 상태 초기화]
│
▼
[게임 상태 재설정]
│
▼
[게임판 재렌더링]
// 버그 발생 및 해결 흐름도
[게임판 생성 시]
│
├─ (잘못된 방법) initialGameBoard 직접 수정
│ │
│ ▼
│ [원본 배열이 수정됨]
│ │
│ ▼
│ [게임 재시작 시 문제 발생]
│
└─ (올바른 방법) initialGameBoard 깊은 복사
│
▼
[새로운 gameBoard 생성]
│
▼
[게임 재시작 시 정상 동작]
게임에서 승자가 나왔을 때 기호('X', 'O') 대신 플레이어의 이름을 표시하기 위해, 플레이어 이름을 App 컴포넌트에서 접근할 수 있도록 해야 합니다. 하지만 Player 컴포넌트의 상태를 끌어올리면 매 타이핑마다 App 컴포넌트 전체가 재렌더링되어 비효율적입니다. 또한 Player 컴포넌트는 두 번 사용되므로 상태 관리가 복잡해집니다.
이를 해결하기 위해 App 컴포넌트에 새로운 상태 players를 추가하고, 플레이어 이름을 저장합니다. Player 컴포넌트에서 저장 버튼을 클릭할 때만 handlePlayerNameChange 함수를 호출하여 players 상태를 업데이트합니다. 이때 이전 상태를 보존하면서 변경된 플레이어의 이름만 업데이트합니다.
동적 속성 설정을 사용하여 객체의 키를 기호(symbol)로 지정하고, 해당 플레이어의 이름을 업데이트합니다. 게임 종료 시 승자의 기호를 사용하여 players 상태에서 승자 이름을 가져와 화면에 표시합니다.
[Player 컴포넌트 1] [Player 컴포넌트 2]
│ │
(로컬 상태로 이름 관리) (로컬 상태로 이름 관리)
│ │
├─── 저장 버튼 클릭 ───▶ │
│ onSave('X', newName) │
│ ├─── 저장 버튼 클릭 ───▶
│ │ onSave('O', newName)
│ │
───────────────────────────────────────────────
│
[App 컴포넌트: handlePlayerNameChange 호출]
│
setPlayers(prevPlayers => ({
...prevPlayers,
[symbol]: newName,
}))
│
[players 상태 업데이트: { X: 이름1, O: 이름2 }]
│
게임 진행...
│
게임 종료 시 승자 결정(winnerSymbol)
│
const winnerName = players[winnerSymbol]
│
<GameOver winner={winnerName} />
│
[GameOver 컴포넌트: 승자 이름 표시]
상태 업데이트 시 이전 상태 보존:
컴포넌트 간 데이터 전달:
렌더링 최적화:
목표: 게임에서 승자가 나왔을 때 플레이어의 기호('X', 'O') 대신 해당 플레이어의 이름을 화면에 표시하고, 이름 변경 시 승자 이름도 업데이트되도록 구현.
플레이어 이름 업데이트:
App 컴포넌트에서 승자 이름 표시:
동적 속성 접근 활용:
결과:
[Player 컴포넌트]
│
├─── (사용자 입력: 플레이어 이름 변경)
│ │
│ [playerName 상태 업데이트]
│
(저장 버튼 클릭)
│
└───▶ [handleEdit 함수 호출]
│
├── isEditing이 true인가?
│ │
│ 예▼
│
├── [onChangeName(symbol, playerName) 호출]
│
└── isEditing 상태 토글 (편집 모드 종료)
────────────────────────────────────────
[App 컴포넌트]
│
(온초점) ────────▶ [handlePlayerNameChange 함수 호출]
│
├── `players` 상태 업데이트
│ (이전 상태 보존, 변경된 이름만 업데이트)
│
└── [players 상태 변경]
────────────────────────────────────────
(게임 진행 중)
│
[우승자 결정 로직]
│
├── 우승자의 기호: firstSquareSymbol ('X' 또는 'O')
│
└───▶ [winner = players[firstSquareSymbol]]
│
└── 승자 이름을 players 상태에서 가져옴
────────────────────────────────────────
[GameOver 컴포넌트]
│
(승자 이름 표시)
│
└── `<p>{winner} won!</p>` 렌더링
이 설명은 React로 개발한 게임(틱택토 등)의 App 컴포넌트를 리팩토링하여 코드의 가독성과 유지보수성을 향상시키는 과정에 대한 것입니다.
App 컴포넌트의 복잡한 로직을 함수로 분리:
상수의 관리 및 명명 규칙 일관성 유지:
코드의 가독성과 유지보수성 향상:
결과 확인:
// App 컴포넌트 구조 개선 흐름:
[App 컴포넌트]
│
├─ 상태 및 변수 선언
│ ├─ gameTurns
│ ├─ players
│ └─ 기타 상태들
│
├─ 파생 상태 계산
│ ├─ gameBoard = deriveGameBoard(gameTurns)
│ └─ winner = deriveWinner(gameBoard, players)
│
├─ UI 렌더링
│ ├─ GameBoard 컴포넌트
│ ├─ Player 컴포넌트
│ └─ GameOver 컴포넌트 (필요 시)
│
└─ 이벤트 핸들러
├─ handleSelectSquare
├─ handleRestart
└─ handlePlayerNameChange
// 함수 분리 및 호출 흐름:
[App 컴포넌트] [외부 함수]
│ │
├─ gameBoard = deriveGameBoard(gameTurns) ───▶ [deriveGameBoard 함수]
│ │
└─ winner = deriveWinner(gameBoard, players) ─▶ [deriveWinner 함수]
// 상수 관리 흐름:
[상수 선언]
├─ INITIAL_GAME_BOARD
└─ PLAYERS
[App 컴포넌트]
├─ 초기 상태로 상수 사용
│ ├─ gameTurns 초기화
│ └─ players 초기화
│
└─ 파생 상태 계산 및 로직에서 상수 사용
// 전체 데이터 흐름 요약:
[사용자 인터랙션]
│
├─ 게임 시작
│
├─ 플레이어가 버튼 클릭
│ │
│ └─ handleSelectSquare 호출
│ │
│ └─ gameTurns 업데이트
│
├─ App 컴포넌트 재렌더링
│ │
│ ├─ gameBoard 재계산 (deriveGameBoard)
│ │
│ └─ winner 재계산 (deriveWinner)
│
├─ 우승자 결정 시
│ │
│ └─ GameOver 컴포넌트 표시
│
└─ Rematch 버튼 클릭 시
│
└─ handleRestart 호출
│
└─ gameTurns 초기화
// 프로젝트 구조 설정
src 폴더
│
├── components 폴더
│ │
│ └── Header.jsx
│
└── App.jsx
// Header 컴포넌트 생성 및 내보내기
[Header.jsx]
│
├── Header 컴포넌트 함수 정의
│ │
│ └── JSX 반환
│ ├── <header>
│ │ ├── <img>
│ │ └── <h1>
│ └── </header>
│
└── export default Header
// App 컴포넌트에서 Header 컴포넌트 사용
[App.jsx]
│
├── Header 컴포넌트 임포트
│ │
│ └── import Header from './components/Header';
│
└── App 컴포넌트 함수 정의
│
└── JSX 반환
│
├── <div>
│ ├── <Header />
│ └── {/* 기타 컴포넌트 */}
└── </div>
[UserInput 컴포넌트 생성]
│
▼
[<section id="user-input"> 반환]
│
▼
[<div className="input-group"> 생성]
│
▼
┌──────────────────────────────────────────┐
│ │
▼ ▼
[p] [p]
│ │
<label htmlFor="initial-investment"> <label htmlFor="annual-investment">
Initial Investment ($) Annual Investment ($)
</label> </label>
<input <input
type="number" type="number"
id="initial-investment" id="annual-investment"
required required
value={initialInvestment} value={annualInvestment}
onChange={...} onChange={...}
/> />
│ │
└──────────────────────────────────────────┘
│
▼
(동일한 방식으로 Expected Return과 Duration 입력 필드 추가)
│
▼
[각 입력 값에 대한 상태 관리]
│
▼
[양방향 바인딩 구현]
│
▼
[입력 값 변경 시 상태 업데이트]
│
▼
[상태 변경 시 입력 필드에 값 반영]
│
▼
[수집된 입력 값을 사용하여 투자 결과 계산]
상태 관리 및 양방향 바인딩 구현:
이벤트 핸들러에 익명 함수 사용:
App 컴포넌트에서 UserInput 컴포넌트 사용:
결과 데이터 계산 및 출력 준비:
// UserInput 컴포넌트 내부 흐름
[UserInput 컴포넌트]
│
├─ 상태 선언 및 초기화
│ └─ useState로 userInput 상태 생성
│ {
│ initialInvestment: 10000,
│ annualInvestment: 1200,
│ expectedReturn: 6,
│ duration: 10,
│ }
│
├─ handleChange 함수 정의
│ └─ (inputIdentifier, newValue) 받아 상태 업데이트
│ ├─ 이전 상태 복사 (...prevInput)
│ └─ 변경된 속성만 덮어쓰기 ([inputIdentifier]: newValue)
│
├─ 각 input 필드에 양방향 바인딩 구현
│ ├─ value 속성에 userInput의 해당 속성 설정
│ ├─ onChange 이벤트 핸들러에 익명 함수 사용
│ └─ handleChange 호출
│ ├─ 첫 번째 인자: 해당 속성의 이름 (문자열)
│ └─ 두 번째 인자: event.target.value
│
└─ 사용자 입력 값 변경 시
└─ handleChange 함수 호출로 상태 업데이트
// App 컴포넌트에서 UserInput 컴포넌트 사용
[App 컴포넌트]
│
├─ `UserInput` 컴포넌트 import
│
├─ JSX에서 Header와 UserInput을 렌더링
│ └─ 하나의 루트 요소로 감싸기 (React Fragment)
│
└─ App 컴포넌트 렌더링 시
└─ Header와 UserInput이 화면에 표시됨
// 전체 애플리케이션 흐름
[애플리케이션 시작]
│
├─ App 컴포넌트 렌더링
│ ├─ Header 컴포넌트 렌더링
│ └─ UserInput 컴포넌트 렌더링
│
├─ UserInput 컴포넌트에서 초기 상태 설정
│
├─ 사용자 입력 값 변경
│ └─ handleChange 함수 호출로 상태 업데이트
│ └─ userInput 상태 변경
│
├─ 상태 변경 시 입력 필드에 값 반영 (양방향 바인딩)
│
└─ 결과 데이터 계산 및 출력 예정
├─ 수집된 입력 값을 기반으로 계산
└─ 계산 결과를 테이블 등에 출력
// 시각적 흐름도:
[사용자]
│
└─ 입력 필드 값 변경 (e.g., initialInvestment)
│
▼
[onChange 이벤트 발생]
│
▼
[익명 함수 호출]
│
├─ inputIdentifier: 'initialInvestment'
└─ newValue: event.target.value
│
▼
[handleChange 함수 호출]
│
└─ setUserInput 상태 업데이트
├─ 이전 상태 복사 (...prevInput)
└─ 변경된 속성만 업데이트 ([inputIdentifier]: newValue)
│
▼
[userInput 상태 변경]
│
▼
[컴포넌트 재렌더링]
│
└─ 입력 필드의 value 속성에 새로운 상태 값 반영
// 상태 끌어올리기 및 상태 관리 흐름
[App 컴포넌트]
│
├─ userInput 상태 정의
│
├─ handleChange 함수 정의
│
├─ UserInput 컴포넌트에 props로 전달
│ ├─ userInput
│ └─ onChange (handleChange 함수)
│
├─ Results 컴포넌트에 props로 전달
│ └─ input (userInput 상태)
│
└─ App 컴포넌트가 상태의 중앙 관리자가 됨
// UserInput 컴포넌트의 데이터 흐름
[UserInput 컴포넌트]
│
├─ props 수신
│ ├─ userInput
│ └─ onChange
│
├─ 입력 필드에 value와 onChange 설정
│ ├─ value={userInput.initialInvestment}
│ └─ onChange 이벤트 발생 시
│ └─ onChange('initialInvestment', event.target.value) 호출
│
└─ 입력 값 변경 시 상위 컴포넌트의 상태 업데이트
// Results 컴포넌트의 데이터 흐름
[Results 컴포넌트]
│
├─ props 수신
│ └─ input (userInput 상태)
│
├─ calculateInvestmentResults 함수 호출
│ └─ input 값을 사용하여 결과 계산
│
├─ 계산된 결과를 테이블로 출력
│
└─ 결과가 변경될 때마다 업데이트된 결과 표시
// 전체 애플리케이션 데이터 흐름
[사용자 입력]
│
└─ UserInput 컴포넌트에서 입력 값 변경
│
└─ onChange 호출로 App 컴포넌트의 userInput 상태 업데이트
│
└─ 상태 변경으로 App, UserInput, Results 컴포넌트 재렌더링
│
├─ UserInput: 최신 userInput 값으로 입력 필드 업데이트
│
└─ Results: 새로운 input 값으로 결과 재계산 및 표시
// 시각적 흐름도
[사용자]
│
└─ 입력 필드 값 변경
│
▼
[UserInput 컴포넌트]
│
└─ onChange 이벤트 발생
│
▼
props.onChange('initialInvestment', newValue)
│
▼
[App 컴포넌트]
│
└─ handleChange 함수 실행
│
▼
userInput 상태 업데이트
│
├─ 새로운 userInput 상태
▼
[컴포넌트 재렌더링]
│
├─ UserInput 컴포넌트
│ └─ props.userInput의 최신 값으로 입력 필드 업데이트
│
└─ Results 컴포넌트
└─ props.input의 최신 값으로 결과 재계산 및 표시
투자 결과 계산:
결과 데이터 확인:
에러 발생 및 원인 분석:
버그 수정:
수정 후 결과 확인:
다음 단계:
// Results 컴포넌트에서 결과 계산 및 출력 준비:
[Results 컴포넌트]
│
├─ input 속성 수신 (userInput 상태)
│
├─ calculateInvestmentResults 함수 import
│
├─ 결과 계산:
│ resultsData = calculateInvestmentResults(input)
│
├─ 결과 확인:
│ console.log(resultsData)
│
├─ 개발자 도구에서 resultsData 확인
│
└─ 다음 단계로 결과를 테이블에 출력
// 버그 발생 및 수정 과정:
[문제 발생]
│
├─ 결과 값에 이상한 값, NaN 발생
│
├─ 원인 분석:
│ - input 필드의 값이 문자열
│ - 문자열과 숫자의 연산으로 문제 발생
│
└─ 버그 수정:
- 입력 값을 숫자로 변환
- handleChange 함수에서 +newValue 사용
// 버그 수정 후 흐름:
[버그 수정 후]
│
├─ App 컴포넌트의 handleChange 함수 수정
│
├─ 입력 값이 숫자로 저장됨
│
├─ calculateInvestmentResults 함수에서 올바른 계산 수행
│
└─ 결과 값이 정상적으로 표시됨
// 전체 데이터 흐름:
[사용자 입력]
│
└─ UserInput 컴포넌트에서 입력 값 변경
│
└─ onChange 이벤트 발생
│
└─ App 컴포넌트의 handleChange 함수 호출
│
└─ userInput 상태 업데이트 (+newValue로 숫자 변환)
│
└─ Results 컴포넌트에 새로운 input 속성 전달
│
└─ calculateInvestmentResults 함수로 결과 계산
│
└─ 결과 값 업데이트 및 화면에 표시
// Results 컴포넌트 데이터 흐름:
[Results 컴포넌트]
│
├─ props로 input 수신 (userInput 상태)
│
├─ calculateInvestmentResults 함수 호출하여 resultsData 계산
│
├─ initialInvestment 계산
│
├─ resultsData.map()을 사용하여 각 연도별 데이터 처리
│ ├─ totalInterest 계산
│ ├─ investedCapital 계산
│ ├─ 각 값을 formatter.format()으로 포맷팅
│
└─ 테이블 생성 및 데이터 출력
<!-- 테이블 구조: -->
<table id="result">
<thead>
<tr>
<th>Year</th>
<th>Total Savings</th>
<th>Interest (Year)</th>
<th>Total Interest</th>
<th>Invested Capital</th>
</tr>
</thead>
<tbody>
<!-- resultsData.map()을 통해 동적으로 생성된 행들 -->
<tr key={yearData.year}>
<td>{yearData.year}</td>
<td>{formatter.format(yearData.valueEndOfYear)}</td>
<td>{formatter.format(yearData.yearlyInterest)}</td>
<td>{formatter.format(totalInterest)}</td>
<td>{formatter.format(investedCapital)}</td>
</tr>
<!-- ... 반복 ... -->
</tbody>
</table>
// 계산 로직 흐름:
[initialInvestment 계산]
initialInvestment =
resultsData[0].valueEndOfYear -
resultsData[0].yearlyInterest -
resultsData[0].yearlyContribution;
[각 연도별 totalInterest 계산]
totalInterest =
yearData.valueEndOfYear -
initialInvestment -
yearData.yearlyContribution * yearData.year;
[각 연도별 investedCapital 계산]
investedCapital =
initialInvestment + yearData.yearlyContribution * yearData.year;
// 전체 앱 데이터 흐름:
[사용자 입력] → [App 컴포넌트] → [Results 컴포넌트]
│ │ │
└─ userInput 변경 ─┘ │
│
[calculateInvestmentResults 함수 호출]
│
[resultsData 생성]
│
[Results 컴포넌트에서 테이블 출력]
// 입력 값 변경 시 데이터 흐름
[사용자 입력 변경]
│
└─ UserInput 컴포넌트에서 입력 값 변경
│
└─ App 컴포넌트의 userInput 상태 업데이트
│
├─ inputIsValid 재계산
│ └─ inputIsValid = userInput.duration >= 1
│
└─ 컴포넌트 재렌더링
│
├─ inputIsValid가 true인 경우:
│ └─ Results 컴포넌트 렌더링
│
└─ inputIsValid가 false인 경우:
└─ 에러 메시지 표시
// 전체 앱의 데이터 흐름
[App 컴포넌트]
│
├─ userInput 상태 관리
├─ inputIsValid 계산
│
├─ UserInput 컴포넌트에 props로 userInput 전달
├─ Results 컴포넌트에 조건부로 input 전달
│
└─ 렌더링 내용 결정:
├─ inputIsValid가 true인 경우:
│ └─ <Results input={userInput} />
└─ inputIsValid가 false인 경우:
└─ <p className="center">Please enter a duration greater than 0.</p>
Strict Mode를 활성화하려면 index.js 또는 index.jsx 파일에서 React.StrictMode로 최상위 컴포넌트를 감싸면 됩니다.
// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
Strict Mode는 개발 단계에서 여러 가지 검사를 수행하여 잠재적인 문제를 찾아냅니다. 그중 하나는 컴포넌트 함수를 두 번씩 실행하는 것입니다. 이렇게 하면 부작용이 있는 코드나 잘못된 상태 관리로 인해 발생하는 문제를 쉽게 감지할 수 있습니다.
문제의 원인
문제 해결 방법
// Results.jsx
import { calculateInvestmentResults } from '../util/investment';
function Results({ input }) {
// results 배열을 함수 내부에서 선언
const results = calculateInvestmentResults(input);
return (
<table id="result">
{/* 테이블 헤더 및 바디 */}
</table>
);
}
export default Results;
이렇게 하면 입력 값을 변경할 때마다 results 배열이 초기화되고, 새로운 데이터로 테이블이 갱신됩니다. Strict Mode를 활성화한 상태에서 앱을 새로 고침하면 오류가 사라지고, 콘솔에도 에러 메시지가 나타나지 않습니다.
// 데이터 흐름 도식화:
[사용자 입력]
│
▼
[입력 필드 onChange 이벤트 발생]
│
└─ handleChange 함수 호출
├─ setEnteredPlayerName(event.target.value)
└─ setSubmitted(false)
│
▼
[enteredPlayerName 상태 업데이트]
│
▼
[input 요소의 value 속성에 enteredPlayerName 반영]
│
▼
(사용자가 입력한 내용이 입력 필드에 표시됨)
────────────────────────────────────
[사용자가 버튼 클릭]
│
▼
[버튼 onClick 이벤트 발생]
│
└─ handleClick 함수 호출
└─ setSubmitted(true)
│
▼
[submitted 상태가 true로 업데이트]
────────────────────────────────────
[화면 렌더링]
│
▼
[조건부 렌더링]
│
├─ submitted가 true인 경우:
│ └─ 'Welcome [enteredPlayerName]' 출력
│
└─ submitted가 false인 경우:
└─ 'Welcome unknown entity' 출력
<div>
로 감싸는 것은 DOM에 불필요한 요소를 추가하게 됩니다.<Fragment> ... </Fragment>
또는 단축 문법인 <> ... </>
를 사용하여 DOM에 영향을 주지 않으면서 여러 요소를 그룹화할 수 있습니다.// 표준 문법
import { Fragment } from 'react';
return (
<Fragment>
<h2>Welcome!</h2>
<p>React is awesome!</p>
</Fragment>
);
// 단축문법
return (
<>
<h2>Welcome!</h2>
<p>React is awesome!</p>
</>
);
[JSX에서 여러 요소를 반환하려 함]
│
▼
[문제 발생]
- 오류 메시지: "Adjacent JSX elements must be wrapped in an enclosing tag"
- 이유: 하나의 루트 요소만 허용됨
│
▼
[해결책 1: <div>로 감싸기]
│
▼
[단점]
- 불필요한 <div>가 DOM에 추가됨
- DOM 구조가 복잡해짐
│
▼
[해결책 2: 리액트 프래그먼트 사용]
│
├─ 방법 1: <Fragment> ... </Fragment>
│ - 'react'로부터 Fragment를 import
│
├─ 방법 2: <> ... </>
│ - 단축 문법, import 불필요
│
▼
[결과]
- 여러 요소를 그룹화하여 하나의 루트 요소로 만듦
- DOM에 불필요한 요소를 추가하지 않음
- 깨끗한 렌더링 결과 획득
JSX에서 여러 형제 요소 반환 ──▶ 오류 발생
│
▼
"하나의 루트 요소만 허용됩니다"
│
▼
┌───────────────┴───────────────┐
│ │
▼ ▼
<div>로 감싸기 리액트 프래그먼트 사용
│ │
▼ ▼
불필요한 <div> 추가 DOM에 영향 없음
│ │
▼ ▼
DOM 구조 복잡화 깨끗한 DOM 구조
Refs란 무엇인가?
컴포넌트 간소화 방법
코드 간소화의 이점
// 데이터 흐름 도식화
[사용자 입력]
│
▼
┌──────────────────────┐
│ <input type="text" │
│ ref={playerNameRef} /> │
└──────────────────────┘
│
[사용자가 입력 필드에 텍스트 입력]
│
▼
(입력 필드에만 값이 표시됨, 컴포넌트 상태 변화 없음)
────────────────────────────────────
[사용자가 'Set Name' 버튼 클릭]
│
▼
┌───────────────────────────┐
│ handleClick 함수 호출 │
└───────────────────────────┘
│
▼
[참조를 통해 입력 값 가져오기]
│
▼
┌───────────────────────────┐
│ playerNameRef.current.value │
└───────────────────────────┘
│
▼
[enteredPlayerName 상태 업데이트]
│
▼
[컴포넌트 리렌더링]
────────────────────────────────────
[조건부 렌더링]
│
▼
┌───────────────────────────┐
│ <h2>Welcome │
│ {enteredPlayerName || 'unknown entity'} │
│ </h2> │
└───────────────────────────┘
│
▼
[화면에 결과 출력]
// 시각적 흐름도:
1. 입력 단계
[사용자]
│
▼
입력 필드에 텍스트 입력
│
▼
<input type="text" ref={playerNameRef} />
2. 버튼 클릭 및 처리
[사용자]
│
▼
'Set Name' 버튼 클릭
│
▼
handleClick 함수 실행
│
▼
playerNameRef.current.value 로 입력 값 획득
│
▼
setEnteredPlayerName 로 상태 업데이트
3. 렌더링 및 출력
[React]
│
▼
상태 변경 감지, 컴포넌트 리렌더링
│
▼
조건부 렌더링으로 출력 결정
│
├─ enteredPlayerName 이 빈 문자열이 아닌 경우:
│ 'Welcome [이름]' 출력
│
└─ enteredPlayerName 이 빈 문자열인 경우:
'Welcome unknown entity' 출력
4. 최종 출력
[화면]
│
▼
사용자에게 결과 표시
이와 같이 useRef를 활용하여 컴포넌트를 간소화하고, 필요한 시점에만 입력 값을 참조하여 상태를 업데이트함으로써 더욱 효율적인 React 컴포넌트를 만들 수 있습니다. 불필요한 상태 관리와 이벤트 핸들러를 제거하여 코드가 간결해지고, 유지보수가 쉬워집니다.
리액트에서 refs(참조)를 사용하여 입력 필드를 초기화하려는 아이디어는 이해할 수 있습니다.
handleClick
함수 내에서 playerName.current.value = ''
로 입력 값을 지우면 원하는 동작을 얻을 수 있습니다.
그러나 이 접근 방식은 리액트의 선언적 프로그래밍 철학과는 맞지 않습니다.
playerName.current.value = ''와 같이 직접 DOM 요소의 값을 변경하는 것은 명령적 프로그래밍에 해당하며, 리액트의 선언적 접근 방식에서 벗어납니다. 이는 다음과 같은 문제를 일으킬 수 있습니다:
UI와 상태의 불일치: 리액트의 상태 관리와 별개로 DOM을 직접 변경하면, 상태와 UI 간의 일관성이 깨질 수 있습니다.
예상치 못한 동작: 리액트의 라이프사이클과 최적화 메커니즘을 우회하게 되어 버그가 발생할 가능성이 높아집니다.
올바른 접근 방식: 상태를 활용한 입력 값 관리
// 1. 상태 변수 추가
const [enteredPlayerName, setEnteredPlayerName] = useState('');
// 2. 입력 필드에 상태 연결
<input
type="text"
value={enteredPlayerName}
onChange={(event) => setEnteredPlayerName(event.target.value)}
/>
// 3. 버튼 클릭 시 상태 초기화
function handleClick() {
// 필요한 로직 수행
setEnteredPlayerName(''); // 상태를 빈 문자열로 설정하여 입력 필드 초기화
}
// 4. 이름 출력 시 상태 사용
<h2>Welcome {enteredPlayerName || 'unknown entity'}</h2>
이렇게 변경하면 다음과 같은 장점이 있습니다:
요약
상태(state)란 무엇인가?
참조(refs)란 무엇인가?
상태를 참조로 대체할 수 없는 이유
상태와 참조의 목적과 사용 시기
참조와 상태를 함께 사용하는 예시
상황: 입력 필드의 값을 읽어와서 상태로 관리하여 UI에 반영해야 할 때.
구현 방법:
const [enteredPlayerName, setEnteredPlayerName] = useState('');
const playerNameRef = useRef();
function handleClick() {
const enteredName = playerNameRef.current.value;
setEnteredPlayerName(enteredName);
}
설명:
// 데이터 흐름 도식화
[컴포넌트 초기 렌더링]
│
├─ 상태 초기화:
│ enteredPlayerName = ''
├─ 참조 생성:
│ playerNameRef = { current: undefined }
│
└─ JSX 렌더링:
<input type="text" ref={playerNameRef} />
<h2>Welcome {enteredPlayerName || 'unknown entity'}</h2>
────────────────────────────────────────
[사용자 입력]
│
└─ 입력 필드에 텍스트 입력 (예: "Alice")
(참조된 input 요소에 값이 입력됨)
────────────────────────────────────────
[사용자가 '이름 설정하기' 버튼 클릭]
│
└─ handleClick 함수 호출
│
├─ playerNameRef.current.value로 입력 값 가져오기
│ enteredName = "Alice"
│
├─ setEnteredPlayerName(enteredName) 호출
│ 상태 업데이트, 컴포넌트 재렌더링 트리거
│
└─ playerNameRef.current.value = '' 로 입력 필드 초기화
────────────────────────────────────────
[컴포넌트 재렌더링]
│
├─ 상태 값:
│ enteredPlayerName = "Alice"
├─ 참조 값:
│ playerNameRef.current = [input 요소]
│
└─ JSX 렌더링:
<input type="text" ref={playerNameRef} value="" />
<h2>Welcome Alice</h2>
────────────────────────────────────────
[화면 출력]
│
├─ 입력 필드는 비어 있음
└─ 화면에 "Welcome Alice" 출력
// 1. 초기 렌더링 단계
[초기 상태]
│
├─ enteredPlayerName = ''
├─ playerNameRef = { current: undefined }
│
└─ <input ref={playerNameRef} />
// 2. 사용자 입력 단계
[사용자 입력]
│
└─ 입력 필드에 "Alice" 입력
(playerNameRef.current.value = "Alice")
// 3. 버튼 클릭 및 처리 단계
[버튼 클릭]
│
└─ handleClick 함수 실행
│
├─ enteredName = playerNameRef.current.value
│ (enteredName = "Alice")
├─ setEnteredPlayerName(enteredName)
│ (상태 업데이트, 컴포넌트 재렌더링)
└─ playerNameRef.current.value = ''
(입력 필드 초기화)
// 4. 컴포넌트 재렌더링 단계
[재렌더링]
│
├─ enteredPlayerName = "Alice"
└─ 화면 업데이트
│
├─ 입력 필드는 빈 문자열
└─ <h2>Welcome Alice</h2> 출력
// 5. 최종 결과
[화면 표시]
│
├─ 입력 필드: 빈 상태
└─ 화면에 "Welcome Alice" 출력
// 시각적 데이터 흐름
App 컴포넌트
│
└── <div id="challenges">
│
├── <TimerChallenge title="Easy" targetTime={1} />
│
├── <TimerChallenge title="Not Easy" targetTime={5} />
│
├── <TimerChallenge title="Getting tough" targetTime={10} />
│
└── <TimerChallenge title="Pros only" targetTime={15} />
각 TimerChallenge 컴포넌트
│
├── Props:
│ - title
│ - targetTime
│
├── State:
│ - isActive
│
├── 렌더링되는 UI:
│ - <h2>{title}</h2>
│ - <p className="challenge-time">{targetTime} second(s)</p>
│ - <p>
│ <button onClick={...}>
│ {isActive ? "도전 끝내기" : "도전 시작"}
│ </button>
│ </p>
│ - <p className={isActive ? "active" : ""}>
│ {isActive ? "타이머가 실행 중입니다..." : "타이머가 비활성화됐습니다"}
│ </p>
│
└── 이벤트 흐름:
- 버튼 클릭 시:
- isActive 상태 토글
- UI 업데이트
- 타이머 로직 실행 (추후 구현)
// 데이터 흐름 도식화
[사용자가 '도전 시작' 버튼 클릭]
│
▼
[handleStart 함수 호출]
│
├─ setTimerStarted(true)
├─ setTimerExpired(false)
├─ timerRef.current = setTimeout(...)
│ └─ 타이머 설정 (targetTime * 1000 밀리초 후 실행)
│
▼
[타이머 실행 중]
│
├─ UI 업데이트
│ - 버튼 텍스트: '중지'
│ - 메시지: '타이머가 실행 중입니다...'
│ - 클래스 이름: 'active'
│
│
[사용자가 '중지' 버튼 클릭]
│
▼
[handleStop 함수 호출]
│
├─ clearTimeout(timerRef.current)
└─ setTimerStarted(false)
└─ UI 업데이트
- 버튼 텍스트: '도전 시작'
- 메시지: '타이머가 비활성화됐습니다'
[타이머가 만료된 경우]
│
▼
[타이머 콜백 함수 실행]
│
├─ setTimerExpired(true)
└─ setTimerStarted(false)
└─ UI 업데이트
- '졌습니다' 메시지 표시
- 버튼 텍스트: '도전 시작'
- 메시지: '타이머가 비활성화됐습니다'
문제 상황 이해하기
참조(useRef)의 사용 이유
참조 생성 및 사용 방법
참조 생성:
import { useRef } from 'react';
function TimerChallenge() {
const timerRef = useRef();
// ...
}
타이머 설정 시 참조에 저장:
function handleStart() {
timerRef.current = setTimeout(() => {
// 타이머 만료 시 실행할 코드
}, targetTime * 1000);
}
타이머 취소 시 참조 사용:
function handleStop() {
clearTimeout(timerRef.current);
}
상태와 참조의 차이점
타이머 시작 및 중지 로직 구현
상태 변수 정의:
const [timerStarted, setTimerStarted] = useState(false);
const [timerExpired, setTimerExpired] = useState(false);
타이머 시작 함수:
function handleStart() {
setTimerStarted(true);
setTimerExpired(false);
timerRef.current = setTimeout(() => {
setTimerExpired(true);
setTimerStarted(false);
}, targetTime * 1000);
}
타이머 중지 함수:
function handleStop() {
clearTimeout(timerRef.current);
setTimerStarted(false);
}
버튼 클릭 시 동작 제어
조건부로 함수 연결:
<button onClick={timerStarted ? handleStop : handleStart}>
{timerStarted ? '중지' : '도전 시작'}
</button>
여러 컴포넌트 인스턴스에서의 동작
참조 사용 시 주의사항
// 데이터 흐름 도식화
[TimerChallenge 컴포넌트 인스턴스 생성]
│
├─ 상태 변수 초기화:
│ - timerStarted = false
│ - timerExpired = false
│
└─ 참조 생성:
- timerRef = useRef()
────────────────────────────────────
[사용자가 '도전 시작' 버튼 클릭]
│
▼
[handleStart 함수 실행]
│
├─ setTimerStarted(true)
├─ setTimerExpired(false)
├─ timerRef.current = setTimeout(...)
│ └─ 타이머 설정 (targetTime * 1000 밀리초 후 실행)
│
└─ 컴포넌트 재렌더링 (timerStarted 상태 변경으로 인해)
────────────────────────────────────
[타이머 실행 중]
│
├─ 사용자가 '중지' 버튼 클릭 가능
│
└─ 타이머 만료 시까지 대기
────────────────────────────────────
[사용자가 '중지' 버튼 클릭]
│
▼
[handleStop 함수 실행]
│
├─ clearTimeout(timerRef.current)
├─ setTimerStarted(false)
│
└─ 컴포넌트 재렌더링 (timerStarted 상태 변경으로 인해)
[타이머 만료 전에 중지됨]
│
└─ '졌습니다' 메시지 표시되지 않음
────────────────────────────────────
[타이머가 만료된 경우]
│
▼
[setTimeout 콜백 함수 실행]
│
├─ setTimerExpired(true)
├─ setTimerStarted(false)
│
└─ 컴포넌트 재렌더링 (상태 변경으로 인해)
│
└─ '졌습니다' 메시지 표시
────────────────────────────────────
[여러 컴포넌트 인스턴스 동작]
│
├─ 각 인스턴스는 고유한 timerRef를 가짐
│
├─ 인스턴스 A에서 타이머 시작
│
├─ 인스턴스 B에서 타이머 시작
│
├─ 인스턴스 A에서 타이머 중지
│
└─ 각 인스턴스의 타이머는 독립적으로 관리됨
<dialog>
HTML 요소를 사용하여 내장 스타일과 기능(백드롭 등)을 활용했습니다.<dialog>
요소에 open 속성을 직접 추가하면 백드롭이 적용되지 않습니다.<dialog>
요소에 연결합니다.// ResultModal.jsx
import React, { forwardRef } from 'react';
const ResultModal = forwardRef(({ result, targetTime }, ref) => (
<dialog className="ResultModal" ref={ref}>
<h2>{result === 'lost' ? '졌습니다' : '이겼습니다'}</h2>
<p>
목표 시간은 <strong>{targetTime}</strong>초였습니다.
</p>
<form method="dialog">
<button>닫기</button>
</form>
</dialog>
));
export default ResultModal;
[TimerChallenge 컴포넌트]
│
├─ 상태 관리:
│ - timerExpired (타이머 만료 여부)
│ - 기타 게임 상태
│
├─ 참조 생성:
│ - dialogRef = useRef()
│
├─ useEffect 훅 설정:
│ - timerExpired가 true로 변경될 때 실행
│ - dialogRef.current.showModal() 호출
│
├─ JSX 렌더링:
│ - <section className="challenge">...</section>
│ - {timerExpired && <ResultModal ref={dialogRef} ... />}
│
└─ 사용자 입력 처리:
- 타이머 시작/중지 버튼 클릭 등
[상태 변경: timerExpired === true]
│
└─ useEffect 실행
│
└─ dialogRef.current.showModal() 호출
│
└─ [ResultModal의 <dialog> 요소 열림]
- 백드롭 적용
- 다이얼로그 표시
[사용자]
│
└─ 다이얼로그에서 '닫기' 버튼 클릭
│
└─ <form method="dialog">를 통해 다이얼로그 닫힘
[다이얼로그 닫힘 후]
│
└─ 게임 재시작 또는 다른 동작 가능
부모 컴포넌트에서 ref 생성
// TimerChallenge.jsx
import React, { useRef } from 'react';
function TimerChallenge(props) {
const dialogRef = useRef();
// ...
}
ref를 자식 컴포넌트에 전달
// TimerChallenge.jsx
return (
<>
{/* 기타 컴포넌트 */}
<ResultModal ref={dialogRef} result="lost" targetTime={targetTime} />
</>
);
자식 컴포넌트에서 forwardRef 사용하여 ref 받기
// ResultModal.jsx
import React, { forwardRef } from 'react';
const ResultModal = forwardRef((props, ref) => {
const { result, targetTime } = props;
return (
<dialog className="ResultModal" ref={ref}>
{/* 모달 내용 */}
</dialog>
);
});
export default ResultModal;
forwardRef로 컴포넌트 감싸기 및 ref 매개변수 받기
ref를 자식 컴포넌트의 DOM 요소에 연결
<dialog>
요소의 ref 속성에 연결합니다.부모 컴포넌트에서 ref를 통해 자식의 DOM 요소 제어
// TimerChallenge.jsx
import React, { useEffect } from 'react';
useEffect(() => {
if (timerExpired) {
dialogRef.current.showModal();
}
}, [timerExpired]);
<dialog>
요소에 접근하고 showModal() 메서드를 호출합니다.ResultModal 컴포넌트를 항상 렌더링
[TimerChallenge 컴포넌트]
│
├─ 참조 생성:
│ └─ const dialogRef = useRef();
│
├─ 자식 컴포넌트에 ref 전달:
│ └─ <ResultModal ref={dialogRef} ... />
│
├─ 상태 변화 감지 (timerExpired):
│ └─ useEffect(() => {
│ if (timerExpired) {
│ dialogRef.current.showModal();
│ }
│ }, [timerExpired]);
│
└─ dialogRef를 통해 자식의 <dialog> 요소 제어
──────────────────────────────────────────
[ResultModal 컴포넌트]
│
├─ forwardRef로 컴포넌트 정의:
│ └─ const ResultModal = forwardRef((props, ref) => { ... });
│
├─ 전달받은 ref를 <dialog>에 연결:
│ └─ <dialog ref={ref}> ... </dialog>
│
└─ 부모 컴포넌트에서 전달된 ref를 통해 제어 가능
──────────────────────────────────────────
[실행 흐름]
│
├─ TimerChallenge에서 타이머 시작
│
├─ 타이머 만료 시 timerExpired 상태 변경
│
├─ useEffect 훅에서 dialogRef.current.showModal() 호출
│
├─ ResultModal의 <dialog> 요소 표시
│
└─ 사용자에게 모달 창이 나타남
// 1. 참조 생성 및 전달
TimerChallenge
┌────────────────────────────────────────────┐
│ const dialogRef = useRef(); │
│ │
│ return ( │
│ <> │
│ {/* 기타 컴포넌트 */} │
│ <ResultModal ref={dialogRef} ... /> │
│ </> │
│ ); │
└────────────────────────────────────────────┘
│
▼
ResultModal
┌────────────────────────────────────────────┐
│ const ResultModal = forwardRef((props, ref) => { │
│ return ( │
│ <dialog ref={ref}> │
│ {/* 모달 내용 */} │
│ </dialog> │
│ ); │
│ }); │
└────────────────────────────────────────────┘
// 2. 상태 변화에 따른 모달 제어
TimerChallenge
┌────────────────────────────────────────────┐
│ useEffect(() => { │
│ if (timerExpired) { │
│ dialogRef.current.showModal(); │
│ } │
│ }, [timerExpired]); │
└────────────────────────────────────────────┘
│
▼
ResultModal의 <dialog> 요소가 표시됨
// 1. forwardRef로 컴포넌트 감싸기
// ResultModal.jsx
import React, { forwardRef } from 'react';
const ResultModal = forwardRef((props, ref) => {
// 컴포넌트 내용
});
export default ResultModal;
// 2. useImperativeHandle 훅 사용하기
// ResultModal.jsx
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
const ResultModal = forwardRef((props, ref) => {
const { result, targetTime } = props;
const dialogRef = useRef();
useImperativeHandle(ref, () => ({
open: () => {
dialogRef.current.showModal();
},
}));
return (
<dialog ref={dialogRef}>
{/* 모달 내용 */}
</dialog>
);
});
export default ResultModal;
// 3. 부모 컴포넌트에서 메소드 호출하기
// TimerChallenge.jsx
import React, { useRef, useEffect } from 'react';
import ResultModal from './ResultModal';
function TimerChallenge(props) {
const resultModalRef = useRef();
useEffect(() => {
if (timerExpired) {
resultModalRef.current.open();
}
}, [timerExpired]);
return (
<>
{/* 기타 컴포넌트 */}
<ResultModal ref={resultModalRef} result="lost" targetTime={targetTime} />
</>
);
}
export default TimerChallenge;
[타이머 시작 버튼 클릭]
|
v
[setInterval 설정]
|
v
[10ms마다 실행되는 함수]
|
v
[timeRemaining 상태 업데이트]
|
+------------------------------+
| |
v v
[timeRemaining > 0] [timeRemaining <= 0]
| |
v v
[타이머 진행 중] [clearInterval 호출]
| |
v v
[UI 업데이트] [모달(ResultModal) 표시]
| |
v v
[타이머 중지 버튼 클릭 시]
|
v
[handleStop 함수 실행]
|
v
[clearInterval 호출]
|
v
[모달(ResultModal) 표시]
[TimerChallenge 컴포넌트]
|
v
[타이머 시작 및 timeRemaining 상태 업데이트]
|
v
[타이머 종료 조건 검사]
|
+------------------------------+
| |
v v
[timeRemaining > 0] [timeRemaining <= 0]
| |
v v
[사용자 중지 버튼 클릭] [타이머 만료]
| |
v v
[ResultModal 컴포넌트 호출] [ResultModal 컴포넌트 호출]
| |
v v
[remainingTime 속성 전달] [remainingTime 속성 전달]
| |
v v
[모달에서 userLost 계산] [모달에서 userLost 계산]
| |
v v
[성공 메시지 및 점수 표시] [패배 메시지 표시]
| |
v v
[모달 내 폼 제출(onSubmit)]
|
v
[onReset 호출(handleReset)]
|
v
[timeRemaining 상태 초기화]
|
v
[새로운 게임 시작]
이번 시간에는 타이머 챌린지 앱의 ResultModal 컴포넌트를 개선하여 사용자의 점수와 성공 메시지를 표시했습니다. 점수는 남은 시간과 목표 시간을 기반으로 계산되며, 계산 공식은 다음과 같습니다:
Score = (1 - (remainingTime / (targetTime * 1000))) * 100
여기서 remainingTime은 밀리초 단위이고, targetTime은 초 단위이므로 targetTime에 1000을 곱해 단위를 맞췄습니다. 계산 순서를 보장하기 위해 괄호를 사용했고, 점수를 깔끔하게 표시하기 위해 반올림을 적용했습니다.
사용자가 패배하지 않은 경우(!userLost), 모달에 점수와 성공 메시지를 표시합니다. 남은 시간이 0에 가까울수록 더 높은 점수를 받게 되며, 점수는 0부터 100 사이의 값입니다. 이 방식은 5초 챌린지 등 다른 모든 타이머 챌린지에도 동일하게 적용됩니다.
변경 사항을 저장하고 테스트하여 점수 계산과 표시가 올바르게 동작하는지 확인했습니다. 이를 통해 사용자에게 더 나은 피드백과 게임 경험을 제공할 수 있게 되었습니다.
[사용자: 타이머 시작]
|
v
[타이머 작동 - 남은 시간 감소]
|
v
[사용자: 타이머 멈춤]
|
v
[남은 시간(remainingTime) 체크]
|
v
[패배 여부 확인: userLost = remainingTime <= 0]
|
+-----------------------------+
| |
v v
[사용자 승리 (!userLost)] [사용자 패배 (userLost)]
| |
v v
[점수 계산] [패배 메시지 표시]
Score = (1 - (remainingTime / (targetTime * 1000))) * 100
| |
v v
[점수 반올림 및 표시] [모달에 패배 메시지 표시]
| |
v v
[모달에 성공 메시지와 점수 표시] |
| |
v v
[사용자: 모달 확인 및 닫기]
|
v
[타이머 및 상태 초기화]
|
v
[새로운 게임 시작 가능]
<dialog>
요소를 사용하면 웹사이트 방문자가 키보드의 ESC(Escape) 키를 눌러 열린 대화창을 닫을 수 있습니다.
현재, 이것은 버튼 클릭으로 대화상자를 닫는 것과 달리, onReset함수를 트리거하지 않습니다.
ESC 키로 대화창을 닫을 때 onReset이 트리거되도록 하려면 <dialog>
요소에 내장된 onClose 속성을 추가하고 그 값을 onReset속성에 바인딩해야 합니다.
다음과 같습니다:
<dialog ref={dialog} className="result-modal" onClose={onReset}
...
</dialog>
React Portal의 특징과 사용 방법에 대해 알아보겠습니다. 기존에 ResultModal 컴포넌트는 TimerChallenge 컴포넌트의 JSX 코드 내에 렌더링되어, DOM 구조상 깊게 중첩되어 있었습니다. 이는 시각적으로는 문제가 없어 보이지만, 기술적으로는 접근성이나 스타일링에서 문제가 발생할 수 있습니다.
이를 해결하기 위해 React Portal을 사용하여 ResultModal 컴포넌트를 DOM 내의 다른 위치, 즉 루트에 가까운 div 요소(id="modal")에 렌더링하도록 변경했습니다. 이를 통해 모달과 같은 오버레이 요소를 DOM 트리의 최상위 수준에 배치하여 구조를 명확히 하고, 잠재적인 스타일 및 접근성 문제를 방지할 수 있습니다.
포털을 사용하기 위해 react-dom 라이브러리에서 createPortal 함수를 임포트하고, 렌더링할 JSX 코드를 createPortal로 감싸고 두 번째 인수로 대상 DOM 노드를 전달합니다. 대상 DOM 노드는 document.getElementById('modal')를 사용하여 선택합니다.
[React 컴포넌트 계층 구조]
|
v
[TimerChallenge 컴포넌트]
|
v
[ResultModal 컴포넌트 렌더링]
|
v
[createPortal로 ResultModal JSX 감싸기]
|
v
[ReactDOM.createPortal( <ResultModal />, document.getElementById('modal'))]
|
v
[DOM의 다른 위치에 ResultModal 렌더링]
(예: <div id="modal"></div>)
|
v
[사용자에게 모달 표시]
// ResultModal.tsx
import React from 'react';
interface ResultModalProps {
// 필요한 props를 정의합니다.
onClose: () => void;
}
const ResultModal: React.FC<ResultModalProps> = ({ onClose }) => {
return (
<div className="modal-overlay">
<div className="modal-content">
<h2>모달 제목</h2>
<p>모달 내용</p>
<button onClick={onClose}>닫기</button>
</div>
</div>
);
};
export default ResultModal;
// TimerChallenge.tsx
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import ResultModal from './ResultModal';
const TimerChallenge: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleModalClose = () => {
setIsModalOpen(false);
};
return (
<div>
<h1>타이머 챌린지</h1>
{/* 타이머 관련 코드 */}
<button onClick={() => setIsModalOpen(true)}>모달 열기</button>
{isModalOpen &&
ReactDOM.createPortal(
<ResultModal onClose={handleModalClose} />,
document.getElementById('modal') as HTMLElement
)}
</div>
);
};
export default TimerChallenge;
<!-- index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>React App</title>
</head>
<body>
<div id="root"></div>
<div id="modal"></div> <!-- 모달을 렌더링할 대상 DOM 노드 -->
</body>
</html>
리액트 프로젝트 관리 앱의 개발을 통해 지금까지 배운 React의 기본기와 고급 개념을 종합적으로 연습해 본다. 이 앱은 새 프로젝트를 생성하고, 프로젝트별로 할 일을 추가하며, 프로젝트와 할 일을 삭제할 수 있는 기능을 제공한다. 컴포넌트와 상태 관리를 활용하고, 참조(ref)와 포털(Portal) 등의 개념을 적용하여 앱을 구현한다. 스타일링을 위해 Tailwind CSS를 사용한다.
[사용자]
|
v
[새 프로젝트 생성] ---> [프로젝트 목록에 추가]
|
v
[프로젝트 선택]
|
v
[할 일 추가] ---> [프로젝트의 할 일 목록에 추가]
|
v
[할 일 삭제] <--- [프로젝트의 할 일 목록]
|
v
[프로젝트 삭제] ---> [프로젝트 목록에서 제거]
// App.tsx
import React from 'react';
import ProjectManager from './components/ProjectManager';
const App: React.FC = () => {
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">프로젝트 관리 앱</h1>
<ProjectManager />
</div>
);
};
export default App;
// components/ProjectManager.tsx
import React, { useState } from 'react';
import ProjectForm from './ProjectForm';
import ProjectList from './ProjectList';
interface Project {
id: number;
name: string;
}
const ProjectManager: React.FC = () => {
const [projects, setProjects] = useState<Project[]>([]);
const addProject = (name: string) => {
const newProject: Project = {
id: Date.now(),
name,
};
setProjects([...projects, newProject]);
};
const deleteProject = (id: number) => {
setProjects(projects.filter((project) => project.id !== id));
};
return (
<div>
<ProjectForm addProject={addProject} />
<ProjectList projects={projects} deleteProject={deleteProject} />
</div>
);
};
export default ProjectManager;
// components/ProjectForm.tsx
import React, { useState } from 'react';
interface ProjectFormProps {
addProject: (name: string) => void;
}
const ProjectForm: React.FC<ProjectFormProps> = ({ addProject }) => {
const [projectName, setProjectName] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (projectName.trim()) {
addProject(projectName.trim());
setProjectName('');
}
};
return (
<form onSubmit={handleSubmit} className="mb-4">
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="프로젝트 이름"
className="border p-2 mr-2"
/>
<button type="submit" className="bg-blue-500 text-white p-2">
프로젝트 추가
</button>
</form>
);
};
export default ProjectForm;
// components/ProjectList.tsx
import React from 'react';
import TaskManager from './TaskManager';
interface Project {
id: number;
name: string;
}
interface ProjectListProps {
projects: Project[];
deleteProject: (id: number) => void;
}
const ProjectList: React.FC<ProjectListProps> = ({ projects, deleteProject }) => {
return (
<div>
{projects.map((project) => (
<div key={project.id} className="border p-2 mb-2">
<h2 className="text-xl font-semibold">
{project.name}
<button
onClick={() => deleteProject(project.id)}
className="text-red-500 ml-4"
>
삭제
</button>
</h2>
<TaskManager projectId={project.id} />
</div>
))}
</div>
);
};
export default ProjectList;
// components/TaskManager.tsx
import React, { useState } from 'react';
interface Task {
id: number;
text: string;
}
interface TaskManagerProps {
projectId: number;
}
const TaskManager: React.FC<TaskManagerProps> = () => {
const [tasks, setTasks] = useState<Task[]>([]);
const [taskText, setTaskText] = useState('');
const addTask = () => {
if (taskText.trim()) {
const newTask: Task = {
id: Date.now(),
text: taskText.trim(),
};
setTasks([...tasks, newTask]);
setTaskText('');
}
};
const deleteTask = (id: number) => {
setTasks(tasks.filter((task) => task.id !== id));
};
return (
<div className="ml-4">
<input
type="text"
value={taskText}
onChange={(e) => setTaskText(e.target.value)}
placeholder="할 일 내용"
className="border p-1 mr-2"
/>
<button onClick={addTask} className="bg-green-500 text-white p-1">
할 일 추가
</button>
<ul className="mt-2">
{tasks.map((task) => (
<li key={task.id} className="flex justify-between">
{task.text}
<button
onClick={() => deleteTask(task.id)}
className="text-red-500"
>
삭제
</button>
</li>
))}
</ul>
</div>
);
};
export default TaskManager;
React 프로젝트 관리 앱을 개발하면서 ProjectsSidebar 컴포넌트를 생성한다. 이 컴포넌트는 사용자에게 프로젝트 목록을 보여주고, 새로운 프로젝트를 추가할 수 있는 버튼을 제공한다. 컴포넌트는 다음과 같이 구성된다:
<aside>
요소를 사용하여 사이드바 내용을 감싼다.<h2>
요소에 "당신의 프로젝트"라는 제목을 넣는다.<button>
요소를 생성하고, "+프로젝트 추가"라는 텍스트를 포함한다. <h1>
요소를 제거하고, 대신 ProjectsSidebar 컴포넌트를 임포트하여 렌더링한다.<Fragment>
에서 <main>
요소로 변경하여, 사이드바와 프로젝트 상세 내용을 감싼다.이렇게 해서 기본적인 사이드바 구조를 완성하였고, 다음 단계에서는 스타일링을 추가하고 기능을 확장할 계획이다.
[App 컴포넌트]
|
v
[ProjectsSidebar 컴포넌트 렌더링]
|
v
<main>
|
+--<ProjectsSidebar />
|
v
<aside>
|
+--<h2>당신의 프로젝트</h2>
|
+--<div>
|
+--<button>+프로젝트 추가</button>
// src/App.tsx
import React from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
const App: React.FC = () => {
return (
<main className="flex">
<ProjectsSidebar />
{/* 추가할 프로젝트의 상세 내용이 여기 들어갈 것입니다 */}
</main>
);
};
export default App;
// src/components/ProjectsSidebar.tsx
import React from 'react';
const ProjectsSidebar: React.FC = () => {
return (
<aside className="w-64 bg-gray-100 p-4">
<h2 className="text-xl font-bold mb-4">당신의 프로젝트</h2>
{/* 프로젝트 목록이 여기 들어갈 것입니다 */}
<div>
<button className="w-full bg-blue-500 text-white py-2 px-4 rounded">
+프로젝트 추가
</button>
</div>
</aside>
);
};
export default ProjectsSidebar;
이번 시간에는 앱의 스타일링을 개선해 보겠습니다. App 컴포넌트에서 Tailwind CSS 클래스를 추가하여 화면 전체 높이를 차지하도록 설정하고, 상하 여백을 추가했습니다. ProjectsSidebar 컴포넌트의 aside 요소에 다양한 클래스를 적용하여 사이드바의 레이아웃과 디자인을 완성했습니다.
구체적으로는:
H2 태그와 버튼에도 스타일을 적용했습니다:
이러한 스타일링을 통해 기본적인 사이드바와 버튼을 완성했습니다. 다음 단계로는 이 버튼을 클릭하면 새로운 창이 열려서 새로운 프로젝트의 상세 정보를 추가할 수 있는 컴포넌트를 구현해 보겠습니다.
// App.tsx
import React from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
const App: React.FC = () => {
return (
<div className="h-screen my-8">
<main className="flex">
<ProjectsSidebar />
{/* 앞으로 추가할 프로젝트 상세 내용 */}
</main>
</div>
);
};
export default App;
// components/ProjectsSidebar.tsx
import React from 'react';
const ProjectsSidebar: React.FC = () => {
return (
<aside className="w-1/3 md:w-72 px-8 py-16 bg-stone-900 text-stone-50 rounded-r-xl">
<h2 className="mb-8 font-bold uppercase text-stone-400 md:text-2xl">
당신의 프로젝트
</h2>
{/* 프로젝트 목록이 여기에 들어갈 예정 */}
<button className="w-full px-2 py-1 md:px-4 md:py-2 text-xs md:text-base rounded-md bg-stone-700 text-stone-400 hover:bg-stone-600 hover:text-stone-100">
+프로젝트 추가
</button>
</aside>
);
};
export default ProjectsSidebar;
새로운 프로젝트를 추가할 수 있는 NewProject 컴포넌트를 생성하였다. 이 컴포넌트에서는 프로젝트의 제목, 상세설명, 마감일을 입력할 수 있는 필드를 제공한다. 코드의 재사용성과 중복 제거를 위해 Input 컴포넌트를 별도로 생성하여 라벨과 입력 필드를 구성하였다.
Input 컴포넌트는 isTextarea 속성을 통해 <input>
또는 <textarea>
를 조건부로 렌더링한다.
또한, 나머지 속성은 spread 연산자 ...를 사용하여 해당 요소에 전달된다.
App 컴포넌트에서는 NewProject 컴포넌트를 ProjectsSidebar 컴포넌트 옆에 배치하기 위해 flex 클래스를 사용하고, 요소 간 간격을 주기 위해 gap-8 클래스를 적용하였다.
현재는 스타일링이 적용되지 않았으며, 이후에 추가할 예정이다. 이렇게 함으로써 사이드바와 프로젝트 입력 폼이 화면에 나란히 표시되며, 사용자로부터 프로젝트 정보를 입력받을 수 있는 준비를 마쳤다.
[App 컴포넌트]
|
v
<main class="flex gap-8">
|
+-- [ProjectsSidebar 컴포넌트]
|
+-- [NewProject 컴포넌트]
|
v
<div>
|
+-- [메뉴 요소]
| |
| +-- [취소 버튼]
| +-- [저장 버튼]
|
+-- [프로젝트 입력 디브]
|
+-- [Input 컴포넌트 (Title)]
| |
| +-- <label>Title</label>
| +-- <input ... />
|
+-- [Input 컴포넌트 (Description)]
| |
| +-- <label>Description</label>
| +-- <textarea ... />
|
+-- [Input 컴포넌트 (Due Date)]
|
+-- <label>Due Date</label>
+-- <input ... />
// components/Input.tsx
import React from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
isTextarea?: boolean;
}
const Input: React.FC<InputProps> = ({ label, isTextarea, ...rest }) => {
return (
<p>
<label>{label}</label>
{isTextarea ? (
<textarea {...rest}></textarea>
) : (
<input {...rest} />
)}
</p>
);
};
export default Input;
// components/NewProject.tsx
import React from 'react';
import Input from './Input';
const NewProject: React.FC = () => {
return (
<div>
<menu>
<button>취소</button>
<button>저장</button>
</menu>
<div>
<Input label="Title" />
<Input label="Description" isTextarea />
<Input label="Due Date" />
</div>
</div>
);
};
export default NewProject;
// App.tsx
import React from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NewProject from './components/NewProject';
const App: React.FC = () => {
return (
<div className="h-screen my-8">
<main className="flex gap-8">
<ProjectsSidebar />
<NewProject />
</main>
</div>
);
};
export default App;
이번에는 Tailwind CSS를 활용하여 앱의 스타일링을 개선했다. 맞춤 너비를 설정하고, 메뉴와 사이드바의 높이를 맞추기 위해 여백을 추가했다. 메뉴 섹션에서는 Flexbox를 활성화하고, 요소들을 정렬 및 배치했다.
버튼들에 스타일을 적용하여 취소 버튼과 저장 버튼의 시각적 차이를 두었다. 특히 저장 버튼은 더욱 눈에 띄게 디자인했다.
Input.jsx 파일에서는 입력 필드들의 스타일을 개선했다. 문단과 라벨, 입력창에 Tailwind CSS 클래스를 추가하여 레이아웃과 디자인을 조정했다. 입력창과 문자 영역의 스타일링을 위해 클래스 문자열을 상수로 저장하여 코드의 중복을 줄였다.
다음 단계로는 프로젝트 추가 버튼을 클릭했을 때 새로운 창을 열고, 사용자로부터 입력값을 받아 실제로 새로운 프로젝트를 생성하는 기능을 구현할 예정이다.
[NewProject 컴포넌트]
|
v
<div class="w-[35rem] mt-16">
|
+-- [메뉴 섹션]
| |
| +-- <menu class="flex items-center justify-end gap-4 my-4">
| |
| +-- [취소 버튼]
| | |
| | +-- 클래스: text-stone-800 hover:text-stone-900
| |
| +-- [저장 버튼]
| |
| +-- 클래스: bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2
|
+-- [입력 필드 섹션]
|
+-- [Input 컴포넌트들]
|
+-- <p class="flex flex-col gap-1 my-4">
|
+-- <label class="text-sm font-bold uppercase text-stone-600">라벨</label>
|
+-- <input 또는 textarea class="{classes}">
|
+-- classes = "w-full p-1 border-b-2 rounded-md border-stone-300 bg-stone-200 text-stone-600 focus:outline-none focus:border-stone-600"
// components/Input.tsx
import React from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
label: string;
isTextarea?: boolean;
}
const Input: React.FC<InputProps> = ({ label, isTextarea, ...rest }) => {
const classes =
'w-full p-1 border-b-2 rounded-md border-stone-300 bg-stone-200 text-stone-600 focus:outline-none focus:border-stone-600';
return (
<p className="flex flex-col gap-1 my-4">
<label className="text-sm font-bold uppercase text-stone-600">{label}</label>
{isTextarea ? (
<textarea className={classes} {...rest}></textarea>
) : (
<input className={classes} {...rest} />
)}
</p>
);
};
export default Input;
// components/NewProject.tsx
import React from 'react';
import Input from './Input';
const NewProject: React.FC = () => {
return (
<div className="w-[35rem] mt-16">
<menu className="flex items-center justify-end gap-4 my-4">
<button className="text-stone-800 hover:text-stone-900">취소</button>
<button className="bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2">
저장
</button>
</menu>
<div>
<Input label="Title" />
<Input label="Description" isTextarea />
<Input label="Due Date" type="date" />
</div>
</div>
);
};
export default NewProject;
프로젝트 관리 앱에서 NoProjectSelected라는 새로운 컴포넌트를 생성하여, 프로젝트가 선택되지 않았거나 추가되지 않았을 때 표시될 대체 콘텐츠를 구성했습니다. 이 컴포넌트는 이미지와 텍스트, 그리고 "새 프로젝트 추가" 버튼을 포함합니다.
스타일링을 위해 Tailwind CSS 클래스를 활용하여 레이아웃과 디자인을 조정했습니다.
이미지는 assets 폴더에서 가져와서 <img>
태그로 표시하고, 텍스트 요소들에도 적절한 스타일을 적용했습니다.
버튼의 중복 코드를 제거하고 재사용성을 높이기 위해 Button 컴포넌트를 별도로 생성했습니다. 이 컴포넌트는 프로젝트 사이드바와 NoProjectSelected 컴포넌트에서 모두 사용되며, 자식 요소를 통해 버튼의 내용을 유연하게 지정할 수 있습니다.
마지막으로, App 컴포넌트에서 NewProject 대신 NoProjectSelected 컴포넌트를 렌더링하도록 변경하여, 현재는 항상 대체 콘텐츠가 표시되도록 설정했습니다. 이후에는 버튼 클릭 여부에 따라 조건부로 컴포넌트를 렌더링하여 기능을 확장할 예정입니다.
[App 컴포넌트]
|
v
[main 요소]
|
+-- [ProjectsSidebar 컴포넌트]
|
+-- [NoProjectSelected 컴포넌트]
|
v
<div class="mt-16 text-center w-2/3">
|
+-- <img src={noProjectImage} alt="빈 할 일 목록" class="w-16 h-16 object-contain mx-auto" />
|
+-- <h2 class="text-xl font-bold text-stone-500 my-4">선택된 프로젝트 없음</h2>
|
+-- <p class="text-stone-400 mb-4">프로젝트를 선택하거나 새로운 프로젝트를 시작하십시오</p>
|
+-- <p class="mt-8">
|
+-- [Button 컴포넌트]
|
+-- "새 프로젝트 추가"
[Button 컴포넌트]
|
v
<button class="..."> {children} </button>
^
|
나머지 props와 children을 받아서 버튼에 적용
// src/components/NoProjectSelected.tsx
import React from 'react';
import Button from './Button';
import noProjectImage from '../assets/no-projects.png';
const NoProjectSelected: React.FC = () => {
return (
<div className="mt-16 text-center w-2/3">
<img
src={noProjectImage}
alt="빈 할 일 목록"
className="w-16 h-16 object-contain mx-auto"
/>
<h2 className="text-xl font-bold text-stone-500 my-4">
선택된 프로젝트 없음
</h2>
<p className="text-stone-400 mb-4">
프로젝트를 선택하거나 새로운 프로젝트를 시작하십시오
</p>
<p className="mt-8">
<Button>새 프로젝트 추가</Button>
</p>
</div>
);
};
export default NoProjectSelected;
// src/components/Button.tsx
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const Button: React.FC<ButtonProps> = ({ children, ...rest }) => {
return (
<button
className="w-full px-2 py-1 md:px-4 md:py-2 text-xs md:text-base rounded-md bg-stone-700 text-stone-400 hover:bg-stone-600 hover:text-stone-100"
{...rest}
>
{children}
</button>
);
};
export default Button;
// src/components/ProjectsSidebar.tsx
import React from 'react';
import Button from './Button';
const ProjectsSidebar: React.FC = () => {
return (
<aside className="w-1/3 md:w-72 px-8 py-16 bg-stone-900 text-stone-50 rounded-r-xl">
<h2 className="mb-8 font-bold uppercase text-stone-400 md:text-2xl">
당신의 프로젝트
</h2>
{/* 프로젝트 목록이 여기에 들어갈 예정 */}
<Button>+프로젝트 추가</Button>
</aside>
);
};
export default ProjectsSidebar;
// src/App.tsx
import React from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NoProjectSelected from './components/NoProjectSelected';
const App: React.FC = () => {
return (
<div className="h-screen my-8">
<main className="flex gap-8">
<ProjectsSidebar />
<NoProjectSelected />
</main>
</div>
);
};
export default App;
[App 컴포넌트]
|
v
[useState로 projectsState 관리]
|
v
[handleStartAddProject 함수 정의]
|
v
[onStartAddProject props로 전달]
|
v
+-------------------------+
| |
v v
[ProjectsSidebar 컴포넌트] [NoProjectSelected 컴포넌트]
| |
v v
[Button 클릭 시] [Button 클릭 시]
| |
v v
[onClick={onStartAddProject}] [onClick={onStartAddProject}]
| |
+-------------+---------------+
|
v
[handleStartAddProject 호출]
|
v
[projectsState 업데이트: selectedProjectId = null]
|
v
[App 컴포넌트 렌더링]
|
v
[projectsState.selectedProjectId === null ?]
|
+------------ Yes ------------+
| |
v v
[content = <NewProject />] [content = <NoProjectSelected />]
|
v
[UI 업데이트: NewProject 컴포넌트 표시]
// App.tsx
import React, { useState } from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NoProjectSelected from './components/NoProjectSelected';
import NewProject from './components/NewProject';
interface Project {
id: number;
title: string;
description: string;
dueDate: string;
}
interface ProjectsState {
projects: Project[];
selectedProjectId: number | null | undefined;
}
const App: React.FC = () => {
const [projectsState, setProjectsState] = useState<ProjectsState>({
projects: [],
selectedProjectId: undefined,
});
const handleStartAddProject = () => {
setProjectsState((prevState) => ({
...prevState,
selectedProjectId: null,
}));
};
let content;
if (projectsState.selectedProjectId === null) {
content = <NewProject />;
} else if (projectsState.selectedProjectId !== undefined) {
// 선택된 프로젝트가 있을 때의 컴포넌트 (추후 구현)
content = <div>선택된 프로젝트 내용</div>;
} else {
content = (
<NoProjectSelected onStartAddProject={handleStartAddProject} />
);
}
return (
<div className="h-screen my-8">
<main className="flex gap-8">
<ProjectsSidebar onStartAddProject={handleStartAddProject} />
{content}
</main>
</div>
);
};
export default App;
// ProjectsSidebar.tsx
import React from 'react';
import Button from './Button';
interface ProjectsSidebarProps {
onStartAddProject: () => void;
}
const ProjectsSidebar: React.FC<ProjectsSidebarProps> = ({
onStartAddProject,
}) => {
return (
<aside className="...">
<h2 className="...">당신의 프로젝트</h2>
{/* 프로젝트 목록 표시 예정 */}
<Button onClick={onStartAddProject}>+프로젝트 추가</Button>
</aside>
);
};
export default ProjectsSidebar;
// NoProjectSelected.tsx
import React from 'react';
import Button from './Button';
import noProjectImage from '../assets/no-projects.png';
interface NoProjectSelectedProps {
onStartAddProject: () => void;
}
const NoProjectSelected: React.FC<NoProjectSelectedProps> = ({
onStartAddProject,
}) => {
return (
<div className="...">
{/* 이미지 및 텍스트 */}
<Button onClick={onStartAddProject}>새 프로젝트 추가</Button>
</div>
);
};
export default NoProjectSelected;
<input type="date">
를 사용하여 날짜 선택기를 제공한다.[사용자]
|
v
[NewProject 컴포넌트에서 입력]
|
v
[handleSave 함수 호출 (저장 버튼 클릭)]
|
v
[입력 값 수집 (titleRef.current.value 등)]
|
v
[onAdd(props.onAdd) 함수 호출]
|
v
[App 컴포넌트의 handleAddProject 함수 실행]
|
v
[projectsState 상태 업데이트 (새 프로젝트 추가)]
|
v
[App 컴포넌트 렌더링]
|
v
[ProjectsSidebar 컴포넌트에 새로운 프로젝트 전달 (다음 단계에서 구현)]
// Input.tsx
import React, { forwardRef } from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
label: string;
isTextarea?: boolean;
}
const Input = forwardRef<HTMLInputElement | HTMLTextAreaElement, InputProps>(
({ label, isTextarea, ...rest }, ref) => {
const classes =
'w-full p-1 border-b-2 rounded-md border-stone-300 bg-stone-200 text-stone-600 focus:outline-none focus:border-stone-600';
return (
<p className="flex flex-col gap-1 my-4">
<label className="text-sm font-bold uppercase text-stone-600">{label}</label>
{isTextarea ? (
<textarea className={classes} ref={ref as React.Ref<HTMLTextAreaElement>} {...rest}></textarea>
) : (
<input className={classes} ref={ref as React.Ref<HTMLInputElement>} {...rest} />
)}
</p>
);
}
);
export default Input;
// NewProject.tsx
import React, { useRef } from 'react';
import Input from './Input';
interface NewProjectProps {
onAdd: (projectData: { title: string; description: string; dueDate: string }) => void;
}
const NewProject: React.FC<NewProjectProps> = ({ onAdd }) => {
const titleRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
const dueDateRef = useRef<HTMLInputElement>(null);
const handleSave = () => {
const enteredTitle = titleRef.current?.value || '';
const enteredDescription = descriptionRef.current?.value || '';
const enteredDueDate = dueDateRef.current?.value || '';
// 유효성 검사 (간단한 예시)
if (!enteredTitle.trim() || !enteredDescription.trim() || !enteredDueDate.trim()) {
alert('모든 필드를 입력해주세요.');
return;
}
onAdd({
title: enteredTitle,
description: enteredDescription,
dueDate: enteredDueDate,
});
};
return (
<div className="w-[35rem] mt-16">
<menu className="flex items-center justify-end gap-4 my-4">
<button className="text-stone-800 hover:text-stone-900">취소</button>
<button onClick={handleSave} className="bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2">
저장
</button>
</menu>
<div>
<Input label="Title" ref={titleRef} type="text" />
<Input label="Description" ref={descriptionRef} isTextarea />
<Input label="Due Date" ref={dueDateRef} type="date" />
</div>
</div>
);
};
export default NewProject;
// App.tsx
import React, { useState } from 'react';
import ProjectsSidebar from './ProjectsSidebar';
import NewProject from './NewProject';
import NoProjectSelected from './NoProjectSelected';
interface Project {
id: number;
title: string;
description: string;
dueDate: string;
}
interface ProjectsState {
projects: Project[];
selectedProjectId: number | null | undefined;
}
const App: React.FC = () => {
const [projectsState, setProjectsState] = useState<ProjectsState>({
projects: [],
selectedProjectId: undefined,
});
const handleAddProject = (projectData: { title: string; description: string; dueDate: string }) => {
setProjectsState((prevState) => ({
...prevState,
projects: [
...prevState.projects,
{
id: Math.random(),
...projectData,
},
],
}));
};
const handleStartAddProject = () => {
setProjectsState((prevState) => ({
...prevState,
selectedProjectId: null,
}));
};
console.log(projectsState);
let content;
if (projectsState.selectedProjectId === null) {
content = <NewProject onAdd={handleAddProject} />;
} else if (projectsState.selectedProjectId !== undefined) {
// 선택된 프로젝트가 있을 때의 컴포넌트 (추후 구현)
content = <div>선택된 프로젝트 내용</div>;
} else {
content = <NoProjectSelected onStartAddProject={handleStartAddProject} />;
}
return (
<div className="h-screen my-8">
<main className="flex gap-8">
<ProjectsSidebar onStartAddProject={handleStartAddProject} />
{content}
</main>
</div>
);
};
export default App;
저장 버튼을 클릭하면 selectedProjectId를 undefined로 설정하여 NewProject 컴포넌트를 닫고 대체 화면으로 돌아가도록 수정하겠습니다. 또한, 사이드바에 프로젝트 목록을 표시하기 위해 projectsState.projects를 ProjectsSidebar 컴포넌트에 projects 속성으로 전달하겠습니다.
ProjectsSidebar에서는 전달받은 projects를 이용하여 프로젝트 목록을 렌더링하고, 각 프로젝트마다 선택 버튼을 추가하겠습니다. 버튼에는 프로젝트 제목을 표시하고, Tailwind CSS 클래스를 활용하여 스타일링하겠습니다.
프로젝트 목록의 상단에 여백을 추가하여 디자인을 개선하고, NewProject 컴포넌트에서 취소 버튼이 동작하도록 구현하겠습니다. 또한, 모든 입력 필드가 채워지지 않았을 때 에러 메시지를 표시하여 사용자 경험을 향상시키겠습니다.
// 데이터 흐름 도식화
[사용자: NewProject에서 '저장' 클릭]
|
v
[App 컴포넌트의 handleAddProject 호출]
|
v
[projectsState.projects에 새 프로젝트 추가]
|
v
[selectedProjectId를 undefined로 설정]
|
v
[App 컴포넌트 재렌더링]
|
v
[ProjectsSidebar에 업데이트된 projects 전달]
|
v
[ProjectsSidebar에서 프로젝트 목록 렌더링]
|
v
[사용자: 사이드바에서 프로젝트 목록 확인]
// App.tsx
import React, { useState } from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NewProject from './components/NewProject';
import NoProjectSelected from './components/NoProjectSelected';
interface Project {
id: number;
title: string;
description: string;
dueDate: string;
}
interface ProjectsState {
projects: Project[];
selectedProjectId: number | null | undefined;
}
const App: React.FC = () => {
const [projectsState, setProjectsState] = useState<ProjectsState>({
projects: [],
selectedProjectId: undefined,
});
const handleAddProject = (projectData: {
title: string;
description: string;
dueDate: string;
}) => {
const newProject: Project = {
id: Math.random(),
...projectData,
};
setProjectsState((prevState) => ({
projects: [...prevState.projects, newProject],
selectedProjectId: undefined, // 컴포넌트 닫기
}));
};
const handleStartAddProject = () => {
setProjectsState((prevState) => ({
...prevState,
selectedProjectId: null,
}));
};
let content;
if (projectsState.selectedProjectId === null) {
content = <NewProject onAdd={handleAddProject} />;
} else {
content = (
<NoProjectSelected onStartAddProject={handleStartAddProject} />
);
}
return (
<div className="h-screen my-8">
<main className="flex gap-8">
<ProjectsSidebar
onStartAddProject={handleStartAddProject}
projects={projectsState.projects}
/>
{content}
</main>
</div>
);
};
export default App;
// ProjectsSidebar.tsx
import React from 'react';
import Button from './Button';
interface Project {
id: number;
title: string;
}
interface ProjectsSidebarProps {
onStartAddProject: () => void;
projects: Project[];
}
const ProjectsSidebar: React.FC<ProjectsSidebarProps> = ({
onStartAddProject,
projects,
}) => {
return (
<aside className="w-1/3 md:w-72 px-8 py-16 bg-stone-900 text-stone-50 rounded-r-xl">
<h2 className="mb-8 font-bold uppercase text-stone-400 md:text-2xl">
당신의 프로젝트
</h2>
<Button onClick={onStartAddProject}>+프로젝트 추가</Button>
<ul className="mt-8">
{projects.map((project) => (
<li key={project.id}>
<button
className="w-full text-left px-2 py-1 rounded-md text-stone-400 hover:text-stone-100 hover:bg-stone-800"
>
{project.title}
</button>
</li>
))}
</ul>
</aside>
);
};
export default ProjectsSidebar;
// NewProject.tsx
import React, { useRef } from 'react';
import Input from './Input';
interface NewProjectProps {
onAdd: (projectData: {
title: string;
description: string;
dueDate: string;
}) => void;
}
const NewProject: React.FC<NewProjectProps> = ({ onAdd }) => {
const titleRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
const dueDateRef = useRef<HTMLInputElement>(null);
const handleSave = () => {
const enteredTitle = titleRef.current?.value || '';
const enteredDescription = descriptionRef.current?.value || '';
const enteredDueDate = dueDateRef.current?.value || '';
if (!enteredTitle.trim() || !enteredDescription.trim() || !enteredDueDate.trim()) {
alert('모든 필드를 입력해주세요.');
return;
}
onAdd({
title: enteredTitle,
description: enteredDescription,
dueDate: enteredDueDate,
});
};
const handleCancel = () => {
// 취소 버튼 클릭 시 동작 (예: 부모 컴포넌트에 알리기)
};
return (
<div className="w-[35rem] mt-16">
<menu className="flex items-center justify-end gap-4 my-4">
<button
onClick={handleCancel}
className="text-stone-800 hover:text-stone-900"
>
취소
</button>
<button
onClick={handleSave}
className="bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2"
>
저장
</button>
</menu>
<div>
<Input label="Title" ref={titleRef} type="text" />
<Input label="Description" ref={descriptionRef} isTextarea />
<Input label="Due Date" ref={dueDateRef} type="date" />
</div>
</div>
);
};
export default NewProject;
유효성 검사를 통해 사용자가 입력한 값이 유효한지 확인하고, 유효하지 않은 경우 에러 메시지를 모달로 표시하는 기능을 구현했습니다. 이를 위해 useRef를 사용하여 입력 필드에 접근하고, forwardRef와 useImperativeHandle을 활용하여 모달 컴포넌트를 제어할 수 있도록 했습니다. React Portal을 사용하여 모달을 DOM의 특정 위치에 렌더링함으로써 재사용성과 유연성을 높였습니다.
App 컴포넌트에서는 프로젝트 상태를 관리하며, ProjectsSidebar와 NoProjectSelected, NewProject 컴포넌트를 조건부로 렌더링합니다. 사용자가 저장 버튼을 클릭했을 때 입력 값이 유효한지 확인하고, 유효하지 않으면 모달을 통해 에러 메시지를 표시하며, 유효하면 새로운 프로젝트를 추가하고 모달을 닫습니다.
[App 컴포넌트]
|
v
[useState로 projectsState 관리]
|
v
[handleAddProject 함수 정의]
|
v
[handleStartAddProject 함수 정의]
|
v
[ProjectsSidebar 컴포넌트에 props 전달]
|
+---------------------------+
| |
v v
[ProjectsSidebar 컴포넌트] [NoProjectSelected 컴포넌트]
| |
+-- [프로젝트 추가 버튼] +-- [모달 참조 연결]
| |
v v
[버튼 클릭 시 onStartAddProject 호출]
|
v
[App 컴포넌트의 handleStartAddProject 실행]
|
v
[selectedProjectId = null]
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[NewProject 컴포넌트 렌더링]
|
v
[NewProject 컴포넌트 내 handleSave 실행]
|
v
[입력 값 유효성 검사]
|
+-- 유효하지 않으면 --> [Modal 컴포넌트 열기]
|
+-- 유효하면 --> [handleAddProject 호출]
|
v
[projectsState.projects에 새 프로젝트 추가]
|
v
[selectedProjectId = undefined]
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[NoProjectSelected 컴포넌트 렌더링]
// src/components/Modal.tsx
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import ReactDOM from 'react-dom';
interface ModalProps {
children: React.ReactNode;
}
export interface ModalHandle {
open: () => void;
close: () => void;
}
const Modal = forwardRef<ModalHandle, ModalProps>(({ children }, ref) => {
const dialogRef = useRef<HTMLDialogElement>(null);
useImperativeHandle(ref, () => ({
open: () => {
dialogRef.current?.showModal();
},
close: () => {
dialogRef.current?.close();
},
}));
return ReactDOM.createPortal(
<dialog ref={dialogRef} className="modal">
{children}
<form method="dialog">
<button className="mt-4 px-4 py-2 bg-stone-700 text-stone-50 rounded-md hover:bg-stone-600">
닫기
</button>
</form>
</dialog>,
document.getElementById('modal-root') as HTMLElement
);
});
export default Modal;
// src/components/Button.tsx
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const Button: React.FC<ButtonProps> = ({ children, ...rest }) => {
return (
<button
className="w-full px-2 py-1 md:px-4 md:py-2 text-xs md:text-base rounded-md bg-stone-700 text-stone-400 hover:bg-stone-600 hover:text-stone-100"
{...rest}
>
{children}
</button>
);
};
export default Button;
// src/components/Input.tsx
import React, { forwardRef } from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
label: string;
isTextarea?: boolean;
}
const Input = forwardRef<HTMLInputElement | HTMLTextAreaElement, InputProps>(
({ label, isTextarea, ...rest }, ref) => {
const classes =
'w-full p-1 border-b-2 rounded-md border-stone-300 bg-stone-200 text-stone-600 focus:outline-none focus:border-stone-600';
return (
<p className="flex flex-col gap-1 my-4">
<label className="text-sm font-bold uppercase text-stone-600">{label}</label>
{isTextarea ? (
<textarea className={classes} ref={ref as React.Ref<HTMLTextAreaElement>} {...rest}></textarea>
) : (
<input className={classes} ref={ref as React.Ref<HTMLInputElement>} {...rest} />
)}
</p>
);
}
);
export default Input;
// src/components/NoProjectSelected.tsx
import React, { useRef } from 'react';
import Button from './Button';
import Modal, { ModalHandle } from './Modal';
import noProjectImage from '../assets/no-projects.png';
interface NoProjectSelectedProps {
onStartAddProject: () => void;
}
const NoProjectSelected: React.FC<NoProjectSelectedProps> = ({ onStartAddProject }) => {
const modalRef = useRef<ModalHandle>(null);
const handleButtonClick = () => {
onStartAddProject();
};
return (
<>
<div className="mt-16 text-center w-2/3 mx-auto">
<img
src={noProjectImage}
alt="빈 할 일 목록"
className="w-16 h-16 object-contain mx-auto"
/>
<h2 className="text-xl font-bold text-stone-500 my-4">선택된 프로젝트 없음</h2>
<p className="text-stone-400 mb-4">
프로젝트를 선택하거나 새로운 프로젝트를 시작하십시오
</p>
<p className="mt-8">
<Button onClick={handleButtonClick}>새 프로젝트 추가</Button>
</p>
</div>
<Modal ref={modalRef}>
<h2 className="text-lg font-bold mb-2">입력 오류</h2>
<p>모든 필드를 올바르게 입력했는지 확인해주세요.</p>
</Modal>
</>
);
};
export default NoProjectSelected;
// src/components/NewProject.tsx
import React, { useRef } from 'react';
import Input from './Input';
import Modal, { ModalHandle } from './Modal';
import Button from './Button';
interface NewProjectProps {
onAdd: (projectData: {
title: string;
description: string;
dueDate: string;
}) => void;
}
const NewProject: React.FC<NewProjectProps> = ({ onAdd }) => {
const titleRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
const dueDateRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<ModalHandle>(null);
const handleSave = () => {
const enteredTitle = titleRef.current?.value.trim() || '';
const enteredDescription = descriptionRef.current?.value.trim() || '';
const enteredDueDate = dueDateRef.current?.value.trim() || '';
if (!enteredTitle || !enteredDescription || !enteredDueDate) {
modalRef.current?.open();
return;
}
onAdd({
title: enteredTitle,
description: enteredDescription,
dueDate: enteredDueDate,
});
};
const handleCancel = () => {
// 선택된 프로젝트를 초기화하여 모달을 닫음
// 추가 로직이 필요할 경우 여기에 작성
};
return (
<>
<div className="w-[35rem] mt-16">
<menu className="flex items-center justify-end gap-4 my-4">
<button
onClick={handleCancel}
className="text-stone-800 hover:text-stone-900"
>
취소
</button>
<button
onClick={handleSave}
className="bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2"
>
저장
</button>
</menu>
<div>
<Input label="Title" ref={titleRef} type="text" />
<Input label="Description" ref={descriptionRef} isTextarea />
<Input label="Due Date" ref={dueDateRef} type="date" />
</div>
</div>
<Modal ref={modalRef}>
<h2 className="text-lg font-bold mb-2">입력 오류</h2>
<p>모든 필드를 올바르게 입력했는지 확인해주세요.</p>
</Modal>
</>
);
};
export default NewProject;
// src/App.tsx
import React, { useState } from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NewProject from './components/NewProject';
import NoProjectSelected from './components/NoProjectSelected';
import Modal, { ModalHandle } from './components/Modal';
interface Project {
id: number;
title: string;
description: string;
dueDate: string;
}
interface ProjectsState {
projects: Project[];
selectedProjectId: number | null | undefined;
}
const App: React.FC = () => {
const [projectsState, setProjectsState] = useState<ProjectsState>({
projects: [],
selectedProjectId: undefined,
});
const handleAddProject = (projectData: {
title: string;
description: string;
dueDate: string;
}) => {
const newProject: Project = {
id: Math.random(),
...projectData,
};
setProjectsState((prevState) => ({
projects: [...prevState.projects, newProject],
selectedProjectId: undefined, // 모달 닫기
}));
};
const handleStartAddProject = () => {
setProjectsState((prevState) => ({
...prevState,
selectedProjectId: null,
}));
};
let content;
if (projectsState.selectedProjectId === null) {
content = <NewProject onAdd={handleAddProject} />;
} else {
content = <NoProjectSelected onStartAddProject={handleStartAddProject} />;
}
return (
<div className="h-screen my-8">
<main className="flex gap-8">
<ProjectsSidebar
onStartAddProject={handleStartAddProject}
projects={projectsState.projects}
/>
{content}
</main>
</div>
);
};
export default App;
// src/index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>React App</title>
</head>
<body>
<div id="root"></div>
<div id="modal-root"></div> <!-- 모달을 렌더링할 대상 DOM 노드 -->
</body>
</html>
모달 컴포넌트 생성
유효성 검사 구현
취소 버튼 기능 추가
스타일링 개선
프로젝트 목록 사이드바 업데이트
[사용자]
|
v
[ProjectsSidebar 컴포넌트] --클릭--> [App 컴포넌트의 handleStartAddProject]
|
v
[App 컴포넌트]
| (selectedProjectId = null)
v
[NewProject 컴포넌트]
|
|-- 입력값 (title, description, dueDate)
|
v
[저장 버튼 클릭]
|
v
[NewProject 컴포넌트의 handleSave]
|
|-- 유효성 검사
| |
| |-- 유효하지 않음 --> [Modal 컴포넌트 열기]
| |
| |-- 유효함 --> [App 컴포넌트의 handleAddProject 호출]
|
v
[App 컴포넌트의 handleAddProject]
|
|-- 프로젝트 추가
|-- selectedProjectId = undefined
|
v
[App 컴포넌트]
|
v
[NoProjectSelected 컴포넌트]
|
|-- 취소 버튼 클릭 --> [App 컴포넌트의 handleCancelAddProject]
|
v
[App 컴포넌트]
| (selectedProjectId = undefined)
v
[NoProjectSelected 컴포넌트 표시]
// src/components/Modal.tsx
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import ReactDOM from 'react-dom';
interface ModalProps {
children: React.ReactNode;
}
export interface ModalHandle {
open: () => void;
close: () => void;
}
const Modal = forwardRef<ModalHandle, ModalProps>(({ children }, ref) => {
const dialogRef = useRef<HTMLDialogElement>(null);
useImperativeHandle(ref, () => ({
open: () => {
dialogRef.current?.showModal();
},
close: () => {
dialogRef.current?.close();
},
}));
return ReactDOM.createPortal(
<dialog
ref={dialogRef}
className="backdrop:bg-stone-900/90 p-6 rounded-md shadow-lg"
>
{children}
<form method="dialog">
<button className="mt-4 px-4 py-2 bg-stone-700 text-stone-50 rounded-md hover:bg-stone-600">
닫기
</button>
</form>
</dialog>,
document.getElementById('modal-root') as HTMLElement
);
});
export default Modal;
// src/components/Button.tsx
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const Button: React.FC<ButtonProps> = ({ children, ...rest }) => {
return (
<button
className="w-full px-2 py-1 md:px-4 md:py-2 text-xs md:text-base rounded-md bg-stone-700 text-stone-400 hover:bg-stone-600 hover:text-stone-100"
{...rest}
>
{children}
</button>
);
};
export default Button;
// src/components/Input.tsx
import React, { forwardRef } from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
label: string;
isTextarea?: boolean;
}
const Input = forwardRef<HTMLInputElement | HTMLTextAreaElement, InputProps>(
({ label, isTextarea, ...rest }, ref) => {
const classes =
'w-full p-1 border-b-2 rounded-md border-stone-300 bg-stone-200 text-stone-600 focus:outline-none focus:border-stone-600';
return (
<p className="flex flex-col gap-1 my-4">
<label className="text-sm font-bold uppercase text-stone-600">{label}</label>
{isTextarea ? (
<textarea className={classes} ref={ref as React.Ref<HTMLTextAreaElement>} {...rest}></textarea>
) : (
<input className={classes} ref={ref as React.Ref<HTMLInputElement>} {...rest} />
)}
</p>
);
}
);
export default Input;
// src/components/NoProjectSelected.tsx
import React, { useRef } from 'react';
import Button from './Button';
import Modal, { ModalHandle } from './Modal';
import noProjectImage from '../assets/no-projects.png';
interface NoProjectSelectedProps {
onStartAddProject: () => void;
}
const NoProjectSelected: React.FC<NoProjectSelectedProps> = ({ onStartAddProject }) => {
const modalRef = useRef<ModalHandle>(null);
const handleButtonClick = () => {
onStartAddProject();
};
return (
<>
<div className="mt-16 text-center w-2/3 mx-auto">
<img
src={noProjectImage}
alt="빈 할 일 목록"
className="w-16 h-16 object-contain mx-auto"
/>
<h2 className="text-xl font-bold text-stone-500 my-4">선택된 프로젝트 없음</h2>
<p className="text-stone-400 mb-4">
프로젝트를 선택하거나 새로운 프로젝트를 시작하십시오
</p>
<p className="mt-8">
<Button onClick={handleButtonClick}>새 프로젝트 추가</Button>
</p>
</div>
<Modal ref={modalRef}>
<h2 className="text-lg font-bold mb-2">입력 오류</h2>
<p>모든 필드를 올바르게 입력했는지 확인해주세요.</p>
</Modal>
</>
);
};
export default NoProjectSelected;
// src/components/NewProject.tsx
import React, { useRef } from 'react';
import Input from './Input';
import Modal, { ModalHandle } from './Modal';
import Button from './Button';
interface NewProjectProps {
onAdd: (projectData: {
title: string;
description: string;
dueDate: string;
}) => void;
}
const NewProject: React.FC<NewProjectProps> = ({ onAdd }) => {
const titleRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
const dueDateRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<ModalHandle>(null);
const handleSave = () => {
const enteredTitle = titleRef.current?.value.trim() || '';
const enteredDescription = descriptionRef.current?.value.trim() || '';
const enteredDueDate = dueDateRef.current?.value.trim() || '';
if (!enteredTitle || !enteredDescription || !enteredDueDate) {
modalRef.current?.open();
return;
}
onAdd({
title: enteredTitle,
description: enteredDescription,
dueDate: enteredDueDate,
});
};
const handleCancel = () => {
// 부모 컴포넌트에 취소 함수 호출 (추가 구현 필요 시)
};
return (
<>
<div className="w-[35rem] mt-16">
<menu className="flex items-center justify-end gap-4 my-4">
<button
onClick={handleCancel}
className="text-stone-800 hover:text-stone-900"
>
취소
</button>
<button
onClick={handleSave}
className="bg-stone-800 text-stone-50 hover:bg-stone-900 hover:text-stone-50 rounded-md px-4 py-2"
>
저장
</button>
</menu>
<div>
<Input label="Title" ref={titleRef} type="text" />
<Input label="Description" ref={descriptionRef} isTextarea />
<Input label="Due Date" ref={dueDateRef} type="date" />
</div>
</div>
<Modal ref={modalRef}>
<h2 className="text-lg font-bold mb-2">입력 오류</h2>
<p>모든 필드를 올바르게 입력했는지 확인해주세요.</p>
</Modal>
</>
);
};
export default NewProject;
// src/components/ProjectsSidebar.tsx
import React from 'react';
import Button from './Button';
interface Project {
id: number;
title: string;
}
interface ProjectsSidebarProps {
onStartAddProject: () => void;
projects: Project[];
}
const ProjectsSidebar: React.FC<ProjectsSidebarProps> = ({
onStartAddProject,
projects,
}) => {
return (
<aside className="w-1/3 md:w-72 px-8 py-16 bg-stone-900 text-stone-50 rounded-r-xl">
<h2 className="mb-8 font-bold uppercase text-stone-400 md:text-2xl">
당신의 프로젝트
</h2>
<Button onClick={onStartAddProject}>+프로젝트 추가</Button>
<ul className="mt-8">
{projects.map((project) => (
<li key={project.id} className="mt-4">
<button
className="w-full text-left px-2 py-1 rounded-md text-stone-400 hover:text-stone-100 hover:bg-stone-800"
// 프로젝트 선택 기능 추가 예정
>
{project.title}
</button>
</li>
))}
</ul>
</aside>
);
};
export default ProjectsSidebar;
// src/App.tsx
import React, { useState } from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NewProject from './components/NewProject';
import NoProjectSelected from './components/NoProjectSelected';
interface Project {
id: number;
title: string;
description: string;
dueDate: string;
}
interface ProjectsState {
projects: Project[];
selectedProjectId: number | null | undefined;
}
const App: React.FC = () => {
const [projectsState, setProjectsState] = useState<ProjectsState>({
projects: [],
selectedProjectId: undefined,
});
const handleAddProject = (projectData: {
title: string;
description: string;
dueDate: string;
}) => {
const newProject: Project = {
id: Math.random(),
...projectData,
};
setProjectsState((prevState) => ({
projects: [...prevState.projects, newProject],
selectedProjectId: undefined, // NewProject 컴포넌트 닫기
}));
};
const handleStartAddProject = () => {
setProjectsState((prevState) => ({
...prevState,
selectedProjectId: null,
}));
};
const handleCancelAddProject = () => {
setProjectsState((prevState) => ({
...prevState,
selectedProjectId: undefined,
}));
};
let content;
if (projectsState.selectedProjectId === null) {
content = <NewProject onAdd={handleAddProject} />;
} else {
content = (
<NoProjectSelected onStartAddProject={handleStartAddProject} />
);
}
return (
<div className="h-screen my-8">
<main className="flex gap-8">
<ProjectsSidebar
onStartAddProject={handleStartAddProject}
projects={projectsState.projects}
/>
{content}
</main>
</div>
);
};
export default App;
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>React App</title>
</head>
<body>
<div id="root"></div>
<div id="modal-root"></div> <!-- 모달을 렌더링할 대상 DOM 노드 -->
</body>
</html>
[사용자]
|
v
[ProjectsSidebar 컴포넌트]
| (프로젝트 추가 버튼 클릭)
v
[App 컴포넌트의 handleStartAddProject 호출]
|
v
[selectedProjectId = null]
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[NewProject 컴포넌트]
|
|-- 입력값 (title, description, dueDate)
|
v
[저장 버튼 클릭]
|
v
[NewProject 컴포넌트의 handleSave 실행]
|
|-- 유효성 검사
| |
| |-- 유효하지 않음 --> [Modal 컴포넌트 열기]
| |
| |-- 유효함 --> [App 컴포넌트의 handleAddProject 호출]
|
v
[App 컴포넌트의 handleAddProject 실행]
|
|-- 프로젝트 추가
|-- selectedProjectId = undefined
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[NoProjectSelected 컴포넌트]
|
|-- 취소 버튼 클릭 --> [App 컴포넌트의 handleCancelAddProject 호출]
|
v
[App 컴포넌트]
| (selectedProjectId = undefined)
v
[NoProjectSelected 컴포넌트 표시]
|
|-- 프로젝트 목록 렌더링
|-- 프로젝트 버튼 클릭 --> [App 컴포넌트의 handleSelectProject 호출]
|
v
[App 컴포넌트의 handleSelectProject 실행]
|
|-- selectedProjectId = 선택된 프로젝트의 ID
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[SelectedProject 컴포넌트 표시]
|
|-- 프로젝트 삭제 버튼 클릭 --> [App 컴포넌트의 handleDeleteProject 호출 예정]
[사용자]
|
v
[ProjectsSidebar 컴포넌트]
| (프로젝트 추가 버튼 클릭)
v
[App 컴포넌트의 handleStartAddProject 호출]
|
v
[selectedProjectId = null]
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[NewProject 컴포넌트]
|
|-- 입력값 (title, description, dueDate)
|
v
[저장 버튼 클릭]
|
v
[NewProject 컴포넌트의 handleSave 실행]
|
|-- 유효성 검사
| |
| |-- 유효하지 않음 --> [Modal 컴포넌트 열기]
| |
| |-- 유효함 --> [App 컴포넌트의 handleAddProject 호출]
|
v
[App 컴포넌트의 handleAddProject 실행]
|
|-- 프로젝트 추가
|-- selectedProjectId = undefined
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[NoProjectSelected 컴포넌트]
|
|-- 프로젝트 목록 렌더링
|-- 프로젝트 버튼 클릭 --> [App 컴포넌트의 handleSelectProject 호출]
|
v
[App 컴포넌트의 handleSelectProject 실행]
|
|-- selectedProjectId = 선택된 프로젝트의 ID
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[SelectedProject 컴포넌트 표시]
|
|-- 삭제 버튼 클릭 --> [App 컴포넌트의 handleDeleteProject 호출]
|
v
[App 컴포넌트의 handleDeleteProject 실행]
|
|-- 프로젝트 삭제
|-- selectedProjectId = undefined
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[NoProjectSelected 컴포넌트 표시]
[사용자]
|
v
[ProjectsSidebar 컴포넌트]
| (프로젝트 추가 버튼 클릭)
v
[App 컴포넌트의 handleStartAddProject 호출]
|
v
[selectedProjectId = null]
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[NewProject 컴포넌트]
|
|-- 입력값 (title, description, dueDate)
|
v
[저장 버튼 클릭]
|
v
[NewProject 컴포넌트의 handleSave 실행]
|
|-- 유효성 검사
| |
| |-- 유효하지 않음 --> [Modal 컴포넌트 열기]
| |
| |-- 유효함 --> [App 컴포넌트의 handleAddProject 호출]
|
v
[App 컴포넌트의 handleAddProject 실행]
|
|-- 프로젝트 추가
|-- selectedProjectId = undefined
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[NoProjectSelected 컴포넌트]
|
|-- 프로젝트 목록 렌더링
|-- 프로젝트 버튼 클릭 --> [App 컴포넌트의 handleSelectProject 호출]
|
v
[App 컴포넌트의 handleSelectProject 실행]
|
|-- selectedProjectId = 선택된 프로젝트의 ID
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[SelectedProject 컴포넌트 표시]
|
|-- 할 일 추가 --> [SelectedProject 컴포넌트의 handleAddTask 호출]
|-- 삭제 버튼 클릭 --> [App 컴포넌트의 handleDeleteProject 호출]
|
v
[App 컴포넌트의 handleAddTask 실행]
|
|-- 선택된 프로젝트의 할 일 목록에 새 할 일 추가
|
v
[SelectedProject 컴포넌트 렌더링 업데이트]
|
v
[Tasks 컴포넌트 및 할 일 목록 업데이트]
|
|-- 할 일 추가 버튼 클릭 --> [NewTask 컴포넌트의 handleAddTask 호출]
|
v
[NewTask 컴포넌트의 handleAddTask 실행]
|
|-- App 컴포넌트의 handleAddTask 호출
|
v
[App 컴포넌트의 handleAddTask 실행]
|
|-- 선택된 프로젝트의 할 일 목록에 새 할 일 추가
|
v
[SelectedProject 컴포넌트 렌더링 업데이트]
|
v
[Tasks 컴포넌트 및 할 일 목록 업데이트]
[사용자]
|
v
[ProjectsSidebar 컴포넌트]
| (프로젝트 추가 버튼 클릭)
v
[App 컴포넌트의 handleStartAddProject 호출]
|
v
[selectedProjectId = null]
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[NewProject 컴포넌트]
|
|-- 입력값 (title, description, dueDate)
|
v
[저장 버튼 클릭]
|
v
[NewProject 컴포넌트의 handleSave 실행]
|
|-- 유효성 검사
| |
| |-- 유효하지 않음 --> [Modal 컴포넌트 열기]
| |
| |-- 유효함 --> [App 컴포넌트의 handleAddProject 호출]
|
v
[App 컴포넌트의 handleAddProject 실행]
|
|-- 프로젝트 추가
|-- selectedProjectId = undefined
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[NoProjectSelected 컴포넌트]
|
|-- 프로젝트 목록 렌더링
|-- 프로젝트 버튼 클릭 --> [App 컴포넌트의 handleSelectProject 호출]
|
v
[App 컴포넌트의 handleSelectProject 실행]
|
|-- selectedProjectId = 선택된 프로젝트의 ID
|
v
[App 컴포넌트 렌더링 업데이트]
|
v
[SelectedProject 컴포넌트 표시]
|
|-- 할 일 추가 --> [SelectedProject 컴포넌트의 handleAddTask 호출]
|-- 할 일 삭제 --> [SelectedProject 컴포넌트의 handleDeleteTask 호출]
|-- 삭제 버튼 클릭 --> [App 컴포넌트의 handleDeleteProject 호출]
|
v
[App 컴포넌트의 handleAddTask 실행]
|
|-- 선택된 프로젝트의 할 일 목록에 새 할 일 추가
|
v
[SelectedProject 컴포넌트 렌더링 업데이트]
|
v
[Tasks 컴포넌트 및 할 일 목록 업데이트]
|
|-- 할 일 삭제 버튼 클릭 --> [Tasks 컴포넌트의 handleDeleteTask 호출]
|
v
[App 컴포넌트의 handleDeleteTask 실행]
|
|-- 선택된 프로젝트의 할 일 목록에서 특정 할 일 삭제
|
v
[SelectedProject 컴포넌트 렌더링 업데이트]
|
v
[Tasks 컴포넌트 및 할 일 목록 업데이트]
문제 해결 및 검증
할 일 삭제 기능 확인
빈 할 일 추가 방지 확인
선택된 프로젝트 강조 표시 확인
개선 사항 및 다음 단계
상태 관리 최적화
할 일 수정 및 완료 기능 추가
에러 모달 개선
UI/UX 개선
주요 학습 목표
프로퍼티 내리꽂기(Prop Drilling)란 무엇인가?
컨텍스트(Context) API를 사용한 상태 관리
리듀서(Reducer) 및 useReducer 훅을 사용한 상태 관리
Reducer
)는 현재 상태와 액션을 받아 새로운 상태를 반환하는 함수입니다. 이는 상태 업데이트 로직을 분리하여 코드의 가독성과 유지보수성을 높여줍니다.// 1. 리듀서 함수 정의
// src/context/ProjectReducer.ts
import { Project, Task } from '../types';
export type Action =
| { type: 'ADD_PROJECT'; payload: { title: string; description: string; dueDate: string } }
| { type: 'DELETE_PROJECT'; payload: { id: number } }
| { type: 'SELECT_PROJECT'; payload: { id: number | null } }
| { type: 'ADD_TASK'; payload: { projectId: number; taskContent: string } }
| { type: 'DELETE_TASK'; payload: { projectId: number; taskId: number } };
export interface ProjectState {
projects: Project[];
selectedProjectId: number | null;
}
export const projectReducer = (state: ProjectState, action: Action): ProjectState => {
switch (action.type) {
case 'ADD_PROJECT':
const newProject: Project = {
id: Math.random(),
title: action.payload.title,
description: action.payload.description,
dueDate: action.payload.dueDate,
tasks: [],
};
return {
...state,
projects: [...state.projects, newProject],
selectedProjectId: null,
};
case 'DELETE_PROJECT':
return {
...state,
projects: state.projects.filter((project) => project.id !== action.payload.id),
selectedProjectId:
state.selectedProjectId === action.payload.id ? null : state.selectedProjectId,
};
case 'SELECT_PROJECT':
return {
...state,
selectedProjectId: action.payload.id,
};
case 'ADD_TASK':
return {
...state,
projects: state.projects.map((project) =>
project.id === action.payload.projectId
? {
...project,
tasks: [
...project.tasks,
{ id: Math.random(), content: action.payload.taskContent },
],
}
: project
),
};
case 'DELETE_TASK':
return {
...state,
projects: state.projects.map((project) =>
project.id === action.payload.projectId
? {
...project,
tasks: project.tasks.filter((task) => task.id !== action.payload.taskId),
}
: project
),
};
default:
return state;
}
};
// 2. useReducer를 사용하여 상태 관리 로직을 리듀서로 이전
// src/context/ProjectContext.tsx
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
import { Project, Task } from '../types';
import { projectReducer, ProjectState, Action } from './ProjectReducer';
interface ProjectContextType extends ProjectState {
dispatch: React.Dispatch<Action>;
}
const ProjectContext = createContext<ProjectContextType | undefined>(undefined);
export const useProjectContext = (): ProjectContextType => {
const context = useContext(ProjectContext);
if (!context) {
throw new Error('useProjectContext must be used within a ProjectProvider');
}
return context;
};
interface ProjectProviderProps {
children: ReactNode;
}
export const ProjectProvider: React.FC<ProjectProviderProps> = ({ children }) => {
const initialState: ProjectState = {
projects: [],
selectedProjectId: null,
};
const [state, dispatch] = useReducer(projectReducer, initialState);
return (
<ProjectContext.Provider value={{ ...state, dispatch }}>
{children}
</ProjectContext.Provider>
);
};
// 3. 컨텍스트의 디스패치 함수를 사용하여 상태를 업데이트
// src/App.tsx
import React from 'react';
import ProjectsSidebar from './components/ProjectsSidebar';
import NewProject from './components/NewProject';
import NoProjectSelected from './components/NoProjectSelected';
import SelectedProject from './components/SelectedProject';
import { ProjectProvider, useProjectContext } from './context/ProjectContext';
const AppContent: React.FC = () => {
const {
projects,
selectedProjectId,
dispatch,
} = useProjectContext();
const selectedProject = projects.find((project) => project.id === selectedProjectId);
const handleAddProject = (projectData: { title: string; description: string; dueDate: string }) => {
dispatch({ type: 'ADD_PROJECT', payload: projectData });
};
const handleCancelAddProject = () => {
dispatch({ type: 'SELECT_PROJECT', payload: { id: null } });
};
const handleStartAddProject = () => {
dispatch({ type: 'SELECT_PROJECT', payload: { id: null } });
};
const handleDeleteProject = (id: number) => {
dispatch({ type: 'DELETE_PROJECT', payload: { id } });
};
const handleAddTask = (projectId: number, taskContent: string) => {
dispatch({ type: 'ADD_TASK', payload: { projectId, taskContent } });
};
const handleDeleteTask = (projectId: number, taskId: number) => {
dispatch({ type: 'DELETE_TASK', payload: { projectId, taskId } });
};
let content;
if (selectedProjectId === null) {
content = <NewProject onAdd={handleAddProject} onCancel={handleCancelAddProject} />;
} else if (selectedProjectId !== undefined) {
if (selectedProject) {
content = (
<SelectedProject
project={selectedProject}
onDelete={handleDeleteProject}
onAddTask={handleAddTask}
onDeleteTask={handleDeleteTask}
/>
);
} else {
content = <NoProjectSelected onStartAddProject={handleStartAddProject} />;
}
} else {
content = <NoProjectSelected onStartAddProject={handleStartAddProject} />;
}
return (
<div className="h-screen my-8">
<main className="flex gap-8">
<ProjectsSidebar
projects={projects}
selectedProjectId={selectedProjectId}
onSelectProject={(id) => dispatch({ type: 'SELECT_PROJECT', payload: { id } })}
/>
{content}
</main>
</div>
);
};
const App: React.FC = () => {
return (
<ProjectProvider>
<AppContent />
</ProjectProvider>
);
};
export default App;
// 4. 컨텍스트를 통해 전달받은 projects, selectedProjectId, onSelectProject를 사용
// src/components/ProjectsSidebar.tsx
import React from 'react';
import Button from './Button';
import { Project } from '../types';
interface ProjectsSidebarProps {
projects: Project[];
selectedProjectId: number | null;
onSelectProject: (id: number) => void;
}
const ProjectsSidebar: React.FC<ProjectsSidebarProps> = ({
projects,
selectedProjectId,
onSelectProject,
}) => {
return (
<aside className="w-1/3 md:w-72 px-8 py-16 bg-stone-900 text-stone-50 rounded-r-xl">
<h2 className="mb-8 font-bold uppercase text-stone-400 md:text-2xl">
당신의 프로젝트
</h2>
<Button onClick={() => onSelectProject(-1)}>+프로젝트 추가</Button>
<ul className="mt-8">
{projects.map((project) => {
const isSelected = project.id === selectedProjectId;
const buttonClasses = isSelected
? 'w-full text-left px-2 py-1 rounded-md bg-stone-800 text-stone-200 hover:bg-stone-900 hover:text-stone-50'
: 'w-full text-left px-2 py-1 rounded-md text-stone-400 hover:text-stone-100 hover:bg-stone-800';
return (
<li key={project.id} className="mt-4">
<button
className={buttonClasses}
onClick={() => onSelectProject(project.id)}
>
{project.title}
</button>
</li>
);
})}
</ul>
</aside>
);
};
export default ProjectsSidebar;
// 5. Tasks 컴포넌트는 할 일 추가와 삭제를 위한 함수를 받음
// src/components/Tasks.tsx
import React from 'react';
import NewTask from './NewTask';
import Button from './Button';
import { Task } from '../types';
interface TasksProps {
tasks: Task[];
onAddTask: (taskContent: string) => void;
onDeleteTask: (taskId: number) => void;
}
const Tasks: React.FC<TasksProps> = ({ tasks, onAddTask, onDeleteTask }) => {
return (
<section className="mt-8">
<h2 className="text-2xl font-bold text-stone-700 mb-4">할 일</h2>
<NewTask onAddTask={onAddTask} />
{tasks.length === 0 ? (
<p className="text-stone-800">이 프로젝트에는 아직 할 일 목록이 없습니다.</p>
) : (
<ul className="list-disc list-inside bg-stone-100 p-4 rounded-md">
{tasks.map((task) => (
<li key={task.id} className="flex items-center justify-between text-stone-600 my-2">
<span>{task.content}</span>
<Button
onClick={() => onDeleteTask(task.id)}
className="bg-red-500 text-white hover:bg-red-600 px-2 py-1 rounded-md text-sm"
>
삭제
</Button>
</li>
))}
</ul>
)}
</section>
);
};
export default Tasks;
// 6. SelectedProject는 프로젝트의 할 일 관리와 삭제를 담당
// src/components/SelectedProject.tsx
import React from 'react';
import Button from './Button';
import Tasks from './Tasks';
import { Project } from '../types';
interface SelectedProjectProps {
project: Project;
onDelete: (id: number) => void;
onAddTask: (projectId: number, taskContent: string) => void;
onDeleteTask: (projectId: number, taskId: number) => void;
}
const SelectedProject: React.FC<SelectedProjectProps> = ({
project,
onDelete,
onAddTask,
onDeleteTask,
}) => {
const formattedDate = new Date(project.dueDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
const handleDelete = () => {
onDelete(project.id);
};
const handleAddTask = (taskContent: string) => {
onAddTask(project.id, taskContent);
};
const handleDeleteTask = (taskId: number) => {
onDeleteTask(project.id, taskId);
};
return (
<div className="w-[35rem] mt-16 p-4 mb-4 border border-stone-300 rounded-md">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold text-stone-600">{project.title}</h1>
<button
onClick={handleDelete}
className="text-stone-600 hover:text-stone-950"
>
삭제
</button>
</div>
<p className="mb-4 text-stone-400">마감일: {formattedDate}</p>
<p className="text-stone-600 whitespace-pre-wrap mb-6">{project.description}</p>
<Tasks tasks={project.tasks} onAddTask={handleAddTask} onDeleteTask={handleDeleteTask} />
</div>
);
};
export default SelectedProject;
// 프로젝트 구조 예시
App
├── Header
│ └── CartModal
├── Shop
│ └── ProductList
│ └── Product
App: 전체 애플리케이션의 상태를 관리하는 최상위 컴포넌트.
Header: 사이트의 헤더로, 장바구니 아이콘을 클릭하면 CartModal이 열립니다.
Shop: 상품 목록을 보여주는 컴포넌트.
ProductList: 여러 개의 Product 컴포넌트를 포함하는 리스트.
Product: 개별 상품을 표시하며, 장바구니에 추가할 수 있는 버튼을 가집니다.
문제 상황 시뮬레이션
프로퍼티 내리꽂기의 문제점
프로퍼티 내리꽂기 해결 방안
컨텍스트의 주요 구성 요소
// 1. CartContext를 생성하여 장바구니 상태와 관련 함수를 관리
// src/context/CartContext.tsx
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface Product {
id: number;
name: string;
price: number;
}
interface CartContextType {
cart: Product[];
addToCart: (product: Product) => void;
removeFromCart: (productId: number) => void;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
export const useCart = (): CartContextType => {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
};
interface CartProviderProps {
children: ReactNode;
}
export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
const [cart, setCart] = useState<Product[]>([]);
const addToCart = (product: Product) => {
setCart([...cart, product]);
};
const removeFromCart = (productId: number) => {
setCart(cart.filter(product => product.id !== productId));
};
return (
<CartContext.Provider value={{ cart, addToCart, removeFromCart }}>
{children}
</CartContext.Provider>
);
};
// 2. App 컴포넌트를 CartProvider로 감싸서 모든 하위 컴포넌트에서 장바구니 상태에 접근할 수 있도록 함
// App.tsx
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import { CartProvider } from './context/CartContext';
const App: React.FC = () => {
return (
<CartProvider>
<Header />
<Shop />
<CartModal />
</CartProvider>
);
};
export default App;
// 3. Header 컴포넌트에서 장바구니 상태를 소비하여 장바구니 아이콘에 상품 수를 표시
// Header.tsx
import React from 'react';
import { useCart } from '../context/CartContext';
const Header: React.FC = () => {
const { cart } = useCart();
return (
<header>
<h1>온라인 쇼핑몰</h1>
<div>
<span>장바구니: {cart.length}개</span>
{/* 장바구니 아이콘 클릭 시 CartModal 열기 */}
</div>
</header>
);
};
export default Header;
// 4. Shop 컴포넌트에서 addToCart 함수를 사용하여 상품을 장바구니에 추가
// Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { useCart } from '../context/CartContext';
interface Product {
id: number;
name: string;
price: number;
}
const Shop: React.FC = () => {
const { addToCart } = useCart();
const products: Product[] = [
{ id: 1, name: '상품 A', price: 10000 },
{ id: 2, name: '상품 B', price: 20000 },
// 추가 상품...
];
return (
<div>
<h2>상품 목록</h2>
<ProductList products={products} addToCart={addToCart} />
</div>
);
};
export default Shop;
// 5. Product 컴포넌트에서 직접적으로 컨텍스트를 사용할 수도 있지만, 이번 예시에서는 Shop에서 전달받은 addToCart 함수를 사용
// Product.tsx
import React from 'react';
import { Product } from '../types';
interface ProductProps {
product: Product;
addToCart: (product: Product) => void;
}
const Product: React.FC<ProductProps> = ({ product, addToCart }) => {
return (
<li>
<span>{product.name} - {product.price}원</span>
<button onClick={() => addToCart(product)}>장바구니에 추가</button>
</li>
);
};
export default Product;
// 6. CartModal 컴포넌트에서 장바구니의 내용을 표시하고, 삭제 기능을 구현
// CartModal.tsx
import React from 'react';
import { useCart } from '../context/CartContext';
const CartModal: React.FC = () => {
const { cart, removeFromCart } = useCart();
return (
<div className="modal">
<h2>장바구니</h2>
{cart.length === 0 ? (
<p>장바구니가 비었습니다.</p>
) : (
<ul>
{cart.map(product => (
<li key={product.id}>
<span>{product.name} - {product.price}원</span>
<button onClick={() => removeFromCart(product.id)}>삭제</button>
</li>
))}
</ul>
)}
{/* 결제 버튼 등 추가 기능 */}
</div>
);
};
export default CartModal;
컨텍스트(Context) API의 장점
리듀서(Reducer)와 useReducer 훅 소개
Reducer
)는 상태 업데이트 로직을 중앙집중화하여, 복잡한 상태 변화를 효율적으로 관리할 수 있게 도와줍니다.
useReducer 훅은 useState보다 더 복잡한 상태 로직을 처리할 때 유용하게 사용할 수 있습니다.src
├── components
│ ├── Header.tsx
│ ├── Shop.tsx
│ ├── ProductList.tsx
│ ├── Product.tsx
│ └── CartModal.tsx
├── context
│ ├── CartContext.tsx
│ └── CartReducer.ts
├── types.ts
├── App.tsx
└── index.tsx
// src/types.ts
export interface Product {
id: number;
name: string;
price: number;
}
// src/context/CartReducer.ts
import { Product } from '../types';
export type CartAction =
| { type: 'ADD_TO_CART'; payload: Product }
| { type: 'REMOVE_FROM_CART'; payload: { id: number } };
export const cartReducer = (state: Product[], action: CartAction): Product[] => {
switch (action.type) {
case 'ADD_TO_CART':
return [...state, action.payload];
case 'REMOVE_FROM_CART':
return state.filter(product => product.id !== action.payload.id);
default:
return state;
}
};
// src/context/CartContext.tsx
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
import { Product } from '../types';
import { cartReducer, CartAction } from './CartReducer';
interface CartContextType {
cart: Product[];
addToCart: (product: Product) => void;
removeFromCart: (productId: number) => void;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
export const useCart = (): CartContextType => {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
};
interface CartProviderProps {
children: ReactNode;
}
export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
const [cart, dispatch] = useReducer(cartReducer, []);
const addToCart = (product: Product) => {
dispatch({ type: 'ADD_TO_CART', payload: product });
};
const removeFromCart = (productId: number) => {
dispatch({ type: 'REMOVE_FROM_CART', payload: { id: productId } });
};
return (
<CartContext.Provider value={{ cart, addToCart, removeFromCart }}>
{children}
</CartContext.Provider>
);
};
// src/App.tsx
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import { CartProvider } from './context/CartContext';
const App: React.FC = () => {
return (
<CartProvider>
<Header />
<Shop />
<CartModal />
</CartProvider>
);
};
export default App;
// src/components/Header.tsx
import React from 'react';
import { useCart } from '../context/CartContext';
const Header: React.FC = () => {
const { cart } = useCart();
return (
<header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
<h1>온라인 쇼핑몰</h1>
<div>
<span>장바구니: {cart.length}개</span>
{/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
</div>
</header>
);
};
export default Header;
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { useCart } from '../context/CartContext';
import { Product } from '../types';
const Shop: React.FC = () => {
const { addToCart } = useCart();
const products: Product[] = [
{ id: 1, name: '상품 A', price: 10000 },
{ id: 2, name: '상품 B', price: 20000 },
{ id: 3, name: '상품 C', price: 30000 },
// 추가 상품...
];
return (
<div style={{ padding: '20px' }}>
<h2>상품 목록</h2>
<ProductList products={products} addToCart={addToCart} />
</div>
);
};
export default Shop;
// src/components/ProductList.tsx
import React from 'react';
import Product from './Product';
import { Product as ProductType } from '../types';
interface ProductListProps {
products: ProductType[];
addToCart: (product: ProductType) => void;
}
const ProductList: React.FC<ProductListProps> = ({ products, addToCart }) => {
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{products.map(product => (
<Product key={product.id} product={product} addToCart={addToCart} />
))}
</ul>
);
};
export default ProductList;
// src/components/Product.tsx
import React from 'react';
import { Product } from '../types';
interface ProductProps {
product: Product;
addToCart: (product: Product) => void;
}
const Product: React.FC<ProductProps> = ({ product, addToCart }) => {
return (
<li style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => addToCart(product)}>장바구니에 추가</button>
</li>
);
};
export default Product;
// src/components/CartModal.tsx
import React from 'react';
import { useCart } from '../context/CartContext';
import { Product } from '../types';
const CartModal: React.FC = () => {
const { cart, removeFromCart } = useCart();
// 모달 표시 로직은 추후 추가 가능
return (
<div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
<h2>장바구니</h2>
{cart.length === 0 ? (
<p>장바구니가 비었습니다.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{cart.map(product => (
<li key={product.id} style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => removeFromCart(product.id)}>삭제</button>
</li>
))}
</ul>
)}
{/* 결제 버튼 등 추가 기능 가능 */}
</div>
);
};
export default CartModal;
Component Composition
)은 여러 컴포넌트를 조합하여 더 복잡한 UI를 구성하는 리액트의 핵심 개념 중 하나입니다.
이를 통해 특정 데이터를 여러 컴포넌트에 전달할 때, 불필요한 중간 컴포넌트를 거치지 않고 효율적으로 데이터를 전달할 수 있습니다.// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { Product } from '../types';
interface ShopProps {
addToCart: (product: Product) => void;
}
const Shop: React.FC<ShopProps> = ({ addToCart }) => {
const products: Product[] = [
{ id: 1, name: '상품 A', price: 10000 },
{ id: 2, name: '상품 B', price: 20000 },
// 추가 상품...
];
return (
<div>
<h2>상품 목록</h2>
<ProductList products={products} addToCart={addToCart} />
</div>
);
};
export default Shop;
// 컴포넌트 합성을 통해 Shop을 감싸는 역할로 사용하고, App 컴포넌트에서 직접 Product를 렌더링합니다.
// src/components/Shop.tsx
import React from 'react';
interface ShopProps {
children: React.ReactNode;
}
const Shop: React.FC<ShopProps> = ({ children }) => {
return (
<div>
<h2>상품 목록</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{children}
</ul>
</div>
);
};
export default Shop;
// src/App.tsx
import React, { useState } from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import ProductList from './components/ProductList';
import Product from './components/Product';
import CartModal from './components/CartModal';
import { Product as ProductType } from './types';
const App: React.FC = () => {
const [cart, setCart] = useState<ProductType[]>([]);
const addToCart = (product: ProductType) => {
setCart([...cart, product]);
};
const removeFromCart = (productId: number) => {
setCart(cart.filter(product => product.id !== productId));
};
return (
<div>
<Header cart={cart} removeFromCart={removeFromCart} />
<Shop addToCart={addToCart}>
<ProductList products={products} addToCart={addToCart} />
</Shop>
<CartModal cart={cart} removeFromCart={removeFromCart} />
</div>
);
};
export default App;
// 컴포넌트 합성을 통해 Shop을 감싸고, Product 컴포넌트를 직접 렌더링합니다.
// src/App.tsx
import React, { useState } from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import Product from './components/Product';
import CartModal from './components/CartModal';
import { Product as ProductType } from './types';
const App: React.FC = () => {
const [cart, setCart] = useState<ProductType[]>([]);
const addToCart = (product: ProductType) => {
setCart([...cart, product]);
};
const removeFromCart = (productId: number) => {
setCart(cart.filter(product => product.id !== productId));
};
const products: ProductType[] = [
{ id: 1, name: '상품 A', price: 10000 },
{ id: 2, name: '상품 B', price: 20000 },
{ id: 3, name: '상품 C', price: 30000 },
// 추가 상품...
];
return (
<div>
<Header cart={cart} removeFromCart={removeFromCart} />
<Shop>
{products.map(product => (
<Product key={product.id} product={product} addToCart={addToCart} />
))}
</Shop>
<CartModal cart={cart} removeFromCart={removeFromCart} />
</div>
);
};
export default App;
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { Product } from '../types';
interface ShopProps {
addToCart: (product: Product) => void;
}
const Shop: React.FC<ShopProps> = ({ addToCart }) => {
const products: Product[] = [
{ id: 1, name: '상품 A', price: 10000 },
{ id: 2, name: '상품 B', price: 20000 },
// 추가 상품...
];
return (
<div>
<h2>상품 목록</h2>
<ProductList products={products} addToCart={addToCart} />
</div>
);
};
export default Shop;
// src/components/Shop.tsx
import React from 'react';
interface ShopProps {
children: React.ReactNode;
}
const Shop: React.FC<ShopProps> = ({ children }) => {
return (
<div>
<h2>상품 목록</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{children}
</ul>
</div>
);
};
export default Shop;
Product 컴포넌트 확인
결과 확인
장점:
단점:
컴포넌트 합성의 한계와 다음 해결책
Reducer
)와 함께 사용하여 복잡한 상태 로직도 체계적으로 관리할 수 있습니다.컨텍스트(Context) API란?
컨텍스트(Context) API의 장점
prop drilling
)를 해결하는 데 있어 컨텍스트는 매우 효과적인 도구입니다. 주요 장점은 다음과 같습니다:// src 폴더 내에 store라는 이름의 새 폴더를 만듭니다.
// 이 폴더는 애플리케이션의 전역 상태와 관련된 모든 컨텍스트 파일을 저장하는 역할을 합니다.
src
├── components
│ ├── Header.tsx
│ ├── Shop.tsx
│ ├── ProductList.tsx
│ ├── Product.tsx
│ └── CartModal.tsx
├── store
│ └── shopping-cart-context.tsx
├── types.ts
├── App.tsx
└── index.tsx
// store 폴더 내에 shopping-cart-context.tsx 파일을 생성합니다. 이 파일에서 컨텍스트를 정의하고, 제공할 값을 설정합니다.
// src/store/shopping-cart-context.tsx
import React, { createContext, useState, ReactNode } from 'react';
import { Product } from '../types';
// 컨텍스트에서 제공할 데이터의 타입 정의
// 컨텍스트가 제공할 데이터의 타입을 정의합니다.
// 여기서는 items 배열과 두 개의 함수 addItemToCart와 removeItemFromCart를 포함합니다.
interface CartContextType {
items: Product[];
addItemToCart: (product: Product) => void;
removeItemFromCart: (productId: number) => void;
}
// 초기값 설정 (빈 컨텍스트 객체)
// CartContext: createContext를 사용하여 컨텍스트를 생성합니다.
// 초기값으로 undefined를 설정하여, 컨텍스트를 사용하는 컴포넌트가 Provider 안에 있어야 함을 명시합니다.
const CartContext = createContext<CartContextType | undefined>(undefined);
// Provider 컴포넌트의 props 타입 정의
interface CartProviderProps {
children: ReactNode;
}
// Provider 컴포넌트 정의
// CartProvider: 컨텍스트의 Provider 역할을 하는 컴포넌트입니다.
// useState를 사용하여 items 상태를 관리하고, addItemToCart와 removeItemFromCart 함수를 정의하여 상태를 업데이트합니다.
// 이 함수들은 하위 컴포넌트에서 사용할 수 있도록 컨텍스트 값을 통해 제공합니다.
export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
const [items, setItems] = useState<Product[]>([]);
// 장바구니에 상품 추가하는 함수
const addItemToCart = (product: Product) => {
setItems((prevItems) => [...prevItems, product]);
};
// 장바구니에서 상품 제거하는 함수
const removeItemFromCart = (productId: number) => {
setItems((prevItems) => prevItems.filter(item => item.id !== productId));
};
return (
<CartContext.Provider value={{ items, addItemToCart, removeItemFromCart }}>
{children}
</CartContext.Provider>
);
};
export default CartContext;
// App 컴포넌트에 CartProvider 적용
// 이제 App 컴포넌트를 수정하여 CartProvider로 전체 애플리케이션을 감싸줍니다. 이를 통해 하위 컴포넌트들이 컨텍스트 값을 사용할 수 있게 됩니다.
// src/App.tsx
// CartProvider로 Header, Shop, CartModal 컴포넌트를 감싸줌으로써, 이들 컴포넌트와 그 자식 컴포넌트들이 CartContext의 값에 접근할 수 있게 됩니다.
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import { CartProvider } from './store/shopping-cart-context';
const App: React.FC = () => {
return (
<CartProvider>
<Header />
<Shop />
<CartModal />
</CartProvider>
);
};
export default App;
// 하위 컴포넌트에서 컨텍스트 사용하기
// 이제 Header, Shop, Product, CartModal 컴포넌트에서 컨텍스트를 사용하여 장바구니 상태를 읽고 업데이트할 수 있도록 수정합니다.
// src/components/Header.tsx
// Header 컴포넌트는 장바구니의 아이템 수를 표시합니다.
import React from 'react';
import CartContext from '../store/shopping-cart-context';
import { useContext } from 'react';
const Header: React.FC = () => {
// useContext: useContext 훅을 사용하여 CartContext의 값을 가져옵니다.
const cartCtx = useContext(CartContext);
// 에러 핸들링: CartContext가 undefined인 경우 에러를 던져, 컴포넌트가 CartProvider 안에 사용되고 있음을 보장합니다.
if (!cartCtx) {
throw new Error('Header must be used within a CartProvider');
}
// totalItems: cartCtx.items.length를 통해 장바구니에 담긴 상품의 수를 계산하여 표시합니다.
const totalItems = cartCtx.items.length;
return (
<header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
<h1>온라인 쇼핑몰</h1>
<div>
<span>장바구니: {totalItems}개</span>
{/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
</div>
</header>
);
};
export default Header;
// Shop 컴포넌트는 상품 목록을 표시하고, 각 상품을 장바구니에 추가할 수 있는 기능을 제공합니다.
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import CartContext from '../store/shopping-cart-context';
import { useContext } from 'react';
import { Product } from '../types';
const Shop: React.FC = () => {
// useContext: CartContext의 값을 가져와서 addToCartHandler 함수를 정의합니다.
const cartCtx = useContext(CartContext);
if (!cartCtx) {
throw new Error('Shop must be used within a CartProvider');
}
const products: Product[] = [
{ id: 1, name: '상품 A', price: 10000 },
{ id: 2, name: '상품 B', price: 20000 },
{ id: 3, name: '상품 C', price: 30000 },
// 추가 상품...
];
// addToCartHandler: 전달받은 product를 cartCtx.addItemToCart 함수를 통해 장바구니에 추가합니다.
const addToCartHandler = (product: Product) => {
cartCtx.addItemToCart(product);
};
// ProductList: products 배열과 onAddToCart 함수를 ProductList 컴포넌트에 전달합니다.
return (
<div style={{ padding: '20px' }}>
<h2>상품 목록</h2>
<ProductList products={products} onAddToCart={addToCartHandler} />
</div>
);
};
export default Shop;
// src/components/ProductList.tsx
import React from 'react';
import Product from './Product';
import { Product as ProductType } from '../types';
interface ProductListProps {
products: ProductType[];
onAddToCart: (product: ProductType) => void;
}
const ProductList: React.FC<ProductListProps> = ({ products, onAddToCart }) => {
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{products.map(product => (
<Product key={product.id} product={product} onAddToCart={onAddToCart} />
))}
</ul>
);
};
export default ProductList;
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';
interface ProductProps {
product: ProductType;
onAddToCart: (product: ProductType) => void;
}
const Product: React.FC<ProductProps> = ({ product, onAddToCart }) => {
return (
<li style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => onAddToCart(product)}>장바구니에 추가</button>
</li>
);
};
export default Product;
// src/components/CartModal.tsx
import React from 'react';
import CartContext from '../store/shopping-cart-context';
import { useContext } from 'react';
import { Product } from '../types';
const CartModal: React.FC = () => {
// useContext: CartContext의 값을 가져와서 장바구니의 아이템들을 표시합니다.
const cartCtx = useContext(CartContext);
if (!cartCtx) {
throw new Error('CartModal must be used within a CartProvider');
}
// removeFromCartHandler: 상품 삭제 버튼 클릭 시 해당 상품을 장바구니에서 제거하는 함수를 호출합니다.
const removeFromCartHandler = (productId: number) => {
cartCtx.removeItemFromCart(productId);
};
return (
<div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
<h2>장바구니</h2>
{cartCtx.items.length === 0 ? (
<p>장바구니가 비었습니다.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{cartCtx.items.map(product => (
<li key={product.id} style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => removeFromCartHandler(product.id)}>삭제</button>
</li>
))}
</ul>
)}
{/* 결제 버튼 등 추가 기능 가능 */}
</div>
);
};
export default CartModal;
state
)를 연결하여 프로퍼티 내리꽂기(prop drilling) 문제를 완전히 해결하는 방법state
)와 상태 업데이트 함수를 컨텍스트 값으로 제공해야 합니다.
이를 통해 애플리케이션의 모든 컴포넌트가 해당 상태를 읽고 업데이트할 수 있게 됩니다.// src/store/shopping-cart-context.tsx
import React, { createContext, useState, ReactNode } from 'react';
import { Product } from '../types';
// 컨텍스트에서 제공할 데이터의 타입 정의
// CartContextType: 컨텍스트가 제공할 데이터의 타입을 정의합니다.
// 여기서는 items 배열과 두 개의 함수 addItemToCart와 removeItemFromCart를 포함합니다.
interface CartContextType {
items: Product[];
addItemToCart: (product: Product) => void;
removeItemFromCart: (productId: number) => void;
}
// 초기값 설정 (빈 컨텍스트 객체)
// CartContext: createContext를 사용하여 컨텍스트를 생성합니다.
// 초기값으로 undefined를 설정하여, 컨텍스트를 사용하는 컴포넌트가 Provider 안에 있어야 함을 명시합니다.
const CartContext = createContext<CartContextType | undefined>(undefined);
// Provider 컴포넌트의 props 타입 정의
interface CartProviderProps {
children: ReactNode;
}
// Provider 컴포넌트 정의
// CartProvider: 컨텍스트의 Provider 역할을 하는 컴포넌트입니다.
// useState를 사용하여 items 상태를 관리하고, addItemToCart와 removeItemFromCart 함수를 정의하여 상태를 업데이트합니다.
// 이 함수들은 하위 컴포넌트에서 사용할 수 있도록 컨텍스트 값을 통해 제공합니다.
export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
const [items, setItems] = useState<Product[]>([]);
// 장바구니에 상품 추가하는 함수
const addItemToCart = (product: Product) => {
setItems((prevItems) => [...prevItems, product]);
};
// 장바구니에서 상품 제거하는 함수
const removeItemFromCart = (productId: number) => {
setItems((prevItems) => prevItems.filter(item => item.id !== productId));
};
return (
<CartContext.Provider value={{ items, addItemToCart, removeItemFromCart }}>
{children}
</CartContext.Provider>
);
};
export default CartContext;
// App 컴포넌트를 수정하여 CartProvider로 전체 애플리케이션을 감싸줍니다.
// 이를 통해 하위 컴포넌트들이 컨텍스트 값을 사용할 수 있게 됩니다.
// src/App.tsx
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import { CartProvider } from './store/shopping-cart-context';
// CartProvider로 감싸기: CartProvider로 Header, Shop, CartModal 컴포넌트를 감싸서 이들 컴포넌트와 그 자식 컴포넌트들이 CartContext의 값에 접근할 수 있게 합니다.
const App: React.FC = () => {
return (
<CartProvider>
<Header />
<Shop />
<CartModal />
</CartProvider>
);
};
export default App;
// 하위 컴포넌트에서 컨텍스트 사용하기
// 이제 각 컴포넌트에서 컨텍스트 값을 읽고 업데이트할 수 있도록 수정하겠습니다.
// src/components/Header.tsx
import React, { useContext } from 'react';
import CartContext from '../store/shopping-cart-context';
const Header: React.FC = () => {
// useContext 사용: useContext 훅을 사용하여 CartContext의 값을 가져옵니다.
const cartCtx = useContext(CartContext);
if (!cartCtx) {
// 에러 핸들링: CartContext가 undefined인 경우 에러를 던져, 컴포넌트가 CartProvider 안에 사용되고 있음을 보장합니다.
throw new Error('Header must be used within a CartProvider');
}
// 아이템 수 계산: cartCtx.items.length를 통해 장바구니에 담긴 상품의 수를 계산하여 표시합니다.
const totalItems = cartCtx.items.length;
return (
<header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
<h1>온라인 쇼핑몰</h1>
<div>
<span>장바구니: {totalItems}개</span>
{/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
</div>
</header>
);
};
export default Header;
// src/components/Shop.tsx
import React, { useContext } from 'react';
import ProductList from './ProductList';
import CartContext from '../store/shopping-cart-context';
import { Product } from '../types';
const Shop: React.FC = () => {
// useContext 사용: CartContext의 값을 가져옵니다.
const cartCtx = useContext(CartContext);
if (!cartCtx) {
throw new Error('Shop must be used within a CartProvider');
}
const products: Product[] = [
{ id: 1, name: '상품 A', price: 10000 },
{ id: 2, name: '상품 B', price: 20000 },
{ id: 3, name: '상품 C', price: 30000 },
// 추가 상품...
];
// addToCartHandler 함수: addItemToCart 함수를 사용하여 상품을 장바구니에 추가합니다.
const addToCartHandler = (product: Product) => {
cartCtx.addItemToCart(product);
};
// ProductList에 onAddToCart 전달: ProductList 컴포넌트에 onAddToCart 함수를 전달하여, 각 상품에서 장바구니에 추가할 수 있도록 합니다.
return (
<div style={{ padding: '20px' }}>
<h2>상품 목록</h2>
<ProductList products={products} onAddToCart={addToCartHandler} />
</div>
);
};
export default Shop;
// src/components/ProductList.tsx
import React from 'react';
import Product from './Product';
import { Product as ProductType } from '../types';
// ProductListProps 인터페이스 정의: products 배열과 onAddToCart 함수를 명시적으로 타입 지정합니다.
interface ProductListProps {
products: ProductType[];
onAddToCart: (product: ProductType) => void;
}
const ProductList: React.FC<ProductListProps> = ({ products, onAddToCart }) => {
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{products.map(product => (
<Product key={product.id} product={product} onAddToCart={onAddToCart} />
))}
</ul>
);
};
export default ProductList;
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';
interface ProductProps {
product: ProductType;
onAddToCart: (product: ProductType) => void;
}
const Product: React.FC<ProductProps> = ({ product, onAddToCart }) => {
return (
<li style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => onAddToCart(product)}>장바구니에 추가</button>
</li>
);
};
export default Product;
// src/components/CartModal.tsx
import React, { useContext } from 'react';
import CartContext from '../store/shopping-cart-context';
import { Product } from '../types';
const CartModal: React.FC = () => {
// useContext 사용: CartContext의 값을 가져와서 장바구니 항목을 표시하고 삭제할 수 있도록 합니다.
const cartCtx = useContext(CartContext);
if (!cartCtx) {
throw new Error('CartModal must be used within a CartProvider');
}
const { items, removeItemFromCart } = cartCtx;
// 총 금액 계산: reduce 함수를 사용하여 장바구니에 담긴 상품의 총 금액을 계산하여 표시합니다.
const totalPrice = items.reduce((total, item) => total + item.price, 0);
return (
<div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
<h2>장바구니</h2>
{items.length === 0 ? (
<p>장바구니에 항목 없음!</p>
) : (
<>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map(product => (
<li key={product.id} style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => removeItemFromCart(product.id)}>삭제</button>
</li>
))}
</ul>
<p>총 금액: {totalPrice}원</p>
{/* 결제 버튼 등 추가 기능 가능 */}
</>
)}
</div>
);
};
export default CartModal;
컨텍스트(Context) API의 장점과 한계
한계
state
)와 상태 업데이트 함수를 컨텍스트 값으로 제공해야 합니다.
이를 통해 애플리케이션의 모든 컴포넌트가 해당 상태를 읽고 업데이트할 수 있게 됩니다.// src/store/shopping-cart-context.tsx
import React, { createContext, useState, ReactNode } from 'react';
import { Product } from '../types';
// 컨텍스트에서 제공할 데이터의 타입 정의
interface CartContextType {
items: Product[];
addItemToCart: (product: Product) => void;
removeItemFromCart: (productId: number) => void;
}
// 컨텍스트 생성 (초기값은 undefined로 설정)
const CartContext = createContext<CartContextType | undefined>(undefined);
// Provider 컴포넌트의 props 타입 정의
interface CartProviderProps {
children: ReactNode;
}
// Provider 컴포넌트 정의
export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
const [items, setItems] = useState<Product[]>([]);
// 장바구니에 상품 추가하는 함수
const addItemToCart = (product: Product) => {
setItems((prevItems) => [...prevItems, product]);
};
// 장바구니에서 상품 제거하는 함수
const removeItemFromCart = (productId: number) => {
setItems((prevItems) => prevItems.filter(item => item.id !== productId));
};
return (
<CartContext.Provider value={{ items, addItemToCart, removeItemFromCart }}>
{children}
</CartContext.Provider>
);
};
export default CartContext;
// 컨텍스트를 보다 쉽게 사용할 수 있도록 useCart라는 커스텀 훅을 생성하겠습니다.
// useCart 훅: useContext를 래핑하여, 컨텍스트 값을 쉽게 사용할 수 있도록 도와줍니다. 또한, 타입 안전성을 보장하며, CartProvider 내부에서만 사용되도록 에러를 던집니다.
// src/hooks/useCart.ts
import { useContext } from 'react';
import CartContext, { CartContextType } from '../store/shopping-cart-context';
const useCart = (): CartContextType => {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
};
export default useCart;
// src/App.tsx
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import { CartProvider } from './store/shopping-cart-context';
const App: React.FC = () => {
return (
<CartProvider>
<Header />
<Shop />
<CartModal />
</CartProvider>
);
};
export default App;
// src/components/Header.tsx
import React from 'react';
import useCart from '../hooks/useCart';
const Header: React.FC = () => {
const { items } = useCart();
const totalItems = items.length;
return (
<header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
<h1>온라인 쇼핑몰</h1>
<div>
<span>장바구니: {totalItems}개</span>
{/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
</div>
</header>
);
};
export default Header;
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { Product } from '../types';
import useCart from '../hooks/useCart';
const Shop: React.FC = () => {
const { addItemToCart } = useCart();
const products: Product[] = [
{ id: 1, name: '상품 A', price: 10000 },
{ id: 2, name: '상품 B', price: 20000 },
{ id: 3, name: '상품 C', price: 30000 },
// 추가 상품...
];
const addToCartHandler = (product: Product) => {
addItemToCart(product);
};
return (
<div style={{ padding: '20px' }}>
<h2>상품 목록</h2>
<ProductList products={products} onAddToCart={addToCartHandler} />
</div>
);
};
export default Shop;
// src/components/ProductList.tsx
import React from 'react';
import Product from './Product';
import { Product as ProductType } from '../types';
interface ProductListProps {
products: ProductType[];
onAddToCart: (product: ProductType) => void;
}
const ProductList: React.FC<ProductListProps> = ({ products, onAddToCart }) => {
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{products.map(product => (
<Product key={product.id} product={product} onAddToCart={onAddToCart} />
))}
</ul>
);
};
export default ProductList;
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';
interface ProductProps {
product: ProductType;
onAddToCart: (product: ProductType) => void;
}
const Product: React.FC<ProductProps> = ({ product, onAddToCart }) => {
return (
<li style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => onAddToCart(product)}>장바구니에 추가</button>
</li>
);
};
export default Product;
// src/components/CartModal.tsx
import React from 'react';
import useCart from '../hooks/useCart';
import { Product } from '../types';
const CartModal: React.FC = () => {
const { items, removeItemFromCart } = useCart();
const totalPrice = items.reduce((total, item) => total + item.price, 0);
const removeFromCartHandler = (productId: number) => {
removeItemFromCart(productId);
};
return (
<div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
<h2>장바구니</h2>
{items.length === 0 ? (
<p>장바구니에 항목 없음!</p>
) : (
<>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map(product => (
<li key={product.id} style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => removeFromCartHandler(product.id)}>삭제</button>
</li>
))}
</ul>
<p>총 금액: {totalPrice}원</p>
{/* 결제 버튼 등 추가 기능 가능 */}
</>
)}
</div>
);
};
export default CartModal;
리액트의 컨텍스트(Context) API를 사용하는 두 가지 방법
useContext
훅 사용Consumer
컴포넌트 사용컨텍스트(Context) 사용 방법
useContext 훅 사용하기
// src/components/CartModal.tsx
import React from 'react';
import useCart from '../hooks/useCart';
const CartModal: React.FC = () => {
const { items, removeItemFromCart } = useCart();
const totalPrice = items.reduce((total, item) => total + item.price, 0);
const removeFromCartHandler = (productId: number) => {
removeItemFromCart(productId);
};
return (
<div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
<h2>장바구니</h2>
{items.length === 0 ? (
<p>장바구니에 항목 없음!</p>
) : (
<>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map(product => (
<li key={product.id} style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => removeFromCartHandler(product.id)}>삭제</button>
</li>
))}
</ul>
<p>총 금액: {totalPrice}원</p>
{/* 결제 버튼 등 추가 기능 가능 */}
</>
)}
</div>
);
};
export default CartModal;
// src/components/CartModalWithConsumer.tsx
import React from 'react';
import CartContext from '../store/shopping-cart-context';
import { Product } from '../types';
const CartModalWithConsumer: React.FC = () => {
return (
<CartContext.Consumer>
{({ items, removeItemFromCart }) => {
const totalPrice = items.reduce((total, item) => total + item.price, 0);
const removeFromCartHandler = (productId: number) => {
removeItemFromCart(productId);
};
return (
<div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
<h2>장바구니</h2>
{items.length === 0 ? (
<p>장바구니에 항목 없음!</p>
) : (
<>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map(product => (
<li key={product.id} style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => removeFromCartHandler(product.id)}>삭제</button>
</li>
))}
</ul>
<p>총 금액: {totalPrice}원</p>
{/* 결제 버튼 등 추가 기능 가능 */}
</>
)}
</div>
);
}}
</CartContext.Consumer>
);
};
export default CartModalWithConsumer;
설명:
useContext vs Consumer 비교
특성 | useContext 훅 | Consumer 컴포넌트 |
---|---|---|
사용 용도 | 함수형 컴포넌트에서 간편하게 컨텍스트 소비 | 주로 클래스형 컴포넌트나 구식 코드베이스 |
코드 간결성 | 매우 간결하고 직관적 | 다소 복잡하고 길어질 수 있음 |
가독성 | 높음 | 낮음 |
TypeScript 지원 | 훅을 통해 타입 안전하게 사용 가능 | render props 패턴으로 타입 지정 어려움 |
결론:
기존 코드에서 Consumer 사용법 제거하기
리액트의 컨텍스트(Context) API를 사용하는 과정에서 컴포넌트의 컨텍스트 값 처리와 사용에 있어 반드시 알아두어야 할 중요한 개념
컨텍스트(Context) 값과 컴포넌트의 리렌더링
useContext 훅과 리렌더링
// src/components/CartModal.tsx
import React from 'react';
import useCart from '../hooks/useCart';
import { Product } from '../types';
const CartModal: React.FC = () => {
const { items, removeItemFromCart } = useCart();
const totalPrice = items.reduce((total, item) => total + item.price, 0);
const removeFromCartHandler = (productId: number) => {
removeItemFromCart(productId);
};
console.log('CartModal 리렌더링됨'); // 리렌더링 확인용 로그
return (
<div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
<h2>장바구니</h2>
{items.length === 0 ? (
<p>장바구니에 항목 없음!</p>
) : (
<>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map(product => (
<li key={product.id} style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => removeFromCartHandler(product.id)}>삭제</button>
</li>
))}
</ul>
<p>총 금액: {totalPrice}원</p>
{/* 결제 버튼 등 추가 기능 가능 */}
</>
)}
</div>
);
};
export default CartModal;
설명:
컴포넌트 재실행과 상태 업데이트
// src/components/Header.tsx
import React from 'react';
import useCart from '../hooks/useCart';
const Header: React.FC = () => {
const { items } = useCart();
const totalItems = items.length;
console.log('Header 리렌더링됨'); // 리렌더링 확인용 로그
return (
<header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
<h1>온라인 쇼핑몰</h1>
<div>
<span>장바구니: {totalItems}개</span>
{/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
</div>
</header>
);
};
export default Header;
설명:
리렌더링의 원인과 최적화 방안
// src/store/cart-total-context.tsx
import React, { createContext, useContext } from 'react';
import useCart from '../hooks/useCart';
interface CartTotalContextType {
totalPrice: number;
}
const CartTotalContext = createContext<CartTotalContextType | undefined>(undefined);
interface CartTotalProviderProps {
children: React.ReactNode;
}
export const CartTotalProvider: React.FC<CartTotalProviderProps> = ({ children }) => {
const { items } = useCart();
const totalPrice = items.reduce((total, item) => total + item.price, 0);
return (
<CartTotalContext.Provider value={{ totalPrice }}>
{children}
</CartTotalContext.Provider>
);
};
export const useCartTotal = (): CartTotalContextType => {
const context = useContext(CartTotalContext);
if (!context) {
throw new Error('useCartTotal must be used within a CartTotalProvider');
}
return context;
};
export default CartTotalContext;
설명:
리렌더링의 원인과 최적화 방안 두번째, React.memo를 사용한 컴포넌트 메모이제이션
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';
interface ProductProps {
product: ProductType;
onAddToCart: (product: ProductType) => void;
}
const Product: React.FC<ProductProps> = ({ product, onAddToCart }) => {
console.log(`Product ${product.id} 리렌더링됨`); // 리렌더링 확인용 로그
return (
<li style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => onAddToCart(product)}>장바구니에 추가</button>
</li>
);
};
export default React.memo(Product);
설명:
리렌더링의 원인과 최적화 방안 세번째, 컨텍스트 값의 불변성 유지
// src/store/shopping-cart-context.tsx
import React, { createContext, useState, ReactNode } from 'react';
import { Product } from '../types';
interface CartContextType {
items: Product[];
addItemToCart: (product: Product) => void;
removeItemFromCart: (productId: number) => void;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
interface CartProviderProps {
children: ReactNode;
}
export const CartProvider: React.FC<CartProviderProps> = ({ children }) => {
const [items, setItems] = useState<Product[]>([]);
const addItemToCart = (product: Product) => {
setItems((prevItems) => [...prevItems, product]); // 불변성 유지
};
const removeItemFromCart = (productId: number) => {
setItems((prevItems) => prevItems.filter(item => item.id !== productId)); // 불변성 유지
};
return (
<CartContext.Provider value={{ items, addItemToCart, removeItemFromCart }}>
{children}
</CartContext.Provider>
);
};
export default CartContext;
컨텍스트를 사용할 때 주의해야 할 점과 최적화 방법에 대해 간략히 살펴보겠습니다.
불필요한 리렌더링 방지
컨텍스트의 과도한 사용 자제
리액트의 컨텍스트(Context) API와 useReducer 훅을 결합하여 보다 복잡한 상태 관리를 효율적으로 구현하는 방법
useReducer를 도입하여 상태 관리의 복잡성을 줄이고, 보다 체계적으로 상태를 업데이트하는 방법을 살펴보겠습니다.
useReducer 훅 도입
리듀서 함수 정의
// src/store/shoppingCartReducer.ts
import { Product } from '../types';
// CartAction 타입: 가능한 액션의 타입을 정의합니다. ADD_ITEM, REMOVE_ITEM, UPDATE_QUANTITY 세 가지 액션을 다룹니다.
export type CartAction =
| { type: 'ADD_ITEM'; payload: Product }
| { type: 'REMOVE_ITEM'; payload: { id: number } }
| { type: 'UPDATE_QUANTITY'; payload: { id: number; quantity: number } };
// shoppingCartReducer 함수: 현재 상태(state)와 액션(action)을 받아서 새로운 상태를 반환합니다.
// ADD_ITEM: 새로운 상품을 장바구니에 추가합니다.
// REMOVE_ITEM: 지정된 ID의 상품을 장바구니에서 제거합니다.
// UPDATE_QUANTITY: 지정된 ID의 상품 수량을 업데이트합니다.
export const shoppingCartReducer = (state: Product[], action: CartAction): Product[] => {
switch (action.type) {
case 'ADD_ITEM':
return [...state, action.payload];
case 'REMOVE_ITEM':
return state.filter(item => item.id !== action.payload.id);
case 'UPDATE_QUANTITY':
return state.map(item =>
item.id === action.payload.id ? { ...item, quantity: action.payload.quantity } : item
);
default:
return state;
}
};
// src/store/CartContextProvider.tsx
import React, { useReducer, ReactNode } from 'react';
import CartContext, { CartContextType } from './shopping-cart-context';
import { Product } from '../types';
import { shoppingCartReducer, CartAction } from './shoppingCartReducer';
interface CartContextProviderProps {
children: ReactNode;
}
export const CartContextProvider: React.FC<CartContextProviderProps> = ({ children }) => {
// useReducer 도입: useReducer 훅을 사용하여 상태(items)와 디스패치 함수(dispatch)를 관리합니다.
const [items, dispatch] = useReducer(shoppingCartReducer, []);
// 액션 디스패치 함수
// addItemToCart: ADD_ITEM 액션을 디스패치합니다.
const addItemToCart = (product: Product) => {
dispatch({ type: 'ADD_ITEM', payload: product });
};
// removeItemFromCart: REMOVE_ITEM 액션을 디스패치합니다.
const removeItemFromCart = (productId: number) => {
dispatch({ type: 'REMOVE_ITEM', payload: { id: productId } });
};
// updateItemQuantity: UPDATE_QUANTITY 액션을 디스패치합니다.
const updateItemQuantity = (productId: number, quantity: number) => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id: productId, quantity } });
};
// 컨텍스트 값 업데이트: ctxValue에 updateItemQuantity 함수를 추가하여 하위 컴포넌트에서 수량 업데이트 기능을 사용할 수 있게 합니다.
const ctxValue: CartContextType = {
items,
addItemToCart,
removeItemFromCart,
updateItemQuantity, // 새로운 액션 함수 추가
};
return (
<CartContext.Provider value={ctxValue}>
{children}
</CartContext.Provider>
);
};
export default CartContextProvider;
// src/store/shopping-cart-context.tsx
import { Product } from '../types';
export interface CartContextType {
items: Product[];
addItemToCart: (product: Product) => void;
removeItemFromCart: (productId: number) => void;
// updateItemQuantity 함수 추가: 컨텍스트 타입에 새로운 함수 updateItemQuantity를 추가하여, 하위 컴포넌트에서 상품 수량을 업데이트할 수 있도록 합니다.
updateItemQuantity: (productId: number, quantity: number) => void; // 새로운 함수 추가
}
const CartContext = createContext<CartContextType | undefined>(undefined);
export default CartContext;
// src/components/Cart.tsx
import React from 'react';
import useCart from '../hooks/useCart';
import { Product } from '../types';
const Cart: React.FC = () => {
// useCart 훅 사용: 컨텍스트에서 items, removeItemFromCart, updateItemQuantity 함수를 가져옵니다.
const { items, removeItemFromCart, updateItemQuantity } = useCart();
const totalPrice = items.reduce((total, item) => total + item.price * (item.quantity || 1), 0);
// 핸들러 함수 정의:
// handleRemove: 상품 삭제 버튼 클릭 시 호출됩니다.
// handleUpdateQuantity: 수량 변경 시 호출됩니다.
const handleRemove = (productId: number) => {
removeItemFromCart(productId);
};
const handleUpdateQuantity = (productId: number, quantity: number) => {
updateItemQuantity(productId, quantity);
};
console.log('Cart 리렌더링됨'); // 리렌더링 확인용 로그
return (
<div>
<h2>장바구니</h2>
{items.length === 0 ? (
<p>장바구니에 항목 없음!</p>
) : (
<>
<ul>
{items.map(item => (
<li key={item.id}>
<span>{item.name} - {item.price}원</span>
<button onClick={() => handleRemove(item.id)}>삭제</button>
{/*상품 수량 입력 필드 추가: 각 상품 옆에 수량을 변경할 수 있는 <input> 필드를 추가하였습니다.*/}
<input
type="number"
min="1"
value={item.quantity || 1}
onChange={(e) => handleUpdateQuantity(item.id, Number(e.target.value))}
/>
</li>
))}
</ul>
<p>총 금액: {totalPrice}원</p>
{/* 결제 버튼 등 추가 기능 가능 */}
</>
)}
</div>
);
};
export default Cart;
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';
import useCart from '../hooks/useCart';
interface ProductProps {
product: ProductType;
}
const Product: React.FC<ProductProps> = ({ product }) => {
const { addItemToCart } = useCart();
console.log(`Product ${product.id} 리렌더링됨`); // 리렌더링 확인용 로그
const handleAddToCart = () => {
addItemToCart({ ...product, quantity: 1 }); // 기본 수량 1로 추가
};
return (
<li style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={handleAddToCart}>장바구니에 추가</button>
</li>
);
};
export default React.memo(Product);
// src/hooks/useCart.ts
import { useContext } from 'react';
import CartContext, { CartContextType } from '../store/shopping-cart-context';
const useCart = (): CartContextType => {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartContextProvider');
}
return context;
};
export default useCart;
// src/store/CartContextProvider.tsx
import React, { useReducer, ReactNode } from 'react';
import CartContext, { CartContextType } from './shopping-cart-context';
import { Product } from '../types';
import { shoppingCartReducer, CartAction } from './shoppingCartReducer';
interface CartContextProviderProps {
children: ReactNode;
}
export const CartContextProvider: React.FC<CartContextProviderProps> = ({ children }) => {
const [items, dispatch] = useReducer(shoppingCartReducer, []);
// 액션 디스패치 함수
const addItemToCart = (product: Product) => {
dispatch({ type: 'ADD_ITEM', payload: product });
};
const removeItemFromCart = (productId: number) => {
dispatch({ type: 'REMOVE_ITEM', payload: { id: productId } });
};
const updateItemQuantity = (productId: number, quantity: number) => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id: productId, quantity } });
};
const ctxValue: CartContextType = {
items,
addItemToCart,
removeItemFromCart,
updateItemQuantity,
};
return (
<CartContext.Provider value={ctxValue}>
{children}
</CartContext.Provider>
);
};
export default CartContextProvider;
// src/types.ts
export interface Product {
id: number;
name: string;
price: number;
quantity?: number; // 수량 추가
}
// src/store/shoppingCartReducer.ts
import { Product } from '../types';
export type CartAction =
| { type: 'ADD_ITEM'; payload: Product }
| { type: 'REMOVE_ITEM'; payload: { id: number } }
| { type: 'UPDATE_QUANTITY'; payload: { id: number; quantity: number } };
export const shoppingCartReducer = (state: Product[], action: CartAction): Product[] => {
switch (action.type) {
case 'ADD_ITEM':
// 이미 장바구니에 있는 상품이라면 수량을 증가시킴
const existingItem = state.find(item => item.id === action.payload.id);
if (existingItem) {
return state.map(item =>
item.id === action.payload.id
? { ...item, quantity: (item.quantity || 1) + (action.payload.quantity || 1) }
: item
);
}
return [...state, action.payload];
case 'REMOVE_ITEM':
return state.filter(item => item.id !== action.payload.id);
case 'UPDATE_QUANTITY':
return state.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
);
default:
return state;
}
};
// src/store/shopping-cart-context.tsx
import React, { createContext } from 'react';
import { Product } from '../types';
export interface CartContextType {
items: Product[];
addItemToCart: (product: Product) => void;
removeItemFromCart: (productId: number) => void;
updateItemQuantity: (productId: number, quantity: number) => void;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
export default CartContext;
// src/hooks/useCart.ts
import { useContext } from 'react';
import CartContext, { CartContextType } from '../store/shopping-cart-context';
const useCart = (): CartContextType => {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartContextProvider');
}
return context;
};
export default useCart;
// src/store/CartContextProvider.tsx
import React, { useReducer, ReactNode } from 'react';
import CartContext, { CartContextType } from './shopping-cart-context';
import { Product } from '../types';
import { shoppingCartReducer, CartAction } from './shoppingCartReducer';
interface CartContextProviderProps {
children: ReactNode;
}
export const CartContextProvider: React.FC<CartContextProviderProps> = ({ children }) => {
const [items, dispatch] = useReducer(shoppingCartReducer, []);
// 액션 디스패치 함수
const addItemToCart = (product: Product) => {
dispatch({ type: 'ADD_ITEM', payload: product });
};
const removeItemFromCart = (productId: number) => {
dispatch({ type: 'REMOVE_ITEM', payload: { id: productId } });
};
const updateItemQuantity = (productId: number, quantity: number) => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id: productId, quantity } });
};
const ctxValue: CartContextType = {
items,
addItemToCart,
removeItemFromCart,
updateItemQuantity,
};
return (
<CartContext.Provider value={ctxValue}>
{children}
</CartContext.Provider>
);
};
export default CartContextProvider;
// src/App.tsx
import React from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import CartContextProvider from './store/CartContextProvider';
const App: React.FC = () => {
return (
<CartContextProvider>
<Header />
<Shop />
<CartModal />
</CartContextProvider>
);
};
export default App;
// src/components/Header.tsx
import React from 'react';
import useCart from '../hooks/useCart';
const Header: React.FC = () => {
const { items } = useCart();
const totalItems = items.reduce((total, item) => total + (item.quantity || 1), 0);
console.log('Header 리렌더링됨'); // 리렌더링 확인용 로그
return (
<header style={{ display: 'flex', justifyContent: 'space-between', padding: '20px', backgroundColor: '#eee' }}>
<h1>온라인 쇼핑몰</h1>
<div>
<span>장바구니: {totalItems}개</span>
{/* 장바구니 아이콘 클릭 시 CartModal 열기 로직 추가 가능 */}
</div>
</header>
);
};
export default Header;
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { Product } from '../types';
import useCart from '../hooks/useCart';
const Shop: React.FC = () => {
const { addItemToCart } = useCart();
const products: Product[] = [
{ id: 1, name: '상품 A', price: 10000 },
{ id: 2, name: '상품 B', price: 20000 },
{ id: 3, name: '상품 C', price: 30000 },
// 추가 상품...
];
const addToCartHandler = (product: Product) => {
addItemToCart(product);
};
return (
<div style={{ padding: '20px' }}>
<h2>상품 목록</h2>
<ProductList products={products} />
</div>
);
};
export default Shop;
// src/components/ProductList.tsx
import React from 'react';
import Product from './Product';
import { Product as ProductType } from '../types';
interface ProductListProps {
products: ProductType[];
}
const ProductList: React.FC<ProductListProps> = ({ products }) => {
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{products.map(product => (
<Product key={product.id} product={product} />
))}
</ul>
);
};
export default ProductList;
// src/components/Product.tsx
import React from 'react';
import { Product as ProductType } from '../types';
import useCart from '../hooks/useCart';
interface ProductProps {
product: ProductType;
}
const Product: React.FC<ProductProps> = ({ product }) => {
const { addItemToCart } = useCart();
console.log(`Product ${product.id} 리렌더링됨`); // 리렌더링 확인용 로그
const handleAddToCart = () => {
addItemToCart({ ...product, quantity: 1 }); // 기본 수량 1로 추가
};
return (
<li style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={handleAddToCart}>장바구니에 추가</button>
</li>
);
};
export default React.memo(Product);
// src/components/CartModal.tsx
import React from 'react';
import useCart from '../hooks/useCart';
import { Product } from '../types';
const CartModal: React.FC = () => {
const { items, removeItemFromCart, updateItemQuantity } = useCart();
const totalPrice = items.reduce((total, item) => total + item.price * (item.quantity || 1), 0);
const handleRemove = (productId: number) => {
removeItemFromCart(productId);
};
const handleUpdateQuantity = (productId: number, quantity: number) => {
if (quantity < 1) return; // 최소 수량 1로 제한
updateItemQuantity(productId, quantity);
};
console.log('CartModal 리렌더링됨'); // 리렌더링 확인용 로그
return (
<div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
<h2>장바구니</h2>
{items.length === 0 ? (
<p>장바구니에 항목 없음!</p>
) : (
<>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map(product => (
<li key={product.id} style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => handleRemove(product.id)}>삭제</button>
<input
type="number"
min="1"
value={product.quantity || 1}
onChange={(e) => handleUpdateQuantity(product.id, Number(e.target.value))}
style={{ marginLeft: '10px', width: '60px' }}
/>
</li>
))}
</ul>
<p>총 금액: {totalPrice}원</p>
{/* 결제 버튼 등 추가 기능 가능 */}
</>
)}
</div>
);
};
export default CartModal;
리액트의 부수 효과(side effect)
부수 효과(Side Effect)란 무엇인가?
왜 부수 효과를 관리해야 할까?
따라서, 부수 효과를 올바르게 관리하는 것은 리액트 애플리케이션의 성능과 안정성을 유지하는 데 매우 중요합니다.
useEffect 훅 소개
useEffect의 기본 사용법
import React, { useEffect } from 'react';
const ExampleComponent: React.FC = () => {
useEffect(() => {
// 이곳에 부수 효과 코드를 작성합니다.
console.log('컴포넌트가 마운트되었습니다.');
// 정리 함수(return)를 반환할 수 있습니다.
return () => {
console.log('컴포넌트가 언마운트되었습니다.');
};
}, []); // 의존성 배열
return <div>예제 컴포넌트</div>;
};
export default ExampleComponent;
설명:
의존성 배열(Dependency Array)
예시: PlacePicker 앱에서의 부수 효과 적용
장소 목록을 로컬 스토리지에 저장하기
로컬 스토리지에서 데이터 불러오기
// src/store/CartContextProvider.tsx
import React, { useReducer, useEffect, ReactNode } from 'react';
import CartContext, { CartContextType } from './shopping-cart-context';
import { Product } from '../types';
import { shoppingCartReducer, CartAction } from './shoppingCartReducer';
interface CartContextProviderProps {
children: ReactNode;
}
export const CartContextProvider: React.FC<CartContextProviderProps> = ({ children }) => {
const [items, dispatch] = useReducer(shoppingCartReducer, []);
// 로컬 스토리지에서 초기 데이터 불러오기
// useEffect: 컴포넌트가 마운트될 때([] 의존성 배열) 로컬 스토리지에서 pickedPlaces 키로 저장된 데이터를 불러옵니다.
// 불러온 데이터가 있으면 INITIALIZE_CART 액션을 디스패치하여 상태를 초기화합니다.
useEffect(() => {
const storedItems = localStorage.getItem('pickedPlaces');
if (storedItems) {
// 초기화 액션 추가: INITIALIZE_CART 액션을 리듀서에 추가하여, 로컬 스토리지에서 불러온 데이터를 상태로 설정합니다.
dispatch({ type: 'INITIALIZE_CART', payload: JSON.parse(storedItems) });
}
}, []);
// 장소 목록이 변경될 때마다 로컬 스토리지에 저장하기
// items 상태가 변경될 때마다 로컬 스토리지에 현재 items 상태를 저장합니다.
useEffect(() => {
localStorage.setItem('pickedPlaces', JSON.stringify(items));
}, [items]);
// 액션 디스패치 함수
const addItemToCart = (product: Product) => {
dispatch({ type: 'ADD_ITEM', payload: product });
};
const removeItemFromCart = (productId: number) => {
dispatch({ type: 'REMOVE_ITEM', payload: { id: productId } });
};
const updateItemQuantity = (productId: number, quantity: number) => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id: productId, quantity } });
};
const ctxValue: CartContextType = {
items,
addItemToCart,
removeItemFromCart,
updateItemQuantity,
};
return (
<CartContext.Provider value={ctxValue}>
{children}
</CartContext.Provider>
);
};
export default CartContextProvider;
// src/store/shoppingCartReducer.ts
import { Product } from '../types';
export type CartAction =
| { type: 'INITIALIZE_CART'; payload: Product[] }
| { type: 'ADD_ITEM'; payload: Product }
| { type: 'REMOVE_ITEM'; payload: { id: number } }
| { type: 'UPDATE_QUANTITY'; payload: { id: number; quantity: number } };
export const shoppingCartReducer = (state: Product[], action: CartAction): Product[] => {
switch (action.type) {
// INITIALIZE_CART 액션 처리: 로컬 스토리지에서 불러온 payload를 그대로 상태로 반환하여 초기화합니다.
case 'INITIALIZE_CART':
return action.payload;
case 'ADD_ITEM':
const existingItem = state.find(item => item.id === action.payload.id);
if (existingItem) {
return state.map(item =>
item.id === action.payload.id
? { ...item, quantity: (item.quantity || 1) + (action.payload.quantity || 1) }
: item
);
}
return [...state, action.payload];
case 'REMOVE_ITEM':
return state.filter(item => item.id !== action.payload.id);
case 'UPDATE_QUANTITY':
return state.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
);
default:
return state;
}
};
컴포넌트에서 부수 효과 사용하기
외부 API에서 데이터 페칭
// src/components/Shop.tsx
import React, { useEffect, useState } from 'react';
import ProductList from './ProductList';
import { Product } from '../types';
import useCart from '../hooks/useCart';
const Shop: React.FC = () => {
const { addItemToCart } = useCart();
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// 외부 API에서 장소 데이터 가져오기
useEffect(() => {
const fetchProducts = async () => {
try {
const response = await fetch('https://api.example.com/places'); // 실제 API 엔드포인트로 변경
if (!response.ok) {
throw new Error('데이터를 불러오는 데 실패했습니다.');
}
const data: Product[] = await response.json();
setProducts(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchProducts();
}, []); // 빈 배열을 전달하여 컴포넌트 마운트 시 한 번만 실행
const addToCartHandler = (product: Product) => {
addItemToCart(product);
};
if (loading) {
return <div>로딩 중...</div>;
}
if (error) {
return <div>에러: {error}</div>;
}
return (
<div style={{ padding: '20px' }}>
<h2>상품 목록</h2>
<ProductList products={products} />
</div>
);
};
export default Shop;
useEffect 훅의 의존성 배열 관리
의존성 배열의 역할
올바른 의존성 배열 설정하기
ESLint의 react-hooks/exhaustive-deps
규칙
react-hooks/exhaustive-deps
를 제공합니다.
이 규칙을 통해 useEffect의 의존성 배열이 정확하게 설정되었는지 확인할 수 있습니다.
따라서, 이 규칙을 활성화하고 코드를 작성할 때 이를 준수하는 것이 좋습니다.useEffect 사용 시 주의사항
useCallback
으로 메모이제이션하여 의존성 배열을 최적화합니다.클린업(Cleanup) 함수 사용
useEffect(() => {
const timer = setTimeout(() => {
console.log('타이머 완료');
}, 1000);
// 클린업 함수
return () => {
clearTimeout(timer);
console.log('타이머 클리어');
};
}, []);
설명:
useEffect 훅을 사용하면 안 되는 상황
// 잘못된 예제
useEffect(() => {
setState(newState); // 의존성 배열에 state가 포함되어 있을 경우 무한 루프 발생
}, [state]);
// 올바른 예제
const total = items.reduce((sum, item) => sum + item.price, 0);
예시: 사용자 위치 파악 및 장소 정렬 기능 추가
App.tsx 수정
// src/App.tsx
import React, { useEffect, useState } from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import CartContextProvider from './store/CartContextProvider';
import { Product } from './types';
import { sortPlacesByDistance } from './loc';
import data from './data'; // 가짜 장소 데이터
const App: React.FC = () => {
const [sortedPlaces, setSortedPlaces] = useState<Product[]>([]);
useEffect(() => {
// 사용자의 위치를 얻기 위한 콜백 함수
const handlePosition = (position: GeolocationPosition) => {
const { latitude, longitude } = position.coords;
const sorted = sortPlacesByDistance(data, latitude, longitude);
setSortedPlaces(sorted);
};
// 사용자의 위치를 요청
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(handlePosition, (error) => {
console.error('위치 정보를 얻는 데 실패했습니다:', error);
});
} else {
console.error('Geolocation을 지원하지 않는 브라우저입니다.');
}
}, []); // 빈 배열을 전달하여 컴포넌트 마운트 시 한 번만 실행
return (
<CartContextProvider>
<Header />
<Shop sortedPlaces={sortedPlaces} />
<CartModal />
</CartContextProvider>
);
};
export default App;
상태 추가:
useEffect 사용:
컴포넌트 전달:
Shop.tsx 수정
// src/components/Shop.tsx
import React from 'react';
import ProductList from './ProductList';
import { Product } from '../types';
import useCart from '../hooks/useCart';
interface ShopProps {
sortedPlaces: Product[];
}
const Shop: React.FC<ShopProps> = ({ sortedPlaces }) => {
const { addItemToCart } = useCart();
const addToCartHandler = (product: Product) => {
addItemToCart(product);
};
return (
<div style={{ padding: '20px' }}>
<h2>장소 목록</h2>
<ProductList products={sortedPlaces} />
</div>
);
};
export default Shop;
부수 효과로 인한 무한 루프 문제
무한 루프가 발생하는 예시
// 잘못된 예시: 무한 루프 발생
import React, { useState } from 'react';
import { sortPlacesByDistance } from './loc';
import data from './data';
const App: React.FC = () => {
const [sortedPlaces, setSortedPlaces] = useState<Product[]>([]);
// 컴포넌트 함수 내에서 직접 부수 효과 처리
navigator.geolocation.getCurrentPosition((position) => {
const sorted = sortPlacesByDistance(data, position.coords.latitude, position.coords.longitude);
setSortedPlaces(sorted); // 상태 업데이트
});
return (
<div>
<Places places={sortedPlaces} />
</div>
);
};
export default App;
문제점:
useEffect 훅을 사용한 부수 효과 관리
import React, { useEffect, useState } from 'react';
import { sortPlacesByDistance } from './loc';
import data from './data';
const App: React.FC = () => {
const [sortedPlaces, setSortedPlaces] = useState<Product[]>([]);
useEffect(() => {
// 부수 효과: 사용자 위치 파악 및 장소 정렬
const handlePosition = (position: GeolocationPosition) => {
const { latitude, longitude } = position.coords;
const sorted = sortPlacesByDistance(data, latitude, longitude);
setSortedPlaces(sorted);
};
// 사용자 위치 요청
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(handlePosition, (error) => {
console.error('위치 정보를 얻는 데 실패했습니다:', error);
});
} else {
console.error('Geolocation을 지원하지 않는 브라우저입니다.');
}
}, []); // 빈 배열: 컴포넌트가 처음 마운트될 때만 실행
return (
<div>
<Places places={sortedPlaces} />
</div>
);
};
export default App;
설명:
무한 루프를 방지하는 방법
useEffect 사용 시 주의사항
잘못된 예시: 의존성 배열에 상태 포함
useEffect(() => {
setState(newState); // 상태 업데이트
}, [state]); // state가 변경될 때마다 실행 → 무한 루프
useEffect(() => {
setState(newState); // 상태 업데이트
}, []); // 빈 배열: 한 번만 실행
useEffect(() => {
const handleResize = () => {
console.log('창 크기 변경됨');
};
window.addEventListener('resize', handleResize);
// 클린업 함수: 이벤트 리스너 제거
return () => {
window.removeEventListener('resize', handleResize);
console.log('이벤트 리스너가 제거되었습니다.');
};
}, []); // 빈 배열: 컴포넌트 마운트 시 한 번만 실행
반면, 사용자 상호작용에 의해 직접 트리거되는 부수 효과는 useEffect를 사용하지 않고도 처리할 수 있습니다. 예를 들어, 버튼 클릭 시 로컬 스토리지에 데이터를 저장하는 작업은 이벤트 핸들러 내에서 직접 처리할 수 있습니다.
// src/components/PlaceItem.tsx
import React from 'react';
import { Product } from '../types';
interface PlaceItemProps {
place: Product;
onSelect: (place: Product) => void;
}
const PlaceItem: React.FC<PlaceItemProps> = ({ place, onSelect }) => {
const handleSelect = () => {
onSelect(place);
// 부수 효과: 로컬 스토리지에 선택된 장소 ID 저장
const storedIds = localStorage.getItem('selectedPlaces');
const parsedIds: number[] = storedIds ? JSON.parse(storedIds) : [];
if (!parsedIds.includes(place.id)) {
const updatedIds = [...parsedIds, place.id];
localStorage.setItem('selectedPlaces', JSON.stringify(updatedIds));
}
};
return (
<li>
<span>{place.name} - {place.price}원</span>
<button onClick={handleSelect}>선택</button>
</li>
);
};
export default React.memo(PlaceItem);
설명:
컴포넌트 내부 상태 업데이트와 부수 효과
// src/components/SearchBar.tsx
import React, { useState } from 'react';
interface SearchBarProps {
onSearch: (query: string) => void;
}
const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => {
const [query, setQuery] = useState<string>('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(query);
};
return (
<form onSubmit={handleSubmit}>
<input type="text" value={query} onChange={handleChange} placeholder="장소 검색" />
<button type="submit">검색</button>
</form>
);
};
export default SearchBar;
설명:
useEffect와 부수 효과의 적절한 사용 사례
데이터 페칭: 서버로부터 데이터를 가져오는 작업.
useEffect(() => {
const fetchData = async () => {
const response = await fetch('https://api.example.com/data');
const result = await response.json();
setData(result);
};
fetchData();
}, []);
구독 설정: 웹소켓이나 이벤트 리스너를 통한 구독.
useEffect(() => {
const handleMessage = (message: MessageEvent) => {
setMessages(prev => [...prev, message.data]);
};
websocket.addEventListener('message', handleMessage);
return () => {
websocket.removeEventListener('message', handleMessage);
};
}, []);
타이머 설정: setInterval이나 setTimeout을 사용하여 타이머를 설정.
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
useEffect가 불필요한 부수 효과
부수 효과와 useEffect의 사용 필요성 재확인
중요한 점:
로컬 스토리지(localStorage)와 부수 효과
장소 추가 시 로컬 스토리지에 저장
// src/components/PlaceItem.tsx
import React from 'react';
import { Product } from '../types';
interface PlaceItemProps {
place: Product;
onSelect: (place: Product) => void;
}
const PlaceItem: React.FC<PlaceItemProps> = ({ place, onSelect }) => {
const handleSelect = () => {
onSelect(place);
// 부수 효과: 로컬 스토리지에 선택된 장소 ID 저장
const storedIds = localStorage.getItem('selectedPlaces');
const parsedIds: number[] = storedIds ? JSON.parse(storedIds) : [];
if (!parsedIds.includes(place.id)) {
const updatedIds = [...parsedIds, place.id];
localStorage.setItem('selectedPlaces', JSON.stringify(updatedIds));
}
};
return (
<li>
<span>{place.name} - {place.price}원</span>
<button onClick={handleSelect}>선택</button>
</li>
);
};
export default React.memo(PlaceItem);
설명:
장소 삭제 시 로컬 스토리지에서 제거
// src/components/CartModal.tsx
import React from 'react';
import { Product } from '../types';
import useCart from '../hooks/useCart';
const CartModal: React.FC = () => {
const { items, removeItemFromCart, updateItemQuantity } = useCart();
const handleRemove = (productId: number) => {
removeItemFromCart(productId);
// 부수 효과: 로컬 스토리지에서 선택된 장소 ID 제거
const storedIds = localStorage.getItem('selectedPlaces');
const parsedIds: number[] = storedIds ? JSON.parse(storedIds) : [];
const updatedIds = parsedIds.filter(id => id !== productId);
localStorage.setItem('selectedPlaces', JSON.stringify(updatedIds));
};
const handleUpdateQuantity = (productId: number, quantity: number) => {
if (quantity < 1) return; // 최소 수량 1로 제한
updateItemQuantity(productId, quantity);
};
return (
<div style={{ padding: '20px', backgroundColor: '#f9f9f9' }}>
<h2>장바구니</h2>
{items.length === 0 ? (
<p>장바구니에 항목 없음!</p>
) : (
<>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map(product => (
<li key={product.id} style={{ marginBottom: '10px' }}>
<span>{product.name} - {product.price}원</span>
<button style={{ marginLeft: '10px' }} onClick={() => handleRemove(product.id)}>삭제</button>
<input
type="number"
min="1"
value={product.quantity || 1}
onChange={(e) => handleUpdateQuantity(product.id, Number(e.target.value))}
style={{ marginLeft: '10px', width: '60px' }}
/>
</li>
))}
</ul>
<p>총 금액: {items.reduce((total, item) => total + item.price * (item.quantity || 1), 0)}원</p>
</>
)}
</div>
);
};
export default CartModal;
설명:
앱 시작 시 로컬 스토리지에서 데이터 불러오기
// src/App.tsx
import React, { useEffect, useState } from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import CartContextProvider from './store/CartContextProvider';
import { Product } from './types';
import { sortPlacesByDistance } from './loc';
import data from './data'; // 가짜 장소 데이터
const App: React.FC = () => {
const [sortedPlaces, setSortedPlaces] = useState<Product[]>([]);
// 부수 효과: 사용자 위치 파악 및 장소 정렬
useEffect(() => {
const handlePosition = (position: GeolocationPosition) => {
const { latitude, longitude } = position.coords;
const sorted = sortPlacesByDistance(data, latitude, longitude);
setSortedPlaces(sorted);
};
// 사용자 위치 요청
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(handlePosition, (error) => {
console.error('위치 정보를 얻는 데 실패했습니다:', error);
});
} else {
console.error('Geolocation을 지원하지 않는 브라우저입니다.');
}
}, []); // 빈 배열: 컴포넌트가 처음 마운트될 때만 실행
// 부수 효과: 로컬 스토리지에서 선택된 장소 ID 불러오기 및 상태 초기화
useEffect(() => {
const storedIds = localStorage.getItem('selectedPlaces');
const parsedIds: number[] = storedIds ? JSON.parse(storedIds) : [];
const storedPlaces: Product[] = parsedIds.map(id => data.find(place => place.id === id)).filter(Boolean) as Product[];
setSortedPlaces(storedPlaces);
}, []); // 빈 배열: 컴포넌트가 처음 마운트될 때만 실행
return (
<CartContextProvider>
<Header />
<Shop sortedPlaces={sortedPlaces} fallbackText="장소를 거리순으로 정렬합니다" />
<CartModal />
</CartContextProvider>
);
};
export default App;
설명:
코드 최적화 및 클린업
초기 상태 설정 시 로컬 스토리지에서 불러오기
// src/App.tsx
import React, { useEffect, useState } from 'react';
import Header from './components/Header';
import Shop from './components/Shop';
import CartModal from './components/CartModal';
import CartContextProvider from './store/CartContextProvider';
import { Product } from './types';
import { sortPlacesByDistance } from './loc';
import data from './data'; // 가짜 장소 데이터
const App: React.FC = () => {
// 초기 상태 설정: 로컬 스토리지에서 불러오기
const [sortedPlaces, setSortedPlaces] = useState<Product[]>(() => {
const storedIds = localStorage.getItem('selectedPlaces');
const parsedIds: number[] = storedIds ? JSON.parse(storedIds) : [];
return parsedIds.map(id => data.find(place => place.id === id)).filter(Boolean) as Product[];
});
useEffect(() => {
// 부수 효과: 사용자 위치 파악 및 장소 정렬
const handlePosition = (position: GeolocationPosition) => {
const { latitude, longitude } = position.coords;
const sorted = sortPlacesByDistance(data, latitude, longitude);
setSortedPlaces(sorted);
};
// 사용자 위치 요청
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(handlePosition, (error) => {
console.error('위치 정보를 얻는 데 실패했습니다:', error);
});
} else {
console.error('Geolocation을 지원하지 않는 브라우저입니다.');
}
}, []); // 빈 배열: 컴포넌트가 처음 마운트될 때만 실행
return (
<CartContextProvider>
<Header />
<Shop sortedPlaces={sortedPlaces} fallbackText="장소를 거리순으로 정렬합니다" />
<CartModal />
</CartContextProvider>
);
};
export default App;
설명:
useEffect가 불필요한 부수 효과의 예시 및 이유
useEffect의 적절한 사용과 비사용 상황 이해
기존에는 Modal 컴포넌트를 열고 닫기 위해 부모 컴포넌트에서 ref를 사용하여 open과 close 메서드를 호출했습니다. 그러나 이 접근 방식 대신 Modal 컴포넌트에 open이라는 props를 추가하고, 부모 컴포넌트에서 상태(state)를 관리하여 모달의 열림과 닫힘을 제어하도록 변경했습니다.
이렇게 변경함으로써 useEffect
훅을 사용하여 open props
의 변경에 따라 모달의 표시 여부를 제어해야 하는 상황이 생겼습니다.
특히 HTML의 <dialog>
요소는 showModal()
메서드를 호출해야 백드롭이 활성화된 모달이 나타나는데, 단순히 open
속성을 설정하는 것으로는 백드롭이 나타나지 않습니다.
따라서 useEffect
를 사용하여 open props
가 변경될 때마다 showModal()
이나 close()
를 호출하도록 구현했습니다.
[사용자 이벤트]
│
└─▶ setModalIsOpen(true) 또는 setModalIsOpen(false)
│
└─▶ modalIsOpen 상태 변경
│
└─▶ Modal 컴포넌트의 open props 변경
│
└─▶ useEffect 훅 감지
│
├─ open === true ─▶ dialog.showModal()
│
└─ open === false ─▶ dialog.close()
import React, { useEffect, useRef } from 'react';
interface ModalProps {
open: boolean;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ open, children }) => {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
if (!dialog.open) {
dialog.showModal();
}
} else {
if (dialog.open) {
dialog.close();
}
}
}, [open]);
return (
<dialog ref={dialogRef}>
{children}
</dialog>
);
};
export default Modal;
import React, { useState } from 'react';
import Modal from './Modal';
const App: React.FC = () => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const openModal = () => setModalIsOpen(true);
const closeModal = () => setModalIsOpen(false);
return (
<div>
<button onClick={openModal}>모달 열기</button>
<Modal open={modalIsOpen}>
<p>모달 내용</p>
<button onClick={closeModal}>모달 닫기</button>
</Modal>
</div>
);
};
export default App;
[App 컴포넌트]
│
├─ 사용자 이벤트 발생 (버튼 클릭 등)
│
└─▶ setModalIsOpen(true/false) 호출
│
└─▶ modalIsOpen 상태 변경
│
└─▶ Modal 컴포넌트의 open prop 변경
│
└─▶ useEffect 훅 실행
│
├─ ref가 설정되었는지 확인
│
├─ open === true ─▶ dialog.showModal()
│
└─ open === false ─▶ dialog.close()
import React, { useEffect, useRef } from 'react';
interface ModalProps {
open: boolean;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ open, children }) => {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
if (!dialog.open) {
dialog.showModal();
}
} else {
if (dialog.open) {
dialog.close();
}
}
}, [open]);
return (
<dialog ref={dialogRef}>
{children}
</dialog>
);
};
export default Modal;
import React, { useState } from 'react';
import Modal from './Modal';
const App: React.FC = () => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const openModal = () => setModalIsOpen(true);
const closeModal = () => setModalIsOpen(false);
return (
<div>
<button onClick={openModal}>모달 열기</button>
<Modal open={modalIsOpen}>
<p>모달 내용입니다.</p>
<button onClick={closeModal}>모달 닫기</button>
</Modal>
</div>
);
};
export default App;
설명
[open]
을 포함하여 open
값이 변경될 때마다 효과가 실행되도록 합니다.주의사항
<dialog>
요소의 open 속성은 읽기 전용이 아니므로, 직접 설정할 수 있습니다. [사용자 이벤트]
│
└─▶ setModalIsOpen(true) 또는 setModalIsOpen(false)
│
└─▶ modalIsOpen 상태 변경
│
└─▶ Modal 컴포넌트의 open 속성 변경
│
└─▶ useEffect 훅 실행 (의존성: [open])
│
├─ open === true ─▶ dialog.showModal()
│
└─ open === false ─▶ dialog.close()
import React, { useEffect, useRef } from 'react';
interface ModalProps {
open: boolean;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ open, children }) => {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
if (!dialog.open) {
dialog.showModal();
}
} else {
if (dialog.open) {
dialog.close();
}
}
}, [open]);
return (
<dialog ref={dialogRef}>
{children}
</dialog>
);
};
export default Modal;
import React, { useState } from 'react';
import Modal from './Modal';
const App: React.FC = () => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const openModal = () => setModalIsOpen(true);
const closeModal = () => setModalIsOpen(false);
return (
<div>
<button onClick={openModal}>모달 열기</button>
<Modal open={modalIsOpen}>
<p>모달 내용입니다.</p>
<button onClick={closeModal}>모달 닫기</button>
</Modal>
</div>
);
};
export default App;
<dialog>
요소는 키보드의 ESC 키를 눌러 닫을 수 있습니다.
이 경우, 대화창은 사라지지만 ‘open’ Prop (즉, modalIsOpen 상태)에 전달된 상태가 false로 설정되지 않습니다.
따라서 모달을 다시 열 수 없습니다 (modalIsOpen 이 여전히 true이므로 UI는 상태와 동기화되어 있지 않습니다).
이 문제를 해결하기 위해, <dialog>
에 내장된 onClose Prop을 추가하여 모달이 닫히는 것을 청취하도록 해야 합니다.
그런 다음 이 이벤트는 모달 컴포넌트의 커스텀 onClose Prop을 허용함으로써 App 컴포넌트로 "전달"됩니다.
Modal 컴포넌트는 다음과 같이 보여야 합니다:App컴포넌트에서는 이제 handleStopRemovePlace 함수를 <Modal>
컴포넌트의 onClose Prop에 값으로 지정할 수 있습니다
import { useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
function Modal({ open, children, onClose }) {
const dialog = useRef();
useEffect(() => {
if (open) {
dialog.current.showModal();
} else {
dialog.current.close();
}
}, [open]);
return createPortal(
<dialog className="modal" ref={dialog} onClose={onClose}>
{children}
</dialog>,
document.getElementById('modal')
);
}
export default Modal;
App컴포넌트에서는 이제 handleStopRemovePlace함수를 <Modal>
컴포넌트의 onCloseProp에 값으로 지정할 수 있습니다:
<Modal open={modalIsOpen} onClose={handleStopRemovePlace}>
<DeleteConfirmation
onCancel={handleStopRemovePlace}
onConfirm={handleRemovePlace}
/>
</Modal>
[사용자 이벤트: 삭제 버튼 클릭]
│
└─▶ setModalIsOpen(true)
│
└─▶ modalIsOpen 상태 변경
│
└─▶ Modal 컴포넌트의 open prop 변경
│
└─▶ Modal 컴포넌트에서 open이 true인 경우에만 children 렌더링
│
└─▶ DeleteConfirmation 컴포넌트 마운트
│
└─▶ useEffect 훅 실행하여 타이머 설정
│
├─ 3초 후 onConfirm 실행
│
└─ 모달 닫힘 또는 취소 시
│
└─▶ 컴포넌트 언마운트
│
└─▶ useEffect의 클린업 함수로 타이머 취소
import React, { useEffect } from 'react';
interface DeleteConfirmationProps {
onConfirm: () => void;
onCancel: () => void;
}
const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
useEffect(() => {
console.log('타이머 설정됨');
const timerId = setTimeout(() => {
console.log('타이머 만료: onConfirm 실행');
onConfirm();
}, 3000);
return () => {
console.log('타이머 취소됨');
clearTimeout(timerId);
};
}, [onConfirm]);
return (
<div>
<p>정말로 삭제하시겠습니까?</p>
<button onClick={onCancel}>취소</button>
<button onClick={onConfirm}>확인</button>
</div>
);
};
export default DeleteConfirmation;
import React, { useEffect, useRef } from 'react';
interface ModalProps {
open: boolean;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ open, children }) => {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
if (!dialog.open) {
dialog.showModal();
}
} else {
if (dialog.open) {
dialog.close();
}
}
}, [open]);
return (
<dialog ref={dialogRef}>
{open ? children : null}
</dialog>
);
};
export default Modal;
import React, { useState } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';
const App: React.FC = () => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const [items, setItems] = useState(['아이템 1', '아이템 2', '아이템 3']);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const openModal = (item: string) => {
setSelectedItem(item);
setModalIsOpen(true);
};
const closeModal = () => {
setModalIsOpen(false);
setSelectedItem(null);
};
const handleConfirm = () => {
if (selectedItem) {
setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
}
closeModal();
};
return (
<div>
<ul>
{items.map((item) => (
<li key={item}>
{item} <button onClick={() => openModal(item)}>삭제</button>
</li>
))}
</ul>
<Modal open={modalIsOpen}>
{modalIsOpen && (
<DeleteConfirmation onConfirm={handleConfirm} onCancel={closeModal} />
)}
</Modal>
</div>
);
};
export default App;
useEffect 훅을 사용하여 타이머를 설정하고 클린업 함수를 통해 타이머를 적절히 정리함으로써, 모달이 닫혔을 때도 타이머가 계속 실행되는 문제를 해결했습니다. 또한, DeleteConfirmation 컴포넌트를 조건부로 렌더링하여 모달이 열릴 때만 타이머가 설정되도록 했습니다. 이를 통해 사용자 경험을 향상시키고 예상치 못한 동작을 방지할 수 있습니다.
[DeleteConfirmation 컴포넌트 마운트]
│
└─▶ useEffect 실행하여 타이머 설정
│
└─▶ 3초 후 onConfirm 실행 예정
│
├─ 사용자가 취소 버튼 클릭
│ │
│ └─▶ 컴포넌트 언마운트
│ │
│ └─▶ useEffect의 클린업 함수 실행
│ │
│ └─▶ clearTimeout으로 타이머 취소
│
└─ 3초 경과 시
│
└─▶ onConfirm 실행
import React, { useEffect } from 'react';
interface DeleteConfirmationProps {
onConfirm: () => void;
onCancel: () => void;
}
const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
useEffect(() => {
console.log('타이머 설정됨');
const timer = setTimeout(() => {
console.log('onConfirm 실행');
onConfirm();
}, 3000);
return () => {
console.log('타이머 정리됨');
clearTimeout(timer);
};
}, [onConfirm]);
return (
<div>
<p>정말로 삭제하시겠습니까?</p>
<button onClick={onCancel}>취소</button>
<button onClick={onConfirm}>확인</button>
</div>
);
};
export default DeleteConfirmation;
import React, { useState } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';
const App: React.FC = () => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const [items, setItems] = useState(['아이템 1', '아이템 2', '아이템 3']);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const openModal = (item: string) => {
setSelectedItem(item);
setModalIsOpen(true);
};
const closeModal = () => {
setModalIsOpen(false);
setSelectedItem(null);
};
const handleConfirm = () => {
if (selectedItem) {
setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
console.log(`${selectedItem} 삭제됨`);
}
closeModal();
};
return (
<div>
<ul>
{items.map((item) => (
<li key={item}>
{item} <button onClick={() => openModal(item)}>삭제</button>
</li>
))}
</ul>
<Modal open={modalIsOpen}>
{modalIsOpen && (
<DeleteConfirmation onConfirm={handleConfirm} onCancel={closeModal} />
)}
</Modal>
</div>
);
};
export default App;
import React, { useEffect, useRef } from 'react';
interface ModalProps {
open: boolean;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ open, children }) => {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
if (!dialog.open) {
dialog.showModal();
}
} else {
if (dialog.open) {
dialog.close();
}
}
}, [open]);
return (
<dialog ref={dialogRef}>
{open ? children : null}
</dialog>
);
};
export default Modal;
문제점 설명:
함수 비교의 문제점:
무한 루프의 위험:
잠재적 해결책:
useCallback
훅을 사용하여 함수를 메모이제이션할 수 있습니다.[App 컴포넌트]
│
├─ handleRemovePlace 함수 생성 (렌더링마다 새로 생성)
│
└─▶ DeleteConfirmation 컴포넌트에 onConfirm으로 전달
│
└─▶ useEffect 의존성 배열에 onConfirm 포함
│
├─ onConfirm 변경으로 useEffect 재실행
│
├─ useEffect 내부에서 상태 업데이트
│
└─▶ 컴포넌트 재렌더링
│
└─▶ handleRemovePlace 함수 재생성
│
└─ (반복) 무한 루프 발생
import React, { useState } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';
const App: React.FC = () => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const [items, setItems] = useState(['Item 1', 'Item 2']);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const handleRemovePlace = () => {
if (selectedItem) {
setItems(items.filter((item) => item !== selectedItem));
setSelectedItem(null);
}
setModalIsOpen(false);
};
const openModal = (item: string) => {
setSelectedItem(item);
setModalIsOpen(true);
};
return (
<div>
{items.map((item) => (
<div key={item}>
{item}
<button onClick={() => openModal(item)}>삭제</button>
</div>
))}
<Modal open={modalIsOpen}>
<DeleteConfirmation onConfirm={handleRemovePlace} onCancel={() => setModalIsOpen(false)} />
</Modal>
</div>
);
};
export default App;
import React, { useEffect } from 'react';
interface DeleteConfirmationProps {
onConfirm: () => void;
onCancel: () => void;
}
const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
useEffect(() => {
const timer = setTimeout(() => {
onConfirm();
}, 3000);
return () => {
clearTimeout(timer);
};
}, [onConfirm]);
return (
<div>
<p>삭제하시겠습니까?</p>
<button onClick={onCancel}>취소</button>
<button onClick={onConfirm}>확인</button>
</div>
);
};
export default DeleteConfirmation;
문제 설명
해결 방법
import React, { useState, useCallback } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';
const App: React.FC = () => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const [items, setItems] = useState(['Item 1', 'Item 2']);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const handleRemovePlace = useCallback(() => {
if (selectedItem) {
setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
setSelectedItem(null);
}
setModalIsOpen(false);
}, [selectedItem]);
const openModal = (item: string) => {
setSelectedItem(item);
setModalIsOpen(true);
};
return (
<div>
{items.map((item) => (
<div key={item}>
{item}
<button onClick={() => openModal(item)}>삭제</button>
</div>
))}
<Modal open={modalIsOpen}>
<DeleteConfirmation onConfirm={handleRemovePlace} onCancel={() => setModalIsOpen(false)} />
</Modal>
</div>
);
};
export default App;
useEffect(() => {
const timer = setTimeout(() => {
onConfirm();
}, 3000);
return () => {
clearTimeout(timer);
};
// 의존성 배열에서 onConfirm 제거
}, []);
[App 컴포넌트]
│
├─ useCallback으로 handleRemovePlace 함수 생성
│ │
│ └─ 의존성 배열 관리 (필요한 경우 의존성 추가)
│
└─▶ handleRemovePlace 함수를 DeleteConfirmation 컴포넌트에 전달
│
└─▶ DeleteConfirmation 컴포넌트의 useEffect에서 onConfirm을 의존성으로 포함
│
├─ 함수가 재생성되지 않으므로 useEffect 재실행 없음
│
└─ 무한 루프 방지 및 정상 동작
import React, { useState, useCallback } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';
const App: React.FC = () => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const [items, setItems] = useState(['장소 1', '장소 2', '장소 3']);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const handleRemovePlace = useCallback(() => {
if (selectedItem) {
setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
setSelectedItem(null);
}
setModalIsOpen(false);
}, [selectedItem]);
const openModal = (item: string) => {
setSelectedItem(item);
setModalIsOpen(true);
};
return (
<div>
<ul>
{items.map((item) => (
<li key={item}>
{item} <button onClick={() => openModal(item)}>삭제</button>
</li>
))}
</ul>
<Modal open={modalIsOpen}>
{modalIsOpen && (
<DeleteConfirmation onConfirm={handleRemovePlace} onCancel={() => setModalIsOpen(false)} />
)}
</Modal>
</div>
);
};
export default App;
import React, { useEffect } from 'react';
interface DeleteConfirmationProps {
onConfirm: () => void;
onCancel: () => void;
}
const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
useEffect(() => {
console.log('타이머 설정됨');
const timer = setTimeout(() => {
console.log('onConfirm 실행');
onConfirm();
}, 3000);
return () => {
console.log('타이머 정리됨');
clearTimeout(timer);
};
}, [onConfirm]);
return (
<div>
<p>정말로 삭제하시겠습니까?</p>
<button onClick={onCancel}>취소</button>
<button onClick={onConfirm}>확인</button>
</div>
);
};
export default DeleteConfirmation;
import React, { useEffect, useRef } from 'react';
interface ModalProps {
open: boolean;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ open, children }) => {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
if (!dialog.open) {
dialog.showModal();
}
} else {
if (dialog.open) {
dialog.close();
}
}
}, [open]);
return (
<dialog ref={dialogRef}>
{open ? children : null}
</dialog>
);
};
export default Modal;
[DeleteConfirmation 컴포넌트 마운트]
│
└─▶ useEffect 실행
│
├─ setTimeout 설정 (TIMER ms 후 onConfirm 호출)
│
├─ setInterval 설정 (10ms마다 remainingTime 감소)
│ │
│ └─▶ setRemainingTime(prevTime => prevTime - 10)
│ │
│ └─▶ remainingTime 상태 업데이트로 리렌더링
│ │
│ └─▶ progress 요소의 value 업데이트
│
└─ 클린업 함수 정의
│
└─ 컴포넌트 언마운트 또는 재실행 시
│
└─▶ clearTimeout 및 clearInterval로 타이머 정리
import React, { useEffect, useState } from 'react';
interface DeleteConfirmationProps {
onConfirm: () => void;
onCancel: () => void;
}
const TIMER = 3000; // 총 타이머 시간 (밀리초)
const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
const [remainingTime, setRemainingTime] = useState(TIMER);
useEffect(() => {
console.log('타이머 및 인터벌 설정됨');
// 지정된 시간 후에 onConfirm 호출
const timer = setTimeout(() => {
console.log('onConfirm 실행');
onConfirm();
}, TIMER);
// 10ms마다 remainingTime 감소
const interval = setInterval(() => {
setRemainingTime((prevTime) => {
if (prevTime <= 0) {
clearInterval(interval);
return 0;
}
return prevTime - 10;
});
}, 10);
// 클린업 함수로 타이머 및 인터벌 정리
return () => {
console.log('타이머 및 인터벌 정리됨');
clearTimeout(timer);
clearInterval(interval);
};
}, [onConfirm]);
return (
<div>
<p>정말로 삭제하시겠습니까?</p>
<progress value={remainingTime} max={TIMER} />
<button onClick={onCancel}>취소</button>
<button onClick={onConfirm}>확인</button>
</div>
);
};
export default DeleteConfirmation;
import React, { useState, useCallback } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';
const App: React.FC = () => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const [items, setItems] = useState(['아이템 1', '아이템 2', '아이템 3']);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const openModal = (item: string) => {
setSelectedItem(item);
setModalIsOpen(true);
};
const closeModal = () => {
setModalIsOpen(false);
setSelectedItem(null);
};
const handleConfirm = useCallback(() => {
if (selectedItem) {
setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
console.log(`${selectedItem}이(가) 삭제되었습니다.`);
setSelectedItem(null);
}
setModalIsOpen(false);
}, [selectedItem]);
return (
<div>
<ul>
{items.map((item) => (
<li key={item}>
{item} <button onClick={() => openModal(item)}>삭제</button>
</li>
))}
</ul>
<Modal open={modalIsOpen}>
{modalIsOpen && (
<DeleteConfirmation onConfirm={handleConfirm} onCancel={closeModal} />
)}
</Modal>
</div>
);
};
export default App;
import React, { useEffect, useRef } from 'react';
interface ModalProps {
open: boolean;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ open, children }) => {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
if (!dialog.open) {
dialog.showModal();
}
} else {
if (dialog.open) {
dialog.close();
}
}
}, [open]);
return (
<dialog ref={dialogRef}>
{open ? children : null}
</dialog>
);
};
export default Modal;
성능 문제 인식:
최적화 방안: 진행 표시줄과 관련된 상태 로직과 useEffect 훅을 별도의 ProgressBar 컴포넌트로 분리합니다. 이를 통해 해당 부분만 리렌더링되도록 하여 불필요한 계산을 줄입니다.
구현 단계:
결과: 이제 ProgressBar 컴포넌트만 10밀리초마다 리렌더링되고, DeleteConfirmation 컴포넌트는 불필요한 리렌더링을 하지 않아 성능이 향상됩니다. 앱은 의도한 대로 동작합니다.
[DeleteConfirmation 컴포넌트]
│
├─ ProgressBar 컴포넌트 렌더링
│ │
│ ├─ remainingTime 상태 관리
│ │
│ ├─ useEffect로 setInterval 설정
│ │ │
│ │ ├─ 10ms마다 remainingTime 감소
│ │ ├─ remainingTime 변경 시 ProgressBar 리렌더링
│ │ └─ 컴포넌트 언마운트 시 clearInterval로 인터벌 정리
│ │
│ └─ progress 요소에 remainingTime 및 TIMER 전달
│
├─ onConfirm 및 onCancel 핸들러 유지
│
└─ ProgressBar 외의 다른 부분은 리렌더링되지 않음
import React, { useEffect, useState } from 'react';
interface ProgressBarProps {
timer: number;
}
const ProgressBar: React.FC<ProgressBarProps> = ({ timer }) => {
const [remainingTime, setRemainingTime] = useState(timer);
useEffect(() => {
const interval = setInterval(() => {
setRemainingTime((prevTime) => {
if (prevTime <= 0) {
clearInterval(interval);
return 0;
}
return prevTime - 10;
});
}, 10);
return () => {
clearInterval(interval);
};
}, [timer]);
return (
<progress value={remainingTime} max={timer} />
);
};
export default ProgressBar;
import React from 'react';
import ProgressBar from './ProgressBar';
interface DeleteConfirmationProps {
onConfirm: () => void;
onCancel: () => void;
}
const TIMER = 3000; // 타이머 시간 (밀리초)
const DeleteConfirmation: React.FC<DeleteConfirmationProps> = ({ onConfirm, onCancel }) => {
return (
<div>
<p>정말로 삭제하시겠습니까?</p>
<ProgressBar timer={TIMER} />
<button onClick={onCancel}>취소</button>
<button onClick={onConfirm}>확인</button>
</div>
);
};
export default DeleteConfirmation;
import React, { useState, useCallback } from 'react';
import Modal from './Modal';
import DeleteConfirmation from './DeleteConfirmation';
const App: React.FC = () => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const [items, setItems] = useState(['아이템 1', '아이템 2', '아이템 3']);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const openModal = (item: string) => {
setSelectedItem(item);
setModalIsOpen(true);
};
const closeModal = () => {
setModalIsOpen(false);
setSelectedItem(null);
};
const handleConfirm = useCallback(() => {
if (selectedItem) {
setItems((prevItems) => prevItems.filter((item) => item !== selectedItem));
console.log(`${selectedItem}이(가) 삭제되었습니다.`);
setSelectedItem(null);
}
setModalIsOpen(false);
}, [selectedItem]);
return (
<div>
<ul>
{items.map((item) => (
<li key={item}>
{item} <button onClick={() => openModal(item)}>삭제</button>
</li>
))}
</ul>
<Modal open={modalIsOpen}>
{modalIsOpen && (
<DeleteConfirmation onConfirm={handleConfirm} onCancel={closeModal} />
)}
</Modal>
</div>
);
};
export default App;
import React, { useEffect, useRef } from 'react';
interface ModalProps {
open: boolean;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ open, children }) => {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
if (!dialog.open) {
dialog.showModal();
}
} else {
if (dialog.open) {
dialog.close();
}
}
}, [open]);
return (
<dialog ref={dialogRef}>
{open ? children : null}
</dialog>
);
};
export default Modal;
[프로젝트 시작]
↓
[src/components 폴더 생성]
↓
[Header.jsx 생성]
↓
[Header 컴포넌트 구현]
├─ header 요소 반환
│ ├─ img 요소: 로고 이미지 표시
│ │ ├─ logoImg를 import하여 src에 설정
│ │ └─ alt 속성에 대체 텍스트 추가
│ └─ h1 요소: "ReactQuiz" 제목 표시
↓
[App.jsx에서 Header 컴포넌트 사용]
├─ import Header from './components/Header';
└─ App 컴포넌트에서 <Header /> 렌더링
↓
[페이지 상단에 헤더 표시됨]
↓
[Quiz.jsx 생성]
↓
[Quiz 컴포넌트 구현]
├─ "현재 활성화된 질문" 반환 (초기 상태)
└─ 상태 관리 시작
├─ useState 훅으로 상태 선언
│ ├─ activeQuestionIndex: 현재 질문 인덱스
│ └─ userAnswers: 사용자의 답변 배열
└─ 향후 실제 질문과 답변 로직 구현 예정
import React from 'react';
import logoImg from '../assets/quiz-logo.png';
const Header: React.FC = () => {
return (
<header>
<img src={logoImg} alt="퀴즈 로고" />
<h1>ReactQuiz</h1>
</header>
);
};
export default Header;
import React, { useState } from 'react';
interface Question {
questionText: string;
options: string[];
correctAnswerIndex: number;
}
const questions: Question[] = [
{
questionText: 'React는 무엇을 위한 라이브러리인가요?',
options: ['데이터베이스', '사용자 인터페이스', '백엔드', '테스트'],
correctAnswerIndex: 1,
},
// 추가적인 질문들을 여기에 추가
];
const Quiz: React.FC = () => {
const [activeQuestionIndex, setActiveQuestionIndex] = useState<number>(0);
const [userAnswers, setUserAnswers] = useState<number[]>([]);
const currentQuestion = questions[activeQuestionIndex];
const handleAnswer = (answerIndex: number) => {
// 사용자의 답변 저장
setUserAnswers([...userAnswers, answerIndex]);
// 다음 질문으로 이동
if (activeQuestionIndex < questions.length - 1) {
setActiveQuestionIndex(activeQuestionIndex + 1);
} else {
// 퀴즈 완료 처리
console.log('퀴즈 완료');
}
};
return (
<div>
<h2>질문 {activeQuestionIndex + 1}</h2>
<p>{currentQuestion.questionText}</p>
<ul>
{currentQuestion.options.map((option, index) => (
<li key={index}>
<button onClick={() => handleAnswer(index)}>{option}</button>
</li>
))}
</ul>
</div>
);
};
export default Quiz;
import React from 'react';
import Header from './components/Header';
import Quiz from './components/Quiz';
const App: React.FC = () => {
return (
<div>
<Header />
<Quiz />
</div>
);
};
export default App;
[questions.js 파일 추가]
↓
[Quiz 컴포넌트에서 questions 데이터 import]
↓
[Quiz 컴포넌트 상태 관리]
└─ userAnswers 상태 (사용자가 선택한 답변 배열)
↓
[activeQuestionIndex 계산]
└─ activeQuestionIndex = userAnswers.length
↓
[현재 질문 및 답변 옵션 표시]
├─ questions[activeQuestionIndex].text를 통해 질문 표시
└─ questions[activeQuestionIndex].options를 map하여 답변 옵션 표시
↓
[사용자가 답변 선택]
↓
[handleSelectAnswer 함수 호출]
↓
[userAnswers 상태 업데이트]
└─ 이전 상태(prevUserAnswers)에 선택된 답변 추가
↓
[activeQuestionIndex 증가 (userAnswers.length 증가)]
↓
[다음 질문 표시]
↓
[반복]
└─ 질문이 남아있는 동안 계속 진행
↓
[질문이 모두 끝나면]
└─ 에러 발생 (추후 처리 예정)
// questions.ts
interface Question {
id: number;
text: string;
options: string[];
}
const questions: Question[] = [
{
id: 1,
text: 'React는 무엇을 위한 라이브러리인가요?',
options: ['데이터베이스', '사용자 인터페이스', '백엔드', '테스트'],
},
{
id: 2,
text: 'TypeScript는 무엇의 슈퍼셋인가요?',
options: ['JavaScript', 'Python', 'C++', 'Java'],
},
// 추가 질문들...
];
export default questions;
// Quiz.tsx
import React, { useState } from 'react';
import questions from '../questions';
const Quiz: React.FC = () => {
const [userAnswers, setUserAnswers] = useState<string[]>([]);
const activeQuestionIndex = userAnswers.length;
const currentQuestion = questions[activeQuestionIndex];
// 답변 옵션을 무작위로 섞는 함수
const shuffleOptions = (options: string[]): string[] => {
return options.sort(() => Math.random() - 0.5);
};
const [shuffledOptions, setShuffledOptions] = useState<string[]>([]);
React.useEffect(() => {
if (currentQuestion) {
setShuffledOptions(shuffleOptions([...currentQuestion.options]));
}
}, [currentQuestion]);
const handleSelectAnswer = (selectedAnswer: string) => {
setUserAnswers((prevUserAnswers) => [...prevUserAnswers, selectedAnswer]);
};
if (!currentQuestion) {
// 모든 질문이 끝났을 때 처리
return <div>퀴즈가 완료되었습니다!</div>;
}
return (
<div id="quiz">
<div id="question">
<h2>질문 {activeQuestionIndex + 1}</h2>
<p>{currentQuestion.text}</p>
</div>
<ul id="answers">
{shuffledOptions.map((option) => (
<li key={option} className="answer">
<button onClick={() => handleSelectAnswer(option)}>
{option}
</button>
</li>
))}
</ul>
</div>
);
};
export default Quiz;
// App.tsx
import React from 'react';
import Header from './components/Header';
import Quiz from './components/Quiz';
const App: React.FC = () => {
return (
<>
<Header />
<main>
<Quiz />
</main>
</>
);
};
export default App;
상태 관리 개선:
결론
답변 순서 무작위 섞기:
퀴즈 완료 시 에러 방지 및 요약 화면 표시:
에러 발생 원인과 해결 방법
결과 확인
[Quiz 컴포넌트 시작]
↓
[현재 질문의 답변 배열 복사]
↓
[답변 배열 무작위로 섞기]
(shuffledAnswers.sort(() => Math.random() - 0.5))
↓
[사용자에게 섞인 답변 표시]
↓
[사용자가 답변 선택]
↓
[userAnswers 상태 업데이트]
↓
[activeQuestionIndex 증가]
(userAnswers.length로 계산)
↓
[퀴즈 완료 여부 확인]
(quizIsComplete = activeQuestionIndex >= QUESTIONS.length)
↓
┌──────────────────────────────┐
│ 퀴즈 완료 여부 │
└──────────────────────────────┘
↓ ↓
[퀴즈 미완료 상태] [퀴즈 완료 상태]
│ │
│ │
↓ ↓
[다음 질문 표시] [요약 화면 표시]
│ │
└────────────────────────────────────┘
import React, { useState, useEffect } from 'react';
import questions from '../questions';
import quizCompleteImage from '../assets/quiz-complete.png';
const Quiz: React.FC = () => {
const [userAnswers, setUserAnswers] = useState<string[]>([]);
const activeQuestionIndex = userAnswers.length;
const quizIsComplete = activeQuestionIndex >= questions.length;
// 현재 질문 가져오기
const currentQuestion = questions[activeQuestionIndex];
// 답변 순서 섞기
const [shuffledAnswers, setShuffledAnswers] = useState<string[]>([]);
useEffect(() => {
if (!quizIsComplete && currentQuestion) {
// 답변 배열 복사 및 섞기
const shuffled = [...currentQuestion.answers];
shuffled.sort(() => Math.random() - 0.5);
setShuffledAnswers(shuffled);
}
}, [currentQuestion, quizIsComplete]);
const handleSelectAnswer = (selectedAnswer: string) => {
setUserAnswers((prevUserAnswers) => [...prevUserAnswers, selectedAnswer]);
};
if (quizIsComplete) {
return (
<div id="summary">
<h2>퀴즈 종료</h2>
<img src={quizCompleteImage} alt="트로피 아이콘" />
</div>
);
}
return (
<div id="quiz">
<div id="question">
<h2>{currentQuestion.text}</h2>
</div>
<ul id="answers">
{shuffledAnswers.map((answer) => (
<li key={answer} className="answer">
<button onClick={() => handleSelectAnswer(answer)}>
{answer}
</button>
</li>
))}
</ul>
</div>
);
};
export default Quiz;
상태 관리:
답변 섞기 로직:
퀴즈 완료 시 화면 처리:
에러 방지:
각 질문에 시간 제한 추가:
진행 표시줄(progress bar) 추가:
시간 만료 시 자동으로 다음 질문으로 이동:
구현 전략:
문제 상황:
원인 분석:
해결 방안:
[Quiz 컴포넌트]
↓
[QuestionTimer 컴포넌트 생성 및 렌더링]
├─ props:
│ ├─ timeout (예: 10000ms)
│ └─ onTimeout (handleSelectAnswer(null))
↓
[QuestionTimer 컴포넌트에서 타이머 시작]
├─ setInterval → 일정 간격으로 remainingTime 감소 및 상태 업데이트
└─ remainingTime이 0이 되면 onTimeout 호출
↓
[remainingTime 상태 업데이트]
↓
[진행 표시줄(progress bar) 업데이트]
↓
[remainingTime이 0이 되면]
├─ setInterval 정지
└─ onTimeout 호출 → Quiz 컴포넌트의 handleSelectAnswer(null) 실행
↓
[Quiz 컴포넌트에서 다음 질문으로 이동]
↓
[QuestionTimer 컴포넌트 재렌더링 및 타이머 재설정]
└─ key={activeQuestionIndex}로 인해 새로운 타이머 설정
import React, { useState, useEffect } from 'react';
interface QuestionTimerProps {
timeout: number; // 밀리초 단위
onTimeout: () => void;
}
const QuestionTimer: React.FC<QuestionTimerProps> = ({ timeout, onTimeout }) => {
const [remainingTime, setRemainingTime] = useState<number>(timeout);
useEffect(() => {
// remainingTime 초기화
setRemainingTime(timeout);
let isCancelled = false;
const interval = setInterval(() => {
setRemainingTime((prevRemainingTime) => {
const newRemainingTime = prevRemainingTime - 100;
if (newRemainingTime <= 0) {
clearInterval(interval);
if (!isCancelled) {
onTimeout();
}
return 0;
}
return newRemainingTime;
});
}, 100);
// 정리 함수
return () => {
isCancelled = true;
clearInterval(interval);
};
}, [timeout, onTimeout]);
return (
<progress
id="question-timer"
max={timeout}
value={remainingTime}
></progress>
);
};
export default QuestionTimer;
import React, { useState } from 'react';
import questions from '../questions';
import QuestionTimer from './QuestionTimer';
const Quiz: React.FC = () => {
const [userAnswers, setUserAnswers] = useState<(string | null)[]>([]);
const activeQuestionIndex = userAnswers.length;
const quizIsComplete = activeQuestionIndex >= questions.length;
const currentQuestion = questions[activeQuestionIndex];
const handleSelectAnswer = (selectedAnswer: string | null) => {
setUserAnswers((prevUserAnswers) => [...prevUserAnswers, selectedAnswer]);
};
if (quizIsComplete) {
return <div>퀴즈가 완료되었습니다!</div>;
}
return (
<div id="quiz">
<QuestionTimer
key={activeQuestionIndex}
timeout={10000} // 10초
onTimeout={() => handleSelectAnswer(null)}
/>
<div id="question">
<h2>{currentQuestion.text}</h2>
</div>
<ul id="answers">
{currentQuestion.options.map((option) => (
<li key={option}>
<button onClick={() => handleSelectAnswer(option)}>{option}</button>
</li>
))}
</ul>
</div>
);
};
export default Quiz;
타이머 재설정 문제 해결:
사용자 답변 처리:
이전에 퀴즈 애플리케이션에 타이머 기능을 추가하였으나, 타이머가 만료된 후 다음 질문으로 바로 넘어가지 않는 문제가 발생하였습니다.
또한, 타이머가 재설정되지 않거나, 예상치 못한 동작을 보이는 현상이 있었습니다.
문제 진단:
Effect 함수의 재실행 원인 분석:
함수의 참조 동일성 문제:
해결 방법:
결과 확인:
요약:
[Quiz 컴포넌트 렌더링]
↓
[handleSelectAnswer 함수 생성]
(매 렌더링 시 새로운 함수 생성)
↓
[handleSkipAnswer 함수 생성]
(매 렌더링 시 새로운 함수 생성)
↓
[onTimeout으로 handleSkipAnswer 전달]
↓
[QuestionTimer 컴포넌트의 useEffect 의존성 배열에 포함된 onTimeout이 변경됨]
↓
[QuestionTimer의 useEffect 재실행]
↓
[타이머와 인터벌이 예상치 않게 재설정됨]
--------------------------------------------
(해결책 적용 후)
[Quiz 컴포넌트 렌더링]
↓
[useCallback으로 handleSelectAnswer 메모이제이션]
↓
[useCallback으로 handleSkipAnswer 메모이제이션]
↓
[onTimeout으로 handleSkipAnswer 전달]
↓
[QuestionTimer 컴포넌트의 useEffect 의존성 배열의 onTimeout이 변경되지 않음]
↓
[QuestionTimer의 useEffect 불필요한 재실행 방지]
↓
[타이머와 인터벌이 올바르게 동작]
import React, { useState, useCallback } from 'react';
import questions from '../questions';
import QuestionTimer from './QuestionTimer';
const Quiz: React.FC = () => {
const [userAnswers, setUserAnswers] = useState<(string | null)[]>([]);
const activeQuestionIndex = userAnswers.length;
const quizIsComplete = activeQuestionIndex >= questions.length;
const handleSelectAnswer = useCallback((selectedAnswer: string | null) => {
setUserAnswers((prevUserAnswers) => [...prevUserAnswers, selectedAnswer]);
}, []);
const handleSkipAnswer = useCallback(() => {
handleSelectAnswer(null);
}, [handleSelectAnswer]);
if (quizIsComplete) {
return <div>퀴즈가 완료되었습니다!</div>;
}
const currentQuestion = questions[activeQuestionIndex];
return (
<div id="quiz">
<QuestionTimer
key={activeQuestionIndex}
timeout={10000}
onTimeout={handleSkipAnswer}
/>
<div id="question">
<h2>{currentQuestion.text}</h2>
</div>
<ul id="answers">
{currentQuestion.options.map((option) => (
<li key={option}>
<button onClick={() => handleSelectAnswer(option)}>{option}</button>
</li>
))}
</ul>
</div>
);
};
export default Quiz;
import React, { useState, useEffect } from 'react';
interface QuestionTimerProps {
timeout: number;
onTimeout: () => void;
}
const QuestionTimer: React.FC<QuestionTimerProps> = ({ timeout, onTimeout }) => {
const [remainingTime, setRemainingTime] = useState<number>(timeout);
useEffect(() => {
console.log('SETTING TIMEOUT');
const timer = setTimeout(() => {
onTimeout();
}, timeout);
return () => {
clearTimeout(timer);
};
}, [timeout, onTimeout]);
useEffect(() => {
console.log('SETTING INTERVAL');
setRemainingTime(timeout);
const interval = setInterval(() => {
setRemainingTime((prevRemainingTime) => {
const newTime = prevRemainingTime - 100;
if (newTime <= 0) {
clearInterval(interval);
return 0;
}
return newTime;
});
}, 100);
return () => {
clearInterval(interval);
};
}, [timeout]);
return (
<progress id="question-timer" max={timeout} value={remainingTime}></progress>
);
};
export default QuestionTimer;
퀴즈 애플리케이션에 타이머 기능을 추가하여 각 질문마다 제한 시간을 설정했습니다.
그러나 진행 표시줄(progress bar)이 예상보다 빠르게 줄어들고, 다음 질문으로 넘어갈 때 타이머와 진행 표시줄이 초기화되지 않는 문제가 발생했습니다.
문제 분석 및 해결 과정:
진행 표시줄이 예상보다 빠르게 줄어드는 문제:
엄격 모드
)가 활성화되어 컴포넌트 함수와 훅이 두 번씩 호출되기 때문입니다.StrictMode
는 개발 중 잠재적인 문제를 발견하기 위해 컴포넌트를 두 번 렌더링합니다.useEffect 훅에 클린업 함수(cleanup function
)를 추가하여 이전 인터벌을 정리합니다.
인터벌을 설정하는 useEffect 훅에서 반환값으로 클린업 함수를 제공합니다:
useEffect(() => {
const interval = setInterval(...);
return () => {
clearInterval(interval);
};
}, [timeout]);
타이머와 진행 표시줄이 다음 질문으로 넘어갈 때 초기화되지 않는 문제:
QuestionTimer 컴포넌트에 key 속성을 추가하여 activeQuestionIndex가 변경될 때마다 컴포넌트를 재생성하도록 합니다:
<QuestionTimer
key={activeQuestionIndex}
timeout={10000}
onTimeout={handleSkipAnswer}
/>
React는 key 속성의 변화를 감지하여 컴포넌트를 언마운트하고 새로운 인스턴스를 마운트합니다.
이를 통해 새로운 질문으로 넘어갈 때마다 타이머와 진행 표시줄이 초기화됩니다.
클린업 함수의 중요성 및 Strict Mode에서의 동작 이해:
최종 결과:
React에서 key 속성의 활용:
[Quiz 컴포넌트]
↓
[QuestionTimer 컴포넌트 렌더링]
├─ props:
│ ├─ key = activeQuestionIndex
│ ├─ timeout
│ └─ onTimeout
↓
[QuestionTimer 컴포넌트 마운트]
↓
[useEffect 훅 실행]
├─ setInterval 설정 (남은 시간 감소)
├─ setTimeout 설정 (onTimeout 호출 예정)
└─ 클린업 함수 등록 (clearInterval, clearTimeout)
↓
[남은 시간 업데이트 및 진행 표시줄 업데이트]
↓
[남은 시간이 0이 되면]
├─ clearInterval 호출
├─ onTimeout 호출 → Quiz 컴포넌트의 handleSkipAnswer 실행
↓
[Quiz 컴포넌트에서 userAnswers 상태 업데이트]
↓
[activeQuestionIndex 증가]
↓
[Quiz 컴포넌트 재렌더링]
↓
[QuestionTimer 컴포넌트 재생성 (key 값 변경)]
├─ 이전 컴포넌트 언마운트 (클린업 함수 실행)
└─ 새로운 컴포넌트 마운트 (타이머 및 인터벌 재설정)
↓
[새로운 질문에 대해 타이머 및 진행 표시줄 초기화]
↓
[위 과정 반복]
import React, { useState, useEffect } from 'react';
interface QuestionTimerProps {
timeout: number; // 밀리초 단위
onTimeout: () => void;
}
const QuestionTimer: React.FC<QuestionTimerProps> = ({ timeout, onTimeout }) => {
const [remainingTime, setRemainingTime] = useState<number>(timeout);
useEffect(() => {
// 남은 시간 초기화
setRemainingTime(timeout);
// 타이머 설정
const timer = setTimeout(() => {
onTimeout();
}, timeout);
// 인터벌 설정 (진행 표시줄 업데이트)
const interval = setInterval(() => {
setRemainingTime((prevRemainingTime) => {
const newTime = prevRemainingTime - 100;
if (newTime <= 0) {
clearInterval(interval);
return 0;
}
return newTime;
});
}, 100);
// 클린업 함수: 타이머와 인터벌 정리
return () => {
clearTimeout(timer);
clearInterval(interval);
};
}, [timeout, onTimeout]);
return (
<progress id="question-timer" max={timeout} value={remainingTime}></progress>
);
};
export default QuestionTimer;
import React, { useState, useCallback } from 'react';
import questions from '../questions';
import QuestionTimer from './QuestionTimer';
const Quiz: React.FC = () => {
const [userAnswers, setUserAnswers] = useState<(string | null)[]>([]);
const activeQuestionIndex = userAnswers.length;
const quizIsComplete = activeQuestionIndex >= questions.length;
const handleSelectAnswer = useCallback((selectedAnswer: string | null) => {
setUserAnswers((prevUserAnswers) => [...prevUserAnswers, selectedAnswer]);
}, []);
const handleSkipAnswer = useCallback(() => {
handleSelectAnswer(null);
}, [handleSelectAnswer]);
if (quizIsComplete) {
return <div>퀴즈가 완료되었습니다!</div>;
}
const currentQuestion = questions[activeQuestionIndex];
return (
<div id="quiz">
<QuestionTimer
key={activeQuestionIndex} // key 속성 추가
timeout={10000} // 10초 제한 시간
onTimeout={handleSkipAnswer}
/>
<div id="question">
<h2>{currentQuestion.text}</h2>
</div>
<ul id="answers">
{currentQuestion.options.map((option) => (
<li key={option}>
<button onClick={() => handleSelectAnswer(option)}>{option}</button>
</li>
))}
</ul>
</div>
);
};
export default Quiz;
결론:
React Strict Mode:
useEffect 훅의 클린업 함수:
key 속성의 활용:
사용자에게 선택한 답변을 강조 표시하고, 정답 여부를 보여주기:
구현 전략:
문제 원인 분석:
[사용자가 답변 선택]
↓
[handleSelectAnswer 함수 호출]
├─ answerState를 'answered'로 설정
├─ setTimeout으로 1초 후에 정답 확인
↓
[1초 후]
├─ 선택한 답변이 정답인지 확인
├─ answerState를 'correct' 또는 'wrong'으로 업데이트
↓
[컴포넌트 렌더링]
├─ answerState와 userAnswers를 기반으로 버튼 CSS 클래스 적용
│ ├─ 선택된 답변 강조 (selected)
│ ├─ 정답이면 'correct' 클래스 적용
│ └─ 오답이면 'wrong' 클래스 적용
↓
[2초 후]
├─ answerState를 ''로 초기화
├─ 다음 질문으로 이동 (userAnswers 배열 업데이트)
↓
[문제 발생]
├─ activeQuestionIndex가 answerState에 따라 동적으로 변경되어
└─ 컴포넌트가 현재 질문과 이전 질문 사이에서 빠르게 전환됨
// Quiz.tsx
import React, { useState, useCallback, useEffect } from 'react';
import questions from '../questions';
import QuestionTimer from './QuestionTimer';
const Quiz: React.FC = () => {
const [userAnswers, setUserAnswers] = useState<(string | null)[]>([]);
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null);
const [isAnswerRevealed, setIsAnswerRevealed] = useState<boolean>(false);
const activeQuestionIndex = userAnswers.length;
const quizIsComplete = activeQuestionIndex >= questions.length;
const currentQuestion = questions[activeQuestionIndex];
const handleSelectAnswer = useCallback((answer: string | null) => {
if (isAnswerRevealed) return; // 이미 답변을 선택한 경우 무시
setSelectedAnswer(answer);
setIsAnswerRevealed(true);
// 1초 후에 다음 작업 수행
setTimeout(() => {
// 정답을 userAnswers에 저장
setUserAnswers((prevUserAnswers) => [...prevUserAnswers, answer]);
// 1초 후에 다음 질문으로 이동
setTimeout(() => {
setSelectedAnswer(null);
setIsAnswerRevealed(false);
}, 1000);
}, 1000);
}, [isAnswerRevealed]);
const handleSkipAnswer = useCallback(() => {
handleSelectAnswer(null);
}, [handleSelectAnswer]);
useEffect(() => {
// 타이머가 만료되어도 답변을 선택하지 않은 경우 처리
if (isAnswerRevealed && selectedAnswer === null) {
setUserAnswers((prevUserAnswers) => [...prevUserAnswers, null]);
setTimeout(() => {
setIsAnswerRevealed(false);
}, 1000);
}
}, [isAnswerRevealed, selectedAnswer]);
if (quizIsComplete) {
return <div>퀴즈가 완료되었습니다!</div>;
}
const correctAnswer = currentQuestion.answers[0];
return (
<div id="quiz">
<QuestionTimer
key={activeQuestionIndex}
timeout={10000}
onTimeout={handleSkipAnswer}
/>
<div id="question">
<h2>{currentQuestion.text}</h2>
</div>
<ul id="answers">
{currentQuestion.answers.map((answer) => {
let className = '';
if (isAnswerRevealed) {
if (answer === selectedAnswer) {
className = answer === correctAnswer ? 'correct' : 'wrong';
} else if (answer === correctAnswer) {
className = 'correct';
}
}
return (
<li key={answer}>
<button
className={className}
onClick={() => handleSelectAnswer(answer)}
disabled={isAnswerRevealed} // 답변 선택 후 버튼 비활성화
>
{answer}
</button>
</li>
);
})}
</ul>
</div>
);
};
export default Quiz;
문제 원인: activeQuestionIndex와 userAnswers.length를 answerState에 따라 동적으로 변경하면서 컴포넌트의 상태 관리가 복잡해지고, 예상치 못한 렌더링 문제가 발생했습니다.
해결 방법: 새로운 상태 변수인 selectedAnswer와 isAnswerRevealed를 도입하여 상태 관리를 단순화하고, 컴포넌트의 렌더링 로직을 안정화했습니다.
이점:
추가 학습 포인트:
문제 상황:
문제 원인 분석:
해결 방법
Answers 컴포넌트 생성 및 로직 분리
key 속성을 활용한 컴포넌트 재생성
Question 컴포넌트 생성으로 구조 개선
[Quiz 컴포넌트]
├─ 상태:
│ ├─ userAnswers
│ ├─ answerState
│ └─ selectedAnswer
├─ 함수:
│ ├─ handleSelectAnswer
│ └─ handleSkipAnswer
└─ 렌더링:
└─ [Question 컴포넌트 key={activeQuestionIndex}]
├─ props:
│ ├─ questionText
│ ├─ answers
│ ├─ onSelectAnswer
│ ├─ onSkipAnswer
│ ├─ selectedAnswer
│ └─ answerState
└─ 렌더링:
├─ [QuestionTimer 컴포넌트]
├─ 질문 텍스트 표시
└─ [Answers 컴포넌트 key={activeQuestionIndex}]
├─ props:
│ ├─ answers
│ ├─ onSelect
│ ├─ selectedAnswer
│ └─ answerState
└─ 로직:
├─ 답변을 한 번만 섞음 (useRef 사용)
├─ 답변 목록 렌더링
└─ 선택된 답변에 따라 CSS 클래스 적용
결론
추가 학습 포인트:
React에서 컴포넌트의 key 값을 변경하면, React는 해당 컴포넌트를 재렌더링하는 것이 아니라, 완전히 새로운 컴포넌트로 인식하여 기존 컴포넌트를 언마운트(unmount)하고 새로운 컴포넌트를 마운트(mount) 합니다. 이로 인해 컴포넌트는 재생성되며, 이 과정에서 컴포넌트의 모든 상태와 useRef로 저장된 값들이 초기화됩니다.
따라서:
예를 들어:
결론적으로, 컴포넌트의 key 값을 변경하면 컴포넌트는 재렌더링되는 것이 아니라 완전히 재생성되며, 이로 인해 useRef로 정의한 값도 초기화되어 유지되지 않게 됩니다.
추가 설명
주의점
상황 요약:
문제 해결 요약:
[Quiz 컴포넌트]
├─ 상태:
│ ├─ userAnswers: (string | null)[]
│ └─ activeQuestionIndex: number (userAnswers.length)
├─ 함수:
│ ├─ handleSelectAnswer (useCallback)
│ └─ handleSkipAnswer (useCallback)
└─ 렌더링:
└─ [Question 컴포넌트 key={activeQuestionIndex}]
├─ props:
│ ├─ questionText: string
│ ├─ answers: string[]
│ ├─ onSelectAnswer: (answer: string | null) => void
│ └─ onSkipAnswer: () => void
└─ 렌더링:
├─ [QuestionTimer 컴포넌트 key={timer}]
├─ 질문 텍스트 표시
└─ [Answers 컴포넌트 key={activeQuestionIndex}]
├─ props:
│ ├─ answers: string[]
│ ├─ onSelect: (answer: string) => void
│ ├─ selectedAnswer: string | null
│ └─ answerState: 'correct' | 'wrong' | ''
└─ 로직:
├─ 답변을 한 번만 섞음 (useRef 사용)
├─ 답변 목록 렌더링
└─ 선택된 답변에 따라 CSS 클래스 적용
[사용자 상호작용]
├─ 사용자가 답변 선택
│ ↓
├─ [Question 컴포넌트] handleSelectAnswer 호출
│ ├─ selectedAnswer 상태 업데이트
│ ├─ 1초 후 isCorrect 상태 업데이트
│ └─ 2초 후 Quiz 컴포넌트로 onSelectAnswer 호출
│
├─ 타이머 만료 시
│ ↓
├─ [QuestionTimer 컴포넌트] onTimeout 호출
│ ↓
└─ [Question 컴포넌트] handleSkipAnswer 호출
└─ Quiz 컴포넌트로 onSelectAnswer(null) 호출
[Quiz 컴포넌트]
↓
└─ userAnswers 업데이트
↓
└─ activeQuestionIndex 증가
↓
└─ [Question 컴포넌트] 재생성 (key 변경)
├─ [QuestionTimer 컴포넌트] 재생성
└─ [Answers 컴포넌트] 재생성
현재 상태:
남은 문제점:
[Quiz 컴포넌트]
├─ 상태:
│ ├─ userAnswers: (string | null)[]
│ └─ activeQuestionIndex: number (userAnswers.length)
├─ 함수:
│ ├─ handleSelectAnswer (useCallback)
│ └─ handleSkipAnswer (useCallback)
└─ 렌더링:
└─ [Question 컴포넌트 key={activeQuestionIndex}]
├─ props:
│ ├─ questionText: string
│ ├─ answers: string[]
│ ├─ onSelectAnswer: (answer: string | null) => void
│ └─ onSkipAnswer: () => void
└─ 렌더링:
├─ [QuestionTimer 컴포넌트 key={timer}]
├─ 질문 텍스트 표시
└─ [Answers 컴포넌트 key={activeQuestionIndex}]
├─ props:
│ ├─ answers: string[]
│ ├─ onSelect: (answer: string) => void
│ ├─ selectedAnswer: string | null
│ └─ answerState: 'correct' | 'wrong' | ''
└─ 로직:
├─ 답변을 한 번만 섞음 (useRef 사용)
├─ 답변 목록 렌더링
└─ 선택된 답변에 따라 CSS 클래스 적용
[사용자 상호작용]
├─ 사용자가 답변 선택
│ ↓
├─ [Question 컴포넌트] handleSelectAnswer 호출
│ ├─ selectedAnswer 상태 업데이트
│ ├─ 1초 후 isCorrect 상태 업데이트
│ └─ 2초 후 Quiz 컴포넌트로 onSelectAnswer 호출
│
├─ 타이머 만료 시
│ ↓
├─ [QuestionTimer 컴포넌트] onTimeout 호출
│ ↓
└─ [Question 컴포넌트] handleSkipAnswer 호출
└─ Quiz 컴포넌트로 onSelectAnswer(null) 호출
[Quiz 컴포넌트]
↓
└─ userAnswers 업데이트
↓
└─ activeQuestionIndex 증가
↓
└─ [Question 컴포넌트] 재생성 (key 변경)
├─ [QuestionTimer 컴포넌트] 재생성
└─ [Answers 컴포넌트] 재생성
[퀴즈 완료]
↓
┌─────────────────┐
│ [Summary 컴포넌트] │
└─────────────────┘
├─ props:
│ └─ userAnswers: (string | null)[]
└─ 렌더링:
├─ 정답/오답/건너뛴 질문 수 표시
└─ 각 질문에 대한 상세 정보 표시
간단한 카운터 프로젝트를 통한 React 내부 동작 이해
프로젝트 개요:
주요 학습 포인트:
결론:
[App 컴포넌트]
├─ 렌더링:
│ ├─ [Header 컴포넌트]
│ └─ [Counter 컴포넌트]
│ ├─ 상태:
│ │ └─ count: number
│ ├─ 렌더링:
│ │ ├─ [IconButton 컴포넌트] (증가 버튼)
│ │ ├─ [IconButton 컴포넌트] (감소 버튼)
│ │ └─ [CounterOutput 컴포넌트]
│ └─ 함수:
│ ├─ handleIncrement
│ └─ handleDecrement
└─ 상태 변경 시:
└─ setCount 호출
└─ [Counter 컴포넌트] 리렌더링
└─ [CounterOutput 컴포넌트] 업데이트
[사용자 상호작용]
├─ 사용자가 증가 버튼 클릭
│ ↓
├─ [IconButton 컴포넌트] onClick 호출
│ ↓
├─ [Counter 컴포넌트] handleIncrement 실행
│ ↓
├─ 상태(count) 증가
│ ↓
└─ [CounterOutput 컴포넌트] 업데이트하여 새로운 count 값 표시
// App.tsx
import React from 'react';
import Header from './Header';
import Counter from './Counter';
const App: React.FC = () => {
return (
<div id="app">
<Header title="간단한 카운터 앱" />
<Counter initialCount={0} />
</div>
);
};
export default App;
// Header.tsx
import React from 'react';
interface HeaderProps {
title: string;
}
const Header: React.FC<HeaderProps> = ({ title }) => {
return (
<header>
<h1>{title}</h1>
</header>
);
};
export default Header;
// Counter.tsx
import React, { useState } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';
interface CounterProps {
initialCount: number;
}
const Counter: React.FC<CounterProps> = ({ initialCount }) => {
const [count, setCount] = useState<number>(initialCount);
const handleIncrement = () => {
setCount((prevCount) => prevCount + 1);
};
const handleDecrement = () => {
setCount((prevCount) => prevCount - 1);
};
return (
<section>
<CounterOutput count={count} />
<div>
<IconButton onClick={handleIncrement} icon={<PlusIcon />} />
<IconButton onClick={handleDecrement} icon={<MinusIcon />} />
</div>
</section>
);
};
export default Counter;
// IconButton.tsx
import React from 'react';
interface IconButtonProps {
onClick: () => void;
icon: React.ReactElement;
}
const IconButton: React.FC<IconButtonProps> = ({ onClick, icon }) => {
return (
<button onClick={onClick}>
{icon}
</button>
);
};
export default IconButton;
// CounterOutput.tsx
import React from 'react';
interface CounterOutputProps {
count: number;
}
const CounterOutput: React.FC<CounterOutputProps> = ({ count }) => {
return (
<div>
<h2>카운트: {count}</h2>
</div>
);
};
export default CounterOutput;
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
주요 내용:
결론:
[App 컴포넌트]
├─ 렌더링:
│ ├─ [Header 컴포넌트]
│ └─ [Counter 컴포넌트]
│ ├─ 상태:
│ │ └─ count: number
│ ├─ 렌더링:
│ │ ├─ [IconButton 컴포넌트] (증가 버튼)
│ │ ├─ [IconButton 컴포넌트] (감소 버튼)
│ │ └─ [CounterOutput 컴포넌트]
│ └─ 함수:
│ ├─ handleIncrement
│ └─ handleDecrement
└─ 상태 변경 시:
└─ setCount 호출
└─ [Counter 컴포넌트] 리렌더링
└─ [CounterOutput 컴포넌트] 업데이트
[사용자 상호작용]
├─ 사용자가 증가 버튼 클릭
│ ↓
├─ [IconButton 컴포넌트] onClick 호출
│ ↓
├─ [Counter 컴포넌트] handleIncrement 실행
│ ↓
├─ 상태(count) 증가
│ ↓
└─ [CounterOutput 컴포넌트] 업데이트하여 새로운 count 값 표시
// App.tsx
import React from 'react';
import Header from './Header';
import Counter from './Counter';
const App: React.FC = () => {
return (
<div id="app">
<Header title="간단한 카운터 앱" />
<Counter initialCount={0} />
</div>
);
};
export default App;
// Header.tsx
import React from 'react';
interface HeaderProps {
title: string;
}
const Header: React.FC<HeaderProps> = ({ title }) => {
return (
<header>
<h1>{title}</h1>
</header>
);
};
export default Header;
// Counter.tsx
import React, { useState } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';
interface CounterProps {
initialCount: number;
}
const Counter: React.FC<CounterProps> = ({ initialCount }) => {
const [count, setCount] = useState<number>(initialCount);
const handleIncrement = () => {
setCount((prevCount) => prevCount + 1);
};
const handleDecrement = () => {
setCount((prevCount) => prevCount - 1);
};
return (
<section>
<CounterOutput count={count} />
<div>
<IconButton onClick={handleIncrement} icon={<PlusIcon />} />
<IconButton onClick={handleDecrement} icon={<MinusIcon />} />
</div>
</section>
);
};
export default Counter;
// IconButton.tsx
import React from 'react';
interface IconButtonProps {
onClick: () => void;
icon: React.ReactElement;
}
const IconButton: React.FC<IconButtonProps> = ({ onClick, icon }) => {
return (
<button onClick={onClick}>
{icon}
</button>
);
};
export default IconButton;
// CounterOutput.tsx
import React from 'react';
interface CounterOutputProps {
count: number;
}
const CounterOutput: React.FC<CounterOutputProps> = ({ count }) => {
return (
<div>
<h2>카운트: {count}</h2>
</div>
);
};
export default CounterOutput;
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
React Profiler를 통한 컴포넌트 실행 분석
rofiler 사용 방법:
실제 예시:
문제점 발견 및 최적화:
예시 최적화: React.memo 사용
// IconButton.tsx
import React from 'react';
interface IconButtonProps {
onClick: () => void;
icon: React.ReactElement;
}
const IconButton: React.FC<IconButtonProps> = React.memo(({ onClick, icon }) => {
console.log('IconButton 렌더링');
return (
<button onClick={onClick}>
{icon}
</button>
);
});
export default IconButton;
설명:
결과:
React 애플리케이션 최적화를 위한 컴포넌트 재렌더링 제어
프로젝트 상황:
목표:
React.memo를 사용한 컴포넌트 최적화
주의사항:
적용 대상:
Counter 컴포넌트 최적화
// Counter.tsx
import React, { useState } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';
interface CounterProps {
initialCount: number;
}
const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
const [count, setCount] = useState<number>(initialCount);
const handleIncrement = () => {
setCount((prevCount) => prevCount + 1);
};
const handleDecrement = () => {
setCount((prevCount) => prevCount - 1);
};
console.log('Counter 렌더링');
return (
<section>
<CounterOutput count={count} />
<div>
<IconButton onClick={handleIncrement} icon={<PlusIcon />} />
<IconButton onClick={handleDecrement} icon={<MinusIcon />} />
</div>
</section>
);
});
export default Counter;
설명:
IconButton 컴포넌트 최적화
// IconButton.tsx
import React from 'react';
interface IconButtonProps {
onClick: () => void;
icon: React.ReactElement;
}
const IconButton: React.FC<IconButtonProps> = React.memo(({ onClick, icon }) => {
console.log('IconButton 렌더링');
return (
<button onClick={onClick}>
{icon}
</button>
);
});
export default IconButton;
설명:
React Profiler를 활용한 성능 분석
Profiler 사용 방법:
Flame Graph vs Ranked Chart:
예시 분석
컴포넌트 최적화의 모범 사례
예시: useCallback 사용
// Counter.tsx
import React, { useState, useCallback } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';
interface CounterProps {
initialCount: number;
}
const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
const [count, setCount] = useState<number>(initialCount);
const handleIncrement = useCallback(() => {
setCount((prevCount) => prevCount + 1);
}, []);
const handleDecrement = useCallback(() => {
setCount((prevCount) => prevCount - 1);
}, []);
console.log('Counter 렌더링');
return (
<section>
<CounterOutput count={count} />
<div>
<IconButton onClick={handleIncrement} icon={<PlusIcon />} />
<IconButton onClick={handleDecrement} icon={<MinusIcon />} />
</div>
</section>
);
});
export default Counter;
설명:
Counter.tsx 최적화
// Counter.tsx
import React, { useState, useCallback } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';
interface CounterProps {
initialCount: number;
}
const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
const [count, setCount] = useState<number>(initialCount);
// useCallback을 사용하여 함수 참조를 메모이제이션
const handleIncrement = useCallback(() => {
setCount((prevCount) => prevCount + 1);
}, []);
const handleDecrement = useCallback(() => {
setCount((prevCount) => prevCount - 1);
}, []);
console.log('Counter 렌더링');
return (
<section>
<CounterOutput count={count} />
<div>
<IconButton onClick={handleIncrement} icon={<PlusIcon />} />
<IconButton onClick={handleDecrement} icon={<MinusIcon />} />
</div>
</section>
);
});
export default Counter;
// IconButton.tsx
import React from 'react';
interface IconButtonProps {
onClick: () => void;
icon: React.ReactElement;
}
const IconButton: React.FC<IconButtonProps> = React.memo(({ onClick, icon }) => {
console.log('IconButton 렌더링');
return (
<button onClick={onClick}>
{icon}
</button>
);
});
export default IconButton;
// App.tsx
import React, { useState, useCallback } from 'react';
import Header from './Header';
import Counter from './Counter';
const App: React.FC = () => {
const [enteredNumber, setEnteredNumber] = useState<string>('');
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setEnteredNumber(e.target.value);
}, []);
console.log('App 렌더링');
return (
<div id="app">
<Header title="간단한 카운터 앱" />
<input type="text" value={enteredNumber} onChange={handleInputChange} placeholder="숫자를 입력하세요" />
<button>설정</button>
<Counter initialCount={0} />
</div>
);
};
export default App;
React.memo()를 사용하면 함수 조차도 재실행되지 않는 것인지.. 확인 필요
React 애플리케이션의 컴포넌트 재렌더링 최적화: React.memo와 컴포넌트 구조 개선
주요 내용:
결론:
[App 컴포넌트]
├─ 상태:
│ ├─ enteredNumber: string
│ └─ chosenCount: number
├─ 함수:
│ ├─ handleInputChange (useCallback)
│ └─ handleSetCount (useCallback)
├─ 렌더링:
│ ├─ [Header 컴포넌트] (React.memo 적용)
│ ├─ [ConfigureCounter 컴포넌트]
│ │ ├─ 상태:
│ │ │ └─ enteredNumber: string
│ │ ├─ 함수:
│ │ │ └─ handleSetClick (useCallback)
│ │ └─ 렌더링:
│ │ ├─ input
│ │ └─ [Button 컴포넌트]
│ └─ [Counter 컴포넌트] (React.memo 적용)
│ ├─ 상태:
│ │ └─ count: number
│ ├─ 함수:
│ │ ├─ handleIncrement (useCallback)
│ │ └─ handleDecrement (useCallback)
│ └─ 렌더링:
│ ├─ [IconButton 컴포넌트] (React.memo 적용)
│ ├─ [IconButton 컴포넌트] (React.memo 적용)
│ └─ [CounterOutput 컴포넌트] (React.memo 적용)
└─ 렌더링 최적화:
├─ React.memo를 통해 Header와 Counter 컴포넌트의 불필요한 재렌더링 방지
└─ 컴포넌트 분할로 ConfigureCounter에서 상태 변경 시 다른 컴포넌트 재렌더링 방지
[사용자 상호작용]
├─ 사용자가 ConfigureCounter의 input에 숫자 입력
│ ↓
├─ [ConfigureCounter 컴포넌트] handleSetClick 호출
│ ├─ handleSetCount 호출 (App 컴포넌트의 함수)
│ └─ chosenCount 상태 업데이트
│ ↓
└─ [Counter 컴포넌트] count 업데이트 및 재렌더링
// App.tsx
import React, { useState, useCallback } from 'react';
import Header from './Header';
import ConfigureCounter from './ConfigureCounter';
import Counter from './Counter';
const App: React.FC = () => {
const [chosenCount, setChosenCount] = useState<number>(0);
// useCallback을 사용하여 함수 참조를 메모이제이션
const handleSetCount = useCallback((newCount: number) => {
setChosenCount(newCount);
}, []);
console.log('App 렌더링');
return (
<div id="app">
<Header title="최적화된 카운터 앱" />
<ConfigureCounter onSet={handleSetCount} />
<Counter initialCount={chosenCount} />
</div>
);
};
export default App;
// Header.tsx
import React from 'react';
interface HeaderProps {
title: string;
}
const Header: React.FC<HeaderProps> = React.memo(({ title }) => {
console.log('Header 렌더링');
return (
<header>
<h1>{title}</h1>
</header>
);
});
export default Header;
// ConfigureCounter.tsx
import React, { useState, useCallback } from 'react';
import log from './log'; // log.js 파일에서 log 함수 임포트
interface ConfigureCounterProps {
onSet: (newCount: number) => void;
}
const ConfigureCounter: React.FC<ConfigureCounterProps> = () => {
const [enteredNumber, setEnteredNumber] = useState<string>('');
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setEnteredNumber(e.target.value);
}, []);
const handleSetClick = useCallback(() => {
const newCount = parseInt(enteredNumber, 10) || 0;
log('ConfigureCounter');
onSet(newCount);
}, [enteredNumber, onSet]);
console.log('ConfigureCounter 렌더링');
return (
<section>
<input
type="number"
value={enteredNumber}
onChange={handleInputChange}
placeholder="숫자를 입력하세요"
/>
<button onClick={handleSetClick}>설정</button>
</section>
);
};
export default ConfigureCounter;
// Counter.tsx
import React, { useState, useCallback } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';
interface CounterProps {
initialCount: number;
}
const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
const [count, setCount] = useState<number>(initialCount);
// useCallback을 사용하여 함수 참조를 메모이제이션
const handleIncrement = useCallback(() => {
setCount((prevCount) => prevCount + 1);
}, []);
const handleDecrement = useCallback(() => {
setCount((prevCount) => prevCount - 1);
}, []);
console.log('Counter 렌더링');
return (
<section>
<CounterOutput count={count} />
<div>
<IconButton onClick={handleIncrement} icon={<PlusIcon />} />
<IconButton onClick={handleDecrement} icon={<MinusIcon />} />
</div>
</section>
);
});
export default Counter;
// IconButton.tsx
import React from 'react';
interface IconButtonProps {
onClick: () => void;
icon: React.ReactElement;
}
const IconButton: React.FC<IconButtonProps> = React.memo(({ onClick, icon }) => {
console.log('IconButton 렌더링');
return (
<button onClick={onClick}>
{icon}
</button>
);
});
export default IconButton;
// CounterOutput.tsx
import React from 'react';
interface CounterOutputProps {
count: number;
}
const CounterOutput: React.FC<CounterOutputProps> = React.memo(({ count }) => {
console.log('CounterOutput 렌더링');
return (
<div>
<h2>카운트: {count}</h2>
</div>
);
});
export default CounterOutput;
// log.js
const log = (componentName) => {
console.log(`${componentName} 실행`);
};
export default log;
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
문제 상황:
해결 방안:
React 애플리케이션의 컴포넌트 재렌더링 최적화: React.memo와 useCallback의 활용
문제 상황:
해결 방안:
최적화 전략의 적용:
결과 검증:
결론:
React 애플리케이션의 컴포넌트 재렌더링 최적화: React.memo와 useMemo의 활용
React.memo의 한계와 useMemo의 필요성:
현명한 컴포넌트 구성의 중요성:
useMemo의 활용:
최적화 전략의 적용과 주의사항:
결론:
React의 렌더링 과정과 가상 DOM(Virtual DOM)의 역할
컴포넌트 함수의 실행과 JSX 반환:
가상 DOM(Virtual DOM)의 개념:
렌더링 최적화 메커니즘:
함수 재실행과 최적화:
결론:
React 컴포넌트 상태 관리와 고유 키의 중요성
컴포넌트 상태의 독립성:
동적인 목록 렌더링과 키의 역할:
인덱스를 키로 사용하는 문제점:
고유한 ID를 키로 사용하는 해결 방안:
결론:
React에서 키(Key)의 중요성과 추가 이점
키(Key)의 역할:
인덱스를 키로 사용하는 문제점:
고유한 키를 사용하는 이점:
키 사용의 추가 이점:
결론:
React에서 키(Key)의 활용: 목록 외의 컴포넌트 상태 재설정
키(Key)의 일반적인 역할:
키의 추가 활용:
useEffect를 사용한 상태 재설정의 문제점:
키를 활용한 상태 재설정의 장점:
결론:
[사용자 상호작용]
├─ 사용자가 입력값을 변경하고 '설정' 버튼을 클릭
│ ↓
├─ [App 컴포넌트]의 chosenCount 상태 업데이트
│ └─ chosenCount를 Counter 컴포넌트에 initialCount 및 key로 전달
│
├─ [Counter 컴포넌트]가 키 변경 감지
│ └─ React가 기존 Counter 컴포넌트를 언마운트하고 새로운 인스턴스를 마운트
│ └─ Counter의 상태(state)가 초기화됨
│
└─ [Counter 컴포넌트]의 새로운 상태로 렌더링
// App.tsx
import React, { useState, useCallback } from 'react';
import Header from './Header';
import ConfigureCounter from './ConfigureCounter';
import Counter from './Counter';
const App: React.FC = () => {
const [chosenCount, setChosenCount] = useState<number>(0);
const handleSetCount = useCallback((newCount: number) => {
setChosenCount(newCount);
}, []);
console.log('App 렌더링');
return (
<div id="app">
<Header title="카운터 앱" />
<ConfigureCounter onSet={handleSetCount} />
<Counter initialCount={chosenCount} />
</div>
);
};
export default App;
// Counter.tsx
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import CounterHistory from './CounterHistory';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';
interface CounterProps {
initialCount: number;
}
interface CounterChange {
change: string;
}
const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
const [count, setCount] = useState<number>(initialCount);
const [history, setHistory] = useState<CounterChange[]>([]);
useEffect(() => {
setCount(initialCount);
setHistory([]);
}, [initialCount]);
const handleIncrement = useCallback(() => {
setCount((prevCount) => prevCount + 1);
setHistory((prevHistory) => [...prevHistory, { change: '증가하기' }]);
}, []);
const handleDecrement = useCallback(() => {
setCount((prevCount) => prevCount - 1);
setHistory((prevHistory) => [...prevHistory, { change: '감소하기' }]);
}, []);
const isPrime = useMemo(() => {
console.log('isPrime 계산 실행');
const checkPrime = (num: number): boolean => {
if (num <= 1) return false;
if (num === 2) return true;
for (let i = 2; i <= Math.sqrt(num); i++) {
if (num % i === 0) return false;
}
return true;
};
return checkPrime(count);
}, [count]);
console.log('Counter 렌더링');
return (
<section>
<CounterOutput count={count} isPrime={isPrime} />
<div>
<IconButton onClick={handleIncrement} icon={<PlusIcon />} />
<IconButton onClick={handleDecrement} icon={<MinusIcon />} />
</div>
<CounterHistory history={history} />
</section>
);
});
export default Counter;
설명:
문제점:
개선된 방법: 키(Key)를 활용한 상태 재설정
// App.tsx
import React, { useState, useCallback } from 'react';
import Header from './Header';
import ConfigureCounter from './ConfigureCounter';
import Counter from './Counter';
const App: React.FC = () => {
const [chosenCount, setChosenCount] = useState<number>(0);
const handleSetCount = useCallback((newCount: number) => {
setChosenCount(newCount);
}, []);
console.log('App 렌더링');
return (
<div id="app">
<Header title="카운터 앱" />
<ConfigureCounter onSet={handleSetCount} />
{/* Counter 컴포넌트에 key를 추가하여 chosenCount가 변경될 때마다 새로운 인스턴스를 생성 */}
<Counter key={chosenCount} initialCount={chosenCount} />
</div>
);
};
export default App;
// Counter.tsx
import React, { useState, useCallback, useMemo } from 'react';
import IconButton from './IconButton';
import CounterOutput from './CounterOutput';
import CounterHistory from './CounterHistory';
import { ReactComponent as PlusIcon } from './icons/plus.svg';
import { ReactComponent as MinusIcon } from './icons/minus.svg';
interface CounterProps {
initialCount: number;
}
interface CounterChange {
change: string;
}
const Counter: React.FC<CounterProps> = React.memo(({ initialCount }) => {
const [count, setCount] = useState<number>(initialCount);
const [history, setHistory] = useState<CounterChange[]>([]);
const handleIncrement = useCallback(() => {
setCount((prevCount) => prevCount + 1);
setHistory((prevHistory) => [...prevHistory, { change: '증가하기' }]);
}, []);
const handleDecrement = useCallback(() => {
setCount((prevCount) => prevCount - 1);
setHistory((prevHistory) => [...prevHistory, { change: '감소하기' }]);
}, []);
const isPrime = useMemo(() => {
console.log('isPrime 계산 실행');
const checkPrime = (num: number): boolean => {
if (num <= 1) return false;
if (num === 2) return true;
for (let i = 2; i <= Math.sqrt(num); i++) {
if (num % i === 0) return false;
}
return true;
};
return checkPrime(count);
}, [count]);
console.log('Counter 렌더링');
return (
<section>
<CounterOutput count={count} isPrime={isPrime} />
<div>
<IconButton onClick={handleIncrement} icon={<PlusIcon />} />
<IconButton onClick={handleDecrement} icon={<MinusIcon />} />
</div>
<CounterHistory history={history} />
</section>
);
});
export default Counter;
설명:
장점:
React의 상태(State) 업데이트 스케줄링과 배칭(Batching)에 대한 이해
상태 업데이트의 비동기성
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // 아직 0이 출력됩니다.
};
위 예시에서 버튼을 클릭하면 count는 1로 업데이트되지만, console.log는 여전히 이전 값인 0을 출력합니다. 이는 상태 업데이트가 비동기적으로 처리되기 때문입니다.
const handleIncrement = () => {
setCount(count + 1);
setCount(count + 1);
};
위와 같이 동일한 상태 업데이트 함수를 연속으로 호출하면, 의도한 대로 두 번 증가하지 않고 한 번만 증가할 수 있습니다.
const handleIncrement = () => {
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
};
이 경우, prevCount는 각각의 업데이트 시점에서 최신 상태 값을 참조하므로, 두 번 클릭 시 count는 정확히 2씩 증가합니다.
useEffect(() => {
setCount(initialCount);
setHistory([]);
}, [initialCount]);
하지만 이 접근 방식은 컴포넌트가 두 번 렌더링되게 만들 수 있으며, 이는 성능 저하와 코드 복잡성을 초래할 수 있습니다.
// App.tsx
import React, { useState, useCallback } from 'react';
import Header from './Header';
import ConfigureCounter from './ConfigureCounter';
import Counter from './Counter';
const App: React.FC = () => {
const [chosenCount, setChosenCount] = useState<number>(0);
const handleSetCount = useCallback((newCount: number) => {
setChosenCount(newCount);
}, []);
console.log('App 렌더링');
return (
<div id="app">
<Header title="카운터 앱" />
<ConfigureCounter onSet={handleSetCount} />
{/* key 속성에 chosenCount를 추가하여 변경 시 컴포넌트 재생성 */}
<Counter key={chosenCount} initialCount={chosenCount} />
</div>
);
};
export default App;
위 예시에서 Counter 컴포넌트에 key={chosenCount}를 추가함으로써, chosenCount가 변경될 때마다 React는 기존 Counter 컴포넌트를 언마운트하고 새로운 인스턴스를 마운트합니다. 이로 인해 Counter의 상태가 초기화됩니다.
const handleMultipleUpdates = () => {
setCount(prev => prev + 1);
setAnotherState(prev => prev + 1);
// React는 이 두 상태 업데이트를 하나의 배치로 처리합니다.
};
이처럼 React는 여러 상태 업데이트를 효율적으로 처리하여 불필요한 렌더링을 방지합니다.
React 성능 최적화를 위한 Million.js 패키지 소개
Million.js란?
Million.js 설치 및 설정
개발 서버 종료 및 패키지 설치
먼저, 현재 실행 중인 개발 서버를 종료합니다.
그런 다음, 프로젝트 디렉토리에서 million 패키지를 설치합니다.
npm install million
Vite 설정 파일 수정
이번 예제에서는 Vite 기반의 프로젝트를 사용하고 있습니다.
Vite 설정 파일(vite.config.js 또는 vite.config.ts)을 열어 Million.js를 활성화하도록 수정합니다.
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { millionVite } from 'million/compiler';
// Million.js를 통합하기 위한 플러그인 추가
export default defineConfig({
plugins: [
react(),
millionVite()
],
});
설명:
특정 컴포넌트 제외 설정
일부 컴포넌트는 Million.js의 최적화에서 제외해야 할 수 있습니다.
예를 들어, 특정 아이콘 컴포넌트에서 문제가 발생할 경우, 해당 컴포넌트를 제외할 수 있습니다.
// IconComponent.tsx
// million-ignore
import React from 'react';
const IconComponent: React.FC = () => {
return <svg>/* SVG 내용 */</svg>;
};
export default IconComponent;
설명:
개발 서버 재시작 및 확인
모든 설정을 마친 후, 개발 서버를 다시 시작합니다.
npm run dev
개발 서버가 정상적으로 실행되고, 애플리케이션이 오류 없이 작동하는지 확인합니다. 성능 향상을 확인하기 위해 복잡한 UI를 가진 애플리케이션에서 Million.js를 적용해보는 것이 좋습니다.
Million.js의 성능 향상 효과
주의 사항:
Million.js의 작동 방식
React 클래스 기반 컴포넌트 이해하기
Error Boundaries
)에 어떻게 활용되는지에 대해 자세히 알아보겠습니다.클래스 기반 컴포넌트란?
함수형 컴포넌트 vs 클래스 기반 컴포넌트:
함수형 컴포넌트:
const MyComponent = () => {
return <div>Hello, World!</div>;
};
클래스 기반 컴포넌트:
import React from 'react';
class MyComponent extends React.Component {
render() {
return <div>Hello, World!</div>;
}
}
export default MyComponent;
클래스 기반 컴포넌트의 특징
클래스 기반 컴포넌트 정의하기
import React from 'react';
class MyComponent extends React.Component {
constructor(props) {
super(props);
// 초기 상태 설정
this.state = {
count: 0,
};
// 메서드 바인딩 (필요한 경우)
this.handleIncrementBinded = this.handleIncrement.bind(this);
}
// 이벤트 핸들러 메서드
handleIncrement() {
this.setState((prevState) => ({
count: prevState.count + 1,
}));
}
// 필수 render 메서드
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.handleIncrementBinded}>Increment</button>
</div>
);
}
}
export default MyComponent;
설명:
클래스 기반 컴포넌트의 라이프사이클 메서드
import React from 'react';
class LifecycleDemo extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
console.log('Constructor');
}
static getDerivedStateFromProps(props, state) {
console.log('getDerivedStateFromProps');
return null;
}
componentDidMount() {
console.log('componentDidMount');
// 예: 데이터 페칭
}
shouldComponentUpdate(nextProps, nextState) {
console.log('shouldComponentUpdate');
return true; // 업데이트 허용
}
getSnapshotBeforeUpdate(prevProps, prevState) {
console.log('getSnapshotBeforeUpdate');
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
console.log('componentDidUpdate');
}
componentWillUnmount() {
console.log('componentWillUnmount');
}
handleIncrement = () => {
this.setState((prevState) => ({
count: prevState.count + 1,
}));
};
render() {
console.log('Render');
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.handleIncrement}>Increment</button>
</div>
);
}
}
export default LifecycleDemo;
콘솔 로그 순서:
오류 경계(Error Boundaries)
오류 경계의 주요 메서드:
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트합니다.
return { hasError: true };
}
componentDidCatch(error, info) {
// 오류 리포팅 서비스에 오류를 기록할 수 있습니다.
console.error('ErrorBoundary caught an error', error, info);
}
render() {
if (this.state.hasError) {
// 폴백 UI를 커스터마이즈할 수 있습니다.
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
export default ErrorBoundary;
import React from 'react';
import ErrorBoundary from './ErrorBoundary';
import BuggyComponent from './BuggyComponent';
const App = () => (
<div>
<ErrorBoundary>
<BuggyComponent />
</ErrorBoundary>
</div>
);
export default App;
설명:
왜 클래스 기반 컴포넌트를 여전히 배워야 하는가?
하지만, 왜 함수형 컴포넌트가 선호되는가?
React 클래스 기반 컴포넌트 심화 이해
클래스 기반 컴포넌트의 정의와 구조
import React from 'react';
class MyComponent extends React.Component {
constructor(props) {
super(props);
// 초기 상태 설정
this.state = {
count: 0,
};
// 메서드 바인딩 (필요한 경우)
this.handleIncrementBinded = this.handleIncrement.bind(this);
}
// 이벤트 핸들러 메서드
handleIncrement() {
this.setState((prevState) => ({
count: prevState.count + 1,
}));
}
// 필수 render 메서드
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.handleIncrementBinded}>Increment</button>
</div>
);
}
}
export default MyComponent;
설명:
상태(State) 관리와 업데이트
import React from 'react';
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = {
count: props.initialCount || 0,
};
this.handleIncrement = this.handleIncrement.bind(this);
this.handleDecrement = this.handleDecrement.bind(this);
}
handleIncrement() {
this.setState((prevState) => ({
count: prevState.count + 1,
}));
}
handleDecrement() {
this.setState((prevState) => ({
count: prevState.count - 1,
}));
}
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.handleIncrement}>+</button>
<button onClick={this.handleDecrement}>-</button>
</div>
);
}
}
export default Counter;
설명:
라이프사이클 메서드(Lifecycle Methods)
주요 라이프사이클 메서드:
import React from 'react';
class LifecycleDemo extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
console.log('Constructor');
}
static getDerivedStateFromProps(props, state) {
console.log('getDerivedStateFromProps');
return null;
}
componentDidMount() {
console.log('componentDidMount');
// 예: 데이터 페칭
}
shouldComponentUpdate(nextProps, nextState) {
console.log('shouldComponentUpdate');
return true; // 업데이트 허용
}
getSnapshotBeforeUpdate(prevProps, prevState) {
console.log('getSnapshotBeforeUpdate');
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
console.log('componentDidUpdate');
}
componentWillUnmount() {
console.log('componentWillUnmount');
}
handleIncrement = () => {
this.setState((prevState) => ({
count: prevState.count + 1,
}));
};
render() {
console.log('Render');
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.handleIncrement}>Increment</button>
</div>
);
}
}
export default LifecycleDemo;
콘솔 로그 순서:
오류 경계(Error Boundaries)
오류 경계의 주요 메서드:
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트합니다.
return { hasError: true };
}
componentDidCatch(error, info) {
// 오류 리포팅 서비스에 오류를 기록할 수 있습니다.
console.error('ErrorBoundary caught an error', error, info);
}
render() {
if (this.state.hasError) {
// 폴백 UI를 커스터마이즈할 수 있습니다.
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
export default ErrorBoundary;
import React from 'react';
import ErrorBoundary from './ErrorBoundary';
import BuggyComponent from './BuggyComponent';
const App = () => (
<div>
<ErrorBoundary>
<BuggyComponent />
</ErrorBoundary>
</div>
);
export default App;
설명:
함수형 컴포넌트의 선호 이유
import React, { useState } from 'react';
const Counter = ({ initialCount }) => {
const [count, setCount] = useState(initialCount || 0);
const handleIncrement = () => {
setCount(prevCount => prevCount + 1);
};
const handleDecrement = () => {
setCount(prevCount => prevCount - 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleIncrement}>+</button>
<button onClick={handleDecrement}>-</button>
</div>
);
};
export default Counter;
// User.js (함수형 컴포넌트)
import React from 'react';
const User = (props) => {
return (
<li>
{props.name}
</li>
);
};
export default User;
// User.js (클래스 기반 컴포넌트)
import React, { Component } from 'react';
class User extends Component {
render() {
return (
<li>
{this.props.name}
</li>
);
}
}
export default User;
불필요한 생성자 제거
변경 전:
class User extends Component {
constructor(props) {
super(props);
// 초기화 작업이 필요 없는 경우
}
render() {
return (
<li>
{this.props.name}
</li>
);
}
}
class User extends Component {
render() {
return (
<li>
{this.props.name}
</li>
);
}
}
render 메서드 구현
this 키워드 사용
class User extends Component {
render() {
return (
<li>
{this.props.name}
</li>
);
}
}
React 클래스 기반 컴포넌트 변환 실습 계속
프로젝트 구조 확인
User 컴포넌트 클래스 기반으로 변환하기
기존 함수형 User 컴포넌트:
// User.js (함수형 컴포넌트)
import React from 'react';
const User = (props) => {
return (
<li>
{props.name}
</li>
);
};
export default User;
// User.js (클래스 기반 컴포넌트)
import React, { Component } from 'react';
class User extends Component {
render() {
return (
<li>
{this.props.name}
</li>
);
}
}
export default User;
변경 사항 설명:
Users 컴포넌트 업데이트
// Users.js (함수형 컴포넌트)
import React, { useState } from 'react';
import User from './User';
const Users = () => {
const [showUsers, setShowUsers] = useState(true);
const toggleUsersHandler = () => {
setShowUsers((prevShowUsers) => !prevShowUsers);
};
return (
<div>
<button onClick={toggleUsersHandler}>Toggle Users</button>
{showUsers && (
<ul>
<User name="John" />
<User name="Jane" />
<User name="Doe" />
</ul>
)}
</div>
);
};
export default Users;
설명:
클래스 컴포넌트에서 상태(state) 관리하기
함수형 Users 컴포넌트 변환 전:
// Users.js (함수형 컴포넌트)
import React, { useState } from 'react';
import User from './User';
const Users = () => {
const [showUsers, setShowUsers] = useState(true);
const toggleUsersHandler = () => {
setShowUsers((prevShowUsers) => !prevShowUsers);
};
return (
<div>
<button onClick={toggleUsersHandler}>Toggle Users</button>
{showUsers && (
<ul>
<User name="John" />
<User name="Jane" />
<User name="Doe" />
</ul>
)}
</div>
);
};
export default Users;
// Users.js (클래스 기반 컴포넌트)
import React, { Component } from 'react';
import User from './User';
class Users extends Component {
constructor(props) {
super(props);
// 초기 상태 설정
this.state = {
showUsers: true,
};
// 메서드 바인딩
this.toggleUsersHandler = this.toggleUsersHandler.bind(this);
}
toggleUsersHandler() {
this.setState((prevState) => ({
showUsers: !prevState.showUsers,
}));
}
render() {
return (
<div>
<button onClick={this.toggleUsersHandler}>Toggle Users</button>
{this.state.showUsers && (
<ul>
<User name="John" />
<User name="Jane" />
<User name="Doe" />
</ul>
)}
</div>
);
}
}
export default Users;
변경 사항 설명:
this 바인딩 문제 해결
생성자에서 메서드 바인딩:
가장 전통적인 방법으로, 생성자 내에서 메서드를 바인딩합니다.
constructor(props) {
super(props);
this.state = { showUsers: true };
this.toggleUsersHandler = this.toggleUsersHandler.bind(this);
}
장점:
단점:
클래스 필드 문법(Class Fields Syntax) 사용:
ES6 클래스 필드 문법을 사용하면, 바인딩을 보다 간결하게 처리할 수 있습니다.
class Users extends Component {
state = {
showUsers: true,
};
toggleUsersHandler = () => {
this.setState((prevState) => ({
showUsers: !prevState.showUsers,
}));
};
render() {
// 렌더링 로직
}
}
장점:
단점:
주의 사항:
전체 클래스 기반 Users 컴포넌트 예시
// Users.js (클래스 기반 컴포넌트 - 클래스 필드 문법 사용)
import React, { Component } from 'react';
import User from './User';
class Users extends Component {
state = {
showUsers: true,
};
toggleUsersHandler = () => {
this.setState((prevState) => ({
showUsers: !prevState.showUsers,
}));
};
render() {
return (
<div>
<button onClick={this.toggleUsersHandler}>Toggle Users</button>
{this.state.showUsers && (
<ul>
<User name="John" />
<User name="Jane" />
<User name="Doe" />
</ul>
)}
</div>
);
}
}
export default Users;
변경 사항 설명:
장점:
React 클래스 기반 컴포넌트에서 부작용(Effects) 관리하기
기존 UserFinder 함수형 컴포넌트 확인
// UserFinder.js (함수형 컴포넌트)
import React, { useState, useEffect } from 'react';
import Users from './Users';
const DUMMY_USERS = [
{ id: 'u1', name: 'John Doe' },
{ id: 'u2', name: 'Jane Doe' },
{ id: 'u3', name: 'Max Mustermann' },
{ id: 'u4', name: 'Mary Poppins' },
];
const UserFinder = () => {
const [enteredFilter, setEnteredFilter] = useState('');
const [filteredUsers, setFilteredUsers] = useState(DUMMY_USERS);
useEffect(() => {
const timer = setTimeout(() => {
if (enteredFilter === '') {
setFilteredUsers(DUMMY_USERS);
return;
}
setFilteredUsers(
DUMMY_USERS.filter((user) =>
user.name.toLowerCase().includes(enteredFilter.toLowerCase())
)
);
}, 500);
return () => {
clearTimeout(timer);
};
}, [enteredFilter]);
const filterChangeHandler = (event) => {
setEnteredFilter(event.target.value);
};
return (
<div>
<input type="text" onChange={filterChangeHandler} />
<Users users={filteredUsers} />
</div>
);
};
export default UserFinder;
설명:
UserFinder 컴포넌트 클래스 기반으로 변환하기
변환 단계 요약:
변환된 UserFinder 클래스 컴포넌트:
// UserFinder.js (클래스 기반 컴포넌트)
import React, { Component } from 'react';
import Users from './Users';
const DUMMY_USERS = [
{ id: 'u1', name: 'John Doe' },
{ id: 'u2', name: 'Jane Doe' },
{ id: 'u3', name: 'Max Mustermann' },
{ id: 'u4', name: 'Mary Poppins' },
];
class UserFinder extends Component {
constructor(props) {
super(props);
this.state = {
enteredFilter: '',
filteredUsers: DUMMY_USERS,
};
this.filterChangeHandler = this.filterChangeHandler.bind(this);
}
componentDidMount() {
console.log('UserFinder mounted');
// 초기 마운트 시 필요한 작업을 여기에 추가할 수 있습니다.
}
componentDidUpdate(prevProps, prevState) {
console.log('UserFinder updated');
if (prevState.enteredFilter !== this.state.enteredFilter) {
// Debounce 로직 구현
clearTimeout(this.timer);
this.timer = setTimeout(() => {
if (this.state.enteredFilter === '') {
this.setState({ filteredUsers: DUMMY_USERS });
return;
}
this.setState({
filteredUsers: DUMMY_USERS.filter((user) =>
user.name.toLowerCase().includes(this.state.enteredFilter.toLowerCase())
),
});
}, 500);
}
}
componentWillUnmount() {
console.log('UserFinder will unmount');
clearTimeout(this.timer);
}
filterChangeHandler(event) {
this.setState({ enteredFilter: event.target.value });
}
render() {
return (
<div>
<input type="text" onChange={this.filterChangeHandler} />
<Users users={this.state.filteredUsers} />
</div>
);
}
}
export default UserFinder;
변경 사항 설명:
주요 차이점 및 주의사항
전체 클래스 기반 UserFinder 컴포넌트 코드
// UserFinder.js (클래스 기반 컴포넌트)
import React, { Component } from 'react';
import Users from './Users';
const DUMMY_USERS = [
{ id: 'u1', name: 'John Doe' },
{ id: 'u2', name: 'Jane Doe' },
{ id: 'u3', name: 'Max Mustermann' },
{ id: 'u4', name: 'Mary Poppins' },
];
class UserFinder extends Component {
constructor(props) {
super(props);
this.state = {
enteredFilter: '',
filteredUsers: DUMMY_USERS,
};
this.filterChangeHandler = this.filterChangeHandler.bind(this);
}
componentDidMount() {
console.log('UserFinder mounted');
// 초기 마운트 시 필요한 작업을 여기에 추가할 수 있습니다.
}
componentDidUpdate(prevProps, prevState) {
console.log('UserFinder updated');
if (prevState.enteredFilter !== this.state.enteredFilter) {
// Debounce 로직 구현
clearTimeout(this.timer);
this.timer = setTimeout(() => {
if (this.state.enteredFilter === '') {
this.setState({ filteredUsers: DUMMY_USERS });
return;
}
this.setState({
filteredUsers: DUMMY_USERS.filter((user) =>
user.name.toLowerCase().includes(this.state.enteredFilter.toLowerCase())
),
});
}, 500);
}
}
componentWillUnmount() {
console.log('UserFinder will unmount');
clearTimeout(this.timer);
}
filterChangeHandler(event) {
this.setState({ enteredFilter: event.target.value });
}
render() {
return (
<div>
<input type="text" onChange={this.filterChangeHandler} />
<Users users={this.state.filteredUsers} />
</div>
);
}
}
export default UserFinder;
설명:
클래스 기반 컴포넌트의 부작용 관리
App 컴포넌트
├── UsersContext.Provider (value: users)
│ ├── UserFinder 클래스 컴포넌트
│ │ └── this.context.users
│ └── 다른 하위 컴포넌트들
└── 다른 컴포넌트들
// store/users-context.tsx
import React from 'react';
// 사용자 데이터 타입 정의
interface User {
id: string;
name: string;
}
// Context의 데이터 타입 정의
interface UsersContextType {
users: User[];
addUser: (user: User) => void;
}
// 초기 값 설정
const DUMMY_USERS: User[] = [
{ id: 'u1', name: 'John Doe' },
{ id: 'u2', name: 'Jane Smith' },
];
const UsersContext = React.createContext<UsersContextType>({
users: DUMMY_USERS,
addUser: () => {},
});
export default UsersContext;
// App.tsx
import React, { Component } from 'react';
import UsersContext from './store/users-context';
import UserFinder from './components/UserFinder';
interface AppState {
users: { id: string; name: string }[];
}
class App extends Component<{}, AppState> {
constructor(props: {}) {
super(props);
this.state = {
users: [
{ id: 'u1', name: 'John Doe' },
{ id: 'u2', name: 'Jane Smith' },
],
};
}
addUserHandler = (user: { id: string; name: string }) => {
this.setState((prevState) => ({
users: prevState.users.concat(user),
}));
};
render() {
return (
<UsersContext.Provider
value={{
users: this.state.users,
addUser: this.addUserHandler,
}}
>
<UserFinder />
{/* 다른 컴포넌트들 */}
</UsersContext.Provider>
);
}
}
export default App;
// components/UserFinder.tsx
import React, { Component } from 'react';
import UsersContext from '../store/users-context';
interface UserFinderState {
searchTerm: string;
}
class UserFinder extends Component<{}, UserFinderState> {
static contextType = UsersContext;
context!: React.ContextType<typeof UsersContext>;
constructor(props: {}) {
super(props);
this.state = {
searchTerm: '',
};
}
searchChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ searchTerm: event.target.value });
};
render() {
const { users } = this.context;
const filteredUsers = users.filter((user) =>
user.name.includes(this.state.searchTerm)
);
return (
<div>
<input type="text" onChange={this.searchChangeHandler} />
<ul>
{filteredUsers.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
}
export default UserFinder;
// Optional: Context의 타입 정의를 별도로 관리
// store/users-context.d.ts
import React from 'react';
export interface User {
id: string;
name: string;
}
export interface UsersContextType {
users: User[];
addUser: (user: User) => void;
}
declare const UsersContext: React.Context<UsersContextType>;
export default UsersContext;
이와 같이 React의 Context API를 클래스 컴포넌트와 함께 사용할 수 있으며, 타입스크립트를 통해 더욱 견고하고 명확한 코드를 작성할 수 있습니다. 다만, 클래스 컴포넌트에서는 Hook을 사용할 수 없기 때문에 여러 Context를 동시에 사용할 때 제약이 있을 수 있다는 점을 유의해야 합니다.
App 컴포넌트
├── UsersContext.Provider (value: users)
│ ├── UserFinder 클래스 컴포넌트
│ │ └── UsersContext.Consumer
│ │ └── this.props.context.users
│ └── 다른 하위 컴포넌트들
└── 다른 컴포넌트들
// store/users-context.tsx
import React from 'react';
// 사용자 데이터 타입 정의
export interface User {
id: string;
name: string;
}
// Context의 데이터 타입 정의
export interface UsersContextType {
users: User[];
addUser: (user: User) => void;
}
// 초기 값 설정
const DUMMY_USERS: User[] = [
{ id: 'u1', name: 'John Doe' },
{ id: 'u2', name: 'Jane Smith' },
];
const UsersContext = React.createContext<UsersContextType>({
users: DUMMY_USERS,
addUser: () => {},
});
export default UsersContext;
// App.tsx
import React, { Component } from 'react';
import UsersContext, { User } from './store/users-context';
import UserFinder from './components/UserFinder';
interface AppState {
users: User[];
}
class App extends Component<{}, AppState> {
constructor(props: {}) {
super(props);
this.state = {
users: [
{ id: 'u1', name: 'John Doe' },
{ id: 'u2', name: 'Jane Smith' },
],
};
}
addUserHandler = (user: User) => {
this.setState((prevState) => ({
users: prevState.users.concat(user),
}));
};
render() {
return (
<UsersContext.Provider
value={{
users: this.state.users,
addUser: this.addUserHandler,
}}
>
<UserFinder />
{/* 다른 컴포넌트들 */}
</UsersContext.Provider>
);
}
}
export default App;
// components/UserFinder.tsx
import React, { Component } from 'react';
import UsersContext, { User } from '../store/users-context';
interface UserFinderState {
searchTerm: string;
}
class UserFinder extends Component<{}, UserFinderState> {
constructor(props: {}) {
super(props);
this.state = {
searchTerm: '',
};
}
searchChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ searchTerm: event.target.value });
};
render() {
return (
<UsersContext.Consumer>
{(context) => {
const filteredUsers = context.users.filter((user: User) =>
user.name.toLowerCase().includes(this.state.searchTerm.toLowerCase())
);
return (
<div>
<input type="text" onChange={this.searchChangeHandler} />
<ul>
{filteredUsers.map((user: User) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}}
</UsersContext.Consumer>
);
}
}
export default UserFinder;
// store/users-context.d.ts
// Optional: Context의 타입 정의를 별도로 관리
import React from 'react';
export interface User {
id: string;
name: string;
}
export interface UsersContextType {
users: User[];
addUser: (user: User) => void;
}
declare const UsersContext: React.Context<UsersContextType>;
export default UsersContext;
<FirstContext.Consumer>
{(firstContext) => (
<SecondContext.Consumer>
{(secondContext) => (
// 컴포넌트 로직
)}
</SecondContext.Consumer>
)}
</FirstContext.Consumer>
오류 경계(Error Boundary)란?
오류 경계의 동작 방식
오류 경계의 구현 조건
오류 경계의 제한 사항
오류 경계의 사용 사례
App 컴포넌트
├── ErrorBoundary
│ ├── UserFinder 컴포넌트
│ │ └── Users 컴포넌트 (오류 발생 가능)
│ └── 다른 하위 컴포넌트들
└── 다른 컴포넌트들
// components/ErrorBoundary.tsx
import React, { Component, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(_: Error): ErrorBoundaryState {
// 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// 에러 리포팅 서비스에 에러를 기록할 수도 있습니다.
console.error('Uncaught error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
export default ErrorBoundary;
// 사용자 목록을 렌더링하며, 특정 조건에서 의도적으로 오류를 발생시키는 컴포넌트입니다.
// components/Users.tsx
import React, { Component } from 'react';
interface UsersProps {
users: { id: string; name: string }[];
}
class Users extends Component<UsersProps> {
componentDidUpdate(prevProps: UsersProps) {
if (this.props.users.length === 0) {
throw new Error('No users available!');
}
}
render() {
return (
<ul>
{this.props.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
}
export default Users;
// 사용자를 검색하고, 검색 결과에 따라 Users 컴포넌트를 렌더링하는 컴포넌트입니다.
// components/UserFinder.tsx
import React, { Component } from 'react';
import Users from './Users';
interface UserFinderState {
searchTerm: string;
users: { id: string; name: string }[];
}
class UserFinder extends Component<{}, UserFinderState> {
constructor(props: {}) {
super(props);
this.state = {
searchTerm: '',
users: [
{ id: 'u1', name: 'John Doe' },
{ id: 'u2', name: 'Jane Smith' },
],
};
}
searchChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ searchTerm: event.target.value });
};
render() {
const filteredUsers = this.state.users.filter(user =>
user.name.toLowerCase().includes(this.state.searchTerm.toLowerCase())
);
return (
<div>
<input type="text" onChange={this.searchChangeHandler} />
<Users users={filteredUsers} />
</div>
);
}
}
export default UserFinder;
// 애플리케이션의 최상위 컴포넌트로, ErrorBoundary를 사용하여 UserFinder 컴포넌트를 감쌉니다.
// App.tsx
import React, { Component } from 'react';
import UserFinder from './components/UserFinder';
import ErrorBoundary from './components/ErrorBoundary';
interface AppState {
// 필요 시 상태 정의
}
class App extends Component<{}, AppState> {
render() {
return (
<ErrorBoundary>
<UserFinder />
{/* 다른 컴포넌트들 */}
</ErrorBoundary>
);
}
}
export default App;
ErrorBoundary 컴포넌트
Users 컴포넌트
UserFinder 컴포넌트
App 컴포넌트
타입스크립트의 장점
추가적인 고려사항
오류 경계의 적용 범위
오류 경계의 재사용성
오류 로깅 및 분석
오류 경계와 함수형 컴포넌트
이와 같이 React의 오류 경계(Error Boundary)를 활용하면, 애플리케이션 내에서 발생할 수 있는 예기치 않은 오류를 효과적으로 관리하고 사용자 경험을 향상시킬 수 있습니다. 클래스 컴포넌트로 구현되어야 한다는 제약이 있지만, 올바르게 활용하면 애플리케이션의 안정성과 신뢰성을 크게 높일 수 있습니다.
React 애플리케이션에서 서버 및 데이터베이스 연동의 필요성
요약
React 애플리케이션과 백엔드 서버 및 데이터베이스 연동의 필요성
요약
프로젝트 개요
백엔드 개요
학습 및 주의점
import React, { useState, useEffect } from 'react';
interface Place {
id: string;
name: string;
}
const PlacePicker: React.FC = () => {
const [places, setPlaces] = useState<Place[]>([]);
const [selectedPlaces, setSelectedPlaces] = useState<string[]>([]);
// Fetch available places
useEffect(() => {
fetch('http://localhost:5000/places')
.then((response) => response.json())
.then((data) => setPlaces(data))
.catch((error) => console.error('Error fetching places:', error));
}, []);
// Toggle place selection
const togglePlaceSelection = (placeId: string) => {
setSelectedPlaces((prev) =>
prev.includes(placeId) ? prev.filter((id) => id !== placeId) : [...prev, placeId]
);
};
// Save user selections
const saveSelection = async () => {
try {
const response = await fetch('http://localhost:5000/user-places', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ places: selectedPlaces }),
});
if (!response.ok) {
throw new Error('Failed to save places');
}
console.log('Selection saved successfully');
} catch (error) {
console.error('Error saving selection:', error);
}
};
return (
<div>
<h1>Place Picker</h1>
<ul>
{places.map((place) => (
<li key={place.id}>
<label>
<input
type="checkbox"
checked={selectedPlaces.includes(place.id)}
onChange={() => togglePlaceSelection(place.id)}
/>
{place.name}
</label>
</li>
))}
</ul>
<button onClick={saveSelection}>Save Selection</button>
</div>
);
};
export default PlacePicker;
import express from 'express';
import bodyParser from 'body-parser';
const app = express();
const PORT = 5000;
// Middleware
app.use(bodyParser.json());
// Mock data
let places = [
{ id: '1', name: 'Place A' },
{ id: '2', name: 'Place B' },
{ id: '3', name: 'Place C' },
];
let userPlaces: string[] = [];
// Routes
app.get('/places', (req, res) => {
res.json(places);
});
app.get('/user-places', (req, res) => {
res.json(userPlaces);
});
app.post('/user-places', (req, res) => {
const { places: selectedPlaces } = req.body;
if (!Array.isArray(selectedPlaces)) {
return res.status(400).json({ error: 'Invalid data format' });
}
userPlaces = selectedPlaces;
res.status(200).json({ message: 'User places updated' });
});
// Start server
app.listen(PORT, () => {
console.log(`Backend running on http://localhost:${PORT}`);
});
주의사항
데이터 처리 방식
import React, { useState, useEffect } from 'react';
// 장소 데이터 타입 정의
interface Place {
id: string;
name: string;
image: string;
}
const AvailablePlaces: React.FC = () => {
const [places, setPlaces] = useState<Place[]>([]); // 초기 상태는 빈 배열
// 백엔드에서 장소 데이터 가져오기
useEffect(() => {
const fetchPlaces = async () => {
try {
const response = await fetch('http://localhost:5000/places');
if (!response.ok) {
throw new Error('Failed to fetch places');
}
const data: Place[] = await response.json();
setPlaces(data); // 데이터 상태 업데이트
} catch (error) {
console.error('Error fetching places:', error);
}
};
fetchPlaces();
}, []); // 의존성 배열이 비어 있으므로 컴포넌트가 처음 렌더링될 때만 실행
if (places.length === 0) {
return <p>Loading places...</p>;
}
return (
<div>
<h2>Available Places</h2>
<ul>
{places.map((place) => (
<li key={place.id}>
<img src={place.image} alt={place.name} width="100" />
<p>{place.name}</p>
</li>
))}
</ul>
</div>
);
};
export default AvailablePlaces;
import express from 'express';
const app = express();
const PORT = 5000;
// 장소 데이터 (임시)
const places = [
{ id: '1', name: 'Central Park', image: 'https://via.placeholder.com/100' },
{ id: '2', name: 'Eiffel Tower', image: 'https://via.placeholder.com/100' },
{ id: '3', name: 'Great Wall of China', image: 'https://via.placeholder.com/100' },
];
// CORS 설정 (프론트엔드와 통신 허용)
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
next();
});
// GET /places 엔드포인트
app.get('/places', (req, res) => {
res.json(places);
});
// 서버 실행
app.listen(PORT, () => {
console.log(`Backend running on http://localhost:${PORT}`);
});
설명
프로세스 실행:
Fetch 함수와 HTTP 요청
React 컴포넌트 내에서의 Fetch 문제
JSON 데이터와 처리
import React, { useState, useEffect } from 'react';
// 장소 데이터 타입 정의
interface Place {
id: string;
name: string;
image: string;
}
const AvailablePlaces: React.FC = () => {
const [places, setPlaces] = useState<Place[]>([]); // 초기 상태: 빈 배열
const [isLoading, setIsLoading] = useState<boolean>(true); // 로딩 상태 관리
const [error, setError] = useState<string | null>(null); // 에러 메시지 관리
// 백엔드에서 데이터 fetch
useEffect(() => {
const fetchPlaces = async () => {
try {
const response = await fetch('http://localhost:3000/places');
if (!response.ok) {
throw new Error('Failed to fetch places');
}
const data: Place[] = await response.json();
setPlaces(data); // 상태 업데이트
} catch (err: any) {
setError(err.message || 'Something went wrong');
} finally {
setIsLoading(false); // 로딩 상태 해제
}
};
fetchPlaces();
}, []); // 의존성 배열이 비어 있으므로 컴포넌트가 처음 렌더링될 때만 실행
if (isLoading) {
return <p>Loading...</p>;
}
if (error) {
return <p>Error: {error}</p>;
}
return (
<div>
<h2>Available Places</h2>
<ul>
{places.map((place) => (
<li key={place.id}>
<img src={place.image} alt={place.name} width="100" />
<p>{place.name}</p>
</li>
))}
</ul>
</div>
);
};
export default AvailablePlaces;
useEffect의 역할:
side effect
)를 처리.비동기 데이터 처리:
상태 관리:
import React, { useState, useEffect } from 'react';
// 장소 데이터 타입 정의
interface Place {
id: string;
name: string;
image: string; // 이미지 파일 이름
}
const AvailablePlaces: React.FC = () => {
const [places, setPlaces] = useState<Place[]>([]); // 초기 상태: 빈 배열
const [isLoading, setIsLoading] = useState<boolean>(true); // 로딩 상태 관리
const [error, setError] = useState<string | null>(null); // 에러 메시지 관리
// 데이터 fetch
useEffect(() => {
const fetchPlaces = async () => {
try {
const response = await fetch('http://localhost:3000/places');
if (!response.ok) {
throw new Error('Failed to fetch places');
}
const data: Place[] = await response.json();
setPlaces(data); // 상태 업데이트
} catch (err: any) {
setError(err.message || 'Something went wrong');
} finally {
setIsLoading(false); // 로딩 상태 해제
}
};
fetchPlaces();
}, []); // 빈 배열로 설정해 무한 루프 방지
// 로딩 상태
if (isLoading) {
return <p>Loading...</p>;
}
// 에러 처리
if (error) {
return <p>Error: {error}</p>;
}
return (
<div>
<h2>Available Places</h2>
<ul>
{places.map((place) => (
<li key={place.id}>
{/* 이미지 경로를 백엔드 URL과 결합 */}
<img
src={`http://localhost:3000/images/${place.image}`}
alt={place.name}
width="100"
/>
<p>{place.name}</p>
</li>
))}
</ul>
</div>
);
};
export default AvailablePlaces;
import express from 'express';
import path from 'path';
const app = express();
const PORT = 3000;
// 장소 데이터 (임시)
const places = [
{ id: '1', name: 'Central Park', image: 'central-park.jpg' },
{ id: '2', name: 'Eiffel Tower', image: 'eiffel-tower.jpg' },
{ id: '3', name: 'Great Wall of China', image: 'great-wall.jpg' },
];
// 정적 파일 서빙 설정
app.use('/images', express.static(path.join(__dirname, 'images')));
// API 엔드포인트
app.get('/places', (req, res) => {
res.json(places);
});
// 서버 실행
app.listen(PORT, () => {
console.log(`Backend running on http://localhost:${PORT}`);
});
이미지 파일 요청 처리
이미지 폴더 구조
backend/
├── app.js
├── images/
│ ├── central-park.jpg
│ ├── eiffel-tower.jpg
│ ├── great-wall.jpg
import React, { useState, useEffect } from 'react';
// 장소 데이터 타입 정의
interface Place {
id: string;
name: string;
image: string;
}
const AvailablePlaces: React.FC = () => {
const [places, setPlaces] = useState<Place[]>([]); // 초기 상태: 빈 배열
const [isLoading, setIsLoading] = useState<boolean>(true); // 로딩 상태 관리
const [error, setError] = useState<string | null>(null); // 에러 메시지 관리
// 데이터 fetch
useEffect(() => {
const fetchPlaces = async () => {
try {
const response = await fetch('http://localhost:3000/places');
if (!response.ok) {
throw new Error('Failed to fetch places');
}
const data: Place[] = await response.json();
setPlaces(data); // 상태 업데이트
} catch (err: any) {
setError(err.message || 'Something went wrong');
} finally {
setIsLoading(false); // 로딩 상태 해제
}
};
// 정의한 비동기 함수 호출
fetchPlaces();
}, []); // 빈 배열로 설정해 무한 루프 방지
// 로딩 상태
if (isLoading) {
return <p>Loading...</p>;
}
// 에러 처리
if (error) {
return <p>Error: {error}</p>;
}
return (
<div>
<h2>Available Places</h2>
<ul>
{places.map((place) => (
<li key={place.id}>
{/* 이미지 경로를 백엔드 URL과 결합 */}
<img
src={`http://localhost:3000/images/${place.image}`}
alt={place.name}
width="100"
/>
<p>{place.name}</p>
</li>
))}
</ul>
</div>
);
};
export default AvailablePlaces;
변경 사항 요약
이 방식의 장점
import React, { useState, useEffect } from 'react';
// 장소 데이터 타입 정의
interface Place {
id: string;
name: string;
image: string;
}
const AvailablePlaces: React.FC = () => {
const [places, setPlaces] = useState<Place[]>([]); // 초기 상태: 빈 배열
const [isFetching, setIsFetching] = useState<boolean>(false); // 로딩 상태 관리
const [error, setError] = useState<string | null>(null); // 에러 메시지 관리
// 데이터 fetch
useEffect(() => {
const fetchPlaces = async () => {
setIsFetching(true); // 로딩 상태 시작
try {
const response = await fetch('http://localhost:3000/places');
if (!response.ok) {
throw new Error('Failed to fetch places');
}
const data: Place[] = await response.json();
setPlaces(data); // 상태 업데이트
} catch (err: any) {
setError(err.message || 'Something went wrong');
} finally {
setIsFetching(false); // 로딩 상태 종료
}
};
fetchPlaces();
}, []); // 빈 배열로 설정해 무한 루프 방지
return (
<div>
<h2>Available Places</h2>
{/* 로딩 상태 표시 */}
{isFetching && <p className="fallback-text">데이터를 불러오는 중입니다...</p>}
{/* 에러 처리 */}
{error && !isFetching && <p className="fallback-text">Error: {error}</p>}
{/* 데이터 없는 경우 대체 메시지 */}
{!isFetching && places.length === 0 && !error && (
<p className="fallback-text">표시할 장소가 없습니다.</p>
)}
{/* 장소 목록 출력 */}
{places.length > 0 && (
<ul>
{places.map((place) => (
<li key={place.id}>
<img
src={`http://localhost:3000/images/${place.image}`}
alt={place.name}
width="100"
/>
<p>{place.name}</p>
</li>
))}
</ul>
)}
</div>
);
};
export default AvailablePlaces;
주요 개선 사항
데이터 흐름 요약
네트워크 스로틀링 테스트
결과
import React, { useState, useEffect } from 'react';
import Error from './components/Error'; // Error 컴포넌트 추가
// 장소 데이터 타입 정의
interface Place {
id: string;
name: string;
image: string;
}
const AvailablePlaces: React.FC = () => {
const [places, setPlaces] = useState<Place[]>([]); // 데이터 상태
const [isFetching, setIsFetching] = useState<boolean>(false); // 로딩 상태
const [error, setError] = useState<string | null>(null); // 에러 상태
// 데이터 fetch
useEffect(() => {
const fetchPlaces = async () => {
setIsFetching(true); // 로딩 시작
setError(null); // 에러 초기화
try {
const response = await fetch('http://localhost:3000/placessss'); // 유효하지 않은 엔드포인트로 테스트
if (!response.ok) {
throw new Error('장소를 불러오는 데 실패했습니다. 다시 시도해주세요.');
}
const data: Place[] = await response.json();
setPlaces(data); // 데이터 상태 업데이트
} catch (err: any) {
setError(err.message || '장소를 불러오는 중 오류가 발생했습니다.');
} finally {
setIsFetching(false); // 로딩 종료
}
};
fetchPlaces();
}, []);
// 에러 상태 처리
if (error) {
return <Error title="에러가 발생했습니다!" message={error} />;
}
// 로딩 상태 처리
if (isFetching) {
return <p className="fallback-text">데이터를 불러오는 중입니다...</p>;
}
// 데이터 없음 처리
if (!isFetching && places.length === 0) {
return <p className="fallback-text">표시할 장소가 없습니다.</p>;
}
// 데이터 출력
return (
<div>
<h2>Available Places</h2>
<ul>
{places.map((place) => (
<li key={place.id}>
<img
src={`http://localhost:3000/images/${place.image}`}
alt={place.name}
width="100"
/>
<p>{place.name}</p>
</li>
))}
</ul>
</div>
);
};
export default AvailablePlaces;
import React from 'react';
interface ErrorProps {
title: string;
message: string;
}
const Error: React.FC<ErrorProps> = ({ title, message }) => {
return (
<div className="error-container">
<h2>{title}</h2>
<p>{message}</p>
</div>
);
};
export default Error;
import React, { useState, useEffect } from 'react';
import Error from './components/Error'; // 에러 컴포넌트
import { sortPlacesByDistance } from './loc'; // 장소 분류 함수
// 장소 데이터 타입 정의
interface Place {
id: string;
name: string;
image: string;
latitude: number; // 장소 위도
longitude: number; // 장소 경도
}
const AvailablePlaces: React.FC = () => {
const [places, setPlaces] = useState<Place[]>([]); // 데이터 상태
const [isFetching, setIsFetching] = useState<boolean>(false); // 로딩 상태
const [error, setError] = useState<string | null>(null); // 에러 상태
useEffect(() => {
const fetchPlaces = async () => {
setIsFetching(true); // 로딩 시작
setError(null); // 에러 초기화
try {
const response = await fetch('http://localhost:3000/places');
if (!response.ok) {
throw new Error('장소를 불러오는 데 실패했습니다. 다시 시도해주세요.');
}
const resData: { places: Place[] } = await response.json();
// 사용자 위치 가져오기
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
// 장소 분류
const sortedPlaces = sortPlacesByDistance(resData.places, latitude, longitude);
setPlaces(sortedPlaces); // 데이터 상태 업데이트
setIsFetching(false); // 로딩 종료
},
(geoError) => {
setError('사용자 위치를 가져오는 데 실패했습니다. 위치 접근 권한을 확인해주세요.');
setIsFetching(false); // 로딩 종료
}
);
} catch (err: any) {
setError(err.message || '데이터를 가져오는 중 오류가 발생했습니다.');
setIsFetching(false); // 로딩 종료
}
};
fetchPlaces();
}, []);
if (error) {
return <Error title="에러가 발생했습니다!" message={error} />;
}
if (isFetching) {
return <p className="fallback-text">데이터를 불러오는 중입니다...</p>;
}
if (places.length === 0) {
return <p className="fallback-text">표시할 장소가 없습니다.</p>;
}
return (
<div>
<h2>Available Places</h2>
<ul>
{places.map((place) => (
<li key={place.id}>
<img
src={`http://localhost:3000/images/${place.image}`}
alt={place.name}
width="100"
/>
<p>{place.name}</p>
</li>
))}
</ul>
</div>
);
};
export default AvailablePlaces;
주요 변경 사항
작동 흐름 요약
export async function fetchAvailablePlaces() {
const response = await fetch('http://localhost:3000/places');
if (!response.ok) {
throw new Error('장소를 불러오는 데 실패했습니다. 다시 시도해주세요.');
}
const resData = await response.json();
return resData.places; // 장소 데이터 반환
}
import React, { useState, useEffect } from 'react';
import Error from './components/Error'; // 에러 컴포넌트
import { sortPlacesByDistance } from './loc'; // 장소 분류 함수
import { fetchAvailablePlaces } from './http'; // 헬퍼 함수 import
// 장소 데이터 타입 정의
interface Place {
id: string;
name: string;
image: string;
latitude: number;
longitude: number;
}
const AvailablePlaces: React.FC = () => {
const [places, setPlaces] = useState<Place[]>([]); // 데이터 상태
const [isFetching, setIsFetching] = useState<boolean>(false); // 로딩 상태
const [error, setError] = useState<string | null>(null); // 에러 상태
useEffect(() => {
const fetchData = async () => {
setIsFetching(true); // 로딩 시작
setError(null); // 에러 초기화
try {
// 헬퍼 함수 호출
const fetchedPlaces = await fetchAvailablePlaces();
// 사용자 위치 가져오기
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
// 장소 분류
const sortedPlaces = sortPlacesByDistance(fetchedPlaces, latitude, longitude);
setPlaces(sortedPlaces); // 데이터 상태 업데이트
setIsFetching(false); // 로딩 종료
},
(geoError) => {
setError('사용자 위치를 가져오는 데 실패했습니다. 위치 접근 권한을 확인해주세요.');
setIsFetching(false); // 로딩 종료
}
);
} catch (err: any) {
setError(err.message || '데이터를 가져오는 중 오류가 발생했습니다.');
setIsFetching(false); // 로딩 종료
}
};
fetchData();
}, []);
if (error) {
return <Error title="에러가 발생했습니다!" message={error} />;
}
if (isFetching) {
return <p className="fallback-text">데이터를 불러오는 중입니다...</p>;
}
if (places.length === 0) {
return <p className="fallback-text">표시할 장소가 없습니다.</p>;
}
return (
<div>
<h2>Available Places</h2>
<ul>
{places.map((place) => (
<li key={place.id}>
<img
src={`http://localhost:3000/images/${place.image}`}
alt={place.name}
width="100"
/>
<p>{place.name}</p>
</li>
))}
</ul>
</div>
);
};
export default AvailablePlaces;
변경 사항 요약
결과
// 사용자 선택 장소 업데이트
export async function updateUserPlaces(places) {
const response = await fetch('http://localhost:3000/user-places', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ places }), // places 키로 객체를 감싸서 전달
});
if (!response.ok) {
throw new Error('사용자 데이터 업데이트에 실패했습니다.');
}
const resData = await response.json();
return resData; // { message: "Success" } 형태의 응답 반환
}
import React, { useState, useEffect } from 'react';
import { updateUserPlaces, fetchAvailablePlaces } from './http'; // 헬퍼 함수
import AvailablePlaces from './AvailablePlaces';
const App = () => {
const [userPlaces, setUserPlaces] = useState([]); // 사용자 선택 장소
const [error, setError] = useState(null);
// 사용자 장소 업데이트
const handleSelectPlace = async (selectedPlace) => {
try {
// 상태 업데이트
setUserPlaces((prevUserPlaces) => {
if (prevUserPlaces.some((place) => place.id === selectedPlace.id)) {
return prevUserPlaces; // 이미 선택된 장소는 무시
}
return [...prevUserPlaces, selectedPlace];
});
// 백엔드로 업데이트 요청
await updateUserPlaces([...userPlaces, selectedPlace]);
} catch (err) {
setError('장소를 업데이트하는 중 문제가 발생했습니다.');
console.error(err);
}
};
useEffect(() => {
const fetchUserPlaces = async () => {
try {
const response = await fetch('http://localhost:3000/user-places');
if (!response.ok) {
throw new Error('사용자 데이터를 불러오는 데 실패했습니다.');
}
const resData = await response.json();
setUserPlaces(resData.places || []);
} catch (err) {
setError('사용자 데이터를 불러오는 중 문제가 발생했습니다.');
}
};
fetchUserPlaces();
}, []);
return (
<div>
<h1>사용자 선택 장소</h1>
{error && <p>{error}</p>}
<AvailablePlaces onSelectPlace={handleSelectPlace} />
<h2>선택한 장소</h2>
<ul>
{userPlaces.map((place) => (
<li key={place.id}>{place.name}</li>
))}
</ul>
</div>
);
};
export default App;
const AvailablePlaces = ({ onSelectPlace }) => {
const [places, setPlaces] = useState([]);
const [isFetching, setIsFetching] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
setIsFetching(true);
setError(null);
try {
const fetchedPlaces = await fetchAvailablePlaces();
setPlaces(fetchedPlaces);
} catch (err) {
setError('장소 데이터를 불러오는 중 문제가 발생했습니다.');
} finally {
setIsFetching(false);
}
};
fetchData();
}, []);
if (isFetching) return <p>데이터를 불러오는 중입니다...</p>;
if (error) return <p>{error}</p>;
return (
<ul>
{places.map((place) => (
<li key={place.id} onClick={() => onSelectPlace(place)}>
{place.name}
</li>
))}
</ul>
);
};
export default AvailablePlaces;
작동 흐름
장점
import React, { useState, useEffect } from 'react';
import { updateUserPlaces, fetchAvailablePlaces } from './http'; // 헬퍼 함수
import AvailablePlaces from './AvailablePlaces';
import Modal from './components/Modal'; // 모달 컴포넌트
import Error from './components/Error'; // 에러 컴포넌트
const App = () => {
const [userPlaces, setUserPlaces] = useState([]); // 사용자 선택 장소
const [errorUpdatingPlaces, setErrorUpdatingPlaces] = useState(null); // 업데이트 에러 상태
const [error, setError] = useState(null);
// 사용자 장소 업데이트
const handleSelectPlace = async (selectedPlace) => {
const previousPlaces = [...userPlaces]; // 이전 상태 저장
const updatedPlaces = [...userPlaces, selectedPlace];
setUserPlaces(updatedPlaces); // 낙관적 업데이트
try {
// 백엔드로 업데이트 요청
await updateUserPlaces(updatedPlaces);
} catch (err) {
setUserPlaces(previousPlaces); // 이전 상태로 복구
setErrorUpdatingPlaces({
message: err.message || '장소 업데이트에 실패했습니다.',
});
}
};
// 에러 닫기 핸들러
const handleError = () => {
setErrorUpdatingPlaces(null); // 에러 상태 초기화
};
useEffect(() => {
const fetchUserPlaces = async () => {
try {
const response = await fetch('http://localhost:3000/user-places');
if (!response.ok) {
throw new Error('사용자 데이터를 불러오는 데 실패했습니다.');
}
const resData = await response.json();
setUserPlaces(resData.places || []);
} catch (err) {
setError('사용자 데이터를 불러오는 중 문제가 발생했습니다.');
}
};
fetchUserPlaces();
}, []);
return (
<div>
<h1>사용자 선택 장소</h1>
{error && <p>{error}</p>}
<AvailablePlaces onSelectPlace={handleSelectPlace} />
<h2>선택한 장소</h2>
<ul>
{userPlaces.map((place) => (
<li key={place.id}>{place.name}</li>
))}
</ul>
{/* 에러 모달 */}
{errorUpdatingPlaces && (
<Modal onClose={handleError}>
<Error
title="에러가 발생했습니다!"
message={errorUpdatingPlaces.message}
onConfirm={handleError}
/>
</Modal>
)}
</div>
);
};
export default App;
import React from 'react';
const Modal = ({ children, onClose }) => {
return (
<div className="modal">
<div className="modal-content">
{children}
<button onClick={onClose}>닫기</button>
</div>
</div>
);
};
export default Modal;
import React from 'react';
const Error = ({ title, message, onConfirm }) => {
return (
<div className="error-container">
<h2>{title}</h2>
<p>{message}</p>
<button onClick={onConfirm}>확인</button>
</div>
);
};
export default Error;
import React, { useState, useEffect, useCallback } from 'react';
import { updateUserPlaces } from './http'; // 헬퍼 함수
import AvailablePlaces from './AvailablePlaces';
import Modal from './components/Modal'; // 모달 컴포넌트
import Error from './components/Error'; // 에러 컴포넌트
const App = () => {
const [userPlaces, setUserPlaces] = useState([]); // 사용자 선택 장소
const [errorUpdatingPlaces, setErrorUpdatingPlaces] = useState(null); // 업데이트 에러 상태
const [error, setError] = useState(null); // 데이터 fetch 에러 상태
// 장소 제거 핸들러
const handleRemovePlace = useCallback(
async (placeId) => {
const previousPlaces = [...userPlaces]; // 이전 상태 저장
const updatedPlaces = userPlaces.filter((place) => place.id !== placeId); // 제거 후 상태
setUserPlaces(updatedPlaces); // 낙관적 업데이트
try {
await updateUserPlaces(updatedPlaces); // 백엔드 동기화
} catch (err) {
setUserPlaces(previousPlaces); // 이전 상태로 복구
setErrorUpdatingPlaces({
message: err.message || '장소를 삭제하는 데 실패했습니다.',
});
}
},
[userPlaces] // userPlaces 상태가 변경될 때 함수 재생성
);
// 에러 닫기 핸들러
const handleError = () => {
setErrorUpdatingPlaces(null); // 에러 상태 초기화
};
useEffect(() => {
// 초기 로드 시 사용자 장소 불러오기
const fetchUserPlaces = async () => {
try {
const response = await fetch('http://localhost:3000/user-places');
if (!response.ok) {
throw new Error('사용자 데이터를 불러오는 데 실패했습니다.');
}
const resData = await response.json();
setUserPlaces(resData.places || []);
} catch (err) {
setError('사용자 데이터를 불러오는 중 문제가 발생했습니다.');
}
};
fetchUserPlaces();
}, []);
return (
<div>
<h1>사용자 선택 장소</h1>
{error && <p>{error}</p>}
<AvailablePlaces onSelectPlace={(place) => handleSelectPlace(place)} />
<h2>선택한 장소</h2>
<ul>
{userPlaces.map((place) => (
<li key={place.id} onClick={() => handleRemovePlace(place.id)}>
{place.name}
</li>
))}
</ul>
{/* 에러 모달 */}
{errorUpdatingPlaces && (
<Modal onClose={handleError}>
<Error
title="에러가 발생했습니다!"
message={errorUpdatingPlaces.message}
onConfirm={handleError}
/>
</Modal>
)}
</div>
);
};
export default App;
// 사용자 선택 장소 업데이트
export async function updateUserPlaces(places) {
const response = await fetch('http://localhost:3000/user-places', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ places }), // places 키로 객체를 감싸서 전달
});
if (!response.ok) {
throw new Error('사용자 데이터 업데이트에 실패했습니다.');
}
return await response.json(); // { message: "Success" } 형태의 응답 반환
}
작동 흐름 요약
장점
import React, { useState, useEffect } from 'react';
import { fetchUserPlaces, updateUserPlaces } from './http'; // 헬퍼 함수
import AvailablePlaces from './AvailablePlaces'; // 장소 선택 컴포넌트
import Modal from './components/Modal'; // 모달 컴포넌트
import Error from './components/Error'; // 에러 컴포넌트
const App = () => {
const [userPlaces, setUserPlaces] = useState([]); // 사용자 장소
const [isFetching, setIsFetching] = useState(false); // 로딩 상태
const [error, setError] = useState(null); // 에러 상태
const [errorUpdatingPlaces, setErrorUpdatingPlaces] = useState(null); // 업데이트 에러 상태
// 사용자 장소 로드
useEffect(() => {
const fetchPlaces = async () => {
setIsFetching(true);
setError(null);
try {
const places = await fetchUserPlaces(); // 헬퍼 함수로 장소 가져오기
setUserPlaces(places);
} catch (err) {
setError({
message: err.message || '사용자 장소를 불러오는 데 실패했습니다.',
});
} finally {
setIsFetching(false);
}
};
fetchPlaces();
}, []);
// 장소 추가
const handleSelectPlace = async (selectedPlace) => {
const previousPlaces = [...userPlaces];
const updatedPlaces = [...userPlaces, selectedPlace];
setUserPlaces(updatedPlaces); // 낙관적 업데이트
try {
await updateUserPlaces(updatedPlaces); // 백엔드 업데이트
} catch (err) {
setUserPlaces(previousPlaces); // 상태 복구
setErrorUpdatingPlaces({
message: err.message || '장소를 추가하는 데 실패했습니다.',
});
}
};
// 장소 제거
const handleRemovePlace = async (placeId) => {
const previousPlaces = [...userPlaces];
const updatedPlaces = userPlaces.filter((place) => place.id !== placeId);
setUserPlaces(updatedPlaces); // 낙관적 업데이트
try {
await updateUserPlaces(updatedPlaces); // 백엔드 업데이트
} catch (err) {
setUserPlaces(previousPlaces); // 상태 복구
setErrorUpdatingPlaces({
message: err.message || '장소를 삭제하는 데 실패했습니다.',
});
}
};
// 에러 닫기
const handleError = () => {
setErrorUpdatingPlaces(null);
};
return (
<div>
<h1>사용자 선택 장소</h1>
{/* 에러 메시지 */}
{error && (
<Error title="에러가 발생했습니다!" message={error.message} onConfirm={() => setError(null)} />
)}
{/* AvailablePlaces 컴포넌트 */}
<AvailablePlaces onSelectPlace={handleSelectPlace} />
{/* 선택된 장소 */}
<h2>선택한 장소</h2>
{isFetching ? (
<p>데이터를 불러오는 중입니다...</p>
) : (
<ul>
{userPlaces.map((place) => (
<li key={place.id} onClick={() => handleRemovePlace(place.id)}>
{place.name}
</li>
))}
</ul>
)}
{/* 업데이트 에러 모달 */}
{errorUpdatingPlaces && (
<Modal onClose={handleError}>
<Error
title="에러가 발생했습니다!"
message={errorUpdatingPlaces.message}
onConfirm={handleError}
/>
</Modal>
)}
</div>
);
};
export default App;
// 사용자 선택 장소 가져오기
export async function fetchUserPlaces() {
const response = await fetch('http://localhost:3000/user-places');
if (!response.ok) {
throw new Error('사용자 데이터를 불러오는 데 실패했습니다.');
}
const resData = await response.json();
return resData.places || [];
}
// 사용자 선택 장소 업데이트
export async function updateUserPlaces(places) {
const response = await fetch('http://localhost:3000/user-places', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ places }), // places를 키로 감싸서 전달
});
if (!response.ok) {
throw new Error('사용자 데이터를 업데이트하는 데 실패했습니다.');
}
return await response.json();
}
리액트 훅의 두 가지 중요한 규칙
커스텀 훅의 필요성 및 역할
커스텀 훅을 활용한 예시
[HTTP 요청 처리 로직 (useEffect)]
⬇
[두 컴포넌트에서 유사한 로직 발견]
⬇
[로직을 일반 함수로 분리 -> 문제 발생 (훅 사용 불가)]
⬇
[커스텀 훅으로 로직 재사용]
⬇
[컴포넌트 내부에서 커스텀 훅 호출 -> 데이터 및 상태 관리]
리액트 훅에는 두 가지 중요한 규칙이 있습니다.
첫 번째, 훅은 컴포넌트 함수 또는 다른 커스텀 훅 내부에서만 사용할 수 있습니다.
두 번째, 훅은 조건문, 반복문, 중첩 함수 내부에 위치하면 안 됩니다.이 규칙을 바탕으로, 커스텀 훅은 컴포넌트 간 중복되는 로직을 재사용하는 데 필요한 도구입니다.
useEffect와 같은 훅 호출 로직을 분리하여 컴포넌트의 로직을 단순화하고, 유지보수성을 높일 수 있습니다.일반 함수로 로직을 분리하면 훅의 제약 조건을 위반하여 올바르게 작동하지 않을 수 있습니다.
하지만 커스텀 훅은 이러한 제약을 준수하면서도, 다양한 컴포넌트에서 로직을 공유할 수 있도록 합니다.
결과적으로 컴포넌트 내부에서 상태 관리 및 데이터 처리를 효율적으로 수행할 수 있게 됩니다.
[컴포넌트(App.jsx, AvailablePlaces.jsx 등)]
⬇
[중복된 로직 발견 (HTTP 요청, 상태 관리)]
⬇
[커스텀 훅 생성 (useFetch)]
⬇
[중복된 로직을 커스텀 훅으로 이동]
⬇
[컴포넌트에서 커스텀 훅 호출 -> 상태 및 데이터 관리]
⬇
[컴포넌트 간결화 및 로직 재사용성 향상]
import { useState, useEffect } from "react";
type FetchState<T> = {
data: T | null;
isLoading: boolean;
error: string | null;
};
export const useFetch = <T>(url: string) => {
const [state, setState] = useState<FetchState<T>>({
data: null,
isLoading: true,
error: null,
});
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error("데이터를 가져오는데 실패했습니다.");
}
const data = (await response.json()) as T;
setState({ data, isLoading: false, error: null });
} catch (error) {
setState({ data: null, isLoading: false, error: (error as Error).message });
}
};
fetchData();
}, [url]);
return state; // { data, isLoading, error }
};
import React from "react";
import { useFetch } from "./hooks/useFetch";
type User = {
id: number;
name: string;
email: string;
};
const App: React.FC = () => {
const { data, isLoading, error } = useFetch<User[]>("https://jsonplaceholder.typicode.com/users");
if (isLoading) return <p>로딩 중...</p>;
if (error) return <p>오류: {error}</p>;
return (
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
);
};
export default App;
커스텀 훅을 활용한 useFetch 구현 과정
커스텀 훅 사용의 이점
[HTTP 요청과 상태 관리 필요]
⬇
[커스텀 훅 생성: useFetch]
⬇
[fetchFn 매개변수로 HTTP 요청 로직 정의]
⬇
[useState로 상태값 관리: isFetching, data, error]
⬇
[초기값 설정 (initialValue)]
⬇
[fetchFn 실행 -> 상태값 업데이트]
⬇
[그룹화된 상태값 객체로 반환]
⬇
[컴포넌트에서 useFetch 호출 및 사용]
import { useState, useEffect } from "react";
type FetchState<T> = {
data: T | null;
isFetching: boolean;
error: string | null;
};
export const useFetch = <T>(
fetchFn: () => Promise<T>,
initialValue: T | null = null
) => {
const [state, setState] = useState<FetchState<T>>({
data: initialValue,
isFetching: false,
error: null,
});
useEffect(() => {
const fetchData = async () => {
setState((prev) => ({ ...prev, isFetching: true }));
try {
const data = await fetchFn();
setState({ data, isFetching: false, error: null });
} catch (error) {
setState({ data: null, isFetching: false, error: (error as Error).message });
}
};
fetchData();
}, [fetchFn]);
return state; // { data, isFetching, error }
};
import React from "react";
import { useFetch } from "./hooks/useFetch";
type Place = {
id: number;
name: string;
};
const App: React.FC = () => {
const fetchPlaces = async (): Promise<Place[]> => {
const response = await fetch("https://example.com/api/places");
if (!response.ok) throw new Error("데이터를 가져오는데 실패했습니다.");
return response.json();
};
const { data, isFetching, error } = useFetch<Place[]>(fetchPlaces, []);
if (isFetching) return <p>로딩 중...</p>;
if (error) return <p>오류: {error}</p>;
return (
<ul>
{data?.map((place) => (
<li key={place.id}>{place.name}</li>
))}
</ul>
);
};
export default App;
상태 업데이트 문제 및 해결 방안
커스텀 훅 사용의 결과
[useFetch 훅 호출]
⬇
[isFetching, data, error, setFetchedData 반환]
⬇
[컴포넌트에서 setFetchedData 추출 및 별칭 지정]
⬇
[handleSelectPlace, handleRemovePlace 함수에서 setFetchedData 호출]
⬇
[상태 업데이트 및 UI 렌더링 반영]
⬇
[독립적 상태 스냅샷으로 다른 컴포넌트에 영향 없음]
import { useState, useEffect } from "react";
type FetchState<T> = {
data: T | null;
isFetching: boolean;
error: string | null;
};
export const useFetch = <T>(
fetchFn: () => Promise<T>,
initialValue: T | null = null
) => {
const [state, setState] = useState<FetchState<T>>({
data: initialValue,
isFetching: false,
error: null,
});
useEffect(() => {
const fetchData = async () => {
setState((prev) => ({ ...prev, isFetching: true }));
try {
const data = await fetchFn();
setState({ data, isFetching: false, error: null });
} catch (error) {
setState({ data: null, isFetching: false, error: (error as Error).message });
}
};
fetchData();
}, [fetchFn]);
// 상태와 상태 업데이트 함수를 반환
return {
...state,
setFetchedData: (data: T) => setState((prev) => ({ ...prev, data })),
};
};
import React, { useCallback } from "react";
import { useFetch } from "./hooks/useFetch";
type Place = {
id: number;
name: string;
};
const App: React.FC = () => {
const fetchPlaces = async (): Promise<Place[]> => {
const response = await fetch("https://example.com/api/places");
if (!response.ok) throw new Error("데이터를 가져오는데 실패했습니다.");
return response.json();
};
const { data, isFetching, error, setFetchedData } = useFetch<Place[]>(fetchPlaces, []);
// 장소 선택 함수
const handleSelectPlace = useCallback(
(place: Place) => {
if (data) {
setFetchedData([...data, place]);
}
},
[data, setFetchedData]
);
// 장소 삭제 함수
const handleRemovePlace = useCallback(
(id: number) => {
if (data) {
setFetchedData(data.filter((place) => place.id !== id));
}
},
[data, setFetchedData]
);
if (isFetching) return <p>로딩 중...</p>;
if (error) return <p>오류: {error}</p>;
return (
<div>
<ul>
{data?.map((place) => (
<li key={place.id}>
{place.name}
<button onClick={() => handleRemovePlace(place.id)}>삭제</button>
</li>
))}
</ul>
<button onClick={() => handleSelectPlace({ id: Date.now(), name: "새 장소" })}>
장소 추가
</button>
</div>
);
};
export default App;
해당 내용은 커스텀 훅(Custom Hook
)을 활용해 컴포넌트 로직을 더욱 효율적으로 관리하는 방법을 설명하고 있다.
기존에는 각 컴포넌트마다 useEffect
와 useState
를 이용해 데이터 로딩, 에러 상태 관리, 데이터 저장 등을 처리했다.
하지만 이는 중복된 로직이 발생할 수 있으며, 재사용성이 떨어질 수 있다.
커스텀 훅을 통한 개선점:
useFetch
)에 담아서 재사용할 수 있다.App
컴포넌트와 AvailablePlaces
컴포넌트 모두 동일한 useFetch
훅을 사용하더라도, 각자의 훅 인스턴스는 독립적으로 상태를 유지하므로 한쪽의 상태 업데이트가 다른 쪽에 간섭하지 않는다.구체적인 예:
정리하자면, 커스텀 훅을 이용하면 로직 중복을 제거하고, 유지보수를 쉽게 하며, 컴포넌트를 더 효율적으로 구성할 수 있다.
[AvailablePlaces 컴포넌트] -- useFetch 훅 호출 --> [useFetch 훅 내부]
(fetchAvailablePlaces 함수, 초기값) (데이터 로딩, 에러 처리, 상태 관리)
|
v
ifFetching, error, fetchedData, setFetchedData
|
v
[AvailablePlaces 컴포넌트에서 state 대체]
import { useState, useEffect } from 'react';
interface UseFetchReturnType<T> {
ifFetching: boolean;
error: Error | null;
fetchedData: T;
setFetchedData: React.Dispatch<React.SetStateAction<T>>;
}
function useFetch<T>(fetchFunction: () => Promise<T>, initialValue: T): UseFetchReturnType<T> {
const [ifFetching, setIfFetching] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [fetchedData, setFetchedData] = useState<T>(initialValue);
useEffect(() => {
let isMounted = true;
setIfFetching(true);
fetchFunction()
.then((data) => {
if (isMounted) {
setFetchedData(data);
setIfFetching(false);
}
})
.catch((err) => {
if (isMounted) {
setError(err);
setIfFetching(false);
}
});
return () => {
isMounted = false;
};
}, [fetchFunction]);
return { ifFetching, error, fetchedData, setFetchedData };
}
export default useFetch;
// 실제 데이터 패칭 로직은 상황에 맞게 구현
export async function fetchAvailablePlaces(): Promise<string[]> {
const response = await fetch('/api/places');
if (!response.ok) {
throw new Error('Failed to fetch places');
}
const data: string[] = await response.json();
return data;
}
import React from 'react';
import useFetch from './hooks/useFetch';
import { fetchAvailablePlaces } from './fetchAvailablePlaces';
export default function AvailablePlaces() {
// useFetch 훅을 통해 로딩 상태, 오류 상태, 데이터 상태를 가져온다.
const { ifFetching, error, fetchedData, setFetchedData } = useFetch<string[]>(fetchAvailablePlaces, []);
// fetchedData를 availablePlaces로 이름 변경
const availablePlaces = fetchedData;
const setAvailablePlaces = setFetchedData;
// 기존에 있던 useEffect 로직 중 거리 계산 관련 부분이 있다면, 추후 해당 로직에 맞게 재활용할 수 있음.
// 예: const distanceCalculations = ... (일단 주석으로 남김)
// useEffect(...) 로직은 이제 필요 없으므로 제거 가능
if (ifFetching) return <div>로딩 중...</div>;
if (error) return <div>오류 발생: {error.message}</div>;
return (
<div>
<h1>사용 가능한 장소 목록</h1>
<ul>
{availablePlaces.map((place, index) => (
<li key={index}>{place}</li>
))}
</ul>
{/* 이후 distance 계산 로직이나 관련 요소를 추가할 수 있음 */}
</div>
);
}
이전에는 단순히 서버나 API로부터 장소 데이터를 가져오는(fetch) 함수(fetchAvailablePlaces)를 커스텀 훅(useFetch)에 전달하여 데이터를 관리하고 있었다. 이제는 여기에 정렬 기능을 추가해보는 과정을 다룬다. 핵심 아이디어는 다음과 같다.
정렬 기능 추가를 위한 함수 분리:
fetchSortedPlaces
라는 별도의 함수를 만들어서 장소 데이터를 가져온 뒤, 사용자의 위치를 기반으로 해당 장소들을 거리 순으로 정렬한다. async
) 함수이며 Promise
를 반환한다.Promise
활용:
navigator.geolocation.getCurrentPosition
)는 콜백 패턴을 사용한다. 이를 Promise
로 감싸면(new Promise((resolve, reject) => {...})
), async/await
패턴이나 useFetch
훅에서 쉽게 사용할 수 있다.Promise
기반 비동기로 전환하는 것은 자바스크립트에서 흔히 쓰이는 패턴이다.정렬 로직의 흐름:
fetchSortedPlaces
함수:fetchAvailablePlaces
함수를 호출해 장소 데이터를 가져온다.Promise
를 통해 받아온다.resolve(sortedPlaces)
를 통해 Promise
로 반환한다.useFetch
훅은 fetchSortedPlaces
함수를 전달받아 Promise
를 반환하는 fetch
함수를 호출하게 되고, 결과적으로 거리 순으로 정렬된 장소 데이터를 받게 된다.커스텀 훅 활용 장점 재확인:
[AvailablePlaces 컴포넌트]
|
V useFetch 호출 (fetch 함수로 fetchSortedPlaces 전달)
[useFetch 훅] --- fetchSortedPlaces 호출 ---> [fetchSortedPlaces 함수]
|
V
fetchAvailablePlaces + 사용자 위치 취득 + 정렬
|
V
resolve(sortedPlaces)
|
V
[useFetch 훅: fetchedData에 sortedPlaces 저장]
|
V
[AvailablePlaces 컴포넌트: 정렬된 장소 데이터 사용]
// fetchAvailablePlaces.ts
export async function fetchAvailablePlaces(): Promise<{name: string; lat: number; lng: number;}[]> {
const response = await fetch('/api/places');
if (!response.ok) {
throw new Error('Failed to fetch places');
}
const data: {name: string; lat: number; lng: number;}[] = await response.json();
return data;
}
// fetchSortedPlaces.ts
import { fetchAvailablePlaces } from './fetchAvailablePlaces';
interface Place {
name: string;
lat: number;
lng: number;
}
// 사용자 위치 정보를 Promise로 감싸는 함수(예: geolocation API)
function getUserPosition(): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject);
});
}
export async function fetchSortedPlaces(): Promise<Place[]> {
// 장소 데이터 가져오기
const places = await fetchAvailablePlaces();
// 사용자 위치 정보 가져오기(Promise로 감싸기)
const position = await getUserPosition();
const userLat = position.coords.latitude;
const userLng = position.coords.longitude;
// 단순 거리 계산(예: 피타고라스 공식)
const sortedPlaces = places.sort((a, b) => {
const distA = Math.sqrt((a.lat - userLat)**2 + (a.lng - userLng)**2);
const distB = Math.sqrt((b.lat - userLat)**2 + (b.lng - userLng)**2);
return distA - distB;
});
// 정렬된 장소 반환
return sortedPlaces;
}
import { useState, useEffect } from 'react';
interface UseFetchReturnType<T> {
ifFetching: boolean;
error: Error | null;
fetchedData: T;
}
function useFetch<T>(fetchFunction: () => Promise<T>, initialValue: T): UseFetchReturnType<T> {
const [ifFetching, setIfFetching] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [fetchedData, setFetchedData] = useState<T>(initialValue);
useEffect(() => {
let isMounted = true;
setIfFetching(true);
fetchFunction()
.then((data) => {
if (isMounted) {
setFetchedData(data);
setIfFetching(false);
}
})
.catch((err) => {
if (isMounted) {
setError(err);
setIfFetching(false);
}
});
return () => {
isMounted = false;
};
}, [fetchFunction]);
return { ifFetching, error, fetchedData };
}
export default useFetch;
// AvailablePlaces.tsx
import React from 'react';
import useFetch from './useFetch';
import { fetchSortedPlaces } from './fetchSortedPlaces';
export default function AvailablePlaces() {
// fetchSortedPlaces를 이용하여 정렬된 장소 데이터 가져오기
const { ifFetching, error, fetchedData: availablePlaces } = useFetch(fetchSortedPlaces, []);
if (ifFetching) return <div>로딩 중...</div>;
if (error) return <div>오류 발생: {error.message}</div>;
return (
<div>
<h1>거리 기준 정렬된 장소 목록</h1>
<ul>
{availablePlaces.map((place, index) => (
<li key={index}>{place.name}</li>
))}
</ul>
</div>
);
}
이로써 커스텀 훅을 활용하는 장점(로직 재사용, 코드 단순화, 유지보수성 향상)이 더욱 명확히 드러난다.
어떤 웹 애플리케이션을 만들든, 결국 사용자 입력을 처리하는 시점은 오게 된다. 양식(Form)과 입력값 검증(Validation)은 웹 개발에서 필수적인 주제이며, 이는 React를 사용하든, 다른 라이브러리나 프레임워크를 사용하든, 혹은 순수 HTML/CSS/JS만 사용하든 동일하게 중요한 부분이다.
양식 제출 관리와 사용자 입력 검증 이해하기:
브라우저 기본 검증 기능 활용:
type="email"
등을 통해 간단한 유효성 검사를 브라우저에서 알아서 처리한다. React에서 가능한 커스텀 솔루션 제안:
사용자 입력 ──> HTML 양식(form)
│ │
│ └─> 브라우저 기본 검증 (required, type 등)
│
│ (제출 이벤트 발생)
v
React 컴포넌트에서 상태 관리
│
│─ 검증 로직(커스텀) 적용
v
양식 제출 처리 로직(서버 전송/로컬 상태 업데이트 등)
import React, { useState, FormEvent } from 'react';
interface FormData {
email: string;
password: string;
}
function isEmailValid(email: string): boolean {
// 간단한 email 검증 예시 (실제 프로덕션에서는 더 정교한 검증 사용)
return email.includes('@');
}
export default function SimpleForm() {
const [formData, setFormData] = useState<FormData>({ email: '', password: '' });
const [error, setError] = useState<string | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
setError(null);
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
// 브라우저 기본 검증 외에 커스텀 검증 로직 추가
if (!isEmailValid(formData.email)) {
setError('이메일 형식이 올바르지 않습니다.');
return;
}
if (formData.password.length < 6) {
setError('비밀번호는 6자 이상이어야 합니다.');
return;
}
// 모든 검증 통과 시 서버 전송 또는 상태 업데이트 가능
// 여기서는 단순히 console에 출력
console.log('Form submitted:', formData);
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label>
이메일:
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required // 브라우저 기본 검증 활용
/>
</label>
</div>
<div>
<label>
비밀번호:
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required // 브라우저 기본 검증 활용
/>
</label>
</div>
{error && <p style={{color: 'red'}}>{error}</p>}
<button type="submit">제출</button>
</form>
);
}
required
속성 등을 통해 브라우저 기본 검증을 활용할 수 있다.isEmailValid
함수를 통한 간단한 커스텀 검증 로직을 추가했다.handleSubmit
) 커스텀 검증을 수행한 뒤 문제가 없다면 콘솔에 결과를 출력한다. 실제로는 서버에 전송하거나 로컬/전역 상태를 업데이트하는 등의 작업을 수행할 수 있다.이를 통해 양식 처리 과정, 브라우저 기본 검증 기능, 커스텀 검증 로직 적용 방법을 한 번에 살펴볼 수 있다.
양식(form)이 어려운 이유는 단순히 입력 필드의 집합이라는 정의에서 끝나지 않는다. 기본적으로 양식은 사용자의 입력을 받고, 이를 처리하며, 최종적으로 서버로 전송하거나 내부 로직으로 전달하는 중요한 요소이다. 여기에 "검증"이라는 과정이 더해지면 복잡성이 상승한다.
양식의 기본 개념:
양식의 두 가지 주요 목적:
제출 관리 및 데이터 추출:
데이터 검증(Validation):
요약하자면, 양식에서 데이터 추출과 제출 관리는 쉽지만, 검증 시점과 방법을 올바르게 결정하는 것이 양식 구현을 어렵게 만드는 핵심 요인이다.
사용자 입력 ──> 양식 필드(입력 요소) ──> 데이터 추출 (state, ref, FormData)
│
└─> 검증 로직 (입력 시점/포커스 아웃 시점/제출 시점?)
│
│─> 오류 메시지 조건부 표시
v
최종 제출 (서버 전송 또는 내부 로직 처리)
import React, { useState, useRef } from 'react';
interface FormData {
email: string;
password: string;
}
function isEmailValid(email: string): boolean {
return email.includes('@');
}
export default function SignupForm() {
const [formData, setFormData] = useState<FormData>({ email: '', password: '' });
const [error, setError] = useState<string | null>(null);
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const handleBlur = (field: 'email' | 'password') => {
const value = field === 'email' ? formData.email : formData.password;
// 포커스를 벗어났을 때 검증
if (field === 'email' && !isEmailValid(value)) {
setError('이메일 형식이 올바르지 않습니다.');
} else if (field === 'password' && value.length < 6) {
setError('비밀번호는 6자 이상이어야 합니다.');
} else {
setError(null);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// 데이터 입력 중에는 실시간 검증X, 포커스 벗어날 때 검증
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 제출 시점에 최종 검증
if (!isEmailValid(formData.email)) {
setError('이메일 형식이 올바르지 않습니다.');
return;
}
if (formData.password.length < 6) {
setError('비밀번호는 6자 이상이어야 합니다.');
return;
}
// 검증 통과 시 서버 전송 로직 등 수행
console.log('Form submitted:', formData);
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>이메일:</label>
<input
type="email"
name="email"
ref={emailRef}
value={formData.email}
onChange={handleChange}
onBlur={() => handleBlur('email')}
required
/>
</div>
<div>
<label>비밀번호:</label>
<input
type="password"
name="password"
ref={passwordRef}
value={formData.password}
onChange={handleChange}
onBlur={() => handleBlur('password')}
required
/>
</div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit">가입하기</button>
</form>
);
}
이런 방식으로 검증 시점을 다양하게 조절하고, 사용자 경험을 개선하며, 최종적으로는 사용하는 앱과 사용자 요구사항에 맞는 최적의 검증 전략을 선택할 수 있다.
HTML 양식(Form)에서 기본적으로 submit 버튼을 누르면 브라우저는 해당 양식의 데이터를 서버로 전송하고, 페이지를 새로고침한다. 이는 전통적인 웹 애플리케이션 패턴에서는 유용하지만, React 기반의 싱글 페이지 애플리케이션(SPA)에서는 불필요한 페이지 새로고침과 서버 요청을 야기하여 문제를 일으킨다.
type="button"
으로 설정하기: submit
동작을 하지 않고, 단순히 클릭 이벤트만 발생시킨다.form
요소의 onSubmit
이벤트 처리하기:form
요소에 onSubmit
핸들러를 지정하면, 내부 버튼(기본값 type="submit"
) 클릭 시 onSubmit
이벤트가 발생한다. event.preventDefault()
를 호출해 브라우저의 기본 제출 동작을 막을 수 있다.onSubmit
이벤트 핸들러에서 바로 event
객체에 접근할 수 있고, preventDefault()
로 기본 동작을 막은 뒤 React 로직(상태 업데이트, 데이터 검증, 서버 요청 등)을 자유롭게 수행할 수 있기 때문이다.
정리하자면, React 애플리케이션에서는 기본 HTML 양식 제출 방식(페이지 새로고침, 서버 전송)을 방지하고, preventDefault()
를 통해 자체적인 데이터 처리 로직을 구현하는 것이 일반적이다.
이렇게 하면 페이지 리로딩 없이도 사용자 입력을 처리하고, 검증하고, 필요하다면 추후에 원하는 시점에 HTTP 요청을 보낼 수 있다.(사용자) ──양식 제출 버튼 클릭──> [form onSubmit 이벤트]
│ │
│ ├─ event.preventDefault() 호출로 기본 제출 방지
│
v
(React 로직 처리)
│ ┌──────────────────┐
│ │ 상태 관리, │
│ │ 사용자 입력 검증 │
│ │ 백엔드로 데이터 전송 │
│ └──────────────────┘
│
v
결과 반영 (UI 업데이트, 서버 응답 반영 등)
이 흐름에서 핵심은 preventDefault()
로 기본 브라우저 동작을 막고, React가 주도적으로 모든 로직을 처리한다는 점이다.
import React, { FormEvent } from 'react';
export default function Login() {
const handleSubmit = (event: FormEvent) => {
event.preventDefault(); // 기본 제출(페이지 새로고침, 서버전송) 동작 방지
console.log('Submitted!');
// 여기서 상태 관리, 검증, 서버 요청 등의 로직을 추가할 수 있다.
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username">사용자 이름:</label>
<input type="text" id="username" name="username" required />
</div>
<div>
<label htmlFor="password">비밀번호:</label>
<input type="password" id="password" name="password" required />
</div>
<button type="submit">로그인</button>
<button type="reset">리셋</button>
</form>
);
}
<form>
태그에 onSubmit={handleSubmit}
를 지정하고, handleSubmit
함수 내부에서 event.preventDefault()
호출.이렇게 하면 리액트 애플리케이션에서 양식을 다루는 기초 패턴을 확립하고, 이후 검증 로직이나 데이터 추출 로직을 점진적으로 적용할 수 있다.
양식(Form) 데이터를 관리하는 과정에서 각 입력 필드마다 상태값을 개별적으로 관리할 수도 있고, 하나의 객체 상태에 여러 필드 값을 함께 담아 관리할 수도 있다.
다양한 상태 관리 방법:
핵심 아이디어:
결과:
사용자 입력 (키보드 입력)
└───> onChange 이벤트
└──> handleInputChange(필드 식별자, 이벤트)
└──> setEnteredValues((prevValues) => ({
...prevValues,
[필드 식별자]: event.target.value
}))
│
v
enteredValues 상태 업데이트
│
v
handleSubmit 호출 시
console.log(enteredValues)
import React, { useState, FormEvent, ChangeEvent } from 'react';
interface EnteredValues {
email: string;
password: string;
}
export default function LoginForm() {
const [enteredValues, setEnteredValues] = useState<EnteredValues>({
email: '',
password: '',
});
const handleInputChange = (field: keyof EnteredValues, event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setEnteredValues((prevValues) => ({
...prevValues,
[field]: value,
}));
};
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
console.log('Submitted Values:', enteredValues);
// 여기서 enteredValues를 사용해 검증, 서버 전송 등 원하는 로직을 실행할 수 있다.
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">이메일</label>
<input
id="email"
type="email"
value={enteredValues.email}
onChange={(e) => handleInputChange('email', e)}
required
/>
</div>
<div>
<label htmlFor="password">비밀번호</label>
<input
id="password"
type="password"
value={enteredValues.password}
onChange={(e) => handleInputChange('password', e)}
required
/>
</div>
<button type="submit">로그인</button>
</form>
);
}
이를 통해 복잡한 양식도 효율적으로 관리할 수 있게 되며, 추가 필드가 늘어나도 코드를 크게 복잡하게 만들지 않고 확장할 수 있다.
사용자 입력을 추적하는 또 다른 방법은 참조(Ref)를 활용하는 것이다. 이 방법은 useState를 통해 상태를 관리하는 대신, DOM 요소에 직접 접근하여 값을 얻어오는 방식이다.
Ref를 사용하는 방법:
<input ref={emailRef} ... />
처럼 입력 필드에 ref를 연결하면, 해당 Ref 객체는 해당 DOM 요소를 가리키게 된다.장점:
단점:
정리하자면, Ref를 통한 입력값 추출 방법은 간단하지만, 대규모 양식이나 복잡한 상황에서는 오히려 관리가 어려울 수 있으며, React 철학과는 약간 거리가 있다.
입력 필드 (DOM 요소) <-- ref 속성 연결 --> Ref 객체 (emailRef, passwordRef)
│
v
handleSubmit 함수
│
v
emailRef.current.value
passwordRef.current.value
이 흐름을 통해서 입력값을 상태로 관리하지 않고도, 제출 시점에 DOM 요소로부터 직접 값을 가져올 수 있다.
import React, { useRef, FormEvent } from 'react';
export default function RefLogin() {
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
const enteredEmail = emailRef.current?.value;
const enteredPassword = passwordRef.current?.value;
console.log('Entered Email:', enteredEmail);
console.log('Entered Password:', enteredPassword);
// 필요하다면 여기서 서버로 데이터 전송, 검증 로직 추가 등 수행
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">이메일</label>
<input id="email" type="email" ref={emailRef} />
</div>
<div>
<label htmlFor="password">비밀번호</label>
<input id="password" type="password" ref={passwordRef} />
</div>
<button type="submit">로그인</button>
</form>
);
}
복잡한 폼 처리의 배경
이벤트 핸들러와 FormData 활용
event.preventDefault()
를 통해 폼이 전통적인 방식(페이지 리로드, HTTP 요청 전송)으로 제출되는 것을 막고, 자바스크립트에서 직접 처리할 수 있다.FormData 객체란?
const formData = new FormData(event.target);
event.target
는 폼을 가리키며, 해당 폼 내부의 모든 입력값을 FormData
객체가 참조하게 된다.이름(name) 속성의 중요성
FormData를 통한 값 추출
formData.get('name')
)const email = formData.get('email');
name
속성을 가진 입력 필드의 값을 얻는다.formData.getAll('name')
)const selectedChannels = formData.getAll('acquisition');
Object.fromEntries(formData.entries())
)formData.entries()
를 통해 [key, value]
형태의 쌍을 얻을 수 있고, 이를 Object.fromEntries()
에 전달하여 모든 입력값을 객체 형태로 변환할 수 있다.const data = Object.fromEntries(formData.entries());
다만, getAll
로 추출해야 하는 복수 값(체크박스 그룹)인 경우 Object.fromEntries()
만으로는 처리하기 어렵다. 이럴 때는 getAll()
을 사용하여 별도로 추출한 후 data
객체에 병합할 수 있다.체크박스 그룹의 처리
name
을 가질 경우, getAll('name')
을 사용하면 선택된 모든 체크박스 값들의 배열을 얻는다.data
객체에 새로운 속성으로 추가해 최종적인 데이터 구조를 완성할 수 있다.정리
FormData
를 사용하면 개별 state
나 ref
없이도 복잡한 폼의 모든 입력값을 빠르게 얻을 수 있다.get()
, getAll()
, 그리고 Object.fromEntries()
를 적절히 활용하면 폼 데이터를 구조화된 객체 형태로 손쉽게 사용할 수 있다.사용자 입력값 입력 ──> 폼(form)
│ (onSubmit 이벤트)
▼
handleSubmit(event)
│
│ event.preventDefault()
▼
new FormData(event.target) 생성
│
▼
FormData 객체
┌────────────────────────┐
│ get('name') │
│ getAll('name') │
│ entries() │
└────────────────────────┘
│
▼
Object.fromEntries()
│
▼
최종 데이터 객체(data)
│
└─> console.log(data) or 서버 전송, 상태 업데이트 등
import React, { FormEvent } from 'react';
interface SignupData {
firstName: string;
lastName: string;
email: string;
password: string;
// 체크박스 그룹의 경우 문자열 배열
acquisition?: string[];
}
const Signup: React.FC = () => {
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
// 객체 변환 (체크박스 같은 복수값 제외)
const data = Object.fromEntries(formData.entries()) as Omit<SignupData, 'acquisition'>;
// 체크박스 그룹 처리
const acquisitionChannels = formData.getAll('acquisition') as string[];
// 최종 데이터 객체 생성
const signupData: SignupData = {
...data,
acquisition: acquisitionChannels
};
console.log(signupData);
// TODO: 서버로 전송하거나 상태 관리 등에 활용
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>
이름(First Name):
<input type="text" name="firstName" required />
</label>
</div>
<div>
<label>
성(Last Name):
<input type="text" name="lastName" required />
</label>
</div>
<div>
<label>
이메일(Email):
<input type="email" name="email" required />
</label>
</div>
<div>
<label>
비밀번호(Password):
<input type="password" name="password" required />
</label>
</div>
<div>
<p>저희를 어떻게 찾으셨나요? (체크박스)</p>
<label>
<input type="checkbox" name="acquisition" value="SNS" />
SNS
</label>
<label>
<input type="checkbox" name="acquisition" value="SearchEngine" />
검색엔진
</label>
<label>
<input type="checkbox" name="acquisition" value="Friend" />
지인추천
</label>
</div>
<button type="submit">회원가입</button>
</form>
);
};
export default Signup;
Object.fromEntries(formData.entries())
를 통해 대부분의 단일값 필드(firstName
, lastName
, email
, password
)를 객체로 변환한다.formData.getAll()
메소드를 통해 배열 형태로 추출한 후, 최종 데이터 객체에 병합한다.signupData
객체를 통해 추후 검증, 서버 전송, 또는 UI 상태 업데이트를 수행할 수 있다.정리하자면, FormData를 사용하면 복잡한 폼에서 모든 입력값을 쉽게 추출하고 구조화할 수 있으며, 이를 통해 개발 효율을 높이고 유지보수를 쉽게 할 수 있다.
폼 리셋에 대한 필요성
reset
)이라 하며, 이는 사용자가 입력한 내용을 모두 초기값(대개 빈 문자열)으로 되돌리는 것을 의미한다.폼 리셋 방법
HTML 속성 이용 (reset
타입 버튼)
<button type="reset">
버튼을 사용하면 폼 내의 모든 입력창 값이 기본값(일반적으로 빈 값)으로 초기화된다.<form>
<input type="text" name="email" />
<button type="reset">리셋</button>
</form>
상태(State) 관리 통한 리셋
리액트에서 상태로 입력값을 관리한다면, 상태를 초기값으로 재설정(setState)하는 것만으로도 연결된 입력창들이 초기값으로 돌아간다.
예를 들어, email과 password를 상태로 관리한다면
setEnteredValues({ email: '', password: '' });
이렇게 하면 상태를 사용해 제어되는 입력값들은 상태 변경에 따라 자동으로 빈 값으로 업데이트된다.
참조(Ref)를 통한 직접 DOM 값 초기화
Form 요소의 reset()
메소드 호출
event.target.reset()
을 호출하면 해당 폼이 초기 상태로 리셋된다.reset
타입의 버튼을 이용하는 것과 유사하며, 코드에서 명시적으로 폼을 리셋하고 싶을 때 사용한다.총정리
type="reset"
버튼 사용, 상태 초기화, event.target.reset()
호출 등의 방법을 사용할 수 있다.HTML
본연의 기능(reset 버튼
, form.reset()
)을 활용하는 것이 권장된다.사용자 입력값 입력 ──> 폼(form)
│
│ (리셋 이벤트 혹은 특정 시점)
▼
리셋 방법 선택:
┌─────────────────────────────────┐
│1. <button type="reset"> 클릭 │
│2. 상태 관리: setValues(...) 호출 │
│3. DOM 접근(ref): ref.value = '' │
│4. form.reset() 메소드 호출 │
└─────────────────────────────────┘
▼
폼 초기화
│
└─> 모든 입력값이 빈 상태 또는 초기값으로 돌아감
아래 예시는 React + TypeScript를 가정한 코드이다. 폼 제출 후 event.target.reset()을 통해 폼을 초기화하는 예시를 들어보았다. 또한 버튼을 통한 reset 방법과 상태를 통한 초기화 예시도 간략히 포함하였다.
import React, { useState, FormEvent } from 'react';
interface FormValues {
name: string;
email: string;
password: string;
}
const Signup: React.FC = () => {
const [values, setValues] = useState<FormValues>({
name: '',
email: '',
password: ''
});
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
// 여기서 values를 서버로 전송하거나, 검증 로직 수행 가능
// 방법1: form.reset() 이용
(event.target as HTMLFormElement).reset();
// 이 방법으로 form 요소 내 모든 입력값이 초기화됨
// 방법2: 상태 초기화
// setValues({ name: '', email: '', password: '' });
// 상태를 통한 제어 컴포넌트라면 이 코드로도 모든 입력값 초기화 가능
// 방법3: reset 타입 버튼 (JSX 내에 단순히 <button type="reset">을 사용)
// 별도의 코드 없이도 폼 초기화 가능
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setValues(prev => ({ ...prev, [name]: value }));
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>
이름:
<input type="text" name="name" value={values.name} onChange={handleChange} />
</label>
</div>
<div>
<label>
이메일:
<input type="email" name="email" value={values.email} onChange={handleChange} />
</label>
</div>
<div>
<label>
비밀번호:
<input type="password" name="password" value={values.password} onChange={handleChange} />
</label>
</div>
<button type="submit">회원가입</button>
<button type="reset">리셋</button>
</form>
);
};
export default Signup;
type="reset"
버튼 클릭 시 HTML의 내장 기능을 통해 폼이 초기화된다.handleSubmit
내에서 (event.target as HTMLFormElement).reset()
을 호출하면 JS로도 폼 전체를 쉽게 초기화할 수 있다.setValues
를 초기 상태로 재설정함으로써 입력값을 비울 수도 있다.요약하자면, 폼 리셋을 위해 HTML 속성, 리액트 상태, form.reset() 메소드 등 다양한 전략을 사용할 수 있으며, 상황에 따라 가장 직관적이고 유지보수하기 쉬운 방법을 선택하면 된다. 다음 단계인 폼 유효성 검증을 배우기 전, 폼 리셋 방법을 이해해두면 더 효율적인 폼 처리 로직을 구현할 수 있다.
아래 정리는, 입력 검증(유효성 검사) 전략, 특히 실시간 검증(매 키 입력 시 검증)과 그 한계점에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표로 데이터 흐름을 도식화하고, 타입스크립트 기반의 React 예시 코드를 첨부하였습니다.
입력 검증(유효성 검사)이 필요한 이유
실시간(매 키 입력) 검증
문제점 및 개선 여지
앞으로 배울 포커스 해제 시점에서의 검증(onBlur
이벤트 활용) 등을 통해 보다 자연스러운 사용자 경험을 제공할 수 있다.
사용자 입력 (키 입력 발생)
│
▼
상태 업데이트 (enteredValues.email 변경)
│
▼ 재랜더링
컴포넌트 함수 재실행 ──> 유효성 검사(@ 문자 존재 여부 등)
│
├─ 유효하면: emailIsInvalid = false
│
└─ 유효하지 않으면: emailIsInvalid = true
│
▼
UI 업데이트
┌───────────────────┐
│ 에러 메시지 표시 │
│ (CSS 클래스 적용) │
└───────────────────┘
import React, { useState, ChangeEvent } from 'react';
const StateLogin: React.FC = () => {
const [enteredEmail, setEnteredEmail] = useState<string>('');
// 이메일 유효성 검사 로직:
// 1. 입력한 값에 '@'가 없으면 유효하지 않은 이메일로 판단.
// 2. 다만, 입력값이 완전히 비어있을 때는 아직 사용자가 입력 중일 수 있으니 에러를 표시하지 않을 수도 있다.
const emailIsInvalid = enteredEmail.trim() !== '' && !enteredEmail.includes('@');
const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => {
setEnteredEmail(event.target.value);
};
return (
<form>
<div className={`control ${emailIsInvalid ? 'control-error' : ''}`}>
<label htmlFor="email">이메일</label>
<input
type="email"
id="email"
value={enteredEmail}
onChange={handleEmailChange}
/>
{emailIsInvalid && (
<p className="error-text">유효한 이메일 주소를 입력해 주십시오.</p>
)}
</div>
<button type="submit">로그인</button>
</form>
);
};
export default StateLogin;
enteredEmail
상태를 통해 입력값을 실시간으로 추적.
emailIsInvalid
변수를 통해 입력값에 대한 유효성 판단:
enteredEmail.trim() !== ''
)@
문자가 없으면(!enteredEmail.includes('@')
), 이메일이 유효하지 않다고 판단.emailIsInvalid
가 true
일 때만 .control-error
CSS 클래스 적용 및 에러 메시지 표시.
이 접근 방식은 즉각적인 피드백 제공이 가능하지만, 사용자 경험 최적화를 위해 입력 완료 또는 포커스 해제 시점에서 검증하는 등의 추가 전략이 필요.
요약:
아래 정리는, 포커스 해제(blur) 이벤트를 활용한 유효성 검증 전략 및 이를 개선하는 방법에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 이용한 데이터 흐름 도식과 타입스크립트 기반의 React 예시 코드를 첨부하였습니다.
포커스 해제(blur) 이벤트를 통한 검증
UX 개선을 위한 추가 로직
정리
사용자 입력 필드에 포커스 획득
│
├─ (처음에는 오류 메시지 없음)
│
▼ 사용자가 입력값 변경 (키 입력)
입력값 상태 업데이트 ──> didEdit 상태 업데이트(사용자가 타이핑 시작 시 false로 리셋)
│
└─ 유효성 검증은 아직 비활성(입력 중이므로 오류 표시 안 함)
사용자가 포커스를 잃음(onBlur 이벤트 발생)
│
▼
didEdit 상태 = true (사용자가 이 필드를 건드림)
│
├─ 입력값이 유효하지 않다면?
│ └─ 오류 메시지 표시(emailIsInvalid = true)
│
└─ 입력값이 유효하다면?
└─ 오류 메시지 표시 안 함(emailIsInvalid = false)
사용자가 다시 입력(키 입력) 시작
│
▼
didEdit 상태 리셋(= false) → 오류 메시지 숨김
│
└─ (유효한 값 입력할 때까지 오류 메시지 미표시)
다시 포커스 해제 시점에 유효성 검증 반복
│
└─ 조건에 따라 오류 메시지 표시 여부 업데이트
import React, { useState, ChangeEvent, FocusEvent } from 'react';
type InputKeys = 'email';
interface ValuesState {
email: string;
}
interface DidEditState {
email: boolean;
}
const BlurValidationForm: React.FC = () => {
const [values, setValues] = useState<ValuesState>({ email: '' });
const [didEdit, setDidEdit] = useState<DidEditState>({ email: false });
// 이메일 유효성 검사 로직:
// @문자가 없고, 사용자가 이 필드를 건드려(didEdit.email = true) 포커스를 잃은 상황에서만 오류 표시
const emailIsInvalid = didEdit.email && !values.email.includes('@');
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setValues(prev => ({ ...prev, [name]: value }));
// 사용자가 키 입력을 시작하면 didEdit를 false로 리셋해서, 오류 메시지 숨기기
setDidEdit(prev => ({ ...prev, [name]: false }));
};
const handleInputBlur = (e: FocusEvent<HTMLInputElement>) => {
const { name } = e.target as HTMLInputElement;
// 포커스 해제 시 didEdit를 true로 설정해, 이 필드를 건드렸음을 표시
setDidEdit(prev => ({ ...prev, [name]: true }));
};
return (
<form>
<div className={`control ${emailIsInvalid ? 'control-error' : ''}`}>
<label htmlFor="email">이메일</label>
<input
type="email"
id="email"
name="email"
value={values.email}
onChange={handleInputChange}
onBlur={handleInputBlur}
/>
{emailIsInvalid && (
<p className="error-text">유효한 이메일 주소를 입력해주세요.</p>
)}
</div>
<button type="submit">제출</button>
</form>
);
};
export default BlurValidationForm;
onBlur 이벤트 핸들러(handleInputBlur)를 통해 사용자가 필드에서 포커스를 벗어날 때 didEdit를 true로 변경한다.
emailIsInvalid는 didEdit.email이 true이며 @ 문자가 없을 때만 true가 된다.
onChange 이벤트에서 키 입력 시 didEdit.email을 false로 리셋하여 이전 오류 메시지를 숨기고, 새로운 시작 기회를 부여한다.
이로써 사용자 경험을 개선:
요약:
아래 정리는, 폼 유효성 검증을 양식 제출 시점에 수행하는 전략과 이로 인해 얻을 수 있는 단순성, 그리고 기존의 키 입력/포커스 기반 검증과 제출 기반 검증을 병행하는 필요성에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.
제출 시점 유효성 검증이란?
구현 방식
정리
사용자 입력 ──> (참조 ref를 통한 값 보관) ──> 제출 버튼 클릭(onSubmit)
│
▼
handleSubmit 함수 실행
│
입력값(ref.value) 가져오기
│
유효성 검사(이메일 '@' 포함 여부)
│
┌─────────────────────┴────────────────────┐
│ │
유효하지 않음 유효함
│ │
setEmailIsInvalid(true) setEmailIsInvalid(false)
오류 메시지 표시 오류 메시지 미표시 (또는 제거)
│ │
└──────────────────────────────────────────┘
아래 코드는 React + TypeScript 환경에서 참조(ref)를 이용하고, 제출 시점에 유효성 검사를 수행하는 간단한 예시이다.
import React, { useRef, useState, FormEvent } from 'react';
const RefBasedLogin: React.FC = () => {
const emailInputRef = useRef<HTMLInputElement>(null);
const [emailIsInvalid, setEmailIsInvalid] = useState(false);
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
const enteredEmail = emailInputRef.current?.value || '';
// 이메일 유효성 검사: '@' 포함 여부 확인
const emailIsValid = enteredEmail.includes('@');
if (!emailIsValid) {
// 유효하지 않은 경우 오류 상태 설정
setEmailIsInvalid(true);
// 유효하지 않은 상태에서 여기서 return 하여 이후 로직 중단
return;
}
// 유효한 경우 오류 상태 초기화
setEmailIsInvalid(false);
// 여기서 서버 전송 로직(HTTP 요청) 또는 다음 단계 진행
console.log('유효한 이메일 주소가 제출되었습니다:', enteredEmail);
};
return (
<form onSubmit={handleSubmit}>
<div className={`control ${emailIsInvalid ? 'control-error' : ''}`}>
<label htmlFor="email">이메일</label>
<input type="email" id="email" ref={emailInputRef} />
{emailIsInvalid && (
<p className="error-text">유효한 이메일 주소를 입력해 주십시오.</p>
)}
</div>
<button type="submit">로그인</button>
</form>
);
};
export default RefBasedLogin;
emailInputRef를 통해 이메일 필드 값을 제출 시점에 가져옴.
handleSubmit 내에서 email.includes('@') 조건으로 유효성 검사.
유효하지 않다면 emailIsInvalid를 true로 두어 오류 메시지 표시.
유효하다면 emailIsInvalid를 false로 두고 폼 제출 로직(예: 서버 전송) 진행.
키 입력이나 포커스 해제 시점에서 검증하지 않고, 제출 시점에만 검증을 수행.
요약:
아래 정리는, 브라우저가 제공하는 내장(form) 유효성 검사 속성(HTML5 빌트인 검증 기능)을 활용하는 방법에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.
브라우저 내장(form) 유효성 검사 기능 활용
정리
사용자 입력 ──> 폼 요소(입력 필드)
│ │
│ ├── required, type="email", minLength 등 속성 지정
│ │
│ ▼
│ 사용자 폼 제출 시도
│
▼
브라우저 기본 검증 실행
│
├─ 유효성 조건 만족 O → 폼 제출 진행
│
└─ 유효성 조건 불만족 X → 브라우저 오류 메시지 표시
(사용자에게 유효한 값 요구)
아래 예시는 React + TypeScript 환경에서 브라우저의 기본 유효성 검증을 활용하는 폼 예제이다. 상태 관리나 추가 검증 로직 없이, HTML 속성만으로 기본적인 검증을 수행한다.
import React from 'react';
const Signup: React.FC = () => {
return (
<form>
<div className="control">
<label htmlFor="firstName">이름(First Name)</label>
<input
type="text"
id="firstName"
name="firstName"
required
/>
</div>
<div className="control">
<label htmlFor="email">이메일(Email)</label>
<input
type="email"
id="email"
name="email"
required
/>
</div>
<div className="control">
<label htmlFor="password">비밀번호(Password)</label>
<input
type="password"
id="password"
name="password"
required
minLength={6}
/>
</div>
<div className="control">
<label htmlFor="acquisition">어떻게 알게 되었나요?(Select)</label>
<select
id="acquisition"
name="acquisition"
required
>
<option value="">선택해 주세요</option>
<option value="search">검색 엔진</option>
<option value="sns">SNS</option>
<option value="friend">지인 추천</option>
</select>
</div>
<div className="control">
<label>
<input
type="checkbox"
name="terms"
required
/>
이용약관에 동의합니다.
</label>
</div>
<button type="submit">회원가입</button>
</form>
);
};
export default Signup;
required: 해당 필드를 비우면 브라우저가 제출을 막고 기본 오류 메시지 표시.
type="email": 이메일 형식이 아니면 브라우저가 자동으로 오류 메시지 표시.
minLength={6}: 비밀번호 최소 길이를 6글자로 지정, 미달 시 오류 메시지.
select 및 checkbox에도 required 지정 가능.
요약:
아래 정리는 HTML5 빌트인 유효성 검사 기능과 커스텀 자바스크립트(리액트) 로직을 결합하여 유효성 검증을 강화하는 방법에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 React 예시 코드를 첨부하였습니다.
빌트인 유효성 검증과 커스텀 로직의 결합
정리
사용자 양식 입력
│
└─> 브라우저 빌트인 검증 (required, type="email", minLength 등)
│
├─ 기본 조건 충족 못할 시 브라우저 오류 메시지 표시
│
▼ 기본 조건 충족 (빌트인 검증 통과)
양식 제출 버튼 클릭(onSubmit)
│
▼
handleSubmit 함수 실행
│
├─ 커스텀 검증 로직 (예: password와 confirm-password 일치 여부 확인)
│ ├─ 불일치 시: setPasswordsAreNotEqual(true), 제출 중단(return)
│ └─ 일치 시: passwordsAreNotEqual(false)
│
└─ 모든 검증 통과 시: 데이터 처리(로그, 서버 전송 등)
아래 코드는 React + TypeScript 환경에서 빌트인 검증과 커스텀 로직을 조합한 회원가입 폼 예시이다.
import React, { useState, FormEvent } from 'react';
interface SignupData {
firstName: string;
email: string;
password: string;
'confirm-password': string;
terms: string;
}
const EnhancedSignup: React.FC = () => {
const [passwordsAreNotEqual, setPasswordsAreNotEqual] = useState(false);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = event.currentTarget;
// 브라우저 빌트인 검증: form.checkValidity()를 통해 확인 가능
// 이 예제에서는 브라우저가 submit 시 자동으로 오류 표시
const formData = new FormData(form);
const data: SignupData = {
firstName: formData.get('firstName') as string,
email: formData.get('email') as string,
password: formData.get('password') as string,
'confirm-password': formData.get('confirm-password') as string,
terms: formData.get('terms') as string,
};
// 커스텀 검증: 비밀번호와 비밀번호 확인 일치 여부 검사
if (data.password !== data['confirm-password']) {
setPasswordsAreNotEqual(true);
return; // 제출 중단
}
// 비밀번호 일치 시 오류 상태 초기화
setPasswordsAreNotEqual(false);
// 여기서 서버 전송 또는 다른 로직 수행
console.log('유효한 데이터 제출:', data);
};
return (
<form onSubmit={handleSubmit}>
<div className="control">
<label htmlFor="firstName">이름(First Name)</label>
<input
type="text"
id="firstName"
name="firstName"
required
/>
</div>
<div className="control">
<label htmlFor="email">이메일(Email)</label>
<input
type="email"
id="email"
name="email"
required
/>
</div>
<div className="control">
<label htmlFor="password">비밀번호(Password)</label>
<input
type="password"
id="password"
name="password"
required
minLength={6}
/>
</div>
<div className="control">
<label htmlFor="confirm-password">비밀번호 확인(Confirm Password)</label>
<input
type="password"
id="confirm-password"
name="confirm-password"
required
minLength={6}
/>
{passwordsAreNotEqual && (
<div className="control-error">
비밀번호가 일치하지 않습니다.
</div>
)}
</div>
<div className="control">
<label>
<input
type="checkbox"
name="terms"
required
/>
이용약관에 동의합니다.
</label>
</div>
<button type="submit">회원가입</button>
</form>
);
};
export default EnhancedSignup;
브라우저 빌트인 검증으로 기본 형태(required, type="email", minLength 등)를 처리.
handleSubmit 내에서 추가 커스텀 검증(비밀번호 일치)을 수행.
불일치 시 setPasswordsAreNotEqual(true)로 상태 업데이트 → 오류 메시지 표시 및 제출 중단.
모든 조건 만족 시 서버 전송 혹은 다음 단계 진행 가능.
요약:
아래 정리는 복잡한 입력 관리 로직과 유효성 검증 코드 중복을 줄이기 위해 재사용 가능한 커스텀 Input 컴포넌트를 도입하는 방법을 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.
문제 상황
해결 방법: 재사용 가능한 커스텀 Input 컴포넌트
정리
사용자 키 입력, 포커스 변화
│
▼
StateLogin.jsx
(상태 관리, 유효성 검증)
│
│ props 전달: label, type, value, error 등
▼
Input.jsx (커스텀 컴포넌트)
┌─────────────────────────────────────┐
│ label, input, 에러 메시지 공통 구조 │
│ props를 통해 전달받은 값에 따라 렌더링 │
└─────────────────────────────────────┘
│
└─ 렌더링 결과: 재사용 가능한 입력 UI
아래 코드는 React + TypeScript 환경에서 Input 커스텀 컴포넌트와 이를 사용하는 StateLogin 컴포넌트를 구현한 간단한 예시이다.
// Input.tsx
import React, { ChangeEvent, FocusEvent } from 'react';
interface InputProps {
label: string;
id: string;
error?: string; // 오류 메시지 (없으면 오류 없음)
// 나머지 속성들: type, name, value, onChange, onBlur 등
[key: string]: any;
}
const Input: React.FC<InputProps> = ({ label, id, error, ...rest }) => {
return (
<div className={`control ${error ? 'control-error' : ''}`}>
<label htmlFor={id}>{label}</label>
<input id={id} {...rest} />
{error && <p className="error-text">{error}</p>}
</div>
);
};
export default Input;
// StateLogin.tsx
import React, { useState, ChangeEvent, FocusEvent } from 'react';
import Input from './Input';
const StateLogin: React.FC = () => {
const [enteredValues, setEnteredValues] = useState({ email: '', password: '' });
const [didEdit, setDidEdit] = useState({ email: false, password: false });
// 유효성 검증 로직
const emailIsInvalid = didEdit.email && !enteredValues.email.includes('@');
const passwordIsInvalid = didEdit.password && enteredValues.password.trim().length < 6;
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setEnteredValues(prev => ({ ...prev, [name]: value }));
// 키 입력 시 didEdit를 false로 reset하여 오류 메시지 숨기기
setDidEdit(prev => ({ ...prev, [name]: false }));
};
const handleInputBlur = (e: FocusEvent<HTMLInputElement>) => {
const { name } = e.target;
// 포커스 해제 시 didEdit를 true로 설정
setDidEdit(prev => ({ ...prev, [name]: true }));
};
// 오류 메시지
const emailErrorMsg = emailIsInvalid ? '유효한 이메일 주소를 입력해주십시오.' : '';
const passwordErrorMsg = passwordIsInvalid ? '유효한 비밀번호를 입력하십시오.' : '';
return (
<form>
<div className="control-row">
<Input
label="이메일"
id="email"
name="email"
type="email"
value={enteredValues.email}
onChange={handleInputChange}
onBlur={handleInputBlur}
error={emailErrorMsg}
/>
<Input
label="비밀번호"
id="password"
name="password"
type="password"
value={enteredValues.password}
onChange={handleInputChange}
onBlur={handleInputBlur}
error={passwordErrorMsg}
/>
</div>
<button type="submit">로그인</button>
</form>
);
};
export default StateLogin;
Input 컴포넌트는 공통적인 UI 구조(레이블, 인풋, 오류 메시지)를 정의.
StateLogin 컴포넌트에서는 상태와 유효성 검증 로직을 유지하면서, 각 필드를 Input 컴포넌트를 사용해 간결하게 렌더링.
에러 메시지나 이벤트 핸들러를 props로 전달할 수 있으므로, 같은 구조를 가진 다른 입력 필드에도 쉽게 적용 가능.
요약:
아래 정리는 유효성 검증 로직을 재사용 가능한 형태로 분리하고, 이를 여러 컴포넌트에서 활용하는 방법에 대해 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.
문제 상황
해결 방법: 유틸리티 함수로 유효성 검증 로직 분리
정리
사용자 입력 ──> StateLogin.jsx
│ │
│ ├─ didEdit 상태 검사
│ ├─ enteredValues 확인
│ ▼
│ 유틸 검증 함수 호출
│ (isEmail, isNotEmpty, hasMinLength 등)
│
├─ 검증 결과 반환(Boolean)
│
└─ 결과에 따라 emailIsInvalid, passwordIsInvalid 등 상태 설정
│
└─ 상태 기반으로 UI 업데이트(오류 메시지 표시/숨김)
export function isEmail(value: string): boolean {
return value.includes('@');
}
export function isNotEmpty(value: string): boolean {
return value.trim() !== '';
}
export function hasMinLength(value: string, length: number): boolean {
return value.trim().length >= length;
}
import React, { useState, ChangeEvent, FocusEvent } from 'react';
import Input from './Input';
import { isEmail, hasMinLength } from '../util/validation';
const StateLogin: React.FC = () => {
const [enteredValues, setEnteredValues] = useState({ email: '', password: '' });
const [didEdit, setDidEdit] = useState({ email: false, password: false });
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setEnteredValues(prev => ({ ...prev, [name]: value }));
// 키 입력 시 didEdit를 false로 reset
setDidEdit(prev => ({ ...prev, [name]: false }));
};
const handleInputBlur = (e: FocusEvent<HTMLInputElement>) => {
const { name } = e.target;
setDidEdit(prev => ({ ...prev, [name]: true }));
};
// 유효성 검증: 유틸 함수 활용
const emailIsInvalid = didEdit.email && !isEmail(enteredValues.email);
const passwordIsInvalid = didEdit.password && !hasMinLength(enteredValues.password, 6);
const emailErrorMsg = emailIsInvalid ? '유효한 이메일 주소를 입력해주십시오.' : '';
const passwordErrorMsg = passwordIsInvalid ? '유효한 비밀번호를 입력하십시오.' : '';
return (
<form>
<div className="control-row">
<Input
label="이메일"
id="email"
name="email"
type="email"
value={enteredValues.email}
onChange={handleInputChange}
onBlur={handleInputBlur}
error={emailErrorMsg}
/>
<Input
label="비밀번호"
id="password"
name="password"
type="password"
value={enteredValues.password}
onChange={handleInputChange}
onBlur={handleInputBlur}
error={passwordErrorMsg}
/>
</div>
<button type="submit">로그인</button>
</form>
);
};
export default StateLogin;
validation.ts 내 함수들을 통해 유효성 검사 로직을 캡슐화하여 재사용.
StateLogin 컴포넌트는 검증 함수 호출만으로 유효성 판단 가능.
검증 로직 변경 시 validation.ts만 수정하면 전체 앱에 반영.
요약:
아래 정리는 커스텀 훅(useInput
)을 활용해 입력값 및 유효성 검증 로직을 재사용 가능한 형태로 캡슐화하고, 컴포넌트 코드를 대폭 간결화하는 방법에 대해 이해하기 쉽고 자세하게 재구성한 것입니다.
또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.
문제 상황
해결 방법: 커스텀 훅(useInput) 도입
정리
사용자 키 입력, 포커스 변화
│
▼
useInput(기본값, 검증함수) 훅
│
├─ useState로 enteredValue, didEdit 관리
├─ handleInputChange, handleInputBlur 정의
├─ validationFn(enteredValue) 호출로 valueIsValid 판별
└─ hasError = didEdit && !valueIsValid 계산
▼
호출한 컴포넌트(StateLogin 등)
│
├─ const { value, hasError, handleInputChange, handleInputBlur } = useInput(...)
├─ UI에 value 반영, hasError 시 오류 메시지 표시
└─ 제출 시 hasError 검사 후 로직 분기
import { useState } from 'react';
type ValidationFn = (value: string) => boolean;
interface UseInputReturn {
value: string;
hasError: boolean;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleInputBlur: (e: React.FocusEvent<HTMLInputElement>) => void;
}
export function useInput(defaultValue: string, validationFn: ValidationFn): UseInputReturn {
const [enteredValue, setEnteredValue] = useState(defaultValue);
const [didEdit, setDidEdit] = useState(false);
const valueIsValid = validationFn(enteredValue);
const hasError = didEdit && !valueIsValid;
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEnteredValue(e.target.value);
setDidEdit(false);
};
const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setDidEdit(true);
};
return {
value: enteredValue,
hasError,
handleInputChange,
handleInputBlur
};
}
import React, { FormEvent } from 'react';
import Input from './Input';
import { useInput } from '../hooks/useInput';
import { isEmail, hasMinLength } from '../util/validation';
const StateLogin: React.FC = () => {
const {
value: emailValue,
hasError: emailHasError,
handleInputChange: handleEmailChange,
handleInputBlur: handleEmailBlur
} = useInput('', (val) => isEmail(val));
const {
value: passwordValue,
hasError: passwordHasError,
handleInputChange: handlePasswordChange,
handleInputBlur: handlePasswordBlur
} = useInput('', (val) => hasMinLength(val, 6));
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
// 유효성 검사
if (emailHasError || passwordHasError) {
return; // 오류가 있으면 제출 중단
}
// 유효하면 후속 작업 (예: 서버 전송)
console.log('이메일:', emailValue, '비밀번호:', passwordValue);
};
return (
<form onSubmit={handleSubmit}>
<div className="control-row">
<Input
label="이메일"
id="email"
name="email"
type="email"
value={emailValue}
onChange={handleEmailChange}
onBlur={handleEmailBlur}
error={emailHasError ? '유효한 이메일 주소를 입력해주세요.' : ''}
/>
<Input
label="비밀번호"
id="password"
name="password"
type="password"
value={passwordValue}
onChange={handlePasswordChange}
onBlur={handlePasswordBlur}
error={passwordHasError ? '유효한 비밀번호를 입력하십시오.(6글자 이상)' : ''}
/>
</div>
<button type="submit">로그인</button>
</form>
);
};
export default StateLogin;
useInput 훅은 단일 입력 필드 상태 관리 및 유효성 검증을 전담한다.
각 입력 필드는 useInput 훅을 별도로 호출해 자신만의 상태와 검증 로직을 갖는다.
StateLogin에서는 더 이상 상태나 이벤트 핸들러를 직접 관리할 필요 없이, useInput에서 반환한 값과 함수만으로 UI와 검증 로직을 연결할 수 있다.
요약:
아래 정리는 폼 처리 및 유효성 검증을 위한 다양한 접근 방식과, 이를 더욱 편리하게 해줄 수 있는 서드파티 라이브러리의 존재를 이해하기 쉽고 자세하게 재구성한 것입니다. 또한 화살표를 통한 데이터 흐름 도식과 타입스크립트 기반의 예시 코드를 첨부하였습니다.
기본적인 폼 처리:
유효성 검증(Validation) 전략:
코드 재사용성 향상:
서드파티 라이브러리 소개
정리
사용자 입력 ──> 리액트 상태 관리 or FormData or refs
│
├─ 브라우저 내장 검증, 커스텀 검증 로직, 커스텀 훅(useInput)
│ │
│ └─ hasError, valueIsValid 등 Boolean 결과 반환
│
└─ UI 업데이트: 오류 메시지 표시, 제출 가능 여부 제어
추후 확장:
서드파티 라이브러리 (React Hook Form, Formik 등)
│
├─ 폼 상태 관리 및 검증 로직 자동화
├─ 커스텀 훅/컴포넌트 제공
└─ 오류 메시지 및 유효성 검사 결과를 더 쉽게 관리
import React from 'react';
import { useForm } from 'react-hook-form';
interface FormData {
email: string;
password: string;
}
const HookFormExample: React.FC = () => {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<FormData>();
const onSubmit = (data: FormData) => {
console.log('양식 데이터:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="control">
<label htmlFor="email">이메일:</label>
<input
id="email"
type="email"
{...register('email', { required: '이메일은 필수입니다.', pattern: { value: /\S+@\S+\.\S+/, message: '올바른 이메일 주소를 입력하세요.' } })}
/>
{errors.email && <p className="error-text">{errors.email.message}</p>}
</div>
<div className="control">
<label htmlFor="password">비밀번호:</label>
<input
id="password"
type="password"
{...register('password', { required: '비밀번호는 필수입니다.', minLength: { value: 6, message: '비밀번호는 최소 6자 이상이어야 합니다.' } })}
/>
{errors.password && <p className="error-text">{errors.password.message}</p>}
</div>
<button type="submit">로그인</button>
</form>
);
};
export default HookFormExample;
useForm 훅을 사용해 폼 상태 및 유효성 검증을 자동화.
register 함수를 통해 각 입력 필드를 폼 상태에 연결하고, 검증 규칙(required, pattern, minLength)을 손쉽게 지정.
에러 메시지를 errors 객체에서 바로 가져와 표시할 수 있어 별도의 상태 관리나 훅 구현 필요 없음.
기존 섹션에서 배운 모든 개념(검증, 상태 관리, 유효성 표시)이 라이브러리를 통해 한층 간단해진다.
요약:
프로젝트 앱
이번 프로젝트를 통해 우리는 다음과 같은 점들을 학습 및 실습하게 됩니다.
사용자 상호작용(버튼 클릭, 아이템 선택)
│
↓
[프론트엔드 UI 컴포넌트] → (상태 변경 요청) → [상태 관리 (State/Context)]
│ │
│ ↓
│ [HTTP 요청 발생] → [백엔드 서버]
│ │
│ ↓
│ [응답 (데이터)]
│ ↑
└───────────────────────────────────┘
│
↓
UI 업데이트 (데이터 반영)
사용자가 UI를 통해 어떤 액션을 취하면(UI 컴포넌트)
해당 변화가 상태 관리 로직(Context, State)에 반영
필요 시 백엔드로 데이터를 요청하거나 주문을 제출하는 HTTP 요청 발생
서버로부터 응답을 받으면 상태를 업데이트하고 UI를 재렌더링
최종적으로 사용자에게 새로운 상태가 반영된 인터페이스 제공
이번 단계에서는 프로젝트 초기 구성을 토대로 음식 주문 앱의 "헤더(Header)" 컴포넌트를 먼저 만들어보는 과정을 다룹니다. 이러한 작업 순서를 선택한 이유는 다음과 같습니다.
간단한 것부터 시작하기: 복잡한 로직 없이 단순히 UI 요소(헤더) 하나를 먼저 구현하면서 프로젝트의 뼈대를 잡아나갑니다. 이를 통해 빠른 성취감을 얻고, 이후 확장 기능(음식 목록, 장바구니, 결제 등)에 집중하기 전에 기초 환경을 안정화할 수 있습니다.
프로젝트 구조 확립: src/components 폴더를 생성하고, 그 안에 Header.jsx (실제 작업 시 Header.tsx) 파일을 만들어 컴포넌트를 정의합니다. 이렇게 컴포넌트를 분리 관리하면서 코드의 가독성과 유지보수성을 높이는 것이 리액트 프로젝트에서 일반적인 패턴입니다.
헤더 컴포넌트 내용:
<img>
태그로 추가합니다.스타일 적용:
헤더 컴포넌트 적용:
[Header 컴포넌트 렌더링 요청]
│
↓
<Header /> 컴포넌트
│ ┌───────────────────┐
│ │ 정적 데이터 (로고, |
│ │ 제목 등) 렌더링 |
│ └───────────────────┘
↓
화면 UI 갱신 (헤더 표시)
여기서 아직 HTTP 요청이나 상태 변화가 없는 단순한 컴포넌트이므로, 데이터 흐름은 매우 단순합니다. 추후 장바구니 아이템 수 표시나, 백엔드 데이터와 연동될 때 흐름이 복잡해질 것입니다.
이번 단계에서는 음식 메뉴 데이터를 백엔드(임시 서버)로부터 가져와 화면에 표시하는 과정을 다룹니다. 이전 단계에서 간단한 헤더 컴포넌트를 만들었다면, 이제 본격적으로 비동기 데이터 로딩과 상태 관리를 다루게 됩니다.
핵심 흐름은 다음과 같습니다.
Meals 컴포넌트 생성:
src/components/Meals.jsx
파일을 생성하여 Meals
라는 컴포넌트를 정의합니다. 이 컴포넌트는 음식 목록을 표시하는 <ul>
요소를 반환하게 됩니다.<ul>
요소에는 id="meals"
라는 속성을 부여하며, index.css
에 정의된 스타일 규칙을 활용할 수 있습니다.백엔드 데이터 가져오기:
상태 관리(useState):
useEffect 사용:
UI 렌더링:
<li>
로 렌더링합니다. 현재는 음식의 이름만 표시하지만, 추후 필요에 따라 가격, 설명, 이미지 등을 추가할 수 있습니다.이 과정을 통해 컴포넌트는 비동기 데이터 로딩부터 상태 업데이트, UI 렌더링까지의 전 과정을 경험하게 됩니다.
[Meals 컴포넌트 렌더링]
│
↓
useEffect 실행 → fetchMeals 함수 호출
│
↓
fetch('http://localhost:3000/meals')
│
↓ (HTTP GET 요청)
[백엔드 서버]
│
↓ (JSON 데이터 응답)
응답 → response.json()
│
↓
데이터(loadedMeals)에 setLoadedMeals를 통해 상태 업데이트
│
↓
컴포넌트 재렌더링 → loadedMeals.map()으로 UI 요소(li) 생성
│
↓
화면에 음식 목록 표시
이번 단계에서는 이전에 불러온 음식 데이터(각 메뉴 아이템)를 화면에 더욱 풍부하게 표시하기 위해 새로운 컴포넌트인 MealItem을 추가합니다. 이 과정을 통해 UI 마크업 구조 분리, CSS 클래스 적용, 전달받은 데이터 활용 등을 학습하게 됩니다.
주요 흐름은 다음과 같습니다.
MealItem 컴포넌트 추가:
<img>
태그를 사용하고, alt 속성에 음식 이름을 전달합니다.Meals 컴포넌트에서 MealItem 사용:
<li>
태그로 직접 음식 이름만 표시하던 방식을 개선합니다.<MealItem meal={meal} key={meal.id} />
형태로 반환합니다.이미지 경로 수정:
추후 계획:
정리하자면, 이번 단계에서는 개별 음식 메뉴를 위한 MealItem
컴포넌트를 도입하여 UI를 구조화하고, 백엔드로부터 가져온 이미지와 상세정보를 실제 화면에 반영하는 방법을 익혔습니다.
[Meals 컴포넌트]
│
loadedMeals(state)
│
┌───┴─────────────────┐
│ │ (.map)
↓ ↓
MealItem(meal 데이터) → UI 렌더링
│
↓
<article> 내부의 img, h3, p 등
│
↓
화면에 음식 상세 정보 표시
이번 단계에서는 음식 메뉴 가격을 국제화(Internationalization, i18n) 기능을 사용해 통일된 화폐 형식으로 표시하는 방법을 다룹니다. 이를 통해 가격을 일정한 형식으로 나타내어 사용자 경험을 개선할 수 있습니다.
별도 유틸리티 폴더(util) 및 파일 생성:
Intl.NumberFormat 활용:
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
});
이러한 작업을 통해 가격이 일관되게 표현되며, 이후 다른 컴포넌트나 기능에서도 동일한 포맷터를 재사용할 수 있습니다.
[MealItem 컴포넌트]
│
│ (meal.price 사용)
↓
[util/formatting.js] 내 currencyFormatter
│
│ currencyFormatter.format(meal.price)
↓
포맷된 화폐 문자열 반환
│
↓
[MealItem 컴포넌트 UI] → 화면에 통일된 화폐 형식 가격 표시
UI 폴더 생성 및 버튼 컴포넌트 작성:
<button>
요소를 감싸며, 다양한 속성과 스타일을 유연하게 적용할 수 있는 재사용 가능한 컴포넌트가 됩니다.자식 콘텐츠(Children) 활용:
<Button>버튼 텍스트</Button>
처럼 자식 요소로 전달되는 텍스트나 JSX를 버튼 내부에 표시합니다.조건부 스타일링(textOnly 속성):
클래스 이름 병합하기:
나머지 속성 전개(Resting Props):
...props
문법을 사용하여 onClick, type 등의 다양한 속성을 <button>
요소에 그대로 전달합니다. 이를 통해 커스텀 버튼이 실제 <button>
과 유사한 사용 경험을 제공할 수 있습니다.적용 예시:
[Button 컴포넌트]
│
│ props (textOnly, className, onClick, etc.)
↓
조건에 따른 CSS 클래스 결정
│
↓
<button ...props> {props.children} </button>
│
↓
화면에 일관된 스타일 및 기능의 버튼 표시
│
↓
[MealItem 컴포넌트 / Header 컴포넌트 등]
│
└─ Button 컴포넌트를 사용하여 다양한 위치에서 일관된 버튼 UI 제공
이번 단계에서는 장바구니 데이터 관리를 위해 컨텍스트(Context)와 리듀서(useReducer)를 활용하는 방식을 다룹니다. 장바구니 데이터는 앱 곳곳에서 필요하므로, 단일 컴포넌트(App)에서 상태를 관리하는 대신 리액트의 Context를 사용하여 전역 상태로 관리하고, 이를 필요한 컴포넌트가 쉽게 접근할 수 있도록 합니다.
[MealsItem 컴포넌트] -- "장바구니에 담기" 버튼 클릭 --> [CartContextProvider] -- useReducer --> [cartReducer]
│ │
│ addItem() 호출 │
│----------------------------------------------------> 액션 { type: 'ADD_ITEM', item: {...} }
│
↓
새 상태 반환 (장바구니 업데이트)
│
↓
Context 업데이트 → UI 재렌더링
// src/store/CartContext.tsx
import React, {useReducer, createContext, ReactNode} from 'react';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
}
type CartAction =
| { type: 'ADD_ITEM'; item: CartItem }
| { type: 'REMOVE_ITEM'; id: string };
interface CartContextProps {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
}
const CartContext = createContext<CartContextProps>({
items: [],
addItem: () => {
},
removeItem: () => {
},
});
const cartReducer = (state: CartState, action: CartAction): CartState => {
if (action.type === 'ADD_ITEM') {
const existingCartItemIndex = state.items.findIndex(
(item) => item.id === action.item.id
);
let updatedItems: CartItem[];
if (existingCartItemIndex >= 0) {
// 항목이 이미 존재하는 경우 수량 증가
const existingItem = state.items[existingCartItemIndex];
const updatedItem = {...existingItem, quantity: existingItem.quantity + action.item.quantity};
updatedItems = [...state.items];
updatedItems[existingCartItemIndex] = updatedItem;
} else {
// 항목이 없는 경우 새로 추가 (quantity 초기값 1 또는 action.item.quantity 사용)
updatedItems = state.items.concat(action.item);
}
return {items: updatedItems};
}
if (action.type === 'REMOVE_ITEM') {
const updatedItems = state.items.filter(item => item.id !== action.id);
return {items: updatedItems};
}
return state;
};
const CartContextProvider: React.FC<{ children: ReactNode }> = ({children}) => {
const [cartState, dispatch] = useReducer(cartReducer, {items: []});
const addItemHandler = (item: CartItem) => {
dispatch({type: 'ADD_ITEM', item});
};
const removeItemHandler = (id: string) => {
dispatch({type: 'REMOVE_ITEM', id});
};
const contextValue: CartContextProps = {
items: cartState.items,
addItem: addItemHandler,
removeItem: removeItemHandler,
};
return (
<CartContext.Provider value={contextValue}>
{children}
</CartContext.Provider>
);
};
export {CartContext, CartContextProvider};
CartContext를 생성하고, 장바구니 상태와 업데이트 함수를 제공합니다.
CartContextProvider는 useReducer를 활용해 장바구니 상태를 관리하고, 액션 타입(ADD_ITEM, REMOVE_ITEM)에 따라 상태를 업데이트합니다.
MealsItem 혹은 다른 컴포넌트에서 CartContext를 구독(useContext)하여 addItem, removeItem 함수를 사용하면 장바구니 상태를 손쉽게 제어할 수 있습니다.
이번 단계에서는 장바구니의 항목을 제거하는 로직을 구현하고, 이 컨텍스트를 이용해 앱 곳곳에서 장바구니 데이터를 활용하는 방법을 다룹니다. 이전 단계에서 구현한 항목 추가 로직(ADD_ITEM)에 이어, 항목 제거 로직(REMOVE_ITEM)을 구현하여 장바구니 항목 수량 조정 기능을 완성하게 됩니다.
REMOVE_ITEM 로직 추가:
CartContextProvider 개선:
MealItem 및 Header 컴포넌트 연동:
이 과정을 통해 복잡한 상태 관리 로직을 컨텍스트와 리듀서로 분리하여 코드의 유지보수성과 확장성을 높이며, 다양한 컴포넌트에서 쉽게 장바구니 상태를 참조하고 조작할 수 있게 합니다.
[MealItem 컴포넌트] -- "장바구니에 담기" 클릭 --> addItem() 호출
| ↓
| [CartContextProvider]
| ↓
| dispatch({type:'ADD_ITEM', item})
| ↓
| [cartReducer] ADD_ITEM 로직
| ↓
(장바구니 상태 업데이트)
|
[Header 컴포넌트] -- useContext로 장바구니 items 접근
|---- reduce() 통해 items의 총 quantity 계산 → 화면 반영
[장바구니 항목 제거 로직] -- REMOVE_ITEM dispatch 호출 시
|--> [cartReducer] REMOVE_ITEM 로직
|--> 항목 수량 감소 또는 항목 완전 삭제
|--> 상태 변경 → UI 재렌더링
이번 단계에서는 장바구니 모달 창을 구현하고, 이를 통해 장바구니 데이터를 사용자에게 보여주는 과정을 다룹니다. 또한 사용자의 진행 상태(장바구니 보기/결제창 이동)를 관리하기 위해 별도의 컨텍스트(UserProgressContext)를 추가하여 모달 열림/닫힘 및 단계 전환을 제어합니다.
이로써 사용자 인터페이스 흐름에서 장바구니 내용을 모달로 띄울 수 있고, 필요에 따라 모달을 닫거나 결제 프로세스로 진행할 수 있습니다.
[Header 컴포넌트]
│ (장바구니 버튼 클릭)
↓ showCart() 호출
[UserProgressContext] -- progress = 'cart'
│
↓
[Cart 컴포넌트]
│
├─ useContext(CartContext) → cartCtx.items
│ (장바구니 아이템 렌더링)
│
└─ useContext(UserProgressContext) → userProgress
│
└─ progress === 'cart' → 모달 open = true
│
└─ '닫기' 버튼 클릭 → hideCart() 호출
→ progress = '' (빈 문자열)
→ 모달 open = false
이번 단계에서는 장바구니 항목을 별도 컴포넌트(CartItem)로 분리하고, 항목에 대한 수량 증가/감소 로직을 추가하는 과정을 다룹니다. 이를 통해 코드 구조를 개선하고 재사용성을 높이며, 장바구니 UI를 좀 더 직관적으로 만들 수 있습니다.
CartItem 컴포넌트 분리:
데이터 및 함수 전달 방식:
기능적 변화:
[Cart 컴포넌트]
│ items.map(...)
│
↓ (각 item 전달)
<CartItem
name={item.name}
quantity={item.quantity}
price={item.price}
onIncrease={() => cartCtx.addItem({ ...item, quantity: 1 })}
onDecrease={() => cartCtx.removeItem(item.id)}
/>
[CartItem 컴포넌트]
│
│ onIncrease 버튼 클릭 → onIncrease 함수 호출 → cartCtx.addItem(...)
│ onDecrease 버튼 클릭 → onDecrease 함수 호출 → cartCtx.removeItem(...)
↓
장바구니 상태 변화(useReducer로 관리)
│
└─ 변경된 상태 UI 재렌더링
이 흐름을 통해 CartItem에서 발생한 클릭 이벤트가 Cart 컴포넌트 → CartContext → useReducer를 거쳐 상태 업데이트로 이어지고, 그 결과 UI에 즉각 반영됩니다.
이번 단계에서는 결제창(Checkout) 기능을 추가하고, 모달 열림/닫힘 제어 로직을 더욱 정교하게 다룹니다. 구체적으로 다음과 같은 변화가 이루어집니다.
'결제창으로 이동' 버튼 조건부 표시:
Checkout 컴포넌트 추가:
UserProgressContext를 통한 상태 제어:
ESC 키로 모달 닫기 처리 개선:
이로써 사용자는 장바구니 → 결제창으로 자연스럽게 이동할 수 있으며, 모달을 어떤 방식으로 닫더라도 앱 상태가 일관성 있게 유지됩니다.
[Header] -- (장바구니 버튼 클릭) --> progress = 'cart' → Cart 모달 표시
[Cart 컴포넌트]
│ (장바구니 항목 추가/삭제)
│
│ (결제창으로 이동 버튼 클릭)
↓
progress = 'checkout' → Cart 모달 닫힘, Checkout 모달 표시
[Checkout 컴포넌트]
│
│ (ESC 키로 모달 닫기 or 닫기 버튼 클릭)
↓
progress = '' (빈 문자열) → Checkout 모달 닫힘
장바구니 > 결제창 이동 시 progress 상태를 변경하여 모달 전환을 제어합니다.
ESC나 닫기 버튼으로 모달 닫을 때도 progress 상태를 동기화해 언제든지 다시 모달을 열 수 있습니다.
이로써 결제창 전환 기능이 완성되었으며, 앱 상태와 UI가 항상 일치하도록 관리할 수 있습니다.
이번 단계에서는 결제(Checkout) 양식을 실제로 제출(submit)하는 과정을 다룹니다. 목표는 다음과 같습니다.
양식 제출 시 이벤트 객체(event)에서 event.target으로 양식 요소에 접근하고, 이 요소를 FormData 생성자에 전달합니다.
FormData 객체를 통해 모든 입력 필드에 할당된 name 속성을 기준으로 사용자가 입력한 값을 간단히 얻을 수 있습니다.
const formData = new FormData(event.target);
const data = Object.fromEntries(formData.entries());
이렇게 하면 data가 { fullName: '입력값', email: '입력값', ... } 형태의 객체가 되어, 각 입력 필드에 대한 값을 속성으로 쉽게 접근할 수 있습니다.
정리하자면, 이번 단계에서 폼 제출 로직을 제어하고, 브라우저 기본 유효성 검증 및 FormData를 활용하여 입력값을 손쉽게 추출하는 방법을 구현했습니다. 다음 시간에는 이 데이터를 백엔드 서버로 실제 전송하여 주문을 완료하는 과정을 다룰 예정입니다.
[Checkout 컴포넌트]
│ (onSubmit)
│
↓ preventDefault() → 기본 제출 동작 방지
↓ FormData(event.target)
↓ Object.fromEntries(formData.entries())
│
│→ data 객체 획득 { fullName: "...", email: "...", ... }
│
│ (다음 단계에서) 백엔드로 주문 데이터 전송
사용자가 양식을 제출하면 폼 제출 이벤트 발생
preventDefault()를 통해 기본 동작 차단
FormData와 Object.fromEntries를 사용해 입력값을 객체 형태로 추출
추출한 데이터는 향후 백엔드로 전송하는 데 사용
이번 단계에서는 결제 양식 데이터를 장바구니 데이터와 함께 백엔드로 전송하여 주문을 완료하는 과정을 다룹니다.
POST 요청 전송:
데이터 구조 일치화:
양식 데이터 + 장바구니 데이터 결합:
양식 제출 시 FormData를 이용해 입력값을 추출합니다.
const formData = new FormData(event.target);
const data = Object.fromEntries(formData.entries());
data에는 사용자가 입력한 고객 정보가 담기며, 여기에 cartCtx.items(장바구니 항목)를 함께 객체로 묶어 백엔드에 전송합니다.
백엔드 응답 확인:
후속 개선점:
[Checkout 컴포넌트] (사용자 양식 작성 → 결제 버튼 클릭)
│
│ onSubmit 이벤트 발생
↓
preventDefault() 실행
│
↓
FormData를 통해 사용자 입력값 추출
│
↓ (cartCtx.items와 결합)
{
customer: { name: "사용자입력", email: "...", ... },
items: [...장바구니 항목들...]
}
│
↓ fetch('/orders', { method: 'POST', ... })
│
│ 백엔드로 주문 데이터 전송
│
↓ 백엔드 응답 성공 (201 CREATED 등)
orders.json 파일에 주문 기록 저장
이번 단계에서는 HTTP 요청을 처리하는 공통 로직을 커스텀 훅(useHttp)으로 추출하여, 중복 코드를 줄이고 다양한 컴포넌트(예: 결제 컴포넌트, 음식 목록 컴포넌트)에서 재사용 가능하게 합니다. 이를 통해 코드 유지보수성이 향상되고, 로딩 상태나 에러 상태를 간단히 공유할 수 있습니다.
useHttp 커스텀 훅 도입:
상태 관리 (로딩, 에러, 데이터):
비동기 로직 처리 (async/await + fetch):
useCallback과 의존성 관리:
결과:
[컴포넌트] -- useHttp 훅 사용 --> { sendRequest, data, isLoading, error } 상태 얻음
│
│ (필요할 때) sendRequest 호출 (예: useEffect 또는 이벤트 핸들러)
↓
HTTP 요청 전송(fetch)
│
↓ 응답 대기
│
├─ 성공 응답 → data 업데이트, isLoading = false
│
├─ 실패 응답 → error 상태 업데이트, isLoading = false
│
└─ isLoading, error, data 상태를 통해 UI 변경
이번 단계에서는 사용자 경험(UX) 개선을 목표로, HTTP 요청 상태에 따른 다양한 UI 표현을 도입합니다. 예를 들어:
이러한 개선 작업을 통해, 사용자에게 단순히 빈 화면을 보여주는 대신 현재 상황을 명확히 전달하고, 문제가 발생했을 때도 의미 있는 피드백을 제공하게 됩니다.
향후 결제(Checkout) 컴포넌트에서도 같은 커스텀 훅을 사용하여, 요청 상태(로딩, 에러, 성공)에 따른 UI를 유사한 방식으로 개선할 계획입니다.
[Meals 컴포넌트]
│
├─ useHttp 훅으로 데이터 요청
│ │
│ ├─ isLoading: true일 때 → "메뉴 가져오는 중..." 출력 (center 정렬)
│ ├─ error 발생 시 → Error 컴포넌트로 에러 메세지 출력
│ └─ 성공 시 → 메뉴 리스트 정상 표시
↓
사용자의 화면에 로딩/에러/성공 상태에 따른 적절한 UI 반영
이번 단계에서는 결제(Checkout) 컴포넌트에서 useHttp 커스텀 훅을 활용하여 주문 요청 전송 과정을 개선하고, 다양한 상태(로딩, 에러, 성공)별로 사용자 경험을 최적화하는 방법을 다룹니다.
이러한 개선을 통해, 사용자에게 직관적이고 명확한 피드백을 제공할 수 있으며, 주문 프로세스를 매끄럽게 진행할 수 있습니다.
[Checkout 컴포넌트]
│
│ useHttp 훅 사용 (POST 요청용 구성 전달)
│
│ (양식 제출 시) handleSubmit 호출
↓
sendRequest(config + formData) 실행
│
├─ 로딩 중: "주문 데이터 보내는 중..." 표시
│
├─ 에러 발생: 에러 컴포넌트를 통해 오류 메시지 표시
│
└─ 성공: 성공 모달 표시 → 확인(Okay) 버튼 클릭 시
├─ 장바구니 비우기 (clearCart)
└─ useHttp 데이터 초기화 (clearData)
핵심 내용:
정리하자면:
(로컬 상태) useState/useReducer
│
▼
단일 컴포넌트 영향
(크로스 컴포넌트/앱 와이드 상태)
│
▼
리액트 컨텍스트 (간단한 전역 상태)
│
▼
prop 드릴링 감소
│
└─ 여전히 복잡해질 수 있음
(더 큰 규모, 복잡한 상태)
│
▼
리덕스(Redux)
├─ 중앙 스토어(Store)에 상태 집중화
├─ 액션(Action)과 리듀서(Reducer)로 명확한 업데이트 로직
└─ 규모가 커져도 예측 가능하고 유지 관리 용이
리덕스를 사용하는 주요 이유 중 하나는 리액트 컨텍스트(React Context)의 한계를 보완하기 위함입니다. 컨텍스트만으로 앱 와이드 상태를 관리할 수 있지만, 이는 규모가 커질수록 다음과 같은 잠재적 문제가 있습니다.
설정 및 관리 복잡성 증가:
성능 이슈:
결국, 규모가 크고 상태 변경이 빈번한 애플리케이션에서는 컨텍스트만으로는 한계가 있으며, 이때 리덕스(Redux)가 더 예측 가능하고 체계적인 상태 관리 패턴을 제공하여 상황을 개선할 수 있습니다.
(앱 와이드 상태 관리 필요)
│
리액트 컨텍스트만 사용
│
├─ 대형 앱: 다수의 ContextProvider 중첩
│ 또는 하나의 거대 Provider로 복잡성 ↑
│
├─ 변경 빈도 높은 상태 관리 시 성능 문제
│
↓
한계 및 비효율 ↑
│
────────────────────────
│
▼
리덕스 도입
│
중앙 스토어 & 정형화된 상태 업데이트 패턴
│
복잡성↓, 예측 가능성↑, 성능 개선
종합 흐름: 컴포넌트 → 액션 발송(dispatch) → 리듀서가 새로운 상태 계산 → 스토어 업데이트 → 구독 컴포넌트에게 알림 → 컴포넌트 UI 업데이트
[컴포넌트] ─ (dispatch) → [액션] → [스토어(리듀서)]
│ │
│ (구독) │ (새 상태 반환)
↓ ↓
[상태 변경 통지 수신] ←───────────────[새로운 상태 저장]
│
↓ (상태 업데이트 반영)
[컴포넌트 UI 재렌더링]
반드시 읽어야 할 내용: 리덕스 createStore()는 사용(불)가능합니다.
그 경고는 무시해야 합니다!
아래 정리는 리덕스(Redux)의 기초를 실제 코드 실행 과정과 함께 이해할 수 있도록 구성한 것입니다. 또한, 데이터 흐름도를 화살표로 표현하고, 타입스크립트 기반의 폴더 구조 및 예시 코드를 전체적으로 제안합니다.
┌────────────────┐ ┌────────────────┐
│ Action │ │ Reducer │
│ (ex:'increment')│ │ (counterReducer)│
└───────┬────────┘ └───────┬────────┘
│ │
│ dispatch(action) │ returns new state
▼ │
┌───────────┐ getState() ┌──▼───────────┐
│ Store │─────────────>│ Subscriber │
│ (createStore) └─────────────┘
└─────┬─────┘
│
│ subscribe(subscriberFn)
│
▼
console.log(state) or UI update
데이터 흐름: 액션 디스패치 → 리듀서 호출 → 새로운 상태 계산 → 스토어에 상태 저장 → 구독 함수 호출 → 상태 확인 및 출력
아래 정리는 리덕스(Redux)에서 액션 타입별로 상태 변경 로직을 처리하는 과정을 이해하기 쉽게 설명한 뒤, 데이터 흐름도를 제시하고, 타입스크립트 기반의 예시 코드를 제안한다.
Action
)에 의해 트리거된다. 액션은 { type: string, payload?: any }
형식의 객체이며, 최소한 type
필드를 가져야 한다.counterReducer
예시에서는 두 가지 액션 타입을 가정할 수 있다: ┌──────────────────┐ ┌───────────────────┐
│ Action │ │ Reducer │
│ {type:'increment'}│ │ (counterReducer) │
│ {type:'decrement'}│ └───────┬──────────┘
└───────┬─────────┘ │
│ dispatch(action) │
▼ │ returns new state based on action
┌───────────┐ getState() ┌─▼────────────┐
│ Store │ ─────────────>│ Subscriber │
│ (createStore) └────────────┘
└─────┬─────┘
│
subscribe(subscriberFn)
│
▼
console.log(state) or UI update
아래 정리는 리덕스를 리액트 앱에 통합하는 과정에 대한 설명을 이해하기 쉽게 정리한 것이다. 또한, 데이터 흐름도를 제시하고, 타입스크립트 기반의 예시 코드까지 제안한다.
리액트 앱에서 리덕스 스토어를 사용하기 위해서는 스토어를 리액트 컴포넌트 트리에 주입해줄 필요가 있다.
이를 위해 react-redux 라이브러리가 제공하는 <Provider>
컴포넌트를 사용한다.
<Provider store={store}>
로 래핑하면, 트리 하위 모든 컴포넌트가 리덕스 스토어에 접근할 수 있게 된다.
“제공(Provide)”한다는 것은 리액트 컴포넌트 계층 전반에 걸쳐 스토어를 참조할 수 있는 환경을 만드는 것을 의미한다.
요약
<Provider>
로 앱을 감싸 스토어를 제공한다.┌──────────────────┐ ┌───────────────────┐
│ React App │ │ Redux Store │
│(Components) │ │ (createStore) │
└─────┬────────────┘ └───────┬──────────┘
│ Provide │
│ <Provider store={store}> │
│ │
│ useDispatch(action) │
│------------------------->│
│ │
│ useSelector(state) │
│<-------------------------│
│ │
┌─────▼────────────┐ ┌───────────────────┐
│ Reducer │ │ state changes │
│(counterReducer) │ │ store updates state│
└───────────────────┘ └───────────────────┘
<Provider>
를 통해 스토어에 접근 가능해진다.예시 코드는 Redux 툴킷(Redux Toolkit) 사용 패턴을 참조하며, 가장 많이 사용되고 검증된 형태를 제시합니다. (물론 전통적인 createStore 사용 방식도 유사하지만, 최근에는 Redux Toolkit을 사용하는 것이 일반적이며, 타입스크립트와의 궁합도 좋습니다.)
// store/counterSlice.ts
import {createSlice, PayloadAction} from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = {
value: 0,
};
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
},
});
export const {increment, decrement, incrementByAmount} = counterSlice.actions;
export default counterSlice.reducer;
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
// 스토어 상태 타입과 디스패치 타입을 추론 기반으로 제공
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// main.tsx
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import {Provider} from "react-redux";
import {store} from "./store";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<App/>
</Provider>
</StrictMode>,
)
<Provider store={store}>
로 감싸면 App 컴포넌트 이하에서 스토어를 사용할 수 있습니다.// App.tsx (예시 컴포넌트)
import {useSelector, useDispatch} from 'react-redux';
import {RootState, AppDispatch} from "./store";
import {increment, decrement, incrementByAmount} from './store/counterSlice.ts';
function App() {
const count = useSelector((state: RootState) => state.counter.value);
const dispatch = useDispatch<AppDispatch>();
return (
<div>
<h1>카운터: {count}</h1>
<button onClick={() => dispatch(decrement())}>감소</button>
<button onClick={() => dispatch(increment())}>증가</button>
<button onClick={() => dispatch(incrementByAmount(5))}>5만큼 증가</button>
</div>
);
}
export default App;
useSelector로 상태를 읽고(state.counter.value),
useDispatch로 액션을 발생시킵니다(dispatch(increment()) 등).
정리
<Provider store={store}>
로 감싸 하위 컴포넌트에서 스토어에 접근하게 합니다.아래 정리는 위 내용(리덕스 스토어를 리액트 앱에 제공(Provider)하는 과정)에 대한 자세한 설명과 데이터 흐름도, 그리고 타입스크립트 기반의 예시 코드를 포함합니다. Redux Toolkit 및 react-redux 기반의 일반적인 패턴을 예로 들어 설명합니다.
전반적인 개념
react-redux
라이브러리가 제공하는 컴포넌트로, 리액트 컴포넌트 트리에 리덕스 스토어를 주입하는 역할을 합니다.<Provider>
로 전체 앱을 감싸면, 그 하위 모든 컴포넌트가 리덕스 스토어에 접근할 수 있습니다.<Provider>
를 특정 하위 트리에서만 사용할 수도 있지만, 그러면 그 하위 컴포넌트만 스토어에 접근 가능합니다. 일반적으로 앱 전체를 스토어와 연계하고자 할 때는 최상단에서 <Provider>
로 감싸는 것이 권장됩니다.과정 정리
<Provider store={store}>
로 <App />
또는 루트 컴포넌트를 감쌉니다.<App />
이하의 모든 컴포넌트는 useSelector와 useDispatch 훅을 통해 스토어 액세스 및 액션 디스패치가 가능해집니다.이는 마치 리액트의 Context API에서 <Context.Provider>
를 사용했던 것과 비슷한 개념입니다.
단, 여기서는 Redux 스토어를 제공하므로 Provider는 Redux 스토어를 리액트 컴포넌트 트리에 전달하는 역할을 합니다.
store (index.ts) ----> index.tsx(main.tsx)에서 Provider로 감쌈 ----> App 및 모든 하위 컴포넌트
└─(생성된 Redux Store) └─<Provider store={store}>
│
▼
모든 하위 컴포넌트에서 useSelector, useDispatch 가능
<App />
을 감쌉니다.아래는 위 내용(리덕스 스토어에서 상태를 읽어오는 방법, 특히 useSelector 훅을 사용하는 방법)에 대한 자세한 정리입니다. 또한 데이터 흐름을 도식화하고, 타입스크립트 기반 예시 코드를 포함합니다. 예시는 Redux Toolkit과 react-redux를 활용하는 가장 널리 사용되고 검증된 패턴을 기반으로 작성하였습니다.
전반적인 개념
흐름 예시
기대 효과
┌────────────────┐
│ Redux Store │
└───────┬────────┘
│
(useSelector로 상태 선택)
│
▼
┌────────────────────┐
│ React Component │
│ (Counter Component) │
└───────┬────────────┘
│ (상태 변경 시 자동 업데이트)
▼
UI 업데이트
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
// RootState와 AppDispatch 타입을 추론 및 내보내기
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = { value: 0 };
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
// main.tsx
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import {Provider} from "react-redux";
import {store} from "./store";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<App/>
</Provider>
</StrictMode>,
)
// components/Counter.tsx
import {useSelector} from 'react-redux';
import {RootState} from "../store";
function Counter() {
// useSelector 훅을 사용하여 Redux 상태에서 카운터 값을 추출
const counter = useSelector((state: RootState) => state.counter.value);
return (
<div>
<h2>현재 카운터 값: {counter}</h2>
{/* 추후 increment, decrement 버튼을 추가할 수 있음 */}
</div>
);
}
export default Counter;
useSelector를 통해 state.counter.value를 받아와 컴포넌트에 표시합니다.
상태가 변경되면 자동으로 최신 값으로 재렌더링됩니다.
아래 정리는 위 내용(리덕스 상태를 변경하기 위해 액션을 디스패치하는 과정)에 대한 자세한 개념 설명과 데이터 흐름도, 그리고 타입스크립트 기반의 예시 코드를 포함합니다. 여기서는 useDispatch 훅을 사용해 액션을 스토어에 전달하고, 리듀서가 이에 따라 상태를 변경하는 전형적인 패턴을 다룹니다.
전반적인 개념
주요 흐름
기대 효과
(버튼 클릭)
│
▼
dispatch(action) --→ Redux Store --→ Reducer(상태 변경) --→ 새로운 상태
│
▼
컴포넌트 재렌더링 (useSelector로 최신 상태 반영)
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = { value: 0 };
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
}
}
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement } from '../store/counterSlice';
function Counter() {
const dispatch = useDispatch<AppDispatch>();
const counter = useSelector((state: RootState) => state.counter.value);
const incrementHandler = () => {
dispatch(increment()); // { type: 'counter/increment' } 액션 디스패치
};
const decrementHandler = () => {
dispatch(decrement()); // { type: 'counter/decrement' } 액션 디스패치
};
return (
<div style={{ textAlign: 'center' }}>
<h2>현재 카운터 값: {counter}</h2>
<div>
<button onClick={incrementHandler}>Increment</button>
<button onClick={decrementHandler}>Decrement</button>
</div>
</div>
);
}
export default Counter;
useDispatch를 사용하여 dispatch 함수 획득.
버튼 클릭 시 dispatch(increment()) 또는 dispatch(decrement())로 액션 디스패치.
상태 변경 후 useSelector로 최신 상태 반영.
정리
이로써 버튼 클릭(사용자 상호작용)을 통해 리덕스 상태를 변경하는 전 과정(상태 읽기, 액션 디스패치, 자동 UI 업데이트)을 이해할 수 있게 되었습니다.
아래는 클래스 기반 컴포넌트를 리덕스와 연동하는 방법을 정리한 것입니다. 함수형 컴포넌트에서는 useSelector, useDispatch를 사용하지만, 클래스 기반 컴포넌트에서는 connect 함수를 활용해 리덕스 상태와 액션을 프랍(props)으로 매핑할 수 있습니다. 이 과정을 통해 클래스 컴포넌트도 전역 상태를 간단히 관리하고 업데이트할 수 있게 됩니다.
전반적인 개념
동작 방식
기대 효과
Redux Store ----> connect(mapStateToProps, mapDispatchToProps)
│ │
│ └──> Props (상태 및 디스패치 함수) ---> Class Component
│ (this.props로 접근)
│
Action Dispatch <---------- mapDispatchToProps <---------- Class Component (this.props로 액션 호출)
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = { value: 0 };
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
}
}
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement } from '../store/counterSlice';
// 클래스 컴포넌트용 prop 타입
interface CounterProps {
counter: number;
increment: () => void;
decrement: () => void;
}
class CounterClass extends Component<CounterProps> {
incrementHandler = () => {
this.props.increment(); // props로 주입된 increment 함수 호출
};
decrementHandler = () => {
this.props.decrement(); // props로 주입된 decrement 함수 호출
};
render() {
return (
<div style={{ textAlign: 'center' }}>
<h2>현재 카운터 값: {this.props.counter}</h2>
<div>
<button onClick={this.incrementHandler}>Increment</button>
<button onClick={this.decrementHandler}>Decrement</button>
</div>
</div>
);
}
}
// Redux 상태를 props로 매핑
const mapStateToProps = (state: RootState) => {
return {
counter: state.counter.value,
};
};
// 액션 디스패치를 props로 매핑
const mapDispatchToProps = (dispatch: AppDispatch) => {
return {
increment: () => dispatch(increment()),
decrement: () => dispatch(decrement()),
};
};
// connect를 이용해 CounterClass 컴포넌트를 리덕스 스토어에 연결
export default connect(mapStateToProps, mapDispatchToProps)(CounterClass);
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import CounterClass from './components/CounterClass';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<CounterClass />
</Provider>
</React.StrictMode>
);
mapStateToProps는 스토어 상태 state.counter.value를 counter prop으로 매핑.
mapDispatchToProps는 dispatch(increment()) 등을 수행하는 함수를 increment, decrement prop으로 매핑.
connect로 감싼 CounterClass 컴포넌트는 props를 통해 상태와 디스패치 함수에 접근 가능.
정리
이로써 클래스 기반 컴포넌트에서 connect
를 사용해 리덕스 상태 및 액션에 접근하는 방식을 이해할 수 있습니다.
이는 함수형 컴포넌트의 훅 패턴과 유사한 목적을 달성하나, 구형 문법 지원 또는 레거시 코드 유지에 유용한 방법입니다.
아래는 리덕스(Redux)에서 액션에 추가 데이터(payload
)를 담아 상태 변경을 유연하게 하는 방법에 대한 설명입니다.
기존의 단순한 type만 포함한 액션에서 한 걸음 더 나아가, 액션 객체에 원하는 데이터를 함께 담아 리듀서에서 이를 활용하는 패턴을 정리하였습니다.
전반적인 개념
과정 정리
기대 효과
(사용자 동작: "10만큼 증가" 버튼 클릭)
│
▼
dispatch({ type: 'increase', amount: 10 })
│
▼
Redux Store
│
▼
Reducer 함수 ----- action.amount(10) -----> 새로운 상태 (counter += 10)
│
▼
컴포넌트 자동 업데이트 (useSelector 통해 새로운 상태 반영)
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = { value: 0 };
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action: PayloadAction<number>) {
// 액션에서 payload로 받은 값을 상태 변경에 활용
state.value += action.payload;
}
}
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement, incrementByAmount } from '../store/counterSlice';
function Counter() {
const dispatch = useDispatch<AppDispatch>();
const counter = useSelector((state: RootState) => state.counter.value);
const increaseHandler = () => {
// 10만큼 증가하는 액션 디스패치
// 액션 객체에 payload로 숫자(10)를 전달
dispatch(incrementByAmount(10));
};
return (
<div style={{ textAlign: 'center' }}>
<h2>현재 카운터 값: {counter}</h2>
<div>
<button onClick={() => dispatch(decrement())}>-1 감소</button>
<button onClick={() => dispatch(increment())}>+1 증가</button>
<button onClick={increaseHandler}>+10 증가</button>
</div>
</div>
);
}
export default Counter;
PayloadAction<number>
로 타입을 지정해 payload가 number임을 명확히 했습니다.increaseHandler에서 incrementByAmount(10) 액션을 디스패치합니다.
리듀서(incrementByAmount)는 이 payload 값을 사용해 상태를 10만큼 증가시킵니다.
버튼 클릭 시 카운터 값이 10씩 증가하는 것을 확인할 수 있습니다.
정리
이로써 단순한 액션에서 한 걸음 더 나아가, 액션 객체를 활용해 다양한 상황에 대처하는 확장성 있는 상태 관리가 가능함을 확인할 수 있습니다.
아래는 전역 상태에 단일 숫자 카운터 값뿐 아니라, "카운터를 보여줄지 여부"를 제어하는 불리언 상태(showCounter)도 리덕스 스토어에서 관리하는 과정을 정리한 것입니다. 또한 이를 토글하는 액션을 추가하는 방법과 전체 흐름을 도식화하였으며, 타입스크립트 기반의 예시 코드를 제시합니다.
전반적인 개념
상태 관리의 유연성
토글 버튼 클릭
│
▼
dispatch({ type: 'toggle' })
│
▼
Redux Store
│
▼
Reducer 함수에서
showCounter = !showCounter
│
▼
showCounter 값 변경
│
▼
useSelector로 showCounter 읽는 컴포넌트 자동 업데이트
│
▼
카운터 표시/숨김 상태 변경 (UI 렌더링 반영)
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
showCounter: boolean;
}
const initialState: CounterState = {
value: 0,
showCounter: true,
};
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
toggle(state) {
state.showCounter = !state.showCounter;
},
},
});
export const { increment, decrement, incrementByAmount, toggle } = counterSlice.actions;
export default counterSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement, incrementByAmount, toggle } from '../store/counterSlice';
function Counter() {
const dispatch = useDispatch<AppDispatch>();
const counter = useSelector((state: RootState) => state.counter.value);
const show = useSelector((state: RootState) => state.counter.showCounter);
const incrementHandler = () => {
dispatch(increment());
};
const decrementHandler = () => {
dispatch(decrement());
};
const increaseByTenHandler = () => {
dispatch(incrementByAmount(10));
};
const toggleCounterHandler = () => {
dispatch(toggle());
};
return (
<div style={{ textAlign: 'center' }}>
<h2>리덕스 카운터</h2>
{show && (
<div>
<h3>현재 카운터 값: {counter}</h3>
<button onClick={decrementHandler}>-1 감소</button>
<button onClick={incrementHandler}>+1 증가</button>
<button onClick={increaseByTenHandler}>+10 증가</button>
</div>
)}
<button onClick={toggleCounterHandler}>토글 카운터 표시</button>
</div>
);
}
export default Counter;
showCounter 상태를 읽어 카운터를 조건부로 렌더링합니다.
toggleCounterHandler에서 dispatch(toggle())를 호출해 showCounter 값을 반전시킵니다.
이로써 토글 버튼 클릭 시 카운터가 사라졌다가 다시 나타나는 동작을 확인할 수 있습니다.
정리
아래 정리는 Redux 리듀서에서 상태를 업데이트할 때 반드시 지켜야 할 "상태 불변성(immutability)" 원칙에 대한 내용입니다. 기존 상태를 직접 변경하는 대신 항상 새로운 상태 객체를 반환해야 하는 이유와 그 중요성에 대해 자세히 설명합니다. 또한 데이터 흐름을 도식화하고, 타입스크립트 기반의 예시 코드를 제시합니다.
전반적인 개념
잘못된 예시
기존 상태 객체 state를 직접 수정한 뒤 그대로 반환하는 경우.
state.counter++;
return state; // 기존 state를 직접 변경하는 잘못된 예
이렇게 하면 참조가 동일하므로 상태 변경 추적이 어렵고, 의도치 않은 부작용 발생 가능성이 높습니다.
올바른 패턴
기존 상태를 복사한 새 객체를 만든 뒤, 필요한 속성만 변경하여 반환합니다.
return {
...state,
counter: state.counter + 1,
};
객체나 배열이 중첩된 경우에도 마찬가지로 내부까지 새로운 객체/배열로 복사하여 변경해야 합니다.
액션 발생 (dispatch action)
│
▼
Reducer 함수
│
▼ (절대 기존 state 수정 금지)
새 상태 객체 반환 (기존 state + 변경 사항을 반영한 신규 객체)
│
▼
Redux Store 상태 업데이트
│
▼
React 컴포넌트 재렌더 (상태 변경 반영)
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
showCounter: boolean;
}
const initialState: CounterState = {
value: 0,
showCounter: true,
};
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
// Redux Toolkit의 createSlice는 immer 라이브러리를 사용하므로
// 여기서 state.value++ 처럼 작성해도 내부적으로 불변성을 유지합니다.
// 그러나 전통적인 Redux 패턴에서는 새로운 객체를 반환해야 합니다.
state.value++;
},
decrement(state) {
state.value--;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
toggle(state) {
state.showCounter = !state.showCounter;
},
// 전통적인 Redux 패턴 예시 (불변성 유지):
// 아래와 같이 불변성을 스스로 유지하는 코드라면,
// 새로운 객체를 직접 반환하는 방식으로 작성해야 합니다.
// increment(state) {
// return {
// ...state,
// value: state.value + 1,
// };
// },
},
});
export const { increment, decrement, incrementByAmount, toggle } = counterSlice.actions;
export default counterSlice.reducer;
createSlice를 쓰면 immer를 통해 불변성이 자동 유지되지만, 전통적인 Redux 패턴에서는 ...state 스프레드 연산자를 사용해 새로운 객체를 반환해야 합니다.
예를 들어 불변성을 직접 관리하려면:
increment(state) {
// 전통적 방식: 새로운 객체 반환
return {
...state,
value: state.value + 1,
};
},
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// components/Counter.tsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement, incrementByAmount, toggle } from '../store/counterSlice';
function Counter() {
const dispatch = useDispatch<AppDispatch>();
const { value, showCounter } = useSelector((state: RootState) => state.counter);
const incrementHandler = () => dispatch(increment());
const decrementHandler = () => dispatch(decrement());
const increaseByTenHandler = () => dispatch(incrementByAmount(10));
const toggleHandler = () => dispatch(toggle());
return (
<div style={{ textAlign: 'center' }}>
<h2>리덕스 카운터</h2>
{showCounter && <div>현재 값: {value}</div>}
<button onClick={decrementHandler}>-1 감소</button>
<button onClick={incrementHandler}>+1 증가</button>
<button onClick={increaseByTenHandler}>+10 증가</button>
<button onClick={toggleHandler}>토글</button>
</div>
);
}
export default Counter;
이로써 Redux 상태 업데이트 시 불변성을 유지하는 이유와 방법을 숙지할 수 있습니다. 불변성은 작은 예제에서도 지켜야 할 중요한 원칙이며, 규모가 큰 애플리케이션에서는 더욱 중요한 가이드라인입니다.
아래 정리는 기존에 설명한 Redux 사용 시 발생할 수 있는 문제점과 이를 더 쉽고 효율적으로 해결할 수 있는 Redux Toolkit에 대한 소개를 다룹니다. 또한 데이터 흐름 도식화와 타입스크립트 기반의 예시 코드도 포함합니다.
발생 가능한 문제점
Redux Toolkit의 등장
(전통적인 Redux 환경)
상태 증가 -----→ Reducer 복잡도 증가 -----→ 관리 어려움 증가
│ │ │
▼ ▼ ▼
액션 식별자 중첩된 상태 복사 불변성 유지 실수
│ │ │
▼ ▼ ▼
상수화/정리 Reducer 분리 immer 사용
│ │ │
▼ ▼ ▼
Redux Toolkit 도입
// store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = { value: 0 };
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
// immer에 의해 state를 직접 변경하는 것처럼 써도 불변성 자동 유지
state.value++;
},
decrement(state) {
state.value--;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer, // 분리된 리듀서들 추가 가능
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// components/Counter.tsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement, incrementByAmount } from '../store/counterSlice';
function Counter() {
const dispatch = useDispatch<AppDispatch>();
const counter = useSelector((state: RootState) => state.counter.value);
return (
<div style={{ textAlign: 'center' }}>
<h2>리덕스 카운터</h2>
<div>현재 값: {counter}</div>
<button onClick={() => dispatch(decrement())}>-1 감소</button>
<button onClick={() => dispatch(increment())}>+1 증가</button>
<button onClick={() => dispatch(incrementByAmount(10))}>+10 증가</button>
</div>
);
}
export default Counter;
앞으로 더 복잡한 프로젝트에서 Redux Toolkit을 활용하면, 전통적인 Redux 설정이나 상태 관리의 복잡성을 크게 줄이고 개발 효율과 안정성을 높일 수 있습니다.
아래는 Redux Toolkit의 createSlice를 사용해 리덕스를 더욱 간결하고 유지보수하기 쉽게 만드는 방법을 정리한 내용입니다. 기존 Redux 방식 대비 장점과 특징을 알아보고, 데이터 흐름을 도식화한 뒤, 타입스크립트 기반 예시 코드를 제시합니다.
Redux Toolkit과 createSlice
특징 및 장점
액션(payload) 처리
createSlice() 호출
│
▼
slice(리듀서, 액션 자동 생성)
│
┌────────┴────────┐
│ │
상태 변경 로직 자동 생성된 액션
(immer로 불변성) (dispatch로 호출)
│ │
▼ ▼
새로운 상태 dispatch(action)
│ │
└──────▶ Redux Store 상태 업데이트 ───▶ React UI 반영
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
showCounter: boolean;
}
const initialState: CounterState = {
value: 0,
showCounter: true,
};
const counterSlice = createSlice({
name: 'counter', // slice 이름 (액션 타입 prefix로 사용됨)
initialState: initialState,
reducers: {
increment(state) {
// immer가 자동으로 불변성 관리 → state.value++ 사용 가능
state.value++;
},
decrement(state) {
state.value--;
},
incrementByAmount(state, action: PayloadAction<number>) {
// action.payload를 사용해 동적 상태 변경 가능
state.value += action.payload;
},
toggleCounter(state) {
state.showCounter = !state.showCounter;
},
},
});
export const { increment, decrement, incrementByAmount, toggle } = counterSlice.actions;
export default counterSlice.reducer;
// increment, decrement, incrementByAmount, toggleCounter 리듀서 함수 정의
// 각 함수는 Redux Toolkit에 의해 자동으로 액션 생성 함수(increment(), decrement(), incrementByAmount(), toggle())를 만듦.
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Redux Toolkit의 configureStore 사용으로 스토어 설정 간소화.
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { increment, decrement, incrementByAmount, toggle } from '../store/counterSlice';
function Counter() {
const dispatch = useDispatch<AppDispatch>();
const { value, showCounter } = useSelector((state: RootState) => state.counter);
return (
<div style={{ textAlign: 'center' }}>
<h2>리덕스 카운터 (Redux Toolkit)</h2>
{showCounter && <div>카운트: {value}</div>}
<button onClick={() => dispatch(decrement())}>-1 감소</button>
<button onClick={() => dispatch(increment())}>+1 증가</button>
<button onClick={() => dispatch(incrementByAmount(10))}>+10 증가</button>
<button onClick={() => dispatch(toggle())}>카운터 토글</button>
</div>
);
}
export default Counter;
// 액션 생성 함수를 바로 dispatch할 수 있으며, 별도의 액션 타입 문자열 관리 불필요
Redux Toolkit을 사용하면 Redux 설정과 유지 관리가 훨씬 편리해집니다. 이는 규모가 커지는 프로젝트에서도 효율적이고 안정적으로 상태 관리를 수행할 수 있게 해주는 강력한 도구입니다.
아래는 Redux Toolkit의 configureStore를 사용하여 store를 설정하고, createSlice에서 정의한 slice의 reducer를 통합하는 방법에 대한 정리입니다. 이로써 기존 createStore와 달리, 여러 slice를 손쉽게 병합할 수 있고, 액션 생성 함수를 직관적으로 사용할 수 있게 됩니다.
Redux Toolkit의 configureStore
예시 흐름
createSlice() ----------------> slice.reducer, slice.actions
│
▼
configureStore({ reducer: slice.reducer }) ----> Redux Store 생성
│
▼
dispatch(slice.actions.increment()) -----------> slice.reducer 처리
│
▼
Redux Store 상태 업데이트 & UI 반영
// store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
showCounter: boolean;
}
const initialState: CounterState = {
value: 0,
showCounter: true,
};
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value++; // immer로 불변성 자동 유지
},
decrement(state) {
state.value--;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
toggleCounter(state) {
state.showCounter = !state.showCounter;
},
}
});
export const counterActions = counterSlice.actions;
export default counterSlice.reducer;
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice'; // 단일 slice reducer
export const store = configureStore({
reducer: counterReducer,
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// components/Counter.tsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { counterActions } from '../store/counterSlice';
function Counter() {
const dispatch = useDispatch<AppDispatch>();
const { value, showCounter } = useSelector((state: RootState) => state);
return (
<div style={{ textAlign: 'center' }}>
<h2>리덕스 카운터</h2>
{showCounter && <div>값: {value}</div>}
<button onClick={() => dispatch(counterActions.decrement())}>-1 감소</button>
<button onClick={() => dispatch(counterActions.increment())}>+1 증가</button>
<button onClick={() => dispatch(counterActions.incrementByAmount(10))}>+10 증가</button>
<button onClick={() => dispatch(counterActions.toggleCounter())}>카운터 토글</button>
</div>
);
}
export default Counter;
counterActions.increment() 형태로 액션을 디스패치.
액션 타입 문자열이나 별도 액션 생성 함수를 직접 정의할 필요 없음.
정리
이로써 Redux Toolkit을 통해 더욱 편리하고 체계적으로 Redux 상태 관리를 진행할 수 있습니다.
아래는 Redux Toolkit의 createSlice를 통해 액션과 리듀서를 간결하고 명확하게 관리하는 방법과 원리에 대한 정리입니다. 또한 데이터 흐름 도식화, 타입스크립트 기반 예시 코드를 포함하였습니다.
액션 생성의 자동화
payload 처리
정리
createSlice() 정의
│
▼
slice.actions ----> 액션 생성 함수 자동 생성
│
▼ (dispatch)
dispatch(slice.actions.incrementByAmount(10))
│
▼
Redux Store
│
▼
slice.reducer에서 action.payload 사용
(이 경우 payload = 10)
│
▼
상태 업데이트 & UI 반영
// store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
showCounter: boolean;
}
const initialState: CounterState = {
value: 0,
showCounter: true,
};
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value++;
},
decrement(state) {
state.value--;
},
incrementByAmount(state, action: PayloadAction<number>) {
// 전달된 payload(숫자)를 action.payload로 접근 가능
state.value += action.payload;
},
toggleCounter(state) {
state.showCounter = !state.showCounter;
},
},
});
export const counterActions = counterSlice.actions;
export default counterSlice.reducer;
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: counterReducer,
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// components/Counter.tsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { counterActions } from '../store/counterSlice';
function Counter() {
const dispatch = useDispatch<AppDispatch>();
const { value, showCounter } = useSelector((state: RootState) => state);
const incrementHandler = () => {
dispatch(counterActions.increment());
};
const decrementHandler = () => {
dispatch(counterActions.decrement());
};
const increaseByTenHandler = () => {
dispatch(counterActions.incrementByAmount(10));
};
const toggleHandler = () => {
dispatch(counterActions.toggleCounter());
};
return (
<div style={{ textAlign: 'center' }}>
<h2>리덕스 카운터 (Redux Toolkit)</h2>
{showCounter && <div>값: {value}</div>}
<button onClick={decrementHandler}>-1 감소</button>
<button onClick={incrementHandler}>+1 증가</button>
<button onClick={increaseByTenHandler}>+10 증가</button>
<button onClick={toggleHandler}>토글</button>
</div>
);
}
export default Counter;
counterActions.increment()로 간단히 액션 디스패치 가능
incrementByAmount(10) 호출 시 자동으로 { payload: 10 } 액션 객체 생성 및 전달
정리
이로써 Redux Toolkit을 통해 액션과 리듀서 로직을 훨씬 더 단순하고 안정적으로 관리할 수 있게 됩니다.
아래는 Redux Toolkit을 이용해 여러 개의 slice(슬라이스)를 생성하고, 이를 하나의 스토어에서 관리하는 방법에 대한 정리입니다. 또한 auth(인증) 상태를 관리하는 새로운 slice를 만들고, 여러 slice를 configureStore에 결합하는 과정을 다룹니다. 마지막으로, slice를 여러 개 사용할 때 useSelector로 상태에 접근하는 방법도 정리합니다.
상태의 분리와 Slice
여러 Slice 통합
하나의 Redux 스토어는 하나의 루트 리듀서만 가질 수 있지만, configureStore를 사용하면 여러 slice 리듀서를 하나의 객체로 전달할 수 있습니다.
const store = configureStore({
reducer: {
counter: counterSlice.reducer,
auth: authSlice.reducer,
},
});
이렇게 하면 counter와 auth라는 두 개의 slice 상태를 전역적으로 관리할 수 있습니다.
상태 접근과 액션 디스패치
예시: 인증 상태 추가
counterSlice.reducer ---> configureStore({ reducer: { counter: counterSlice.reducer, auth: authSlice.reducer } }) ---> Redux Store
authSlice.reducer ---^
dispatch(authActions.login()) ---> authSlice.reducer --> state.auth.isAuthenticated = true
dispatch(authActions.logout()) ---> authSlice.reducer --> state.auth.isAuthenticated = false
useSelector((state) => state.counter.value) // 카운터 상태 접근
useSelector((state) => state.auth.isAuthenticated) // 인증 상태 접근
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
showCounter: boolean;
}
const initialCounterState: CounterState = {
value: 0,
showCounter: true,
};
const counterSlice = createSlice({
name: 'counter',
initialState: initialCounterState,
reducers: {
increment(state) {
state.value++;
},
decrement(state) {
state.value--;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
toggleCounter(state) {
state.showCounter = !state.showCounter;
},
},
});
export const counterActions = counterSlice.actions;
export default counterSlice.reducer;
import { createSlice } from '@reduxjs/toolkit';
interface AuthState {
isAuthenticated: boolean;
}
const initialAuthState: AuthState = {
isAuthenticated: false,
};
const authSlice = createSlice({
name: 'auth',
initialState: initialAuthState,
reducers: {
login(state) {
state.isAuthenticated = true;
},
logout(state) {
state.isAuthenticated = false;
}
},
});
export const authActions = authSlice.actions;
export default authSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import authReducer from './authSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
auth: authReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// counter와 auth라는 두 개의 slice reducer를 하나의 store로 통합
// state.counter로 카운터 관련 상태 접근, state.auth로 인증 관련 상태 접근
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { authActions } from '../store/authSlice';
function Header() {
const dispatch = useDispatch<AppDispatch>();
const isAuth = useSelector((state: RootState) => state.auth.isAuthenticated);
const logoutHandler = () => {
dispatch(authActions.logout());
};
return (
<header>
<h1>My App</h1>
<nav>
<ul>
{isAuth && <li><a href="/">My Page</a></li>}
{isAuth && <li><button onClick={logoutHandler}>Logout</button></li>}
{!isAuth && <li>로그인 필요</li>}
</ul>
</nav>
</header>
);
}
export default Header;
// 인증 상태(isAuthenticated)를 읽어 조건부 렌더링
// 로그아웃 액션을 디스패치하여 인증 상태 변경
이로써 Redux Toolkit을 활용한 다중 slice 관리와 전역 상태에 쉽게 접근하는 방법을 이해할 수 있으며, 애플리케이션 규모가 커질 때도 상태 관리를 체계적으로 유지할 수 있습니다.
아래는 Redux Toolkit을 사용해 여러 slice의 상태를 관리하고, 리액트 컴포넌트와 연동하여 로그인/로그아웃 기능을 구현하는 과정을 정리한 것입니다. 또한 데이터 흐름을 도식화하고 타입스크립트 기반 예시 코드를 제시합니다.
전반적인 개념
구체적인 흐름
이로써 하나의 스토어에서 여러 slice 상태를 관리하고, 전역 상태 변화를 쉽고 직관적으로 UI에 반영할 수 있습니다.
사용자 동작:
로그인 버튼 클릭
│
▼
dispatch(authActions.login())
│
▼
authSlice.reducer에서
isAuthenticated = true로 변경
│
▼
Redux Store 업데이트
│
▼
useSelector(state => state.auth.isAuthenticated)
│
▼
App / Header 컴포넌트가 상태 변화 감지
조건부 렌더링 업데이트 (Auth → UserProfile, Navigation 표시)
import { createSlice } from '@reduxjs/toolkit';
interface AuthState {
isAuthenticated: boolean;
}
const initialAuthState: AuthState = {
isAuthenticated: false,
};
const authSlice = createSlice({
name: 'auth',
initialState: initialAuthState,
reducers: {
login(state) {
state.isAuthenticated = true;
},
logout(state) {
state.isAuthenticated = false;
},
},
});
export const authActions = authSlice.actions;
export default authSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import authReducer from './authSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
auth: authReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { authActions } from '../store/authSlice';
function Auth() {
const dispatch = useDispatch<AppDispatch>();
const loginHandler = (event: React.FormEvent) => {
event.preventDefault();
dispatch(authActions.login());
};
return (
<form onSubmit={loginHandler}>
<h2>로그인</h2>
<button type="submit">로그인하기</button>
</form>
);
}
export default Auth;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { authActions } from '../store/authSlice';
function Header() {
const isAuth = useSelector((state: RootState) => state.auth.isAuthenticated);
const dispatch = useDispatch<AppDispatch>();
const logoutHandler = () => {
dispatch(authActions.logout());
};
return (
<header>
<h1>My App</h1>
<nav>
<ul>
{isAuth && <li><a href="/">내 페이지</a></li>}
{isAuth && <li><button onClick={logoutHandler}>로그아웃</button></li>}
{!isAuth && <li>로그인 필요</li>}
</ul>
</nav>
</header>
);
}
export default Header;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import Header from './Header';
import Auth from './Auth';
import UserProfile from './UserProfile';
function App() {
const isAuth = useSelector((state: RootState) => state.auth.isAuthenticated);
return (
<>
<Header />
{!isAuth && <Auth />}
{isAuth && <UserProfile />}
</>
);
}
export default App;
이로써 Redux Toolkit을 이용해 다중 slice 상태를 전역적으로 관리하고, UI에 반영하는 전체 흐름을 이해하고 구현할 수 있습니다.
아래는 Redux Toolkit을 사용한 상태 관리 구조를 더 잘 정리하고, 유지보수하기 쉬운 형태로 리팩토링하는 방법에 대한 정리입니다. 각각의 slice(슬라이스)를 별도 파일로 분리하고, index.ts(또는 index.js) 파일에서 모두 결합하는 패턴을 설명합니다. 또한 데이터 흐름을 도식화하고, 타입스크립트 기반의 예시 코드를 제공합니다.
슬라이스 파일 분리의 필요성
리팩토링 절차
개별 slice 파일 생성: 예를 들어 counterSlice.ts 파일에는 카운터 관련 상태, 리듀서, 액션을 정의하고 export 합니다. authSlice.ts 파일에는 인증 관련 로직을 정의합니다.
index.ts에서 통합: configureStore로 store를 생성할 때, 여러 slice에서 export한 reducer를 모두 reducer 프로퍼티의 객체로 전달합니다.
reducer: {
counter: counterReducer,
auth: authReducer,
}
액션/리듀서 export 관리: 각 slice 파일에서 .actions를 export하고, .reducer를 export해서 index.ts에서 스토어 설정 시 활용합니다. 컴포넌트에서는 필요한 slice 액션을 해당 slice 파일에서 import하여 사용합니다.
장점
counterSlice.ts ----------------> export counterActions, counterReducer
authSlice.ts -------------------> export authActions, authReducer
index.ts (store 설정)
┌─────────────────────────────────┐
│ import counterReducer │
│ import authReducer │
└─────────────────────────────────┘
│
▼
configureStore({
reducer: {
counter: counterReducer,
auth: authReducer
}
})
│
▼
Redux Store
컴포넌트 ---------> slice별 actions import ---> dispatch(actions.someAction())
│
▼
slice.reducer
│
▼
상태 변경
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
showCounter: boolean;
}
const initialCounterState: CounterState = { value: 0, showCounter: true };
const counterSlice = createSlice({
name: 'counter',
initialState: initialCounterState,
reducers: {
increment(state) {
state.value++;
},
decrement(state) {
state.value--;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
toggleCounter(state) {
state.showCounter = !state.showCounter;
},
},
});
export const counterActions = counterSlice.actions;
export default counterSlice.reducer;
import { createSlice } from '@reduxjs/toolkit';
interface AuthState {
isAuthenticated: boolean;
}
const initialAuthState: AuthState = {
isAuthenticated: false,
};
const authSlice = createSlice({
name: 'auth',
initialState: initialAuthState,
reducers: {
login(state) {
state.isAuthenticated = true;
},
logout(state) {
state.isAuthenticated = false;
},
},
});
export const authActions = authSlice.actions;
export default authSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import authReducer from './authSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
auth: authReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { authActions } from '../store/authSlice';
function Header() {
const isAuth = useSelector((state: RootState) => state.auth.isAuthenticated);
const dispatch = useDispatch<AppDispatch>();
const logoutHandler = () => {
dispatch(authActions.logout());
};
return (
<header>
<h1>My App</h1>
<nav>
<ul>
{isAuth && <li><button onClick={logoutHandler}>로그아웃</button></li>}
{!isAuth && <li>로그인 필요</li>}
</ul>
</nav>
</header>
);
}
export default Header;
이로써 Redux Toolkit을 활용한 구조적이고 확장성 있는 상태 관리 패턴을 구현할 수 있으며, 대규모 앱에서도 유지보수성과 가독성을 크게 개선할 수 있습니다.
아래는 리덕스(Redux)와 리덕스 툴킷(Redux Toolkit)을 활용한 상태 관리 개념에 대한 최종 정리입니다. 전반적인 개념, 데이터 흐름, 그리고 타입스크립트 기반의 실용 예시 코드를 담았습니다. 이 정리를 통해 리덕스의 핵심 아이디어를 다시 확인하고, 실제 프로젝트에 어떻게 적용할지 명확히 알 수 있습니다.
핵심 개념
주요 포인트
결론
사용자 상호작용(버튼 클릭 등)
│
▼ dispatch(action)
Redux Store
│
▼ Reducer(이전 상태, 액션 → 새로운 상태)
│
▼ 새로운 상태 Store에 저장
│
▼ useSelector로 상태 읽기 → React UI 업데이트
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
showCounter: boolean;
}
const initialCounterState: CounterState = { value: 0, showCounter: true };
const counterSlice = createSlice({
name: 'counter',
initialState: initialCounterState,
reducers: {
increment(state) {
state.value++;
},
decrement(state) {
state.value--;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
toggleCounter(state) {
state.showCounter = !state.showCounter;
},
},
});
export const counterActions = counterSlice.actions;
export default counterSlice.reducer;
import { createSlice } from '@reduxjs/toolkit';
interface AuthState {
isAuthenticated: boolean;
}
const initialAuthState: AuthState = { isAuthenticated: false };
const authSlice = createSlice({
name: 'auth',
initialState: initialAuthState,
reducers: {
login(state) {
state.isAuthenticated = true;
},
logout(state) {
state.isAuthenticated = false;
},
},
});
export const authActions = authSlice.actions;
export default authSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import authReducer from './authSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
auth: authReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import Header from './Header';
import Auth from './Auth';
import UserProfile from './UserProfile';
import Counter from './Counter';
function App() {
const isAuth = useSelector((state: RootState) => state.auth.isAuthenticated);
return (
<React.Fragment>
<Header />
{!isAuth && <Auth />}
{isAuth && <UserProfile />}
<Counter />
</React.Fragment>
);
}
export default App;
// useSelector로 전역 상태(auth.isAuthenticated)를 읽어 인증 상태에 따라 Auth 또는 UserProfile을 조건부 렌더링
// counterActions, authActions를 컴포넌트에서 import하여 useDispatch로 액션 디스패치 가능
이로써 Redux의 주요 아이디어와 Redux Toolkit 활용 방법을 확실히 이해했으며, 실제 프로젝트에 적용할 기초를 다졌습니다.
아래는 Redux에서 비동기 코드(예: HTTP 요청)나 부수 효과(side effects)를 처리하는 방법에 대한 개념 정리입니다. Redux의 핵심 규칙인 "리듀서는 순수 함수여야 한다"는 점을 상기하면서, 비동기 로직이나 부수 효과를 처리할 수 있는 위치와 패턴을 살펴봅니다.
리듀서의 특성
부수 효과와 비동기 로직 처리 위치
정리
(컴포넌트) useEffect / 커스텀 액션 크리에이터
│
▼
비동기 요청(API 호출)
│ 성공/실패 응답
▼
dispatch(action with payload)
│
▼
Redux Store
│
▼
Reducer (순수함수, 동기적)
상태 업데이트
│
▼
React 컴포넌트 re-render
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from './index';
interface User {
id: string;
name: string;
}
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
}
const initialState: UserState = {
user: null,
loading: false,
error: null,
};
// 비동기 액션 생성 함수 (Thunk)
export const fetchUser = createAsyncThunk('user/fetchUser', async (userId: string) => {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const data: User = await response.json();
return data;
});
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
clearError(state) {
state.error = null;
},
},
extraReducers: builder => {
builder
.addCase(fetchUser.pending, state => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action: PayloadAction<User>) => {
state.loading = false;
state.user = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Something went wrong';
});
},
});
export const userActions = userSlice.actions;
export const selectUser = (state: RootState) => state.user;
export default userSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
export const store = configureStore({
reducer: {
user: userReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { fetchUser, userActions } from '../store/userSlice';
function UserProfile() {
const dispatch = useDispatch<AppDispatch>();
const { user, loading, error } = useSelector((state: RootState) => state.user);
useEffect(() => {
dispatch(fetchUser('1234'));
}, [dispatch]);
if (loading) return <p>로딩중...</p>;
if (error) return <p>에러 발생: {error}</p>;
if (!user) return <p>사용자 정보 없음</p>;
return (
<div>
<h2>{user.name}</h2>
{/* 유저 정보 표시 */}
</div>
);
}
export default UserProfile;
// UserProfile 컴포넌트는 useEffect 내에서 fetchUser 비동기 액션을 디스패치
// 비동기 요청이 완료되면 extraReducers를 통해 리듀서가 상태를 업데이트
// 리듀서 자체는 여전히 순수하고 동기적이므로, 비동기 처리는 fetchUser 액션 크리에이터 내에서 발생
아래는 리덕스를 사용해 장바구니 기능을 구현하는 과정에 대한 정리입니다. 장바구니 토글 상태 관리와 실제 장바구니 품목 관리 로직을 Redux Toolkit으로 구현하는 핵심 아이디어를 담고 있습니다. 또한 데이터 흐름을 도식화하고 타입스크립트 기반 예시 코드를 포함하였습니다.
구현 목표
Redux를 통한 상태 관리 구조
구현 흐름
<Provider>
로 store를 주입합니다.사용자 클릭(장바구니 버튼)
│
▼ dispatch(uiActions.toggleCart())
│
▼
uiSlice.reducer - cartIsVisible 상태 토글
│
▼
React 컴포넌트 re-render
│
▼ 상태 반영 (장바구니 UI 표시/숨기기)
사용자 클릭("장바구니에 추가" 버튼)
│
▼ dispatch(cartActions.addItemToCart({id, title, price}))
│
▼
cartSlice.reducer - items 배열 및 totalQuantity 갱신
│
▼
React 컴포넌트 re-render
│
▼ 상태 반영 (장바구니 내 항목 수, 가격 등 업데이트)
import { createSlice } from '@reduxjs/toolkit';
interface UIState {
cartIsVisible: boolean;
}
const initialUIState: UIState = { cartIsVisible: false };
const uiSlice = createSlice({
name: 'ui',
initialState: initialUIState,
reducers: {
toggleCart(state) {
state.cartIsVisible = !state.cartIsVisible;
},
},
});
export const uiActions = uiSlice.actions;
export default uiSlice.reducer;
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
title: string;
price: number;
quantity: number;
totalPrice: number;
}
interface CartState {
items: CartItem[];
totalQuantity: number;
}
const initialCartState: CartState = {
items: [],
totalQuantity: 0,
};
interface AddItemPayload {
id: string;
title: string;
price: number;
}
const cartSlice = createSlice({
name: 'cart',
initialState: initialCartState,
reducers: {
addItemToCart(state, action: PayloadAction<AddItemPayload>) {
const newItem = action.payload;
const existingItem = state.items.find(item => item.id === newItem.id);
state.totalQuantity++;
if (!existingItem) {
// 새 아이템 추가
state.items.push({
id: newItem.id,
title: newItem.title,
price: newItem.price,
quantity: 1,
totalPrice: newItem.price,
});
} else {
// 기존 아이템 수량 증가 및 총 가격 업데이트
existingItem.quantity++;
existingItem.totalPrice += newItem.price;
}
},
removeItemFromCart(state, action: PayloadAction<string>) {
const id = action.payload;
const existingItem = state.items.find(item => item.id === id);
if (!existingItem) return;
state.totalQuantity--;
if (existingItem.quantity === 1) {
// 수량 1 → 삭제
state.items = state.items.filter(item => item.id !== id);
} else {
// 수량 감소 및 총 가격 감소
existingItem.quantity--;
existingItem.totalPrice -= existingItem.price;
}
},
},
});
export const cartActions = cartSlice.actions;
export default cartSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import uiReducer from './uiSlice';
import cartReducer from './cartSlice';
export const store = configureStore({
reducer: {
ui: uiReducer,
cart: cartReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from './store';
import Cart from './components/Cart';
import Layout from './components/Layout';
function App() {
const showCart = useSelector((state: RootState) => state.ui.cartIsVisible);
return (
<Layout>
{showCart && <Cart />}
{/* 제품 목록 및 기타 UI */}
</Layout>
);
}
export default App;
import React from 'react';
import { useDispatch } from 'react-redux';
import { uiActions } from '../store/uiSlice';
const CartButton: React.FC = () => {
const dispatch = useDispatch();
const toggleCartHandler = () => {
dispatch(uiActions.toggleCart());
};
return <button onClick={toggleCartHandler}>내 장바구니</button>;
};
export default CartButton;
import React from 'react';
import CartButton from './CartButton';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
const Layout: React.FC = (props) => {
// totalQuantity를 가져와서 상단 레이아웃에 표시할 수 있습니다.
const totalQuantity = useSelector((state: RootState) => state.cart.totalQuantity);
return (
<div>
<header style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', background: '#ccc' }}>
<h1>리덕스 장바구니 예제</h1>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CartButton />
<span style={{ marginLeft: '1rem' }}>총 개수: {totalQuantity}</span>
</div>
</header>
<main style={{ padding: '1rem' }}>{props.children}</main>
</div>
);
};
export default Layout;
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';
const Cart: React.FC = () => {
const dispatch = useDispatch<AppDispatch>();
const items = useSelector((state: RootState) => state.cart.items);
const addItemHandler = (id: string, title: string, price: number) => {
dispatch(cartActions.addItemToCart({ id, title, price }));
};
const removeItemHandler = (id: string) => {
dispatch(cartActions.removeItemFromCart(id));
};
if (items.length === 0) {
return <p>장바구니가 비어있습니다!</p>;
}
return (
<div style={{ border: '1px solid #ccc', padding: '1rem', background: '#eee' }}>
<h2>내 장바구니</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map(item => (
<li key={item.id} style={{ marginBottom: '1rem', borderBottom: '1px solid #ddd', paddingBottom: '0.5rem' }}>
<div><strong>{item.title}</strong></div>
<div>가격: {item.price}원</div>
<div>수량: {item.quantity}</div>
<div>총 가격: {item.totalPrice}원</div>
<div style={{ marginTop: '0.5rem' }}>
<button onClick={() => removeItemHandler(item.id)}>-</button>
<button onClick={() => addItemHandler(item.id, item.title, item.price)}>+</button>
</div>
</li>
))}
</ul>
</div>
);
};
export default Cart;
이로써 장바구니 기능을 비롯한 복잡한 전역 상태 관리도 Redux Toolkit을 통해 직관적이고 효율적으로 구현할 수 있습니다.
아래는 리덕스를 사용해 장바구니 항목을 추가/삭제/수정하는 과정을 상세히 설명한 내용을 정리한 것입니다. 전반적인 로직 정리, 데이터 흐름도식, 그리고 타입스크립트 기반 예시 코드를 통해 이해를 돕습니다.
주요 기능
cartSlice 상태 예시:
{
items: [
{
id: string;
title: string;
price: number;
quantity: number;
totalPrice: number;
},
...
];
totalQuantity: number;
}
UI 연계
정리
사용자 동작 (장바구니에 추가 버튼 클릭)
│
▼ dispatch(cartActions.addItemToCart({id, title, price}))
│
▼
cartSlice.reducer
- 항목 존재 여부 확인
- 새로운 항목 추가 또는 기존 항목 수량·가격 업데이트
- totalQuantity 증가
│
▼
Redux Store 상태 업데이트
│
▼
useSelector로 컴포넌트에서 상태 읽기
│
▼
UI 업데이트 (항목 수, 총 수량 반영)
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
title: string;
price: number;
quantity: number;
totalPrice: number;
}
interface CartState {
items: CartItem[];
totalQuantity: number;
}
const initialCartState: CartState = {
items: [],
totalQuantity: 0,
};
interface AddItemPayload {
id: string;
title: string;
price: number;
}
const cartSlice = createSlice({
name: 'cart',
initialState: initialCartState,
reducers: {
addItemToCart(state, action: PayloadAction<AddItemPayload>) {
const newItem = action.payload;
const existingItem = state.items.find(item => item.id === newItem.id);
state.totalQuantity++;
if (!existingItem) {
state.items.push({
id: newItem.id,
title: newItem.title,
price: newItem.price,
quantity: 1,
totalPrice: newItem.price,
});
} else {
existingItem.quantity++;
existingItem.totalPrice += newItem.price;
}
},
removeItemFromCart(state, action: PayloadAction<string>) {
const id = action.payload;
const existingItem = state.items.find(item => item.id === id);
if (!existingItem) return;
state.totalQuantity--;
if (existingItem.quantity === 1) {
state.items = state.items.filter(item => item.id !== id);
} else {
existingItem.quantity--;
existingItem.totalPrice -= existingItem.price; // 총 가격 감소 처리
}
},
},
});
export const cartActions = cartSlice.actions;
export default cartSlice.reducer;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';
interface CartItemProps {
item: {
id: string;
title: string;
quantity: number;
price: number;
totalPrice: number;
};
}
const CartItem: React.FC<CartItemProps> = (props) => {
const { id, title, quantity, price, totalPrice } = props.item;
const dispatch = useDispatch<AppDispatch>();
const removeItemHandler = () => {
dispatch(cartActions.removeItemFromCart(id));
};
const addItemHandler = () => {
dispatch(cartActions.addItemToCart({ id, title, price }));
};
return (
<li style={{ marginBottom: '1rem', borderBottom: '1px solid #ddd', paddingBottom: '0.5rem' }}>
<div><strong>{title}</strong></div>
<div>가격: {price}원</div>
<div>수량: {quantity}</div>
<div>총 가격: {totalPrice}원</div>
<div style={{ marginTop: '0.5rem' }}>
<button onClick={removeItemHandler}>-</button>
<button onClick={addItemHandler}>+</button>
</div>
</li>
);
};
export default CartItem;
설명:
요약
아래는 리덕스를 비동기 코드(HTTP 요청)와 결합하는 방법을 다룬 내용에 대한 정리입니다. 기본적인 원칙(리듀서에서 부수 효과 금지)과 이를 해결하는 두 가지 접근법(컴포넌트 내부에서 처리 또는 액션 생성 함수를 통한 처리)을 명확히 설명합니다. 또한 데이터 흐름 도식과 타입스크립트 기반 예시 코드를 제공합니다.
문제 상황
Redux 원칙
두 가지 접근법
결론
사용자 상호작용(장바구니 업데이트)
│
▼
(선택지 1) 컴포넌트에서 HTTP 요청
(선택지 2) 액션 생성 함수(Thunk 등)에서 HTTP 요청
│
▼ 요청 완료
dispatch(결과 액션)
│
▼
Redux Reducer (순수 함수)
상태 업데이트 (비동기 로직 없음)
│
▼
React 컴포넌트 re-render
import { AppDispatch } from './index';
import { cartActions } from './cart-slice';
// 액션 생성 함수 예시(Thunk 사용)
export const sendCartData = (cart: { items: any[]; totalQuantity: number }) => {
return async (dispatch: AppDispatch) => {
// 비동기 HTTP 요청
const sendRequest = async () => {
const response = await fetch('https://your-backend.firebaseio.com/cart.json', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' }, // 중요!! 어떤 데이터 타입으로 보내는지 명시 필요!!
body: JSON.stringify(cart),
});
if (!response.ok) {
throw new Error('장바구니 저장 실패!');
}
};
try {
await sendRequest();
dispatch(cartActions.showNotification({ status: 'success', title: 'Success!', message: 'Cart data sent!' }));
} catch (error) {
dispatch(cartActions.showNotification({ status: 'error', title: 'Error!', message: 'Sending cart data failed!' }));
}
};
};
// sendCartData 액션 생성 함수에서 비동기 HTTP 요청을 실행한 뒤, 성공 또는 실패 시 적절한 액션을 디스패치합니다.
// 리듀서는 여전히 순수하고 동기적이며, 비동기 로직은 액션 생성 함수 내에서 처리합니다.
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cart-slice';
// ... 다른 리듀서들
export const store = configureStore({
reducer: {
cart: cartReducer,
// ... 다른 slice들
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { sendCartData } from '../store/cart-actions';
import { RootState } from '../store';
const Cart: React.FC = () => {
const dispatch = useDispatch();
const cart = useSelector((state: RootState) => state.cart);
useEffect(() => {
dispatch(sendCartData(cart));
}, [cart, dispatch]);
// cart 상태를 렌더링
return <div>장바구니 렌더링...</div>;
};
export default Cart;
설명:
요약
이번 예시는 다음과 같은 핵심 포인트를 다룹니다.
아래 예시는 사람들이 널리 사용하고 검증된 Redux Toolkit 패턴을 따르며, 타입스크립트를 사용합니다. 또한 전체 폴더 구조와 모든 파일의 코드 예시를 제공합니다.
아래 예시는 Firebase 대신 일반 Node.js Express 서버를 사용한 전체 예시 코드입니다. 백엔드(Node.js + Express)와 프론트엔드(React + Redux Toolkit + TypeScript) 모두를 제시합니다. 이 예시에서는 다음과 같은 특징을 갖습니다.
아래 코드는 검증된 패턴(Thunk, Redux Toolkit, React-Redux 사용)과 타입스크립트를 사용합니다. 코드 생략 없이, 전체 폴더 구조 및 파일 예시를 모두 제공합니다.
my-app/
├─ server/
│ ├─ server.ts // Node.js + Express 서버 코드
│ ├─ cartData.json // (옵션) 서버 시작 시 빈 파일 또는 초기 데이터
│ └─ package.json // 서버용 의존성 (express)
├─ src/
│ ├─ store/
│ │ ├─ cart-slice.ts
│ │ ├─ ui-slice.ts
│ │ ├─ cart-actions.ts
│ │ └─ index.ts
│ ├─ components/
│ │ ├─ Layout.tsx
│ │ ├─ Cart.tsx
│ │ ├─ CartItem.tsx
│ │ ├─ CartButton.tsx
│ │ ├─ Products.tsx
│ │ ├─ ProductItem.tsx
│ │ └─ Notification.tsx
│ ├─ App.tsx
│ ├─ index.tsx
│ └─ react-app-env.d.ts
├─ package.json
└─ tsconfig.json
전제 조건:
실행 방법:
// server/server.ts (타입스크립트 기반 Node.js 서버)
import express, { Request, Response } from 'express';
import cors from 'cors';
interface CartItem {
id: string;
title: string;
price: number;
quantity: number;
totalPrice: number;
}
interface CartData {
items: CartItem[];
totalQuantity: number;
}
// 메모리에 장바구니 데이터 저장 (실제 프로덕션에선 DB 연동 권장)
let cartData: CartData = {
items: [],
totalQuantity: 0
};
const app = express();
app.use(cors());
app.use(express.json());
// GET /cart: 장바구니 데이터 가져오기
app.get('/cart', (req: Request, res: Response) => {
res.json(cartData);
});
// PUT /cart: 장바구니 데이터 저장/갱신
app.put('/cart', (req: Request, res: Response) => {
const newCartData = req.body as CartData;
// 백엔드는 로직 없이 그대로 저장
cartData = {
items: newCartData.items || [],
totalQuantity: newCartData.totalQuantity || 0
};
res.json({ message: '장바구니 데이터 업데이트 완료' });
});
app.listen(3001, () => {
console.log('백엔드 서버가 http://localhost:3001 에서 동작 중입니다.');
});
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
title: string;
price: number;
quantity: number;
totalPrice: number;
}
interface CartState {
items: CartItem[];
totalQuantity: number;
changed: boolean;
}
const initialCartState: CartState = {
items: [],
totalQuantity: 0,
changed: false,
};
interface AddItemPayload {
id: string;
title: string;
price: number;
}
const cartSlice = createSlice({
name: 'cart',
initialState: initialCartState,
reducers: {
replaceCart(state, action: PayloadAction<CartState>) {
state.totalQuantity = action.payload.totalQuantity;
state.items = action.payload.items;
state.changed = false;
},
addItemToCart(state, action: PayloadAction<AddItemPayload>) {
const newItem = action.payload;
const existingItem = state.items.find(item => item.id === newItem.id);
state.totalQuantity++;
state.changed = true;
if (!existingItem) {
state.items.push({
id: newItem.id,
title: newItem.title,
price: newItem.price,
quantity: 1,
totalPrice: newItem.price,
});
} else {
existingItem.quantity++;
existingItem.totalPrice += newItem.price;
}
},
removeItemFromCart(state, action: PayloadAction<string>) {
const id = action.payload;
const existingItem = state.items.find(item => item.id === id);
if (!existingItem) return;
state.totalQuantity--;
state.changed = true;
if (existingItem.quantity === 1) {
state.items = state.items.filter(item => item.id !== id);
} else {
existingItem.quantity--;
existingItem.totalPrice -= existingItem.price;
}
},
},
});
export const cartActions = cartSlice.actions;
export default cartSlice.reducer;
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Notification {
status: 'pending' | 'success' | 'error';
title: string;
message: string;
}
interface UIState {
cartIsVisible: boolean;
notification: Notification | null;
}
const initialUIState: UIState = {
cartIsVisible: false,
notification: null,
};
interface ShowNotificationPayload {
status: 'pending' | 'success' | 'error';
title: string;
message: string;
}
const uiSlice = createSlice({
name: 'ui',
initialState: initialUIState,
reducers: {
toggleCart(state) {
state.cartIsVisible = !state.cartIsVisible;
},
showNotification(state, action: PayloadAction<ShowNotificationPayload>) {
state.notification = {
status: action.payload.status,
title: action.payload.title,
message: action.payload.message,
};
},
},
});
export const uiActions = uiSlice.actions;
export default uiSlice.reducer;
import { AppDispatch, RootState } from './index';
import { uiActions } from './ui-slice';
import { cartActions } from './cart-slice';
export const fetchCartData = () => {
return async (dispatch: AppDispatch) => {
dispatch(uiActions.showNotification({
status: 'pending',
title: '불러오는 중...',
message: '장바구니 데이터를 가져오는 중...'
}));
const fetchData = async () => {
const response = await fetch('http://localhost:3001/cart');
if (!response.ok) {
throw new Error('데이터 불러오기 실패!');
}
const data = await response.json();
return data;
};
try {
const cartData = await fetchData();
dispatch(cartActions.replaceCart({
items: cartData.items || [],
totalQuantity: cartData.totalQuantity || 0,
changed: false,
}));
dispatch(uiActions.showNotification({
status: 'success',
title: '성공!',
message: '장바구니 데이터 불러오기 성공!'
}));
} catch (error) {
dispatch(uiActions.showNotification({
status: 'error',
title: '오류!',
message: '장바구니 데이터를 가져오지 못했습니다.'
}));
}
};
};
export const sendCartData = (cart: RootState['cart']) => {
return async (dispatch: AppDispatch) => {
dispatch(uiActions.showNotification({
status: 'pending',
title: '보내는 중...',
message: '장바구니 데이터를 서버에 보내는 중...'
}));
const sendRequest = async () => {
const response = await fetch('http://localhost:3001/cart', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: cart.items,
totalQuantity: cart.totalQuantity
}),
});
if (!response.ok) {
throw new Error('장바구니 전송 실패!');
}
};
try {
await sendRequest();
dispatch(uiActions.showNotification({
status: 'success',
title: '성공!',
message: '장바구니 데이터 전송 성공!'
}));
} catch (error) {
dispatch(uiActions.showNotification({
status: 'error',
title: '오류!',
message: '장바구니 데이터 전송에 실패했습니다!'
}));
}
};
};
import { configureStore } from '@reduxjs/toolkit';
import uiReducer from './ui-slice';
import cartReducer from './cart-slice';
export const store = configureStore({
reducer: {
ui: uiReducer,
cart: cartReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState, AppDispatch } from './store';
import { fetchCartData, sendCartData } from './store/cart-actions';
import Layout from './components/Layout';
import Cart from './components/Cart';
import Products from './components/Products';
import Notification from './components/Notification';
let isInitial = true;
const App: React.FC = () => {
const dispatch = useDispatch<AppDispatch>();
const showCart = useSelector((state: RootState) => state.ui.cartIsVisible);
const cart = useSelector((state: RootState) => state.cart);
const notification = useSelector((state: RootState) => state.ui.notification);
useEffect(() => {
dispatch(fetchCartData());
}, [dispatch]);
useEffect(() => {
if (isInitial) {
isInitial = false;
return;
}
if (cart.changed) {
dispatch(sendCartData(cart));
}
}, [cart, dispatch]);
return (
<>
{notification && <Notification status={notification.status} title={notification.title} message={notification.message}/>}
<Layout>
{showCart && <Cart />}
<Products />
</Layout>
</>
);
}
export default App;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartButton from './CartButton';
interface LayoutProps {
children?: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = (props) => {
const totalQuantity = useSelector((state: RootState) => state.cart.totalQuantity);
return (
<div>
<header style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', background: '#ccc' }}>
<h1>리덕스 장바구니 예제</h1>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CartButton />
<span style={{ marginLeft: '1rem' }}>총 개수: {totalQuantity}</span>
</div>
</header>
<main style={{ padding: '1rem' }}>{props.children}</main>
</div>
)
}
export default Layout;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartItem from './CartItem';
const Cart: React.FC = () => {
const items = useSelector((state: RootState) => state.cart.items);
if (items.length === 0) {
return <p>장바구니가 비어있습니다!</p>;
}
return (
<div style={{ border: '1px solid #ccc', padding: '1rem', background: '#eee' }}>
<h2>내 장바구니</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map(item => (
<CartItem
key={item.id}
item={{
id: item.id,
title: item.title,
quantity: item.quantity,
price: item.price,
totalPrice: item.totalPrice
}}
/>
))}
</ul>
</div>
);
};
export default Cart;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';
interface CartItemProps {
item: {
id: string;
title: string;
quantity: number;
price: number;
totalPrice: number;
};
}
const CartItem: React.FC<CartItemProps> = (props) => {
const { id, title, quantity, price, totalPrice } = props.item;
const dispatch = useDispatch<AppDispatch>();
const removeItemHandler = () => {
dispatch(cartActions.removeItemFromCart(id));
};
const addItemHandler = () => {
dispatch(cartActions.addItemToCart({ id, title, price }));
};
return (
<li style={{ marginBottom: '1rem', borderBottom: '1px solid #ddd', paddingBottom: '0.5rem' }}>
<div><strong>{title}</strong></div>
<div>단가: {price}원</div>
<div>수량: {quantity}</div>
<div>총 가격: {totalPrice}원</div>
<div style={{ marginTop: '0.5rem' }}>
<button onClick={removeItemHandler}>-</button>
<button onClick={addItemHandler}>+</button>
</div>
</li>
);
};
export default CartItem;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { uiActions } from '../store/ui-slice';
const CartButton: React.FC = () => {
const dispatch = useDispatch<AppDispatch>();
const toggleCartHandler = () => {
dispatch(uiActions.toggleCart());
};
return <button onClick={toggleCartHandler}>내 장바구니</button>;
};
export default CartButton;
import React from 'react';
import ProductItem from './ProductItem';
const DUMMY_PRODUCTS = [
{ id: 'p1', price: 6, title: 'My First Book', description: 'The first book I ever wrote.' },
{ id: 'p2', price: 5, title: 'My Second Book', description: 'The second book I ever wrote.' },
];
const Products: React.FC = () => {
return (
<section>
<h2>구매할 상품 선택</h2>
<ul>
{DUMMY_PRODUCTS.map(product => (
<ProductItem
key={product.id}
id={product.id}
title={product.title}
price={product.price}
description={product.description}
/>
))}
</ul>
</section>
);
};
export default Products;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';
interface ProductItemProps {
id: string;
title: string;
price: number;
description: string;
}
const ProductItem: React.FC<ProductItemProps> = (props) => {
const { id, title, price, description } = props;
const dispatch = useDispatch<AppDispatch>();
const addToCartHandler = () => {
dispatch(cartActions.addItemToCart({ id, title, price }));
};
return (
<li style={{ marginBottom: '1rem', border: '1px solid #ddd', padding: '1rem' }}>
<h3>{title}</h3>
<p>{description}</p>
<div>가격: {price}원</div>
<button onClick={addToCartHandler}>장바구니에 추가</button>
</li>
);
};
export default ProductItem;
import React from 'react';
interface NotificationProps {
status: 'pending' | 'success' | 'error';
title: string;
message: string;
}
const Notification: React.FC<NotificationProps> = (props) => {
let backgroundColor = '#ccc';
if (props.status === 'pending') {
backgroundColor = 'blue';
}
if (props.status === 'success') {
backgroundColor = 'green';
}
if (props.status === 'error') {
backgroundColor = 'red';
}
return (
<section style={{ backgroundColor, padding: '1rem', position: 'fixed', top: '1rem', right: '1rem', color: '#fff' }}>
<h2>{props.title}</h2>
<p>{props.message}</p>
</section>
);
};
export default Notification;
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
<Provider store={store}>
<App />
</Provider>
);
아래는 비동기 코드(HTTP 요청)와 부수 효과 처리 문제를 해결하는 더 나은 패턴을 정리한 예시입니다.
이전에 설명한 단점(컴포넌트 내부에서 리덕스 상태를 임의로 변환하고, 비동기 요청 준비를 하는 것)이 아닌, 액션 생성 함수(Thunk
)를 통해 모든 비동기 및 데이터 변환 로직을 처리하는 방식을 보여줍니다.
이를 통해 리듀서는 순수 로직만 유지하고, 컴포넌트는 단순히 액션 디스패치만 담당하며, 비동기 로직과 데이터 변환은 액션 생성 함수에서 처리됩니다.
이렇게 하면 컴포넌트와 리듀서 모두 깔끔하고 유지보수하기 쉽습니다.
사용자 상호작용 (제품 "장바구니에 추가" 버튼 클릭)
│
▼ dispatch(addToCartThunk(제품정보))
│
▼ Thunk 액션 함수
- 현재 cart 상태 조회(useSelector 필요없음, getState 사용)
- 기존 cart 상태 + 새로운 제품정보로 데이터 변환 (불변성 유지)
- 변환된 cart 상태 백엔드에 PUT 요청
- 요청 성공 후 cartActions.replaceCart(...)로 리듀서 상태 업데이트
│
▼
Redux Reducer(순수 로직)
상태 업데이트 후 UI 재렌더
│
▼
React 컴포넌트는 useSelector로 상태 조회, 변경사항 반영
이 흐름에서 컴포넌트 내부에서 상태 변환이나 비동기 로직을 하지 않고, Thunk 액션 함수에서 모든 것을 처리합니다. 리듀서는 여전히 순수 상태 변경만 수행합니다.
여기서는 "모든 변환을 Thunk에서 처리"하는 극단적 패턴을 시연하기 위해 addItemToCart 등을 리듀서에서 제거했습니다.
설명: store/cartActions.ts
정리:
이 패턴을 사용하면 처음 제기된 문제(컴포넌트 내부에서 비동기 로직과 상태 변환 로직을 혼용하는 불편함)를 해결하고, 코드 유지보수성과 확장성을 높일 수 있습니다.
아래는 리듀서 내에 모든 장바구니 상태 변환 로직을 유지하면서, 비동기 HTTP 요청(백엔드 동기화)을 컴포넌트 내부(App.tsx)의 useEffect를 통해 처리하는 예시 코드입니다. 이 방식은 다음과 같은 특징을 가집니다.
아래 예시는 Firebase를 백엔드로 사용합니다. Node.js 백엔드를 사용하려면 URL을 Node.js 서버로 변경하면 됩니다. 여기서는 PUT 요청으로 Firebase에 데이터를 저장하며, Firebase 특성상 .json 엔드포인트를 통해 데이터베이스에 바로 쓰기 가능하다고 가정합니다.
사용자 "장바구니에 추가" 버튼 클릭
│
▼ dispatch(cartActions.addItemToCart(...))
│
▼
Redux Reducer
(상태 변환 로직)
│
▼
cart 상태 변경
│
▼
useSelector로 App 컴포넌트에서 cart 상태 감지
│
▼ (useEffect 동작)
HTTP PUT 요청으로 Firebase에 cart 데이터 동기화
리듀서는 여전히 순수한 상태 업데이트만 담당하며, 부수 효과와 비동기 로직은 컴포넌트(App.tsx)에서 처리합니다.
설명: store/cartSlice.ts
설명: App.tsx
정리:
현재 우리가 사용하는 방식으로 useEffect를 사용할 때 한 가지 문제에 직면합니다: 앱이 시작될 때 그것이 실행된다는 것입니다.
이것이 왜 문제일까요?
이것은 초기(즉, 비어 있는) 카트를 백엔드로 보내고 거기에 저장된 모든 데이터를 덮어쓰기 때문에 문제입니다.
우리는 이것을 고칠 것입니다, 나는 단지 그것을 지적하고 싶었습니다! (위 예시 코드는 괜찮음. 위 문제 수정한 예시코드임)
아래는 전체 폴더 구조 및 코드 예시를 모두 제시하며, 코드 생략 없이 완전한 형태를 보여줍니다.
사용자 장바구니 변경 액션 (addItemToCart, removeItemFromCart)
│
▼
Redux Reducer (cartSlice)
장바구니 상태 변경, changed = true
│
▼
App.tsx useEffect (cart가 변경될 때 실행)
│
▼ HTTP PUT 요청 (Firebase)
성공/실패 여부 확인
│
▼ dispatch(uiActions.showNotification(...))
알림 상태 업데이트 (uiSlice)
│
▼
Notification 컴포넌트 렌더링, UI에 알림 표시
리듀서는 순수 로직만 담당하고, 비동기 로직(HTTP 요청)과 그에 따른 알림 상태 변경은 컴포넌트에서 처리합니다.
아래는 비동기 HTTP 요청(백엔드 동기화)과 부수 효과를 Thunk 액션 크리에이터를 통해 처리하는 패턴에 대한 예시입니다. 이 접근 방식에서는 다음과 같은 특징이 있습니다:
아래 예시는 Redux Toolkit, React, TypeScript, Firebase 연동을 가정한 전체 코드 예시입니다. (Firebase 대신 Node.js 서버 사용도 가능하나, URL만 변경하면 됨.)
사용자 장바구니 액션 (addItemToCart 등)
│
▼
Redux Reducer(순수 상태 변경)
│
▼ cart 상태 변경, changed = true
│
▼
App.tsx useEffect로 cart 변화 감지
│
▼ dispatch(sendCartData(cart))
(Thunk 액션)
│
▼ Thunk 액션 내에서 부수 효과(HTTP 요청)
- 알림 표시(uiActions.showNotification)
- Firebase PUT 요청
- 성공/실패 시 알림 상태 변경 액션 디스패치
│
▼
Notification 컴포넌트 렌더링으로 결과 표시
Thunk 액션(sendCartData)이 비동기 로직, 알림 상태 업데이트를 책임지며, 컴포넌트는 단지 sendCartData(cart)를 디스패치하기만 하면 된다.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
title: string;
price: number;
quantity: number;
totalPrice: number;
}
interface CartState {
items: CartItem[];
totalQuantity: number;
changed: boolean;
}
const initialCartState: CartState = {
items: [],
totalQuantity: 0,
changed: false,
};
interface AddItemPayload {
id: string;
title: string;
price: number;
}
const cartSlice = createSlice({
name: 'cart',
initialState: initialCartState,
reducers: {
replaceCart(state, action: PayloadAction<CartState>) {
state.items = action.payload.items;
state.totalQuantity = action.payload.totalQuantity;
state.changed = false;
},
addItemToCart(state, action: PayloadAction<AddItemPayload>) {
const newItem = action.payload;
const existingItem = state.items.find(item => item.id === newItem.id);
state.totalQuantity++;
state.changed = true;
if (!existingItem) {
state.items.push({
id: newItem.id,
title: newItem.title,
price: newItem.price,
quantity: 1,
totalPrice: newItem.price,
});
} else {
existingItem.quantity++;
existingItem.totalPrice += newItem.price;
}
},
removeItemFromCart(state, action: PayloadAction<string>) {
const id = action.payload;
const existingItem = state.items.find(item => item.id === id);
if (!existingItem) return;
state.totalQuantity--;
state.changed = true;
if (existingItem.quantity === 1) {
state.items = state.items.filter(item => item.id !== id);
} else {
existingItem.quantity--;
existingItem.totalPrice -= existingItem.price;
}
},
},
});
export const cartActions = cartSlice.actions;
export default cartSlice.reducer;
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Notification {
status: 'pending' | 'success' | 'error';
title: string;
message: string;
}
interface UIState {
cartIsVisible: boolean;
notification: Notification | null;
}
const initialUIState: UIState = {
cartIsVisible: false,
notification: null,
};
interface ShowNotificationPayload {
status: 'pending' | 'success' | 'error';
title: string;
message: string;
}
const uiSlice = createSlice({
name: 'ui',
initialState: initialUIState,
reducers: {
toggleCart(state) {
state.cartIsVisible = !state.cartIsVisible;
},
showNotification(state, action: PayloadAction<ShowNotificationPayload>) {
state.notification = {
status: action.payload.status,
title: action.payload.title,
message: action.payload.message,
};
},
},
});
export const uiActions = uiSlice.actions;
export default uiSlice.reducer;
import { AppDispatch, RootState } from './index';
import { uiActions } from './ui-slice';
// 장바구니 데이터를 서버(Firebase)에 전송하는 thunk 액션
export const sendCartData = (cart: RootState['cart']) => {
return async (dispatch: AppDispatch) => {
dispatch(uiActions.showNotification({
status: 'pending',
title: '보내는 중...',
message: '장바구니 데이터를 서버에 보내는 중...'
}));
try {
const response = await fetch('https://your-firebase-project.firebaseio.com/cart.json', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' }, // 중요!! 어떤 데이터 타입으로 보내는지 명시 필요!!
body: JSON.stringify({
items: cart.items,
totalQuantity: cart.totalQuantity,
}),
});
if (!response.ok) {
throw new Error('장바구니 전송 실패!');
}
dispatch(uiActions.showNotification({
status: 'success',
title: '성공!',
message: '장바구니 데이터 전송 성공!',
}));
} catch (error) {
dispatch(uiActions.showNotification({
status: 'error',
title: '오류!',
message: '장바구니 데이터 전송에 실패했습니다!',
}));
}
};
};
import { configureStore } from '@reduxjs/toolkit';
import uiReducer from './ui-slice';
import cartReducer from './cart-slice';
export const store = configureStore({
reducer: {
ui: uiReducer,
cart: cartReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from './store';
import Layout from './components/Layout';
import Cart from './components/Cart';
import Products from './components/Products';
import Notification from './components/Notification';
import { sendCartData } from './store/cart-actions';
let isInitial = true;
const App: React.FC = () => {
const dispatch = useDispatch<AppDispatch>();
const showCart = useSelector((state: RootState) => state.ui.cartIsVisible);
const cart = useSelector((state: RootState) => state.cart);
const notification = useSelector((state: RootState) => state.ui.notification);
useEffect(() => {
// 첫 렌더링 시에는 전송 안 함
if (isInitial) {
isInitial = false;
return;
}
if (cart.changed) {
// 장바구니 데이터 변경 시 Thunk 액션 디스패치
dispatch(sendCartData(cart));
}
}, [cart, dispatch]);
return (
<>
{notification && <Notification status={notification.status} title={notification.title} message={notification.message}/>}
<Layout>
{showCart && <Cart />}
<Products />
</Layout>
</>
);
}
export default App;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartButton from './CartButton';
interface LayoutProps {
children?: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = (props) => {
const totalQuantity = useSelector((state: RootState) => state.cart.totalQuantity);
return (
<div>
<header style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', background: '#ccc' }}>
<h1>리덕스 장바구니 예제</h1>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CartButton />
<span style={{ marginLeft: '1rem' }}>총 개수: {totalQuantity}</span>
</div>
</header>
<main style={{ padding: '1rem' }}>{props.children}</main>
</div>
)
}
export default Layout;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartItem from './CartItem';
const Cart: React.FC = () => {
const items = useSelector((state: RootState) => state.cart.items);
if (items.length === 0) {
return <p>장바구니가 비어있습니다!</p>;
}
return (
<div style={{ border: '1px solid #ccc', padding: '1rem', background: '#eee' }}>
<h2>내 장바구니</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map(item => (
<CartItem
key={item.id}
item={{
id: item.id,
title: item.title,
quantity: item.quantity,
price: item.price,
totalPrice: item.totalPrice
}}
/>
))}
</ul>
</div>
);
};
export default Cart;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';
interface CartItemProps {
item: {
id: string;
title: string;
quantity: number;
price: number;
totalPrice: number;
};
}
const CartItem: React.FC<CartItemProps> = (props) => {
const { id, title, quantity, price, totalPrice } = props.item;
const dispatch = useDispatch<AppDispatch>();
const removeItemHandler = () => {
dispatch(cartActions.removeItemFromCart(id));
};
const addItemHandler = () => {
dispatch(cartActions.addItemToCart({ id, title, price }));
};
return (
<li style={{ marginBottom: '1rem', borderBottom: '1px solid #ddd', paddingBottom: '0.5rem' }}>
<div><strong>{title}</strong></div>
<div>단가: {price}원</div>
<div>수량: {quantity}</div>
<div>총 가격: {totalPrice}원</div>
<div style={{ marginTop: '0.5rem' }}>
<button onClick={removeItemHandler}>-</button>
<button onClick={addItemHandler}>+</button>
</div>
</li>
);
};
export default CartItem;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { uiActions } from '../store/ui-slice';
const CartButton: React.FC = () => {
const dispatch = useDispatch<AppDispatch>();
const toggleCartHandler = () => {
dispatch(uiActions.toggleCart());
};
return <button onClick={toggleCartHandler}>내 장바구니</button>;
};
export default CartButton;
import React from 'react';
import ProductItem from './ProductItem';
const DUMMY_PRODUCTS = [
{ id: 'p1', price: 6, title: 'My First Book', description: 'The first book I ever wrote.' },
{ id: 'p2', price: 5, title: 'My Second Book', description: 'The second book I ever wrote.' },
];
const Products: React.FC = () => {
return (
<section>
<h2>구매할 상품 선택</h2>
<ul>
{DUMMY_PRODUCTS.map(product => (
<ProductItem
key={product.id}
id={product.id}
title={product.title}
price={product.price}
description={product.description}
/>
))}
</ul>
</section>
);
};
export default Products;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';
interface ProductItemProps {
id: string;
title: string;
price: number;
description: string;
}
const ProductItem: React.FC<ProductItemProps> = (props) => {
const { id, title, price, description } = props;
const dispatch = useDispatch<AppDispatch>();
const addToCartHandler = () => {
dispatch(cartActions.addItemToCart({ id, title, price }));
};
return (
<li style={{ marginBottom: '1rem', border: '1px solid #ddd', padding: '1rem' }}>
<h3>{title}</h3>
<p>{description}</p>
<div>가격: {price}원</div>
<button onClick={addToCartHandler}>장바구니에 추가</button>
</li>
);
};
export default ProductItem;
import React from 'react';
interface NotificationProps {
status: 'pending' | 'success' | 'error';
title: string;
message: string;
}
const Notification: React.FC<NotificationProps> = (props) => {
let backgroundColor = '#ccc';
if (props.status === 'pending') {
backgroundColor = 'blue';
}
if (props.status === 'success') {
backgroundColor = 'green';
}
if (props.status === 'error') {
backgroundColor = 'red';
}
return (
<section style={{ backgroundColor, padding: '1rem', position: 'fixed', top: '1rem', right: '1rem', color: '#fff' }}>
<h2>{props.title}</h2>
<p>{props.message}</p>
</section>
);
};
export default Notification;
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
<Provider store={store}>
<App />
</Provider>
);
store/cartActions.ts (Thunk 액션)
App.tsx
정리:
아래는 Thunk 액션 크리에이터를 활용하여 애플리케이션 로드시 Firebase에서 장바구니 데이터를 가져온 뒤 상태를 업데이트하고, 장바구니 상태 변경 시 서버에 동기화하는 전체 예시 코드입니다. 이전 예시에서는 장바구니 데이터를 fetch하지 않았으나, 이번 예시에서는 fetchCartData Thunk 액션을 추가해 앱 로드 시 Firebase에서 장바구니 데이터를 가져옵니다.
이로써 애플리케이션 로드시 백엔드 데이터로 장바구니 상태를 초기화하고, 이후 장바구니 변경 시 자동 동기화를 구현하며, 불필요한 재전송 문제를 피할 수 있습니다.
앱 로드 시
│
▼ dispatch(fetchCartData())
(Thunk 액션)
│
▼ Firebase에서 장바구니 데이터 GET
│
▼ cartActions.replaceCart(...) 디스패치
changed = false로 설정
│
▼ 리듀서에서 상태 업데이트
│
▼ App.tsx useEffect (cart.changed감지)
초기 장바구니 대입은 changed=false이므로 sendCartData 미실행
│
사용자가 장바구니 변경(addItemToCart 등)
│
▼ changed = true
│
▼ cart 상태 변경 감지 → sendCartData(cart) 디스패치
Firebase에 PUT 요청
│
▼ 성공/실패 여부에 따라 uiActions.showNotification 디스패치
│
▼ Notification 컴포넌트로 알림 표시
fetchCartData로 초기 상태를 로딩한 뒤, changed=false 상태로 시작하므로 불필요한 재전송 없음. 이후 장바구니 변경 시 changed=true가 되면 sendCartData로 서버 동기화.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
title: string;
price: number;
quantity: number;
totalPrice: number;
}
interface CartState {
items: CartItem[];
totalQuantity: number;
changed: boolean;
}
const initialCartState: CartState = {
items: [],
totalQuantity: 0,
changed: false,
};
interface AddItemPayload {
id: string;
title: string;
price: number;
}
const cartSlice = createSlice({
name: 'cart',
initialState: initialCartState,
reducers: {
replaceCart(state, action: PayloadAction<CartState>) {
state.items = action.payload.items;
state.totalQuantity = action.payload.totalQuantity;
// fetch 후 교체하므로 changed = false
state.changed = false;
},
addItemToCart(state, action: PayloadAction<AddItemPayload>) {
const newItem = action.payload;
const existingItem = state.items.find(item => item.id === newItem.id);
state.totalQuantity++;
state.changed = true;
if (!existingItem) {
state.items.push({
id: newItem.id,
title: newItem.title,
price: newItem.price,
quantity: 1,
totalPrice: newItem.price,
});
} else {
existingItem.quantity++;
existingItem.totalPrice += newItem.price;
}
},
removeItemFromCart(state, action: PayloadAction<string>) {
const id = action.payload;
const existingItem = state.items.find(item => item.id === id);
if (!existingItem) return;
state.totalQuantity--;
state.changed = true;
if (existingItem.quantity === 1) {
state.items = state.items.filter(item => item.id !== id);
} else {
existingItem.quantity--;
existingItem.totalPrice -= existingItem.price;
}
},
},
});
export const cartActions = cartSlice.actions;
export default cartSlice.reducer;
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Notification {
status: 'pending' | 'success' | 'error';
title: string;
message: string;
}
interface UIState {
cartIsVisible: boolean;
notification: Notification | null;
}
const initialUIState: UIState = {
cartIsVisible: false,
notification: null,
};
interface ShowNotificationPayload {
status: 'pending' | 'success' | 'error';
title: string;
message: string;
}
const uiSlice = createSlice({
name: 'ui',
initialState: initialUIState,
reducers: {
toggleCart(state) {
state.cartIsVisible = !state.cartIsVisible;
},
showNotification(state, action: PayloadAction<ShowNotificationPayload>) {
state.notification = {
status: action.payload.status,
title: action.payload.title,
message: action.payload.message,
};
},
},
});
export const uiActions = uiSlice.actions;
export default uiSlice.reducer;
import { AppDispatch, RootState } from './index';
import { uiActions } from './ui-slice';
import { cartActions } from './cart-slice';
// 장바구니 데이터를 서버(Firebase)로 전송하는 thunk 액션
export const sendCartData = (cart: RootState['cart']) => {
return async (dispatch: AppDispatch) => {
dispatch(uiActions.showNotification({
status: 'pending',
title: '보내는 중...',
message: '장바구니 데이터를 서버에 보내는 중...'
}));
try {
const response = await fetch('https://your-firebase-project.firebaseio.com/cart.json', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' }, // 중요!! 어떤 데이터 타입으로 보내는지 명시 필요!!
body: JSON.stringify({
items: cart.items,
totalQuantity: cart.totalQuantity
}),
});
if (!response.ok) {
throw new Error('장바구니 전송 실패!');
}
dispatch(uiActions.showNotification({
status: 'success',
title: '성공!',
message: '장바구니 데이터 전송 성공!',
}));
} catch (error) {
dispatch(uiActions.showNotification({
status: 'error',
title: '오류!',
message: '장바구니 데이터 전송에 실패했습니다!',
}));
}
};
};
// 애플리케이션 로드시 서버(Firebase)에서 장바구니 데이터를 가져오는 thunk 액션
export const fetchCartData = () => {
return async (dispatch: AppDispatch) => {
const fetchData = async () => {
const response = await fetch('https://your-firebase-project.firebaseio.com/cart.json');
if (!response.ok) {
throw new Error('장바구니 데이터를 가져올 수 없습니다!');
}
const data = await response.json();
return data;
};
try {
const cartData = await fetchData();
dispatch(cartActions.replaceCart({
items: cartData?.items || [],
totalQuantity: cartData?.totalQuantity || 0,
changed: false,
}));
// 필요하다면 성공 알림도 표시 가능하지만 여기서는 생략
} catch (error) {
dispatch(uiActions.showNotification({
status: 'error',
title: '오류!',
message: '장바구니 데이터를 가져오지 못했습니다.'
}));
}
};
};
import { configureStore } from '@reduxjs/toolkit';
import uiReducer from './ui-slice';
import cartReducer from './cart-slice';
export const store = configureStore({
reducer: {
ui: uiReducer,
cart: cartReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from './store';
import Layout from './components/Layout';
import Cart from './components/Cart';
import Products from './components/Products';
import Notification from './components/Notification';
import { sendCartData, fetchCartData } from './store/cart-actions';
let isInitial = true;
const App: React.FC = () => {
const dispatch = useDispatch<AppDispatch>();
const showCart = useSelector((state: RootState) => state.ui.cartIsVisible);
const cart = useSelector((state: RootState) => state.cart);
const notification = useSelector((state: RootState) => state.ui.notification);
// 앱 로드 시 장바구니 데이터 fetch
useEffect(() => {
dispatch(fetchCartData());
}, [dispatch]);
// 장바구니 상태 변경 시 서버에 동기화
useEffect(() => {
if (isInitial) {
isInitial = false;
return;
}
if (cart.changed) {
dispatch(sendCartData(cart));
}
}, [cart, dispatch]);
return (
<>
{notification && <Notification status={notification.status} title={notification.title} message={notification.message}/>}
<Layout>
{showCart && <Cart />}
<Products />
</Layout>
</>
);
}
export default App;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartButton from './CartButton';
interface LayoutProps {
children?: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = (props) => {
const totalQuantity = useSelector((state: RootState) => state.cart.totalQuantity);
return (
<div>
<header style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', background: '#ccc' }}>
<h1>리덕스 장바구니 예제</h1>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CartButton />
<span style={{ marginLeft: '1rem' }}>총 개수: {totalQuantity}</span>
</div>
</header>
<main style={{ padding: '1rem' }}>{props.children}</main>
</div>
)
}
export default Layout;
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import CartItem from './CartItem';
const Cart: React.FC = () => {
const items = useSelector((state: RootState) => state.cart.items);
if (items.length === 0) {
return <p>장바구니가 비어있습니다!</p>;
}
return (
<div style={{ border: '1px solid #ccc', padding: '1rem', background: '#eee' }}>
<h2>내 장바구니</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map(item => (
<CartItem
key={item.id}
item={{
id: item.id,
title: item.title,
quantity: item.quantity,
price: item.price,
totalPrice: item.totalPrice
}}
/>
))}
</ul>
</div>
);
};
export default Cart;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';
interface CartItemProps {
item: {
id: string;
title: string;
quantity: number;
price: number;
totalPrice: number;
};
}
const CartItem: React.FC<CartItemProps> = (props) => {
const { id, title, quantity, price, totalPrice } = props.item;
const dispatch = useDispatch<AppDispatch>();
const removeItemHandler = () => {
dispatch(cartActions.removeItemFromCart(id));
};
const addItemHandler = () => {
dispatch(cartActions.addItemToCart({ id, title, price }));
};
return (
<li style={{ marginBottom: '1rem', borderBottom: '1px solid #ddd', paddingBottom: '0.5rem' }}>
<div><strong>{title}</strong></div>
<div>단가: {price}원</div>
<div>수량: {quantity}</div>
<div>총 가격: {totalPrice}원</div>
<div style={{ marginTop: '0.5rem' }}>
<button onClick={removeItemHandler}>-</button>
<button onClick={addItemHandler}>+</button>
</div>
</li>
);
};
export default CartItem;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { uiActions } from '../store/ui-slice';
const CartButton: React.FC = () => {
const dispatch = useDispatch<AppDispatch>();
const toggleCartHandler = () => {
dispatch(uiActions.toggleCart());
};
return <button onClick={toggleCartHandler}>내 장바구니</button>;
};
export default CartButton;
import React from 'react';
import ProductItem from './ProductItem';
const DUMMY_PRODUCTS = [
{ id: 'p1', price: 6, title: 'My First Book', description: 'The first book I ever wrote.' },
{ id: 'p2', price: 5, title: 'My Second Book', description: 'The second book I ever wrote.' },
];
const Products: React.FC = () => {
return (
<section>
<h2>구매할 상품 선택</h2>
<ul>
{DUMMY_PRODUCTS.map(product => (
<ProductItem
key={product.id}
id={product.id}
title={product.title}
price={product.price}
description={product.description}
/>
))}
</ul>
</section>
);
};
export default Products;
import React from 'react';
import { useDispatch } from 'react-redux';
import { AppDispatch } from '../store';
import { cartActions } from '../store/cart-slice';
interface ProductItemProps {
id: string;
title: string;
price: number;
description: string;
}
const ProductItem: React.FC<ProductItemProps> = (props) => {
const { id, title, price, description } = props;
const dispatch = useDispatch<AppDispatch>();
const addToCartHandler = () => {
dispatch(cartActions.addItemToCart({ id, title, price }));
};
return (
<li style={{ marginBottom: '1rem', border: '1px solid #ddd', padding: '1rem' }}>
<h3>{title}</h3>
<p>{description}</p>
<div>가격: {price}원</div>
<button onClick={addToCartHandler}>장바구니에 추가</button>
</li>
);
};
export default ProductItem;
import React from 'react';
interface NotificationProps {
status: 'pending' | 'success' | 'error';
title: string;
message: string;
}
const Notification: React.FC<NotificationProps> = (props) => {
let backgroundColor = '#ccc';
if (props.status === 'pending') {
backgroundColor = 'blue';
}
if (props.status === 'success') {
backgroundColor = 'green';
}
if (props.status === 'error') {
backgroundColor = 'red';
}
return (
<section style={{ backgroundColor, padding: '1rem', position: 'fixed', top: '1rem', right: '1rem', color: '#fff' }}>
<h2>{props.title}</h2>
<p>{props.message}</p>
</section>
);
};
export default Notification;
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
<Provider store={store}>
<App />
</Provider>
);
store/cartActions.ts
정리:
아래는 이전까지 다룬 문제들을 모두 해결한 최종 패턴의 예시 코드입니다. 이 예제에서는 다음과 같은 개선점을 반영했습니다.
changed 필드 관리:
데이터 전송 시 changed 제외:
removeItemFromCart 로직 수정:
fetchCartData 시 items 기본값 설정:
이로써 애플리케이션이 로드될 때 Firebase에서 장바구니를 가져오고, 이후 장바구니 변경 시에만 서버에 동기화하며, 알림을 통해 상태를 표시하는 완전한 패턴이 완성됩니다.
앱 로드 시
│
▼ dispatch(fetchCartData())
(Thunk 액션)
│
▼ Firebase GET 요청
│
▼ cartActions.replaceCart(...) 디스패치
changed = false로 상태 설정
│
▼ 리듀서 상태 업데이트
사용자가 장바구니 변경(addItemToCart, removeItemFromCart)
│
▼ changed = true
│
▼ cart 상태 변경 감지 → sendCartData(cart) 디스패치
Firebase PUT 요청 (changed 제외)
│
▼ 성공/실패 시 uiActions.showNotification 디스패치
│
▼ Notification 컴포넌트로 알림 표시
로드 시 가져온 데이터는 changed=false 상태이므로 즉시 재전송 없음. 이후 로컬 변경 시 changed=true → 서버 동기화.
아래는 Redux DevTools를 활용해 리덕스 상태와 작업(액션)을 디버깅하는 예시를 자세히 정리한 내용입니다. 이 예시는 이전까지 구현한 패턴(Thunk 액션을 통한 비동기 로직 처리, 리듀서에서 상태 변환 로직 유지, 컴포넌트에서 최소한의 역할만 수행)을 그대로 유지한 상태에서, Redux DevTools를 사용하여 상태 변화를 추적하고, 디버그하는 과정을 보여줍니다.
핵심 포인트:
정리:
아래는 지금까지 배운 내용을 종합적으로 정리한 내용입니다. 리덕스(Redux)를 활용한 전역 상태 관리, 비동기 작업 처리, 부수 효과 관리, 그리고 리덕스 데브툴(Redux DevTools)의 활용까지 모두 아우르는 핵심 개념을 정리했습니다.
사용자 상호작용 (버튼 클릭, 장바구니 업데이트)
│
▼ dispatch(액션) → Reducer(상태변환)
│
▼
Redux Store 상태 변경
│
▼ (useSelector로 감지)
컴포넌트 로직(예: useEffect) 또는 Thunk 액션 크리에이터
│
├─ 비동기 요청(HTTP)
├─ 부수 효과(알림 표시 등)
▼
디스패치(결과 액션) → Reducer(상태변환)
│
▼
UI 업데이트 & Redux DevTools로 상태/액션 추적
리듀서는 순수 상태 업데이트 담당
비동기 로직은 컴포넌트나 Thunk 액션에서 처리
DevTools로 모든 액션과 상태 변화를 추적 가능
정리:
아래는 싱글 페이지 애플리케이션(SPA)에서 라우팅을 도입하는 이유와 방법에 대한 개념을 종합적으로 정리한 내용입니다.
기존 상황: 싱글 페이지 애플리케이션(SPA)
문제점
해결책: 클라이언트 측 라우팅
결과
(기존 SPA)
┌─────────────────┐
│ 하나의 페이지 │
│ URL 변화 없음 │
│ 한 화면만 렌더링 │
└───────┬─────────┘
│
▼
(문제) 특정 화면 상태로 직접 링크 불가,
사용자가 항상 시작 지점에서 탐색 필요
│
▼
(해결: 클라이언트 측 라우팅)
┌─────────────────┐
│ React Router 활용 │
│ URL 변화 반영 │
│ 경로별 컴포넌트 렌더링 │
└───────┬─────────┘
│
▼
사용자는 /products, /cart 등
특정 URL로 바로 접근 가능
페이지 전환 없이도 마치 멀티 페이지처럼 동작
이로써 하나의 SPA 내에서 URL에 따라 다른 화면을 렌더링하는 로직을 구현할 수 있으며, React Router 등 라이브러리를 활용해 쉽게 클라이언트 측 라우팅을 도입할 수 있다.
아래는 라우팅(Routing)의 개념을 이해하고, React Router를 사용하여 싱글 페이지 애플리케이션(SPA)에서도 URL 경로에 따라 다른 화면(컴포넌트)을 렌더링하는 패턴을 예시로 보여준 코드입니다. 이 예시를 통해 서버로부터 매번 새로운 HTML을 로딩하지 않고도 다양한 경로에 따른 페이지 전환을 구현할 수 있습니다. 또한 React Router를 통해 브라우저 주소창의 URL 변화를 감지하고, 해당 URL에 맞는 컴포넌트를 렌더링함으로써 마치 멀티 페이지 애플리케이션처럼 동작하는 SPA를 만들 수 있습니다.
아래 예시는 react-router-dom v6 이상 버전을 사용하며, 가장 많이 검증된 패턴을 따릅니다. 전체 폴더 구조와 모든 파일 코드를 자세하게 제시합니다(코드 생략 없음).
라우팅(Routing) 개념
결과
사용자 URL 접근 (예: /welcome)
│
▼
React Router 감지
현재 경로 = /welcome
│
▼
해당 경로에 매칭되는 컴포넌트 렌더링
│
▼
사용자 화면에 /welcome에 맞는 콘텐츠 표시
│
▼
다른 링크 클릭 → URL 변경 (예: /products)
│
▼
React Router가 다시 감지 → 매칭되는 컴포넌트 렌더링
서버로 새로운 페이지를 요청하지 않고, 클라이언트 측 라우팅으로 URL에 따라 다른 컴포넌트를 렌더링.
이로써 단일 페이지 애플리케이션 환경에서 URL 경로 변경에 맞춰 동적으로 다른 화면(컴포넌트)을 렌더링하는 클라이언트 측 라우팅 기법을 이해하고, 구현할 수 있게 된다.
src/App.tsx
src/main.tsx
사용자 브라우저에서 URL 입력 또는 링크 클릭 (예: /products)
│
▼
BrowserRouter 관찰 URL 변경
│
▼
useRoutes() 훅에서 정의한 경로 배열 확인
│
▼
path="/products"에 매칭되는 element 렌더링 (Products 컴포넌트)
│
▼
화면에 해당 컴포넌트 내용 표시
이로써 서버로 새로운 페이지를 요청하지 않고, 클라이언트 측에서 URL 경로에 따라 다른 컴포넌트를 렌더링하는 클라이언트 측 라우팅을 구현할 수 있습니다.
<Routes>
와 <Route>
컴포넌트 대신 useRoutes 훅을 사용해 라우트를 선언하는 문법을 지원한다.아래는 React Router를 사용하여 싱글 페이지 애플리케이션(SPA)에 라우팅을 추가하는 개념을 정리하고, 실제 코드 예시를 제시한 것입니다. 코드 예시는 타입스크립트를 사용하며, react-router-dom v6 버전을 활용합니다. 또한 전체 폴더 구조와 모든 파일의 코드를 구체적으로 제시하고, 코드 생략 없이 제공합니다.
라우팅(Routing)이란?
왜 필요한가?
어떻게 구현하는가?
<Link>
를 사용하여 경로 이동 지원이렇게 하면 SPA 환경에서 클라이언트 측 라우팅을 구현해, 다양한 페이지로 자연스럽게 이동할 수 있게 됩니다.
사용자 URL 입력 또는 링크 클릭 (예: /welcome)
│
▼
BrowserRouter에서 URL 감지
│
▼
useRoutes(또는 Routes/Route)로 정의한 경로 매칭
│
▼
매칭되는 컴포넌트 렌더링
│
▼
사용자에게 해당 페이지 콘텐츠 표시
전통적인 방식과 달리, 서버에 새 HTML을 요청하지 않고도 경로 변경 시 컴포넌트만 교체하여 화면 전환.
src/components/MainHeader.tsx
<Link>
컴포넌트를 사용해 클라이언트 측 경로 이동 가능.src/App.tsx
정리
<Link>
컴포넌트를 통해 페이지 전환 시 전체 페이지 리로딩 없이 클라이언트 측 경로 변경 구현.아래는 React Router v6.4 이상의 버전에서 createBrowserRouter와 RouterProvider를 사용해 기본 라우팅을 설정하는 전체 예시 코드입니다. 이 예시는 다음과 같은 특징을 가집니다.
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import HomePage from './pages/HomePage';
// createBrowserRouter에 라우트 정의
// 하나의 경로('/')와 element(HomePage)를 설정
const router = createBrowserRouter([
{
path: '/',
element: <HomePage />
}
]);
const App: React.FC = () => {
return (
// RouterProvider로 router를 전달
<RouterProvider router={router} />
);
}
export default App;
src/App.tsx
<HomePage />
렌더링정리
아래는 React Router v6.4 이상의 버전에서 createBrowserRouter와 RouterProvider를 사용하여 다중 페이지를 지원하는 예시입니다. 이 예제는 이전 예제에서 한 걸음 더 나아가 /products 경로를 추가해 "ProductsPage"를 별도로 렌더링합니다.
핵심 포인트:
src/App.tsx
정리
이 패턴은 사람들이 가장 많이 사용하고 검증된 React Router v6+ 패턴으로, 라우팅을 깔끔하게 관리하고 각 경로별 페이지를 별도 컴포넌트로 유지함으로써 코드 구조를 명확히 합니다.
아래는 React Router v6.4 이상에서 두 가지 방식으로 라우트를 정의하는 예시를 모두 보여준 뒤, 최종적으로 객체 기반 솔루션을 사용하는 방식을 선택하는 코드 예시입니다.
즉, 처음에는 createBrowserRouter()에 직접 객체 배열을 전달하는 방식(객체 기반)을 사용하고, 이후 createRoutesFromElements()와 <Route>
JSX를 사용하는 또 다른 방식을 코드에 주석으로 함께 남깁니다.
마지막에 주석 처리한 코드를 제외한 최종 코드는 객체 기반 라우트 정의를 사용하는 패턴으로 돌아옵니다.
핵심 포인트:
<Route>
컴포넌트 조합을 통해 JSX로 라우트 정의 가능src/App.tsx
정리
<Route>
컴포넌트를 사용해 라우트를 JSX 형태로 정의 가능. 기존 React Router v6 이전 방식과 유사.결론:
react-10 프로젝트 코드 참고하면됨
아래는 React Router v6.4 이상에서 Link 컴포넌트를 사용해 페이지 간 내비게이션을 구현하는 예제입니다. 이전 예제에서 / 경로의 HomePage와 /products 경로의 ProductsPage를 정의한 상태이며, 이번에는 HomePage에서 Link를 통해 ProductsPage로 이동하는 링크를 추가합니다.
<a href="/products">
를 사용했을 때 새로운 HTTP 요청이 서버로 전송되어 SPA(Single Page Application)의 장점이 사라졌습니다.사용자 / URL 접속 (http://localhost:3000/)
│
▼
React Router: '/' 경로 매칭 → HomePage 렌더
│
▼ HomePage 내 Link 컴포넌트 클릭
("/products" 경로로 이동)
│
▼
React Router: '/products' 경로 매칭 → ProductsPage 렌더
│
▼
브라우저 히스토리 변경, HTTP 요청 없음 (SPA 유지)
Link 컴포넌트를 통해 경로 이동 시, 서버 재요청 없이 클라이언트 측 라우팅으로 페이지 전환.
src/pages/HomePage.tsx
src/App.tsx
정리
<a>
태그 대신 Link 컴포넌트를 사용하여 /products로 이동하는 링크 추가.이로써 React Router를 사용해 부드러운 클라이언트 측 라우팅과 링크 기반 네비게이션을 구현하는 전형적이고 검증된 패턴을 제시했습니다.
아래는 React Router v6.4 이상에서 레이아웃 라우트를 활용해 모든 페이지 상단에 내비게이션 바를 표시하고, 자식 라우트를 Outlet으로 렌더링하는 패턴의 예시 코드입니다. 또한 CSS 모듈을 사용해 간단한 스타일링을 적용합니다.
브라우저에서 http://localhost:3000/ 요청
│
▼
RouterProvider → createBrowserRouter로 정의한 라우트 확인
│
▼
RootLayout(부모 라우트) 렌더링
│
▼
MainNavigation(네비게이션) + Outlet(자식 라우트)
│
├─ '/' 경로이면 HomePage 렌더
└─ '/products' 경로이면 ProductsPage 렌더
RootLayout이 상위 레이아웃을 제공하고, Outlet을 통해 자식 라우트를 렌더링함. 모든 자식 페이지에서 네비게이션과 공통 스타일 유지.
src/App.tsx
정리
이로써 React Router를 활용한 레이아웃 라우트, Link 기반 클라이언트 내비게이션, CSS 모듈 스타일링을 모두 적용한 검증된 패턴을 제시했습니다.
아래 예시는 React Router v6.4 이상에서 오류 페이지(ErrorPage
)를 구현하는 방법을 보여줍니다.
이전에 구현한 라우트(/, /products)에 대해 존재하지 않는 경로 접속 시 React Router가 발생시키는 오류를 커스텀 오류 페이지로 처리하는 패턴을 구현합니다.
사용자가 지원하지 않는 경로 접속 (예: http://localhost:3000/abc)
│
▼
React Router 오류 감지
│
▼
errorElement (ErrorPage) 렌더링
│
▼
ErrorPage에 MainNavigation 표시 + 오류 메시지 출력
정상 경로: / → HomePage, /products → ProductsPage, 잘못된 경로 → ErrorPage로 처리.
src/index.css (전역 스타일)
src/pages/RootLayout.tsx
src/ErrorPage.tsx
src/App.tsx
정리
이로써 React Router로 라우트를 정의할 때 오류 발생 상황에 대비한 커스텀 오류 페이지 적용 패턴을 완성했습니다.
아래 예제는 React Router v6.4 이상에서 NavLink 컴포넌트를 사용해 현재 활성인 페이지를 나타내는 링크 스타일을 적용하는 방법을 보여줍니다. 이전 예제에서는 Link를 사용했지만, NavLink를 사용하면 현재 활성 라우트를 가리키는 링크에 자동으로 활성 상태를 지정할 수 있습니다. 또한 end 속성을 사용해 '/' 경로의 NavLink가 다른 경로에서 활성으로 표시되지 않도록 설정합니다.
브라우저에서 http://localhost:3000/ 접속
│
▼
RootLayout + MainNavigation + HomePage 렌더
│
▼ 사용자가 NavLink "Products" 클릭
NavLink로 URL 변경, HTTP 요청 없음
│
▼
Router: /products 경로 매칭 → ProductsPage 렌더
│
▼
NavLink "Products" 링크 isActive=true → .active 클래스 적용
NavLink를 통해 현재 활성 경로에 해당하는 링크를 스타일로 강조.
import React from 'react';
import { NavLink } from 'react-router-dom';
import classes from './MainNavigation.module.css';
const MainNavigation: React.FC = () => {
return (
<header className={classes.header}>
<nav>
<ul className={classes.list}>
<li>
<NavLink
to="/"
end
className={(navData) => (navData.isActive ? classes.active : '')}
>
Home
</NavLink>
</li>
<li>
<NavLink
to="/products"
className={(navData) => (navData.isActive ? classes.active : '')}
>
Products
</NavLink>
</li>
</ul>
</nav>
</header>
);
};
export default MainNavigation;
설명:
정리
react-10 프로젝트 코드 참고하면됨
아래 예시는 React Router v6.4 이상에서 useNavigate 훅을 사용해 프로그램적으로(코드에서) 페이지 이동을 구현하는 방법을 보여줍니다.
이전까지는 주로 <Link>
나 <NavLink>
를 사용해 사용자가 클릭하는 방식으로 페이지를 전환했지만,
이번 예시에서는 버튼 클릭, 타이머 만료, 폼 제출 등의 이벤트에 따라 코드 내부에서 다른 경로로 이동시키는 방법을 시연합니다.
<Link>
컴포넌트 대신, 코드 흐름에 따라 자동으로 페이지를 이동시키고 싶을 때 유용import React from 'react'
import { useNavigate } from 'react-router-dom'
const HomePage: React.FC = () => {
const navigate = useNavigate()
const navigateHandler = () => {
// 프로그램적으로 /products로 이동
navigate('/products')
}
return (
<main>
<h1>My Home Page</h1>
<p>루트 경로(/)로 접근했을 때 표시되는 홈 페이지입니다.</p>
<button onClick={navigateHandler}>Go to Products (프로그램적 이동)</button>
</main>
)
}
export default HomePage
설명:
정리
react-10 프로젝트 코드 참고하면됨
아래 예제는 React Router v6.4 이상에서 동적 경로 파라미터(Dynamic Route Segment
)를 사용하여 상품 상세 페이지를 구현하는 패턴을 보여줍니다.
이전까지 /products 페이지에서 단순히 제품 페이지임을 표시했지만, 실제 애플리케이션에서는 특정 제품의 상세 정보 페이지를 구현하고자 할 수 있습니다.
이를 위해 :productId와 같은 경로 파라미터를 사용하고, useParams 훅을 통해 URL에 포함된 값을 추출할 수 있습니다.
사용자가 http://localhost:3000/products/p1 접속
│
▼
Router: '/products/:productId' 경로 매칭
productId 파라미터 = 'p1'
│
▼
ProductDetailPage 렌더
useParams() → { productId: 'p1' }
│
▼
화면에 productId 값 표시
(나중에 백엔드 호출 등 추가 가능)
URL 뒤에 오는 값(p1, product-1, abc)이 productId 파라미터로 전달되어 화면에 표시됨.
import React from 'react';
import { Link } from 'react-router-dom';
const ProductsPage: React.FC = () => {
return (
<main>
<h1>The Products Page</h1>
<p>/products 경로로 접근했을 때 이 페이지가 표시됩니다.</p>
<ul>
<li><Link to="/products/p1">Product 1</Link></li>
<li><Link to="/products/p2">Product 2</Link></li>
<li><Link to="/products/p3">Product 3</Link></li>
</ul>
</main>
);
};
export default ProductsPage;
import React from 'react';
import { useParams } from 'react-router-dom';
const ProductDetailPage: React.FC = () => {
const params = useParams();
// params.productId를 통해 URL에서 추출한 제품 ID 값 확인 가능
return (
<main>
<h1>Product Details</h1>
<p>제품 ID: {params.productId}</p>
</main>
);
};
export default ProductDetailPage;
설명:
정리
react-10 프로젝트 코드 참고하면됨
아래 예제는 React Router v6.4 이상에서 동적 경로 파라미터를 활용하는 예시를 더욱 발전시킨 것입니다. 이전 예제에서는 /products/:productId 경로를 사용해 특정 제품 상세 페이지로 이동할 수 있는 동적 경로를 정의했습니다. 이번 예제에서는 백엔드에서 가져온다고 가정한 제품 목록(여기서는 하드코딩)을 기반으로, 각 제품별로 적절한 링크를 동적으로 생성합니다. 이렇게 하면 새로운 제품이 추가되더라도 코드 변경 없이 목록과 링크를 자동으로 갱신할 수 있습니다.
<Link>
를 동적으로 생성사용자 http://localhost:3000/products 접속
│
▼
ProductsPage 렌더 → PRODUCTS 배열 map()으로 링크 리스트 생성
│
▼ 사용자가 특정 제품 링크 클릭 (예: Product 1)
/products/p1 경로 이동
│
▼
Router: '/products/:productId' 매칭
productId = 'p1' 추출
│
▼
ProductDetailPage 렌더, useParams() → { productId: 'p1' }
import React from 'react'
import { Link } from 'react-router-dom'
// 백엔드에서 가져온다고 가정하는 하드코딩된 제품 목록
const PRODUCTS = [
{ id: 'p1', title: 'Product 1' },
{ id: 'p2', title: 'Product 2' },
{ id: 'p3', title: 'Product 3' }
]
const ProductsPage: React.FC = () => {
return (
<main>
<h1>The Products Page</h1>
<p>/products 경로로 접근했을 때 이 페이지가 표시됩니다.</p>
<ul>
{PRODUCTS.map(prod => (
<li key={prod.id}>
{/* 동적 경로로 이동하는 Link 생성 */}
<Link to={`/products/${prod.id}`}>{prod.title}</Link>
</li>
))}
</ul>
</main>
)
}
export default ProductsPage
설명:
<Link>
생성정리
react-10 프로젝트 코드 참고하면됨
아래 예제는 React Router v6.4 이상에서 상대 경로와 절대 경로의 차이, 그리고 상대 경로 사용 시 relative 속성을 통해 링크 동작을 제어하는 방법을 보여줍니다. 특히, 제품 상세 페이지(ProductDetailPage)에서 상위 경로로 돌아가는 버튼을 상대 경로로 구현하고, relative="path"를 사용해 현재 URL 경로를 기준으로 상위 경로로 이동할 수 있도록 하는 패턴을 예시합니다.
이전 단계에서 우리는 /products/:productId 형태의 동적 경로를 구현하고, 제품 목록(ProductsPage)에서 동적 링크를 생성했습니다. 이번에 추가로, 제품 상세 페이지(ProductDetailPage)에서 Link 컴포넌트를 상대 경로로 사용해 "뒤로 가기" 기능을 구현하며, relative="path" 설정으로 의도한 페이지로 정확히 이동할 수 있게 합니다.
핵심 포인트:
전제 조건:
사용자 http://localhost:3000/products/p1 접속
│
▼ ProductDetailPage 렌더 (productId = 'p1')
│
사용자가 "Back" 버튼 클릭
│
▼ Link to=".." relative="path" 해석
현재 URL: /products/p1
'..' → 상위 경로: /products
│
▼ 경로 /products로 이동 → ProductsPage 렌더
relative="path"를 통해 ..가 실제 파일 경로와 유사한 방식으로 상위 경로 해석됨.
import React from 'react'
import { useParams, Link } from 'react-router-dom'
const ProductDetailPage: React.FC = () => {
const params = useParams()
return (
<main>
<h1>Product Details</h1>
<p>제품 ID: {params.productId}</p>
{/* 상대 경로 사용: '..'은 상위 경로로 이동, relative="path"로 현재 URL 기준 경로 해석 */}
<p><Link to=".." relative="path">Back</Link></p>
</main>
)
}
export default ProductDetailPage
설명:
정리
react-10 프로젝트 코드 참고하면됨
아래 예제는 React Router v6.4 이상에서 인덱스 라우트(Index Route
)를 사용하여 기본 페이지를 설정하는 방법을 보여줍니다.
이전 예시에서, 홈 페이지를 부모 라우트(RootLayout)의 기본 페이지로 설정하기 위해 빈 경로(path: '')로 자식 라우트를 정의했습니다.
하지만 인덱스 라우트는 이 작업을 더욱 명확하고 직관적으로 해줍니다.
사용자가 http://localhost:3000/ 접속
│
▼
Router: '/' 경로 매칭 (RootLayout)
│
인덱스 라우트(index: true) 확인
│
▼
HomePage 렌더 (부모 라우트 경로에서 기본 표시될 페이지)
정리
react-10 프로젝트 코드 참고하면됨
아래는 백엔드-프런트엔드 프로젝트 환경에서 리액트 라우팅 연습을 위한 설정 방법과 흐름을 정리한 내용입니다. 이전까지는 리액트 라우팅 기능(동적 경로, 상대 경로, 인덱스 라우트 등)을 학습했으며, 이제 실제 실습을 위해 백엔드 서버와 프런트엔드 서버를 모두 가동하는 상황을 설명합니다.
사용자 http://localhost:3000/ 접속
│
▼
RootLayout 렌더 (MainNavigation 포함)
인덱스 라우트(HomePage) 로딩
│
▼ NavLink/Link 이용해 다른 페이지로 이동
예: /events → EventsRootLayout + EventsPage
/events/new → NewEventPage
/events/:eventId → EventDetailPage (useParams로 id 추출)
/events/:eventId/edit → EditEventPage
이 패턴은 사람들이 가장 많이 사용하고 검증된 라우팅 패턴을 담고 있으며, 리액트 라우터 공식 문서와 커뮤니티에서 널리 권장하는 접근 방식입니다.
아래 예시는 React Router v6.4 이상에서 loader 함수를 사용해 라우트 렌더링 전 데이터를 불러오는 패턴을 정리한 것입니다. 지금까지는 컴포넌트 안에서 useEffect로 데이터를 가져왔다면, 이제 라우트 정의 시 loader 프로퍼티를 사용하여 컴포넌트 렌더링 전에 데이터를 가져오고, 해당 데이터를 컴포넌트에 바로 제공할 수 있습니다.
사용자가 http://localhost:3000/events 접속
│
▼
React Router 해당 라우트 매칭
│
▼ loader 함수 실행 (HTTP 요청 → backend-api)
응답 받은 events 데이터 반환
│
▼
데이터 로딩 완료 후 EventsPage 렌더링
useLoaderData 훅으로 loader 함수 반환 값 접근
이벤트 리스트 표시
라우트 접근 → loader 함수 실행 (데이터 로딩) → 데이터 준비 후 컴포넌트 렌더.
정리
react-11 프로젝트 코드 참고하면됨
아래 예시는 useLoaderData 훅을 사용하여 loader 함수로부터 반환된 데이터를 컴포넌트에서 간편하게 접근하는 패턴을 정리한 것입니다. 이전 단계에서는 useEffect와 상태 관리로 백엔드 API에서 데이터를 가져왔지만, 이제 loader 함수와 useLoaderData 훅을 사용함으로써 컴포넌트 로직이 훨씬 단순해집니다.
사용자가 http://localhost:3000/events 접속
│
▼
App.tsx 내 라우트 정의에서 eventsLoader 함수 실행
│
▼ 로더 함수 내 백엔드 요청 (http://localhost:8080/events)
응답 받은 이벤트 데이터 반환
│
▼
React Router가 반환값을 useLoaderData로 제공
│
▼
EventsPage 컴포넌트 useLoaderData()로 이벤트 리스트 수신
│
▼
EventsList 컴포넌트에 이벤트 리스트 전달, 렌더링
라우트 접근 → loader로 데이터 로딩 → useLoaderData로 즉시 접근 → 간단한 컴포넌트 코드.
정리
react-11 프로젝트 코드 참고하면됨
아래 예시는 useLoaderData 훅을 라우트 계층 구조에서 어떻게 사용할 수 있는지 보여줍니다. 라우트의 loader 함수로 데이터를 가져오면 해당 라우트 및 그 하위 컴포넌트, 라우트에서 useLoaderData로 데이터를 접근할 수 있습니다. 즉, 로더가 정의된 라우트보다 상위에 있는 컴포넌트에서는 해당 로더 데이터에 접근할 수 없습니다.
사용자 http://localhost:3000/events 접속
│
▼ loader 함수 실행 → 백엔드에서 이벤트 데이터 가져옴
│
▼ 이벤트 데이터 로더에서 return
│
▼ React Router가 useLoaderData로 데이터 제공
│
▼ EventsPage에서 useLoaderData()로 데이터 획득
Props로 EventsList에 이벤트 데이터 전달
│
▼ EventsList에서 props로 받은 이벤트 렌더링
(만약 RootLayout에서 useLoaderData() 시도하면?)
└─ 해당 데이터 로더는 이보다 하위 라우트에서 정의되어
있으므로 여기서는 접근 불가 → undefined
라우트 계층 구조: RootLayout > EventsRootLayout > (EventsPage 등)
useLoaderData는 해당 라우트 또는 하위 컴포넌트에서만 데이터 접근 가능.
import React from 'react';
import { useLoaderData } from 'react-router-dom';
import EventsList from '../components/EventsList';
export async function eventsLoader() {
const response = await fetch('http://localhost:8080/events');
if (!response.ok) {
throw new Error('이벤트를 가져올 수 없습니다.');
}
const resData = await response.json();
return resData.events;
}
const EventsPage: React.FC = () => {
// EventsPage와 동일 레벨 라우트에서 loader 함수 정의
// → useLoaderData로 바로 데이터 접근 가능
const events = useLoaderData() as Array<{id:string; title:string}>;
// 데이터 props로 EventsList에 전달
return <EventsList events={events} />;
};
export default EventsPage;
설명:
RootLayout.tsx (useLoaderData 사용 실패 예시)
import React from 'react';
import { Outlet } from 'react-router-dom';
import MainNavigation from '../components/MainNavigation';
// 여기서 useLoaderData를 사용할 경우
// 해당 라우트(loader 없음)에 대한 데이터가 없으므로 undefined 반환
const RootLayout: React.FC = () => {
// const data = useLoaderData(); // 이 줄 활성화 시 data는 undefined
return (
<>
<MainNavigation />
<Outlet />
</>
);
};
export default RootLayout;
import React from 'react';
interface Event {
id: string;
title: string;
}
interface EventsListProps {
events: Event[];
}
// 여기서도 useLoaderData 사용 가능하지만, 현재 구조에선 필요 없음
// 이벤트 데이터를 상위 페이지 컴포넌트(EventsPage)에서 props로 받아 처리
const EventsList: React.FC<EventsListProps> = ({ events }) => {
if (!events || events.length === 0) {
return <p>No events found!</p>;
}
return (
<ul>
{events.map(event => (
<li key={event.id}>{event.title}</li>
))}
</ul>
);
};
export default EventsList;
설명:
정리
useLoaderData() 훅은 현재 컴포넌트가 속한 "가장 가까운" 라우트의 loader 함수가 반환한 데이터에만 접근할 수 있습니다. 즉, 해당 컴포넌트를 렌더링하는 라우트에서 정의된 loader 함수가 있다면, 그 loader의 결과를 useLoaderData()로 가져올 수 있습니다. 하지만 부모 라우트에서 정의된 loader 데이터를 자식 라우트 컴포넌트에서 useLoaderData()로 직접 접근할 수는 없습니다. 자식 라우트에서 부모 라우트 loader 데이터를 사용하려면 useRouteLoaderData(routeId) 등의 다른 훅을 사용해야 합니다.
자세한 설명:
정리하자면:
부모 라우트 (loader 있음)
├─ 자식 라우트 (loader 없음)
│ └─ 자식 컴포넌트
자식 컴포넌트에서 useLoaderData() 호출 시 → 자식 라우트 자체의 loader 데이터 반환 (없다면 undefined)
부모 라우트 loader 데이터 접근 필요 → useRouteLoaderData(routeId) 사용
아래 예시는 loader 함수를 컴포넌트 파일(페이지 파일) 내부에 정의하고 export한 뒤, 라우트 정의(App.tsx)에서 import하여 사용하는 검증된 패턴을 보여줍니다. 이렇게 하면 데이터 가져오기 로직(loader)이 해당 데이터와 직접적으로 관련된 컴포넌트(페이지) 파일 내부에 위치하므로, 코드 구조가 더욱 직관적이고 유지보수하기 쉬워집니다.
사용자가 http://localhost:3000/events 접속
│
▼
App.tsx 라우트 정의 확인
events 라우트 → loader 프로퍼티 → EventsPage.loader 함수 호출
│
▼ 백엔드 (8080 포트)로 요청, 응답받아 events 데이터 반환
│
▼ React Router가 useLoaderData로 events 제공
EventsPage에서 useLoaderData() 호출, 이벤트 리스트 얻음
│
▼ EventsList로 props 전달 → 이벤트 목록 렌더링
loader 함수는 EventsPage.tsx에서 정의 → App.tsx에서 import → 라우트 로딩 시 자동 호출.
// EventsPage.tsx
import React from 'react';
import { useLoaderData } from 'react-router-dom';
import EventsList from '../components/EventsList';
export async function eventsLoader() {
const response = await fetch('http://localhost:8080/events');
if (!response.ok) {
throw new Error('이벤트를 가져올 수 없습니다.');
}
const resData = await response.json();
return resData.events; // 이벤트 배열 반환
}
const EventsPage: React.FC = () => {
// loader 함수 반환값을 useLoaderData로 접근
const events = useLoaderData() as Array<{id:string, title:string}>;
return <EventsList events={events} />;
};
export default EventsPage;
// App.tsx
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import RootLayout from './pages/RootLayout';
import HomePage from './pages/HomePage';
import EventsRootLayout from './pages/EventsRootLayout';
import EventsPage, { eventsLoader } from './pages/EventsPage'; // loader import
import EventDetailPage from './pages/EventDetailPage';
import NewEventPage from './pages/NewEventPage';
import EditEventPage from './pages/EditEventPage';
import ErrorPage from './pages/ErrorPage';
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <HomePage /> },
{
path: 'events',
element: <EventsRootLayout />,
children: [
{ index: true, element: <EventsPage />, loader: eventsLoader }, // loader 사용
{ path: 'new', element: <NewEventPage /> },
{ path: ':eventId', element: <EventDetailPage /> },
{ path: ':eventId/edit', element: <EditEventPage /> }
]
}
]
}
]);
const App: React.FC = () => {
return <RouterProvider router={router} />;
};
export default App;
설명:
정리
react-11 프로젝트 코드 참고하면됨
아래 예시는 loader 함수 실행 시점과 데이터 로딩 지연 시 발생하는 사용자 경험 변화를 정리한 것입니다. 우리는 백엔드에서 setTimeout을 사용해 응답을 지연시켜, 라우트 전환 시 loader 함수가 호출되고 데이터가 반환되기 전까지 화면 렌더링이 지연되는 상황을 시연합니다. 이를 통해 React Router가 데이터 로딩이 완료될 때까지 기다린 뒤, 해당 데이터를 이용해 페이지를 렌더링한다는 점을 명확히 알 수 있습니다.
사용자가 http://localhost:3000/events 접속 시도
│
▼ 라우트 전환 시작, React Router → eventsLoader 호출
eventsLoader : 백엔드 http://localhost:8080/events 요청
│
(백엔드에서 1.5초 지연 발생 - setTimeout)
│
▼ 1.5초 후 백엔드 응답
React Router Promise 해제, 데이터 준비
│
▼ 데이터 로딩 완료 후 EventsPage 렌더
useLoaderData()로 데이터 접근
│
▼ EventsList에 이벤트 전달, 렌더링
라우트 접근 시점에 loader 실행 → 백엔드 지연 시 전환 지연 → 데이터 준비 후 렌더링.
// server.ts
import express, { Request, Response } from 'express'
const app = express()
const PORT = 8080
const DUMMY_EVENTS = [
{ id: 'e1', title: 'Some Event', description: 'First event description' },
{ id: 'e2', title: 'Another Event', description: 'Second event description' }
]
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
next()
})
app.get('/events', (req: Request, res: Response) => {
// 응답 지연 시뮬레이션
setTimeout(() => {
res.json({ events: DUMMY_EVENTS })
}, 1500) // 1.5초 지연
})
app.use((req: Request, res: Response) => {
res.status(404).json({ message: 'Not Found' })
})
app.listen(PORT, () => {
console.log(`Backend API server running on http://localhost:${PORT}`)
})
설명:
정리
react-11 프로젝트 코드 참고하면됨
아래 예시는 React Router v6.4 이상에서 useNavigation 훅을 사용하여 라우트 전환 중에 로딩 상태를 사용자에게 표시하는 방식을 정리한 것입니다. loader 함수를 통해 페이지 진입 전 데이터를 로딩할 때, 백엔드 응답 지연 시 화면에 아무 변화가 없으면 사용자 경험이 좋지 않습니다. useNavigation 훅을 통해 현재 라우트 전환 상태(idle, loading, submitting)를 확인하고, loading 상태일 때 "Loading..." 등 로딩 표시를 보여줄 수 있습니다.
이 방식은 로딩 상태 표시를 전환이 시작되기 전에 있던 페이지(예: RootLayout)에서 처리하므로, 데이터가 도착하기 전 사용자에게 시각적 피드백을 줄 수 있습니다.
사용자가 http://localhost:3000/events 클릭
│
▼
React Router 라우트 전환 시작 → useNavigation() state = 'loading'
│
RootLayout에서 navigation.state 확인 후 "Loading..." 표시
│
백엔드 응답 도착, loader 데이터 준비 완료
│
navigation.state = 'idle'로 전환
│
"Loading..." 제거, 새 페이지 렌더링 (EventsPage)
전환 시작 → loading 상태 표시 → 데이터 준비 후 idle 상태로 전환 → 최종 페이지 렌더.
// RootLayout.tsx
import React from 'react';
import { Outlet, useNavigation } from 'react-router-dom';
import MainNavigation from '../components/MainNavigation';
const RootLayout: React.FC = () => {
const navigation = useNavigation();
// navigation.state: 'idle', 'loading', 'submitting'
return (
<>
<MainNavigation />
{navigation.state === 'loading' && <p>Loading...</p>}
<Outlet />
</>
);
};
export default RootLayout;
설명:
정리
react-11 프로젝트 코드 참고하면됨
아래 예시는 loader 함수에서 Response 객체(혹은 react-router-dom의 json() 유틸리티)를 활용하는 패턴을 보여줍니다. 이전까지는 loader 내에서 fetch한 응답 데이터를 직접 파싱(await response.json()) 후 반환했지만, 사실 loader에서는 Response 객체 자체를 반환하거나 json() 함수를 이용해 손쉽게 JSON 응답을 만들 수도 있습니다. 이렇게 하면 useLoaderData()를 사용했을 때 해당 응답이 자동으로 해제되어 데이터에 접근할 수 있습니다.
사용자가 /events 경로 진입
│
▼ loader 함수 호출 → 백엔드 API fetch
응답 받으면 json()으로 Response 객체 생성
│
▼ Response 반환 → useLoaderData()로 해당 JSON 데이터 자동 접근
│
▼ EventsPage에서 useLoaderData()로 이벤트 리스트 얻음
│
▼ EventsList에 props로 전달, 화면에 이벤트 출력
loader 반환값(Response, json()) → useLoaderData()에서 자동 추출.
// EventsPage.tsx
import React from 'react';
import { useLoaderData, json } from 'react-router-dom';
import EventsList from '../components/EventsList';
export async function eventsLoader() {
const response = await fetch('http://localhost:8080/events');
if (!response.ok) {
// 여기서도 응답 객체를 json()으로 반환 가능
// 에러 상태 코드 지정 가능
throw json({ message: '이벤트를 가져올 수 없습니다.' }, { status: 500 });
}
// 응답이 정상이라면 응답을 바로 json()으로 감싸 반환
const resData = await response.json();
// json()으로 감싸지 않고 단순 객체 반환도 가능
//return resData.events;
// json() 함수를 사용해 응답 객체를 직접 생성 가능
return json(resData.events, { status: 200 });
// 이렇게 하면 useLoaderData()에서 이 json 응답이 해제되어 events 배열을 바로 받음
}
const EventsPage: React.FC = () => {
const events = useLoaderData() as Array<{id:string, title:string}>;
return <EventsList events={events} />;
};
export default EventsPage;
설명:
정리
아래는 React Router v7에서 json() 함수가 제거된 배경과 이를 대체하는 방법에 대한 정리입니다.
핵심 요약:
React Router v6.4에서 도입된 json() 함수는 loader 함수에서 JSON 형식의 응답을 쉽게 반환하기 위한 유틸리티였습니다.
React Router v7로 넘어오면서 json() 함수가 제거되었습니다(#12146 언급).
v7에서는 더 이상 json()을 react-router-dom에서 import할 수 없으며, 대신 표준 Response 객체를 직접 생성하거나, SSR 환경/런타임별로 적절한 JSON 응답 생성 방식을 사용해야 합니다.
json() 없이도 loader 함수 내에서 다음과 같이 직접 Response 객체를 반환할 수 있습니다
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
이렇게 하면 useLoaderData()가 이 응답을 인식하고 JSON 데이터에 접근할 수 있습니다.
왜 제거되었나?
대체 방법:
직접 Response 생성:
export async function loader() {
const response = await fetch('http://localhost:8080/events');
if (!response.ok) {
throw new Response(JSON.stringify({message: 'Error fetching events'}), {
status: 500,
headers: {'Content-Type': 'application/json'}
});
}
const data = await response.json();
return new Response(JSON.stringify(data.events), {
status: 200,
headers: {'Content-Type': 'application/json'}
});
}
이 경우 useLoaderData()로 접근 시 data는 events 배열을 자동으로 파싱해줍니다.
SSR/서버 환경에서의 대안:
loader 함수에서 fetch → 백엔드 API 응답 받음
│
▼
응답을 JSON 문자열화 후 new Response(...)로 감싸 반환
│
▼
useLoaderData() 훅 호출 시 반환된 Response 인식
│
▼
JSON 응답 자동 파싱 → 컴포넌트에서 데이터 직접 사용
// EventsPage.tsx
import React from 'react';
import { useLoaderData } from 'react-router-dom';
import EventsList from '../components/EventsList';
export async function eventsLoader() {
const response = await fetch('http://localhost:8080/events');
if (!response.ok) {
// json() 대신 Response를 직접 생성
return new Response(JSON.stringify({ message: '이벤트를 가져올 수 없습니다.' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data.events), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
const EventsPage: React.FC = () => {
// useLoaderData()는 반환된 Response를 자동으로 해제
// 단, v7에서 이 부분은 어떻게 동작하는지 공식 문서 참고 필요
// v7에서는 Response를 직접 반환하면 useLoaderData가 어떻게 처리할지 재확인 필요
// 만약 Response 해제가 자동으로 안 된다면, 수동 파싱 필요
const events = useLoaderData() as Array<{id:string; title:string}>;
return <EventsList events={events} />;
};
export default EventsPage;
주의:
정리
react-11 프로젝트 코드 참고하면됨
아래는 loader 함수에 대한 중요한 특성을 다시 한 번 정리한 내용입니다.
이러한 특성으로 인해 loader 함수는 React 컴포넌트가 렌더링되기 전 데이터 로딩 로직을 브라우저 환경에서 수행할 수 있는 강력한 도구가 됩니다.
사용자가 /events 페이지 접근
│
▼
라우트 전환 시작 → loader 함수 호출
│
▼ 브라우저 환경에서 loader 실행
- localStorage 접근 가능
- 쿠키 접근 가능
- fetch, new Response 등 브라우저 API 사용 가능
단, useState 등 React 훅 불가
│
▼ 데이터 로딩 후 useLoaderData()로 컴포넌트에 데이터 전달
loader는 브라우저 상에서 작동하므로 브라우저 API 활용 가능하나 React 훅 불가.
// src/pages/EventsPage.tsx
import React from 'react'
import { useLoaderData } from 'react-router-dom'
import EventsList from '../components/EventsList'
// loader 함수: 브라우저 환경에서 동작
export async function eventsLoader() {
// 브라우저 API 사용 예:
const userToken = localStorage.getItem('user_token');
// 여기서 useState, useEffect 불가 (컴포넌트 아님)
const response = await fetch('http://localhost:8080/events', {
headers: userToken ? { Authorization: `Bearer ${userToken}` } : {}
})
if (!response.ok) {
// json() 대신 Response를 직접 생성
return new Response(JSON.stringify({ message: '이벤트를 가져올 수 없습니다.' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
const data = await response.json()
return new Response(JSON.stringify(data.events), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
}
const EventsPage: React.FC = () => {
// useLoaderData()는 반환된 Response를 자동으로 해제
// 단, v7에서 이 부분은 어떻게 동작하는지 공식 문서 참고 필요
// v7에서는 Response를 직접 반환하면 useLoaderData가 어떻게 처리할지 재확인 필요
// 만약 Response 해제가 자동으로 안 된다면, 수동 파싱 필요
const events = useLoaderData() as Array<{ id: string; title: string }>
return <EventsList events={events} />
}
export default EventsPage
설명:
정리
react-11 프로젝트 코드 참고하면됨
아래 예시는 loader 함수에서 오류를 처리하는 방법을 정리한 것입니다. 이전까지는 loader 함수에서 API 요청 실패 시 오류 메시지를 데이터 형태로 반환하거나, useEffect 기반 해결책을 사용했습니다. 이제는 loader 함수 내부에서 throw로 오류를 발생시킬 수 있으며, 이렇게 발생한 오류는 React Router가 감지하여 가장 가까운 errorElement를 렌더링합니다. 즉, 라우트 정의에서 errorElement를 지정한 페이지가 오류 시 대체 렌더링되는 폴백(Fallback) 페이지로 사용됩니다.
사용자가 /events 페이지 접근
│
▼ loader 함수 실행 중 API 오류 발생 → throw new Error('...')
│
▼ React Router: 오류 감지
가장 가까운 라우트에서 errorElement 확인
│
▼ errorElement (ErrorPage) 렌더링
사용자에게 오류 UI 표시
loader 내에서 발생한 오류 → errorElement 페이지 렌더링.
// src/pages/EventsPage.tsx
import React from 'react'
import { useLoaderData } from 'react-router-dom'
import EventsList from '../components/EventsList'
// loader 함수: 브라우저 환경에서 동작
export async function eventsLoader() {
// 브라우저 API 사용 예:
const userToken = localStorage.getItem('user_token');
// 여기서 useState, useEffect 불가 (컴포넌트 아님)
const response = await fetch('http://localhost:8080/events', {
headers: userToken ? { Authorization: `Bearer ${userToken}` } : {}
})
if (!response.ok) {
// 오류 발생 시 단순히 throw new Error()
// 이렇게 던진 오류는 errorElement로 처리됨
throw new Error('이벤트를 가져올 수 없습니다.');
}
const data = await response.json()
return new Response(JSON.stringify(data.events), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
}
const EventsPage: React.FC = () => {
// useLoaderData()는 반환된 Response를 자동으로 해제
// 단, v7에서 이 부분은 어떻게 동작하는지 공식 문서 참고 필요
// v7에서는 Response를 직접 반환하면 useLoaderData가 어떻게 처리할지 재확인 필요
// 만약 Response 해제가 자동으로 안 된다면, 수동 파싱 필요
const events = useLoaderData() as Array<{ id: string; title: string }>
return <EventsList events={events} />
}
export default EventsPage
// src/App.tsx
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import RootLayout from './pages/RootLayout';
import HomePage from './pages/HomePage';
import EventsRootLayout from './pages/EventsRootLayout';
import EventsPage, { eventsLoader } from './pages/EventsPage';
import EventDetailPage from './pages/EventDetailPage';
import NewEventPage from './pages/NewEventPage';
import EditEventPage from './pages/EditEventPage';
import ErrorPage from './pages/ErrorPage';
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
// 루트 라우트에 errorElement 지정
errorElement: <ErrorPage />,
children: [
{ index: true, element: <HomePage /> },
{
path: 'events',
element: <EventsRootLayout />,
// errorElement를 여기에 별도로 지정할 수도 있지만
// 루트 라우트에 지정했으므로 생략 가능
children: [
{ index: true, element: <EventsPage />, loader: eventsLoader },
{ path: 'new', element: <NewEventPage /> },
{ path: ':eventId', element: <EventDetailPage /> },
{ path: ':eventId/edit', element: <EditEventPage /> }
]
}
]
}
]);
const App: React.FC = () => {
return <RouterProvider router={router} />;
};
export default App;
설명:
정리
react-11 프로젝트 코드 참고하면됨
아래 예시는 loader 함수에서 Response 객체를 throw하여 오류를 발생시키고, 이를 errorElement를 통해 처리하는 패턴을 보여줍니다. 또한, useRouteError() 훅을 사용해 오류 발생 시 던진 Response 객체나 Error 객체에 접근하여 상황에 따라 다른 오류 메시지를 표시합니다. 이렇게 하면 라우팅 수준에서 오류를 효율적으로 관리할 수 있으며, 다양한 상태 코드(404, 500 등)에 따라 동적으로 오류 메시지를 변경할 수 있습니다.
또한, PageContent라는 헬퍼 컴포넌트를 사용해 오류 페이지의 스타일을 개선하고, MainNavigation을 추가해 사용자 경험을 향상시킬 수 있습니다.
사용자가 /events 페이지 접근
│
▼ loader 함수 실행
백엔드 요청 실패 시 throw new Response(..., {status:500})
│
▼ React Router 오류 감지
errorElement로 지정된 ErrorPage 렌더링
│
▼ ErrorPage에서 useRouteError() 사용
error.status=500, error.data.message 접근
│
▼ 상태 코드 따라 다른 메시지 표시
PageContent, MainNavigation으로 UI 개선
오류 발생 → errorElement 렌더링 → useRouteError()로 오류 정보 파악 및 동적 메시지 제공.
// src/components/PageContent.tsx
import React from 'react';
import classes from './PageContent.module.css';
interface PageContentProps {
title: string;
children?: React.ReactNode;
}
const PageContent: React.FC<PageContentProps> = ({ title, children }) => {
return (
<section className={classes.content}>
<h1>{title}</h1>
{children}
</section>
);
};
export default PageContent;
import React from 'react'
import { useRouteError } from 'react-router-dom'
import PageContent from '../components/PageContent'
import MainNavigation from '../components/MainNavigation'
// 오류 객체의 형태를 가정하는 인터페이스 정의
interface ErrorResponse {
status?: number; // HTTP 상태 코드 (예: 404, 500)
data?: {
title: string
message: string
}; // JSON 문자열 형태로 응답 바디 가정
}
const ErrorPage: React.FC = () => {
const err = useRouteError()
// err를 ErrorResponse | Error 로 가정
const error = err as ErrorResponse | Error
let title = '오류가 발생했습니다!'
let message = '요청한 페이지를 불러오는 도중 문제가 발생했습니다.'
// error가 ErrorResponse 형태로 status 필드가 있는지 확인
if (error && 'status' in error && typeof error.status === 'number') {
if (error.status === 404) {
title = '찾을 수 없음!'
message = '리소스 또는 페이지를 찾을 수 없습니다.'
} else if (error.status === 500) {
// error.data 가 있는지 확인 후 JSON 파싱 시도
if (error.data) {
try {
const data = error.data
message = data.message || message
title = data.title || title
} catch {
// JSON 파싱 실패 시 기본 message 유지
}
}
}
}
return (
<>
<MainNavigation />
<PageContent title={title}>
<p>{message}</p>
</PageContent>
</>
)
}
export default ErrorPage
import React from 'react';
import { useLoaderData } from 'react-router-dom';
import EventsList from '../components/EventsList';
export async function eventsLoader() {
const response = await fetch('http://localhost:8080/events');
if (!response.ok) {
// 500 에러를 Response 객체로 throw
return new Response(JSON.stringify({ message: '이벤트를 가져올 수 없습니다.' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return data.events;
}
const EventsPage: React.FC = () => {
const events = useLoaderData() as Array<{id:string, title:string}>;
return <EventsList events={events} />;
};
export default EventsPage;
설명:
오류 발생 시 throw new Response(...) 대신 return new Response(...) 가능하지만, 오류 전파 위해 throw 사용 권장
실제로는 throw new Response(...)를 통해 errorElement로 바로 전파
if (!response.ok) {
throw new Response(JSON.stringify({ message: '이벤트를 가져올 수 없습니다.'}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
이렇게 throw new Response(...)로 변경하면 errorElement를 통해 오류 처리 가능.
정리
react-11 프로젝트 코드 참고하면됨
아래는 React Router v6.4 버전 기준의 json() 메서드 활용 방식과, 최근에 등장한 React Router v7 버전의 변경 사항을 비교하여 정리한 내용입니다. 또한, 이를 활용하는 전체 폴더 구조와 코드 예시를 제시합니다.
결론적으로, v6.4에서 json()을 통해 간단히 JSON 응답을 반환하는 패턴은 v7 이후 사라졌으며, v7에서는 표준 브라우저 API나 다른 방식으로 응답을 처리해야 합니다.
결론: json() 함수는 v6.4 에서 편의를 제공했으나 v7에서 제거되었으므로, v7 환경에서는 Response 객체를 직접 다루거나 기타 문서화된 접근법을 따라야 한다.
아래 예시는 React Router v7 기준으로 json() 함수 없이 loader에서 응답을 처리하는 예시 코드입니다.
v7에서는 json() 함수가 제거되었으므로, 응답을 직접 Response 객체로 만들거나 단순히 JavaScript 객체를 반환해야 합니다. 오류 발생 시 throw new Response(...)를 사용해 HTTP 상태 코드와 메시지를 담은 응답을 던질 수 있으며, useRouteError() 훅으로 이를 감지하고 ErrorPage에서 상태 코드와 메시지에 따라 조건부 렌더링을 할 수 있습니다.
핵심 포인트 (v7 기준):
사용자가 /events 페이지 접근
│
▼ loader 함수 실행
fetch 백엔드 → 응답 수신
if (!ok) throw new Response(JSON.stringify({message:'...'}), {status:500})
else return data.events (단순 JS 객체)
│
▼ useLoaderData()로 data.events 바로 접근 가능
오류 발생 시 throw된 Response 감지 → ErrorPage 렌더링
│
▼ ErrorPage에서 useRouteError()로 오류 및 상태 코드 접근
JSON.parse(error.data)로 메시지 파싱
상태 코드에 따라 다른 메시지 출력
정리
react-11 프로젝트 코드 참고하면됨
아래는 React Router를 활용하여 이벤트 상세 페이지를 구현하는 예시입니다.
<a>
대신 <Link>
를 사용하고, 상대 경로를 이용해 events/:eventId로 이동할 수 있게 합니다.주요 포인트:
정리
react-11 프로젝트 코드 참고하면됨
아래는 이벤트 상세 페이지와 이벤트 편집 페이지에 대해 공통 loader를 활용하고 useRouteLoaderData를 사용해 상위 라우트의 loader 데이터를 재사용하는 예시입니다. 이를 통해 같은 이벤트 데이터를 두 페이지에서 공유할 수 있으며, 중복된 로직을 제거하고, 코드 유지보수를 용이하게 합니다.
사용자가 /events/e1 접속 시도
│
▼ 상위 라우트 (event-detail 라우트) loader 호출
fetch http://localhost:8080/events/:eventId
데이터 반환 또는 오류 throw
│
▼ EventDetailPage (index 라우트) useLoaderData()로 상위 loader 데이터 접근
│
사용자가 "Edit" 버튼 클릭 -> /events/e1/edit 이동
│
▼ EditEventPage에서 useRouteLoaderData('event-detail') 호출
상위 라우트의 loader 데이터 재사용
│
▼ EventForm event 데이터로 미리 채워진 상태로 표시
이렇게 상위 라우트에 loader 정의 → 하위 페이지들이 loader 데이터 재사용 가능.
// EventDetailLayout.tsx
import React from 'react';
import { Outlet, LoaderFunctionArgs } from 'react-router-dom';
export async function eventDetailLoader({ params }: LoaderFunctionArgs) {
const id = params.eventId;
if (!id) {
throw new Response(JSON.stringify({ message: 'eventId가 누락되었습니다.' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const response = await fetch(`http://localhost:8080/events/${id}`);
if (!response.ok) {
throw new Response(JSON.stringify({ message: '이벤트 상세 정보를 가져올 수 없습니다.' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return data;
}
const EventDetailLayout: React.FC = () => {
// 단순히 Outlet만 렌더링하는 레이아웃
return <Outlet />;
};
export default EventDetailLayout;
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import RootLayout from './pages/RootLayout';
import HomePage from './pages/HomePage';
import EventsRootLayout from './pages/EventsRootLayout';
import EventsPage, { eventsLoader } from './pages/EventsPage';
import EventDetailLayout, { eventDetailLoader } from './pages/EventDetailLayout';
import EventDetailPage from './pages/EventDetailPage';
import EditEventPage from './pages/EditEventPage';
import NewEventPage from './pages/NewEventPage';
import ErrorPage from './pages/ErrorPage';
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
// 루트 라우트에 errorElement 지정
errorElement: <ErrorPage />,
children: [
{ index: true, element: <HomePage /> },
{
path: 'events',
element: <EventsRootLayout />,
// errorElement를 여기에 별도로 지정할 수도 있지만
// 루트 라우트에 지정했으므로 생략 가능
// loader 함수를 events 라우트에 적용,
// 인덱스 라우트(EventsPage)에서 useLoaderData로 접근 가능
children: [
// loader로부터 Response 객체(json()) 반환
{ index: true, element: <EventsPage />, loader: eventsLoader },
{ path: 'new', element: <NewEventPage /> },
{
path: ':eventId',
id: 'event-detail',
loader: eventDetailLoader,
// 이 라우트는 event-detail loader로 데이터 로딩 후 자식 라우트에 제공
element: <EventDetailLayout />,
children: [
{ index: true, element: <EventDetailPage /> },
{ path: 'edit', element: <EditEventPage /> }
]
}
]
}
]
}
]);
const App: React.FC = () => {
return <RouterProvider router={router} />
}
export default App
설명:
정리
react-11 프로젝트 코드 참고하면됨
아래는 React Router를 활용하여 폼 데이터를 백엔드로 전송하기 위한 전반적인 방식에 대해 정리한 내용입니다. 지금까지는 loader 함수를 통해 데이터를 로딩하는 법을 배웠다면, 이제는 폼을 통해 사용자가 입력한 데이터를 전송(action) 하는 방법을 살펴볼 차례입니다. React Router는 loader와 유사하게, 데이터를 전송하고 처리하기 위한 action 함수를 제공하며, 이를 통해 폼 제출 시 백엔드로 데이터를 손쉽게 전송할 수 있습니다.
우리는 NewEventPage에서 EventForm을 표시하고, Save 버튼을 누르면 action을 통해 백엔드로 데이터를 전송하도록 할 것입니다.
사용자가 /events/new 페이지 접근
│
▼ NewEventPage 로드, EventForm 표시
사용자가 폼 작성 후 Save 클릭
│
▼ action 함수 호출 (NewEventPage에서 정의 후 라우트에 action 프로퍼티로 등록)
action에서 FormData로 폼 값 추출 → 백엔드로 POST 요청
│
▼ 백엔드 응답 성공 시 action에서 redirect('/') 등 리다이렉션 수행
│
▼ 페이지 전환 없이 라우트만 변경, 새 데이터 반영
action 함수로 폼 데이터 전송 및 후처리, 성공 시 리다이렉션까지 처리 가능.
// src/pages/NewEventPage.tsx
import React from 'react';
interface EventData {
title?: string;
image?: string;
date?: string;
description?: string;
}
interface EventFormProps {
event?: EventData;
}
const EventForm: React.FC<EventFormProps> = ({ event }) => {
return (
<form method="post">
<p>
<label htmlFor="title">Title</label>
<input id="title" name="title" defaultValue={event?.title || ''} required />
</p>
<p>
<label htmlFor="image">Image</label>
<input id="image" name="image" defaultValue={event?.image || ''} />
</p>
<p>
<label htmlFor="date">Date</label>
<input type="date" id="date" name="date" defaultValue={event?.date || ''} required />
</p>
<p>
<label htmlFor="description">Description</label>
<textarea id="description" name="description" defaultValue={event?.description || ''} />
</p>
<button type="submit">Save</button>
</form>
);
};
export default EventForm;
// src/pages/NewEventPage.tsx
import React from 'react';
import { useNavigation, redirect } from 'react-router-dom';
import EventForm from '../components/EventForm';
import type { ActionFunctionArgs } from 'react-router-dom';
// action 함수 정의: 폼 제출 시 호출됨
export async function newEventAction({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const eventData = {
title: formData.get('title'),
image: formData.get('image'),
date: formData.get('date'),
description: formData.get('description'),
};
const response = await fetch('http://localhost:8080/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eventData),
});
if (!response.ok) {
throw new Response(JSON.stringify({ message: '이벤트 생성 실패!' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
return redirect('/events'); // 성공 시 /events 페이지로 이동
}
const NewEventPage: React.FC = () => {
const navigation = useNavigation();
// navigation.state 이용해 로딩 상태 표시 가능(생략)
return <EventForm />;
};
export default NewEventPage;
// src/App.tsx
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import RootLayout from './pages/RootLayout';
import HomePage from './pages/HomePage';
import EventsRootLayout from './pages/EventsRootLayout';
import EventsPage, { eventsLoader } from './pages/EventsPage';
import { eventDetailLoader, EventDetailLayout } from './pages/EventDetailLayout';
import EventDetailPage from './pages/EventDetailPage';
import EditEventPage from './pages/EditEventPage';
import NewEventPage, { newEventAction } from './pages/NewEventPage';
import ErrorPage from './pages/ErrorPage';
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <HomePage /> },
{
path: 'events',
element: <EventsRootLayout />,
children: [
{ index: true, element: <EventsPage />, loader: eventsLoader },
{ path: 'new', element: <NewEventPage />, action: newEventAction }, // action 등록
{
path: ':eventId',
id: 'event-detail',
loader: eventDetailLoader,
element: <EventDetailLayout />,
children: [
{ index: true, element: <EventDetailPage /> },
{ path: 'edit', element: <EditEventPage /> }
]
}
]
}
]
}
]);
const App: React.FC = () => <RouterProvider router={router} />;
export default App;
설명:
정리
이슈
react-11 프로젝트 코드 참고하면됨
아래는 폼 데이터 전송을 위해 React Router v7의 action 함수를 사용하여 폼 제출을 처리하고, 제출 성공 시 redirect를 통해 다른 페이지로 이동하는 과정을 정리한 내용과 예시 코드입니다.
<Form method="post">
를 사용하여 폼 제출 시 action 함수 자동 호출사용자가 /events/new 페이지 접근
│
▼ NewEventPage에서 <Form method="post"> 사용
사용자가 폼 입력 후 Save 클릭
│
▼ action(newEventAction) 호출
└ request.formData()로 폼 데이터 추출
└ fetch로 백엔드에 POST 전송
│
▼ 백엔드 이벤트 생성 성공 시 action에서 redirect('/events') 호출
│
▼ /events 페이지로 이동, 새 이벤트 리스트 반영
제출 후 자동으로 action 함수가 호출되어 데이터 처리 및 리디렉트까지 처리.
정리
<Form>
컴포넌트를 사용하면 별도의 preventDefault나 useNavigate 없이도 폼 제출을 처리하고, 백엔드에 데이터 전송 후 페이지 이동까지 손쉽게 구현할 수 있습니다.react-11 프로젝트 코드 참고하면됨
아래는 리액트 라우터 액션 함수를 프로그램적으로 트리거하는 방법을 정리한 내용과 예시 코드입니다.
이제까지 <Form>
컴포넌트를 사용하여 액션을 트리거하는 방법을 배웠다면, 이번에는 useSubmit
훅을 사용해 사용자 확인(prompt
) 후 프로그램적으로 액션을 호출하는 패턴을 다룹니다.
<Form>
컴포넌트로 폼 제출 시 자동으로 액션 호출 가능사용자가 /events/:eventId 페이지 접근
│
▼ EventDetailPage 렌더링, EventItem 표시
Delete 버튼 클릭 시 confirm() 호출 → 사용자가 OK 선택
│
▼ useSubmit() 훅으로 submit() 함수 호출
└ method='delete' 설정
└ action 함수가 실행됨(eventDetailAction)
│
▼ action 함수에서 params.eventId로 이벤트 식별
fetch로 백엔드 /events/:eventId DELETE 요청 전송
│
▼ 성공 시 action에서 redirect('/events')
│
▼ /events 페이지로 이동, 삭제된 이벤트 반영
confirm → useSubmit() → action 호출 → 백엔드 삭제 요청 → redirect 순으로 진행.
설명:
정리
<Form>
없이도 useSubmit 훅을 사용해 조건부로 액션 함수를 트리거할 수 있음을 알 수 있습니다.react-11 프로젝트 코드 참고하면됨
아래는 사용자가 이벤트를 추가할 때 useNavigation 훅을 사용해 제출 상태를 파악하고, 폼 제출 중임을 사용자에게 피드백하는 방법에 대한 정리와 예시 코드입니다. 이 방식으로 사용자는 폼을 제출한 뒤 백엔드 처리 완료 전까지 “Submitting…” 상태를 확인할 수 있고, 버튼을 비활성화해 중복 제출을 막을 수 있습니다.
사용자가 /events/new 접근
│
▼ NewEventPage 렌더링, useNavigation 훅 사용
"Save" 버튼 클릭 → 폼 제출 → action 함수 호출
│
▼ action 함수에서 백엔드로 POST 요청
navigation.state === 'submitting' 동안 "Submitting..." 표시, Save 비활성화
│
▼ 백엔드 처리 후 action 완성 → redirect('/events')
│
▼ /events로 이동, isSubmitting 상태 해제, UI 정상화
정리
react-11 프로젝트 코드 참고하면됨
아래는 백엔드 검증 오류를 프런트엔드에서 반영하는 방법에 대한 정리와 예시 코드입니다. 이 방법을 통해 서버 측에서 유효성 검증에 실패한 경우, 액션 함수에서 오류 응답을 반환하고, 클라이언트 측에서 useActionData() 훅을 사용해 해당 오류 정보를 폼 위에 표시할 수 있습니다. 이는 사용자가 폼을 제출했을 때 발생하는 오류를 사용자 친화적으로 처리하는 검증된 패턴입니다.
사용자가 /events/new 페이지에서 폼 입력 후 Submit
│
▼ action(newEventAction) 호출
폼 데이터 추출 → 백엔드 /events에 POST 요청
│
▼ 백엔드 검증 실패 (예: title, date 누락)
status 422 + JSON 오류 정보 반환
│
▼ action 함수에서 response.status 422 확인
return response로 action 데이터 반환
│
▼ NewEventPage에서 useActionData()로 오류 데이터 수신
│
▼ EventForm에서 오류 메시지 렌더링 (사용자에게 검증 오류 알림)
서버 검증 실패 → action에서 response 반환 → useActionData로 오류 표시.
설명:
정리
react-11 프로젝트 코드 참고하면됨
아래는 동일한 액션 함수를 신규 이벤트 생성과 기존 이벤트 편집 모두에서 재사용하는 방법을 정리한 내용과 예시 코드입니다.
이를 통해 하나의 액션 함수에서 HTTP 메서드와 URL을 상황에 따라 동적으로 결정하여 공통 로직을 재사용할 수 있습니다.
또한, <Form>
컴포넌트의 method 속성을 조정하거나, 라우트 파라미터를 이용해 URL을 유연하게 변경하는 패턴을 보게 됩니다.
<Form method="post">
나 <Form method="patch">
로 액션 호출 시, action
함수 내부에서 request.method
값을 통해 실제 백엔드 요청 시 메서드를 결정/events
경로에 POST 요청, 편집 시 /events/:eventId
경로에 PATCH
요청/events/new 페이지 접근 (새 이벤트 추가)
│
▼ <Form method="post"> 사용
action 함수(manipulateEventAction) 호출
request.method = 'POST'
│
▼ action 함수에서 'POST' 감지 → /events에 POST 요청 후 redirect('/events')
새 이벤트 추가 완료
/events/:eventId/edit 페이지 접근 (기존 이벤트 편집)
│
▼ <Form method="patch"> 사용
action 함수(manipulateEventAction) 호출
request.method = 'PATCH'
│
▼ action 함수에서 'PATCH' 감지 → /events/:eventId에 PATCH 요청 후 redirect('/events')
이벤트 편집 완료
새 이벤트 추가와 기존 이벤트 편집 둘 다 동일한 액션 활용.
설명:
<Form method="post">
→ manipulateEventAction 내부에서 POST /events<Form method="patch">
→ manipulateEventAction 내부에서 PATCH /events/:eventId정리
react-11 프로젝트 코드 참고하면됨
아래는 useFetcher
훅을 사용해 전환 없이 액션을 트리거하는 방법에 대한 정리와 예시 코드입니다.
이 방법을 통해 <Form>
를 사용하더라도 라우트 전환 없이 백엔드 요청(액션/로더 실행)을 수행하고, 반환된 데이터를 이용해 UI를 업데이트할 수 있습니다.
이는 예를 들어 전역 네비게이션 영역에 폼(뉴스레터 구독 폼 등)이 있을 때 특히 유용합니다.
메인 네비게이션 영역에 newsletter 폼 (fetcher.Form) 존재
│
▼ 사용자가 email 입력 후 submit
fetcher.Form → /newsletter 액션 호출 (POST)
(라우트 전환 없음)
│
▼ 액션 함수 newsletterAction에서 이메일 처리 후 메시지 반환
return new Response(JSON.stringify({ message: '등록 성공' }), ...)
│
▼ fetcher.data로 액션 반환 데이터 접근
fetcher.state 통해 로딩 상태 관리
│
▼ useEffect 등으로 fetcher.data 변화 감지
알림 표시 등 UI 업데이트
정리
react-11 프로젝트 코드 참고하면됨
React Router에서 액션 함수 호출 방식은 로더와 다르게, UI 렌더링 계층 구조와는 독립적으로 작동합니다. 즉, 특정 경로에 액션이 정의되어 있으면, 해당 경로를 대상으로 하는 요청(예: 폼 제출)만 이루어지면, 라우트 계층이나 부모/자식 관계에 상관없이 액션이 호출될 수 있습니다.
로더(Loader)와 액션(Action)의 차이를 이해하면 이 동작 원리를 이해하기 쉽습니다.
로더(Loader)
액션(Action)
따라서 newsletterAction
은 /newsletter
경로에 라우트로 등록되어 있고, MainNavigation
내의 fetcher.Form
에서 action="/newsletter"
로 요청을 보내기 때문에,
현재 페이지(루트나 자식 라우트와 상관없이)나 계층과 상관없이 /newsletter
라우트에 등록된 액션(newsletterAction
)이 호출되는 것입니다.
아래 예시는 React Router v7 기준에서, 과거 버전(6.x)에서 사용하던 defer/Await/Suspense 방식을 더 이상 사용할 수 없게 된 상황을 고려하여, “느린(지연된) 데이터 로딩” 시에 어떻게 대응할 수 있는지 보여줍니다.
v7부터는 defer가 제거되었고, Single Fetch / Turbo-Stream 등의 내부 변경이 생겼습니다.
따라서 예전처럼 loader()에서 defer()
→ <Await>
→ <Suspense>
조합을 쓸 수 없습니다.
대신 “하나의 loader가 Promise로 데이터를 반환하면, 그 로더가 resolve될 때까지 전환이 지연되는” (기존과 유사한) 동작이 기본 형태입니다.
결과적으로 페이지가 모든 데이터를 기다린 뒤 렌더링됩니다.
이때 "로딩 중 스피너"를 표시하려면 라우트 전환 중임을 감지(useNavigation
)해서 처리하거나, 다른 별도 기법(e.g. "Single Fetch" + React 19 Streaming 기능)으로 부분 렌더링을 적용해야 합니다.
아래 예시 코드는 “느린 응답”을 가정하여 기본 로더가 Promise를 반환하고, 그동안 페이지 전환이 잠시 지연되는 형태를 보여줍니다.
또한, 라우트 전환 시 로딩 상태는 useNavigation
을 통해 표시합니다.
React Router v7에서 defer 제거
defer
, <Await>
, <Suspense>
를 통한 부분 렌더링(“부분만 먼저 렌더, 나머지는 지연 로딩”)은 v7에서 공식적으로 제거됨.지연된 데이터 로딩 시 어떻게?
<p>로딩중...</p>
)코드 흐름
[사용자가 /events 페이지로 전환] → [loader() 함수 호출] →
(서버의 느린 응답) → Promise 대기 →
[resolve되면 useLoaderData()가 데이터 수신] → [이벤트 목록 렌더]
전환하는 동안: navigation.state === 'loading'
완료 시: navigation.state === 'idle', useLoaderData()로 최종 데이터 사용
요약
defer
, <Await>
, <Suspense>
조합을 통한 부분 렌더링 API가 제거됨.Partial Rendering
)을 위해선 React 19+에서 제공하는 스트리밍과 “Single Fetch” 등 별도 방식이 필요.react-11 프로젝트 코드 참고하면됨
아래 예시는 React Router v7에서 defer/Await/Suspense가 제거된 상황에서, 두 가지 이상의 느린(지연된) 로딩 시나리오를 어떻게 처리할 수 있는지 보여줍니다.
이전 v6 시절에는 defer(...) → <Await resolve=...>
→ <Suspense fallback=...>
조합으로 "일부 데이터 먼저 렌더, 남은 데이터 지연" 같은 접근을 했지만,
v7부터는 공식적으로 defer가 제거되었습니다.
예제 설명에서는 “동시에 여러 개의 HTTP 요청을 보내되, 필요한 경우 한쪽만 기다린 뒤 페이지 로딩을 끝내고 다른 요청은 페이지 표시 후에 로딩”하는 방식을 보이려 했으나,
v7에서는 “Single Fetch”/“Turbo-Stream” 등 새 아키텍처가 강조되고, 기존 defer API가 없어진 상태입니다.
그러므로 본 문서에선 두 가지 방식을 비교합니다:
결론적으로 v7에서 “두 개 이상 HTTP 요청을 지연”하여 부분 렌더링하려면, React 19 스트리밍과 “Single Fetch” 전략을 사용하거나, 따로 Promise를 분리하여 “한 요청만 우선 대기”하고 나머지는 페이지 로딩 후 fetch로 처리하는 식을 수동 구현해야 합니다.
v7의 “defer” 제거
여러 요청 시나리오
<Await>
각각 표기대안 - “한 요청만 우선 대기, 나머지는 useEffect로 fetch”
로딩 표기
useNavigation().state
로 구현. 'loading' 상태에서 스피너/“Loading...” 표기.[사용자 clicks "/events/e1"]
→ [loader() (eventDetailLoader) 호출]
→ [HTTP 요청 1: /api/events/e1] (blocking)
→ (가능하면) [HTTP 요청 2: /api/events] (별도 fetch?)
→ [fetch 완료 시 페이지 전환]
→ [useLoaderData()로 event(단일) 수신]
→ [모든 이벤트 목록은 useEffect 등 별도 방식 or blocking?]
<Await>
, <Suspense>
가 v7에서는 없어짐.이처럼 React Router v7에서 여러 요청을 느리게 로딩하고, 하나만 우선 받거나, 나머지는 페이지 표시 후 처리하려면, 기본 blocking + useEffect 패턴을 적절히 섞는 방식을 써야 합니다.
과거 v6 스타일의 defer를 사용해도 빌드가 되지 않으며, 새로이 공개된 “Single Fetch + React 19 스트리밍” 등을 적용해야만 “부분 로딩”을 라우터 레벨에서 세련되게 수행할 수 있습니다.
아래 내용은 리액트 라우터(react-router)에서 라우팅과 데이터 로딩/제출(action) 전반을 학습한 뒤, 마지막으로 Homepage 컴포넌트를 조금 다듬어서 섹션을 마무리하는 과정에 대한 요약 정리입니다.
라우트 설정
<Outlet>
을 통해 자녀 라우트를 감싸는 중첩 라우팅.오류 처리
데이터 가져오기
데이터 제출하기
action
함수(라우트에 지정): 폼을 제출할 때 호출<Form method="post">
또는 <Form method="patch">
등 사용useNavigation().state
로 제출/로딩 상태 확인(버튼 비활성화 등)redirect('/어딘가')
로 라우터에서 자동 이동 처리부분 로딩 / 지연 로딩
Homepage 마무리
[사용자 방문 "/" 경로]
↓ (라우터에서 HomePage 로딩)
↓ HomePage 내부에서 PageContent 렌더링
[사용자 "이벤트 목록" 링크 클릭 -> "/events"]
→ 라우터에서 eventsLoader() 호출
→ 백엔드("/api/events") 요청
└─ (성공 or 오류)
↓ 응답 완료 시 EventsPage 렌더
└─ (오류 시 ErrorPage 렌더)
이로써 긴 섹션을 마무리하며, 리액트 라우터의 다양한 기능(라우트 설정, 에러 페이지, 데이터 로딩/제출, 중첩 라우트, useNavigation 등)을 익혔습니다. 필요할 때마다 한 번씩 복습하여 프로젝트에 적용하시면 됩니다.
아래는 클라이언트(리액트 앱)와 백엔드 서버 간의 토큰 기반 인증 과정을 요약한 내용입니다.
인증(Authorization) 필요성
서버 측 세션 vs 인증 토큰
백엔드 가짜 API 동작 (데모 환경)
리액트 앱(클라이언트)의 해야 할 일
전체 개념
이로써 “클라이언트-서버 분리” 구조에서 인증 토큰을 활용해 인증을 구현할 수 있음.
[1] 클라이언트(리액트)에서 로그인 요청 (이메일/비밀번호)
↓
[2] 서버 (가짜 백엔드 API)
└─ 자격 증명 검증
└─ 성공 시 JWT 토큰 생성
↓
[3] 클라이언트로 토큰 반환
↓
[4] 클라이언트가 토큰 저장 (localStorage 등)
↓
[5] 보호된 리소스 요청 시
└─ 요청 헤더/파라미터에 토큰 첨부
↓
[6] 서버 (미들웨어 등)에서 토큰 유효성 검증
└─ 유효 → 요청 처리 (데이터 반환, 수정, 삭제 등)
└─ 무효 → 오류 (401 Unauthorized 등)
위 흐름을 통해 리액트 앱은 로그인 성공 후 받은 JWT 토큰을 계속 활용하여 인증이 필요한 요청을 수행하게 되고, 서버는 매 요청마다 토큰의 신뢰성을 검사해 안전하게 보호 자원을 관리할 수 있다.
아래는 "인증( Auth ) 라우트 추가" 과정을 정리하고, 필요한 전체 폴더 구조와 타입스크립트 기반 예시 코드(생략 없이) 를 보여주는 예시입니다. 라우팅/인증과 관련된 핵심 포인트만 추려서, 가장 많이 사용되고 검증된 패턴으로 작성했습니다.
[사용자] --(브라우저에서 /auth 요청)--> [React Router: App.tsx]
└─> (auth 라우트 확인)
│
└─> [AuthenticationPage.tsx 컴포넌트 렌더링]
└─> 인증 폼 표시
[메인 메뉴: MainNavigation.tsx]
└─> NavLink("/auth")
└─> 클릭 시 -> 브라우저 주소 /auth -> App.tsx -> AuthenticationPage.tsx
아래 정리에서는 쿼리 매개변수(Search Params)를 사용하여 로그인/회원가입 모드를 전환하는 AuthForm 로직을 예시로 보여줍니다.
또한 React Router v7의 createBrowserRouter
를 활용하여 라우트를 구성하는 전체 폴더 구조와 모든 파일을 TypeScript
기반으로 작성해 보았습니다.
페이지별 로그인/회원가입 모드 전환
useState
를 써서 isLogin
상태를 토글했지만,?mode=login
또는 ?mode=signup
)로 모드를 관리하면 한 번에 해당 URL로 직접 연결할 수 있어 편리하다.쿼리 매개변수 사용
useSearchParams
훅을 사용하여 현재 URL
의 mode
파라미터 값을 읽는다.mode
값이 'login'이면 로그인 모드, 그 외면 회원가입 모드로 간주한다.링크 전환
Link
또는 NavLink
를 사용, 예: /auth?mode=signup
/ /auth?mode=login
React Router
가 쿼리 매개변수를 변경해 주고, AuthForm
은 useSearchParams
로 모드를 읽어 적절한 UI를 보여준다.라우팅 구성 (createBrowserRouter
)
createBrowserRouter
를 통해 /auth
라우트 생성.AuthPage
컴포넌트에서 AuthForm
을 렌더링하고, 쿼리 매개변수에 따라 로그인/회원가입 UI를 전환.flowchart LR
A[사용자: /auth?mode=login] -->|페이지 진입| B(AuthPage)
B --> C(AuthForm)
C -->|useSearchParams로 mode 읽기| D{mode==='login'?}
D -->|Yes| E[로그인 UI]
D -->|No| F[회원가입 UI]
E -->|Link: to='?mode=signup'| C
F -->|Link: to='?mode=login'| C
A: 사용자 /auth?mode=login
접근 시
B: AuthPage
컴포넌트 렌더링
C: AuthForm
안에서 useSearchParams
로 mode
파라미터 읽기
D: 조건 분기(로그인/회원가입 모드)
E, F: 각각의 UI 표시 후, 반대 모드로 전환하는 링크를 제공
마무리
URL 공유: /auth?mode=login
/ /auth?mode=signup
등으로 다른 사용자가 직접 들어올 수 있다.
상태 대신 URL 파라미터: useState
로 모드를 관리하던 방식을 보다 웹 친화적인 방식으로 대체할 수 있다.
실제 백엔드 연동 로직(로그인/회원가입 요청)은 또 다른 액션 혹은
fetcher
를 통해 처리하면 된다. 이 예시에서는 간단히 쿼리 파라미터를 읽어 UI만 전환하는 핵심 로직을 보여주었다.
이상으로 라우팅과 쿼리 매개변수를 이용한 모드 전환 예시를 모두 살펴보았습니다. 원하는 부분을 추가/수정하여 실제 회원가입·로그인 요청을 연동하면 쉽게 인증 기능을 완성할 수 있습니다.
React Router v7+
에서 createBrowserRouter
문법은 공식 문서를 확인하세요.mode
외에도 여러 쿼리 파라미터를 다룰 수 있고, searchParams.set(...)
등을 통해 프로그래밍적으로도 업데이트 가능함.react-12 프로젝트 코드 참고하면됨
아래 정리는 인증 로직을 간단히 구현해 보는 예시입니다.
백엔드(Node.js
+ Express
)에서 가입(회원 생성) 및 로그인 로직을 처리하고, 프론트엔드(React
+ react-router-dom v7
)에서 AuthForm
을 통해 가입/로그인을 요청합니다.
요청 시 중복 이메일 등 유효성 검증 오류가 발생하면 상태 코드(422
등)를 받아서 UI에 표시할 수 있도록 구현합니다.
백엔드(Node/Express
) 구조
/signup
, /login
제공422
) 응답프론트엔드(React) 구조
AuthPage
: 인증 페이지 컴포넌트, AuthForm
렌더AuthForm
: Form
(react-router-dom
) 통해 가입/로그인 전송action
(react-router-dom
의 data submission
): 전송된 폼 데이터를 받아 백엔드로 fetch
요청 → 결과(성공/오류)에 따라 UI 처리(리다이렉트 or 오류 데이터 반환)mode=login
or mode=signup
)로 가입/로그인 모드 구분오류 처리
422
등)를 응답하면 프론트 action
에서 그대로 반환 → AuthForm
이 action data
로 오류 메시지 표시 가능flowchart LR
A[AuthForm] -->|사용자 입력 제출 (POST /auth action)| B[authAction 함수]
B -->|전송된 폼데이터| C{mode = ?}
C -->|signup| D[fetch('/signup')]
C -->|login| E[fetch('/login')]
D -->|결과 JSON| F[백엔드: 이메일 중복검사 → OK or 422]
E -->|결과 JSON| G[백엔드: 이메일/비번검사 → OK or 401/422]
F -->|응답| H{응답 상태확인}
G -->|응답| H
H -->|정상(200 OK)| I[리다이렉트('/')]
H -->|오류(422)| J[오류 데이터 반환]
I -->|리다이렉트 후| K[HomePage]
J -->|action data| A
A -->|오류 메시지 표시| A
AuthForm: 가입/로그인 전송 (mode
파라미터로 구분)
authAction: 폼 데이터를 받아 백엔드 /signup
or /login
에 fetch
백엔드: 가입/로그인 로직 처리 → OK 시 토큰 등 반환, 오류 시 상태 코드(422
등) 반환
action: 상태 코드 확인 후 → 정상: redirect('/')
, 오류: return response;
AuthForm: action data
로 오류 메시지 표시
정리
이렇게 최신 React Router v7 기준으로 가입/로그인 과정을 예시로 구현할 수 있습니다. 실제 사용 시 비밀번호 해싱, 에러 처리(422, 401, 500 등), 토큰 저장/사용(로컬 스토리지 등) 로직을 추가하면 기본 인증 기능을 완성할 수 있습니다.
react-12 프로젝트 코드 참고하면됨
아래는 React Router DOM v7
를 사용하여,
유효성 검증 오류와 인증 관련 오류를 useActionData
/ useNavigation
훅으로 처리하는
AuthForm
컴포넌트 및 전체 라우트 구성 예시 코드를 타입스크립트 기반으로 작성한 예시입니다.
useActionData
<Form method="post">
로 전송된 데이터가 라우트의 action
함수에서 특정 형태(예: 오류 응답)로 반환될 때, 이를 받아 화면에 표시할 수 있게 해주는 훅입니다.422
(유효성 검증 실패), 401
(인증 실패) 등의 상태 코드를 그대로 return
하면, 그 응답을 useActionData()
로 받아 UI에서 오류 메시지 등을 표시할 수 있습니다.useNavigation
navigation.state
가 submitting
이면, 지금 양식을 서버로 전송 중임을 의미하므로 로딩 스피너나 버튼 비활성화 등의 UX 개선이 가능해집니다.유효성 검증 오류나 인증 관련 오류
422
(유효하지 않은 입력값)나 401
(로그인 실패) 등을 응답하면, 해당 응답을 그대로 return
함으로써 액션 데이터로 전달합니다.useActionData
를 통해 오류 정보를 화면에 표시할 수 있습니다(예: data.errors
, data.message
등).버튼의 전송 상태 표시
useNavigation
을 써서 navigation.state === 'submitting'
이면 Submitting...
, 아니면 Save
등으로 표시.flowchart LR
A[사용자<br>AuthForm에 입력] --> B(Form 전송)
B --> C{라우트의<br>action함수}
C -->|서버 fetch| D[백엔드]
D -->|응답 OK<br>또는 오류| C
C -->|OK: redirect("/")| F[홈으로 이동]
C -->|오류(422/401)| E[return response]
E -->|useActionData| G[AuthForm에서 오류 표시]
C -->|기타 오류| H[throw new Response(500)]
AuthForm
에 이메일·비밀번호 입력 후 전송action
함수가 실행됨fetch
로 백엔드 서버에 인증 요청redirect("/")
→ 홈으로 이동return response
→ 컴포넌트 useActionData
로 오류 표시throw new Response(500)
→ 에러 라우트 처리요약
AuthForm
에서useActionData()
로 서버 응답(오류 메시지 등) 을 받는다.useNavigation()
으로 양식 전송 중인지 상태를 체크해 로딩 UI를 표시한다.action
(authAction
)에서request.formData()
로 폼 데이터를 받고, mode=login|signup
쿼리 파라미터를 파악해 백엔드로 fetch
전송.422
, 401
등)는 그대로 return response
→ 프론트의 useActionData
로 전송, 그 외 500
등은 throw new Response(...)
.App.tsx
)에서 /auth
라우트에 action: authAction
을 연결해,<Form method="post">
가 전송될 때 자동으로 인증 로직이 실행되도록 만든다.이와 같이 최신 React Router DOM v7 + TypeScript 환경에서, 유효성 검증 오류/인증 오류를 표시하고, 로딩 상태(제출 중)도 안내하는 인증 플로우를 구성할 수 있습니다.
- 중요: 실제 백엔드 API에서
422
,401
응답을 올바르게 구현해야useActionData
로 오류 처리 로직이 정상 동작합니다.- 추가적으로 브라우저 콘솔에서 Network 탭 확인시, 전송된 fetch 요청의 Body, Header를 검수하며 문제없게끔 세팅하면 됩니다.
react-12 프로젝트 코드 참고하면됨
아래는 로그인 및 오류 페이지(ErrorPage
)를 처리하는 예시를 간단히 정리한 코드 예시입니다.
ErrorPage
를 표시하도록 React Router DOM v7
에서 지원하는 errorElement
를 활용합니다.로그인
useActionData
로 오류 메시지 표시.오류 페이지
createBrowserRouter
사용 시, 상위 라우트 혹은 특정 라우트에 errorElement를 지정할 수 있습니다.throw new Response(..., { status: 500 })
처리를 하면, 가장 가까운 errorElement
가 렌더링됩니다.메인 네비게이션(MainNavigation
) 포함 문제
ErrorPage
내부에 MainNavigation
을 포함하되, 에러 상태에서 사용 불가능한 기능이 있다면 조건부 렌더링하는 식으로 처리 가능합니다.errorElement
만 할당해서, 오류 시 네비게이션 포함된 페이지를 렌더링해도 됩니다.flowchart LR
A[사용자 입력<br>(Login or Signup)] --> B(Form 전송)
B -->|action 함수| C[백엔드 요청<br>(fetch)]
C --> D{응답}
D -->|성공| E[리다이렉트<br>/]
D -->|오류| F[throw Response()]
F -->|라우터 감지| G[ErrorPage<br>렌더링]
action
함수에서 백엔드에 인증 요청redirect("/")
→ 홈 이동throw new Response(...)
→ 라우터가 errorElement
로 이동 → ErrorPage
표시login
/signup
) 라우트와 연동422
/401
응답 → useActionData
로 오류 메시지 표시redirect('/')
로 홈 이동App.tsx
에서 errorElement: <ErrorPage />
설정throw new Response(...)
발생 시 자동으로 ErrorPage
렌더링useRouteError
로 오류 객체 접근AuthForm
+ authAction(백엔드 호출)
+ ErrorPage
createBrowserRouter
+ RouterProvider
RootLayout
) 아래 자식 라우트로 /auth
, /
등 구성이렇게 정리하면, **오류 발생 시 ErrorPage
**가 표시되고, 인증 로직은 기존대로 작동하며, 오류 응답은 useActionData
+ throw Response
로 처리하게 됩니다.
react-12 프로젝트 코드 참고하면됨
아래는 "로그인/회원가입 시 받은 토큰을 로컬 스토리지에 저장하고, 보호된 API 요청에 토큰을 첨부하는 과정을 React Router(최신 v7) 기반으로 정리한 예시입니다.
목표 요약
token
)을 추출하여 로컬 스토리지(localStorage
) 에 저장Authorization
헤더에 Bearer <token>
형태로 토큰 첨부전체 요약 설명
회원가입 or 로그인
토큰 저장
response.json()
으로 데이터(토큰 등)를 받음localStorage.setItem('token', <받은 토큰>)
으로 저장토큰 활용
localStorage.getItem('token')
으로 토큰 조회Authorization: Bearer <token>
형태로 첨부해 fetch
요청[1] 사용자: 폼 제출 (로그인 or 가입)
↓
[2] AuthPage.action() (React Router의 action)
- (이메일/비번) -> fetch -> 백엔드('/login' or '/signup')
↓
[3] 백엔드: 토큰 생성 후 JSON 응답 { token: "...", ... }
↓
[4] AuthPage.action() 내부에서 응답 JSON 파싱
- 로컬 스토리지에 token 저장
↓
[5] 다른 요청(이벤트 편집/삭제/생성 action):
- localStorage에서 token 읽어옴
- fetch 시, 헤더에 "Authorization: Bearer <token>"
↓
[6] 백엔드: 토큰 검증 → 보호된 리소스 접근 허용 or 오류
회원가입/로그인:
localStorage.setItem('token', <토큰>)
저장보호된 요청:
Authorization: Bearer <토큰>
형태로 전송React Router v7 구조:
createBrowserRouter
+ RouterProvider
errorElement
로 오류 처리 페이지action
에서 fetch
로 백엔드 호출, 예외 시 new Response(...)
등 사용이로써 인증 토큰을 활용하여 보호된 리소스에 접근하는 React Router
+ 백엔드 API 구조
가 완성됩니다.
react-12 프로젝트 코드 참고하면됨
아래 정리는 로그아웃(토큰 제거) 기능을 추가하고, UI에서 로그인 상태에 따라 표시되는 메뉴를 달리하는 흐름을 예시로 보여줍니다. 이미 이전 단계(회원가입, 로그인, 토큰 저장/전송 등)가 완료된 상태에서, 로그아웃 라우트와 메뉴 표시 로직만 새롭게 추가한다고 생각하시면 됩니다.
로그아웃 작업(액션)과 라우트를 추가합니다.
Logout.tsx
(또는 LogoutPage.tsx
) 파일을 만들고 컴포넌트 없이 작업 함수만 export
합니다.logoutAction
) 안에서는 로컬 저장소에서 토큰을 제거하고, redirect('/')
로 메인 페이지로 보냅니다.path: 'logout'
에 action: logoutAction
을 연결해 줍니다.메인 메뉴(MainNavigation)에서 로그아웃 버튼을 누르면, 해당 라우트로 POST
전송되도록 설정합니다.
Form
컴포넌트를 사용해 action="/logout"
, method="post"
형태로 POST
전송합니다.<button type="submit">Logout</button>
형식으로 두면 클릭 시 액션을 호출합니다.UI 표시:
getAuthToken()
으로 토큰이 있으면 “로그인/회원가입” 대신 “로그아웃”을 표시하고, 없는 경우는 반대로 처리.[클라이언트(브라우저)] --(POST /logout)--> [로그아웃 라우트 액션 (logoutAction)]
logoutAction에서 localStorage.removeItem('token')
↓
redirect('/')
↓
[클라이언트는 / 로 이동, 토큰 없는 상태로 UI 재렌더링]
MainNavigation
의 Logout
버튼 클릭Form
이 /logout
으로 POST
전송logoutAction
함수가 실행되어 토큰 제거 후 리다이렉트 반환/
경로로 리다이렉트되어 토큰이 없는 상태로 렌더링localStorage.setItem('token', yourToken)
/ 로그아웃 시 removeItem('token')
.getAuthToken()
결과에 따라 메뉴 표시.Form
으로 로그아웃을 post
전송하면 logoutAction
이 실행되어 토큰 삭제 → 리디렉트.react-12 프로젝트 코드 참고하면됨
아래 예시 코드는 이미 공유해 주신 코드 기반에서, "토큰 유무를 루트 라우트의 로더로 관리" 하여 모든 페이지(하위 라우트)에서 useRouteLoaderData
로 가져올 수 있게끔 수정하는 방법을 정리해본 것입니다.
즉, 토큰 상태를 루트 레이아웃(라우트)에 두고 변경 시 자동으로 UI를 갱신하는 패턴입니다.
다른 컴포넌트/페이지에서 토큰 사용 예시
EventDetailPage(또는 EventForm 등)
에서도 편집/삭제 버튼 노출 시:useRouteLoaderData('root') as string | null
를 사용하여 토큰 유무 확인 가능disabled
처리EventsNavigation
컴포넌트가 있다면, 마찬가지로 useRouteLoaderData('root')
로 토큰을 얻고, 로그인 여부에 따라 UI 구성다른 파일(생략, 기존 내용 동일)
AuthenticationPage.tsx
, authAction
등은 기존과 같으며, 토큰 저장(localStorage.setItem('token', token)
) 이후 루트 라우트가 재평가되어 UI가 갱신됨LogoutPage.tsx
에서 토큰 removeItem('token') → 라우터가 다시 로더 실행 → tokenLoader
가 null 리턴 → UI 재렌더링 (로그인 메뉴 보이고 Logout 메뉴 사라짐)정리
tokenLoader
)를 설정한다.tokenLoader
내부에서 로컬 스토리지에서 토큰을 가져와 반환useRouteLoaderData('root')
로 언제든 토큰 상태를 조회authAction
에서 토큰을 로컬 스토리지에 저장 → 라우터가 재평가 → tokenLoader
재실행 → 토큰 존재 시 UI 업데이트logoutAction
에서 localStorage.removeItem('token')
후 redirect
→ tokenLoader
에서 null을 리턴 → UI 재평가이 방식으로 로그인/로그아웃 시 UI 자동 갱신을 처리하고, 중앙 집중 관리하는 효과를 얻을 수 있습니다.
react-12 프로젝트 코드 참고하면됨
중요: 여기서 저는 특정한 상황에서 값을 리턴하지 않는 라우트 로더를 설정했습니다.
여러분은 오류를 피하기 위해, 달리 아무것도 리턴하지 않는 모든 if
구문에 return null
구문을 추가하셔야 합니다.
정확히 말씀 드리자면, 다음 강의에서 추가할 checkAuthLoader()
함수의 if 구문 뒤에 return null
을 추가하셔야 합니다:
export function checkAuthLoader() {
// 이 함수는 다음 공부에서 추가될 것입니다.
// 최종적으로 이런 모습이 되도록 하십시오.
const token = getAuthToken();
if (!token) {
return redirect('/auth');
}
return null; // 이 부분은 다음 공부에서 빠져 있고, 여러분이 추가하셔야 합니다.
아래 예시 코드는 이미 공유해 주신 코드 기반에서, "특정 라우트를 토큰(로그인) 여부에 따라 보호" 하는 로직을 추가하는 방식입니다.
즉, 토큰이 없으면 해당 라우트에 접근 시 자동으로 /auth
페이지로 리디렉션 하도록 로더(loader
) 를 붙이는 것이 핵심입니다.
흐름 (데이터 흐름 도식)
/events/new
(or :eventId/edit
) 라우트 접속checkAuthLoader
실행checkAuthLoader
:getAuthToken()
으로 로컬스토리지의 토큰 검사redirect('/auth?mode=login')
return null
redirect
되어 로그인 페이지로 이동NewEventPage
또는 EditEventPage
)를 렌더링newEventAction
or editEventAction
도 토큰 확인(헤더에 첨부)정리
loader
로 방어 (checkAuthLoader
)loader
에서 토큰이 없으면 redirect('/auth')
→ 실질적 라우트 접근 불가tokenLoader(루트 라우트)
+ useRouteLoaderData('root')
→ “로그인 상태” UI 관리checkAuthLoader(특정 서브 라우트)
→ “실제 페이지 접근” 방어이로써 로그인하지 않은 사용자가 직접 URL 입력으로 이동하려 해도, 실패(리디렉션)하게 되어 보호 라우트가 완성됩니다.
react-12 프로젝트 코드 참고하면됨
아래 예시 코드는 이미 공유해주신 코드 기반에서, 한 시간 후 토큰이 만료되도록 구현하는 흐름을 보여줍니다.
핵심은 루트 레이아웃(예: AppLayout
)에서 토큰이 존재하면 1시간 타이머를 걸어 만료 시 로그아웃 라우트(POST /logout)를 자동 전송하는 것입니다.
데이터 흐름 (도식)
tokenLoader
호출AppLayout
컴포넌트에서 useLoaderData()
로 토큰 획득AppLayout
의 useEffect
: 토큰이 있으면 1시간 setTimeout
→ 만료 시 submit(null, {method: 'post', action: '/logout'})
checkAuthLoader
: 토큰 필요 라우트 접근 시 토큰이 없으면 /auth
로 강제 이동정리
useEffect
로 타이머 설정checkAuthLoader
→ 토큰 없으면 /auth
로 리디렉션tokenLoader
+ 루트 레이아웃 + useEffect
→ "자동 만료" & "UI 반영"이로써 실용적으로 "로그인 상태 유지 + 토큰 만료 시 자동 로그아웃 + 보호 라우트 접근 차단" 과정을 완성할 수 있습니다.
react-12 프로젝트 코드 참고하면됨
아래 예시 코드는 이미 공유해주신 코드에 토큰 만료 시점을 반영하여 자동 로그아웃을 처리하는 과정을 추가한 것입니다.
핵심 아이디어: 토큰과 “토큰 만료 시점(만료 시간)”을 함께 로컬 스토리지에 저장하고,
데이터 흐름 (도식)
localStorage
저장AppLayout
)에서 loader
→ tokenLoader()
→ getAuthToken()
(토큰 + 만료 여부)AppLayout useEffect
:/logout
전송setTimeout
→ 만료시 /logout
logoutAction
) → 토큰/만료시간 삭제 → 메인으로 Redirect
checkAuthLoader
: "보호" 라우트 접근 시, 토큰이 없거나 만료면 redirect('/auth')
정리
getAuthToken
에서 만료 여부를 체크해 EXPIRED
판단setTimeout
후 자동 로그아웃checkAuthLoader
를 등록해 토큰 없으면 redirect('/auth')
이로써 실제 토큰 만료 시나리오(1시간 중간에 새로고침해도 시간이 재설정되지 않고 "남은 시간"만큼만 유지)를 구현할 수 있으며, 인증 로직이 더욱 현실적으로 개선됩니다.
react-12 프로젝트 코드 참고하면됨
로컬 개발 → 실제 서버
빌드(Build)
서버 구성
https://내도메인/
등으로 접속할 수 있다.서버 측 라우팅 vs. 클라이언트 측 라우팅
리액트 라우터
)가 URL 변화를 감지하고, 컴포넌트만 갈아끼워서 화면을 바꾼다.배포 과정의 일반 흐름
[로컬 개발]
│
▼
(소스 코드 작성/수정)
│
▼
(npm run build) → [정적 빌드 결과물 (HTML/JS/CSS)]
│
▼
[호스팅 서버]
(업로드 & 라우팅설정)
│
▼
사용자가 브라우저로 접속
(https://내도메인)
│
▼
(서버 측, 정적 파일 서빙)
│
▼
클라이언트 측 라우팅 (리액트가 동작)
이로써 리액트 애플리케이션을 로컬에서 실제 프로덕션 서버로 배포할 때 거치는 과정을 간략히 정리했다. 특히 라우팅 설정에서, SPA 특성을 고려해 404 설정(모든 경로를 index.html로 향하게)이 필요하다는 점이 핵심이다.
아래는 리액트 애플리케이션 배포 과정에 관한 핵심 내용을 한글로 정리한 요약본이며, 전체적인 흐름을 이해하기 위한 데이터 흐름 도식도 포함했습니다.
코드 작성 및 철저한 테스트
코드 최적화(지연 로딩 등)
프로덕션용 빌드(Production Build)
서버에 업로드(호스팅)
build/
or dist/
)를 업로드하여 사용자가 도메인으로 접근할 수 있게 한다.서버/도메인 설정
[1. 코드 작성 & 테스트]
↓
(기능 개발, 오류 처리, UI 확인)
//----------------------------------------------
[2. 코드 최적화]
↓
(지연 로딩, 코드 크기 감소)
//----------------------------------------------
[3. 프로덕션 빌드]
↓
(npm run build → dist/ or build/ 폴더)
//----------------------------------------------
[4. 서버 업로드(호스팅)]
↓
(정적 파일을 특정 호스팅 제공자/서버에 업로드)
//----------------------------------------------
[5. 실제 배포 & 설정]
↓
(도메인 연결, 404 설정, etc.)
//----------------------------------------------
사용자는 https://내도메인 접근
→ 서버에서 빌드된 정적 파일 전달
→ 브라우저가 리액트 앱 로딩
이처럼 코드 작성 → 최적화 → 프로덕션 빌드 → 서버 업로드 → 도메인/설정 순으로 진행하면, 최종적으로 전 세계 사용자들에게 최적화된 리액트 애플리케이션을 제공할 수 있다.
아래는 지연 로딩(Lazy Loading
) 개념과 적용 이유를 정리한 내용이며, 이에 관한 데이터 흐름 도식과 간단한 타입스크립트 기반 예시 코드를 포함합니다.
지연 로딩은 큰 규모의 리액트 애플리케이션에서 초기 로딩 속도를 개선하는 핵심 테크닉입니다.
지연 로딩(Lazy Loading)이란?
chunk
)를 동적으로 로딩하는 기법.UX
)을 개선한다.왜 필요한가?
작동 방식
import()
구문과 함께 동적 import
형태로 감싼 뒤, React
의 Suspense
& lazy API
를 사용해 지연 로딩을 구현한다.장점과 주의점
Spinner
등)를 제공해야 자연스러운 사용자 경험을 유지할 수 있다.적용 시나리오
[사용자: 사이트 접속]
↓
[초기 로딩 시]
- React SPA 초기 코드(필수 부분)만 다운로드
- lazy 로딩 설정된 컴포넌트는 아직 불러오지 않음
//----------------------------------------------
// (사용자가 특정 페이지/컴포넌트를 요청)
[라우트 이동 or 함수 실행 시]
↓
[lazy 로딩된 컴포넌트 필요]
↓
동적 import()로 해당 청크 다운로드
↓
React Suspense 대체 UI → 실제 컴포넌트 로딩 완료
↓
[화면에 컴포넌트 렌더]
핵심: “필요할 때만 코드 청크를 내려받음”
이점: 초기 로딩 속도 단축, 사용자에게 빠른 첫 화면 제공
정리
code splitting
)과 함께 리액트 앱 최적화를 위한 핵심 기법이다.React
는 lazy
와 Suspense
컴포넌트를 제공해 구현이 간단하다.react-12 프로젝트 코드 참고하면됨
아래는 지연 로딩(Lazy Loading)을 통해 페이지(또는 컴포넌트)와 loader를 모두 동적으로 불러오는 방법에 대해 정리한 내용입니다. 먼저 개념과 과정을 정리하고, 이어서 데이터 흐름 도식과 타입스크립트 기반 예시 코드(react-router-dom v7 기준)를 제공합니다.
지연 로딩이란?
왜 지연 로딩과 loader까지 동적으로 로딩하나?
구현 핵심
React.lazy(() => import('./...'))
와 <Suspense fallback={<...}>
를 함께 사용.loader
: 기존 import
가 아닌, 동적 import(import() 함수)
로 loader
코드를 가져오고, 거기서 export
된 loader
함수를 호출해 반환하도록 만듦.loader
와 element
를 동시에 지연 로딩할 수 있다.주요 단계
BlogPage
& loader
등에서 기존 import BlogPage
, import { loader }
를 삭제.Lazy
로딩용 함수(예: lazy(() => import('./pages/Blog'))
)를 만들어 컴포넌트를 동적 import
.loader
에는 async function()
형태로 작성한 뒤, import('./pages/Blog').then(...)
을 통해 module.loader(...)
를 호출해 동적으로 반환.<Suspense>
로 컴포넌트를 감싸거나 fallback
을 지정해 로딩 표시.장점 / 주의점
(사용자) (브라우저)
| |
| 1) "/blog" 라우트 진입 |
|--------------------------------------->|
| |
| [라우트 설정] |
| - element: lazy(() => import(...))
| - loader: () => import(...).then(...)
| |
| 2) 브라우저가 "Blog" 컴포넌트 & loader 코드 동적 import
| |
| <Suspense fallback="로딩중..."> |
| Blog 컴포넌트 로딩 완료 후 렌더링 |
| </Suspense> |
|--------------------------------------->|
| 화면 갱신 |
V
핵심: 특정 라우트(‘/blog’)로 접근하면, 그때서야 Blog 컴포넌트와 loader가 동적 import로 로딩된다.
요약
<Suspense fallback={<...>}>
를 사용해 로딩 중 메시지나 스피너 등을 표시하면 자연스러운 UX를 제공할 수 있다.react-12 프로젝트 코드 참고하면됨
아래 내용은 "리액트 애플리케이션을 프로덕션 빌드(build
)하여 서버에 업로드하기 위한 과정"을 한눈에 보기 좋게 정리한 것입니다.
개발 코드와 프로덕션 코드의 차이
프로덕션 빌드의 필요성
프로덕션 빌드의 결과물
서버 업로드(배포) 단계
flowchart LR
A(개발 중<br>코드 작성) -->|npm start<br>(개발 서버)| B(브라우저에서<br>실시간 확인)
A -->|npm run build<br>(프로덕션 빌드)| C(build/ 폴더 생성)
C -->|서버 업로드| D(실제 서버<br>또는 호스팅)
D --> E(사용자 접속<br>최적화된 코드를<br>브라우저에 전달)
A: 개발자가 JSX 등 최신 문법으로 코드 작성 (개발 코드)
B: 개발 서버를 통해 브라우저가 변환 결과를 실시간 확인
C: 프로덕션 빌드 명령어 실행 → 최적화된 정적 파일을 build/ 폴더에 생성
D: build/ 폴더의 파일을 배포(서버·호스팅 업로드)
E: 최종 사용자가 웹사이트 접속 시, 최적화된 코드를 다운로드받아 React 앱 실행
정리
위 단계를 통해 개발 코드를 프로덕션으로 준비하고, 사용자에게 빠르고 효율적인 React 애플리케이션을 제공하게 됩니다.
아래는 “Firebase를 활용해 리액트 애플리케이션을 실제 서버(호스팅)에 배포”하는 과정을 요약하고 정리한 내용입니다.
Firebase 프로젝트 생성
Firebase CLI 도구 설치 및 로그인
Firebase 호스팅 초기화 (firebase init)
리액트 앱 빌드
Firebase 배포 (deploy)
추가 설정
flowchart LR
A(React App 코드 작성) -->|npm run build| B(build 폴더 생성)
B --> C{firebase init<br>hosting}
C -->|설정 완료| D(firebase deploy)
D -->|업로드| E(Firebase Hosting 서버)
E -->|생성된 URL로 접속| F(사용자 브라우저)
A: 리액트 애플리케이션 개발 (개발 서버 사용)
B: 프로덕션 빌드(build/) 생성 (최적화·압축)
C: Firebase 호스팅 설정(firebase init hosting)
D: 배포 명령(firebase deploy)
E: Firebase 서버에 정적 파일이 업로드됨
F: 사용자 브라우저가 Firebase 호스팅 URL로 접속 → 최적화된 리액트 앱 이용
핵심 정리
위 과정을 따르면, 리액트 SPA(정적 웹사이트)를 Firebase 서버에서 전 세계 어디서든 접근 가능하게 호스팅할 수 있습니다.
아래는 "SPA(싱글 페이지 애플리케이션) 배포 시 서버 측 라우팅 vs. 클라이언트 측 라우팅 이슈를 요약하고 정리한 내용입니다.
서버 측 라우팅 vs. 클라이언트 측 라우팅
SPA 설정의 필요성
서버 설정 방법
결론
flowchart LR
A(사용자 브라우저<br>URL 입력: /posts) --> B(서버)
B -->|항상 index.html<br>(SPA 설정)| C(Browser JS<br>React Router)
C -->|경로= /posts<br>컴포넌트 로딩| D(PostsPage)
style C fill:#E6FFB1,stroke:#888,stroke-width:2px
style B fill:#ffcccc,stroke:#888,stroke-width:2px
이 과정을 통해, 서버 측 라우팅 없이 클라이언트 측만으로 경로 이동이 가능해집니다.
아래는 TanStack Query(이전 명칭: React Query)에 관한 소개 및 섹션 개요를 정리한 내용입니다.
useEffect
+ fetch
)와의 차이점useEffect
훅과 fetch
를 사용해 HTTP 요청 가능.TanStack Query
를 사용하면 이러한 로직이 자동화되고 최적화된 방식으로 제공됨.useQuery
)과 뮤테이션 훅(useMutation
) 사용법.flowchart LR
A[컴포넌트] --> B[useQuery or useMutation 훅]
B -->|자동 요청| C[HTTP 요청 (GET/POST/PUT/DELETE)]
C -->|응답 데이터| B
B -->|데이터 반환| A
B --> D[캐싱/상태 관리]
D -->|UI 업데이트| A
useQuery
, useMutation
)을 호출.정리: 이번 섹션에서는 TanStack Query를 이용해 리액트 앱에서 HTTP 요청 로직을 더욱 간편하고 강력하게 관리하는 방법을 익힙니다. 캐싱, 로딩/오류 처리 자동화, 낙관적 업데이트 등 다양한 고급 기능을 학습하고, 프로젝트를 통해 실습합니다.
아래는 Tanstack Query 사용을 위한 시작 프로젝트 구조와 준비 과정을 정리한 내용입니다.
Tanstack Query 공식 문서 참고
시작 프로젝트 구성
아래는 Tanstack 쿼리 도입 전후의 개념과 코드 흐름을 정리한 내용입니다.
Tanstack 쿼리(이전 명칭: React Query)
HTTP
요청을 간편하게 전송하고, 프론트엔드 UI를 백엔드 데이터와 동기화하기 위한 서드파티 라이브러리.refetch
), 오류/로딩 상태 관리 등 고급 기능을 기본 내장해, 개발자가 작성해야 할 코드를 크게 줄여 줌.기존 방식(useEffect
+ fetch
)와의 차이
useEffect
)과 브라우저 내장 API(fetch
)만으로도 백엔드와 연동 가능.사용 예시
NewEventSection
컴포넌트에서 HTTP
요청을 전송해 이벤트 목록을 가져오는 로직.useState
로 관리, useEffect
내에서 fetch
호출.useQuery
훅으로 로딩/에러/데이터 상태를 자동 관리하고, UI에선 간단히 값만 가져와 활용.추가 고급 기능
flowchart LR
A[useQuery 훅] --> B[캐시 확인]
B -->|캐시에 데이터 있으면| C[즉시 UI 반영]
B -->|캐시에 데이터 없으면 / 만료됨| D[fetch로 백엔드 호출]
D --> E[백엔드 응답]
E --> F[캐시에 데이터 저장/업데이트]
F --> C[UI에 최신 데이터 반영]
useQuery
훅이 실행되면 캐시를 우선 확인.fetch
로 백엔드에서 데이터 요청.import { useQuery } from '@tanstack/react-query'
interface EventItem {
id: string;
title: string;
}
async function fetchEvents() {
const response = await fetch('/api/events')
if (!response.ok) {
throw new Error('이벤트 가져오기 실패!')
}
const data = await response.json()
return data.events as EventItem[]
}
function NewEventSection() {
// useQuery 훅: 'events'라는 key로 식별 & fetchEvents 함수로 데이터 가져옴
const {
data: events,
isLoading,
error,
isError,
refetch
} = useQuery({
queryKey: ['events'],
queryFn: fetchEvents
// 기타 옵션 가능
})
if (isLoading) {
return <p>로딩 중 (Tanstack Query) ...</p>
}
if (isError) {
return <p style={{ color: 'red' }}>에러 발생: {(error as Error).message}</p>
}
return (
<section>
<h2>이벤트 목록 (Tanstack Query)</h2>
<button type="button" onClick={() => refetch()}>다시 불러오기</button>
<ul>
{events?.map(evt => (
<li key={evt.id}>{evt.title}</li>
))}
</ul>
</section>
)
}
export default NewEventSection
설명
queryKey
: 캐시 구분 용도. 여기서는 'events'라는 문자열 배열로 설정.fetchEvents
: 실제 HTTP 요청 함수(별도 api/eventsApi.ts 파일로 분리 가능).isLoading
/ isError / data: 각각 로딩/오류/데이터 상태가 자동 관리됨.refetch()
: 수동으로 재요청을 트리거할 수 있는 함수.마무리
@tanstack/react-query
설치QueryClientProvider
로 전체 앱 감싸기useQuery
/ useMutation
등 훅으로 데이터 로직 구현.이 과정을 거치면 HTTP 요청 처리 로직이 매우 간소화되고 캐싱, 리패치, 에러 처리, 로딩 상태 등 다양하고 유용한 기능을 자연스럽게 적용할 수 있습니다.
아래 예시는 Tanstack 쿼리를 TypeScript 기반 리액트 프로젝트에서 사용하는 간단한 데모를 보여줍니다.
이 예시에서 NewEventsSection
컴포넌트는 기존 useEffect
+fetch
코드를 없애고, 대신 Tanstack 쿼리를 통해 서버(백엔드)로부터 이벤트 목록을 가져옵니다.
Tanstack 쿼리란?
코드 구조
useQuery
훅을 사용해, "쿼리 함수"(실제 fetch
역할) + 쿼리 키(queryKey
)를 설정.useQuery
훅이 제공하는 data
, isError
, error
, isPending
(또는 isLoading
등)을 사용해 로딩·오류·데이터 상태를 UI로 표현.QueryClientProvider
로 전체 앱을 감싸서 Tanstack 쿼리 기능 활성화.[NewEventsSection] [util/http.ts]
useQuery() -----------> fetchEvents() -> (fetch to backend)
|
+-- data, isError, isPending ----> React Rendering (UI)
(1) NewEventsSection
에서 useQuery
훅을 사용해 fetchEvents
함수를 호출.
(2) fetchEvents
가 fetch
로 백엔드에 HTTP 요청을 전송 → 응답 결과(이벤트 배열) 반환.
(3) useQuery
훅이 data
/ isError
/ isPending
등 상태를 제공해 UI에서 렌더링 처리.
tanstack query 이점
이로써 기존 useEffect + useState 로 작성했던 로직을 Tanstack 쿼리로 대체하여, 더욱 간결하고 유연한 데이터 가져오기(페치) 로직을 구현할 수 있습니다.
react-13 프로젝트 코드 참고하면됨
아래 예시는 Tanstack 쿼리(이전 이름: 리액트 쿼리)의 캐시 처리와 staleTime
, gcTime
(=cacheTime
) 설정을 활용하는 방법을 한글로 정리한 내용입니다.
queryKey
)로 재실행할 경우 즉시 기존 데이터를 표시(사용자에게 빠른 응답)한 뒤, 내부적으로 새로운 요청을 진행해 업데이트된 데이터가 있다면 갱신합니다.staleTime
(기본값: 0
)무효 상태다
)고 간주하기까지 걸리는 시간(밀리초).staleTime: 5000
이면, 데이터 가져온 후 5초 동안은 이 데이터를 유효한 것으로 간주 → 5초 이내 다시 요청 시, 추가 요청 없이 캐시 데이터만 사용. 5초가 지나면 새 요청 전송.gcTime
or cacheTime
(기본값: 5분 = 300000ms
)staleTime
이 지나면 해당 데이터는 “오래되었다”로 표시되지만, gcTime
동안은 캐시로 남아있을 수 있음.gcTime
이 지나면 해당 데이터는 캐시에서 완전히 제거(가비지 컬렉션).staleTime=0
: 매번 컴포넌트 렌더링 때마다 백그라운드로 새 요청 → 하지만 “화면 표시용”으로는 캐시 데이터 즉시 사용.staleTime=5000
: 5초 이내 재방문 시 새 요청 없이 캐시 데이터만 사용(네트워크 탭에서 요청이 뜨지 않음). 5초 지난 후 접근 시 새 요청 전송.gcTime=30000
: 30초 동안만 캐시에 남음 → 30초가 지나면 캐시에서 제거되어 완전히 새 요청이 필요.(컴포넌트 렌더링) --> useQuery({ queryKey: ["events"], ... })
+------------------ 캐시에 [events] 데이터 있는지 확인
| ↓ (있으면 즉시 표시)
| ↓ (없거나 staleTime 지났다면?)
|
+-> [백엔드 서버 요청] (fetch)
↓ (응답)
+-> 캐시에 데이터 저장
+-> 화면 업데이트
이와 같이 Tanstack 쿼리의 캐시 처리와 staleTime, cacheTime 설정 방법을 사용하면, 사용자가 페이지를 여러 번 이동해도 곧바로 캐시된 데이터를 보여주면서, 필요한 시점에 백엔드에서 최신 데이터를 가져오는 훌륭한 UX를 구현할 수 있습니다.
react-13 프로젝트 코드 참고하면됨
아래는 이번 강의에서 FindEventSection 컴포넌트에 TanStack 쿼리를 적용한 흐름 요약입니다.
fetchEvents
함수
/api/events
에 요청을 보내 이벤트 목록을 가져오는 역할을 하는 함수.search
쿼리 파라미터를 붙여 백엔드에서 검색 기능도 수행.EventItem
을 반환.response.ok
가 아니면 throw new Error(...)
로 예외 처리.FindEventSection
컴포넌트
ref
를 통해 입력값을 얻고, useEffect
등으로 직접 fetch
를 했을 수 있으나, TanStack 쿼리를 사용해 간소화.useQuery
훅을 활용해 1 queryKey
와 2 queryFn
(실제로 fetchEvents
호출)을 설정.searchTerm
이라는 로컬 상태를 두어(폼 submit
시 갱신) 이 값이 변할 때마다, 쿼리 함수와 쿼리 키가 달라지도록 하여 동적으로 이벤트를 검색.{ search: searchTerm }
)를 포함해 캐시가 섞이지 않도록 유의.문제점/버그
searchTerm
이 undefined
(또는 '')인 상태로 URL에 쿼리 파라미터가 잘못 붙거나, NewEventsSection
도 같은 fetch
함수를 호출함으로써 서로 충돌 가능성.['events']
vs ['events', { search: ... }]
등).추가 TanStack 쿼리 옵션
staleTime
, cacheTime
등을 지정해 데이터 갱신 시점과 캐시 만료 시점을 조절 가능.staleTime=0
(기본값) → 매번 re-fetch
,staleTime>0
→ 일정 시간 이내에 다시 페이지 방문하면 re-fetch
없이 캐시 사용.cacheTime
(기본 5분) 지나면 실제 캐시 메모리에서 제거.queryKey
를 동적으로 지정하여 요청이 별도 캐시로 관리되도록 한다.fetchEvents
함수도 searchTerm
등 인자를 받아 유연하게 URL을 구성한다.flowchart LR
A[사용자 입력 (검색)] --> B(FindEventSection)
B -->|setSearchTerm(폼 Submit)| C[searchTerm useState]
B -->|useQuery| D[queryFn(fetchEvents)]
D --> E[/api/events?search=...]
E --> D
D --> B
B -->|data,isLoading,isError| UI[렌더링 로직]
import { FormEvent, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchEvents, EventItem, HttpError } from '../../util/http.ts'
import LoadingIndicator from '../UI/LoadingIndicator.tsx'
import ErrorBlock from '../UI/ErrorBlock.tsx'
import EventItemComp from './EventItemComp.tsx' // 예: 개별 이벤트를 표시하는 컴포넌트
export default function FindEventSection() {
const searchRef = useRef<HTMLInputElement>(null)
const [searchTerm, setSearchTerm] = useState('')
function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!searchRef.current) return
const val = searchRef.current.value.trim()
setSearchTerm(val)
}
const {
data,
isFetching,
isError,
error,
isLoading
} = useQuery<EventItem[], Error>({
queryKey: ['find-events', { search: searchTerm }],
queryFn: () => fetchEvents(searchTerm),
// 검색어가 없으면 굳이 요청 보내지 않고 싶은 경우 enabled 옵션
enabled: searchTerm.length > 0,
staleTime: 0,
gcTime: 5 * 60 * 1000 // 5분
})
let content: JSX.Element = <p>검색어를 입력해주세요.</p>
if (isLoading || isFetching) {
content = <LoadingIndicator />
} else if (isError) {
const msg = (error as HttpError)?.info?.message || 'Failed to fetch events.'
content = <ErrorBlock title="An error occurred" message={msg} />
} else if (data && data.length > 0) {
content = (
<ul className="events-list">
{data.map(ev => (
<li key={ev.id}>
<EventItemComp event={ev} />
</li>
))}
</ul>
)
} else if (data && data.length === 0) {
content = <p>검색 결과가 없습니다.</p>
}
return (
<section>
<header>
<h2>Find Events</h2>
<form onSubmit={handleSubmit}>
<input type="text" ref={searchRef} />
<button type="submit">검색</button>
</form>
</header>
{content}
</section>
)
}
핵심 포인트
요약
FindEventSection
에서 검색어를 로컬 상태로 관리해 useQuery
와 함께 전송, URL
을 조합해 백엔드로 요청.queryKey
에 검색어를 반영해, 다른 섹션(NewEventsSection
)과의 캐시 충돌 회피.enabled
옵션으로 검색어 없을 시 요청 비활성화 가능.staleTime
, cacheTime
설정으로 효율적 데이터 재활용 가능.이상으로, TanStack 쿼리를 활용한 검색(FindEventSection
) 구현 예시 및 전체 코드 구조를 정리했습니다.
react-13 프로젝트 코드 참고하면됨
React Query(또는 TanStack Query)에서 쿼리 함수(queryFn
)를 구성할 때, 라이브러리가 제공하는 기본 객체(signal
, meta
등)와 사용자 정의 데이터(예: searchTerm
)를 함께 전달할 수 있습니다.
문제 원인
NewEventsSection
과 FindEventSection
이 동일한 fetchEvents
함수를 사용하되, 후자의 경우 searchTerm
검색어를 쿼리 파라미터로 추가해야 합니다.useQuery
훅은 내부적으로 queryFn
에 객체를 넘기는데, 여기에는 signal
(요청 취소 시 사용) 등의 기본 정보가 포함됩니다.fetchEvents
가 이 객체를 제대로 처리하지 않으면, searchTerm
이 이상한 값(혹은 객체)으로 설정되거나, 빈 문자열로 설정되어버리는 문제가 발생할 수 있음.해결 아이디어
fetchEvents
에서 (obj: {signal?: AbortSignal; searchTerm?: string;}
) 형태의 매개변수를 받도록 수정.FindEventSection
에서 useQuery
를 작성할 때, queryFn
을 익명 함수로 래핑해 직접 (context) => fetchEvents({ signal: context.signal, searchTerm })
형태로 호출.searchTerm
은 로컬 상태에서 관리.signal
은 React Query가 제공하는 abort
신호.NewEventsSection
처럼 검색어 없이 단순히 이벤트를 가져오는 섹션은 useQuery
에서 굳이 wrapper
함수 없이 fetchEvents()
로 호출하거나, searchTerm
을 ''로 전달.queryKey
)가 다르도록 설정(예: ['events']
vs ['events', {search: searchTerm}]
)해 캐시 충돌 방지.추가 포인트
signal
: React Query가 요청 취소(페이지 이동 등) 시 내부적으로 fetch
중단 가능.searchTerm
: 빈 문자열이면 쿼리 파라미터를 붙이지 않도록 fetchEvents
내에서 분기 처리.NewEventsSection
과 FindEventSection
이 독립적으로 쿼리 함수를 호출하면서도 캐시나 abort signal
을 적절히 활용할 수 있음.flowchart LR
A[FindEventSection] -->|useState(searchTerm)| B
A -->|useQuery| D[queryFn 래핑함수]
D -->|호출| E[fetchEvents({signal, searchTerm})]
E -->|fetch /api/events?search=...| F[백엔드]
F --> E
E --> D
D --> A
subgraph React Query
D
end
A -->|결과(data,isLoading etc.)| UI[렌더링/UI 표시]
FindEventSection
에서 searchTerm
상태를 폼 submit 시 갱신.useQuery
훅이 래핑된 queryFn
을 호출 → fetchEvents({signal, searchTerm})
실행.FindEventSection
컴포넌트에 전달되어 UI 렌더링.이렇게 쿼리 함수에는 React Query가 제공하는 정보와 사용자 데이터를 함께 전달해 로직을 유연하게 구성할 수 있다.
react-13 프로젝트 코드 참고하면됨
fetchEvents
에 signal
을 넘겨주는 이유는 브라우저의 Fetch API가 제공하는 요청 취소(Abort
) 기능을 활용하기 위함입니다.
signal이 없으면
signal
이 있으면
React Query
(혹은 AbortController
)에서 Abort
시그널을 발생시키면, 그 즉시 fetch
요청을 중단할 수 있습니다.즉, signal
을 넘기면 이 요청을 취소할 수 있다
는 의미가 되어,
사용자가 빠르게 이동하거나 요청이 무의미해진 순간에 자동 혹은 수동으로 요청을 중단(Abort)할 수 있게 됩니다.
반대로, signal
을 넘기지 않으면 그 요청은 끝까지 진행되고, 다른 페이지로 이동한 뒤 늦게 도착한 응답을 처리해야 할 수도 있어 성능·UX 면에서 부정적인 상황이 생길 수 있습니다.
처음 페이지 진입하면 events api 호출이 2번 감. 왜??
또한, 첫번째 events api 호출은 canceled 됨
이는 strict mode 때문에 events api 호출이 2번가는 듯. 그리고 canceled는 tanstack query의 signal 덕분인듯.
아래 정리는 리액트 쿼리(TanStack Query
)에서 "검색어를 이용한 쿼리"와 "쿼리 비활성화(enabled
)" 그리고 "isLoading
vs isPending
"를 어떻게 사용하는지에 대한 핵심 요약입니다.
FindEventSection
컴포넌트에서 검색어 searchTerm
을 상태로 관리한다.searchTerm === undefined(초기값)
인 경우, 쿼리를 비활성화(enabled=false
)하여 HTTP 요청을 보내지 않도록 설정할 수 있다.searchTerm !== undefined
가 되면, enabled=true
가 되어 리액트 쿼리가 fetch
요청을 전송한다.enabled=true
라면, 모든 이벤트를 조회하도록 fetchEvents
를 호출한다.isLoading
과 isPending
의 차이점이 있다:isLoading
: 실제로 쿼리를 실행 중일 때(즉, 활성화된 쿼리의 첫 로딩)만 true
.enabled=false
상태라면 쿼리가 실행되지 않으므로 isLoading
이 true
가 되지 않는다.isPending
: 쿼리 로딩 중이거나 비활성화된 상태 등에서 대기 중으로 볼 때도 true
가 될 수 있다.isLoading
/isPending
중 선택하여 로딩 UI를 표시한다.searchTerm=undefined
, 쿼리 비활성화 → HTTP 요청 없음, 로딩 스피너도 없음.searchTerm
값이 생김(enabled=true
), HTTP 요청을 보내고 isLoading
상태에 맞춰 로딩 표시.searchTerm=''
이 된 경우에도 enabled=true
→ fetchEvents
호출(전체 이벤트 불러오기).[FindEventSection] --(searchTerm: undefined)--> { enabled: false } --> [쿼리 비활성, 요청X ]
|
| (사용자가 검색어 입력: setSearchTerm('city'))
v
{ enabled: true } --(useQuery)--> fetchEvents({ searchTerm: 'city' })
|
|--> [백엔드 GET /events?search=city ... 결과]
|--> isLoading -> false, data -> [필터된 이벤트 리스트]
|
| (검색어 '' 로 변경)
v
{ enabled: true } --(useQuery)--> fetchEvents({ searchTerm: '' })
|
|--> [백엔드 GET /events -> 모든 이벤트]
// FindEventSection.tsx
/**
* - 주요 포인트
* - searchTerm의 초기값을 undefined로 설정: enabled: searchTerm !== undefined.
* - → 처음에는 HTTP 요청 안 보냄 + 로딩 스피너도 표시 안 함.
* - 검색어를 입력 후 Submit → searchTerm이 ''(빈 문자열)이 되더라도 enabled=true 이므로 전체 이벤트 조회.
* - isLoading만 사용 (isPending 대신)
* - → 비활성화된 쿼리 상태에서 로딩 스피너가 뜨지 않음.
*
* ※ 백엔드가 /events로 GET 요청 시 searchTerm이 빈 문자열이면 전체 이벤트, 특정 문자열이면 해당 키워드가 제목/설명에 포함된 이벤트만 반환한다고 가정.
* */
/**
* 추가 참고
* staleTime, cacheTime(gCTime) 등 옵션을 조정해, 캐시/재요청 타이밍을 세부 튜닝 가능.
* 검색 후 다시 검색어를 undefined로 돌리는 로직(“초기 상태” 복귀)은 필요하다면 UI에 별도 버튼을 추가해 구현 가능.
* */
/**
* 이렇게 enabled 설정과 searchTerm의 초기값을 undefined로 해서,
* (1) 시작 시에는 쿼리를 전송하지 않고,
* (2) 검색어가 한 번이라도 설정되면('' 포함) 전체/필터된 이벤트를 가져오는 로직을 구현할 수 있습니다.
* */
react-13 프로젝트 코드 참고하면됨
아래는 TanStack Query에서 사용하는 세 가지 플래그(또는 상태)인 isLoading, isPending, isFetching의 차이를 정리한 내용입니다.
각 상태 플래그의 의미
세 플래그의 사용 상황 비교
쿼리 A: (isPending, isLoading, isFetching)
쿼리 B: ...
------------------------------
[isLoading] : 처음(=데이터 전무) fetch 중이면 true
[isPending] : 지금 이 쿼리(혹은 뮤테이션)가 "fetch/실행 중"이면 true
[isFetching] : QueryClient 전역에서 "fetching 중인 쿼리 개수" (정수)
처음 쿼리 로딩
데이터 최초 로딩 후, 다시 refetch
// src/components/SomeQueryExample.tsx
import { useQuery } from '@tanstack/react-query'
interface RepoData {
name: string
description: string
subscribers_count: number
stargazers_count: number
forks_count: number
}
export default function SomeQueryExample() {
const {
data,
isLoading, // 첫 fetch 시점에만 true
isPending, // fetch(또는 refetch) 등 진행 중이면 언제든 true
error,
} = useQuery<RepoData, Error>({
queryKey: ['repo'],
queryFn: async () => {
const res = await fetch('https://api.github.com/repos/TanStack/query')
if (!res.ok) {
throw new Error('Failed to fetch repo data!')
}
return res.json() as Promise<RepoData>
},
})
if (isLoading) {
// "최초 로딩" 시점에만 true
return <p>처음 데이터를 가져오는 중...</p>
}
if (isPending) {
// 모든 fetch 시점 (최초, refetch 등)
return <p>데이터 갱신 중(Background fetch)...</p>
}
if (error) {
// 에러 처리
return <p style={{color:'red'}}>에러: {error.message}</p>
}
// data가 존재 -> 화면 표시
return (
<section>
<h2>{data?.name}</h2>
<p>{data?.description}</p>
<p>👀 {data?.subscribers_count} ✨ {data?.stargazers_count} 🍴 {data?.forks_count}</p>
</section>
)
}
주요 포인트
정리
아래 정리는 리액트 쿼리에서 useMutation
훅을 사용하여 새로운 이벤트(데이터)를 백엔드로 전송(예: POST 요청)하는 방법을 다룹니다.
본격적인 코드 작성 전, 전제 사항은 다음과 같습니다:
@tanstack/react-query
라이브러리가 설치되어 있고,QueryClientProvider
로 전체 앱을 감싸는 설정이 되어 있음/events
경로에 POST 요청을 받으면 새 이벤트를 생성할 수 있는 상태 (예: Node/Express + in-memory data 등)아래 내용 및 코드는 TypeScript 기반으로 작성했습니다. (React Router는 7버전을 가정)
<NewEventModal/>
이 열림.useMutation
훅의 mutate()
를 호출해 백엔드로 POST /events 요청을 전송.isPending
, isError
등)을 추적 → 로딩 상태나 오류 상태 등을 컴포넌트에서 쉽게 처리.isError === true
가 되어, 적절한 오류 메시지를 표시.사용자
└─ [새 이벤트 생성 버튼 클릭]
│
▼
<NewEventModal> (폼 입력)
└─ handleSubmit() {
// (1) formData 수집
// (2) useMutation().mutate(formData)
}
┌─────────────────┐
│ useMutation(...) │
└─────────────────┘
│
▼
fetch('/events', POST) -- (createNewEvent)
│
▼
백엔드 (/events)
- POST -> in-memory data에 push
│
응답(성공 or 실패)
▼
리액트 쿼리 → isPending / isError / error 등 업데이트
▼
<NewEventModal> re-render
- 로딩UI, 에러메시지, 후처리 등 표시
핵심 포인트 정리
useMutation
:POST
), 수정(PUT
/PATCH
), 삭제(DELETE
) 등 “백엔드 변경”을 위한 전송 시 사용.GET
(데이터 조회)에 적합, useMutation
은 Create
/Update
/Delete
같은 변경(mutation
)에 적합.mutationFn
:createNewEvent
).mutate(전달데이터)
가 호출될 때마다 이 함수를 실행해 백엔드로 전송.isPending
: 요청 진행 중인지 여부.isError
+ error
: 오류 발생 여부 및 오류 객체.isSuccess
: 변형(요청) 성공 여부.throw new Error(...)
→ useMutation
이 isError = true
로 처리하고 error
객체에 해당 에러가 저장됨.handleSubmit
→ mutate(formData)
→ 응답 대기 → 성공/실패 UI 반영.예시 시나리오
<NewEventModal>
열림handleSubmit
→ mutate(newEventData)
호출/events
에 POST 요청 전송 → in-memory data에 푸시isSuccess = true
, 모달 닫기 or 다른 후처리 가능isError = true
, error
에 메시지. UI 표시결론
Create
/Update
/Delete
)을 위한 useMutation
API도 제공해준다.useMutation
을 통해 “필요할 때(mutate)만” 요청을 전송하고, 상태(isPending
, isError
등)를 편리하게 추적·관리할 수 있다.react-13 프로젝트 코드 참고하면됨
아래 정리 내용과 예시 코드는 이전까지 공유한 코드 구조를 그대로 유지하면서, 이미지 리스트 가져오기(useQuery
) 기능을 새로 추가하는 방식으로 작성했습니다.
즉, 이미지를 선택하기 위한 ImagePicker
컴포넌트, 그리고 EventForm
컴포넌트에서 useQuery
로 이미지를 불러오는 부분이 새로 추가되었습니다.
/events/images
경로로 GET 요청을 보내면 사용자가 선택할 수 있는 이미지들의 목록([{id, imageUrl}, ...]
같은 형식이라고 가정)를 받을 수 있음.public/
폴더에서 제공(정적 호스팅)하므로, 실제 이미지 표시 시에는 <img src="백엔드서버주소/파일경로" ...>
형태로 접근.EventForm
컴포넌트가 useQuery를 통해 fetchSelectableImages
함수를 호출./api/events/images
(프록시 설정)로 GET
요청을 전송해, 이미지 목록을 JSON으로 받아옴.EventForm
에서 받은 이미지 목록(data
)을 ImagePicker
컴포넌트에 images prop
으로 넘김.ImagePicker
는 전달받은 images
배열을 렌더링하여 사용자가 이미지 하나를 선택할 수 있게 함.NewEventModal
열림.EventForm
이 모달 내부에 렌더링되면서, useQuery
가 실행돼 이미지 목록을 가져옴.ImagePicker
컴포넌트에 images prop
이 들어오면, 사용자가 원하는 이미지를 클릭(혹은 라디오 버튼) 방식으로 선택.Create
버튼 누르면 useMutation
으로 새 이벤트 + 선택 이미지를 서버에 전송.백엔드 (/events/images) →
fetchSelectableImages()
→ useQuery(queryFn=fetchSelectableImages, queryKey=["events-images"])
→ (isLoading / isError / data)
→ EventForm
→ ImagePicker(images=data)
→ [사용자 이미지 선택]
useQuery
(이미지 목록):
queryKey: ['events-images']
(고정)queryFn: fetchSelectableImages
isLoading
, isError
, data
등을 통해 로딩/오류/정상 데이터 처리ImagePicker
:
props: images: string[]
(또는 객체 배열)코드 작성 시:
요약
이로써 이미지 선택 기능을 Tanstack Query와 함께 구현할 수 있습니다.
react-13 프로젝트 코드 참고하면됨
아래 예시는 TanStack Query(v5 기준)로 작성된 새 이벤트 생성 로직에서, 변형이 성공했을 때 특정 쿼리 키(예: ['events']
)를 무효화하여 UI를 즉각 갱신하는 방법을 보여줍니다.
또한 성공 시 특정 페이지로 이동(또는 모달 닫기 등) 같은 후속 동작도 설명합니다.
useMutation
+ onSuccess
['events']
)를 무효화하여 UI를 즉시 갱신한다.onSuccess
내부에서 queryClient.invalidateQueries(...)
.navigate
또는 모달 닫기 등 적절한 후속 조치.[User Click + Add New Event] → setShowModal(true)
↓
[NewEventModal isOpen] → <EventForm> → handleCreate()
↓
useMutation.mutate(newEventData) → POST /events
↓
onSuccess → queryClient.invalidateQueries(['events'])
+ onClose() [모달 닫기]
(+ navigate('/events') 등)
↓
[NewEventsSection]에서 useQuery(['events']) → 재요청 → 즉시 최신 데이터
TypeScript + React Router v7 + TanStack Query v5
추가 예시 코드/패턴
useMutation<SuccessType, ErrorType, VariablesType>
등 제너릭 인자 활용.onSettled
콜백: 성공 여부와 상관없이 특정 동작(ex: 스피너 해제 등) 수행.enabled(useQuery)
/ invalidateQueries(exact: boolean)
등 고급 옵션.위 코드를 통해 새 이벤트 생성 시 성공 후 화면 즉시 업데이트(invalidateQueries
), 오류 처리, 모달 닫기 또는 페이지 이동 로직까지 모두 구현할 수 있습니다.
react-13 프로젝트 코드 참고하면됨
onSettled 콜백은 TanStack Query(리액트 쿼리)에서 useMutation 훅을 구성할 때 지정할 수 있는 하나의 옵션입니다.
onSuccess, onError와 달리, 변이가 성공했든 실패했든 간에(즉, 결과가 확정되면) 무조건 실행되는 콜백입니다.
따라서 onSuccess와 onError가 각각의 경우(성공 / 실패)만 처리하는 콜백이라면, onSettled는 결과와 상관없이 공통으로 처리해야 할 로직을 담을 때 쓰입니다.
onSettled 콜백이란?
useMutation({
mutationFn: someMutationFn,
onSuccess: (data) => {
// 성공했을 때만
},
onError: (error) => {
// 에러났을 때만
},
onSettled: (data, error) => {
// 성공/실패 상관없이 "결과 확정" 시 공통 동작
},
})
onSuccess: 성공 시만.
onError: 에러 시만.
onSettled: 성공 or 에러가 완료된 직후.
예시 용도
따라서 onSettled는 "그냥 onSuccess나 onError에 정의된 함수를 합쳐놓은 개념인가?"라고 볼 수도 있지만, 성공/실패 양쪽에서 공통으로 처리할 로직을 넣기 위한 별도 옵션입니다.
요약하면, onSettled는 무조건 실행되는 “결과 확정 시점” 공통 콜백이며, onSuccess/onError와는 용도 면에서 중첩되지 않습니다.
onSettled
는 onSuccess
, onError
와는 별개로, 변이가 종료된 이후(성공 or 실패)에 공통으로 동작하는 로직을 넣고 싶을 때 사용하는 콜백입니다.onSuccess
/onError
함수와 같다"가 아니라, 성공/실패를 가리지 않고 꼭 실행해야 하는 cleanup 로직 등을 넣을 수 있는 “제3의 콜백”이라는 점이 핵심입니다.아래는 과제 요구사항(이벤트 세부 정보 페이지 로드 및 삭제 기능 구현)에 대한 정리와 함께, 데이터 흐름 도식, 그리고 타입스크립트 기반의 예시 코드(최신 react-router-dom v7 기준)를 포함한 전체 파일 예시입니다.
이벤트 세부 정보 페이지(EventDetails
컴포넌트)
useQuery
훅을 사용해 fetchEvent
함수를 호출하여 특정 이벤트(식별자: eventId
)의 상세 데이터를 가져옴imageFileName
(또는 imageId
)를 바탕으로 실제 이미지 URL을 구성(예: http://localhost:8080/public/xxx.jpg
)imageUrl
이 들어있다면 그대로 사용삭제 기능(Delete
버튼)
useMutation
훅을 통해 deleteEvent
함수를 호출invalidateQueries({ queryKey: ['events'] })
등으로 전체 이벤트 목록 갱신라우팅
router/index.tsx
에서 /events/:eventId
경로로 EventDetails
컴포넌트를 매핑NewEventsSection
)에 View Details 버튼(혹은 링크)을 배치하여 해당 라우트로 이동 (URL 파라미터로 eventId
전달)[EventList 페이지] --(ViewDetails 클릭)-->
/events/:eventId 라우트 ->
[EventDetails 컴포넌트]
-> useQuery({ queryKey: ['event-detail', eventId], queryFn: fetchEvent(eventId) })
-> 백엔드( /api/events/:eventId )로 GET 요청
-> 응답된 이벤트 상세 데이터 표시
[Delete 버튼 클릭] ->
useMutation(deleteEvent) 호출 ->
백엔드(/api/events/:eventId) DELETE 요청 ->
onSuccess에서 queryClient.invalidateQueries({ queryKey: ['events'] }) ->
-> 최신 이벤트 목록 반영 or 라우팅 이동
EventDetails.tsx
파일을 추가해, useQuery(fetchEvent)
로 특정 이벤트 상세 정보를 로드하고 화면에 표시Delete
버튼을 useMutation(deleteEvent)
와 연결해, 삭제 성공 시 invalidateQueries('events')
실행 및 라우팅NewEventsSection.tsx
)에서 View Details 버튼(혹은 링크)로 /events/:eventId
경로로 이동http.ts
에 fetchEvent
, deleteEvent
함수를 추가해 각 API 연동 로직 작성router/index.tsx
에서 :eventId
경로 매핑위 코드 예시는 과제(세부정보 페이지 + 삭제 기능) 구현에 필요한 전반적인 흐름을 담고 있습니다. 실무에서는 디자인/구조에 맞춰 스타일을 적용하거나, 에러 처리 로직/유효성 검사 등을 보강할 수 있습니다.
react-13 프로젝트 코드 참고하면됨
아래 정리에서는 EventDetails
컴포넌트를 어떻게 작성하면 이벤트 상세 정보(데이터)를 가져오고, Delete
버튼을 눌렀을 때 이벤트를 삭제하여 UI에 반영하는지를 자세히 설명합니다.
또한 리액트 쿼리의 쿼리 무효화(invalidate
) 및 라우팅 연동(useNavigate
) 과정을 코드 예시로 함께 보여줍니다.
flowchart TB
A[사용자: EventDetails 페이지 방문] --> B[/events/:eventId 라우트/]
B --> C[EventDetails 컴포넌트]
C --> D[useQuery -> fetchEvent(eventId)]
D --> E[백엔드 GET /events/:eventId]
E --> D[응답(이벤트 상세 데이터)]
D --> C[데이터 state 업데이트]
C --> F[화면에 이벤트 상세 표시]
F --> G(사용자 Delete 클릭)
G --> H[useMutation -> deleteEvent(eventId)]
H --> I[백엔드 DELETE /events/:eventId]
I --> H[응답(성공 or 실패)]
H --> C[onSuccess -> queryClient.invalidateQueries({ queryKey: ['events'] }) + navigate('/')]
C --> B
/events/:eventId
URL에 접근하면 EventDetails
컴포넌트가 렌더링된다.useQuery
로 fetchEvent(eventId)
를 호출 → 백엔드 GET /events/:eventId
로 이벤트 정보 가져오기Delete
버튼을 누르면 useMutation
→ deleteEvent(eventId)
→ 백엔드 DELETE /events/:eventId
onSuccess
콜백에서invalidateQueries({ queryKey: ['events'] })
→ 기존 이벤트 목록 쿼리 데이터 무효화 & 재요청 트리거navigate('/')
로 이동 (또는 다른 동작)타입스크립트 & React Router v7 최신 버전 기준 예시
결론
이로써 “이벤트 상세 정보” 페이지에서 데이터 로드와 삭제 기능을 구현할 수 있습니다. TanStack Query를 이용해 로직이 간결해지고, invalidateQueries를 통해 캐시 데이터를 새롭게 갱신할 수 있어 사용자에게 최신 상태가 반영됩니다.
react-13 프로젝트 코드 참고하면됨
아래 정리 내용을 보시면, 왜 이벤트를 삭제하고 난 뒤 뒤로 이동했을 때 특정 이벤트에 대한 404
요청이 발생하는지,
그리고 이 문제를 invalidateQueries
의 refetchType
옵션으로 어떻게 해결할 수 있는지를 알 수 있습니다.
EventDetails
)에서 이벤트를 삭제함
왜 404 요청이 발생하는가?
invalidateQueries({ queryKey: ['events'] })
로 모든 이벤트 관련 쿼리 무효화refetch
)원하는 동작
해결 방법: invalidateQueries
의 refetchType
옵션
refetchType
을 none
으로 설정하면 무효화되었을 때 즉시 재요청하지 않음qurey
가 무효화되기는 하지만, "다음번에 해당 컴포넌트가 렌더링될 때" 재요청이 일어남404
발생이 사라짐flowchart LR
A[EventDetails 페이지 로딩] -- useMutation( deleteEvent ) -->
B{이벤트 삭제}
B --> C[onSuccess: queryClient.invalidateQueries({ queryKey: ['events'], refetchType:'none' })]
C --> D[모든 "events" 쿼리를 무효화\n(즉시 재요청 X)]
D --> E[현재 상세페이지: 재요청 X → 404 없음]
D --> F[다른 페이지 이동 시 → 컴포넌트 렌더링 & 해당 쿼리 재실행]
F --> G[갱신된 데이터 반영\n(삭제된 이벤트는 목록에서 제외)]
EventDetails
컴포넌트에서 삭제 뮤테이션 실행onSuccess
시점에 invalidateQueries({ queryKey: ['events'], refetchType: 'none' })
events
와 연관된 모든 쿼리가 무효화됨refetchType
이 active
(기본값)라면, 무효화되자마자 재요청하여 404 발생refetchType: 'none'
→ 무효화되지만 즉시 재요청은 안 함qurey
가 재요청되지 않아 404
가 발생하지 않음이렇게 하면 "이벤트 삭제 후 현재 상세 페이지를 즉시 재요청하지 않도록" 할 수 있어, 삭제된 이벤트에 대한 404 요청을 방지할 수 있습니다.
react-13 프로젝트 코드 참고하면됨
events
쿼리키의 refetchType
을 none
으로 해도, 뒤로가기 했을 때 이동되는 페이지는 이벤트 상세 페이지임.
그러면 event-detail
쿼리키는 호출되니깐 404
가 뜨는게 맞음.404
가 뜨지 않게하려면 event-detail
쿼리키가 호출 안되어야하는데, onSuccess
에서 event-detail
쿼리키의 refetchType
을 none
으로 해도 event-detail
쿼리키는 호출됨.
refetchType
의 역할이 뭔지 제대로 알아보자.
아래 설명은 TanStack Query(리액트 쿼리)에서 invalidateQueries()
를 호출할 때 사용되는 refetchType
옵션에 대한 자세한 안내입니다.
invalidateQueries()
와 refetchType
queryClient.invalidateQueries()
메서드는 특정 쿼리(또는 쿼리 집합)를 “무효화(invalidate
)”해,
이미 캐시에 저장된 데이터가 더 이상 유효하지 않게 되었음을 알리는 역할을 합니다.
무효화되면 해당 쿼리들은 “기존에 가져왔던 데이터가 오래되었다”고 표시되고, 기본 동작으로는 “액티브(활성) 쿼리”가 자동으로 재요청(refetch
) 되면서 새로운 데이터를 받아오게 됩니다.refetchType
입니다.refetchType: 'active'
(기본값)
useQuery()
등의 훅을 통해 현재 컴포넌트에서 렌더링되고 있는(구독 중인) 쿼리를 뜻합니다.useQuery()
가 마운트되면 새로 요청이 일어납니다.refetchType: 'inactive'
inactive
) 쿼리만 “즉시” 백그라운드에서 재요청한다.refetchType: 'all'
refetch
)한다.queryKey
나 다른 필터를 통해 결정됩니다.refetchType: 'none'
useQuery()
가 새로 마운트됨)에서 다음 요청을 보냅니다.404
를 발생시키는 것을 방지하기 위해 refetchType: 'none'
을 사용하면 유용합니다.정리: 각 refetchType 요약
이렇게 refetchType
옵션을 통해 무효화한 쿼리가 언제(지금 당장? 다음 렌더?), 어떤 상태(액티브/비활성)에 있을 때 재요청을 하는지 세밀하게 제어할 수 있습니다.
/**
* 만약 위의 이벤트 상세를 불러오는 쿼리키가 queryKey: ['event-detail', eventId] 이게 아니라 queryKey: ['events', eventId] 이런 형식이었다면,
* events 쿼리키의 데이터를 무효화처리하고 events 쿼리키 관련 쿼리를 호출하기 때문에
* queryKey: ['events', eventId] 이 쿼리키도 호출됐을 것.
* 즉, 현재 이벤트 상세페이지에서 해당 이벤트를 삭제하고, 삭제된 이벤트 상세를 불러오려하니 404가 발생하는 것
* 즉, 쿼리키가 겹칠 때, 바로 호출은 하지 말라는 뜻에서 refetchType: 'none' 설정!!!
* */
아래 내용은 이벤트 상세 페이지를 개선하여 삭제 확인 모달을 띄우고, 진행 중 로딩 상태와 오류 처리를 좀 더 사용자 친화적으로 만드는 과정을 정리한 것입니다. 예시로, 사용자에게 “삭제할까요?”를 묻는 모달을 표시하고, 삭제가 진행되는 동안 로딩 상태를 표시하며, 오류가 생겼을 경우 메시지를 보여주는 흐름입니다.
삭제 준비 상태(isDeleting State
)
EventDetails.tsx
)에서 useState
를 사용해 isDeleting
여부를 관리한다.isDeleting
을 true
로 설정 → 확인 모달을 열게 된다.isDeleting = false
로 되돌려 모달을 닫는다.useMutation
의 mutate
함수를 호출 → HTTP DELETE 요청 전송.삭제 로딩/오류 처리
기존 useMutation
객체에 있는 isPending
, isError
, error
를 별칭으로 구조분해 할당
const {
mutate: mutateDelete,
isPending: isDeletingNow, // 예: isPending -> 별칭 isDeletingNow
isError: isDeleteError,
error: deleteError
} = useMutation<...>({ ... })
삭제 확인 모달 내부에서
isDeletingNow
가 true
라면 "Deleting..." 같은 문구로 로딩상태 표시isDeleteError === true
)가 발생하면 deleteError
활용해 오류 메시지 출력모달 표시/숨김
isDeleting
값이 true
면, Modal 컴포넌트가 렌더링되어 삭제 여부를 묻는다.(onClose) → isDeleting = false
.전체 흐름
isDeleting = true
→ 확인 모달 출력isDeleting = false
모달 닫기mutateDelete(eventId)
→ 진행 중 상태(isDeletingNow = true
) → 완료 후(onSuccess
) navigate('/')
+ invalidateQueries(‘events’)
→ 홈으로 이동flowchart TB
A((EventDetails 컴포넌트)) --> B[Delete 버튼 클릭<br/>setIsDeleting(true)]
B --> C{조건: isDeleting === true?}
C -->|Yes| D[확인 모달(Modal) 표시]
D --> E[취소 버튼 클릭<br/> setIsDeleting(false)]
D --> F[삭제 버튼 클릭 <br/> mutateDelete(eventId)]
F --> G[useMutation 진행중 -> isDeletingNow = true -> 로딩 표시]
G --> H{onSuccess or onError}
H -->|성공| I[invalidateQueries({ queryKey: ['events'], refetchType: 'none' }) + navigate('/')]
H -->|오류| J[오류 메시지 표시 (deleteError)]
A: 기존 EventDetails 페이지 컴포넌트.
B→C: 사용자가 "Delete" 버튼을 누르면 isDeleting state = true로 전환.
C: isDeleting이 true면 모달 표시.
모달 내부:
F→G→H: useMutation 진행 → 완료 시 onSuccess/onError.
I: 성공 시 전체 이벤트 목록 쿼리 무효화 및 홈으로 이동.
J: 오류 발생 시 에러 메시지 표시.
결론
useMutation
로직 자체는 그대로이며, 추가로 isDeleting
(사용자 UI 상태)와 isDeletingNow
(HTTP 진행 여부)로 나누어 각 단계를 명확히 분리 가능합니다.invalidateQueries({ queryKey: ['events'], refetchType: 'none' })
를 통해 즉시 재요청을 막고, 페이지 전환과 함께 재로딩이 일어날 때만 데이터가 갱신되도록 조절할 수 있습니다.react-13 프로젝트 코드 참고하면됨
아래 내용은 “이벤트 편집 페이지”(EditEvent
)에서 이벤트 데이터를 먼저 로드한 후 폼에 미리 채워진 상태로 표시하는 작업을 설명합니다.
즉, 유효한 이벤트 ID를 통해 백엔드에서 이벤트 정보를 가져오고, 그 데이터를 EventForm
에 inputData
로 전달해 기존 이벤트 내용을 편집할 수 있게 하는 흐름입니다.
EditEvent
컴포넌트로 이동
useQuery
훅을 사용해 이벤트 ID(params.id
)에 해당하는 데이터를 불러옵니다.queryKey
를 ['event-detail', params.id]
등으로 설정하고, queryFn
에서 fetchEvent
함수를 호출합니다.EventForm
에는 inputData
프로퍼티가 있으며, 로드된 이벤트 데이터(data
)를 inputData
로 넘겨 폼을 미리 채워줍니다.로딩·오류 처리
isLoading
또는 isPending
(버전에 따라)을 통해 로딩 스피너 표시.isError
/ error
로 오류 블록 표시.Link
를 통해 모달을 닫고 다른 페이지로 돌아갈 수 있게 처리.캐시 활용
EventDetails
(상세 보기) 컴포넌트와 EditEvent
(편집 모달)에서 동일한 queryKey(['event-detail', id])
와 fetchEvent
함수를 쓰면, 한 컴포넌트에서 이미 로딩했던 데이터가 다른 컴포넌트에서도 즉시 재사용됨.전체 흐름
EditEvent
모달 열림.useQuery
로 fetchEvent(id)
실행 → 로딩 중(isLoading=true
)이면 스피너, 오류시 에러 표시.EventForm
에 inputData
로 전달 → 폼 필드 자동 채움.flowchart TB
A((EditEvent 컴포넌트)) --> B[useQuery fetchEvent(id)]
B -->|데이터 로딩| C{isLoading?}
C -->|true| D[로딩 스피너 표시]
C -->|false| E{isError?}
E -->|true| F[오류 블록 표시 + 모달 닫기 링크]
E -->|false| G[EventForm inputData=data]
G --> H(이미 저장된 이벤트 내용 자동 채움)
EditEvent
(모달 컴포넌트)useQuery
훅으로 이벤트 ID 기반 데이터 로드EventForm
에 inputData=data
전달useQuery
로 이벤트를 로드해 기존 데이터로 폼을 미리 채움.LoadingIndicator
, 오류 시 ErrorBlock
표시.EventDetails
)에서 편집 모달로 전환 시 즉시 데이터 표시 가능(이미 캐시됨).useMutation
을 추가 구현해 onSubmit
에서 호출 가능.이처럼 이미 존재하는 데이터를 다시 가져와 편집 모달을 미리 채우는 흐름은 리액트 쿼리로 매우 간단히 구현할 수 있습니다.
react-13 프로젝트 코드 참고하면됨
아래 예시는 “이벤트 편집 기능”을 단순히 Update
요청만 보내고, invalidateQueries
없이 navigate
로 돌아가는 형태로 구현한 버전입니다.
즉 updateEvent
함수로 PUT
(또는 PATCH
) 요청을 전송하고, onSuccess
대신 직접 navigate
를 호출하여 모달(또는 페이지)을 닫는 방식을 보여줍니다.
EditEvent
컴포넌트에서 useQuery
로 해당 이벤트 정보를 가져와 EventForm
에 채워 넣음useMutation(updateEvent)
을 호출하여 백엔드에 PUT 요청 전송navigate
를 통해 이전 페이지(세부 정보 페이지 등)로 돌아감invalidateQueries
를 호출하지 않아 UI에 갱신이 반영되지 않음(수동 새로고침 후 바뀐 데이터 확인)주의: 실제로는
invalidateQueries
(또는Optimistic Update
,Query Re-fetch
등)를 통해 편집 후 화면에 새 데이터 반영이 이뤄지도록 하는 편이 좋습니다. 여기서는invalidateQueries
없이navigate
만 하는 예시임.
[Edit 버튼 클릭]
↓ (라우터 이동: /events/:id/edit)
[EditEvent 컴포넌트]
↓ useQuery(fetchEvent) → 기존 이벤트 데이터 로드
↓ useMutation(updateEvent)
↓ "Update" 클릭
↓ mutate({ id, event: formData }) 전송
↓ [백엔드 PUT 요청 -> DB 수정]
↓ 완료 후 -> navigate("/events/:id" 등)
(InvalidateQueries 없음 -> UI 즉각 반영 X)
EditEvent
컴포넌트에서 useQuery
로 기존 이벤트 정보를 가져와서 EventForm
의 inputData
로 전달EventForm
에서 입력(수정) 후 onSubmit
→ updateEvent
뮤테이션을 mutate
로 호출navigate(events/:id)
로 돌아가지만 invalidateQueries
를 하지 않으므로 곧바로 UI가 갱신되진 않음(새로고침 시 반영)이처럼 updateEvent
요청 이후 즉시 navigate
만 하는 예시 코드를 통해, invalidateQueries
없이 화면에 바로 반영되지 않는 시나리오를 확인할 수 있습니다.
(그런데 실습해보니까 되는데..? 난 왜 되는거지..? tanstack query 버전이 달라서 그런건가?)
react-13 프로젝트 코드 참고하면됨
아래 정리는 “낙관적 업데이트(Optimistic Updates)” 를 구현하기 위한 전반적인 과정과 예시 코드입니다. 중요 포인트: 백엔드와 프론트엔드 사이에 업데이트 요청을 보내는 동안 사용자 화면에 즉시 새로운 데이터를 적용하여 “퍼포먼스가 매우 빠르게 느껴지는 UX”를 제공하고, 만약 요청이 실패하면 롤백해버리는 기능입니다.
현재 문제
queryClient.invalidateQueries
방식만 사용하면, 요청이 끝날 때까지 화면에 반영되지 않음 → 사용자 입장에서는 응답을 기다리는 불편함이 있음.낙관적 업데이트 기본 흐름
onMutate
(Mutation
옵션)queryClient.cancelQueries(queryKey)
: 해당 쿼리와 관련된 진행 중인 요청을 취소 (충돌 방지)const previousData = queryClient.getQueryData(...)
: 이전 캐시 데이터 저장 → 롤백용queryClient.setQueryData(...)
: 응답 기다리지 않고 새 데이터(업데이트 후 상태)로 캐시 수정 → UI 즉시 반영return { previousData }
: 이 반환 값은 onError
에서 사용될 context
가 됨onError
queryClient.setQueryData(...)
로 이전 데이터(previousData
) 복원 → 낙관적 업데이트 취소(롤백)onSettled
mutation
이 최종적으로 끝났을 때 실행queryClient.invalidateQueries(...)
등을 다시 수행해 백엔드와 최종 데이터 동기화 가능.장점과 주의점
cancelQueries
/rollback
로직 누락 시, 의도치 않은 데이터 충돌 및 UI 오류 발생할 수 있음.flowchart TB
A[사용자 "Update" 클릭] --> B[React Query Mutation mutate() 호출]
B -->|즉시 실행| onMutate
onMutate -->|1. cancelQueries| queryClient
onMutate -->|2. getQueryData| previousData
onMutate -->|3. setQueryData(newData)| 캐시
캐시 --> UI[화면 즉시 반영]
B -->|네트워크 요청| 백엔드(업데이트)
백엔드(업데이트) -->|응답 성공| onSuccess
onSuccess --> onSettled
onSettled -->|invalidateQueries| queryClient
onSettled --> UI
백엔드(업데이트) -->|응답 실패| onError
onError -->|setQueryData(previousData)| 캐시
캐시 --> UI[화면 롤백]
onError --> onSettled
onMutate
: 낙관적 업데이트 (cancel
+ setQueryData
)
onError
: 실패 시 롤백
onSettled
: 어떤 결과든(성공/실패) 최종 정리 (invalidateQueries
로 백엔드와 동기화 등)
요약
onMutate
에서 cancelQueries
+ getQueryData
+ setQueryData
로 낙관적 업데이트 실시onError
에서 롤백onSettled
에서 invalidateQueries
로 백엔드 동기화이렇게 하면, 업데이트 버튼 클릭 → UI 즉시 반영 → 요청 실패 시 롤백 → 최종적으로 성공하면 onSettled
에서 재확인 가능.
이 과정을 통해 응답 대기 시간 없이 UI가 즉시 업데이트되는 “부드러운 사용자 경험”을 제공하면서, 실패 시에는 이전 데이터로 복원할 수 있다.
react-13 프로젝트 코드 참고하면됨
위 내용에서 설명한 핵심 포인트는 fetchEvents
함수를 더 확장하여 max
쿼리 매개변수를 처리하고, NewEventsSection
(또는 다른 컴포넌트)에서 이 max
값을 queryKey
와 fetchEvents
에 함께 넘기는 것입니다.
이때, **검색어(searchTerm
)와 max
**를 동시에 사용할 수도 있고, 하나만 쓸 수도 있으므로, fetchEvents
함수에서는 다양한 경우(둘 다 설정 / 하나만 설정 / 둘 다 없음)에 맞춰 URL을 적절히 만들도록 처리합니다.
또한, 리액트 쿼리에서 제공하는 쿼리 함수의 인자로 들어오는 context
객체({ queryKey, signal, ... }
)를 효율적으로 쓰기 위해, queryKey[1]
에 저장된 값을 전개(spread
)하여 한꺼번에 fetchEvents
에 넘기는 방식을 쓰면, 코드 중복을 방지할 수 있습니다.
fetchEvents
함수 확장
searchTerm
만 처리하던 로직을 max
쿼리 매개변수도 추가 처리하도록 수정합니다.searchTerm
와 max
둘 다 있을 수도, 하나만 있을 수도, 없을 수도 있기 때문에, 각각의 경우에 맞춰 URL을 만들거나 혹은 좀 더 깔끔한 로직으로 처리할 수 있습니다. (예: 쿼리 파라미터를 배열에 저장 후 join('&')로 합치는 방식 등)리액트 쿼리 queryKey
& fetchEvents
함수 파라미터 연결
NewEventsSection
(또는 다른 컴포넌트)에서 max
값을 3으로 지정한다면useQuery({
queryKey: ['events-limited', { max: 3 }],
queryFn: (ctx) => {
const { signal, queryKey } = ctx
// queryKey[1] = { max: 3 }
return fetchEvents({ signal, ...queryKey[1] })
},
...
})
fetchEvents
안에서는 max
값이 있으면 ?max=...
쿼리 파라미터로 붙여서 백엔드로 요청.max
쿼리 파라미터를 인식해, 맨 뒤의 max
개만 리턴하도록 만들 수 있습니다.코드 중복 방지 (spread
연산자)
queryFn
으로 넘어오는 ctx
에서 ctx.queryKey[1]
(두 번째 배열 요소)을 꺼낸 뒤, fetchEvents
에 그대로 전개 연산자를 이용해 넘기면 됩니다.queryFn: ({ queryKey, signal }) => {
return fetchEvents({
signal,
...queryKey[1] // { max: 3 }라든가, { searchTerm: 'abc' } 등
})
},
searchTerm
, max
, 그 외 필요 옵션들을 한꺼번에 넘길 수 있어 코드가 깔끔해집니다.최종 결과
NewEventsSection
에서는 3개의 최신 이벤트만 표시.FindEventSection
에서는 검색 기능 수행.searchTerm
, max
등 여러 옵션을 자유롭게 조합하여 fetchEvents
를 재활용할 수 있습니다.[NewEventsSection.tsx]
└─ useQuery → queryKey: ['events-limited', { max: 3 }]
queryFn({ queryKey, signal }) {
fetchEvents({ signal, ...queryKey[1] });
}
↓
fetchEvents({ signal, max: 3, searchTerm?: '' })
↓
(백엔드 GET /events?max=3 → 최근 3개의 이벤트 반환)
↓
└─ Re-render UI with top 3 events
searchTerm
) + max
값 등 여러 옵션을 한꺼번에 fetchEvents
에 넘기려면,fetchEvents
함수에서 이 값을 받아, 동적으로 URL 쿼리 파라미터를 구성.NewEventsSection
등)에서는 queryKey
를 ['특정-키', { searchTerm, max }]
형태로 만들고, queryFn
에서 fetchEvents({ signal, ...queryKey[1] })
로 호출.req.query.max
혹은 req.query.search
를 확인해, 이벤트 배열을 원하는 대로 필터링/슬라이싱 처리하도록 작성합니다.위와 같은 방식으로 max만 사용해서 “최근 N개” 이벤트만 가져오거나, searchTerm과 조합해 “최근 N개의 검색된 이벤트”만 가져오는 등 유연한 확장이 가능합니다.
react-13 프로젝트 코드 참고하면됨
아래 정리 내용은 리액트 라우터(데이터 로더
& 액션
)와 리액트 쿼리를 함께 사용하는 방법
을 중심으로, 기존에 학습한 리액트 쿼리의 캐싱 및 스테일 처리와 라우팅을 결합하는 사례를 살펴봅니다.
(이 정리는 리액트 라우터 v7+에서 제공하는 데이터 로더
/액션
기능, 그리고 리액트 쿼리 최신 버전
(예: @tanstack/react-query ^5.x)을 가정하고 있습니다.)
리액트 라우터의 로더(Loader
)
loader
함수를 사용하면, 특정 라우트에 접근할 때 해당 컴포넌트가 렌더링되기 전에 데이터를 미리 가져올 수 있습니다.loader
가 반환하는 프로미스가 완료될 때까지 기다렸다가 화면을 그립니다.queryClient.fetchQuery(...)
를 직접 사용하면, 리액트 쿼리 캐시에 데이터를 미리 로드해 둘 수 있습니다.리액트 라우터의 액션(Action
)
action
함수는 양식(form
)을 제출할 때 트리거되어, 서버로 데이터를 전송하거나 백엔드 로직을 수행하고, 리다이렉트 등의 후속 동작을 결정합니다.action
내부에서 updateEvent(...)
같은 함수를 직접 호출해 데이터를 전송/변형할 수 있습니다.queryClient.invalidateQueries(...)
등을 통해 관련 캐시를 무효화하여, 다음 렌더링 시 최신 데이터가 반영되도록 만들 수 있습니다.리액트 쿼리와 리액트 라우터의 결합 방식
Loader
) 단계loader
에서 queryClient.fetchQuery({ queryKey, queryFn })
를 호출해 캐시에 미리 데이터를 채워둡니다.useQuery
가 캐시에 이미 있는 데이터를 사용해 빠르게 화면을 그립니다.staleTime
을 적절히 설정하면, 방금 fetchQuery
로 가져온 데이터가 일정 시간 동안은 stale
하지 않으므로 추가 HTTP 요청을 보내지 않게 됩니다.)useQuery
계속 사용useQuery
로 캐시된 데이터를 사용하되, 로더에서 사전 로드된 캐시가 있으면 즉시 표시합니다.useQuery
의 동작 원리에 따라 staleTime
등을 고려해 재검증할 수 있어요.Action
)form
) 제출 시, 리액트 라우터의 액션이 호출되어 서버에 데이터 변형을 수행합니다.queryClient.invalidateQueries(...)
등으로 캐시를 무효화한 뒤, redirect(...)
로 다른 페이지(또는 같은 페이지)로 이동시킵니다.action
대신 (또는 병행) useMutation
에서 onMutate
/onError
같은 로직을 작성할 수도 있습니다.중복 요청 방지를 위한 staleTime 설정
useQuery
가 중복 요청을 또 보낼 수 있습니다.useQuery
의 옵션으로 staleTime
을 설정하면 됩니다(예: staleTime: 10_000
).staleTime
동안은 캐시가 stale
하지 않으므로, 추가 fetch
가 일어나지 않고, 로더에서 받아온 캐시된 데이터를 그대로 사용합니다.로딩/오류 표시
useQuery
로딩 표시를 할지, 라우터에서 제공하는 라우트 전 로딩
표시를 할지는 선택 사항입니다.errorElement
로 전환할 수 있거나, 컴포넌트 내부에서 isError
로 별도 처리하는 방식을 선택할 수 있습니다.[사용자가 /events/:eventId/edit 라우트 진입]
│
↓ (라우터: loader 함수 실행)
(loader) → queryClient.fetchQuery({ queryKey: ['event-detail', eventId], queryFn: fetchEvent })
│
├─> 백엔드 GET /events/:eventId HTTP 요청
│
└─> 응답 수신 → 캐시에 저장 (queryKey='event-detail', eventId)
│
└─> loader 반환 → 라우터가 컴포넌트 렌더링 시작
↓
[EditEvent 컴포넌트] useQuery( queryKey=['event-detail', eventId], ... )
│
├─ 캐시에 이미 데이터가 있으면 즉시 표시 (staleTime 내면 추가 요청 X)
├─ staleTime 만료 등 발생 시 → 추가 fetch
│
▼ (사용자가 폼을 수정 후 “Update” 버튼 클릭)
(action) → form submit → action 함수 호출
│
└─ action 내부:
1) request.formData()로 폼 데이터 파싱
2) updateEvent({ id: eventId, event: updatedEventData })
3) queryClient.invalidateQueries(['some-query'])
4) return redirect('/events/:eventId' etc.)
loader
/action
과 리액트 쿼리를 결합하면,loader
로 먼저 queryClient.fetchQuery
→ 사전 로드useQuery
가 캐시 데이터 사용action
으로 양식 전송 시 데이터 변형 + 캐시 무효화 + 리다이렉트useQuery
사용하되, staleTime
을 통해 중복 요청 방지 가능useSubmit
훅으로 라우터 action
을 호출, action
내부에서 http
요청 → 무효화 → redirect
이러한 구조는 라우트 전 데이터 준비와 폼 제출 후 처리를 리액트 라우터 방식으로 깔끔히 관리할 수 있다는 장점이 있습니다.
반면 리액트 쿼리의 낙관적 업데이트, isPending
같은 세밀한 상태 제어는 action
만으로 구현하기가 다소 복잡할 수 있으므로,
프로젝트 상황에 따라 선택적으로 병행 활용합니다.
복습
리액트 라우터 + 리액트 쿼리 결합 흐름
장단점
[사용자 → 페이지 로드]
↓
[리액트 라우터] - loader 함수 호출
↓ (queryClient.fetchQuery('event-detail', fetchEvent)...)
[백엔드로 GET 요청 → 이벤트 상세]
↓ (데이터 응답)
[리액트 쿼리 캐시에 저장]
↓
[컴포넌트(EditEvent) 렌더링]
↓ (useQuery('event-detail') 호출)
[이미 캐시에 데이터 있음 → 즉시 반환(또는 staleTime 지나면 fetch)]
[EditEvent 폼 제출]
↓ (리액트 라우터 - action 함수 호출)
[action() → updateEvent(id, data)]
↓ (서버로 PUT 요청)
[완료 후 queryClient.invalidateQueries(...) → 재동기화]
↓
[redirect("/events/:eventId")]
이렇게 리액트 쿼리와 리액트 라우터를 함께 사용하면, 사전에 로드된 데이터와 캐시 사용, 폼 전송 시 액션 함수와 무효화 등을 편리하게 결합해 최적의 사용자 경험을 제공할 수 있습니다.