본문 바로가기

기술들/Today I Learned

비동기, 동기 정리 (feat. callback, promise, async/await)

 Node.js는 비동기 기반의 javascript 런타임이다.

 

여기서 비동기란 무엇일까? 일단 비동기와 대조적인 말인 '동기'란 것의 개념부터 정리하자.

 

'동기적'이라는 말은 어떤 일을 진행할 때, 한 번에 한 가지 일만을 처리하는 것을 말한다. 예를 들어, 카페에서 점원이 손님 A의 주문을 받은 후, A에게 커피를 제공한 다음 다른 손님의 주문을 받는 것을 말한다. 다른 손님들은 손님 A가 커피를 받기전까지 주문조차 못한다. 만약 점원이 '비동기적'으로 일처리를 진행했다면 손님 A의 주문뿐만 아니라 대기하고 있는 다른 손님의 주문을 모두 받은 다음, 차례대로 커피를 제공할 것이다.

 

영어로 동기적은 'blocking' 하다고 하고, 비동기적은 'non-blocking' 하다고 한다. 영어가 더 직관적이다. 

 

이 예시를 볼때, '비동기'가 확실히 일을 진행하는데 효율적이라고 볼 수 있다. 이 '비동기'를 기반으로 하는 Node.js에서는 Javascript로직이 거의 '동시'에 처리된다고 말할 수 있다. 구체적으로 말하면 Node.js는 싱글스레드 기반이기 때문에 '동시'에 진행되진 않는다. 시간이 걸리는 로직을 기다리지 않고 일단 다음 로직으로 넘어가는 것이다. 이렇게 '비동기'적으로 진행된다면 javascript로 짜여진 로직의 실행속도가 상대적으로 빠르다. 

 

그렇지만 반드시 '비동기'만 필요한 것은 아니다. javascript로직을 동기적으로 제어하는 부분도 반드시 필요하다. 

 

예시를 들어보겠다. 

아래와 같이 문자열을 파라미터로 받는 함수 printString이 있다고 해보자. 

function printString (string) {
  setTimeout(
    () => { console.log(string) },
    Math.floor(Math.random()*100) + 1
    )
}

그리고 이 함수를 이용해서 다음과 같이 정의한 printAllString함수를 실행시키면 결과는 어떻게 될까? 

차례대로  a,b,c가 나올까? 

function printAllString() {
    printString("a");
    printString("b");
    printString("c");
}

printAllString();

 

 

실행시켜보면 랜덤되는 순서로 출력될  것이다. 왜냐한면 printString 내부에 setTimeout 함수의 시간설정을 랜덤으로 설정했기 때문이다. 그럼 이 랜덤되는 순서를 어떻게 동기적으로 a, b, c 차례대로 출력되게 할 수 있을까? 

 

첫번째 해결방안 callback함수

callback은 비동기를 제어하는 녀석이다. 'callback'의 의미는 "무슨 일 끝나면 반드시 나한테 전화줘" 라고 볼 수 있다.

위 예시로 의미를 적용하면 a가 출력되면 나를 실행시켜줘(callback, b를 출력하는 일). b가 출력되면 나를 실행시켜줘(callback, c를 출력하는 일)이 된다. 즉, callback을 이용하면 a,b,c 차례대로 출력을 할 수 있게 된다. 

 

 

다음과 같이 printString함수에 두번째 인자로 callback 함수를 넣고 내부에서 실행시킨다.  

function printString (string, callback) {
  setTimeout(
    () => { 
    console.log(string) 
    callback();
    },
    Math.floor(Math.random()*100) + 1
    )
}

그리고 다음과 같이 callback이 적용된 printString 함수를 printAllString에서 다음과 같이 사용한다면

function printAllString() {
  printString("a", () => {
    printString("b", () => {
      printString("c", () => {}
    })
  });
}

printAllString();

첫번째 printString("a", callback)을 통해 console.log("a")로 a가 출력될 것이고, 그 다음 printString함수의 두번째 인자로 입력한 callback함수가 실행되서  b가 출력될 것이다. 

 

이처럼 callback함수를 이용해 동기적으로 차례대로 출력할 수 있지만, 만약 출력해야 하는 글자의 수가 10000개라면? 작동은 하겠지만, 아래와 같은 형태가 될 것이다. 이를 'callback 지옥'이라고 일컫는데, callback함수로직은 멋진 피라미드 형태가 되어 보기엔 예쁘지만 가독성이 매우 안 좋은 비효율적인 로직이 될것이다.  

function printAllString() {
  printString("a", () => {
    printString("b", () => {
      printString("c", () => {},
        printString("a", () => {
    	  printString("b", () => {
      	    printString("c", () => {
          		.......  
            }
      ...
      })
    })
  });
}

printAllString();

 

두 번째 해결방안 promise 객체

callback 지옥을 타파할 수 있는 녀석이다. 

let promise = new Promise((resolve, reject) => {
  if(err) reject(new Error());
    resolve(result);
});

promise 객체는 위의 로직과 같이 new Promise라는 생성자 함수로 생성되며,

안에 executor함수로 구성되어 있는데 이 함수는 new Promise에 의해 자동으로 실행된다. 

//executor 함수
let executor = (resolve, reject) => {
  if(err) reject(new Error());
  
  resolve(value);
}

executor함수는 promise의 핵심이다. 이 함수는 resolve, reject 두 가지의 파라미터를 받는데, 

resolve인자는 executor 함수내의 로직이 성공적으로 끝난 경우, 그 결과인 value를 출력할 수 있다.

reject인자는 반대로 로직이 실패한 경우, err를 출력하는 역할을 한다. 

 

그럼 위 첫번째 블록의 코드에서, 일이 성공적으로 끝난다면 promise라는 변수에는 result 값이 할당될 것이고, 

실패한다면 error가 출력될 것이다. 

 

그리고 promise객체에는 then이라는 메소드가 있다. then을 사용해야 동기적으로 로직을 처리할 수 있는데,

다음과 같이 사용한다. 

let sample = () => {
  return new Promise((resolve, reject) => {
    resolve("then으로 넘길 성공한 값");
    reject("then으로 넘길 err");
  })
}

let a = sample().then(
  (resolve) => console.log(resolve), //comma있음 주의
  (err) => console.log(err);
)

a; // "then으로 넘길 성공한 값"

then의 첫번째 인자로 promise객체에서 resolve 값이 넘어오고, 

then의 두번째 인자로 reject 값이 넘어온다(에러 발생 시). 

 

then은 프로미스 객체에 계속 이어붙일 수 있는데, 첫번째 then에서 나온 결과값을 다음 then으로 값을 넘기기 위해선 

return 을 해주면 다음 then에서 파라미터로 받을 수 있다 .

let sample2 = (a, b) => {
  return new Promise((resolve, reject) => {
    let sum = a+b
    resolve(sum);
  })
}

sample2(2,3)
.then((result) => {
   console.log(result); //5
   return result+3; // 5+3 = 8
})
.then((result2) => {
   console.log(result2); //8
})

// console창에 5, 8 표시됨. 

이렇게 계속 이어 붙이면서 동기적인 처리가 가능하다.  이를 '프로미스 체이닝' 이라고 부른다. 이와 같은 처리가 가능한 이유는 

promise.then을 호출하면 promise가 반환되기 때문이다. promise가 반환되어야만 then 메소드를 사용할 수 있다.

 

 

이를 활용해서 a, b, c를  차례대로 출력해보자. 

우선, printString 함수를 promise 객체를 리턴하는 함수로 바꾼다.

let printString = (string) => {
  return new Promise((resolve, reject) => {
    console.log(string);
    resolve("출력완료");
  }) 
}

let printAllString = () => {
  printString("a")  // a출력
  .then(() => printString("b")) // a 출력과정이 끝나면 printString("b") 실행 => b출력 
  .then(() => printString("c")) // b 출력과정이 끝나면 printString("c") 실행 => c출력
};

printAllString();

 그리고 then메소드를 통해 printString을 차례대로 사용하면 a, b, c 순으로 출력될 것이다.

 

한가지 더, promise를 좀 더 간편하게 쓰는 방법이 있다. async / await 

 

세번째 해결방안 async / await

간단하게 규칙만 알아보자. 

 

< async >

함수 바깥에 async라는 키워드를 붙이면 그 함수를 promise를 반환하는 함수가 된다. 

async function sample3(a,b) {
  let sum = a+b;
  return sum;
}

sample3(2,3).then((result) => console.log(result)) // 콘솔창에 5 출력

 return new Promise()를 async로 대체했다고 보면 된다. 단순히 표현만 달라졌다.

 

< await >

await는 async 함수 안에서만 사용할 수 있다. async 안에 promise로직이 있고 앞에 await를 명시해주면, 자바스크립트가 await를 만났을 때 promise 로직이 처리될 때까지 기다린다.

let sample4 = async () => {
  let promise = (resolve, reject) => {
    resolve("value");
  }
  
  let result = await promise; //result에 "value" 할당
  
  return result;
}

await는 비동기적으로 진행되는 node.js를 동기적으로 처리할 때 많이 쓰인다. 

 

'기술들 > Today I Learned' 카테고리의 다른 글

HTTP Header 정리 (HTTP/1.1 기준)  (0) 2020.11.22
HTTP 기초 정리  (0) 2020.11.20
[Today I Learned] 11월 5일(목) - solo day  (0) 2020.11.05
[Today I Learned] 9월 15일(화)  (0) 2020.09.16
[Today I Learned] 9월 10일(목)  (0) 2020.09.10