thumbnail

promise를 탐구해 보기

생성일2024. 4. 17.
태그
작성자지한솔

notion image
우리는 많은 프로젝트를 진행하면서 Promise를 사용하곤 합니다. 요즘같은 대 개발자 시대에는 async, await 으로 비동기 처리를 마치 동기처럼 진행하기 때문에 순서를 고려할 상황이 많이 줄어들게 됩니다.
하지만, 이 개발 방식은 우리로 하여금 promise에 대한 지식과 멀어지게 만듭니다. 그렇기 때문에 promise에 대해 다시 정의하는 시간과, 효율적으로 사용하는 방식에 대해 알아보고자 합니다.

Promise 는 프로미스가 생성된 시점에는 알려지지 않았을 수도 있는 값을 위한 대리자로, 비동기 연산이 종료된 이후에 결과 값과 실패 사유를 처리하기 위한 처리기를 연결할 수 있습니다. 프로미스를 사용하면 비동기 메서드에서 마치 동기 메서드처럼 값을 반환할 수 있습니다. 다만 최종 결과를 반환하는 것이 아니고, 미래의 어떤 시점에 결과를 제공하겠다는 '프로미스(promise)'를 반환합니다.
위에서 설명했듯이 비동기 메서드에서 마치 동기 메서드 처럼 값을 반환할 수 있다 로 되어있습니다. 하지만, 이 값은 최종 결과를 반환하는 것이 아닌, 미래에 값을 제공하겠다는 promise를 반환하는 것입니다.
이 promise를 어떻게 우리가 원하는 값으로 변환할 수 있을까요? resolve를 통해 우리가 원하는 시점에 값을 제어, 단순한 promise에서 then을 통해 값을 핸들링 할 수 있습니다.
const promise1 = Promise.resolve(123); promise1.then((value) => { console.log(value); // Expected output: 123 });

Promise는 이벤트 루프의 순서가 어떻게 될까?

이벤트 루프에서 Promise는 하나의 비동기 테스크로 동작합니다. 여기서 조금 더 딥하게 들어가자면, Promise는 비동기 테스크 중 마이크로 테스크에 해당합니다. setTimeout, setInterval 과 같은 js상의 테스크를 매크로테스크, Promise로 되어있는 테스크를 마이크로테스크 라고 합니다.
console.log('스크립트 시작'); setTimeout(() => { console.log('setTimeout 실행'); // 매크로테스크 }, 0); Promise.resolve().then(() => { console.log('Promise 1 해결됨'); // 마이크로테스크 }).then(() => { console.log('Promise 2 해결됨'); // 마이크로테스크 }); console.log('스크립트 종료');
위 순서대로 코드를 리딩한다면, setTimeout, Promise는 비동기 테스크, 따라서
  • ‘스크립트 시작’
  • ‘스크립트 종료’
순으로 콜스택이 종료될 것입니다. 그럼 비동기 테스크엔 setTimeout, Promise가 쌓이게 되는데, 여기서 마이크로테스크인 Promise가 우선 실행될 것입니다. 따라서 최종 결과는
결과 확인하기
스크립트 시작
스크립트 종료
Promise 1 해결됨
Promise 2 해결됨
setTimeout 실행
로 끝나게 됩니다. 그렇다면 setTimeout과 Promise를 섞어서 사용하게 된다면 어떻게 될까요? 좀 더 극단적인 예시를 사용해보겠습니다.
console.log('스크립트 시작'); setTimeout(() => { console.log('첫 번째 setTimeout 시작'); Promise.resolve().then(() => { console.log('첫 번째 setTimeout 내의 Promise 해결'); }); console.log('첫 번째 setTimeout 종료'); }, 0); Promise.resolve().then(() => { console.log('첫 번째 Promise 해결'); setTimeout(() => { console.log('첫 번째 Promise 내의 setTimeout 실행'); }, 0); }); console.log('스크립트 종료');
위 내용에 대한 결과는 아래와 같습니다.
결과 확인하기
스크립트 시작
스크립트 종료
첫 번째 Promise 해결
첫 번째 setTimeout 시작
첫 번째 setTimeout 종료
첫 번째 setTimeout 내의 Promise 해결
첫 번째 Promise 내의 setTimeout 실행

자 그럼, Promise의 테스크 우선 순위에 대해서도 알아봤으니, 이젠 Promise를 활용한 간단한 코드들을 작성해 보고자 합니다.

Promise를 활용한 코드 예제

React 19 도큐먼트에서는 다음과 같은 예제 코드를 보여줍니다.
function UpdateName({}) { const [name, setName] = useState(""); const [error, setError] = useState(null); const [isPending, setIsPending] = useState(false); const handleSubmit = async () => { setIsPending(true); const error = await updateName(name); setIsPending(false); if (error) { setError(error); return; } redirect("/path"); }; return ( <div> <input value={name} onChange={(event) => setName(event.target.value)} /> <button onClick={handleSubmit} disabled={isPending}> Update </button> {error && <p>{error}</p>} </div> ); }
물론 before 코드로, 이 코드를 useTransition으로 변경하는 내용에 대한 내용이지만, 우린 위 코드를 가지고 이야기를 할 예정입니다.
간단한 button에 disabled를 통해, 더블클릭을 방지하는 코드입니다. 여기서 우리가 알고있는 useState의 속성에 의문이 생깁니다.
useState는 하나의 콜스택에서 배치로 동작하기 때문에, 모든 setState를 모아 한번에 처리를 하게 됩니다. 그래서 이전값을 참조하기 위해선
const test = () => { setState((prev) => prev + 1); setState((prev) => prev + 1); }
위 처럼 이전 값을 참조해야 하죠. 그렇다면 setIsPending 이 setState가 하나의 콜스택 안에 정의되어 있는데, 어떻게 저 코드가 문제없이 동작하는 것일까요? 이 내용을 상세하게 콜스택을 통해 파악해 보고자 합니다.

1. handleSubmit 호출 시작

  • 사용자가 Update 버튼을 클릭할 때 이 함수가 호출됩니다. 이 시점에서 handleSubmit 함수가 콜 스택에 푸시됩니다.

2. setIsPending(true) 실행

  • setIsPending 함수가 콜 스택에 푸시되고 실행됩니다. 이 함수는 내부적으로 React의 상태를 업데이트하여, 로딩 상태를 나타내는 UI를 활성화시킬 수 있습니다.
  • setIsPending 함수의 실행이 완료되고 콜 스택에서 팝됩니다.

3. updateName(name) 호출

  • updateName 함수가 콜 스택에 푸시됩니다. 이 함수는 비동기 함수로, 서버에 이름을 업데이트하는 HTTP 요청을 보낼 수 있습니다.
  • 비동기 함수 updateName은 Promise를 반환하고, 네트워크 요청을 처리하는 동안 콜 스택에서 팝됩니다. 이 때, 네트워크 요청은 Web API 또는 브라우저의 백그라운드에서 처리됩니다.

4. 비동기 대기 (await updateName(name))

  • await 키워드에 의해 updateName 함수의 Promise가 해결될 때까지 handleSubmit 함수의 실행이 일시 중단됩니다. 이 동안 다른 스크립트 실행이 가능해집니다.

5. updateName의 Promise 해결

  • updateName의 네트워크 요청이 완료되고 결과가 Promise에 의해 반환됩니다. 이 결과는 error 변수에 저장됩니다.
  • handleSubmit 함수의 나머지 부분이 콜 스택에 다시 푸시되어 실행을 재개합니다.

6. setIsPending(false) 실행

  • setIsPending 함수가 다시 콜 스택에 푸시되어 실행됩니다. 이번에는 로딩 상태를 비활성화하는 역할을 합니다.
  • 함수 실행 완료 후 콜 스택에서 팝됩니다.

7. error 체크 및 처리

  • if (error) 조건문이 평가됩니다. 에러가 있으면 setError(error)가 콜 스택에 푸시되어 실행됩니다.
  • 에러를 처리하고 사용자에게 피드백을 제공한 후, return 문에 의해 handleSubmit 함수에서 나옵니다.

8. redirect("/path") 실행

  • 에러가 없는 경우, redirect 함수가 콜 스택에 푸시되어 실행됩니다. 이 함수는 사용자를 다른 경로로 리디렉션합니다.
  • 함수 실행 완료 후 콜 스택에서 팝됩니다.

9. handleSubmit 종료

  • handleSubmit 함수의 모든 실행이 완료되고, 콜 스택에서 팝됩니다.

콜스택의 순서는 위와 같습니다. setState도 결국 실행이 되어 콜스택을 빠져나갈 뿐 랜더링 성능을 위해 배치 비동기 처리를 진행하는 것입니다. 여기서 처리되는 비동기 처리는 매크로테스크와 마이크로테스크 둘과 무관합니다. 비동기로 상태 업데이트를 스케줄링 하지만, 실제 Dom업데이트는 React의 렌더링 주기에 의해 결정되기 때문입니다.
따라서, 2번의 true는 실행되며 pop되고, 그 이후 promise를 만나 처리하기 전, 업데이트가 실행되게 됩니다. 그로인해 버튼은 disabled 상태가 되는 것입니다.
여기서, 버튼의 중복 클릭을 고려하지 않고 개발을 하다보면 마주치는 이슈가 있습니다.
어? 왜 같은 유저인데, 데이터가 두개가 있나요??
심장떨리는 위 말을 이은 화룡 정점..
어? 세개나 있네요?
이 말을 들으면 백엔드와 프론트엔드는 긴장상태에 들어갑니다. 그치만, 모두가 2개씩, 3개씩이 아니라 여러개가 동적으로 있다면 프론트엔드 이슈일 확률이 거의 99%에 가까워지죠..
그럼 우리는 위 상황을 토대로 버튼 컴포넌트를 만들고, 그 버튼의 onClick에 해당하는 비동기 처리들이 시작되기 전, disabled를 true, 처리가 완료되는 시점에 disabled를 false로 바꾸는 컴포넌트를 만들 수 있습니다.
const Button = ({ children, onClick, ...props }: Props): JSX.Element => { const [isLoading, setIsLoading] = useState(false); const onClickHandler = (event: MouseEvent<HTMLButtonElement>) => { setIsLoading(true); Promise.resolve(onClick?.(event)).finally(() => { setIsLoading(false); }); }; return ( <button disabled={isLoading} onClick={onClickHandler} {...props}> {children} </button> ); }; export default Button;
click 이벤트의 성공 유무와 상관 없이 loading을 false로 바꿔줄 수 있습니다.
우리는 이렇게 Promise 내부를 핸들링해 하나의 컴포넌트를 설계했습니다. 이러한 컴포넌트 설계 이외에도 Promise를 활용한 다른 예제들도 많이 사용할 수 있습니다.

프로미스 기반 탭 컨트롤러

import React, { useState, useEffect } from 'react'; function TabController({ tabs }) { const [activeTab, setActiveTab] = useState(tabs[0].label); const [content, setContent] = useState(''); useEffect(() => { const currentTab = tabs.find(tab => tab.label === activeTab); currentTab.content().then(setContent); }, [activeTab, tabs]); return ( <div> <ul> {tabs.map(tab => ( <li key={tab.label} onClick={() => setActiveTab(tab.label)}> {tab.label} </li> ))} </ul> <div>{content}</div> </div> ); } // tabs를 다음과 같이 사용할 수 있습니다: // const tabs = [ // { label: 'Home', content: () => Promise.resolve('Home content') }, // { label: 'Profile', content: () => Promise.resolve('Profile content') }, // { label: 'Settings', content: () => Promise.resolve('Settings content') } // ];
promise를 제대로 다룰 줄 안다면, 여러가지 비동기 처리의 컴포넌트를 유연하게 구현할 수 있을 것입니다. 하지만, async await만 배우는 학습을 진행한다면, Promise에 무뎌지게 되고, 결국 유연하지 못한 동기적 프로그래밍만을 수행하게 될 것입니다.
뭐가 정답인진 알 수 없으나, 이왕이면 여러가지를 알 수 있는 상태가 조금 더 좋지 않을까요? 저는 그렇게 생각합니다. 유연한 사고를 위해 promise를 공부해보는것도 좋을 것 같습니다.
감사합니다.