[Javascript] 비동기 처리 - (2) Promise

2025. 7. 2. 09:00Javascript/Javascript

728x90
반응형

 

 

 

지난 포스팅에서 언급했던 비동기 처리의 콜백 함수는 엄연히 말하자면 비동기를 순차적으로 처리하기 위한 일종의 편법 같은 것이지 정식으로 지원하는 비동기 전용 함수가 아니다. 따라서 Promise 객체는 이러한 한계점을 극복하기 위해 비동기 처리를 위한 전용 객체로 탄생하게 되었다. Promise는 비동기 작업의 성공 또는 실패와 그 결과 값을 나타내는 객체로 비동기 작업을 쉽고 깔끔하게 연결할 수 있도록 해준다.

 

 

 

콜백 함수를 통한 비동기 처리의 문제점

ES6에 Promise가 도입되어 지금처럼 널리 사용되기 이전에는 아래와 같이 주로 콜백 함수를 다른 함수의 인자로 넘겨 비동기 처리해왔다. 

findUserAndCallBack(1, function (user) {
  console.log("user:", user);
});

function findUserAndCallBack(id, cb) {
  setTimeout(function () {
    console.log("waited 0.1 sec.");
    const user = {
      id: id,
      name: "User" + id,
      email: id + "@test.com",
    };
    cb(user);
  }, 100);
}

// 결과
// waited 0.1 sec.
// user: {id: 1, name: "User1", email: "1@test.com"}

 

단순한 코드를 작성할 때에는 위와 같이 전통적인 방식으로 콜백 함수를 통해 비동기 처리를 해도 문제가 발생하지 않는다. 하지만 콜백 함수를 중첩하여 연쇄적으로 호출해야 하는 복잡한 코드의 경우 계속되는 들여쓰기 때문에 코드 가독성이 현저히 떨어지게 된다. 자바스크립트 개발자들 사이에서 소위 콜백 지옥이라고 불리는 이 문제를 해결하기 위해 여러 방법들이 논의되었고 그 중 하나가 바로 Promise이다.

 

 


 

 

Promise의 개념

 

자바스크립트의 Promise는 처음 접하면 다소 낯설 수 있는 개념이다. 많은 개발자들이 자바스크립트를 공부하는 과정에서 Promise 때문에 포기하게 된다. 그러니 우리는 조금 쉽게 이해해 보자. 우리의 일상 속에서 흔히 일어나는 '약속'이라는 개념으로 접근해 보면 이해하기 훨씬 쉬워진다.

 

여러분이 어린 아이라고 가정해 보자. 그리고 어느 날 아이스크림이 먹고 싶어졌다.

"엄마, 나 아이스크림 사줘!"

 

하지만 엄마는 바로 사주는 것이 아니라 이렇게 답한다.

"조금만 이따가 사줄게."

 

즉 지금 당장은 아무 일도 일어나지 않지만 나중에 어떤 결과가 생길 수 있다는 '약속'이 생긴 것이다. 자바스크립트에서 Promise를 생성하는 상황 또한 이와 유사하다.

const 아이스크림약속 = new Promise((resolve, reject) => {
  // 엄마가 아이스크림을 사주면 resolve,
  // 못 사주면 reject
});

 

얼마 후 엄마가 진짜로 아이스크림을 사줬다면 이 약속은 이행된 것이다. Promise에서는 resolve()가 호출되는 상황이다. .then()은 약속이 지켜졌을 때 실행할 코드를 등록하는 메서드다. 즉 아이가 아이스크림을 받았을 때의 반응이라고 볼 수 있다.

const 아이스크림약속 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("아이스크림 맛있다!");
  }, 3000); // 3초 후에 결과 전달
});

아이스크림약속.then((결과) => {
  console.log(결과); // "아이스크림 맛있다!"
});

 

반대로 엄마가 갑자기 바빠져서 아이스크림을 사주지 못했을 수도 있다. 이 경우는 약속이 지켜지지 않은 상황이며 Promise에서는 reject()가 호출된다. 실패한 경우에는 아래와 같이 .catch()를 사용해서 에러를 처리할 수 있다. 현실적으로는 아이가 실망하는 상황에 해당한다.

let 아이스크림약속 = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject("미안해. 바빠서 못 샀어..");
  }, 3000);
});

아이스크림약속
  .then((결과) => {
    console.log(결과);
  })
  .catch((에러) => {
    console.log(에러); // "미안해. 바빠서 못 샀어.."
  });

 

 

결국 프로미스(Promise)는 지금 당장은 원하는 결과를 얻을 수 없지만 가까운 미래에 그 결과를 제공해 주겠다는 약속인 것이다. 그렇다면 왜 자바스크립트는 이러한 약속(Promise)라는 개념이 필요할까?

 

자바스크립트는 기본적으로 Non-blocking 코드를 지향하는 언어이다. 이는 어떤 작업을 실행할 때 그 결과가 나올 때까지 코드 실행을 기다리지 않고 다음 작업을 계속 진행한다는 뜻이다. 예를 들어 어떤 데이터를 서버에서 받아와야 하는 상황을 가정해 보자. 이 경우 네트워크를 통해 요청하고 서버가 응답을 줄 때까지 기다려야 한다. 파일을 읽거나 데이터베이스에 접근하는 경우도 마찬가지다. 이들 모두 시간이 걸리는 작업인데 그 시간은 사람에게는 잠깐일지 몰라도 컴퓨터, 특히 CPU에서 실행되는 코드 입장에서는 엄청나게 긴 지연 시간(delay)으로 느껴진다.

 

만약 이런 작업이 끝날 때까지 자바스크립트가 모든 동작을 멈추고 기다린다면 어떤 문제가 발생할까? 사용자는 웹 페이지가 멈춘 것처럼 느낄 수 있고 다른 작업은 아무것도 진행되지 않으며 전체 앱의 반응성이 떨어지게 된다. 그래서 자바스크립트는 이러한 작업들을 비동기 방식으로 처리한다. 즉 데이터를 요청해 놓고 기다리는 동안 다른 코드를 ㅁ너저 실행하고 나중에 결과가 준비되면 그때 다시 처리하는 것이다.

 

하지만 이렇게 나중에 결과가 생기는 작업을 처리하려면 뭔가 체계적인 방법이 필요하다. 처음에는 콜백 함수를 사용해서 처리했지만 위에서 언급한 것과 같이 코드가 복잡해지고 가독성이 떨어지는 문제가 생긴 것이다. 이를 해결하기 위해 등장한 것이 바로 Promise이다.

 

 

위에서 봤던 예시는 Promise를 이용해 아래와 같이 작성할 수 있다.

findUser(1).then(function (user) {
  console.log("user:", user);
});

function findUser(id) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log("waited 0.1 sec.");
      const user = {
        id: id,
        name: "User" + id,
        email: id + "@test.com",
      };
      resolve(user);
    }, 100);
  });
}

// 결과
// waited 0.1 sec.
// user: {id: 1, name: "User1", email: "1@test.com"}

 

위 코드는 콜백 함수를 인자로 넘기는 대신 Promise 객체를 생성하여 반환하였고 호출부에서는 리턴 받은 Promise 객체에 then()을 호출하여 결과 값을 가지고 실행할 로직을 넘겨주고 있다. 콜백 함수를 통해 비동기를 처리하던 기존 코드와의 가장 큰 차이점은 함수를 호출하면 Promise 타입의 결과 값이 리턴되고 이 결과 값을 가지고 다음에 수행할 작업을 진행한다는 것이다. 따라서 기존 방식보다 비동기 처리 코드임에도 불구하고 마치 동기 방식의 처리 코드처럼 읽히기 때문에 가독성 면에서 직관적으로 느껴질 수 있다.

 

 


 

 

Promise 생성 방법

 

이번에는 자바스크립트에서 직접 Promise 객체를 만드는 방법에 대해 알아보자.

 

Promise는 new 키워드를 사용해 객체처럼 만들 수 있다. 이때 생성자에는 함수 하나를 인자로 전달해야 하고, 이 함수는 두 개의 파라미터 resolve와 reject를 갖는다.

const promise = new Promise(function(resolve, reject) {
  // 작업 수행 후 성공하면 resolve(), 실패하면 reject()
});

 

이 구조는 사실상 어떠한 비동기 작업을 시도하고 성공하거나 실패하면 알려주겠다는 약속을 만드는 것이다. 실제로는 위처럼 변수에 담아서 쓰기보다는 함수의 반환 값으로 Promise를 만들어 return하는 방식이 더 일반적이다.

 

화살표 함수를 사용하면 아래와 같이 사용할 수 있다.

function returnPromise() {
  return new Promise((resolve, reject) => {
    // 어떤 비동기 작업...
  });
}

 

이 함수 안에서는 상황에 따라 resolve() 또는 reject()를 호출해야 한다. 보통 resolve()에는 나중에 얻게 될 성공 결과 값을 넘기고, reject()에는 실패했을 때 넘길 에러 정보를 담는다.

 

Promise를 직접 만들어 보기 위해 간단한 나눗셈 함수를 예시로 들어보자(실제로 나눗셈을 비동기로 작업할 필요는 없지만 이해하기 쉽도록).

function devide(numA, numB) {
  return new Promise((resolve, reject) => {
    if (numB === 0) {
      reject(new Error("0으로는 나눌 수 없습니다."));
    } else {
      resolve(numA / numB);
    }
  });
}

 

위 함수는 두 숫자를 받아 나눗셈을 시도하고 정상적인 경우 결과 값을 resolve()로 넘기고 나눗셈이 불가능한 경우(0으로 나누는 경우)는 reject()로 에러를 반환한다.

// 정상적인 인자일 때
devide(8, 2)
  .then((result) => console.log("성공:", result))
  .catch((error) => console.log("실패:", error));

// 결과
// 성공: 4


// 비정상적인 인자일 때
devide(8, 0)
  .then((result) => console.log("성공:", result))
  .catch((error) => console.log("실패:", error));
  
// 결과
// 실패: Error: 0으로는 나눌 수 없습니다.

 

이렇게 결과를 보면 나눗셈이 성공했을 땐 .then()이 실행되고 실패했을 땐 .catch()가 실행된다는 걸 확인할 수 있다. 즉 Promise는 비동기 작업이 끝난 뒤 어떤 일이 일어났는지에 따라 적절한 처리를 할 수 있도록 .then과 .catch() 메서드를 제공한다는 것을 알 수 있다.

 

 


 

Promise 사용 방법

 

실제 개발을 하다 보면 우리가 직접 new Promise()를 사용해 Promise 객체를 생성하는 경우는 그리 많지 않다. 대부분의 경우는 이미 Promise를 반환하도록 만들어진 라이브러리 함수나 브라우저 내장 함수를 활용하게 된다.

 

대표적인 예로 브라우저 환경에서 API 호출을 할 때 사용하는 fetch() 함수가 있다. 이 함수는 URL을 인자로 받아 네트워크 요청을 보내고 미래 시점에 얻게 될 그 결과를 Promise 객체로 리턴해 준다. 즉 지금은 결과가 없지만 나중에 응답이 도착하면 알려주겠다는 약속을 만들어 주는 것이다.

fetch("https://jsonplaceholder.typicode.com/posts/1")
  .then((response) => console.log("response:", response))
  .catch((error) => console.log("error:", error));
  
// 결과
// response: Response {type: "cors", url: "https://jsonplaceholder.typicode.com/posts/1", redirected: false, status: 200, ok: true, …}

 

위의 경우 fetch 함수가 리턴한 Promise는 정상적으로 응답을 받아오면서 resolve() 상태가 되고 .then()에 넘겨준 콜백 함수가 호출된다. 실제로 콘솔에는 HTTP 응답 객체가 출력될 것이다.

 

하지만 만약 fetch 함수에 잘못된 인자를 넘기면 어떻게 될까? 예를 들어 인자를 아예 넘기지 않는다면?

fetch()
  .then((response) => console.log("response:", response))
  .catch((error) => console.log("error:", error));
  
// 결과
// error: TypeError: Failed to execute 'fetch' on 'Window': 1 argument required, but only 0 present.
//     at main-sha512-G7qgGx8Wefk5JskAfRw2DfBPNPQTxDC23DcZ+KQTmNoSr2S6pZ3IJgYs1ThvLvvH7uI_KhycDx_FIDNlu5KhOw==.bundle.js:9070
//     at <anonymous>:1:1

 

이 경우에는 fetch()가 실행되자마자 내부적으로 오류가 발생하고 Promise는 reject() 상태가 되며 .catch()에 넘긴 콜백 함수가 실행된다. 콘솔에는 argument required와 같은 에러 메시지가 출력된다.

 

이처럼 프로미스를 사용하는 구조에서 .then()에는 작업이 성공했을 때 실행할 코드를, .catch()에는 실패하거나 예외가 발생했을 때 실행할 코드를 각각 등록할 수 있다. 결과적으로 then-catch 구조는 동기 처리에서 사용하던 try-catch 블록과 유사한 형태로 비동기 상황에서도 정상 흐름과 에러 처리를 분리해서 깔끔하게 코드 구조를 유지할 수 있도록 도와준다.

 

 


 

Promise의 메서드 체이닝(Method Chaining)

앞서 소개했던 .then()이나 .catch() 메서드는 단순히 결과를 처리하는 것에서 끝나지 않는다. 이 메서드들은 새로운 Promise 객체를 리턴하기 때문에 사슬처럼 이어 붙이면서 연쇄적으로 호출할 수 있다. 이러한 방식을 흔히 메서드 체이닝(Method Chaining)이라고 부른다. 즉 하나의 작업이 끝나고 그 결과를 바탕으로 또 다른 작업을 이어나가는 흐름을 콜백 중첩 없이 자연스럽게 구성할 수 있는 것이다.

 

기존에 사용했던 fetch() 예제를 다시 가져와 보자. 기본적으로 fetch() 함수는 네트워크 응답 전체(Response 객체)를 반환하는데 우리가 자주 사용하는 본문 내용(JSON)을 얻기 위해서는 response.json()을 호출해 줘야 한다. 이 또한 비동기 작업이기 때문에 또 다시 .then()으로 이어받아야 한다.

fetch("https://jsonplaceholder.typicode.com/posts/1")
  .then((response) => response.json())
  .then((post) => console.log("post:", post))
  .catch((error) => console.log("error:", error));

// 결과
// post: {userId: 1, id: 1, title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", body: "quia et suscipit↵suscipit recusandae consequuntur …strum rerum est autem sunt rem eveniet architecto"}

 

첫 번째 .then()은 응답을 JSON으로 파싱하는 작업을 하고 두 번째 .then()은 그 파싱된 데이터를 받아소 콘솔에 출력한다. 어떤 단계에서든 문제가 발생하면 .catch()가 실행되어 에러 처리를 담당한다.

 

 

그렇다면 이번에는 좀 더 복잡한 작업을 연결해 보자. 앞선 예시에서 사용자의 ID를 가져와 해당 사용자의 상세 정보를 추가로 요청하는 흐름이다.

fetch("https://jsonplaceholder.typicode.com/posts/1")
  .then((response) => response.json()) // 게시글 데이터를 JSON으로 변환
  .then((post) => post.userId)         // userId만 추출
  .then((userId) => "https://jsonplaceholder.typicode.com/users/" + userId) // 사용자 API URL 생성
  .then((url) => fetch(url))           // 해당 URL로 다시 fetch 요청
  .then((response) => response.json()) // 응답을 다시 JSON으로 파싱
  .then((user) => console.log("user:", user)) // 사용자 정보 출력
  .catch((error) => console.log("error:", error));

// 결과
// user: {id: 1, name: "Leanne Graham", username: "Bret", email: "Sincere@april.biz", address: {…}, …}

 

이처럼 여러 개의 .then()을 이어붙이면 복잡한 비동기 로직도 동기 코드처럼 읽기 쉬운 구조로 만들 수 있다.

 

 

그런데 여기에 한 가지 중요한 포인트가 있다. .then()이나 .catch()에 넘긴 콜백 함수 안에서 Promise 객체를 리턴하든 일반 값을 리턴하든 다음 .then()에서는 항상 그 값을 받을 수 있는 Promise 객체가 자동으로 생성된다는 점이다. 그러므로 아래 두 예시는 모두 유효하다.

.then(() => {
  return 42; // 일반 값
})


.then(() => {
  return fetch(url); // Promise 객체
})

 

이처럼 자바스크립트의 Promise 체이닝은 내부적으로 굉장히 유연하게 동작하기 때문에 단계별로 값을 가공하거나 새로운 비동기 작업을 이어붙이는 작업을 자연스럽게 처리할 수 있다.

 

정리하면 .then()이나 .catch()는 단순히 결과를 처리하는 메서드가 아니라 비동기 작업을 단계적으로 이어서 구성할 수 있도록 도와주는 핵심 기능이라고 할 수 있다. 이러한 체이닝 구조를 이해하고 활용하면 복잡한 비동기 로직도 훨씬 깔끔하게 작성할 수 있다.

 

 

Promise를 사용하면서 자연스럽게 발생하는 이러한 코딩 스타일은 정말 흔히 볼 수 있으며 자바스크립트 개발자들 사이에서도 호불호가 갈리기도 한다. 따라서 최근에는 이러한 Promise를 이용해 메서드 체이닝하는 코딩 스타일은 async/await 키워드를 사용하는 방식으로 대체되는 추세이다. 다음 포스팅에서는 async/await을 사용해 어떻게 자바스크립트 비동기 처리 코드를 개선할 수 있는지 알아보려 한다.

 

 

 

 

 

 

 

728x90
반응형