Contents
- 비동기 작업이란 무엇인가?
- Promise란
- Promise의 병렬 처리
- 마무리
Intro
지난번 LightHouse를 이용한 최적화 결과가 기대에 미치지 못했던 것을 계기로 블로그를 이루는 전반적인 코드를 살펴보았다.
그 과정에서, 메인 페이지의 가장 최근에 작성한 5개의 포스트를 가져오는 작업이 느려 렌더링이 느리다는 점을 발견했다.
해결책으로 이를 Promise.all 도입하여 여러 비동기 작업을 병렬로 처리했고, 이번 글에서는 Promise의 병렬 처리 기능에 대해 정리해보려한다.
1. 비동기 작업이란 무엇인가?
우선 동기와 비동기의 차이부터 이해해야한다.
1.1 동기식 처리 모델(Synchronous processing model)
동기식 처리 모델은 직렬적으로 태스크(task)를 수행한다.
즉, 태스크는 순차적으로 실행되며 어떤 작업이 수행 중이면 다음 태스크는 대기하게 된다.
예를 들어 서버에서 데이터를 가져와서 화면에 표시하는 태스크를 수행할 때, 서버에 데이터를 요청하고 데이터가 응답될 때까지 이후의 태스크들은 블로킹된다.
1.2 비동기식 처리 모델(Asynchronous processing model)
비동기식 처리 모델은 병렬적으로 태스크를 수행한다.
즉, 태스크가 종료되지 않은 상태라 하더라도 대기하지 않고 즉시 다음 태스크를 실행한다.
예를 들어 서버에서 데이터를 가져와서 화면에 표시하는 태스크를 수행할 때, 서버에 데이터를 요청한 이후 서버로부터 데이터가 응답될 때까지 대기하지 않고(Non-Blocking) 즉시 다음 태스크를 수행한다.
이후 서버로부터 데이터가 응답되면 이벤트가 발생하고 이벤트 핸들러가 데이터를 가지고 수행할 태스크를 계속해 수행한다.
자바스크립트의 대부분의 DOM 이벤트와 Timer 함수(setTimeout, setInterval), Ajax 요청은 비동기식 처리 모델로 동작한다.
비동기 작업을 사용하면 이러한 시간이 걸리는 작업들이 완료되기를 기다리는 동안 다른 코드를 실행할 수 있다는 장점이 있다.
따라서 전체적인 애플리케이션의 성능과 반응성을 향상시킬 수 있다.
2. Promise는 약속
무엇을 약속하느냐? 미래의 어떤 시점에 결과를 제공하겠다는 약속이다.
더 구체적으로 말하면 Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체다.
Promise를 사용하면 비동기 작업이지만, 동기 작업처럼 값을 반환할 수 있다.
다만 즉시 반환하는 것이 아니라, 미래의 어떤 시점에 결과를 제공하겠다는 '약속'을 반환한다.
Promise는 다음 중 하나의 상태를 가진다.
- 대기(Pending): 초기 상태, 이행(fulfilled) 또는 거부(rejected)되지 않은 상태
- 이행(fulfilled): 연산이 성공적으로 완료된 상태
- 거부(rejected): 연산이 실패한 상태
예를 들어 블로그 포스트를 가져오는 비동기 함수는 다음과 같이 작성할 수 있다.
function fetchPost(id) { return new Promise((resolve, reject) => { fetch(`/api/posts/${id}`) .then((response) => response.json()) .then((data) => resolve(data)) .catch((error) => reject(error)); }); }
3. 비동기 작업이 여러개라면?
여러 개의 비동기 작업을 동시에 처리해야 한다면, Promise의 병렬 처리 기능을 활용할 수 있다.
Javascript는 이를 위해 Promise.all(), Promise.race(), Promise.allSettled()을 제공한다.
3.1 Promise.all()
여러 개의 Promise를 병렬로 실행하고, 모든 Promise가 성공적으로 이행될 때까지 기다린다.
특징은 다음과 같다.
- 모든 Promise가 이행되면 결과값들을 배열로 반환
- 하나라도 거부되면 전체가 거부되며, 첫 번째로 거부된 Promise의 이유를 반환
- 입력된 모든 Promise가 이행될 때까지 기다리므로, 가장 오래 걸리는 작업만큼의 시간이 소요
3.2 Promise.race()
여러 Promise 중 가장 먼저 이행되거나 거부되는 Promise의 결과를 반환한다.
특징은 다음과 같다.
- 가장 빨리 완료되는 Promise의 결과만을 반환
- 이행이든 거부든 상관없이 가장 먼저 완료되는 Promise의 결과를 반환
- 시간 제한이 있는 작업이나 여러 소스에서 동일한 데이터를 가져오는 경우 유용
3.3 Promise.allSettled()
모든 Promise가 처리될 때까지 기다린 후, 각 Promise의 상태와 값(또는 거부 이유)을 담은 객체 배열을 반환한다.
특징은 다음과 같다.
- 모든 Promise의 최종 상태를 알 수 있다
- 일부 Promise가 거부되더라도 나머지 Promise의 결과를 얻을 수 있다
- 각 Promise의 성공 여부와 관계없이 모든 작업의 결과를 확인하고 싶을 때 유용
정리하자면 각 함수의 선택 요인은 다음과 같다.
- 모든 작업이 성공해야 하는 경우: Promise.all()
- 가장 빠른 결과만 필요한 경우: Promise.race()
- 모든 작업의 결과를 개별적으로 처리해야 하는 경우: Promise.allSettled()
4. 코드 개선
변경 전 코드
import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import MarkdownRenderer from '../../components/markdown-renderer/MarkdownRenderer'; import fetchRecentPostsTitles from '../../utils/FetchRecentPostsInfos.ts'; import usePostContext from '../../context/PostContext'; import styles from './Home.module.css'; const RECENT_POSTS_STANDARD = 5; interface PostTitle { title: string; } export default function Home() { const { totalPostsNumber } = usePostContext(); const [markdown, setMarkdown] = useState<string>(''); const [recentPostsTitles, setRecentPostsTitles] = useState<PostTitle[]>([]); useEffect(() => { fetch(`/markdowns/home/intro.md`) .then((response) => { if (!response.ok) { throw new Error('Failed to fetch markdown file'); } return response.text(); }) .then((text) => setMarkdown(text)); const markdownPaths: string[] = []; for (let i = 0; i < RECENT_POSTS_STANDARD; i++) { markdownPaths.push(`/markdowns/posts/${totalPostsNumber - i}.md`); } fetchRecentPostsTitles(markdownPaths).then((titles) => setRecentPostsTitles(titles.slice(0, RECENT_POSTS_STANDARD)) ); }, [totalPostsNumber]); return ( <div> <img src="/Symbol.svg" className={styles.symbol} /> <MarkdownRenderer markdown={markdown} /> <h1 className={styles.recentPostsTitle}>Recently Posted</h1> <ul className={styles.recentPostsList}> {recentPostsTitles.map(({ title }, index) => ( <li key={index} className={styles.recentPostItem}> <Link to={`/post/${totalPostsNumber - index}`}>{title || 'None'}</Link> </li> ))} </ul> </div> ); }
변경 후 코드
import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import MarkdownRenderer from '../../components/markdown-renderer/MarkdownRenderer'; import usePostContext from '../../context/PostContext'; import fetchRecentPostsTitles from '../../utils/FetchRecentPostsInfos.ts'; import styles from './Home.module.css'; const RECENT_POSTS_STANDARD = 5; interface PostTitle { title: string; } export default function Home() { const { totalPostsNumber } = usePostContext(); const [markdown, setMarkdown] = useState<string>(''); const [recentPostsTitles, setRecentPostsTitles] = useState<PostTitle[]>([]); useEffect(() => { const fetchData = async () => { try { // 네트워크 요청 병렬 처리 const markdownResponse = fetch(`/markdowns/home/intro.md`); const markdownPaths = Array.from({ length: RECENT_POSTS_STANDARD }, (_, i) => `/markdowns/posts/${totalPostsNumber - i}.md` ); const titlesPromise = fetchRecentPostsTitles(markdownPaths); const [markdownResult, titles] = await Promise.all([markdownResponse, titlesPromise]); if (!markdownResult.ok) throw new Error('Failed to fetch markdown file'); const text = await markdownResult.text(); setMarkdown(text); setRecentPostsTitles(titles.slice(0, RECENT_POSTS_STANDARD)); } catch (error) { console.error(error); } }; fetchData(); }, [totalPostsNumber]); return ( <div> <img src="/Symbol.svg" alt="Symbol" className={styles.symbol} /> <MarkdownRenderer markdown={markdown} /> <h1 className={styles.recentPostsTitle}>Recently Posted</h1> <ul className={styles.recentPostsList}> {recentPostsTitles.map(({ title }, index) => ( <li key={index} className={styles.recentPostItem}> <Link to={`/post/${totalPostsNumber - index}`}>{title || 'None'}</Link> </li> ))} </ul> </div> ); }
변경 전의 마크다운 파일 fetch와 최근 포스트 제목 fetch가 순차적으로 실행되는데 반해,
마크다운 파일 fetch와 최근 포스트 제목 fetch를 병렬로 처리하여 전체 로딩 시간을 단축했다.
이로 인해 웰컴 페이지의 전체 렌더링 시간이 크게 단축될 수 있었다.
5. 마무리
비동기 작업을 효율적으로 처리하기 위한 Promise의 병렬 처리 방법에 대해 살펴봤다.
앞으로는 Promise.race나 Promise.allSettled 같은 다른 메서드들도 프로젝트의 상황에 맞게 활용하여 더 나은 성능 최적화를 시도해 볼 계획이다.
비동기 작업을 효과적으로 처리하는 것은 성능뿐만 아니라 사용자 경험을 향상시키는 중요한 요소이니, 이를 고려한 프로그래밍을 고민해야겠다.