[Web-Front] React/[TAVE] React Design Pattern Study

비동기 프로그래밍 패턴

유진진 2025. 5. 15. 22:12

비동기 프로그래밍

자바스크립트에서 동기 코드는 blocking 방식으로 실행된다. 즉, 코드가 순서대로 실행된다는 뜻이다. 코드에서 다음 줄은 현재 문장의 실행이 완료된 후에만 실행될 수 있다.

반면에 비동기 코드는 nonblocking 방식으로 실행된다.

자바스크립트 엔진은 현재 실행 중인 코드가 다른 작업을 기다리는 동안 백그라운드에서 해당 비동기 코드를 실행할 수 있다.

다음 작업을 할 때 비동기가 유용하다.

  • 네트워크 요청
  • 데이터베이스 읽기/쓰기
  • 기타 I/O (입출력) 작업

비동기와 프로미스와 같은 기능을 사용하면 비동기 코드를 마치 동기 코드처럼 작동하도록 작성할 수 있어 코드의 가독성과 이해도를 높여준다.

 

배경: 콜백 지옥

콜백

함수가 끝난 후 다른 함수를 호출하는 방식

콜백 함수는 다른 함수에 인자로 전달되어, 그 다른 함수의 실행이 끝난 뒤에 실행되는 함수이다.

  • 문제점 : 콜백 안에 콜백에 또 들어가는 “콜백 지옥”이 발생한다.

1-1. 기본 콜백 함수

function greet(name, callback) {
  console.log("안녕, " + name);
  callback();
}

function sayBye() { // 콜백 함수 
  console.log("잘 가~");
}

greet("철수", sayBye);

// 안녕, 철수
// 잘 가~

sayBye는 greet 함수가 끝나고 실행되는 콜백 함수이다.

callback()은 sayBye()를 실행시킨 것이다.

 

1-2. 비동기에서 콜백 함수

function fetchData(callback) {
  setTimeout(() => { // 비동기 작업 
    console.log("데이터를 가져왔습니다");
    callback();
  }, 5000);
}

fetchData(() => {
  console.log("이제 데이터를 처리합니다");
});
// (5초 후)
// 데이터를 가져왔습니다.
// 이제 데이터를 처리합니다.

여기서 setTimeout()은 비동기 작업이고, 작업이 끝난 뒤 콜백 함수가 실행된다.

 

 

콜백의 문제점 : 콜백 지옥

콜백을 계속 중첩하면 코드가 점점 복잡해지고 가독성이 나빠진다.

예제 로그인 코드는 사용자 로그인 → 유저 정보 가져오기 → UI 업데이트 순으로 함수를 호출한다.

function loginUser(id, callback) {
  setTimeout(() => {
    console.log(`사용자 ${id} 로그인`);
    callback(id);
  }, 1000);
}

function getUserProfile(id, callback) {
  setTimeout(() => {
    console.log(`${id}의 프로필 정보 가져오기`);
    callback({ id: id, name: "홍길동" });
  }, 1000);
}

function updateUI(profile, callback) {
  setTimeout(() => {
    console.log(`UI 업데이트: ${profile.name}님 환영합니다!`);
    callback();
  }, 1000);
}

// 콜백 지옥 
loginUser("user01", (id) => {
  getUserProfile(id, (profile) => {
    updateUI(profile, () => {
      console.log("모든 작업 완료!🎉");
    });
  });
});

 

작동 순서

  1. loginUser :
    1. 1초 후,
    2. ("사용자 user01 로그인"),
    3. 콜백 함수로 getUserProfile 호출
  2. getUserProfile :
    1. 1초 후,
    2. ("user01의 프로필 정보 가져오기"),
    3. 콜백 함수로 updateUI 호출
  3. updateUI :
    1. 1초 후,
    2. ("UI 업데이트: 홍길동님 환영합니다!"),
    3. 콜백 함수로 ("모든 작업 완료!🎉") 실행

 

출력 결과

// (1초 후)
사용자 user01 로그인

// (2초 후)
user01의 프로필 정보 가져오기

// (3초 후)
UI 업데이트: 홍길동님 환영합니다!
모든 작업 완료!🎉

 


프로미스 패턴

미래 시점에 비동기 작업의 성공/실패를 처리한다.

.then() 과 .catch() 를 사용한다.

  • 장점 : 콜백 지옥을 피할 수 있으며 콜백보다 가독성이 높고, 에러 처리도 쉽다.

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

프로미스는 Promise 생성자를 사용하여 만들 수 있고, 이 생성자는 함수를 인수로 받는다. 인수로 받는 그 함수는 또 resolve와 reject라는 두 인수를 받는다.

  • resolve : 비동기 작업이 성공적으로 완료되었을 때 호출됨
  • reject : 비동기 작업이 실패했을 때 호출된다.
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = true;
      if (success) resolve("데이터 가져옴");
      else reject("에러 발생");
    }, 1000);
  });
}

fetchData()
  .then(result => {
    console.log(result); // 성공 처리
  })
  .catch(error => {
    console.error(error); // 실패 처리
  });

fetchData() 함수는 요청 결과를 나타내는 Promise 객체를 반환한다. (이 예제에서는 아니지만 예를 들면 네트워크 요청의 결과)

요청이 성공하면 Promise는 응답을 완료(fulfilled)하여 데이터를 반환하고, 실패하면 에러와 함께 거부(rejected)된다.

→ Promise 객체의 .then 및 .catch 메서드를 통해 결과를 처리할 수 있다.

 

 

프로미스 체이닝

여러 개의 프로미스를 함께 연결하여 복잡한 비동기 로직을 만들 수 있다.

→ 여러 비동기 작업을 then()으로 연속적으로 연결해서 처리하는 방법으로, 콜백 지옥처럼 중첩하지 않고, 평평하고 읽기 쉬운 코드로 만들 수 있다.

⇒ 콜백 지옥을 해결하는 핵심 기술 !

// 프로미스 체이닝 
asyncFunc()
  .then(result1 => {
    // 첫 번째 결과 처리
    return nextAsync(result1);
  })
  .then(result2 => {
    // 두 번째 결과 처리
    return anotherAsync(result2);
  })
  .then(result3 => {
    // 최종 결과 처리
  })
  .catch(error => {
    // 에러 처리 (중간 어디에서든 발생 가능)
  });

각 .then()은 앞선 .then()의 반환 값을 받아 처리하고,

에러는 .catch() 하나로 처리할 수 있다.

 

 

로그인 콜백 예제 해결

앞선 로그인 콜백 지옥 예시를 프로미스 체이닝으로 해결해보자.

loginUser("user01")
  .then(getUserProfile)
  .then(updateUI)
  .then(() => {
    console.log("모든 작업 완료!🎉");
  })
  .catch((err) => {
    console.error("에러 발생:", err);
  });

콜백 방식보다 훨씬 가독성이 좋아졌다.

 

프로미스 에러 처리

.catch 메서드로 프로미스 체인의 실행 중에 발생할 수 있는 에러를 처리한다.

 

프로미스 병렬 처리

여러 프로미스를 동시에 실행하여 더 빠르게 처리하는 방법이다.

하나씩 기다리지 않고 동시에 처리한다.

  • ex> 사용자 목록 10명 각각의 데이터를 동시에 가져오기
  • ex> API 3개를 동시에 요청하기

 

1. Promise.all([])

모든 프로미스가 성공해야 결과를 반환하고 하나라도 실패하면 에러이다.

const p1 = fetch("/user");
const p2 = fetch("/posts");
const p3 = fetch("/comments");

Promise.all([p1, p2, p3])
  .then(([res1, res2, res3]) => {
    console.log("모든 요청 완료!");
  })
  .catch(err => {
    console.error("하나라도 실패함:", err);
  });

p1, p2, p3 가 동시에 실행되고, 결과는 배열로 한번에 받는다.

 

2. Promise.allSettled([])

성공/실패에 무관하게 모든 프로미스가 끝날 때까지 기다린다.

결과를 모두 확인하고 싶을 때 사용한다.

const tasks = [
  Promise.resolve("성공"),
  Promise.reject("실패"),
  Promise.resolve("또 성공")
];

Promise.allSettled(tasks).then(results => {
  results.forEach((result, i) => {
    if (result.status === "fulfilled") {
      console.log(`[${i}] 결과:`, result.value);
    } else {
      console.log(`[${i}] 에러:`, result.reason);
    }
  });
});

 

실행 결과

가장 먼저 완료된 결과(실패): 실패1

 

 

 

4. Promise.any([])

성공한 프로미스 결과들 중에서 가장 먼저 성공한 결과만 받는다.

실패한 결과는 무시하고, 만약 전부 실패하면 .catch 한다.

 

1. 성공하는 결과가 있는 경우 

const p1 = Promise.reject("실패1");
const p2 = Promise.resolve("성공1");
const p3 = Promise.reject("실패2");
const p4 = Promise.resolve("성공2");

Promise.any([p1, p2, p3, p4]).then((result) => {
  console.log("성공한 것 중 제일 먼저 끝난 결과:", result);
});

// 결과
// 성공한 것 중 제일 먼저 끝난 결과: 성공1

 

2. 모든 결과가 실패하는 경우

const p1 = Promise.reject("실패1");
const p3 = Promise.reject("실패2");

Promise.any([p1, p3])
  .then((result) => {
    console.log("성공한 것 중 제일 먼저 끝난 결과:", result);
  })
  .catch((error) => {
    console.error("모든 Promise가 실패했습니다.");
    console.error(error); // AggregateError: All promises were rejected
  });
  
// 결과
// 모든 Promise가 실패했습니다.
// [AggregateError: All promises were rejected] {
//   [errors]: [ '실패1', '실패2' ]
// }

 

 

 

프로미스 순차 실행

Promise.resolve

Array.prototype.reduce()와 함께 자주 쓰인다.

Promise.resolve() 는 즉시 이행되는 Promise를 만들어서 초기 체인을 시작하는데 사용된다.

그리고 .reduce() 로 이전 Promise가 끝난 후 다음 작업을 연결한다.

reduce()는 배열의 모든 요소를 순서대로 처리하면서 누적 결과를 만들어내는 메서드이다.

const nums = [1, 2, 3];

function delayPrint(n) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(n);
      resolve();
    }, 1000);
  });
}

nums.reduce((promise, n) => {
  return promise.then(() => delayPrint(n));
}, Promise.resolve());

// 아래 방식과 동일함 
// Promise.resolve()
//   .then(() => delayPrint(1))
//   .then(() => delayPrint(2))
//   .then(() => delayPrint(3));
  • .reduce 와 함께 쓰이는 방식
    • Promise.resolve() : 처음에 바로 실행되는 Promise
    • 그 다음부터 n마다 delayPrint(n)이 순서대로 호출된다.
    • .then 으로 체이닝하면서 다음 작업이 이전 작업이 끝날 때까지 기다린다.

실행 결과 (1초 간격으로 출력됨)

// (1초 후)
1
// (2초 후)
2
// (3초 후)
3

 

 

 

프로미스 메모이제이션

캐시를 사용해서 프로미스 함수 호출의 결과 값을 저장하여 중복된 요청을 방지할 수 있다. → 성능 최적화

요청한 url이 이미 캐시에 존재한다면 캐시된 데이터가 반환된다.

만약 존재하지 않으면 새로운 요청이 발생하고, 나중에 같은 요청이 들어올 때를 대비해 캐시에 저장된다.

const cache = {};

function fetchData(key) {
  if (!cache[key]) {
    const promise = new Promise((resolve, reject) => {
      setTimeout(() => {
        console.log("Fetching from server...");
        resolve("데이터: " + key);
      }, 1000);
    });
    console.log(promise); // Promise { <pending> } // 이면 아직 결과가 정해지지 않은 상태
    cache[key] = promise;
  }
  return cache[key]; // 캐싱된 Promise 반환
}

fetchData("user123").then(console.log); // 1초 후 결과 출력
fetchData("user123").then(console.log); // 바로 결과 출력 (동일 Promise 공유)

 

실행 결과

Promise { <pending> }

// (1초 후)
Fetching from server...
데이터: user123

// (그 후 바로 출력)
데이터: user123

 

Promise { <pending> } 으로 출력되는 것은 아직 1초가 지나지 않아, 결과가 정해지지 않은 상태라는 뜻이다. 비동기 작업이 완료되지 않아(즉, resolve가 호출되지 않아) pending 상태이며, 작업이 끝나면 resolve()가 호출되어 결과를 반환한다.

첫 호출인 경우 캐시에 없으므로 1초 기다리고, resolve(”데이터: user123”) 가 되고, then(console.log)에 등록되어, Fetching from server... 와 데이터: user123 가 출력된다.

 

두 번째 호출인 경우 캐시에 있으므로 기다리지 않고 바로 데이터: user123 를 출력한다.

Fetching from server... 가 한번 출력 된 것을 확인하면 두 번째 호출은 캐싱된 데이터를 바로 반환했다는 것을 알 수 있다.

 

pending으로 결과가 정해지지 않았는데 그 다음 줄에서 어떻게 cache에 저장할 수 있는 걸까?

Promise는 비동기 작업이 진행 중일 떄 “결과를 나중에 받을 수 있도록” 약속하는 객체이다. Promise는 그 결과가 아직 결정되지 않았더라도 비동기 작업을 추적하는 객체로서 존재한다. 때문에, 비동기 작업이 아직 완료되지 않았다고 해서 그 Promise 객체를 사용할 수 없는 것은 아니다. Promise 객체는 **"상태"**만 추적하고, 결과는 추후에 resolve나 reject로 설정되는 것이다.

여기서 Promise는 아직 값을 갖고 있지는 않지만 작업이 완료될 때 결과 값을 받을 수 있는 참조를 제공하기 때문에, 값이 결정되지 않았더라도 cache에 저장되어 나중에 해당 작업이 끝났을 때 결과를 받을 수 있게 된다.

 

 

프로미스 재시도

Promise Retry.

프로미스(비동기 작업, 특히 네트워크 요청)가 실패할 때 일정 횟수만큼 다시 시도할 수 있다.

function retryPromise(fn, retries = 3, delay = 1000) {
  return new Promise((resolve, reject) => {
    function attempt(remaining) {
      fn()
        .then(resolve) // 성공하면 "성공!" 출력됨
        .catch((err) => {
          if (remaining === 0) {
            reject(err); // 계속 끝까지 실패하는 경우 "실패!" 출력됨
          } else {
            // 실패했지만 앞으로 재시도 기회가 남은 경우
            console.log(`재시도 중... 남은 횟수: ${remaining}`);
            setTimeout(() => attempt(remaining - 1), delay);
          }
        });
    }

    attempt(retries);
  });
}

// 실패할 수도 있는 비동기 함수
function fetchData() {
  return new Promise((resolve, reject) => {
    if (Math.random() < 0.5) {
      reject("실패!");
    } else {
      resolve("성공!");
    }
  });
}

retryPromise(fetchData, 5, 500).then(console.log).catch(console.error);

 

실행 결과

재시도 중... 남은 횟수: 5
재시도 중... 남은 횟수: 4
성공!
  1. retryPromise() 호출
  2. fn()으로는 실패할 수도 있는 비동기 함수인 fetchData() 가 전달되고, retries = 5, delay = 100 이 저장됨
  3. attempt(retries); 호출. ( 즉, attempt(5) 호출 )
  4. fetchData() 가 호출되고 .then과 .catch 로 결과를 반환한다.
  5. 실패하여 .catch 가 실행된 경우, 재시도 기회가 남아있으면 delay (1초) 만큼 기다린 후에 다시 attempt()를 호출한다. ⇒ 재귀 함수

async / await 패턴

프로미스를 더 간단하게 쓰기 위한 문법적 설탕이다.

async 함수 안에서 await로 동기 코드처럼 작성할 수 있다.

  • 장점 : 코드가 가장 깔끔하고, 동기 코드처럼 쓸 수 있으며, 에러 처리는 try...catch로 쉽게 할 수 있다.
async function getData() {
  try {
    const result = await fetchData();  // fetchData는 Promise를 반환해야 함
    console.log(result);
  } catch (error) {
    console.error(error);
  }
}

getData();
  • await 키워드는 fetch 호출이 완료될 때까지 함수 실행을 일시 중지한다.
  • 요청이 성공하면 try 블록에서 데이터가 콘솔에 기록되고, 실패하면 에러가 catch 블록에서 처리된다.

'[Web-Front] React > [TAVE] React Design Pattern Study' 카테고리의 다른 글

렌더링 패턴  (2) 2025.05.15
리액트 디자인 패턴  (1) 2025.05.15
플라이웨이트 패턴  (1) 2025.05.15
주요 기초 디자인 패턴 3가지  (1) 2025.05.15