0. 배경 설명
제목과 관련된 이야기를 시작하기 전에 간단하게 배경 설명을 해야겠다.
거의 마무리 단계에 있는 프로젝트인 '잼시네마(영화 정보 사이트)'의 뉴스 탭을 개발 중에
내가 서버 측 코드 newsRouter.ts 에 만들어놓은 두 가지 API가 있었다.
첫 번째 API는 fetch-and-store 이라는 이름(주소?)의 API이다.
외부 API인 딥서치 뉴스 API를 사용하여 우리가 원하는 영화라는 키워드와 관련된 뉴스 기사들의 정보를 받아오고
그 데이터들을 정제하여 우리 프로젝트의 DB에 저장하는 API로 커스텀 한 것이다.
두 번째 API는 list라고 이름 지은 API이다.
이 API의 역할은 DB에 저장된 뉴스 데이터들을 날짜 내리참순으로 가져와서 프론트(클라이언트) 측으로 보내준다.
(해당 코드 참고)
newsRouter.get('/fetch-and-store', async (req, res) => {
const url = `https://api-v2.deepsearch.com/v1/articles?keyword=영화&page_size=30&api_key=${news_api_key}`;
try {
const response = await fetch(`${url}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`Error: ${response.status}`);
}
const data = await response.json();
const articles = data.data; // 뉴스 데이터 배열
if (!articles || articles.length === 0) {
return res.status(400).json({ error: 'No articles found' });
}
// 중복 체크를 위한 ID 리스트 가져오기
const existingIds = new Set();
const existingRows = await new Promise<any[]>((resolve, reject) => {
db.query('SELECT id FROM news', [], (err, result) => {
if (err) reject(err);
resolve(result);
});
});
existingRows.forEach((row) => existingIds.add(row.id));
// 새로운 뉴스만 삽입
const insertPromises = articles
.filter((article: any) => !existingIds.has(article.id)) // 중복 제거
.map((article: any) => {
return new Promise<void>((resolve, reject) => {
const sql = `
INSERT INTO news (id, title, summary, content_url, image_url, thumbnail_url, published_at, publisher)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
db.query(
sql,
[
article.id,
article.title,
article.summary,
article.content_url,
article.image_url,
article.thumbnail_url,
article.published_at,
article.publisher,
],
(err) => {
if (err) reject(err);
resolve();
}
);
});
});
await Promise.all(insertPromises); // 모든 삽입 작업 완료
res.json({
message: `${insertPromises.length} articles stored successfully`,
});
} catch (error) {
console.error('Error fetching or storing data:', error);
res.status(500).json({ error: 'Failed to fetch and store news' });
}
});
// 2. DB에서 뉴스 리스트 가져오기
newsRouter.get('/list', (req, res) => {
db.query(
'SELECT * FROM news ORDER BY published_at DESC',
[],
(err, result) => {
if (err) {
res.status(500).json('뉴스 리스트 불러오기 실패');
throw err;
} else {
res.json(result);
}
}
);
});
이제 프론트 단에서 이 API를 호출하여 뉴스 탭을 클릭했을 때 페이지 이동과 함께 화면에 각 뉴스 기사들을 무한 스크롤
방식으로 주루룩 나열하려고 했다. (무한 스크롤 방식에 대해서는 다음 포스트에서 다루어 보겠다.)
처음에는 단순히 코드의 작동만을 목표로 생각 없이 코딩했던지라 아래와 같이 useEffect를 2번 사용하여
fetch-and-store 한 번, list 한 번 작동을 시켰다.
interface INewsData {
id: string;
publisher: string;
published_at: string;
title: string;
summary: string;
image_url: string;
content_url: string;
thumbnail_url: string;
}
const [newsData, setNewsData] = useState<INewsData[]>([]);
useEffect(() => {
axios
.get('http://localhost:8001/news/fetch-and-store')
.then((res) => {
console.log(res);
})
.catch((err) => console.log(err));
}, []);
useEffect(() => {
axios
.get('http://localhost:8001/news/list')
.then((res) => {
console.log(res);
setNewsData(res.data);
})
.catch((err) => console.log(err));
}, []);
1. 문제 상황
당연히 작동이 제대로 될 리가 없었고 프론트단에서 state 변수로 만들어놓았던 newsData 배열이 undefined인 채로
map 함수가 작동을 하니 오류가 발생했다. 이 오류는 초기 값을 [] 와 같이 빈 배열로 만들어줌으로써 해결은 하였지만
뉴스 기사가 DP 되지 않는 것은 마찬가지였다.
일단 전체적인 틀을 잡고 러프하게 코드를 짜고 오류나 문제를 하나씩 해결해가며 개발을 하는 스타일이기에
이 글을 보는 분들은 나를 좀 바보 같다고 생각하실 거 같다.
콘솔을 찍어보니 두 API 자체는 동작이 정상적으로 이루어졌다.
fetch-and-store이 작동하여 딥서치 API에서 영화 관련 뉴스를 잘 가져와 DB(아래는 본 프로젝트 DB의 news 테이블과 저장된 예시 데이터들이다)에 잘 저장을 하였고
list 또한 state변수 newsData에 저장이 잘 되었다.
1-1. 문제 원인
근데 왜 빈 배열로 콘솔에 찍히고 map함수도 제대로 동작하지 않았을까?
답은 간단했다. 비동기 제어 처리를 해주지 않았기 때문에 내 의도와 달리 순차 실행이 되지 않았기 때문이다.
여기서 내가 하고자 했던 의도의 순차 실행은
1. 먼저 fetch-and-store가 실행되어 DB에 뉴스 데이터들을 저장함
2. 그 다음 list가 실행되어 DB에 있는 뉴스 데이터들을 가져와 state변수(배열) newsData에 저장함
3. 그 다음 그 newsData를 map 함수를 이용해 화면에 나열함
이다.
그렇지만 코드는 비동기로 짜놓고 따로 제어 처리를 해주지 않았기 때문에
fetch-and-store가 끝나기도 전에 list 요청이 실행되어 DB에 아직 뉴스 데이터가 저장되지 않은 상태에서
list 요청이 실행되므로 빈 배열 반환되어 결국 화면에는 뉴스 기사가 보이지 않은 것이다.
그러면 해결방법으로는 동기처리를 제대로 해주던지
아니면 비동기의 제어 처리를 함으로써 실행 순서를 컨트롤해야 한다.
1-2. 1차 문제 해결(의존성 배열)
그래서 일단 API 실행 코드를 useEffect 내에 작성했기에 의존성 배열을 이용하여 제어 처리를 했다.
check라는 state 변수를 만들고 초기값을 0으로 지정했다.
첫 번째 fetch-and-store가 포함되어 있는 useEffect가 동작할 때 check 변수를 1로 변경하고
두 번째 list 주관하는 useEffect의 의존성 배열에 check를 넣음으로써 fetch-and-store가 동작한 후
list가 동작하여 통신할 수 있도록 했다.
코드는 아래와 같다.
const [newsData, setNewsData] = useState<INewsData[]>([]);
const [check, setCheck] = useState(0); // 1️⃣ 비동기 제어처리를 위한 의존성 state변수 check
useEffect(() => {
axios
.get('http://localhost:8001/news/fetch-and-store')
.then((res) => {
console.log(res);
setCheck(1); // 2️⃣ fetch-and-store가 정상적으로 통신 되었다면 check 값을 변경함
})
.catch((err) => console.log(err));
}, []);
useEffect(() => {
axios
.get('http://localhost:8001/news/list')
.then((res) => {
console.log(res);
setNewsData(res.data);
})
.catch((err) => console.log(err));
}, [check]); // ✅ 의존서 배열 부분에 check state 변수를 넣어줌으로써 check값에 변동이
// 생겼을 경우에만(이 경우는 fetch-and-store가 정상 작동 된 후)
// list 가 통신되도록 함
그 결과, 원했던 대로 뉴스 페이지가 기능을 수행했다.
하지만 이는 결과만 정상적으로 되었을 뿐 과정은 야매로 한 것이었기에 다른 제어처리 방법을 알아보기로 했다.
2. 2차 문제 해결(async/await)
서치 결과 크게 3가지 방법으로 나누어 볼 수 있겠다
1. 콜백(Callback) 함수 기법
2. 프로미스(Promise), then. 체이닝 기법
3. async/await ✅
이렇게 3가지로 나눠볼 수 있겠다.
이 3가지 중 이번 포스트에서는 내가 사용한 async/await 을 다뤄보도록 하겠다.
다른 2가지 방법은 기회가 된다면 나중에 포스팅 해야겠다.
일단 변경된 코드는 다음과 같다.
const [newsData, setNewsData] = useState<INewsData[]>([]);
useEffect(() => {
const fetchNews = async () => {
try {
// 1️⃣ 뉴스 데이터 가져와서 DB 저장
await axios.get('http://localhost:8001/news/fetch-and-store');
// 2️⃣ 저장된 뉴스 데이터를 다시 가져오기
const response = await axios.get('http://localhost:8001/news/list');
console.log(response);
setNewsData(response.data);
} catch (error) {
console.error('Error fetching news:', error);
}
};
fetchNews();
}, []); // ✅ 의존성 배열이 빈 배열이므로 컴포넌트 마운트 시 한 번만 실행
코드를 하나하나 분석하며 해설을 하겠습니다.
1. const [newsData, setNewsData] = useState<INewsData[]>([]);
✅ useState를 사용하여 newsData라는 상태(state)를 선언.
✅ 기본값은 빈 배열 [], 즉 뉴스 데이터가 들어오기 전 상태.
✅ setNewsData로 newsData 상태 업데이트 가능.
2. useEffect 내부 코드
✅ useEffect는 컴포넌트가 처음 렌더링 될 때 한 번 실행됨.
✅ 비동기 함수를 fetchNews로 정의한 후, 즉시 실행.
코드 실행 흐름을 살펴보면
첫 번째로,
이 뉴스 페이지 리스트 컴포넌트가 마운트 되며 useEffect가 실행되고
그 안에 있던 fetchNews가 실행됩니다.
두 번째로,
뉴스데이터를 가져와서 DB에 저장합니다.
1️⃣ await axios.get('http://localhost:8001/news/fetch-and-store');
- 백엔드 API /news/fetch-and-store를 호출해서 외부 뉴스 API에서 데이터를 가져와 DB에 저장해.
- await를 사용했기 때문에 이 작업이 끝날 때까지 기다린 후 다음 단계로 넘어감.
세 번째로,
DB에 저장된 뉴스 데이터를 다시 가져옵니다.
2️⃣ const response = await axios.get('http://localhost:8001/news/list');
- 뉴스 데이터를 저장한 후, news/list API를 호출하여 DB에 저장된 최신 뉴스 리스트를 가져옴.
- await로 요청이 완료될 때까지 기다린 후 response.data를 받아옴.
마지막 네 번째로,
- setNewsData를 호출해서 newsData 상태를 업데이트.
- 이로 인해 컴포넌트가 리렌더링되면서 최신 뉴스 데이터가 화면에 반영됨.
3. 궁금한 점
이러고 난 후 의문이 하나 들었다.
fetchNews 함수를 굳이 만들 필요 없이 그냥 async를 바로 선언하면 안 될까 하는 것이었다.
구글링을 통해 알아본 결과 이유는 이러했다.
useEffect는 콜백함수가 아무것도 반환하지 않거나 clean-up function을 반환해야 한다.
그런데 콜백함수 내에서 async/await 구문을 직접적으로 사용하면 자동으로 함수를 비동기 함수로 전환하고
Promise를 반환하기 때문에 useEffect hook의 동작과 충돌하여 예기치 않은 문제가 발생할 수 있기에
내부에 별도의 함수, 여기선 fetchNews를 정의하고 즉시 호출하는 방법을 사용한다고 합니다.
4. 결론
결론을 맺고자 합니다.
여러 방식 중에서 async/await 를 채택한 이유는 다음과 같습니다.
💡 이 방식을 채택한 이유(장점)
- 불필요한 상태(check) 제거 → 더 직관적이고 간결한 코드
- 첫 번째 요청이 끝난 후에 두 번째 요청 실행 (await 사용)
- 에러 처리 개선 → try/catch로 오류 발생 시 로그 출력
이러한 장점들을 가지고 있기에 위에서 말했던 방법 중
이러한 비동기 흐름을 보장하는 제어 처리를 한 것입니다.
✅ 비동기 흐름을 보장해야 하는 이유
- fetch-and-store: 외부 API에서 데이터를 받아와 DB에 저장하는 작업 → 시간이 걸릴 수 있음.
- list: DB에서 뉴스 데이터를 불러오는 작업 → ①이 끝나기 전에 실행되면, 데이터가 없을 가능성이 큼.
그래서 await을 사용하거나, then을 이용해서 첫 번째 요청이 끝난 후 두 번째 요청이 실행되도록 만들어야 함.
+ 추가로 이러한 API 요청과 통신 함수들은 시간이 꽤나 걸릴 수 있기 때문에 로딩 상태를 추가해서 UI에 반영해주면 사용자 경험이 더 좋아지는 효과를 얻을 수 있다고 합니다.
+ 또 에러 핸들링 측면에서 axios.get 요청이 실패했을 때 사용자에게 메시지를 표시해주면 UX 개선이 이루어질 것입니다.
+ 그리고 저는 이 뉴스 리스트 페이지에서 각 뉴스 기사들을 무한 스크롤 방식으로 보여줄 것이기에 여기에 추가적인 코드 추가를 하였습니다.
'React' 카테고리의 다른 글
[JavaScript]💡3. 비동기 처리의 핵심, Promise와 async/await (0) | 2025.04.02 |
---|---|
[React]📘2. Virtual DOM(가상 돔)이란? (0) | 2025.04.02 |
[React] 💻 모바일 웹 반응형 디자인 하기 (4) | 2024.12.28 |
[React] 🛠 CORS 오류를 해결하자 (SSL? HTTPS? HTTP?) (0) | 2024.12.28 |
[React] 🚀GET과 POST의 차이점, 언제 어떤 걸 써야 할까? (0) | 2024.12.28 |