비동기 처리는 모던 웹 개발에서 피할 수 없는 핵심 개념이다. 다만 대부분의 개발자들은 Promise와 async/await의 정확한 차이를 모른 채 코드를 작성하거나, 둘을 같은 것으로 생각하는 실수를 범한다. 이번에는 Promise의 기초부터 시작해서 async/await이 정확히 뭐고 왜 필요한지, 언제 어떤 상황에서 써야 하는지 완벽하게 정리해서 소개하겠다.

 

Promise는 뭔가 - 기초부터 이해하기
Promise는 자바스크립트에서 비동기 작업의 결과를 나타내는 객체다. 세 가지 상태를 가진다: 대기(pending), 성공(fulfilled), 실패(rejected). 서버에 데이터를 요청했을 때 응답이 올 때까지 기다리지 말고, "나중에 응답 오면 이 콜백을 실행해" 라는 식으로 작동한다.

 

기본 Promise 문법
// ✗ 잘못된 코드 - 콜백 지옥 (Callback Hell)
function fetchData(url, callback) {
  setTimeout(() => {
    console.log('데이터 로드됨: ' + url);
    callback(null, { data: 'result' });
  }, 1000);
}

fetchData('/api/users', function(err, data) {
  if (!err) {
    fetchData('/api/posts', function(err, data2) {
      if (!err) {
        fetchData('/api/comments', function(err, data3) {
          // 깊어질수록 읽기 어려움
          console.log(data3);
        });
      }
    });
  }
});

✗ 왜 안 되는가: 콜백이 중첩되면서 코드 가독성이 급격히 떨어진다. 에러 처리도 각 단계마다 해야 한다.

// ✓ 올바른 코드 - Promise 체이닝
function fetchData(url) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('데이터 로드됨: ' + url);
      resolve({ data: 'result' });
    }, 1000);
  });
}

fetchData('/api/users')
  .then(data => {
    console.log('1단계 완료:', data);
    return fetchData('/api/posts');
  })
  .then(data => {
    console.log('2단계 완료:', data);
    return fetchData('/api/comments');
  })
  .then(data => {
    console.log('3단계 완료:', data);
  })
  .catch(error => {
    console.error('어디든 에러 발생:', error);
  });

✓ 왜 이게 맞는가: .then() 체인으로 순차 처리가 명확해졌고, .catch()로 모든 에러를 한 곳에서 처리할 수 있다.

 

Promise의 세 가지 상태와 resolve/reject
// Promise 생성 - resolve와 reject 파라미터
const myPromise = new Promise((resolve, reject) => {
  const success = true;
  
  if (success) {
    // 성공 상태로 전환 - then으로 받음
    resolve('작업 성공!');
  } else {
    // 실패 상태로 전환 - catch로 받음
    reject('작업 실패!');
  }
});

myPromise
  .then(result => console.log(result))        // '작업 성공!'
  .catch(error => console.error(error))       // 에러 발생 시
  .finally(() => console.log('완료!'));       // 상태 관계없이 항상 실행

 

async/await - Promise를 더 간결하게
async/await는 Promise를 더 읽기 쉬운 "동기식" 코드처럼 보이게 만든 문법 설탕(syntactic sugar)일 뿐이다. 내부적으로는 여전히 Promise로 동작한다. async 함수는 항상 Promise를 반환한다.

 

async/await 기본 사용법
// ✗ 잘못된 코드 - await 없이 Promise 반환값 사용 시도
async function loadUserData() {
  const userData = fetchData('/api/users');  // Promise 객체가 그대로 반환됨
  console.log(userData.id);  // undefined - 실제 데이터가 아님
}

loadUserData();

✗ 왜 안 되는가: await를 빼먹으면 Promise 객체 자체만 받아서 .then()을 체인할 수 없고, userData.id는 존재하지 않는다.

// ✓ 올바른 코드 - async/await로 비동기 작업을 마치 동기처럼 처리
async function loadUserData() {
  try {
    const userData = await fetchData('/api/users');
    console.log('유저 데이터:', userData);
    
    const postData = await fetchData('/api/posts');
    console.log('포스트 데이터:', postData);
    
    return { user: userData, posts: postData };
  } catch (error) {
    console.error('데이터 로드 실패:', error);
  }
}

// async 함수는 항상 Promise를 반환하므로 then으로 받거나 다른 async에서 await
await loadUserData();

✓ 왜 이게 맞는가: 마치 동기 코드처럼 읽혀서 이해하기 쉽고, try/catch로 에러 처리도 깔끔하다. 여러 await를 순차 실행할 때도 직관적이다.

 

Promise vs async/await - 실전 비교
항목Promise (.then())async/await
코드 가독성체인이 길어지면 복잡해짐동기식처럼 직관적
에러 처리.catch() 메서드try/catch 블록
여러 비동기 대기.all() / .race() 필요간단한 반복문 or Promise.all() 조합
디버깅스택 트레이스가 길어짐명확한 라인 번호 추적
브라우저 지원IE11부터 가능최신 브라우저 필요 (IE 미지원)

 

Promise.all() vs async/await - 병렬 처리
// Promise.all() 방식 - 3개 요청을 동시에 보냄
Promise.all([
  fetchData('/api/users'),
  fetchData('/api/posts'),
  fetchData('/api/comments')
])
  .then(([users, posts, comments]) => {
    console.log('모든 데이터:', users, posts, comments);
  })
  .catch(error => console.error(error));
// async/await 방식 - 동일하게 동시 실행
async function loadAllData() {
  try {
    // 이렇게 하면 순차 실행됨 (잘못된 사용)
    const users = await fetchData('/api/users');
    const posts = await fetchData('/api/posts');      // users 완료 후 시작
    const comments = await fetchData('/api/comments'); // posts 완료 후 시작
    // 총 3초 소요
  } catch (error) {
    console.error(error);
  }
}

async function loadAllDataFast() {
  try {
    // 올바른 병렬 실행 - Promise.all() 조합
    const [users, posts, comments] = await Promise.all([
      fetchData('/api/users'),
      fetchData('/api/posts'),
      fetchData('/api/comments')
    ]);
    // 총 1초 소요 (가장 긴 요청 기준)
    console.log('병렬 완료:', users, posts, comments);
  } catch (error) {
    console.error(error);
  }
}

await loadAllDataFast();

 

흔한 실수와 해결책
실수 1: async 함수 내에서 여러 await를 순차 실행해버리기
// ✗ 잘못된 코드 - 불필요한 순차 대기
async function getUserWithPosts(userId) {
  const user = await fetchData(`/api/users/${userId}`);
  const posts = await fetchData(`/api/posts?userId=${userId}`);
  // user 받은 후에야 posts를 요청함 - 낭비
  return { user, posts };
}

// ✓ 올바른 코드 - 병렬 처리
async function getUserWithPosts(userId) {
  const [user, posts] = await Promise.all([
    fetchData(`/api/users/${userId}`),
    fetchData(`/api/posts?userId=${userId}`)
  ]);
  return { user, posts };
}

 

실수 2: await를 빼먹고 Promise 객체만 받기
// ✗ 잘못된 코드
async function processData() {
  const result = fetchData('/api/data');  // await 없음!
  console.log(result.name);  // Promise { <pending> } 출력, name은 undefined
}

// ✓ 올바른 코드
async function processData() {
  const result = await fetchData('/api/data');
  console.log(result.name);  // 실제 데이터 출력
}

 

실수 3: async 함수에서 return하면 그게 Promise로 래핑됨
// async 함수는 자동으로 Promise를 반환함
async function getValue() {
  return 42;  // 실제로는 Promise.resolve(42)를 반환
}

// 따라서 이렇게 받아야 함
getValue().then(value => console.log(value));  // 42

// 또는 다른 async 함수에서
async function useValue() {
  const val = await getValue();
  console.log(val);  // 42
}

 

실무에서 자주 쓰는 패턴
패턴 1: API 요청 래퍼 함수
// 실무에서 자주 쓰이는 fetch 래퍼
async function apiCall(url, options = {}) {
  try {
    const response = await fetch(url, {
      method: options.method || 'GET',
      headers: {
        'Content-Type': 'application/json',
        ...options.headers
      },
      body: options.body ? JSON.stringify(options.body) : null
    });
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    
    return await response.json();
  } catch (error) {
    console.error('API 호출 실패:', error);
    throw error;  // 호출자가 처리하도록 넘김
  }
}

// 사용
async function loadUsers() {
  try {
    const users = await apiCall('/api/users');
    console.log('유저 목록:', users);
  } catch (error) {
    // 에러 처리
  }
}

 

패턴 2: 재시도 로직 (Retry)
async function retryFetch(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fetch(url).then(r => r.json());
    } catch (error) {
      if (i === maxRetries - 1) throw error;  // 마지막 시도 실패
      console.log(`재시도 ${i + 1}/${maxRetries}...`);
      await new Promise(r => setTimeout(r, 1000));  // 1초 대기
    }
  }
}

await retryFetch('/api/unreliable-endpoint');

 

패턴 3: 타임아웃 설정
// Promise.race()로 타임아웃 구현
async function fetchWithTimeout(url, timeoutMs = 5000) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('요청 타임아웃')), timeoutMs)
  );
  
  return await Promise.race([
    fetch(url).then(r => r.json()),
    timeout
  ]);
}

try {
  const data = await fetchWithTimeout('/api/data', 3000);
  console.log(data);
} catch (error) {
  console.error(error.message);  // '요청 타임아웃' 또는 네트워크 에러
}

 

정리 - 언제 뭘 쓸까
Promise와 async/await은 결국 같은 비동기 처리 도구다. 다만 async/await이 코드 가독성이 훨씬 낫고 try/catch도 직관적이어서 최신 프로젝트에서는 async/await을 권장한다. 다만 Promise.all()이나 Promise.race() 같은 유틸리티 함수는 여전히 필요하므로 둘을 적절히 조합해서 쓰면 된다. 이 글에서 소개한 병렬 처리 패턴과 재시도/타임아웃 로직을 참고해 팀의 API 호출 코드를 개선하면, 안정성 높고 유지보수하기 좋은 비동기 코드를 작성할 수 있을 것이다.