본문 바로가기
Back-end/기초부터 따라하는 nest.js

시즌 2 #7-1. 기초부터 따라하는 Nest.js 2 : 동기와 비동기 그리고 Promise

by hsloth 2024. 6. 7.

 
해당 포스팅은 node.js의 기본적인 개념인 비동기와 Promise에 대해서 간단하게 설명하고 넘어가기 위한 포스팅입니다. 굳이 필요가 없으신 분들은 넘어가셔도 됩니다. 나중에 어느정도 기초적인 cs지식을 익히고 나서 보시면 좋을 것 같습니다.
틀린 부분이 있다면 지적해주시면 감사하겠습니다.
 
 
https://suloth.tistory.com/202

시즌 2 #7. 기초부터 따라하는 Nest.js 2 : 간단한 API 구현하기

https://suloth.tistory.com/201 시즌 2 #6. 기초부터 따라하는 Nest.js 2 : Nest.js 프로젝트 생성 & 구조 설명지난 포스팅에서는 간단하게 DB 구조를 짜고, 해당 DB 구조에 대한 설명을 들었습니다.https://suloth.ti

suloth.tistory.com

위 포스팅에서 async와 Promise라는 키워드가 등장했습니다. 그래서 한 번 짚고 넘어가려고 합니다.


동기와 비동기


Node.js의 특징을 검색하면, 가장 많이 보이는 것이 "싱글 스레드"라는 단어입니다.
Node.js를 공부하려면 필수적으로 알아야하는 개념이기도 하고요.
Node.js는 싱글 스레드 기반의 자바스트립트 런타임입니다.
스레드를 하나밖에 사용하지 않아서 한 번에 하나의 요청만 처리할 수 있죠. 여러 명의 사용자가 동시에 "이거 해줘"라고 하면 하나씩 하나씩 처리를 합니다.
 

그런데 좀 이상하지 않나요?

한 번에 하나의 요청밖에 처리할 수 없는데 어떻게 웹 서버로 동작을 할까요? 실제로 서버로 들어오는 요청은 1초에 수천, 수만 건이 될 수 있는데요. 그러면 맨 마지막에 요청한 클라이언트는 앞의 요청이 모두 처리된 뒤 동작하니까 엄청 오랜 시간을 기다려야 하는 거 아닌가요? 컴퓨터는 빠르니까 수만 건의 요청이 들어와도 금방 처리해서 그런걸까요?
물론, 컴퓨터가 빨라서 많은 양의 요청이 들어와도 금방 처리할 수 있습니다. 그런데, 수만 건의 요청 중에서 몇몇 요청이 엄청난 연산량을 요구해서 요청 하나 당 10초의 연산 시간을 잡아먹는다고 하면요? 그러면 어떡하죠? 만약 1만 건의 요청이 들어왔는데, 그 모든 요청들이 다 10초의 연산시간을 필요로 하는 요청이면 10,000 x 10 = 100,000. 즉, 맨 마지막에 요청보낸 사람은 약 10만초 = 약 28시간을 기다려야하는 일이 발생합니다. 엄청 오랜 시간을 기다려야하죠.
 

그래서 이를 해결하는 방법 중 하나가 바로 비동기를 이용하는 것입니다.

비동기란 간단하게 말하면, "순서대로 처리하지 않는 것" 입니다. 이게 뭔 소리죠? 순서대로 처리하지 않는다뇨. 사용자에게 요청이 들어오면 맛집 앞에 길게 줄 서 있는 것 마냥, 순서대로 처리를 해야 사용자들의 불만이 없지 않을까요? 라는 생각이 들 수 있습니다. 맞습니다. 하지만, 중간에 시간이 오래걸리는 요청이 있다면 해당 요청은 가급적 뒤로 처리를 미루는게 뒷 손님들을 위해서 좋겠죠. 그리고 시간이 오래걸리는 요청을 먼저 처리하다보면 뒷 손님들은 아마 지쳐서 떠날겁니다. 그러면 맛집 입장에서는 오히려 손해겠죠?
 

그래서 동기랑 비동기 방식의 차이가 뭔데요?

동기 방식의 경우, 싱글 스레드 환경에서는 스레드 하나가 모든 일은 전부 다합니다. 요청을 받고, 요청에 대한 처리를 하고, 응답을 클라이언트에게 전달하죠.
그래서 여러 요청이 들어오면 스레드 하나를 가지고는 처리가 힘드니, 보통 멀티 스레드를 활용하죠. 스레드 개수를 늘려서 요청에 빠르게 응답할 수 있도록 한 것입니다. 식당으로 치면, 손님이 많아지니 종업원을 늘린거죠.
요청이 20개고 스레드가 10개면, 스레드당 2개의 요청을 처리하면 되니까요.
그리고 종업원 하나가 하나의 주문을 담당하니까 종업원(스레드)끼리 어떤 요리를 어떤 손님에게 갖다 주어야할지 혼선을 빚을 일도 없습니다. 자기가 맡은 일은 자기가 하면 되니까요. (여기서는 종업원이 자기가 맡은 주문에 대한 요리까지 직접 한다고 생각하면 됩니다)
 
반면 비동기 방식의 경우, 스레드는 그저 전달만 해주는 역할을 합니다. 식당으로 치면 웨이터의 역할이죠. 사용자에게 요청을 받고, 요청에 대한 처리는 잠시 보류한 뒤, 다음 사용자의 요청을 받습니다. 이런식으로 사용자의 요청을 먼저 몽땅 받은 뒤에, 처리를 하고 응답을 클라이언트에게 전달하는 방식을 비동기 방식이라고 합니다.
예를들자면, 1인 사업장의 음식점에서 사장님이 손님에게 주문을 먼저 받고 주문을 주방에 전달한 다음에, 요리가 나오면 손님에게 가져다주는 것. 이라고 할 수 있겠네요. 사장님이 한 손님의 주문만 받고 나머지 손님의 주문을 받지 않은채 요리를 계속 하고 있다면, 주문을 받지 않은 손님에게서 불만이 나올 수 밖에 없겠죠? 그래서 미리 주문을 받아서 주문표를 만든 뒤, 해당 주문표를 주방에 전달해주게끔(주방에 주문표를 붙여놓게끔) 하는 거죠. (물론 요리도 사장님이 직접해야하겠지만요 😅)
 
그러면 여기서 궁금증이 생길 수 있습니다. 어? 그러면 어차피 주문만 먼저 몽땅 받고 요리를 한꺼번에 하는거니까 첫 손님이 기다리는 시간이 되게 길어지는거 아니에요? 이러면, 첫손님은 10분만에 받을 수 있는 요리를, 맨 마지막 사람이랑 똑같이 받는 거잖아요. 그러면 첫 손님이 요리가 나오는 시간이 길어져서 식당을 나가는거 아니에요?
맞습니다. 그러면 손님은 분명 나갈거에요.
그래서 Node.js에서는 따로 스레드 풀을 두어 비동기 작업을 처리하도록 하였습니다. 요청을 전달/응답하는 스레드를 하나 두고(싱글 스레드), 뒤에서 비동기 요청의 연산을 담당하는 스레드 풀(여러 개의 스레드)를 두는 것이죠. 그래서 엄밀히 말하면 Node.js는 스레드를 하나만 사용하는 것은 아닙니다. (정확히 말하면 이벤트 루프를 이용한 런타임 환경이 싱글 스레드로 동작합니다. 이벤트 루프를 메인스레드로 활용하는데, 이벤트 루프는 싱글스레드이기 때문에 "싱글스레드"라고 하는 것입니다. 이벤트 루프에 대해서는 나중에 시간이 나신다면 한 번 알아보시는 걸 추천합니다)
식당으로 예를 들자면, 요리사를 고용하는 것이겠네요! 서빙은 한 명이 하고, 요리사가 뒤에서 열심히 요리해서 웨이터한테 전달을 해주면 웨이터는 주문을 받고, 요리를 갖다주는 역할만 하면 되는 것이죠.
 
다만, 이 방식에는 문제점이 있습니다. 멀티 스레드로 비동기를 동작 시킬 경우 비효율적이라는 점입니다. 웨이터가 주문을 주방에 전달하면 주방에서 요리를 만들어줍니다. 그러면 그 사이에 웨이터는 다른 주문을 또 받을 것이고, 이런 식으로 주문이 점점 쌓이고 나오는 요리가 점점 많아지다 보면 어떤 음식이 어떤 테이블로 나가야할지 혼선을 빚을 수 있습니다. 이 주문이 내가 받은 주문이 아니라 다른 웨이터가 받은 주문일 수 있다보니 서로 헷갈리는 것이죠. 이를 해결할 수 있는 방법은 있습니다만, 아무래도 웨이터(스레드)를 하나씩 쓰는 것 보다 비효율적이죠.
그래서 Node.js 진영에서는 싱글 스레드 기반으로 서버를 여러 개 띄우는 전략을 사용합니다. 식당으로 예를 들자면... 같은 이름의 식당으로 분점을 내는 것과 비슷하달까요. 그러면 손님의 수는 분산이 되어 더욱 빠르게 처리할 수 있게 됩니다.
 


그러면, Nest.js 에서 비동기를 다루는 방식을 알아볼까요?


생각보다 어렵지 않습니다. (나중에 복잡한 로직을 다루게 되면 어려울 수 있는데, 그 부분은 나중에 숙련되면 차차 알아보시길 권장합니다)
딱 세 가지만 기억하면 됩니다.

  1. 함수 정의 시, 함수 이름 앞에 async
  2. 함수 호출 시, 맨 앞에 await
  3. 함수의 리턴 타입에는 Promise

간단히 예를들어 보겠습니다.

async sayHello(): Promise<string> {
    return "Hello"
}

async main() {
  const hello = await sayHello()
  
  console.log(hello)
}

main()

 
간단합니다. 끝이에요.
Hello를 리턴하는 sayHello함수를 async로 작성하고, 리턴 타입인 string에 Promise를 감싸주어 Promise<string>을 리턴타입으로 지정해줍니다.
그리고 sayHello 함수를 사용할 때는 await를 앞에 붙여주면 끝이에요.
몇 가지 주의사항이 있긴하지만, 그 부분은 지금은 크게 신경쓸 필요는 없습니다. 나중에 천천히 알아가도록 합시다 ㅎㅎ
 


Promise?


Promise는 Javascript에서 비동기 처리에 사용되는 객체입니다.
다른 언어에서는 Future라고도 하죠. (ex. Dart언어) Future 또한 비동기를 다룰 때 사용되는 객체입니다. (Javascript에서는 아닙니다)
 
Promise나 Future 둘 다 미래와 관련된 단어들입니다.
Promise - 약속이죠. 말 그대로 비동기 처리에 대해 약속을 해놓는 것입니다. "이 비동기 함수는 잘 처리될거야. 약속할게."라는 의미입니다. 하지만, 약속은 언제든지 깨질 수 있다는 점을 명시하시기 바랍니다.
Future - 미래라는 뜻이죠. 비동기 처리에 대한 결과가 어떻게 될지 모르니 Future라는 객체로 감싸서 명시해 주는 것입니다. "이 함수는 비동기 함수라서 미래에 어떻게 될 지 몰라"라고 말이죠.
 
이런식으로 비동기 함수는 처리될지, 안될지 확실하지 않기 때문에 타입을 확정적으로 결정지을 수 없습니다. 그래서 Promise로 감싸주는 거죠.
 
Promise는 기본적으로 fulfilled와 pending, rejected 라는 상태를 가지고 있습니다.
fulfilled는 약속이 지켜졌다는 것을 의미하죠.
pending은 약속을 이행중이다라는 것을 의미합니다.
rejected는 약속이 지켜지지 않았다. (거절당했다) 라는 것을 의미합니다.
 
그림으로 표현하면 다음과 같습니다.

출처 : MDN(mozilla)

일단 약속을 하게되면, pending 상태가 되어 약속이 이행중이라는 것을 알려줍니다. 그리고 만약 약속이 지켜지면 fulfill 되어 비동기에 대한 로직이 돌아가게됩니다. 
만약 약속이 지켜지지 않으면, catch를 통해 약속이 지켜지지 않았다는 것을 에러로 처리할 수 있습니다.
 
그 뒤 부터는 무한 반복입니다. 약속이 지켜지든 안지켜지든 해당 결론을 통해 또 Promise를 만들 수 있거든요. (위 그림에서 async actions와 error handling부분 오른쪽 부터는 보실 필요 없습니다)
 
Promise 객체를 직접적으로 다루기 위해서는 callback함수와 resolve, reject를 이용하여 다룰 수 있는데, 그 부분은 궁금하시다면 따로 찾아보시기 바랍니다. 생각보다 간단합니다.
여기서는 Promise의 개념만 짚고 넘어가겠습니다.
 

async function a(): Promise<string> {
    return "1";
}

위 구문을 해석하자면, "a라는 함수는 string을 반환한다고 약속이 되어있는 함수야. 근데, string을 반환하지 않을 수도 있어"라는 뜻입니다. 여기서 주의할 점은, Promise를 리턴타입으로 가지면 함수 앞에 무조건 async 키워드가 있어야한다는 겁니다. (async는 비동기 라는 뜻이라고 생각하면 됩니다)
비동기 연산은 스레드 풀에 맡긴채로, 싱글 스레드는 해당 비동기 연산이 완료 된다면 그와 관련된 나머지 작업들을 진행합니다.
물론 비동기 연산이 어떠한 이유에서 실패하면, 비동기 연산이 실패했다는 메시지와 함께 에러를 내뱉습니다.
 
그래서 만약에 x라는 함수에서 y를 참조하고, y라는 함수에서 z를 참조하고, z라는 함수에서 a를 참조하게 되면 x, y, z 함수들도 모두 다 리턴 타입이 Promise로 감싸지게 됩니다. a가 에러를 내뱉으면 x, y, z함수 모두 제 기능을 할 수 없으니까요. 그래서 비동기 관련 로직이 1개만 있어서 관련된 함수는 모두 Promise타입이 됩니다.
 
그래서 a함수의 Promise<string>에서 string 타입을 꺼내서 사용하고 싶으면 어떻게 하는데요?

async function z(): Promise<string> {
    const res = await a();
    
    return res;
}

 
간단합니다. 위와 같이 async 함수 내부에서 비동기 함수를 호출할 경우, await 구문을 비동기 함수를 호출할 때, 앞에 붙여주면 됩니다.
 

오른쪽 그림은 await를 붙여서 res가 string 타입이 된 모습입니다.

 
함수를 호출할 때 await 키워드를 붙이면 해당 함수가 약속을 이행할 때까지(약속을 지키거나, 안지키거나 둘 중 하나) 기다렸다가, 약속이 지켜지면 나머지 로직을 실행하기 때문입니다.
즉, 왼쪽 그림에서는 a함수를 호출한 뒤, a함수의 약속이 지켜지는지 기다리지 않고 바로 res를 리턴해버립니다.
반면 오른쪽 그림에서는 a함수를 호출한 뒤, a함수의 약속이 지켜지는지 기다렸다가 약속이 지켜진다면, string타입을 반환하는 것을 알기 때문에 res에 string데이터를 대입한 뒤, return res; 를 실행합니다.
 
그래서 왼쪽 그림에서는 res를 Promise<string>타입인 채로 리턴하기 때문에 string관련 함수를 사용할 수 없지만, 오른쪽 그림에서는 res가 string타입이기 때문에 string 관련 함수를 사용할 수 있습니다.

 
왼쪽 그림에서는 split 함수에 빨간줄이 쳐져있는 것을 확인할 수 있습니다. 에러 메시지를 읽어보면, Promise<string> 타입에는 split이라는 함수가 존재하지 않는다고 되어있네요. 그래서 await을 잊어버리고 안 적은게 아니냐고 물어봅니다.
 
이런식으로 비동기 함수에 await를 적어가면서 로직을 천천히 짜시면 됩니다. 다만, 비동기의 경우 동시성을 다루기 매우 어렵기 때문에 주의하셔서 사용하셔야합니다. 물론, 지금 단계에서 그런 것까지 고민할 필요는 없습니다. 아~ 그렇구나! 하고 넘어가주세요.
 
이상으로 Promise에 대한 기본개념 포스팅을 마치겠습니다. 감사합니다.
 
혹시 부족한 부분이 있다면, 댓글을 통해 말씀해주시면 감사하겠습니다.