블로그

프로미스(Promise) 이해 및 활용하기

등록일
2024-08-13 20:54:00
조회수
352

안녕하세요, Softeer 개발 인턴 소현입니다.


앞으로 Softeer의 개발에 참여하며 웹 개발자가 흔히 겪게 되는 문제 몇 가지를 해결해 본 후

고민한 내용들을 블로그에 정리해볼 예정인데요.

아직 많이 부족하지만 함께 공부한다는 입장에서 읽어주시면 감사하겠습니다.🙇‍♀️



이번 글에서는 JavaScript 프로미스에 대해 다뤄보도록 하겠습니다.


프론트엔드 개발에서 비동기 처리는 필수적인 개념입니다.

자바스크립트에서는 Promise 객체를 사용하여 비동기 처리를 간편하게 할 수 있는데요.

Promise의 메서드와 개념을 예제와 적용 사례를 통해 알아보겠습니다.



Promise 란?

Promise란 비동기 처리를 용이하게 해주는 자바스크립트 객체입니다.

두 가지 주요 인수인 resolve와 reject를 사용하여 비동기 작업의 결과를 처리합니다. 주로 다음 세 가지 메서드를 제공합니다

  • then(onFulfilled, onRejected): Promise가 해결(resolve)되거나 거부(reject)된 후 호출된다.
  • catch(onRejected): Promise가 거부된 경우 호출된다.
  • finally(onFinally): Promise의 상태와 관계없이 항상 호출된다.



1. Promise의 Finally

finally는 인수를 받지 않으며, Promise의 처리 결과에 관계없이 무조건 실행됩니다. → 프로미스 결과 처리를 위한 함수가 아님

  • 프로미스가 처리되면 f 가 항상 실행된다는 점에서 .then(f,f) 과 유사합니다.
  • 성공, 실패 결과에 상관없이 무조건 절차를 진행하고 싶을 때 사용합니다.


예시로는 로딩 인디케이터(loading indicator)와 같이 ‘보편적’으로 처리되어야 하는 코드를 처리하는 경우가 있습니다.



🔗 Finally의 체이닝 유지


finally 블록에서 반환된 값은 무시되고, 이전 Promise의 결과값이 그대로 다음 체인으로 전달됩니다.

→ finally()핸들러는 then() 과 달리 Promise를 실행할 시 finally()를 통과해, 자동으로 다음 핸들러에 결과와 에러를 전달합니다.

e.g.,

Promise.resolve(2).then(() => 77, () => {})의 경우 promise는 77 을 반환하지만,

Promise.resolve(2).finally(() => 77) 의 경우 promise는 2 를 반환합니다.


예제:

다음과 같은 코드가 있다고 합시다. (브라우저에서 실행하기 위한 코드입니다.)

위 코드에서.finally() 를 중간에도 쓰고 마지막에도 썼습니다.

* 로 표시한 .then() 핸들러에서 data 에 접근할 수 있나요?


다음 예시와 같이,

→ finally()는 통과되기 때문에, 뒤에 존재하는 then() 핸들러에서 data에 접근할 수 있습니다.



2. Promise에서 에러 핸들링하기

catch는 Promise가 거부(reject)되었을 때 호출되어 에러를 처리합니다.

catch 블록이 정상적으로 종료되면, 다음 핸들러인 then이 호출됩니다.


예제:

다음과 같은 코드가 있다고 합시다. (브라우저에서 실행하기 위한 코드입니다.)

  1. * 에서 !res.ok 가 true 로 평가되었다면 무슨 일이 일어나나요?
  2. ** 에서 .json() 에서 에러가 발생하면 무슨 일이 일어나나요?
  3. *, ** 에서 에러가 나면 *** 가 실행되나요?
  4. **** 에서 err.message 를 리턴한 것이 어떤 결과를 낳나요? **** 에서 에러 메시지가 Not OK 나 Invalid Data 가 아닌 에러에 대해서 다른 처리를 하고 싶으면 코드를 어떻게 바꾸면 좋을까요?


Promise의 then에서 에러가 발생할 경우, 가장 가까운 catch 문으로 이동하여 해당 에러를 처리합니다.

  1. throw new Error('Not Ok')가 실행되어 catch문으로 이동하여 ‘Not OK’ message를 반환합니다. 그 후 then 함수에서 alert으로 ‘Not Ok’를 보여줍니다.
  2. promise reject 상태가 되어 catch문으로 이동하여, 발생한 에러 메세지를 catch에서 반환 후, alert를 띄웁니다.
  3. 에러가 나면 가장 가까운 catch문으로 넘어가기 때문에 다음 then은 실행되지 않습니다.
  4. err.message를 리턴하면, 그 다음 then에서 alert로 리턴했던 에러 메세지를 띄워줍니다,


에러에 대해서 다른 처리를 하고 싶다면, catch문을 이용한 분기처리를 이용할 수 있습니다.


혹은, 브라우저 환경에선 에러가 발생했을 때, 전역 에러를 처리하는 unhandledrejection 이벤트 핸들러로 처리할 수 있습니다



3. Macrotask, Microtask

이벤트 루프

태스크가 들어오길 기다렸다가 태스크가 들어오면 이를 처리하고, 처리할 태스크가 없는 경우엔 잠드는 끊임없이 돌아가는 자바스크립트 루프

  • Microtask 큐 : 엔진이 바쁠 때의 태스크가 추가되는 큐, 다른 이벤트 핸들러나 랜더링 작업, Macrotask큐보다 우선적으로 처리 (e.g., Promise의 then, catch, finally)
  • Macrotask 큐 : 상대적으로 덜 긴급한 작업을 처리, Microtask가 비었을 때 처리되는 큐 (e.g., script, mousemove, setTimeout, UI 렌더링 이벤트)

매크로태스크 하나를 처리할 때마다 또 다른 매크로태스크나 렌더링 작업을 하기 전에 마이크로태스크 큐에 쌓인 마이크로태스크 전부를 처리합니다.


뿐만 아니라, 프론트엔드 작업에 있어서 렌더링 등 ui 업데이트에 영향을 미칠 수 있기 때문에 실행 순서를 고려해야 합니다.

  • 지연시간이 0인 setTimeout은 이벤트가 완전히 처리되고 난 후(버블링이 끝난 후)에 특정 작업을 수행하도록 스케줄링할 때도 사용될 수 있습니다.
  • 마이크로태스크 전체가 처리되는 동안에는 UI 변화나 네트워크 이벤트 핸들링이 일어나지 않는다.
  • 렌더링이나 네트워크 요청 등의 작업들은 마이크로태스크 전부가 처리되고 난 직후 처리된다.



예제:

다음과 같은 코드가 있다고 합시다. (브라우저에서 실행하기 위한 코드입니다.)

이 코드를 실행하면 등장할 알럿 창의 순서를 예측할 수 있나요? (결정적인가요?)


이벤트 루프 알고리즘은 매크로태스크 큐에서 가장 오래된 태스크를 꺼내 실행 후, 모든 마이크로태스크 작업이 끝나면, 매크로태스크가 실행됩니다.

따라서, code → promise → timeout 순서로 실행되며, 이 동작은 항상 일관되게 일어납니다.



📌적용 사례: 이벤트 처리 최적화

프로젝트에 참여하면서 크롬에서만 macOS 환경에서 한글을 입력할 경우, enter 입력시 이벤트가 동시에 두 번 발생하는 버그가 있었는데요. 원인은 키보드 입력기(IME) 때문이었습니다.

처음에는 .keypress를 이용하여 해결하였으나, 공식문서에서는 이를 더 이상 권장하지 않는다고 적혀있었는데요.


그렇게 다른 방안을 모색하였고, 결론적으로는, throttle 함수를 구현해 해결할 수 있었습니다.

throttle은 이벤트를 일정 시간이 지나기 전에는 다시 호출되지 않도록 하는 기능입니다.


Throttle 함수 구현

해당 throttle 함수는 handle함수를 microtask로 처리하여, macrotask가 완료된 직후에 실행되도록 합니다.

이를 통해 enter키 입력과 같은 이벤트를 순차적으로 처리할 수 있었습니다.



4. Promise 의 Flatten

Promise.resolve

  • Promise.resolve: Promise와 유사한 객체의 중첩된 계층을 단일 계층으로 평면화(Flatten)합니다.
  • 동기적인 코드 또한 wrapping하여 Promise 연산을 수행합니다.
  • e,g,, Promise.resolve(Promise.resolve(1))은 Promise.resolve(1)과 동일하게 동작합니다.

Async-await

  • async-await: Promise 기반으로, 함수 앞에 async를 붙이면 해당 함수는 항상 Promise를 반환합니다.
  • 비동기 코드를 동기적으로 작성하는 느낌을 줍니다.
  • await는 해당 프로미스가 처리될 때까지 함수 실행을 기다리게 만듭니다.
  • 읽고 쓰기 쉬운 비동기 코드를 작성할 수 있습니다.


→ 각 방식에는 장단점이 존재하기 때문에 상황에 맞게 선택해 사용하는 것이 중요합니다.


예제 코드 비교:

code#1

code#2

#1, #2 두 코드 모두 비동기 처리를 수행하는 코드입니다.


code#1의 경우:

  • 이 함수는 async로 정의되어 있습니다. data가 존재할 경우, data는 자동으로 Promise로 감싸져 반환됩니다.
  • data가 없으면, fetchData()를 호출하여 Promise가 해결될 때까지 await으로 기다렸다가 그 결과를 반환합니다.


code#2의 경우:

  • Promise.resolve는 내부적으로 비동기 처리를 체이닝 방식으로 수행합니다.
  • data가 존재할 경우, Promise.resolve(data)로 wrapping되어 반환됩니다. 이는 동기적인 코드도 Promise 흐름을 타도록 할 수 있습니다.
  • data가 존재하지 않으면, fetchData()가 호출되고, 중첩된 Promise를 평면화(Flatten)하여 그대로 반환합니다.

→ code#1과 다르게 추가적인 함수를 정의하지 않고 간결한 코드를 유지할 수 있습니다.


code#3

code#4


Promise의 Flatten 연산:

  • Promise는 함수형 프로그래밍의 Monad 개념을 따르며, 내부적으로 Flatten 연산을 수행합니다.
  • Promise가 아닌 값을 Promise로 wrapping하는 연산도 자동으로 수행합니다.
  • Code#3의 경우, 동기적으로 동작하는 코드(1)도 쉽게 Promise의 제어 흐름을 타게 할 수 있습니다.
  • Code#4의 경우, 여러 겹의 중첩된 Promise도 신경 쓸 필요 없이 단일 Promise로 평면화되어 처리됩니다.



코드 예시: 채팅 페이지에서 대화 ID 처리

다음 코드에서는 convId.value가 존재하면 해당 값을 사용하고, 그렇지 않으면 createConvId()를 호출하여 새로운 값을 비동기적으로 생성합니다.

만약 createConvId()가 Promise를 반환할 경우, Promise.resolve()가 이를 단일 계층으로 변환합니다.

결과적으로 Promise.resolve(convId.value ?? createConvId())는 항상 단일 계층의 Promise를 반환합니다.


이처럼, Promise.resolve와 같은 메서드를 사용하면 중첩된 비동기 작업을 간단하게 처리하여 코드의 가독성과 유지보수성을 높일 수 있습니다.

이를 통해 Top-level Await 때문에 불필요하게 async 함수를 작성하지 않고도 비동기 작업을 효율적으로 처리할 수 있습니다.




참고자료

  • https://ko.javascript.info/promise-basics#ref-205
  • https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/finally
  • https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch
  • https://ko.javascript.info/promise-error-handling
  • https://ko.javascript.info/microtask-queue
  • https://ko.javascript.info/event-loop#ref-687
  • https://ko.javascript.info/async-await
  • https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve

최신 블로그