왜 Promise 인가요?

우현민·2023년 2월 1일
2

javascript

목록 보기
5/5
post-thumbnail

이번 글에서는 Promise 에 대해 알아보겠습니다.

이 글은 Promise 에 대해 이제 막 감을 잡은 분들께 추천드립니다.
카일 심슨의 You Don't Know JS 와 내용이 상당량 겹칩니다. 많이 참고하여 작성했습니다.

비동기?

Promise 를 이해하기 위해서는 먼저 비동기에 대해 이해해야 합니다. 비동기는 말 그대로, 동기가 아닌 것 입니다. 동기 작업이란 선행 작업이 끝난 다음, 다음 작업을 하는 것입니다.

가령, 제가

  1. 밥을 먹고
  2. 다 먹고 나서 유튜브를 보고
  3. 그러고 나서 빨래를 돌렸다면,

이건 동기 작업입니다.

반면

  1. 밥을 먹으면서 유튜브를 보다가 갑자기 생각나서 빨래를 돌리고 다시 밥을 먹으면서 유튜브를 봤다면,

이건 비동기 작업입니다. 둘을 구분하는 핵심은 "앞의 것이 끝나고 나서 다음 것을 했냐, 아니면 앞의 것이 끝나지 않았는데 다음 것을 했냐" 에 있습니다.



웹과 비동기

비동기는 왜 필요할까요? 간단한 웹사이트를 하나 생각해 보겠습니다. 우리의 웹사이트는 아래 작업을 처리합니다.

  • 사이트에 접속했을 때, 서버에게 todo list 를 받아온다.
  • 화면 오른쪽 위에는 프로필 사진 버튼이 있어서, 마우스를 호버하면 투명도가 진해지고, 클릭하면 마이페이지로 이동한다.
  • 화면이 스크롤된다.

자, 이제 제가 사이트에 접속해서 화면 오른쪽 위에 있는 프로필 사진 버튼을 클릭해서 마이페이지로 이동하려 합니다. 우리의 사이트가 동기적으로 작동한다면, 아마 실행 순서는 아래와 같을 거예요.

  1. 사이트 접속
  2. 서버에게 api 를 찔러서 todo list 받아오기
  3. 프로필 사진에 마우스 호버
  4. 프로필 사진의 투명도를 진하게 만듦
  5. ...

이 작업들이 동기적으로 진행된다면, 앞의 정의에 의해 이들 작업은 앞의 것이 끝나야 다음 것이 가능해집니다. 그러니까, 2번 작업을 생각했을 때, 사이트에 접속해서 todo list 가 받아와지기 전까지는 프로필 사진에 마우스를 호버해도 투명도가 진해지지 않고, 클릭해도 마이페이지로 이동할 수 없고, 화면이 스크롤되지도 않습니다.

간단한 예시인데, 실제 웹은 매우 복잡하고 정말 많은 태스크들이 "오래" 걸립니다. 그러니까 요약하자면, 동기적으로 작동하는 웹은 한마디로 끔찍합니다. 이 부분은 비단 웹뿐 아니라 대부분의 프로그램들이, 심지어 프로그램이 아닌 것들에게도 마찬가지로 해당됩니다. 밥을 먹는 동안에는 아무리 불러도 대답하지 않고, 밥을 다 먹고 나서야 "응? 왜 불렀어?" 라며 대답하는 친구가 있다고 상상해 볼까요?



비동기 코드

먼저, 자바스크립트는 싱글 스레드입니다.

싱글 스레드인 자바스크립트가 어떻게 비동기를 처리하는지는 제 다른 글 [JavaScript 이벤트 루프와 비동기 처리]를 참고해 주세요. 이번 글에서는 동작 원리에 대해 다루진 않고, 코드가 어떻게 생겼는지에 대해서만 생각하겠습니다.

그래서 스레드가 있는 언어라면 스레드를 쓰면 되겠으나 - 가령 c언어라면 pthread_create 같은 함수가 있습니다 - javascript 에는 스레드 개념은 없기 때문에 다른 방식을 차용해야 합니다. 어떤 syntax 가 가능할까요?

우리의 미션은 아래의 동작이 가능한 syntax 를 찾는 것입니다.

foo()bar() 를 순서대로 비동기적으로 실행하고, foo() 가 끝나면 바로 이어서 baz() 를 실행해.



콜백

첫 번째 단서는 콜백 패턴입니다.

foo(bar);
baz();

이렇게 함수에 함수를 인자로 넘겨줘서, 함수가 끝난 시점에 다음 함수를 실행하도록 할 수 있습니다. 저렇게 보면 와닿지 않지만, 이렇게 보면 와닿습니다.

setTimeout(() => alert(1), 1000);
console.log(2);

이 코드는 이렇게 동작합니다.

  • setTimeout 를 수행하고, 비동기로 끝나는 걸 기다리지 않고 console.log 수행
  • setTimeout 이 때가 되면 (1초가 지나면) () => alert(1) 을 수행

완벽합니다. 이 패턴을 콜백이라고 하며, 콜백 패턴을 통해 js에서 비동기를 처리할 수 있습니다.

콜백의 문제점

물론 콜백은 비동기를 구현할 수 있는 syntax 입니다. 하지만, 별로 만족스럽진 않습니다.

사고 방식

setTimeout(() => alert(1), 1000);
console.log(2);

위 코드는 잘 동작은 합니다만, syntax 가 조금 어색합니다. 사람 말로 바꾸면,

기다릴거고 기다리는 게 끝나면 alert를 해. 기다리는 시간은 1초야. 2 를 콘솔에 출력해.

딱히, 1초 대기를 걸어두고 2를 출력한 다음 1초 대기가 끝나면 alert 가 일어날 거라고 기대되지 않습니다.

사실 이 문제는 Promise 에서도 마찬가지이고, 인간은 기본적으로 비동기 코드를 작성하는 데에 취약하긴 합니다.

중첩 콜백

콜백 지옥이라고도 불리는 대표적인 문제점입니다. 아래 코드를 볼까요?

document.addEventListener(event => {
  if (event.target.value === 'hello') {
    setTimeout(() => {
      ajax('some-uri', (response) => {
        console.log(response.data);
      })
    }, 1000)
  } else {
    ajax('some-other-uri', (response) => {
      console.log(response.data);
    });
  }
})

저는 잘 이해하지 못하겠습니다. 만약 "저건 원래 어려운 거 아니냐! 억지다!" 라는 생각이 드신다면, await 을 사용한 아래 코드를 볼까요?

document.addEventListener(event => {
  if (event.target.value === 'hello') {
    await delay(1000);
    const response = await ajax('some-uri');
    console.log(response.data);
  } else {
    const response = await ajax('some-other-uri');
    console.log(response.data);
  }
});

콜백은 많으면 많을수록 알아보기 힘듭니다.

제어 권한

사실 가장 심각한 문제는 제어 권한입니다. 비동기의 문제를 떠나서 콜백 패턴 자체의 어쩔 수 없는 문제인데, 콜백 패턴에서는 콜백 함수를 수행하는 권한이 내가 아니라 상대방에게 있습니다.

const foo = (callback) => {
  doSomething();
  callback();
}


foo(() => {
  console.log('1');
})

콜백 함수 () => console.log('1') 을 수행하는 권한은 콜백을 넘겨받은 foo 에게 있습니다. 이게 왜 문제일까요? 아래 상황을 가정해 보겠습니다.

여기 맛있는 5000원짜리 떡볶이를 판매하는 웹사이트가 있습니다. 고객이 웹에서 카드 번호를 입력하고 엔터를 누르면 음식점으로 음식 제작 요청이 들어옵니다. 카드사에서 카드 번호를 입력했을 때 특정 금액을 결제하고 성공 여부를 알려주는 함수를 제공해준다고 가정하겠습니다.

import { purchaseItem } from 'some-bank';

...

const onClickPurchase = (cardNumber) => {
  purchaseItem(cardNumber, () => {
    // 음식 제작 요청 전송
    requestFood();
  })
}

이 코드에서, () => requestFood() 를 수행할 권한은 전적으로 some-bank 에서 만든 purchaseItem 함수에게 있습니다. 그런데 some-bank 사 개발팀의 실수로, 들어온 콜백 함수를 100번 수행하는 테스트 코드가 어떻게 로직이 기가 막히게 꼬여서 프로덕션까지 배포되었다고 가정해 봅시다.

  • 고객이 카드번호를 입력하고 결제 요청을 합니다.
  • purchaseItem 함수 에서 카드번호를 확인하고, 5000원을 결제합니다.
  • 음식점으로 떡볶이 100인분 제작 요청이 들어옵니다 (!!) 👻

이걸 미연에 방지하기 위해선, 클로저를 활용하여 이렇게 구현해야 할 것입니다.

const onClickPurchase = (cardNumber) => {
  let flag = false;
  purchaseItem(cardNumber, () => {
    if (flag) return;
    flag = true;
    // 음식 제작 요청 전송
    requestFood();
  })
}

이걸 모든 콜백에 적용해야겠죠? 상상만 해도.. 끔찍합니다.

억지스럽다고 생각할 수 있지만, 생각보다 써드파티 라이브러리들은 실수를 자주 하곤 합니다. 그리고 이런 실수가 일어날 수 있으니 항상 마음 졸이며 버전을 올리는 것보단, 그냥 제어의 권한을 프로그래머가 가지는 syntax 가 더 좋습니다. 선행해보자면, 만약 purchaseItem 이 Promise 를 반환한다면, 아래와 같이 구현할 수 있습니다.

const onClickPurchase = (cardNumber) => {
  purchaseItem(cardNumber)
    .then(() => {
      // 음식 제작 요청 전송
      requestFood();
    })
}

똑같이 콜백 패턴 아니냐구요? Promise.then 은 javascript 표준 스펙이고, 여기에 버그가 있다면 그건 브라우저 자체에 있는 버그입니다. 브라우저는 써드파티 라이브러리보다는 수백 수천만배 믿을 만 합니다. 사실 javascript 스펙을 믿지 않는다면 믿을 수 있는 게 없기도 합니다.



프로미스

프로미스는 생각보다 가까운 곳에 있는 아이디어입니다. 맥도날드에 가서 키오스크로 햄버거를 주문하면 553번 이라고 적힌 주문 티켓이 나옵니다. 전광판에 553 이라는 번호가 보이면, 점원에게 달려가서 주문 티켓을 주고 햄버거를 가져옵니다. 이 티켓이 바로 Promise 입니다.

엄밀히 말하면, 티켓을 들고 전광판을 지켜보다가 점원에게 달려가는 것까지가 Promise 입니다.

프로미스는 "미래의 값" 을 추상화한 "객체"입니다. 미래의 값은 아래의 3가지 상태를 가지며, 아래 3가지 상태에 해당하지 않는 경우는 없습니다.

  • 아직이거나 (pending)
  • 잘 됐거나 (fulfilled)
  • 안 됐거나 (rejected)

Promise.then 을 통해 pending => fulfilled 로 넘어갈 때 동작을 트리거할 수 있습니다. Promise.catch 를 통해 pending => rejected 로 넘어갈 때 동작을 트리거할 수 있습니다.

자바스크립트는 값을 이리저리 넘기고 변형하며 춤추는 언어이기에 이는 언어 철학에도 훌륭하게 부합합니다. 가령 아래와 같이 Promise 를 반환하고 가공하고 반환할 수도 있습니다.

function foo() {
  return new Promise((resolve) => resolve(1)); // 1로 fulfilled 될 미래값
}

function bar() {
  return foo().then((value) => value + 1); // foo() 미래값에 1을 더한 미래값
}

bar().then(console.log); // bar() 미래값을 출력 -> 2가 나온다.

또한 이런 것도 가능합니다. 아래 코드는 세 개의 요청을 동시에 비동기로 보내고 모두를 기다린 후에 그 다음 작업을 처리합니다.

Promise.all([
  axios.get('asf'),
  axios.get('qwer'),
  axios.get('zxcv'),
]).then(([r1, r2, r3]) => {
  // do something
});

그리고 최근에는 asyncawait 패턴도 추가되었습니다. 이건 정말 "비동기를 동기적으로 읽을 수 있게" 해 줍니다.

const resposne1 = await axios.get('asfd');
console.log(reponse1);
const resposne2 = await axios.get('asfddd');
console.log(reponse2);

돌아가서, 콜백 패턴이었다면 이런 것들은 어떻게 구현해야 했을까요? 저는 도저히 떠오르지 않습니다.



정리하며

이렇게 Promise 는 비동기 미래값을 매우 잘 추상화해둔 객체란 것을 알 수 있었습니다. Promise 가 있기에 비동기를 값으로 다룰 수 있으며, 비동기 이후의 일들에 대한 제어 권한을 프로그래머가 가질 수 있게 되었습니다.

profile
프론트엔드 개발자입니다

0개의 댓글