프로그래밍/Javascript

자바스크립트 비동기 처리: 콜백에서 async/await까지 정복하기

shimdh 2025. 2. 11. 09:57
728x90

서론: 비동기 처리, 왜 중요할까요?

웹 개발의 핵심은 사용자에게 부드럽고 반응성이 뛰어난 경험을 제공하는 것입니다. 이를 위해선 사용자 인터페이스(UI)가 멈추지 않고 원활하게 작동해야 하며, 동시에 서버와의 통신이나 데이터 처리와 같은 시간이 소요되는 작업도 효율적으로 처리해야 합니다. 이 모든 것을 가능하게 하는 것이 바로 비동기 처리입니다.

예를 들어, 사용자가 웹 페이지에서 버튼을 클릭했을 때 서버로부터 데이터를 가져와야 한다고 가정해 보겠습니다. 만약 이 과정이 동기적으로 처리된다면, 데이터를 완전히 가져올 때까지 웹 페이지는 멈춰버리고 사용자는 아무런 조작도 할 수 없게 됩니다. 이는 매우 불편한 사용자 경험을 초래합니다. 반면, 비동기 처리를 사용하면 데이터를 가져오는 동안에도 웹 페이지는 계속 작동하며, 사용자는 다른 작업을 수행할 수 있습니다. 데이터 로딩이 완료되면 그 결과를 화면에 표시하여 자연스러운 흐름을 유지할 수 있습니다.

자바스크립트는 이러한 비동기 처리를 위해 콜백 함수, 프로미스, async/await와 같은 다양한 방법을 제공합니다.

1. 콜백 함수: 비동기 처리의 기본

콜백 함수는 자바스크립트에서 비동기 처리를 구현하는 가장 기본적인 방법입니다. 다른 함수의 인자로 전달되어 특정 이벤트가 발생하거나 비동기 작업이 완료되었을 때 호출되는 함수를 말합니다.

1.1 콜백 함수의 동작 원리

  1. 함수 정의: 먼저, 비동기 작업이 완료된 후 수행할 작업을 정의한 콜백 함수를 작성합니다.
  2. 비동기 함수 호출: 비동기 작업을 수행하는 함수를 호출할 때, 1단계에서 정의한 콜백 함수를 인자로 함께 전달합니다.
  3. 콜백 함수 호출: 비동기 작업이 완료되면, 비동기 함수 내부에서 전달받은 콜백 함수를 호출하여 결과를 처리합니다.

1.2 콜백 함수 예제

기본 예제:

function greeting(name, callback) {
  const message = `안녕하세요, ${name}!`;
  callback(message);
}

function logMessage(message) {
  console.log(message);
}

// greeting 함수를 호출할 때 logMessage 함수를 콜백으로 전달합니다.
greeting("철수", logMessage); // 출력: 안녕하세요, 철수!

greeting 함수는 인사말을 생성한 후, 인자로 받은 callback 함수를 호출합니다. logMessage는 콘솔에 메시지를 출력하는 함수입니다. greeting을 호출할 때 logMessage를 콜백으로 전달하면, "안녕하세요, 철수!"가 콘솔에 출력됩니다.

파일 읽기 예제 (Node.js):

const fs = require('fs'); // Node.js의 파일 시스템 모듈

function readFileContent(filePath, callback) {
  fs.readFile(filePath, 'utf8', (err, data) => {
    if (err) {
      callback(err, null);
    } else {
      callback(null, data);
    }
  });
}

readFileContent('example.txt', (err, data) => {
  if (err) {
    console.error('파일 읽기 에러:', err);
  } else {
    console.log('파일 내용:', data);
  }
});

fs.readFile 함수는 비동기적으로 파일을 읽습니다. 읽기가 완료되면 지정된 콜백 함수가 호출되고, 에러가 발생하면 err 객체가, 성공하면 파일 내용(data)이 전달됩니다.

사용자 입력 처리 예제 (브라우저):

function waitForUserInput(callback) {
  const input = prompt("이름을 입력하세요:");
  callback(input);
}

waitForUserInput((userInput) => {
  console.log("입력된 이름:", userInput);
});

prompt 함수는 사용자 입력을 받는 브라우저 내장 함수입니다. 사용자가 입력을 완료하고 확인을 누르면, 전달된 콜백 함수가 호출되어 입력값을 처리합니다.

1.3 AJAX와 콜백

AJAX(Asynchronous JavaScript and XML)는 웹 애플리케이션에서 서버와 비동기적으로 데이터를 주고받을 수 있도록 해주는 기술입니다. XMLHttpRequest 객체를 사용하여 서버에 요청을 보내고, 응답을 받으면 콜백 함수를 호출하여 처리합니다.

GET 요청 예제:

function fetchData(url, callback) {
  const xhr = new XMLHttpRequest();
  xhr.open("GET", url);

  xhr.onload = function () {
    if (xhr.status === 200) {
      // 응답 데이터가 성공적으로 로드되면 callback을 호출
      callback(null, JSON.parse(xhr.responseText));
    } else {
      // 에러가 발생하면 에러와 함께 callback을 호출
      callback(new Error("데이터 로드 실패: " + xhr.status), null);
    }
  };

  xhr.onerror = function () {
    // 네트워크 에러가 발생하면 에러와 함께 callback을 호출
    callback(new Error("네트워크 에러"), null);
  };

  xhr.send();
}

fetchData("https://api.example.com/data", function (error, data) {
  if (error) {
    console.error(error);
  } else {
    console.log("받은 데이터:", data);
  }
});

POST 요청 예제:

function postData(url, data, callback) {
  const xhr = new XMLHttpRequest();
  xhr.open("POST", url);
  xhr.setRequestHeader("Content-Type", "application/json");

  xhr.onload = function () {
    if (xhr.status === 201) {
      callback(null, JSON.parse(xhr.responseText));
    } else {
      callback(new Error("데이터 전송 실패: " + xhr.status), null);
    }
  };

  xhr.onerror = function () {
    callback(new Error("네트워크 에러"), null);
  };

  xhr.send(JSON.stringify(data));
}

const newData = { name: "새로운 사용자", age: 30 };
postData("https://api.example.com/users", newData, (error, response) => {
  if (error) {
    console.error(error);
  } else {
    console.log("서버 응답:", response);
  }
});

postData 함수는 서버에 POST 요청을 보내고, 성공하면 응답 데이터를, 실패하면 에러 객체를 콜백 함수에 전달합니다.

1.4 콜백 지옥: 중첩된 콜백의 늪

콜백 함수는 비동기 처리를 간편하게 구현할 수 있지만, 중첩해서 사용할 경우 코드의 가독성이 떨어지고 유지보수가 어려워지는 콜백 지옥(Callback Hell) 문제가 발생합니다.

asyncFunction1(function (result1) {
  asyncFunction2(result1, function (result2) {
    asyncFunction3(result2, function (result3) {
      asyncFunction4(result3, function(result4) {
        // ... 계속 중첩되는 콜백 함수 ...
      });
    });
  });
});

위 코드처럼 여러 비동기 작업을 순차적으로 처리해야 할 때 콜백 함수가 계속 중첩되면 코드의 깊이가 깊어지고, 이해하기 어려워집니다. 이러한 콜백 지옥은 코드의 품질을 저하시키는 주요 원인이 됩니다.

2. 프로미스: 콜백 지옥 탈출을 위한 구조선

프로미스(Promise)는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다. 프로미스를 사용하면 비동기 코드를 더 체계적으로 구성하고, 콜백 지옥 문제를 효과적으로 해결할 수 있습니다.

2.1 프로미스의 세 가지 상태

프로미스는 다음 세 가지 상태 중 하나를 갖습니다.

  1. 대기(Pending): 비동기 작업이 아직 완료되지 않은 초기 상태입니다.
  2. 이행(Fulfilled): 비동기 작업이 성공적으로 완료되어 결과 값을 가진 상태입니다.
  3. 거부(Rejected): 비동기 작업이 실패하여 에러 이유를 가진 상태입니다.

2.2 프로미스 생성

Promise 생성자를 사용하여 새로운 프로미스 객체를 생성할 수 있습니다. 생성자는 resolvereject 두 개의 함수를 인자로 받는 콜백 함수를 인자로 받습니다.

const myPromise = new Promise((resolve, reject) => {
  // 비동기 작업 수행 (예: 서버 요청, 타이머 등)
  const success = true; // 비동기 작업의 성공 여부를 가정

  if (success) {
    resolve("작업 성공!"); // 작업이 성공하면 resolve 함수를 호출하여 결과를 전달
  } else {
    reject("작업 실패!"); // 작업이 실패하면 reject 함수를 호출하여 에러 이유를 전달
  }
});
  • resolve(value): 프로미스를 이행 상태로 변경하고, value를 결과 값으로 설정합니다.
  • reject(error): 프로미스를 거부 상태로 변경하고, error를 에러 이유로 설정합니다.

2.3 프로미스 사용: .then().catch()

프로미스가 이행되거나 거부되면, .then() 또는 .catch() 메서드를 사용하여 각각의 경우에 대한 처리 로직을 작성할 수 있습니다.

myPromise
  .then((result) => {
    console.log(result); // "작업 성공!" 출력
  })
  .catch((error) => {
    console.error(error); // "작업 실패!" 출력
  });
  • .then(callback): 프로미스가 이행되었을 때 호출될 콜백 함수를 등록합니다. 콜백 함수는 프로미스의 결과 값을 인자로 받습니다.
  • .catch(callback): 프로미스가 거부되었을 때 호출될 콜백 함수를 등록합니다. 콜백 함수는 에러 이유를 인자로 받습니다.

setTimeout을 사용한 프로미스 예제:

const delayedPromise = (success) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (success) {
        resolve("2초 후 작업 성공");
      } else {
        reject("2초 후 작업 실패");
      }
    }, 2000);
  });
};

delayedPromise(true)
  .then((result) => console.log(result))
  .catch((error) => console.error(error));

delayedPromise(false)
  .then((result) => console.log(result))
  .catch((error) => console.error(error));

이 예제는 2초 후에 성공 또는 실패하는 프로미스를 생성합니다. success 값에 따라 resolve 또는 reject가 호출됩니다.

2.4 여러 프로미스 다루기: Promise.all(), Promise.race(), Promise.allSettled()

Promise.all(): 여러 개의 프로미스를 동시에 실행하고, 모든 프로미스가 이행될 때까지 기다립니다.

const promise1 = Promise.resolve(3);
const promise2 = new Promise((resolve) => setTimeout(resolve, 100, "foo"));
const promise3 = new Promise((resolve) => setTimeout(resolve, 500, "bar"));

Promise.all([promise1, promise2, promise3])
  .then((values) => {
    console.log(values); // [3, "foo", "bar"] 출력
  })
  .catch((error) => {
    console.error("에러 발생:", error); // 하나라도 거부되면 에러 발생
  });

Promise.all()은 입력받은 모든 프로미스가 이행되면 결과 값들의 배열을 반환하고, 하나라도 거부되면 즉시 거부됩니다.

Promise.race(): 여러 프로미스 중 가장 먼저 완료(이행 또는 거부)된 프로미스의 결과(또는 에러)를 반환합니다.

const racePromise1 = new Promise((resolve) => setTimeout(resolve, 500, "첫 번째"));
const racePromise2 = new Promise((resolve) => setTimeout(resolve, 100, "두 번째"));

Promise.race([racePromise1, racePromise2])
  .then((value) => {
    console.log("가장 빠른 프로미스:", value); // "두 번째" 출력
  });

Promise.allSettled(): 모든 프로미스의 결과(이행 또는 거부)를 배열로 반환합니다. 각 프로미스의 상태와 결과(또는 에러 이유)를 확인할 수 있습니다.

const settledPromise1 = Promise.resolve(3);
const settledPromise2 = new Promise((resolve, reject) => setTimeout(reject, 100, "에러 발생"));

Promise.allSettled([settledPromise1, settledPromise2])
  .then((results) => {
    console.log(results);
    // [
    //   { status: 'fulfilled', value: 3 },
    //   { status: 'rejected', reason: '에러 발생' }
    // ]
  });

2.5 프로미스로 fetchData 함수 개선하기

function fetchData(url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", url);

    xhr.onload = function () {
      if (xhr.status === 200) {
        resolve(JSON.parse(xhr.responseText));
      } else {
        reject(new Error("데이터 로드 실패: " + xhr.status));
      }
    };

    xhr.onerror = function () {
      reject(new Error("네트워크 에러"));
    };

    xhr.send();
  });
}

fetchData("https://api.example.com/data")
  .then((data) => {
    console.log("받은 데이터:", data);
  })
  .catch((error) => {
    console.error(error);
  });

이제 fetchData 함수는 프로미스를 반환합니다. 이를 통해 .then().catch()를 사용하여 비동기 작업의 결과를 더 깔끔하게 처리할 수 있습니다.

3. async/await: 비동기 코드의 미래

async/await는 ES8(ECMAScript 2017)에 도입된 최신 비동기 처리 문법입니다. 프로미스를 기반으로 동작하며, 비동기 코드를 마치 동기 코드처럼 작성할 수 있도록 해줍니다.

3.1 async/await 기본 사용법

  • async: 함수 선언 앞에 async 키워드를 붙이면 해당 함수는 항상 프로미스를 반환하는 비동기 함수가 됩니다.
  • await: async 함수 내부에서만 사용할 수 있으며, 프로미스 앞에 await 키워드를 붙이면 해당 프로미스가 완료(이행 또는 거부)될 때까지 기다립니다.
async function getData() {
  try {
    const data = await fetchData("https://api.example.com/data"); // fetchData는 프로미스를 반환
    console.log("받은 데이터:", data);
  } catch (error) {
    console.error("에러 발생:", error);
  }
}

getData();

getDataasync 함수이므로 항상 프로미스를 반환합니다. await 키워드는 fetchData 프로미스가 완료될 때까지 기다립니다. try...catch 블록을 사용하여 에러를 처리할 수 있습니다.

3.2 여러 await 사용하기

async function processData() {
  try {
    const data1 = await fetchData("https://api.example.com/data1");
    console.log("첫 번째 데이터:", data1);

    const data2 = await fetchData("https://api.example.com/data2");
    console.log("두 번째 데이터:", data2);

    const result = await process(data1, data2); // process는 data1과 data2를 처리하는 비동기 함수
    console.log("처리 결과:", result);
  } catch (error) {
    console.error("에러 발생:", error);
  }
}

processData();

이 예제는 여러 await를 사용하여 비동기 작업을 순차적으로 처리합니다. 각 await는 이전 프로미스가 완료될 때까지 기다립니다.

3.3 Promise.allawait 함께 사용하기

async function getAllData() {
  try {
    const [data1, data2, data3] = await Promise.all([
      fetchData("https://api.example.com/data1"),
      fetchData("https://api.example.com/data2"),
      fetchData("https://api.example.com/data3"),
    ]);

    console.log("데이터 1:", data1);
    console.log("데이터 2:", data2);
    console.log("데이터 3:", data3);
  } catch (error) {
    console.error("에러 발생:", error);
  }
}

getAllData();

Promise.allawait와 함께 사용하면 여러 프로미스를 동시에 실행하고, 그 결과를 한 번에 받아올 수 있습니다.

3.4 async/await의 장점

  • 가독성 향상: 비동기 코드를 동기 코드와 유사한 흐름으로 작성할 수 있어 코드의 가독성이 크게 향상됩니다.
  • 간편한 에러 처리: try...catch 블록을 사용하여 비동기 작업에서 발생하는 에러를 쉽게 처리할 수 있습니다.
  • 디버깅 용이: 동기 코드와 유사한 방식으로 디버깅할 수 있어 개발 효율성이 높아집니다.

결론: 어떤 비동기 처리 방법을 선택해야 할까요?

자바스크립트는 비동기 처리를 위해 콜백, 프로미스, async/await와 같은 다양한 방법을 제공합니다. 각 방법은 장단점이 있으며, 상황에 따라 적절한 방법을 선택하는 것이 중요합니다.

  • 콜백 함수:
    • 장점: 가장 기본적인 방법으로, 간단한 비동기 작업에 적합합니다.
    • 단점: 콜백 지옥 문제가 발생할 수 있어 복잡한 비동기 처리에는 적합하지 않습니다.
  • 프로미스:
    • 장점: 콜백 지옥 문제를 해결하고 코드의 가독성을 높여줍니다. .then(), .catch(), Promise.all() 등을 활용하여 유연한 비동기 처리가 가능합니다.
    • 단점: 여전히 .then() 체이닝이 길어질 수 있습니다.
  • async/await:
    • 장점: 비동기 코드를 동기 코드처럼 작성할 수 있어 가독성이 매우 뛰어나고, 에러 처리도 간편합니다.
    • 단점: 최신 문법이므로, 오래된 브라우저에서는 지원되지 않을 수 있습니다. (Babel과 같은 트랜스파일러를 사용하면 해결 가능)

결론적으로, 최신 자바스크립트 개발에서는 async/await를 사용하는 것이 가장 권장됩니다. async/await는 가독성, 유지보수성, 에러 처리 등 모든 면에서 가장 뛰어난 방법입니다.

728x90