2026년 3월 10일
이 글을 읽고 나면, 컴포넌트의 생명주기를 이해하고, useEffect로 외부 데이터를 가져오고, 다양한 조건에 따라 화면을 유연하게 구성할 수 있습니다.
2편에서는 props와 state로 사용자와 상호작용하는 투두 리스트를 만들었습니다. 하지만 아직 부족한 것이 있습니다.
이번 편에서는 이 세 가지를 다룹니다.
React 컴포넌트의 주된 역할은 데이터를 받아서 화면을 그리는 것입니다. 그 외에 컴포넌트가 해야 하는 모든 것을 **부수 효과(Side Effect)**라고 합니다.
컴포넌트의 본업:
props와 state를 기반으로 JSX를 반환하여 화면을 그리는 것
부수 효과:
- API 호출 (서버에서 데이터 가져오기)
- 타이머 설정 (setInterval, setTimeout)
- 이벤트 리스너 등록
- 브라우저 타이틀 변경
- localStorage 읽기/쓰기이러한 부수 효과를 처리하기 위해 React가 제공하는 Hook이 **useEffect**입니다.
import { useEffect } from "react";
useEffect(() => {
// 여기에 실행할 코드를 작성합니다
}, [의존성 배열]);useEffect는 두 개의 인자를 받습니다:
| 인자 | 역할 |
|---|---|
| 첫 번째: 함수 | 실행할 코드 |
| 두 번째: 배열 | 언제 이 코드를 실행할지 결정하는 조건 |
두 번째 인자인 **의존성 배열(Dependency Array)**에 따라 동작이 달라집니다.
[] — 컴포넌트가 처음 나타날 때 1번만 실행useEffect(() => {
console.log("컴포넌트가 화면에 나타났습니다!");
}, []); // 👈 빈 배열가장 많이 사용하는 패턴입니다. 컴포넌트가 화면에 처음 렌더링된 직후, 이 함수가 딱 한 번 실행됩니다. 주로 API를 호출하여 초기 데이터를 가져올 때 사용합니다.
[value] — 해당 값이 바뀔 때마다 실행const [count, setCount] = useState(0);
useEffect(() => {
document.title = `클릭 횟수: ${count}`;
}, [count]); // 👈 count가 바뀔 때마다 실행count가 변경될 때마다 브라우저 탭의 제목이 업데이트됩니다. 의존성 배열에 넣은 값이 바뀔 때만 실행되므로, 불필요한 실행을 방지할 수 있습니다.
useEffect(() => {
console.log("렌더링될 때마다 실행됩니다");
}); // 👈 두 번째 인자 자체가 없음이 패턴은 거의 사용하지 않습니다. 컴포넌트가 렌더링될 때마다 매번 실행되기 때문에 성능 문제를 일으킬 수 있습니다.
정리하면 이렇습니다:
| 의존성 배열 | 실행 시점 | 사용 예시 |
|---|---|---|
[] | 처음 1번 | API 호출, 이벤트 리스너 등록 |
[value] | value가 바뀔 때 | 검색어에 따른 재검색, 문서 제목 변경 |
| 없음 | 매 렌더링 | 거의 사용하지 않음 |
실제로 useEffect를 사용하여 외부 API에서 데이터를 가져오는 예제를 만들어 보겠습니다. JSONPlaceholder라는 무료 테스트 API를 사용합니다.
📁 src/UserList.tsx
import { useState, useEffect } from "react";
interface User {
id: number;
name: string;
email: string;
company: {
name: string;
};
}
function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
// API에서 사용자 목록을 가져옵니다
fetch("https://jsonplaceholder.typicode.com/users")
.then((response) => {
if (!response.ok) {
throw new Error("데이터를 불러오는 데 실패했습니다");
}
return response.json();
})
.then((data) => {
setUsers(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, []); // 👈 빈 배열: 컴포넌트가 처음 나타날 때 1번만 실행
if (loading) {
return <p style={{ textAlign: "center", padding: "40px" }}>로딩 중...</p>;
}
if (error) {
return (
<p style={{ textAlign: "center", padding: "40px", color: "red" }}>
에러: {error}
</p>
);
}
return (
<div style={{ maxWidth: "600px", margin: "40px auto" }}>
<h1 style={{ textAlign: "center" }}>사용자 목록</h1>
{users.map((user) => (
<div
key={user.id}
style={{
padding: "16px",
borderBottom: "1px solid #eee",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<strong>{user.name}</strong>
<p style={{ color: "#666", margin: "4px 0 0", fontSize: "14px" }}>
{user.email}
</p>
</div>
<span
style={{
fontSize: "12px",
color: "#888",
backgroundColor: "#f5f5f5",
padding: "4px 8px",
borderRadius: "4px",
}}
>
{user.company.name}
</span>
</div>
))}
</div>
);
}
export default UserList;이 코드의 실행 흐름을 따라가 보겠습니다:
1. 컴포넌트 첫 렌더링
- users: [] (빈 배열)
- loading: true
- 화면: "로딩 중..." 표시
2. useEffect 실행 (첫 렌더링 직후)
- fetch로 API 호출 시작
3. API 응답 도착 (성공 시)
- setUsers(data) → users에 사용자 데이터 저장
- setLoading(false) → 로딩 상태 해제
4. 컴포넌트 리렌더링
- loading: false
- users: [사용자 10명의 데이터]
- 화면: 사용자 목록 표시여기서 중요한 것은, 3개의 state를 사용하여 3가지 상태를 관리한다는 점입니다:
| State | 역할 | 화면 |
|---|---|---|
loading: true | 데이터를 가져오는 중 | "로딩 중..." 표시 |
error: "..." | 에러가 발생함 | 에러 메시지 표시 |
users: [...] | 데이터가 준비됨 | 사용자 목록 표시 |
이 패턴은 API를 호출하는 거의 모든 컴포넌트에서 사용됩니다. 로딩, 에러, 성공 세 가지 상태를 관리하는 것은 실무에서도 기본 중의 기본입니다.
useEffect 안에서 반환하는 함수를 정리(Cleanup) 함수라고 합니다. 컴포넌트가 화면에서 사라질 때 실행됩니다.
useEffect(() => {
// 실행할 코드
const timer = setInterval(() => {
console.log("1초마다 실행");
}, 1000);
// 정리 함수: 컴포넌트가 사라질 때 실행
return () => {
clearInterval(timer); // 타이머 해제
};
}, []);정리 함수가 필요한 이유는, 컴포넌트가 사라졌는데도 타이머나 이벤트 리스너가 계속 동작하면 **메모리 누수(Memory Leak)**가 발생하기 때문입니다. 리소스를 사용한 뒤에는 반드시 해제하는 습관이 중요합니다.
비유: 방을 쓰고 나갈 때
useEffect 실행 = 방에 들어가서 에어컨을 켠다
Cleanup 함수 = 방을 나가면서 에어컨을 끈다
에어컨을 끄지 않으면? → 전기 낭비 = 메모리 누수💡 지금 단계에서 Cleanup을 완벽하게 이해하지 않아도 괜찮습니다. "타이머나 이벤트 리스너를 등록했으면, return으로 해제 코드를 넣어야 한다" 정도만 기억해 두면 됩니다.
위의 UserList 예제에서 이미 사용했습니다:
if (loading) {
return <p>로딩 중...</p>;
}
if (error) {
return <p>에러: {error}</p>;
}
return <div>사용자 목록...</div>;이것이 가장 기본적인 조건부 렌더링입니다. 조건에 따라 다른 JSX를 반환합니다.
React에서 조건부 렌더링을 구현하는 방법은 크게 세 가지입니다.
function StatusMessage({ status }: { status: "loading" | "error" | "success" }) {
if (status === "loading") {
return <p>로딩 중입니다...</p>;
}
if (status === "error") {
return <p>문제가 발생했습니다.</p>;
}
return <p>완료되었습니다!</p>;
}컴포넌트가 완전히 다른 화면을 보여줘야 할 때 사용합니다. 각 조건에서 return하기 때문에, 아래 코드가 실행되지 않습니다.
? :function Greeting({ isLoggedIn }: { isLoggedIn: boolean }) {
return (
<div>
{isLoggedIn ? (
<h1>다시 오신 것을 환영합니다!</h1>
) : (
<h1>로그인해 주세요.</h1>
)}
</div>
);
}조건에 따라 두 가지 중 하나를 보여줄 때 사용합니다. JSX 안에서 인라인으로 사용할 수 있어 간결합니다.
&&function Notification({ count }: { count: number }) {
return (
<div>
<h1>알림</h1>
{count > 0 && <span>새로운 알림이 {count}개 있습니다.</span>}
</div>
);
}조건이 참일 때만 무언가를 보여주고, 거짓이면 아무것도 안 보여줄 때 사용합니다.
&& 연산자의 동작 원리는 이렇습니다: 왼쪽이 true이면 오른쪽을 반환하고, 왼쪽이 false이면 아무것도 렌더링하지 않습니다.
⚠️
&&사용 시 주의사항tsx// ❌ 주의: count가 0이면 화면에 "0"이 표시됩니다 {count && <span>{count}개</span>} // ✅ 안전한 방법: 명시적으로 boolean 비교 {count > 0 && <span>{count}개</span>}JavaScript에서
0은 falsy 값이지만, React는0을 화면에 렌더링합니다. 따라서 숫자를 조건으로 사용할 때는> 0같은 비교 연산자를 사용하는 것이 안전합니다.
| 상황 | 추천 패턴 |
|---|---|
| 완전히 다른 화면을 보여줄 때 | if + Early Return |
| 둘 중 하나를 보여줄 때 | 삼항 연산자 ? : |
| 보여주거나 / 안 보여주거나 | && 연산자 |
React에서 배열 데이터를 화면에 표시하려면 JavaScript의 map 메서드를 사용합니다.
function FruitList() {
const fruits = ["사과", "바나나", "포도", "딸기"];
return (
<ul>
{fruits.map((fruit, index) => (
<li key={index}>{fruit}</li>
))}
</ul>
);
}map은 배열의 각 요소를 순회하면서, 각 요소에 대한 JSX를 반환합니다.
fruits 배열: ["사과", "바나나", "포도", "딸기"]
│ │ │ │
▼ ▼ ▼ ▼
map 결과: <li>사과</li> <li>바나나</li> <li>포도</li> <li>딸기</li>리스트를 렌더링할 때 각 항목에 key props를 전달해야 합니다. 이것은 React가 각 항목을 구별하기 위한 고유 식별자입니다.
// ❌ key에 index를 사용하는 것은 권장되지 않습니다
{items.map((item, index) => (
<div key={index}>{item.name}</div>
))}
// ✅ 고유한 id를 key로 사용하는 것이 올바릅니다
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}key가 중요한 이유는, React가 리스트를 업데이트할 때 어떤 항목이 추가/삭제/변경되었는지 key를 기준으로 판단하기 때문입니다.
key가 없거나 index를 사용하면:
리스트 중간에 항목을 추가하면, React는 변경된 위치 이후의 모든 항목을
다시 그립니다. (비효율적)
고유한 id를 key로 사용하면:
React는 정확히 어떤 항목이 추가되었는지 알고, 그 항목만 추가합니다. (효율적)index를 key로 사용해도 되는 경우는 배열의 순서가 절대 바뀌지 않고, 항목이 추가/삭제되지 않는 정적인 리스트뿐입니다. 그 외의 경우에는 항상 고유한 id를 사용합니다.
실무에서는 단순한 문자열 배열보다 객체 배열을 렌더링하는 경우가 대부분입니다.
📁 src/PostList.tsx
interface Post {
id: number;
title: string;
author: string;
likes: number;
createdAt: string;
}
function PostList() {
const posts: Post[] = [
{
id: 1,
title: "React를 배우기 시작했습니다",
author: "김철수",
likes: 12,
createdAt: "2025-01-15",
},
{
id: 2,
title: "useEffect 완전 정복",
author: "이영희",
likes: 24,
createdAt: "2025-01-16",
},
{
id: 3,
title: "TailwindCSS가 좋은 이유",
author: "박민수",
likes: 8,
createdAt: "2025-01-17",
},
];
return (
<div style={{ maxWidth: "600px", margin: "40px auto" }}>
<h1 style={{ textAlign: "center", marginBottom: "24px" }}>게시글 목록</h1>
{posts.map((post) => (
<div
key={post.id}
style={{
padding: "20px",
marginBottom: "12px",
border: "1px solid #eee",
borderRadius: "8px",
}}
>
<h2 style={{ margin: "0 0 8px", fontSize: "18px" }}>{post.title}</h2>
<div
style={{
display: "flex",
justifyContent: "space-between",
color: "#666",
fontSize: "14px",
}}
>
<span>{post.author}</span>
<div style={{ display: "flex", gap: "12px" }}>
<span>❤️ {post.likes}</span>
<span>{post.createdAt}</span>
</div>
</div>
</div>
))}
</div>
);
}
export default PostList;리스트를 렌더링할 때, 데이터가 비어 있는 경우도 고려해야 합니다.
function PostList({ posts }: { posts: Post[] }) {
if (posts.length === 0) {
return (
<div style={{ textAlign: "center", padding: "40px", color: "#999" }}>
<p>아직 작성된 게시글이 없습니다.</p>
<p>첫 번째 글을 작성해 보세요!</p>
</div>
);
}
return (
<div>
{posts.map((post) => (
<PostItem key={post.id} post={post} />
))}
</div>
);
}빈 상태를 아무 표시 없이 두면 사용자가 "화면이 고장난 건가?" 하고 혼란을 겪을 수 있습니다. 빈 상태에 적절한 안내 메시지를 표시하는 것은 좋은 UX 습관입니다.
지금까지 배운 useEffect, 조건부 렌더링, 리스트 렌더링을 모두 활용하는 실습입니다.
📁 src/App.tsx
import { useState, useEffect } from "react";
interface User {
id: number;
name: string;
email: string;
company: {
name: string;
};
}
function App() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchTerm, setSearchTerm] = useState("");
// 1) 컴포넌트가 처음 나타날 때 사용자 데이터를 가져옵니다
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users")
.then((response) => {
if (!response.ok) throw new Error("데이터 로딩 실패");
return response.json();
})
.then((data) => {
setUsers(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, []);
// 2) 검색어에 따라 사용자를 필터링합니다
const filteredUsers = users.filter(
(user) =>
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
);
// 3) 로딩 상태
if (loading) {
return (
<div style={{ textAlign: "center", padding: "60px" }}>
<p style={{ fontSize: "18px", color: "#666" }}>사용자 정보를 불러오는 중...</p>
</div>
);
}
// 4) 에러 상태
if (error) {
return (
<div style={{ textAlign: "center", padding: "60px" }}>
<p style={{ fontSize: "18px", color: "#ef4444" }}>⚠️ {error}</p>
<button
onClick={() => window.location.reload()}
style={{
marginTop: "12px",
padding: "8px 16px",
backgroundColor: "#4F46E5",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
}}
>
다시 시도
</button>
</div>
);
}
// 5) 성공 상태
return (
<div style={{ maxWidth: "640px", margin: "40px auto", padding: "0 20px" }}>
<h1 style={{ textAlign: "center", marginBottom: "8px" }}>사용자 목록</h1>
<p style={{ textAlign: "center", color: "#666", marginBottom: "24px" }}>
총 {users.length}명의 사용자가 있습니다
</p>
{/* 검색창 */}
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="이름 또는 이메일로 검색..."
style={{
width: "100%",
padding: "12px 16px",
fontSize: "16px",
border: "1px solid #ddd",
borderRadius: "8px",
marginBottom: "20px",
boxSizing: "border-box",
}}
/>
{/* 검색 결과 정보 */}
{searchTerm && (
<p style={{ color: "#666", fontSize: "14px", marginBottom: "12px" }}>
"{searchTerm}" 검색 결과: {filteredUsers.length}명
</p>
)}
{/* 사용자 목록 */}
{filteredUsers.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px", color: "#999" }}>
<p>검색 결과가 없습니다.</p>
<p style={{ fontSize: "14px" }}>다른 검색어를 시도해 보세요.</p>
</div>
) : (
filteredUsers.map((user) => (
<div
key={user.id}
style={{
padding: "16px 20px",
marginBottom: "8px",
border: "1px solid #eee",
borderRadius: "8px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<strong style={{ fontSize: "16px" }}>{user.name}</strong>
<p style={{ color: "#666", margin: "4px 0 0", fontSize: "14px" }}>
{user.email}
</p>
</div>
<span
style={{
fontSize: "12px",
color: "#4F46E5",
backgroundColor: "#EEF2FF",
padding: "4px 10px",
borderRadius: "12px",
whiteSpace: "nowrap",
}}
>
{user.company.name}
</span>
</div>
))
)}
</div>
);
}
export default App;이 실습에서 사용된 개념들을 정리하면:
| 개념 | 코드에서의 역할 |
|---|---|
useEffect + 빈 배열 | 컴포넌트 마운트 시 API에서 사용자 데이터를 가져옴 |
useState (4개) | users, loading, error, searchTerm 각각의 상태 관리 |
if Early Return | loading, error 상태에서 전체 화면을 다르게 표시 |
&& 연산자 | 검색어가 있을 때만 검색 결과 수를 표시 |
삼항 연산자 ? : | 검색 결과가 있으면 목록, 없으면 안내 메시지 표시 |
map | 필터링된 사용자 배열을 순회하며 카드 렌더링 |
filter | 검색어에 맞는 사용자만 걸러냄 |
key | 각 사용자 카드에 user.id를 고유 키로 전달 |
이 하나의 컴포넌트에 이번 편에서 배운 모든 것이 들어있습니다.
💡
filteredUsers는 왜 state가 아닌가요?tsxconst filteredUsers = users.filter(...);
filteredUsers는users와searchTerm으로부터 계산해낼 수 있는 값입니다. 이런 값은 별도의 state로 만들 필요가 없습니다.users나searchTerm이 바뀌면 컴포넌트가 리렌더링되고, 리렌더링될 때filteredUsers도 자동으로 다시 계산됩니다.이것을 **파생 상태(Derived State)**라고 합니다. "이미 있는 state로부터 계산할 수 있는 값은 state로 만들지 않는다"는 것은 React에서 중요한 원칙입니다.
이번 3편에서 다룬 내용을 정리합니다.
📌 핵심 정리
- useEffect는 컴포넌트에서 부수 효과(API 호출, 타이머 등)를 처리하는 Hook입니다.
- 의존성 배열
[]은 "처음 1번만",[value]는 "value가 바뀔 때마다" 실행됩니다.- Cleanup 함수(return)를 통해 타이머, 이벤트 리스너 등의 리소스를 정리합니다.
- 조건부 렌더링은 if문(Early Return), 삼항 연산자(
? :), AND 연산자(&&) 세 가지 패턴이 있습니다.- 리스트 렌더링은
map을 사용하며, 각 항목에 고유한key를 전달해야 합니다.- API 호출 시에는 로딩, 에러, 성공 세 가지 상태를 반드시 관리합니다.
- 기존 state로 계산할 수 있는 값은 별도의 state로 만들지 않습니다(파생 상태).
다음 4편에서는 TypeScript를 배웁니다. 지금까지 코드에서 interface, string, number 같은 타입 관련 코드가 등장했는데, 그 의미와 활용법을 본격적으로 다룹니다. TypeScript를 사용하면 코드를 실행하기 전에 버그를 잡을 수 있어서, 프로젝트가 커질수록 그 가치가 높아집니다.
댓글 0