비동기 처리 방식

Raccoon·2025년 8월 25일

비동기 처리 방식

비동기 처리 방식 이란 어떤 작업이 끝날 때까지 기다리지 않고, 다음 작업을 먼저 수행하는 방식을 말한다.

웹 개발에서의 비동기 처리

웹 개발에서 비동기 처리는 주로 서버와 통신하는 작업에서 사용된다.

이는 서버에 데이터를 요청하고 응답을 받는 데는 시간이 걸리기 때문이다.

만약 동기 방식으로 처리한다면, 서버에서 응답이 올 때까지 웹페이지가 멈춰버리게 될 것이다.

이 때문에 사용자는 아무것도 할 수 없는 상태가 된다.

하지만 비동기 방식을 사용하면, 서버에 데이터를 요청해놓고 웹페이지는 멈추지 않고 계속해서 사용자 입력을 받거나 다른 작업을 처리할 수 있다.

서버에서 응답이 오면, 그 때 받은 데이터를 이용해 화면을 업데이트하는 식으로 작동한다.

JS 비동기 처리 방식의 진화와 비교

세 가지 주요 방식이 존재한다.

  1. Callback (초기 ~ 2015년)
  2. Promise (2015년 ES6)
  3. Async / Await (2017년 ES8)

1. Callback

가장 오래된 방식이며, 다른 함수의 인자로 전달되어 특정 작업이 완료된 후 호출되는 함수이다.

사용 예시

// 전형적인 콜백 패턴
function fetchUser(id, callback) {
    setTimeout(() => {
        const user = { id, name: '홍길동' };
        callback(null, user);
    }, 1000);
}

// 사용법
fetchUser(123, (error, user) => {
    if (error) {
        console.error('에러 발생:', error);
        return;
    }
    console.log('사용자:', user);
});

단점

콜백 지옥 이 발생할 수 있다.

콜백 지옥 이란 비동기 작업이 여러 개 중첩될 때, 콜백 함수가 계속해서 깊어지는 현상을 말한다.

코드가 마치 피라미드처럼 안쪽으로 들여쓰기되어 가독성이 매우 떨어지고, 유지보수가 어려워지는 문제를 일으킨다.

예를 들어, 세 개의 비동기 작업을 순서대로 실행해야 한다고 가정해보자.

  1. 사용자 정보를 가져온다.
  2. 가져온 사용자 정보로 게시물 목록을 가져온다.
  3. 가져온 게시물 목록으로 댓글을 가져온다.

이러한 로직을 콜백 함수로 구현하면 다음과 같이 코드가 깊어진다.

// 첫 번째 비동기 함수: 사용자 정보 가져오기
getUser(userId, function(user) {
  // 두 번째 비동기 함수: 게시물 목록 가져오기
  getPosts(user.id, function(posts) {
    // 세 번째 비동기 함수: 댓글 가져오기
    getComments(posts[0].id, function(comments) {
      console.log(comments);
      // 작업이 더 많아지면 들여쓰기는 끝없이 깊어진다
    });
  });
});

이러한 문제를 해결하기 위해 Promiseasync / await 가 등장하게 된다.

2. Promise

Promise는 기존의 콜백 함수 방식이 가진 '콜백 지옥' 문제를 해결하기 위해 도입되었다.

Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체이다.

즉, 미래에 어떤 결과를 반환하거나 오류가 발생할 것을 약속하는 객체라고 생각하면 된다.

Promise의 세 가지 상태

Promise는 비동기 작업의 진행 상황에 따라 다음 세 가지 상태 중 하나에 속합니다.

  1. 대기(pending): 비동기 작업이 아직 완료되지 않은 초기 상태
  2. 이행(fulfilled): 비동기 작업이 성공적으로 완료된 상태 (resolve 호출됨)
  3. 거부(rejected): 비동기 작업이 실패한 상태 (reject 호출됨)

한 번 fulfilled 또는 rejected 상태가 되면, 다시 pending 상태로 돌아갈 수 없다.

사용 예시

// Promise 생성
const myPromise = new Promise((resolve, reject) => {
  // 비동기 작업 (예: 2초 후 완료)
  setTimeout(() => {
    const isSuccess = true;
    if (isSuccess) {
      resolve("작업 성공! 🎉"); // 성공 시 resolve 호출
    } else {
      reject("작업 실패! 😭"); // 실패 시 reject 호출
    }
  }, 2000);
});

// Promise 사용
myPromise
  .then((result) => {
    // 성공적으로 이행되면 실행
    console.log(result); // "작업 성공! 🎉" 출력
  })
  .catch((error) => {
    // 거부되면 실행
    console.error(error); // "작업 실패! 😭" 출력
  })
  .finally(() => {
    // 성공/실패 여부와 관계없이 항상 실행
    console.log("작업이 끝났습니다.");
  });

장점

  • 가독성 향상: then()catch() 메서드를 체인처럼 연결하여 비동기 코드를 순차적으로 읽을 수 있게 해준다.
  • 쉬운 오류 처리: catch() 메서드를 통해 비동기 작업 도중 발생하는 모든 오류를 한 곳에서 처리할 수 있어, 에러 핸들링이 간결해진다.

단점

Promise는 콜백 지옥 문제를 해결했지만, 여전히 몇 가지 문제점이 있다.

  • 복잡한 체이닝
    • 여러 개의 Promise를 순차적으로 처리해야 할 때, .then() 메서드를 계속해서 연결하는 체이닝(Chaining) 방식은 코드를 읽기 어렵게 만들 수 있다.
    • 특히 체인이 길어지면 중간 에러 처리용 catch가 많이 생겨서 가독성이 급격하게 저하될 수 있다.
  • 불필요한 중첩
    • 모든 .then() 블록 안에 새로운 코드를 작성해야 하므로, 여전히 어느 정도의 들여쓰기가 발생합니다. 콜백 지옥만큼 심하지는 않지만, 코드가 깊어질수록 가독성이 떨어질 수 있다.

Callback 에서의 시나리오로 예시 코드를 살펴보자.

  1. 사용자 정보를 가져온다.
  2. 가져온 사용자 정보로 게시물 목록을 가져온다.
  3. 가져온 게시물 목록으로 댓글을 가져온다.
// 가상의 API 호출 함수 (Promise 반환)
function getUser(userId) {
console.log('1. 사용자 정보 가져오는 중...');
return new Promise(resolve => setTimeout(() => resolve({ id: userId, name: 'Alice', posts: [101, 102] }), 1000));
}

function getPosts(postIds) {
console.log('2. 게시물 목록 가져오는 중...');
return new Promise(resolve => setTimeout(() => resolve([{ id: 101, title: '첫 번째 글' }, { id: 102, title: '두 번째 글' }]), 1000));
}

function getComments(postId) {
console.log('3. 댓글 목록 가져오는 중...');
return new Promise(resolve => setTimeout(() => resolve(['댓글 1', '댓글 2']), 1000));
}

// Promise 체이닝 시작
getUser('user123')
.then(user => {
// 첫 번째 작업 결과(user)를 다음 작업에 전달
return getPosts(user.posts);
})
.then(posts => {
// 두 번째 작업 결과(posts)를 다음 작업에 전달
return getComments(posts[0].id);
})
.then(comments => {
// 최종 결과 출력
console.log('✅ 최종 댓글:', comments);
})
.catch(error => {
console.error('❌ 오류 발생:', error);
});

중첩 구조에서 가독성이 저하될 것이라는게 명확해 보인다.

3. async / await

async/await는 이러한 Promise의 단점을 해결하고 비동기 코드를 동기 코드처럼 읽고 쓸 수 있도록 만든 문법이다.

사용 예시

// 가상의 API 호출 함수 (Promise 반환)
function getUser(userId) {
  console.log('1. 사용자 정보 가져오는 중...');
  return new Promise(resolve => setTimeout(() => resolve({ id: userId, name: 'Alice', posts: [101, 102] }), 1000));
}

function getPosts(postIds) {
  console.log('2. 게시물 목록 가져오는 중...');
  return new Promise(resolve => setTimeout(() => resolve([{ id: 101, title: '첫 번째 글' }, { id: 102, title: '두 번째 글' }]), 1000));
}

function getComments(postId) {
  console.log('3. 댓글 목록 가져오는 중...');
  return new Promise(resolve => setTimeout(() => resolve(['댓글 1', '댓글 2']), 1000));
}

// async 함수 선언
async function fetchData() {
  try {
    // 1. await 키워드로 Promise가 완료될 때까지 기다림
    const user = await getUser('user123');
    
    // 2. await 키워드로 다음 Promise가 완료될 때까지 기다림
    const posts = await getPosts(user.posts);
    
    // 3. await 키워드로 마지막 Promise가 완료될 때까지 기다림
    const comments = await getComments(posts[0].id);
    
    // 4. 모든 작업이 끝난 후 최종 결과 출력
    console.log('✅ 최종 댓글:', comments);
    
  } catch (error) {
    // try-catch로 모든 에러를 한 곳에서 처리
    console.error('❌ 오류 발생:', error);
  }
}

// 함수 호출
fetchData();

장점

  • 우수한 가독성
    • 비동기 코드가 동기 코드처럼 위에서 아래로 흐르는 직선적 구조처럼 보인다. (코드 읽는 순서와 실행 순서가 그대로 일치함)
    • Promise 의 경우, 체인이 길어질 수록 가독성이 좋지 않다는 단점이 있었고, 이를 보완

단점

  1. 동시성(Concurrency) 처리 주의 필요

    awaitPromise가 완료될 때까지 코드가 일시 정지한다는 특징 때문에, 여러 비동기 작업을 동시에 병렬적으로 처리하는 데 불리하다.

    예를 들어, 서로 의존성이 없는 3개의 API를 호출하는 상황이라고 가정했을 때, await 를 다음과 같이 사용했다고 가정해보자.

    async function sequentialFetch() {
      const result1 = await fetch('api/data1'); // 1초 소요
      const result2 = await fetch('api/data2'); // 1초 소요
      const result3 = await fetch('api/data3'); // 1초 소요
      // 총 3초 소요
    }

    총 3초의 시간이 소요된다.

    해당 문제를 해결하기 위해, Promise.all() 을 사용하면 모든 fetch 가 한 번에 실행되도록 해서, 1초 의 시간이 소요되게 할 수 있다.

    async function parallelFetch() {
      // Promise.all()을 사용해 모든 fetch가 동시에 실행되도록 함
      const [result1, result2, result3] = await Promise.all([
        fetch('api/data1'),
        fetch('api/data2'),
        fetch('api/data3')
      ]);
      // 총 1초 소요
    }

    단, Promise.all()은 서로 의존성이 없는 여러 개의 비동기 작업을 동시에(병렬로) 실행할 때 사용하는 메서드이고, 의존성이 없는 관계에 적합하다.

    만약, A의 결과가 B에 필요한 경우, B는 A가 끝날 때까지 기다려야만 시작할 수 있다면, 이런 관계를 의존 관계라고 부른다.

    이 경우에는 병렬 처리가 불가능하며, await를 사용한 순차적 처리가 더 자연스럽고 적합하다.

    작업 간의 의존성이 있다면 async / await 를 통해 코드의 가독성을 확보하는 것이 좋고,

    작업 간의 의존성이 없다면 Promise.all() 을 사용해 모든 작업을 동시에 실행하는 것이 훨씬 빠르고 효율적이다.

  2. 에러 처리 범위 제한

    try-catch는 해당 블록 내의 에러만 처리할 수 있어,

    비동기 작업이 여러 함수에 분산되어 있을 때 에러 처리가 복잡해질 수 있다.

     // 에러 처리가 복잡해지는 경우
      async function complexOperation() {
        try {
          const step1 = await operation1(); // 여기서 에러 발생 가능
        } catch (error) {
          // operation1의 에러만 처리
        }
    
        try {
          const step2 = await operation2(); // 여기서도 에러 발생 가능
        } catch (error) {
          // operation2의 에러만 처리
        }
      }
profile
꾸준함을 목표로 합니다.

0개의 댓글