ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Node.js] 비동기와 Promise 이해하기
    ✍️ 개인 스터디 기록 2022. 11. 27.

    Node.js 에서 promise all을 사용하면 드라마틱한 성능 향상이 있을 수있다.

    const userList = [
      { name: 'ethan', id: 1 },
      { name: "david", id: 2 },
      { name: 'john', id: 3 }
    ];
    
    // 1초가 걸리는 쿼리
    const getUserById = (id) => {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          const [user] = userList.filter(user => user.id === id)
          resolve(user)
        }, 1000)
      })
    }
    
    // 2초가 걸리는 쿼리
    const getAllUsers = () => {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve(userList)
        }, 2000)
      })
    }
    
    const fetchData1 = async () => {
      console.time('소요시간 : ')
      const user = await getUserById(2)
      const userList = await getAllUsers()
      // 불러온 데이터를 출력하기까지 최소 3초가 소요된다.
      console.log(user)
      console.log(userList)
      console.timeEnd('소요시간 : ')
    }
    
    const fetchData2 = async () => {
    	console.time('소요시간 :')
      // Promise.all() 은 실행할 비동기 태스크들이 담긴 배열을 인자로 받습니다.
      const [user, userList] = await Promise.all([getUserById(2), getAllUsers()])
      console.log(user)
      console.log(userList)
      console.timeEnd('소요시간 :')
    }
    
    fetchData1()
    fetchData2()

    왜 이런 결과가 발생한 걸까? 우선 promise가 무엇인지 부터 알아보자!

    promise란 무엇이고, 어떻게 동작하는지

    promise 는 콜백의 단점을 보완하기 위해 등장한 개념이다.

    ☹️ 콜백에는 어떤 단점이 있는데?

    1. 콜백 지옥(뎁스 늘어나 코드 가독성이 떨어지는 문제)아래와 같이 코드의 중첩이 생긴다.
    2. 이전 요청의 값을 가지고 다음 요청을 순차적으로 처리해야 하는 경우 아래와 같이 코드의 중첩이 생긴다.
    get('/setp1', a => {
    	get(`/step2/${a}` b => {
    		get(`/step3/${b}` c => {
    				.
    				.
    				.
    		})
    	})
    })


    2. 에러처리가 어려움(이건 좀 심각한 문제)

    try {
    	setTimeout(()=>{ throw new Error('Error!'); }, 1000);
    } catch (e) {
    	console.err('캐치한 에러', e);
    }

    ⇒ 콜백 함수 ()=>{ throw new Error('Error!'); 을 호출 시킨 것은 setTimeout 함수가 아니다. 따라서 catch 블록에서 에러가 캐치되지 않는다.

    😄 Promise 의 등장

    Promise는 비동기 처리의 “상태”와 “결과” 값을 갖는 객체이다.
    Promise를 가지고 있으면 비동기 처리의 실행부 와는 상관없이 그 비동기 처리의 상태와 결과만 따로 분리가 가능하다!

    프로미스는 처음 생성시 기본적으로 pending 상태이고,
    내부에서 비동기로직을 처리하기 위해 매개변수로 resolve, reject 두개의 콜백을 받는다.
    resolve, reject 두 콜백으로 개발자가 비동기 작업의 결과를 실행과 분리 할 수 있는데 그 예시는 다음과 같다.

    const myPromise = new Promise((resolve, reject) => {
    	//<----------------------------------------------- 비동기 작업 요청 부분
    	if (비동기 처리 성공) {
    		resolve(비동기 처리의 결과 값)
    	} else {
    		// 비동기 처리의 실패
    		reject(실패 처리로 넘길 값)
    	}
    })
    
    
    
    myPromise 	//<------------------------------------ 비동기 작업의 결과 처리 부분
    	.then(data => { console.log(data); })
    	.catch(e => { console.error(e); })

    예시) aws-sdk 에서 제공하는 콜백 방식의 함수 VS promise 방식의 함수

    aws-sdk에서 제공하는 콜백함수는 다음과 같이 콜백방식을 지원한다.

    s3.putObject(params, (err, data) => {
     if (err) {
    	// 여기서 에러 처리
     } else {
    	// 여기서 성공 처리
     }
    });

    만약 aws에서 제공하는 저 함수를 promise 방식으로 쓰고 싶다면 아래처럼 promise를 리턴하는 방식으로 직접 비동기 로직을 사용하는 방법을 생각 해 볼 수 있다.

    const putObjectPromise = new Promise((resolve, reject) => {
    	s3.putObject(params, (err, data) => {
    	 if (err) {
    		// 여기서 에러 처리
    	 } else {
    		// 여기서 성공 처리
    	 }
    	});
    })
    
    putObjectPromise
    	.then(data => { console.log(data); })
    	.catch(e => { console.error(e); })

    하지만 호출부와 결과처리부를 따로 가지고 놀기 위해 저렇게 Promise 로 감쌀 필요 없이 aws-sdk에서는 해당 비동기함수에 프로비스 버전을 지원해준다. 😃

    // 실제로 aws-sdk에서는 해당 비동기함수에 프로비스 버전을 지원해준다. 😃
    // putObject의 promise 버전 함수가 제공 된다.
    const putObjectPromise = s3.putObject(params).promise();
    
    
    putObjectPromise.then((data) => {
    	console.log(data);
    })
    .catch((e) => {
    	console.error('캐치한 에러', e);
    })

    그럼, async / await 이란 무엇이고, 어떻게 동작하는걸까?

    async / await 의 등장으로 비동기 함수를 동기적으로 호출해야하는 상황에서 코드를 더 깔끔하게 작성할 수 있게 되었다.
    비동기 함수를 동기적으로 호출해야하는 상황의 예시를 다시 가져와 보자

    // 1. 콜백을 이용한 코딩 방식
    const main = () => {
    	get('/setp1', a => {
    		get(`/step2/${a}` b => {
    			get(`/step3/${b}` c => {
    					.
    					.
    					.
    			})
    		})
    	})
    }

    step2 에서는 setp1의 결과를 필요로하고, step3에서는 step2의 결과를 필요로 한다.

    이럴 경우 Promise 체인을 사용하거나, async / await 을 이용한 코딩 방식을 적용하는것이 좋은데 async / await 을 사용하면 코드를 동기적으로 보이게 만들 수 있다.(← 위에서 말한 콜백 단점 1 해결)

    const getPromise(url) = new Promise(resolve => {
    	get(url, data => {
    		resolve(data);
    	})
    })
    
    
    // 2. 프로미스 체인을 이용한 코딩 방식
    const main = async () => {
    	getPromise('/setp1')
    	.then(a => {
    		return getPromise(`/step2/${a}`) // Promise를 리턴하므로
    	})
    	.then(b => { // 아래 then에서 결과를 이어서 받아 볼 수 있다.
    		return getPromise(`/step3/${b}`)
    	})
    }
    
    //  3. async / await 을 이용한 코딩 방식
    const main = async () => {
    	const a = await getPromise('/setp1');
    	const b = await getPromise(`/step2/${a}`);
    	const c = await getPromise(`/step3/${b}`);
    	.
    	.
    	.
    }


    그리고 에러 캐치 에서도, async / await 을 사용하면 try-catch에서 에러 캐치가 가능하다.(← 위에서 말한 콜백 단점 2 해결)

    try {
    	setTimeout(()=>{ throw new Error('Error!'); }, 1000);
    } catch (e) {
    	console.err('캐치한 에러', e);
    }
    const timeoutPromise = () => {
      return new Promise((_, reject) => {
        setTimeout(()=>{ reject(new Error('콜백에서 발생한 에러!')); }, 1000);
      })
    }
    
    const main = async () => {
    	try {
    		await timeoutPromise();
    	} catch (e) {
    		console.error('캐치한 에러', e);
    	}
    }
    
    main();

    그렇다면 노드에서 promise의 동작이 어떻게 비동기로 처리되는 것일까?


    노드는 내부적으로 비동기 I/O 처리를 하기 위해 C++로 작성된 libuv 라는 라이브러리를 사용한다.
    그리고 libuv는 이벤트 루프를 가지고 있어서 다음과 같은 여러 페이즈로 비동기로 실행시킨 작업들이 스케줄링 된다.
    ⇒ 라운드 로빈 스케줄링 방식이랑 비슷 (각각의 페이즈는 할당량이 정해져 있어 할당량 만큼만 실행된다.)
    ⇒ 각각의 페이즈는 FIFO 큐를 가지고 있어 각 페이즈 내로 들어온 작업을 스케줄링 한다.

    이벤트 루프에 포함된 6종의 페이즈 이외에
    노드는 별도로 nextTickQueue, 와 microTaskQueue를 가지고 있는데,
    이 두개의 큐는 페이즈에서 관리하는 큐와는 다르게 실행 제한이 없다.
    ⇒ 라운드 로빈 스케줄링 방식이 아님 / 그냥 큐가 다 빌때까지 CPU 점유

    promise의 resolve로 호출된 콜백이 이 microTaskQueue에 쌓여져서 관리 되는데 그렇다면 promise resolve가 무한이 호출 된다면 과연 어떻게 될까? 실행한도가 없으니까 계속 무한이 점유하게 되어서 다른 큐에 쌓인 작업들이 실행이 안될까?

    Micro Task 과다 중첩 예제

    // 1. Timer 를 예약한다.
    setTimeout(() => { console.log('I am Timer!') }, 0);
    
    // 2. Promise 생성
    let myPromise = new Promise(resolve => {
        console.group('Promise start~');
        return resolve(1);
    });
    
    // 3. loopCount 만큼 순회하며 myPromise에 계속 새 fulfilled된 Promise를 연결한다.
    let loopCount = 100000;
    while (loopCount--) {
    	myPromise = myPromise.then(val => {
        console.log('Promise value', val);
        return Promise.resolve(++val) // val을 증가시킨 새로운 fulfilled 된 Promise 생성 후 리턴
      });
    }
    promise start~
      Promise value 1
    .
    .
    .
    	Promise value 99997
    	Promise value 99998
    	Promise value 99999
    	Promise value 100000
    I am Timer!

    ⇒ 타이머스 큐에 등록된 타이머가 microTaskQueue에 Promis then 으로 쌓인 작업이 실행되기 전까지 실행이 안되는 모습

    타이머큐 에 등록된 잡 VS microTaskQueue에 등록된 잡 어떤게 먼저 실행될까?

    const main = () => {
      console.log('start')
      
      setTimeout(() => console.log('timer'), 0)
      
      new Promise((resolve, reject) =>
        resolve('promise')
      ).then(resolve => console.log(resolve))
      
      console.log('end')
    }
    
    main()

    노드 11버전 이전에는 microTaskQueue 검사는 한 틱이 지나고 엿다. 그런데 11버전 이후 부터 microTaskQueue 검사의 우선순위가 이벤트 큐 검사 보다 높게 바뀌었고, 이벤트 루프 수행중에도 microTaskQueue에 완료된 작업이 있는지 바로바로 체크해서 실행한다.

    다시 처음 에 나왔던 예시를 보면 이제 이해 할 수 있다.

    async await 코드를 promise.all로 빠꾸는 경우 드라마틱한 성능 향상이 나오는 경우가 있는데 어떤 경우고, 왜 그러는지

    const userList = [
      { name: 'ethan', id: 1 },
      { name: "david", id: 2 },
      { name: 'john', id: 3 }
    ];
    
    // 1초가 걸리는 쿼리
    const getUserById = (id) => {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          const [user] = userList.filter(user => user.id === id)
          resolve(user)
        }, 1000)
      })
    }
    
    // 2초가 걸리는 쿼리
    const getAllUsers = () => {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve(userList)
        }, 2000)
      })
    }
    
    const fetchData1 = async () => {
      console.time('소요시간 : ')
      const user = await getUserById(2)
      const userList = await getAllUsers()
      // 불러온 데이터를 출력하기까지 최소 3초가 소요된다.
      console.log(user)
      console.log(userList)
      console.timeEnd('소요시간 : ')
    }
    
    const fetchData2 = async () => {
    	console.time('소요시간 :')
      // Promise.all() 은 실행할 비동기 태스크들이 담긴 배열을 인자로 받습니다.
      const [user, userList] = await Promise.all([getUserById(2), getAllUsers()])
      console.log(user)
      console.log(userList)
      console.timeEnd('소요시간 :')
    }
    
    fetchData1()
    fetchData2()

    fetchData1의 경우
    타이머 큐에
    1초 뒤 실행 되는 콜백 정보를 가지고 잇는 타이머가 먼저 타이머 큐에 등록되고,
    이벤트 루프가 돌면서 1초가 지난 시점이 되면 타이머 큐에 등록된 그 타이머를 통해 콜백을 실행한다.(<- 딱 1초 뒤에 실행이 아니라 이벤트 루프 돌다가 타이머 페이즈에 들어가면 1초가 지났는지를 검사해서 그때 실행 됨. )
    그리고 그 콜백이 실행되면서 2초 뒤 실행 되는 콜백이 다시 타이머 큐에 쌓이게 되고 다시 또 2 초가 지나야
    두번째로 들어온 타이머가 큐에서 빠져나가면서 콜백이 실행된다.
    그래서 최소 3초 이상(딱 3초 후에 실행이아니라 최소 3초 까지 보장한다는 거다)이 걸리게 된다.


    fetchData2의 경우
    Promise.all()이 호출 되면서 두 타이머(1초짜리, 2초짜리)가 모두 타이머스 큐에 한번에 등록된다.
    이벤트 루프가 돌면서 1초가 지난 시점이 되면 타이머 큐에 등록된 1초짜리 타이머를 꺼내 콜백을 실행한다.
    더이상 실행 가능한 타이머가 없으므로 다음 페이즈로 이동한다.
    그렇게 이벤트 루프를 돌다가
    그리고 또 1초가 지나(총 2초) 게 되면 타이머큐에 실행가능한 마지막 2초짜리 타이머를 꺼내 실행한다.
    그래서 최소 2초 이상이 걸리게 된다. (타이머스 페이즈에 다시 돌아오는 시간이 딱 2초 라는 보장이 없으므로, 2초 조금 넘을수도 있다.)

    댓글

GitHub: https://github.com/Yesung-Han