Frontend 28
useEffect로 게시글 Read와 loading/error 화면 만들기
fetch 함수를 그냥 App 안에서 부르면 될 것 같은데 왜 무한 루프가 생기고, loading과 error state가 왜 따로 필요한지 모르겠다.
근거 · 교안 p212-p228
1장: 렌더링 본문에 fetch를 넣으면 왜 무한 루프에 빠질까요?
솔라는 노트북 화면에 떠 있는 코드 조각을 자신만만하게 바라봤다. 서버에서 게시글 목록을 가져와 화면에 보여주는 간단한 페이지. 로직은 명쾌했다. 컴포넌트가 실행될 때 데이터를 가져와서, useState로 만든 상태에 저장하면 끝. 뭐가 더 필요하지?
import React, { useState } from 'react';
function PostListPage() {
const [posts, setPosts] = useState([]);
fetch('https://api.example.com/posts')
.then(response => response.json())
.then(data => {
console.log('데이터를 가져왔습니다!');
setPosts(data);
});
return (
<div>
<h1>게시글 목록</h1>
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
</div>
);
}
“언니, 이것 좀 봐봐. 금방 다 만들었어.”
솔라의 부름에 옆에서 책을 읽던 루나가 다가와 화면을 들여다봤다. 코드는 짧고 군더더기 없어 보였다. 데이터를 가져오는 fetch 함수, 데이터를 저장할 posts 상태, 그리고 목록을 그려주는 JSX 코드.
“음, 직접적이고 간단하네. 데이터를 가져와서 상태를 바꾸는 코드구나.”
“그치? 함수가 실행되면 fetch로 데이터 가져오고, setPosts로 상태 업데이트하면 React가 알아서 화면을 다시 그려줄 테니까.”
솔라의 설명에는 막힘이 없었다. 모든 조각이 제자리에 있는 듯했다. 루나는 고개를 끄덕이며 솔라의 의자를 가리켰다.
“좋아. 그럼 어떻게 동작하는지 직접 실행해 볼까?”
솔라는 의기양양하게 개발 서버를 실행하고 브라우저 창을 열었다. 하얀 화면이 잠깐 나타났다가, 곧이어 ‘게시글 목록’이라는 제목이 떴다. 그런데 무언가 이상했다. 게시글 목록이 나타나야 할 자리는 계속 비어 있었고, 노트북 팬이 갑자기 굉음을 내며 돌기 시작했다. 마치 보이지 않는 무언가를 필사적으로 처리하고 있는 것처럼.
“어? 왜 이러지? 데이터가 안 오나?”
솔라가 당황하며 브라우저의 개발자 도구를 열었다. 콘솔 창을 확인한 순간, 솔라의 눈이 동그래졌다.
데이터를 가져왔습니다!
데이터를 가져왔습니다!
데이터를 가져왔습니다!
데이터를 가져왔습니다!
…
똑같은 로그가 쉴 새 없이 화면을 뒤덮고 있었다. 스크롤바가 순식간에 작아질 정도로 빠르게 메시지가 쌓였다. 마치 고장 난 수도꼭지에서 물이 쏟아져나오는 것 같았다. 솔라는 ‘Network’ 탭으로 급히 이동했다. 그곳의 상황은 더 심각했다. posts를 요청하는 API 호출이 1초에도 수십 번씩, 폭포수처럼 쏟아지고 있었다.
“이게… 뭐야? 왜 요청을 계속 보내는 거지?”
솔라는 완전히 혼란에 빠졌다. 자신의 논리대로라면 fetch는 한 번만 실행되어야 했다. 루나는 마우스 커서를 솔라의 코드 위로 가져갔다.
“솔라, 이 컴포넌트가 처음 화면에 그려질 때 어떤 일이 일어나는지 순서대로 말해볼래?”
“음… PostListPage 함수가 호출되고, useState로 posts 상태가 만들어져. 그리고…”
“그리고?”
“fetch 함수가 실행돼서 서버에 데이터를 요청하지.”
“맞아. 그 다음에 데이터가 성공적으로 도착하면?”
루나가 .then 블록 안의 setPosts(data) 부분을 가리켰다.
“setPosts가 호출돼서 posts 상태가 새로운 데이터로 바뀌어.”
솔라는 당연하다는 듯 대답했다. 루나는 아무 말 없이 다음 단계를 기다렸다. 바로 그 순간, 솔라의 머릿속에서 무언가 연결되기 시작했다.
“setPosts가 호출되면… 상태가 바뀌니까… React가…”
솔라의 목소리가 점점 작아졌다. 루나가 조용히 다음 말을 거들었다.
“React가 컴포넌트를 다시 그린다고 했지? 무엇을 위해서?”
“변경된 상태를 화면에 반영하기 위해서…”
솔라는 말을 잇지 못하고 화면과 자신의 코드를 번갈아 쳐다봤다. 잠시 정적이 흘렀다. 화면에서는 여전히 fetch 요청이 폭주하고 있었다.
“…아.”
마침내 솔라의 입에서 짧은 탄식이 터져 나왔다.
“컴포넌트를 다시 그린다는 건… PostListPage 함수를 처음부터 다시 실행한다는 거잖아! 그럼 또 fetch를 호출하고, 또 setPosts를 호출하고, 그래서 또다시 렌더링하고… 계속, 계속!”
솔라는 방금 자신이 발견한 끝없는 순환의 고리를 믿을 수 없다는 듯 중얼거렸다. 렌더링이 fetch를 부르고, fetch가 상태 변경을 부르고, 상태 변경이 다시 렌더링을 부르는 덫. 자신의 코드가 스스로 만든 무한 루프에 갇혀버린 것이었다.
그제야 솔라는 컴포넌트의 ‘몸통’ 부분에 코드를 두는 것의 의미를 깨달았다. 그곳은 렌더링될 때마다 무조건 실행되는 영역이었다. 상태를 바꾸는 코드를 그곳에 두는 것은, 달리는 기차의 기관사에게 ‘도착하면 즉시 출발하라’는 명령을 내리는 것과 같았다.
“정확히 봤어. 상태를 바꾸는 로직이 렌더링 자체에 포함되어 있으면, 스스로를 계속해서 호출하는 순환을 만들게 돼.”
루나의 차분한 목소리가 솔라의 발견을 확증해주었다. 이제 솔라는 더 이상 ‘서버 호출은 그냥 함수 본문에 넣으면 된다’고 생각하지 않았다. 그것이 얼마나 위험한 함정인지 온몸으로 느꼈다. 팬 소음, 폭주하는 네트워크 요청, 끝없이 찍히는 로그. 이 모든 것이 ‘리렌더-루프’라는 괴물의 증거였다.
솔라는 소란스러운 팬 소리가 멎은 노트북을 보며 말했다.
“알겠어, 언니. 여기는 절대 아니네. 그럼… 이 fetch는 대체 어디에 둬야 하는 거야? 딱 한 번만, 컴포넌트가 처음 나타날 때만 실행하고 싶은데.”
2장: useEffect: 렌더링 이후 ‘단 한 번만’ 실행되는 약속
솔라는 무한 루프의 원인이었던 자신의 코드를 멍하니 바라보고 있었다. 컴포넌트 본문, 즉 렌더링의 심장부에 상태를 바꾸는 코드를 넣는 것이 어떤 재앙을 불러오는지 똑똑히 목격했다. fetch는 분명 필요하지만, 그 자리가 아님은 확실했다. 하지만 어디로 옮겨야 할지 막막했다.
그때 루나가 조용히 키보드를 가져갔다. 솔라의 코드 맨 윗줄에 import React, { useState } from 'react'; 옆으로 무언가를 더 타이핑했다.
import React, { useState, useEffect } from 'react';
그리고는 솔라가 만든 fetch 블록 전체를 새로운 구조 안으로 옮겼다. 마치 위험 물질을 안전한 격리 상자에 넣는 것처럼. 솔라가 작성했던 코드는 그대로였지만, 이제는 useEffect라는 이름의 함수와 낯선 대괄호 [] 한 쌍이 그 코드를 감싸고 있었다.
import React, { useState, useEffect } from 'react';
function PostListPage() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('https://api.example.com/posts')
.then(response => response.json())
.then(data => {
console.log('데이터를 가져왔습니다!');
setPosts(data);
});
}, []); // <--- 이 부분이 추가되었다
return (
<div>
{/* ... JSX ... */}
</div>
);
}
솔라는 화면의 변화를 눈으로 좇았다. 코드가 옮겨졌을 뿐, 바뀐 건 거의 없었다.
“useEffect? 그냥 함수로 한 번 감싼 거 아니야? 그리고 이 빈 괄호는 뭐야? 이게 무슨 차이를 만드는데?”
솔라의 목소리에는 의구심이 가득했다. 그저 코드를 다른 곳으로 옮긴다고 해서 폭주하던 네트워크 요청이 멈출 거라고는 믿기 어려웠다. 컴포넌트 함수 안쪽에 있는 건 매한가지 아닌가.
루나는 코드를 가리키며 비유를 들었다.
“컴포넌트 함수 본문은 ‘무엇을 그릴지’ 계산하고 준비하는 설계실이야. 화면이 바뀔 때마다 설계도는 처음부터 다시 검토되지. 우리가 아까 겪었던 문제처럼 말이야.”
루나는 잠시 말을 멈추고 useEffect 블록을 가리켰다.
“그리고 이건 ‘설계가 끝나고 건물이 다 지어진 후에 할 일’을 적어두는 별도의 작업 지시서 같은 거야. 설계도를 그릴 때마다 작업 지시를 내리는 게 아니라, 건물이 딱 세워진 그 순간에만 실행하라는 약속이지.”
작업 지시서. 설계실 밖의 일. 솔라는 그 비유를 곱씹어보았다. 렌더링 그 자체와는 분리된 어떤 작업이라는 뉘앙스가 느껴졌다.
“그럼… 이 useEffect 안에 있으면 렌더링 될 때마다 실행되지 않는다는 거야?”
“직접 확인해 볼까? 아까와 똑같이 개발자 도구를 열고 실행해 봐.”
솔라는 반신반의하며 브라우저를 새로고침했다. 노트북 팬은 조용했다. 잠시 후 ‘게시글 목록’ 제목 아래로 데이터가 화면에 나타났다. 솔라는 떨리는 마음으로 개발자 도구의 콘솔 창을 확인했다.
데이터를 가져왔습니다!
단 한 줄. 아까처럼 화면을 뒤덮던 로그는 온데간데없었다. 솔라는 서둘러 ‘Network’ 탭으로 이동했다. posts API 요청 기록 역시 단 한 개만 깔끔하게 찍혀 있었다. 폭풍우가 지나간 뒤의 고요함 같았다.
“됐어! 딱 한 번만 실행됐어! 어떻게 이게 가능해? useEffect 때문이야? 아니면 저 이상한 대괄호 [] 때문이야?”
솔라는 흥분을 감추지 못하고 물었다. 루나는 마우스 커서로 코드 맨 마지막에 추가된 빈 대괄호, []를 가리켰다.
“바로 저 빈 대괄호가 ‘약속의 조건’이야. React에서는 ‘의존성 배열(dependency array)’이라고 불러. useEffect는 이 배열 안에 들어있는 값이 바뀔 때만 내부의 코드를 다시 실행하거든.”
루나는 솔라가 이해할 수 있도록 말을 이었다.
“그런데 지금 이 배열은 텅 비어있지? 그건 ‘이 작업은 어떤 것에도 의존하지 않으니, 컴포넌트가 처음 화면에 나타난 직후 딱 한 번만 실행하고, 그 후에는 다시는 실행하지 말아 주세요’라는 특별한 약속인 셈이야.”
그제야 솔라의 머릿속에 흩어져 있던 조각들이 맞춰졌다. useEffect는 렌더링의 주된 흐름에 영향을 주지 않는 ‘부수 효과(Side Effect)’를 위한 격리 공간이었다. 그리고 의존성 배열은 그 부수 효과를 언제 다시 실행할지 결정하는 정교한 제어 스위치였다. 빈 배열은 ‘최초 1회 실행’이라는 가장 단순하고 강력한 규칙이었던 것이다.
컴포넌트의 몸통은 렌더링을 위한 순수한 계산만 담당하게 하고, 외부 세계와 통신하는 위험한 작업은 useEffect라는 안전한 울타리 안에서, 그것도 []라는 조건으로 단 한 번만 실행되도록 제어한다. 이로써 무한 루프의 고리는 완벽하게 끊어졌다.
“아…! 알겠다. useEffect는 렌더링 흐름 자체에서 외부 작업을 분리하는 거구나. 그리고 저 대괄호가 그 작업의 실행 타이밍을 제어하는 스위치인 거고! 비어있으면 ‘한 번만’ 스위치.”
솔라는 이제 코드를 완전히 새로운 눈으로 볼 수 있었다. useEffect는 단순한 포장지가 아니라, 렌더링과 부수 효과를 분리하여 다루는 리액트의 핵심적인 ‘부수 효과 제어기’였다.
문제가 해결되었다는 안도감에 솔라는 기쁘게 페이지를 몇 번 더 새로고침 해보았다. 데이터는 언제나처럼 잘 나타났다. 그런데 아주 짧은 순간, 데이터가 도착하기 전까지 게시글 목록 영역이 텅 비어있는 것이 눈에 들어왔다.
“언니, 그런데 데이터가 오기 전까지 잠깐 목록이 비어 있네. 사용자가 보면 이게 로딩 중인 건지, 아니면 원래 비어있는 페이지인지 헷갈릴 것 같아. 만약 인터넷이 느리면 꽤 오랫동안 빈 화면만 보일 텐데….”
3장: 로딩 중, 에러 발생 시, 성공 시: 친절한 UI를 위한 3가지 상태
무한 루프의 함정을 피하고 나서야 솔라는 다시 자신의 결과물을 차분히 뜯어볼 수 있었다. useEffect 덕분에 데이터는 정확히 한 번만 잘 불러와졌다. 하지만 솔라의 시선은 데이터가 채워지기 전, 그 짧은 찰나의 공백에 머물러 있었다. 인터넷이 빠른 지금도 잠시나마 보이는 저 빈 화면. 만약 사용자의 네트워크가 느리다면, 이 공백은 끝없는 기다림처럼 느껴질 것이다.
“이 빈 화면, 역시 마음에 걸려.”
솔라의 중얼거림에 루나가 다가왔다. 루나는 아무 말 없이 브라우저의 개발자 도구를 열고 ‘Network’ 탭으로 갔다. 그리고 ‘Throttling’ 드롭다운 메뉴에서 ‘Slow 3G’ 옵션을 선택했다.
“다시 한번 새로고침 해볼래?”
솔라가 고개를 갸웃거리며 페이지를 새로고침했다. 그러자 방금 전까지는 순식간에 지나갔던 빈 화면이 이제는 하얀 민낯을 뻔뻔하게 드러낸 채 몇 초 동안이나 계속되었다. ‘게시글 목록’이라는 제목만 덩그러니 있을 뿐, 그 아래는 아무것도 없었다. 이게 로딩 중인지, 에러가 난 건지, 아니면 원래 아무 내용이 없는 페이지인지 도무지 알 길이 없었다. 답답한 정적이었다.
“아… 이건 그냥 좀 불편한 수준이 아니네. 사용자는 앱이 고장 났다고 생각하고 그냥 나가버릴 거야.”
“맞아. 우리는 지금 데이터가 올 거라는 걸 알지만, 사용자는 모르니까. 그럼 요청이 실패했을 땐 어떨까?”
루나는 fetch 함수의 URL 주소에서 철자 하나를 슬쩍 지웠다. posts를 post로 바꾼 것이다. 당연히 존재하지 않는 주소였다. 솔라가 다시 새로고침하자, 이번에는 ‘Slow 3G’가 아니었음에도 불구하고 화면은 영원히 하얗게 비어 있었다. 콘솔 창에만 조용히 붉은색 에러 메시지가 찍혔을 뿐, 사용자는 아무런 피드백도 받지 못했다.
“최악이네. 데이터가 없는 건지, 로딩이 영원히 안 끝나는 건지, 에러가 난 건지… 아무것도 알 수가 없어.”
솔라는 이제 빈 화면만으로는 절대 충분하지 않다는 사실을 깨달았다. 사용자는 지금 무슨 일이 일어나고 있는지 알 권리가 있었다.
“우리의 컴포넌트는 지금 딱 두 가지 상태만 알아. ‘게시글이 없는 상태’와 ‘게시글이 있는 상태’.”
루나는 useState([]) 코드를 가리키며 말했다.
“하지만 방금 우리가 겪어보니, 적어도 두 가지 상태가 더 필요하지 않을까? 데이터를 ‘불러오는 중인 상태’, 그리고 ‘불러오기 실패한 상태’.”
루나는 useState를 두 줄 더 추가했다.
import React, { useState, useEffect } from 'react';
function PostListPage() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
// ... fetch 로직 ...
}, []);
// ... JSX ...
}
“상태를 세 개나 쓴다고? posts 하나면 충분한 거 아니었어?”
솔라는 코드가 복잡해지는 것 같아 약간 미심쩍었다. 루나는 useEffect 안으로 커서를 옮겼다.
“이 fetch 작업의 여정을 따라가면서 상태를 표지판처럼 세워주면 돼. 여행 시작 전에는 ‘출발 준비 중’이라고 알려주고, 무사히 도착하면 ‘도착 완료’, 길을 잃으면 ‘길을 잃었다’고 알려주는 거야.”
루나의 손가락을 따라 코드가 바뀌기 시작했다.
fetch가 시작되기 직전, ‘로딩 중’이라는 표지판을 세운다.- 데이터를 성공적으로 받으면, ‘로딩 끝’ 표지판과 함께 데이터를 채운다.
- 만약 에러가 발생하면,
catch블록에서 ‘에러 발생’ 표지판을 세우고 ‘로딩 끝’ 표지판도 세운다.
useEffect(() => {
setLoading(true); // 1. 로딩 시작
setError(null); // 혹시 모를 이전 에러 초기화
fetch('https://api.example.com/posts') // 여기를 다시 틀린 주소로 바꿔서 시험해볼 거야
.then(response => {
if (!response.ok) {
throw new Error('데이터를 불러오는 데 실패했습니다.');
}
return response.json();
})
.then(data => {
setPosts(data); // 2. 성공 시 데이터 저장
setLoading(false); // 로딩 끝
})
.catch(error => {
setError(error); // 3. 실패 시 에러 저장
setLoading(false); // 로딩 끝
});
}, []);
이제 남은 것은 이 표지판들을 보고 화면이 스스로 모습을 바꾸게 하는 일이었다. 루나는 컴포넌트의 return 부분을 수정했다.
if (loading) {
return <div>로딩 중입니다...</div>;
}
if (error) {
return <div>에러가 발생했습니다: {error.message}</div>;
}
return (
<div>
<h1>게시글 목록</h1>
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
</div>
);
세 갈래의 길. 코드는 이제 loading 상태일 때, error 상태일 때, 그리고 모든 게 성공했을 때 각각 다른 UI를 보여주도록 분기되었다.
먼저, 루나는 다시 API 주소를 틀리게 바꾼 채로 솔라에게 실행해보라고 했다. 솔라가 새로고침하자, 화면에는 더 이상 침묵의 빈 화면 대신 명확한 메시지가 나타났다.
에러가 발생했습니다: 데이터를 불러오는 데 실패했습니다.
“오! 콘솔을 보지 않아도 바로 알 수 있어!”
다음으로, 루나는 주소를 바로잡고 네트워크 속도를 다시 ‘Slow 3G’로 설정했다. 솔라가 새로고침하자, 답답했던 하얀 공백 대신 친절한 안내 문구가 화면을 채웠다.
로딩 중입니다...
몇 초 후, 로딩 메시지는 사라지고 게시글 목록이 그 자리에 나타났다. 성공, 로딩 중, 실패. 비동기 작업의 모든 가능한 시나리오가 사용자에게 명확하게 전달되었다.
솔라는 비로소 깨달았다. 서버에서 데이터를 가져오는 작업은 단순히 ‘데이터가 있다/없다’의 이분법적인 문제가 아니었다. 그것은 시작과 끝이 있고, 성공과 실패의 가능성이 있는 하나의 작은 ‘과정’이었다. 그 과정을 사용자에게 투명하게 보여주는 것이 얼마나 중요한지를.
“알겠다. posts는 ‘결과물’을 담는 상태고, loading과 error는 ‘과정’을 보여주는 상태구나. 이 세 가지가 한 세트였어.”
솔라는 자신이 만든 코드를 자랑스럽게 바라봤다. 이제는 어떤 네트워크 환경에서도, 어떤 예기치 못한 서버 문제 앞에서도 사용자에게 최소한의 예의를 지킬 수 있는 컴포넌트가 되었다. 이것이 바로 비동기 작업의 상태를 분배하여 각기 다른 UI를 보여주는 ‘비동기 UI 상태 분배기’의 역할이었다.
솔라는 문득 자신의 다른 프로젝트를 떠올렸다. 사용자 프로필을 보여주는 페이지. 그곳에도 분명히 서버에서 데이터를 가져오는 로직이 있었다. 솔라는 키보드에 손을 얹고, 마치 새로운 부품을 조립하듯 머릿속으로 코드를 그려나갔다.
const [user, setUser] = useState(null);
const [isLoadingProfile, setLoadingProfile] = useState(true);
const [profileError, setProfileError] = useState(null);
그녀는 더 이상 useEffect 안에 fetch를 넣는 것을 망설이지 않았다. 이제는 그 안에서 어떤 깃발을 들고, 어떤 표지판을 세워야 하는지 완벽하게 알고 있었다. 로딩 중, 에러, 그리고 마침내 환하게 빛날 성공의 화면까지. 모든 길이 그녀의 손안에 있었다.