반디북
자바스크립트 + 리액트 디자인 패턴
Ch9
도은

비동기 프로그래밍 패턴에 대해 알아보자.

비동기 프로그래밍

  • 동기 코드는 순서대로 한 문장씩 실행
  • 비동기 코드는 논블로킹 방식으로 실행
    • 기다리지 않고 백그라운드에서 해당 비동기 코드를 실행
  • 비동기(async, await), 프로미스(promise)와 같은 자바스크립트 언어의 기능은 비동기 코드를 더 쉽게 작성할 수 있도록 해준다.
    • 마치 동기 흐름처럼 작성할 수 있다.

🤔 비동기 코드를 동기 흐름처럼 작성하면 뭐가 좋을까?

  • 우선, 비동기 코드를 동기 흐름처럼 작성하지 않는다고 한다면 콜백으로 처리한다는 것을 생각할 수 있다.

👇 콜백으로 비동기 처리

function getData(callback, errorCallback) {
  setTimeout(() => {
    try {
      // 실제 코드
      const data = '데이터 도착!';
      // 예외 상황이 없으면 정상 콜백 호출
      callback(data);
    } catch (error) {
      // 에러가 발생하면 에러 콜백 호출
      errorCallback(error);
    }
  }, 1000);
}
 
// 사용 예
getData(
  (result) => {
    console.log('✅ 성공:', result);
  },
  (error) => {
    console.error('❌ 에러 발생:', error.message);
  },
);

👇 위 코드를 async/await. Promise로 처리한다면

// 1️⃣ Promise
function getData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      try {
        // 예외 없이 정상 처리
        const data = '데이터 도착!';
        resolve(data);
 
        // 만약 예외를 발생시키고 싶다면 아래 주석을 풀어봐
        // throw new Error('데이터를 가져오는데 실패했어요!');
      } catch (error) {
        reject(error);
      }
    }, 1000);
  });
}
 
getData()
  .then((result) => {
    console.log('✅ 성공:', result);
  })
  .catch((error) => {
    console.error('❌ 에러 발생:', error.message);
  });
 
// 2️⃣ async/await
function getData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      try {
        const data = '데이터 도착!';
        resolve(data);
 
        // throw new Error('데이터를 가져오는데 실패했어요!');
      } catch (error) {
        reject(error);
      }
    }, 1000);
  });
}
 
async function run() {
  try {
    const result = await getData();
    console.log('✅ 성공:', result);
  } catch (error) {
    console.error('❌ 에러 발생:', error.message);
  }
}
 
run();

개인적인 생각..

  • Promise와 async/await 방식에 너무 익숙해져서 그런 건지 잘 모르겠지만
    • 콜백 함수 형태는 저게 비동기 처리를 위한 함수인지 파악하기 어려울 것 같다고 생각했다.
    • 네이밍이라도 getSyncData.. 이런 식으로 해줘야 그나마 알 수 있을 듯?
  • 반면 Promise, async/await 코드는 이 비동기 함수 작업이 끝나고 나서 할 작업임을 읽기 쉬웠다.
  • Promise, async/await를 이후 처리에 대해 뭐가 될지와는 무관하게
    • 성공했다면 resolve, 실패했다면 reject를 사용해주면 되니
    • 결과를 사용하는 쪽에서는 복잡한 내부 구현을 몰라도 일관된 방식으로 다룰 수 있어서 편하다.

프로미스 패턴

  • 프로미스는 비동기 작업의 결과를 나타내는 객체
    • 대기(pending), 완료(fulfilled), 거부(rejected)의 세 가지 상태를 가질 수 있다.

프로미스 체이닝

  • 여러 개의 프로미스를 함께 연결하여 복잡한 비동기 로직 다루는 것 가능
function makeRequest(url) {
  return new Promise((resolve, reject) => {
    fetch(url)
      .then((response) => response.json())
      .then((data) => resolve(data))
      .catch((error) => reject(error));
  });
}
 
function processData(data) {
  // process data
  return processedData;
}
 
makeRequest('https://example.com')
  .then((data) => processData(data))
  .then((processedData) => console.log(processedData))
  .catch((error) => console.error(error));

프로미스 병렬 처리

  • Promise.all 메서드를 사용하여 여러 프로미스를 동시에 실행
Promise.all([makeRequest('http://example.com/1'), makeRequest('http://example.com/2')]).then(([data1, data2]) => {
  console.log(data1, data2);
});

프로미스 순차 실행

  • Promise.resolve 메서드를 사용하여 프로미스를 순차적으로 실행
    • 중간에 실패하게 되면 catch문으로 바로 빠진다.
Promise.resolve()
  .then(() => makeRequest1())
  .then(() => makeRequest2())
  .then(() => makeRequest3())
  .then(() => {
    // 모든 요청 완료
  });

프로미스 메모이제이션

  • 캐시를 사용하여 프로미스 함수 호출의 결과 값을 저장한다.
  • 중복된 요청을 방지
const cache = new Map();
 
function memoizedMakeRequest(url) {
  if (cache.has(url)) {
    return cache.get(url);
  }
  return new Promise((resolve, reject) => {
    fetch(url)
      .then((response) => response.json())
      .then((data) => {
        cache.set(url, data);
        resolve(data);
      })
      .catch((error) => reject(error));
  });
}
  • 데이터를 갱신해야할 때가 있기도 할텐데
function memoizedMakeRequest(url, { forceRefresh = false } = {}) {
  if (!forceRefresh && cache.has(url)) {
    return Promise.resolve(cache.get(url));
  }
 
  return fetch(url)
    .then((response) => response.json())
    .then((data) => {
      cache.set(url, data);
      return data;
    });
}
 
// 캐시 사용
memoizedMakeRequest('/api/user');
 
// 강제로 다시 요청
memoizedMakeRequest('/api/user', { forceRefresh: true });

프로미스 파이프라인

  • 함수형 프로그래밍 기법을 활용하여 비동기 처리의 파이프라인을 생성
  • 중간 로직을 유닛 테스트 하기도 쉽고
  • 로운 변환 로직을 추가하거나 빼기에도 유연하다.
function transform1(data) {
  // 데이터 변환
  return transformedData;
}
 
function transform2(data) {
  // 데이터 변환
  return transformedData;
}
 
makeRequest('http://example.com')
  .then((data) => pipeline(data))
  .then(transform1)
  .then(transform2)
  .then((transformedData) => console.log(transformedData))
  .catch((error) => console.error(error));

프로미스 재시도

  • 실패했을 때 다시 시도
function makeRequestWithRetry(url) {
  let attempts = 0;
 
  const makeRequest = () => {
    return new Promise((resolve, reject) => {
      fetch(url)
        .then((response) => response.json())
        .then((data) => resolve(data))
        .catch((error) => reject(error));
    });
  };
 
  const retry = (error) => {
    attempts++;
    if (attempts >= 3) {
      throw new Error('Request failed after 3 attempts');
    }
    console.log(`Retrying request: attempt ${attempts}`);
    return makeRequest();
  };
 
  return makeRequest().catch(retry);
}

async/await 패턴

  • 프로미스를 기반으로 구축
  • 비동기 코드 작업을 보다 쉽고 간결하게 만들어준다.

비동기 함수 조합

  • 여러 비동기 함수를 조합
  • 순차적으로 실행되야 하는 경우에도 유용
async function makeRequest(url) {
  const response = await fetch(url);
  const data = await response.json();
  return data;
}
 
async function processData(data) {
  // 데이터 처리
  return processedData;
}
 
async function main() {
  const data = await makeRequest('http://example.com');
  const processedData = await processData(data);
  console.log(processedData);
}

비동기 반복

  • for-await-of 반복문을 사용하여 비동기 반복 가능 객체를 순회
async function* createAsyncIterable() {
  yield 1;
  yield 2;
  yield 3;
}
 
async function main() {
  for await (const value of createAsyncIterable()) {
    console.log(value);
  }
}

🤔 for-await-of 반복문?

  • 비동기 이터러블을 순회하기 위한 반복문
  • for of랑 비슷한데, 비동기 작업을 처리 가능

비동기 에러 처리

  • try-catch 블록을 사용하여 비동기 함수 실행 도중 발생할 수 있는 에러를 처리
async function main() {
  try {
    const data = await makeRequest('http://example.com');
    const processedData = await processData(data);
    console.log(processedData);
  } catch (error) {
    console.error(error);
  }
}
세민

1. 자바스크립트 환경

자바스크립트는 싱글 스레드 기반의 언어 이다. 브라우저와 Node.js 모두 단일 스레드에서 동작하기 때문에, 네트워크 요청, 파일 입출력, 대규모 데이터 처리 등 시간이 오래 걸리는 작업이 있을 때 이를 비동기적으로 처리하지 않으면 전체 애플리케이션이 멈춘다.

2. 콜백 패턴

2.1 콜백 함수

콜백 함수는 특정 작업이 끝난 뒤 호출되는 함수를 말한다.

setTimeout(function () {
  console.log('3초 후 실행!');
}, 3000);

2.2 콜백 지옥

콜백 안에서 또 다른 비동기 콜백을 부르면, 코드가 점점 “옆으로” 길어지고 가독성이 떨어진다.

login(user, function(err, info) {
  if (err) { ... }
  getProfile(info.id, function(err, profile) {
    if (err) { ... }
    updateUI(profile);
  });
});

문제점

  • 들여쓰기가 깊어진다.
  • 에러 핸들링이 중첩 구조로 반복된다.

3. Promise 패턴

3.1 Promise

Promise는 아직 끝나지 않은 비동기 작업을 감싸는 객체이다.

3가지 상태:

  • Pending(대기),
  • Fulfilled(성공),
  • Rejected(실패)
const promise = fetch('api/data');
promise.then(res => res.json()).catch(err => ...);

장점

  • then/catch로 직렬 처리와 에러 분리를 깔끔하게 할 수 있다.
  • 여러 비동기 작업을 “체인” 형태로 이어 쓸 수 있다.

3.2 Promise 체이닝

login(user)
  .then((info) => getProfile(info.id))
  .then((profile) => updateUI(profile))
  .catch((err) => handleError(err));
  • 들여쓰기가 유지되는 then 체이닝 지옥이 발생한다.
  • 에러 핸들링을 한 군데로 모일 수 있다.

3.3 ⭐ Promise.all / Promise.race

Promise.all

여러 비동기 작업을 병렬로 실행:

Promise.all([api1(), api2(), api3()])
  .then(([res1, res2, res3]) => { ... });
  • 여러 API를 병렬적으로 요청한다.

Promise.race

가장 먼저 종료(fulfilled 또는 rejected)되는 Promise의 결과(값 또는 에러)를 반환

function fetchWithTimeout(url, options = {}, timeout = 5000) {
  const fetchPromise = fetch(url, options);
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Request timed out')), timeout));
 
  return Promise.race([fetchPromise, timeoutPromise]);
}
fetchWithTimeout('https://jsonplaceholder.typicode.com/todos/1', {}, 2000)
  .then((res) => res.json())
  .then((data) => {
    console.log('성공:', data);
  })
  .catch((err) => {
    console.error('실패:', err.message); // 2초 안에 응답 없으면 'Request timed out'
  });

4. async/await 패턴

4.1 async/await

Promise 패턴은 좋지만 여전히 then/catch의 “계단식” 체이닝이 발생한다. async/await는 이러한 Promise 기반 비동기 코드를 동기 코드처럼 흐를 수 있게 해준다.

async function showProfile(user) {
  try {
    const info = await login(user);
    const profile = await getProfile(info.id);
    updateUI(profile);
  } catch (err) {
    handleError(err);
  }
}
  • 가독성 향상.
  • try/catch 문으로 에러도 동기 코드처럼 처리.

5. 비동기 제어 흐름 패턴

5.1 시퀀셜(순차) vs. 패러럴(병렬) 실행

비동기 작업은 서로 의존성이 있으면 순차로, 독립적이면 병렬로 처리하는 것이 효율적이다.

  • 순차 실행: for...of + await
  • 병렬 실행: Promise.all
for (const url of urls) {
  const response = await fetch(url);
}

(사실상 위 두 개 말고는 써본적이 없는.. 🤔)

6. 궁금한 점

8.2 Promise와 async/await 선택 기준

  • 코드가 짧고 한 번만 쓰이는 곳엔 Promise 체이닝이 더 가독성 높을 때도 있다.
  • 하지만 실제 비즈니스 로직, 특히 여러 단계의 API 요청과 조건 분기가 뒤섞이면 async/await가 훨씬 낫다고 생각함.

혹시 Promise랑 async/await를 혼용해서 쓰는지? 언제는 00 을 쓴다. 이런 기준이 있는지?