동기 / 비동기

람뽀·2024년 5월 1일

More study

목록 보기
2/4
post-thumbnail

동기 / 비동기

자바스크립트는 위 이미지와 같이 코드가 순차적으로 실행된다. 이것을 동기적으로 동작한다고 말한다. 각 코드는 차례대로 순서를 기다렸다가 실행된다. (출처)그런데 세번째 줄에 settimeout메소드를 추가해서, 5초후에 콘솔에 3이 출력되도록 했다.
결과는 1 -> 2 -> 5초후 3 -> 4 이렇게 동작하지 않고 4가 먼저 출력된 후 5초후에 3이 출력됐다. 3번째 코드는 전체적인 흐름에서 벗어나서 독립적으로 자신의 시간표에 따라 동작했다. 이렇게 병렬적으로 실행되는 방식을 비동기적이라고 말한다.
동기적인 실행 방식은 순서가 있기 때문에 코드의 실행 순서와 결과를 파악하기 쉽지만 실행 시간이 길어진다.

반면에 비동기적인 방식은 동시에 여러가지 일이 일어나서 순서와 결과를 파악하기 힘들지만, 실행 속도가 빠르다는 장점이 있다.

그래서 보통 언제 끝날지 예측이 되지 않는 작업이나, 주요 작업이 아닐 경우에 비동기적으로 처리한다. 대표적으로 통신이 있다.

서버와 브라우저의 통신은 언제 끝날지 예측 할 수 없다. 그러면 실행 완료까지 막연히 기다리기보다 그 시간에 다른 일을 처리하다가 통신이 완료되면 그 때 콜백을 호출하여 필요한 작업을 처리하는 방식이 더 효율적이다.
익숙한 네이버 사이트를 들어가보았다. 자세히 보면 화면의 일부는 다른곳 보다 늦게 화면에 그려진다.

그것은 해당 영역은 자바스크립트가 데이터 통신을 끝마치고 필요한 광고 정보 등을 받아 온 후에야 화면을 그릴 수 있기 때문이다.

만약 이 과정이 동기적으로 진행되면 화면은 데이터 통신을 하는 영역이 그려지기 전까지 실행을 멈추고 통신이 끝난 후에 다시 실행되어 화면의 다음 부분을 그릴것이다.


혹은 개발자도구의 네트워크탭에 들어간 후 검색창에 어떤 단어를 검색하면, 브라우저와 서버가 통신을 하면서 새로운 정보를 가져오고 그것이 화면에 그려지면서 추천목록이 뜨는 것을 볼 수 있다.

새로운 데이터를 통신해왔는데 화면 전체를 새로 그리지 않고 일부만 수정한다. 이것이 웹에서 화면을 비동기로 처리하는 대표적인 Ajax방식이다.

만약 이렇게 비동기적으로 동작하지 않으면 우리는 통신을 하는동안 웹페이지를 전혀 조작 할 수 없게 될 것이다.

비동기적으로 다른 서버에게 데이터를 요청할때 XMLHttpRequest 객체나 혹은 fetch 메서드로 요청을하게 하는데, 서버로부터 응답을 기다리는 동안에도 사용자와의 인터랙션을 유지할 수 있으므로 사용자 경험을 향상시킬 수 있게 된다. (출처)

자바스크립트는 본래 동기적인 작업만 처리 할 수 있다. 그것은 자바스크립트가 싱글스레드언어이기 때문이다.

스레드란 자바스크립트 엔진이 작업을 할 수 있는 계산기라고 생각하면 된다. 즉 자바스크립트에는 작업을 처리 할 수 있는 공간이 하나라서 한번에 한 작업만 처리 할 수 있는 것이다.

그러면 자바스크립트는 왜 싱글스레드로 동작할까?
자바스크립트는 원래 웹페이지의 보조적인 기능을 수행하려고 만들어진 언어이다.
(추가로 자바스크립트 딥다이브에서는 애초에 자바스크립트는 사용자(개발자)의 편의성과 작업효율성을 위해 쉽게 고안된 언어라고 되어있다. 그렇기 때문에 상대적으로 접근이 쉬운 동기적인 방식을 채택한 것 같다.)
멀티 스레드인 자바는 다소 무겁고 어렵다는 인식이 있고, 동시성 문제가 존재하다보니, 복잡한 시나리오를 신경 쓸 필요가 없는 싱글 스레드 형식이 채택되었었다.
https://inpa.tistory.com/entry/%F0%9F%8C%90-js-async해당 블로그 글을 읽어보면 멀티스레드의 힘듬(?)을 간접적으로 설명해준다

하지만 아까 첫 예시에서는 setTimeout을 이용하여 자바스크립트가 비동기적으로 동작하는 것을 보았다. 어떻게 가능한 것일까? 이것은 자바스크립트가 동작하는 런타임환경을 살펴보면 알 수 있다.
자바스크립트를 실행시키는 자바스크립트 엔진에는 하나의 메모리힙과 스택이 존재한다. 메모장과 계산기가 한개씩 존재하는 것이다.

with chatGPT
메모리 힙(Memory Heap): 메모리 힙은 동적으로 할당된 메모리가 저장되는 곳입니다. 객체, 변수 및 함수의 인스턴스와 같은 모든 동적 할당 메모리가 여기에 저장됩니다. 메모리 힙은 코드 실행 중에 동적으로 크기가 조정될 수 있습니다. 이는 런타임 중에 필요한 만큼의 메모리를 할당하고 해제하여 자원을 효율적으로 관리할 수 있도록 해줍니다.
콜 스택(Call Stack): 콜 스택은 현재 실행 중인 함수의 정보를 저장하는 스택(Stack) 구조입니다. 함수가 호출될 때마다 해당 함수의 정보(로컬 변수, 매개변수, 반환 주소 등)가 콜 스택의 맨 위에 푸시(push)되고, 함수가 종료될 때 해당 정보가 팝(pop)되어 스택에서 제거됩니다. 이를 통해 자바스크립트 엔진은 어떤 함수가 현재 실행 중인지 추적하고 함수 호출과 반환을 관리할 수 있습니다. 이는 함수 호출의 순서와 상태를 추적하여 실행 흐름을 제어하는 데 사용됩니다.
-> 쉽게 변수가 저장되는 부분 / 함수가 실행되는 부분 이라고 생각해도 될 것 같다.


위 코드에는 3개의 함수가 있다. printSquare함수는 square함수를 호출하고, square함수는 multiply함수를 호출하는 콜백함수의 형태이다. 그래서 각 함수는 하나씩 처리 되지 않기 때문에
스택은 아래부터 함수를 쌓아 오른쪽 이미지와 같은 상태가 되고, 함수를 처리 할 때는 위에 있는 것부터 차례대로 실행한다.

그러다가 square함수가 실행되고 나면 다음 메소드인 console.log를 스택에 담아 실행하고, printSquare함수를 처리하고 작업을 완료시킨다.

이렇게 자바스크립트는 본래 동기적으로 동작한다.

그러나 자바스크립트 엔진이 동작하는 브라우저 환경에는 Web API / callback queue / event loop가 존재한다.

Web API는 웹브라우저에서 제공하는 API로 비동기로 처리되는 작업을 실행한다. 사실 아까 사용한 settimeout은 자바스크립트가 제공하는 메소드가 아니라 웹 브라우저가 제공한 api였던 것이다. 그렇기 때문에 js엔진에서 동작하는것이 아니라 브라우저 환경에서 동작한다. 이벤트나 dom조작, ajax또한 마찬가지이다.

그리고 실행이 완료되면 callback queue는 Web API에서 넘겨받은 Callback 함수를 저장하고, event loop는 js엔진의 callstack을 살펴보다가 비어있으면 그 때 callback Queue의 작업을 Call Stack으로 옮긴다.

위 코드를 예시로 살펴보자. 스택은 본래 하나의 작업만 처리 할 수 있으니까 Hello를 띄우고 5초후에 OzCoding을 뛰우고 그 후에 School을 화면에 띄울까?

첫줄인 console.log('hello')는 콜백함수가 아님으로 스택에 담겼다가 바로 실행되어 콘솔창에 Hello를 띄운다.

다음줄인 setTimeout은 스택에 쌓이는 것이 아니라 WebApi에서 동작하게 된다. setTimeout의 콜백함수인 function(){console.log('OzCoding')} 은 콜백큐에 담겨 5초간 대기한다.

그러면 스택이 비게 되니까 엔진은 다음 코드인 console.log('School')를 스택에 담아 실행하고 콘솔창에는 Hello와 School이 찍히게 된다.

이후 5초가 지나면 이벤트루프는 스택을 확인하고 스택이 비어있느면 콜백큐의 함수를 스택으로 옮기고, js엔진은 그것을 실행하여 콘솔창에 띄운다. 결과적으로 Hello School OzCoding순서로 출력되게 된다.

이렇게 비동기작업은 시간을 단축시키고 다음 동작을 할 수 있게 하는 장점이 있지만, Hello School OzCoding과 같이 의도하지 않은 방향으로 동작 할 수도 있다.

만약 처음 의도대로 Hello -> 5초 후 OzCoding -> School로 출력되게 하려면 어떻게 해야할까?

바로 비동기콜백 / promise / async await 이다.

이것들은 비동기코드를 순서에 맞게 동기적으로 동작하도록 사용하기 위한 수단이다.


비동기 콜백

콜백함수

콜백함수는 함수의 인자로 전달되는 함수로, 매개변수로 사용되어 다른 함수에 의해 호출되는 함수를 말한다.

콜백함수라는 개념 자체는 비동기와 관련이 없는 말이지만, 하나의 함수가 호출 되어야 인자로 사용된 함수도 호출 될 수 있다는 점을 이용하여 비동기 코드를 동기적으로 처리할 수 있다.

함수1((함수2){
  함수1이 처리할일
  함수2(함수3){
  	함수2가 처리할일
  	함수3()
  })
})

형태는 위와 같다.

함수1의 인자로 함수2를 받는다.
함수1의 일이 처리되면 함수2가 호출된다.
함수2는 함수3을 인자로 받는다.
함수2의 일이 처리되면 함수3을 호출한다.
함수3이 실행된다.

이런식으로 작업에 동기성을 부여하는 것이다.
아까 예시에 적용해보면, 의도대로 콘솔에 출력되는 것을 볼 수 있다.

비동기 콜백의 단점은 콜백해야 하는 함수가 많아질수록 코드가 복잡해지는 콜백지옥 callback hell에 빠질 수 있다는 것이다.

이러한 단점을 보안하기 위해 나온 것이 promise다.


Promise


promise객체는 위와 같은 문법으로 만들 수 있다.

new Promise에 전달되는 함수는 executor(실행자, 실행 함수) 라고 부른다. executor는 new Promise가 만들어질 때 자동으로 실행되는데, 결과를 만들어내는 제작 코드를 내부에 포함한다.

개발자는 resolve와 reject를 신경 쓰지 않고 executor안 코드만 작성하면 된다. executor의 인수 resolve와 reject는 자바스크립트에서 자체 제공하는 콜백이기 때문이다.(자바스크립트가 알아서 넣어준다.)

executor는 인수로 받은 콜백중 하나를 반드시 호출해야한다. 일이 성공적으로 끝나면 결과를 나타내는 value와 함께 resolve를 호출하고, 에러가 발생하면 에러객체를 나타내는 error와 함께 reject를 호출한다.

new Promise가 생성한 promise객체는 다음과 같은 내부 프로퍼티를 가진다.

  • state는 처음에 pending(보류)였다가 -> resolve가 호출되면 fulfilled / reject가 호출되면 rejected로 변한다.
  • result : undefined -> resolve가 호출되면 value / reject가 호출되면 error로변환된다.

즉, 생성되는 promise객체는 executor의 실행 결과에 따라 달라지는 상태와 결과값을 가지고 있게 된다.

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => resolve("완료"), 1000);
});

예시로 위와 같은 코드를 살펴보자.

promise가 생성되면서 executor가 실행된다. executor내부의 타이머 함수가 실행되고 1초 후에 일이 성공적으로 끝났다는 신호가 전달되면서 promise객체의 상태는 fulfiilled가 되고 result에는 '완료'가 할당된다.

이러한 상태는 한번 변경되면 더이상 변하지 않는다. 만약에 여러개의 setTimeout으로 성공이나 실패를 전달해도, 맨처음에 변경된 상태만 저장되고 나머지는 무시된다.

위 예시에서는 비동기 작업을 처리했지만, 꼭 비동기작업에만 사용 할 수 있는것은 아니다.

let promise = new Promise(function(resolve, reject) {
  resolve(123); // 결과(123)를 즉시 resolve에 전달함
});

위 예제와 같이 즉시 호출하면 pending상태 없이 바로 resolve상태가 된다.

이렇게 만들어진 promise객체의 state, result프로퍼티는 내부프로퍼티임으로 개발자가 직접 접근 할 수 없고, 허용된 .then/.catch/.finally 메서드를 사용하여 접근해야한다.

내부프로퍼티? 출처
자바스크립트에는 아래와 같은 두 가지 타입의 객체 필드(프로퍼티와 메서드)가 있습니다.
public: 어디서든지 접근할 수 있으며 외부 인터페이스를 구성합니다. 지금까지 다룬 프로퍼티와 메서드는 모두 public입니다.
private: 클래스 내부에서만 접근할 수 있으며 내부 인터페이스를 구성할 때 쓰입니다.
커피 머신을 조작하려면 제공된 버튼을 이용하면 되지만, 그 안의 엔진 하나하나를 직접 조작 할 수는 없는 것과 같다.

then

promise.then(
  function(result) { /* 결과(result)를 다룹니다 */ },
  function(error) { /* 에러(error)를 다룹니다 */ }
);

then의 첫번째 인수는 프로미스가 이행됐을 때(resolve) 실행되는 함수로 실행 결과(result)를 받는다.
두번째 인수는 프로미스가 거부됐을 때(reject)실행되는 함수로 에러를 받는다.promise 객체가 생성되면서 executor가 실행되고 내부의 타이머가 1초후에 '완료'라는 값과 성공상태를 전달하면 then메소드는 그 결과값을 받아 알림창에 출력한다.

setTimeout은 webAPI에서 처리하는 비동기 작업이지만 이렇게 promise를 사용하면, 작업이 처리되고 나서 결과를 받아 알림창에 띄우도록 작업이 동기적으로 진행되게 만들 수 있다. 위에서 작성한 비동기 콜백과 유사하게 동작 하는 것이다.

즉, promise에 then을 사용해야 promise에 들어있는 결과값을 활용할 수 있다. 이것은 코드를 실행은 하되 결과는 내가 원할 때 사용하겠다는 것이다.

catch, finally

에러만 다루고 싶을 때는 .then(null, error)이렇게 then의 첫번째 인자에 null을 전달하면 되는데, catch를 사용하면 이러한 방식과 똑같이 동작한다.
그러면 then으로 둘 다 처리 할 수 있는데 왜 catch를 사용하는걸까?

finally는 성공, 실패여부와 상관없이 프로미스가 처리완료되면 동작한다.

promise chaining


콜백함수에서 여러개의 함수 호출을 이어줬던 것처럼, promise도 결과값을 다음 함수로 전달하여 연속적으로 동작하게 할 수 있다.첫번째 줄은 잘안보이지만 promise객체를 생성하고, 생성되면 hello라는 result를 갖게 된다.
result로 첫번째 then이 접근하여 콘솔에 찍고, 그것을 resolve값이라는 변수에 담아 반환한다.
그러면 두번째 then은 resolve값에 담긴 hello를 받아서 world를 추가해서 콘솔에 출력하는 방식으로 진행된다.

이런식으로 작업을 동기적으로 실행 시킬 수 있다. 데이터 통신의 경우 비동기로 동작하는 통신이 완료되면 그 결과값을 받아와서 then으로 처리하는 방식으로 보통 코드를 입력한다.


(fetch를 사용할 때 new Promise()를 직접 사용하여 프로미스를 생성할 필요가 없는 이유는 fetch 함수가 프로미스를 반환하기 때문이다. 자체적으로 promise를 반환하고 있기 때문에 그냥 then을 붙여서 사용하면 된다.)

앞에서 공부한 출처에 들어가보면, promise에 관련된 내용이 훨씬 길고 자세하지만, 내가 현재 필요한 공부 수준은 데이터 통신할 때 왜 promise를 사용하는지, 어떻게 사용하는지에 대한 것이기 때문에 현재 학습에 필요한 부분만 공부했다. 후에 해당 부분이 필요하면 추가로 공부하는것이 효율상 나을것 같다. 지금 해야 할 것이 많은데 이것만 하루종일 붙잡고 있을 수는 없다!

콜백의 단점을 보안하기 위해 만들어진 promise도 코드가 길어지고 체이닝이 많아지면 가독성이 나빠진다. 이것을 보안하는 것이 async / await이다.


async / await

async / await은 비동기 코드를 동기적인 코드로 보이게 만들어서 코드의 가독성을 향상시켜준다. 사용방법은 위와 같다.

함수 앞에 async를 붙이면 해당 함수는 항상 promise를 반환한다.
프라미스가 아닌 값을 반환하더라도 이행 상태의 프라미스(resolved promise)로 값을 감싸 이행된 프라미스가 반환되도록 한다.
즉, 곧 죽어도 널 프로미스로 만들겠다 라는 의미이다.

async function f() {
  return 1;
}
f().then(alert); // 1

위 함수를 호출하면, result가 1인 이행(resolve)프로미스가 반환되어 then으로 확인 할 수 있다.

async를 붙여서 만든 함수는 내부에 await를 사용 할 수 있다. 자바스크립트는 await 키워드를 만나면 프라미스가 처리될 때까지 기다렸다가 그 결과를 반환한다.

async function f() {
  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("완료!"), 1000)
  });
  let result = await promise; // 프라미스가 이행될 때까지 기다림 (*)
  alert(result); // "완료!"
}
f();

f();를 실행하면, 함수 내부가 실행되다가 await가 있는 줄에서 잠시 중단된다. 이후 프로미스가 처리되면 실행이 재개되고 promise 객체가 반환하는 결과값이 result 변수에 할당된다.

이렇게 await은 프로미스가 처리 될 때까지 함수 실행을 잠시 기다리게 만든다. 프라미스가 처리되길 기다리는 동안엔 엔진이 다른 일(다른 스크립트를 실행, 이벤트 처리 등)을 할 수 있기 때문에, CPU 리소스가 낭비되지 않는다. await은 then이 사용 가능한곳에서 사용 가능 하다.(thenable)

여기가 async / await을 사용할 때 햇갈렸던 점이다.
async로 작성하면 프로미스를 반환한다고 하니까 그 프로미스를 await이 처리하는건가? 하고 생각했다
. 그런데 async가 반환하는 프로미스와 await이 대기하는 프로미스는 다르다.
async함수가 프로미스를 반환한다는 것과 async를 사용하면 내부에서 await을 사용 할 수 있다는 것을 구분해야한다.
await은 async함수가 반환하는 프로미스를 처리하는게 아니라 async함수 내부에 존재하는 프로미스의 처리를 기다리는 녀석이다.
async 함수가 반환하는 프로미스는 await이 아니라 함수 호출자에게 반환되는 것이다.


이런식으로 체이닝도 만들 수 있다.

4개의 비동기 함수를 순차적으로 실행해보자.
코드적으로 async await이 더 간단하고 읽기 편안하지만 똑같이 동작하는걸 확인 할 수 있다.

에러를 다룰 때 promise와 다른점은 promise는 catch를 사용 할 수 있었지만 await은 그렇지 않다는 것이다. await은 오류를 받으면 마치 throw문을 작성한 것처럼 동작한다.

그래서 async / await의 오류도 try / catch문을 이용해서 처리해야한다.


결론

그래서 결과적으로 왜 데이터통신을 할 때 promise나 async/await을 사용해야 하느냐?
XMLHttpRequest나 fetch와 같은 데이터 통신은 이미 그 자체로 비동기적으로 동작한다.
그니까 오히려 비동기적으로 동작하기 때문에 promise나 async/await을 사용해야 하는거다.
데이터통신이 비동기라는건 데이터통신 코드부분이 비동기가 되니까 바로 그 다음줄이 실행된다는 뜻이다.
그런데 데이터 통신을 해서 어떤 데이터를 가져오면 그것을 가공하여 화면에 그리는 등의 작업을 할텐데, 데이터통신이 비동기니까 값을 받아오기도 전에 다음줄이 실행되어 그것을 화면에 그리라고 하는 오류가 발생 할 수 있는 것이다.
그래서 데이터통신이 완료되고 나면 그 결과를 다음에 적혀있는 코드로 가공하라~ 하는식으로 작성해주기 위해서 promise나 async/await를 사용하는것이다.

profile
기록의 힘. 가보자고 😎

0개의 댓글