Javascript 비동기 처리 동작원리

2024. 11. 11. 15:15·🎭JavaScript

비동기 처리의 필요성

실행컨텍스트 스택 (=콜스택, excution context)

모든 소스코드는 코드를 실행하기 앞서 평가과정으로 거치고, 이 평가과정을 통해 실행에 필요한 정보(식별자, 스코프, 코드 실행 순서 등)을 담은 실행 컨텍스트라는 것을 생성하게 된다.

이 실행컨텍스트들을 담은 스택이 바로 콜스택 !

  • 평가란? 코드를 계산하여 값을 만드는것(1+3 은 4로 평가)

만약 아래와 같은 소스코드를 실행한다고 하면

let x = 3

function f1() => {
	console.log("This is func1");
}

//중첩되어있음
function f2() => {
	f1();
	console.log("This is func2");
}

f2();

스택 자료구조에 따라 아래처럼 생성된다. LIFO
여기서는 전역 컨텍스트는 전역 변수에 값이 할당되고 전역 함수가 호출 되는 것을 말한다.

콜스택에 push = 코드가 실행됨
콜스택에 pop = 역할을 다해 코드 실행이 종료됨

즉, 가장 위에 있는 컨텍스트가 실행중 일때는 아래 컨텍스트들은 '실행 대기중인 테스트'가 된다. 위에 함수가 pop이 되어야 아래함수가 실행되기 때문이다.

자바스크립트는 이 콜스택이라고 불리는 실행컨텍스트 스택을 하나만 가지고 있다. 한번에 최상단에 있는 테스크만 실행가능하고 동시에 여러개의 함수를 실행할 수 없다는 것!

이것은 싱글스레드 방식 이라고 한다.

싱글스레드 방식

single-threaded Processes 방식

실행 대기중인 태스크가 현재 실행중인 테스크의 종료까지 대기하는 과정을 블로킹이라고 하고, 딜레이 된다고 표현한다.

만약 현재 싱행중인 함수가 크고 시간이 많이 걸리는 실행 컨텍스트라면 블로킹이 발생하고 비효율적인 코드가 된다.
이렇듯 실행순서를 보장하는 방식을 동기처리 방식 이다 .

setTimeout() 함수를 보면

const f1 = () => {
	console.log("f1");
}

const f2 = () => {
	console.log("f2");
}

setTimeout(f1, 3000);
f2();

지금까지의 내용을 보면 위에 코드는 f1이 먼저 출력되고, 3초후에 f2가 실행될 것 처럼 보인다.
하지만 그렇지 않다.....

"f2"
"f1"

으로 출력되는데
실행중인 테스크가 종료되지 않더라도 다음 태스크를 실행하는 것을 비동기처리 방식이라고 한다.
실행순서를 보장하지 않지만, 딜레이와 블로킹을 최소화한다.

setTimeout은 브라우저 내장(web API)되어있는 비동기함수로서 스택 내에서 다른함수를 대기시키지 않는다.

비동기 처리의 동작원리

브라우저의 이벤트 루프

우리가 브라우저를 사용하다보면, 싱글스레드 방식이라는게 안믿길 정보로 많은 태스크가 동시에 처리되는것처럼 느껴진다. 이는 브라우저에 이벤트 루프라는 기능이 내장되어있기 때문이다.

자바스크립트엔진은 앞서 언급했던 콜스택을 통해 요청된 작업을 순차적으로 진행하게 되는데, 이를 제외한 나머지 비동기 처리는 브라우저 또는 node.js가 담당하게 된다.

이러한 동시성을 위해 브라우저 환경은 태스크 큐와 이벤트 루프를 제공하고 있다.

const f1 = () => {
	console.log("f1");
}

const f2 = () => {
	console.log("f2");
}

setTimeout(f1, 3000);
f2();

소스코드가 실행되면 자바스크립트 엔진을 구성하는 콜스택에 실행 컨텍스트가 쌓이다가 f1을 실행하는 setTimeout 함수를 만나게 된다.

이때 setTimeout은 3000밀리초의 타이머를 세팅하고, 바로 콜스택에서 빠진다. 그러면 바로 다음의 f2()함수가 블로킹(대기)없이 바로 실행 될 수 있다.

이후 전역 실행 컨텍스트까지 실행을 마치고 콜스택은 모두 pop된다.setTimeout 의 3초가 진행되는 동안에.

약속한 3초의 타이머가 완료되면 콜백함수 f1은 task queue또는 callback queue라는 곳으로 이동하게 되는데,
태스크큐란 비동기 함수의 콜백함수 (여기서는 f1)또는 이벤트 핸들러가 일시적으로 보관되는 영역이다.

task queue는 주로 비동기 함수를 저장하기위해, 
callback queue는 web API를 저장하는 큐를 가리킵니다.

이것을 관리하는 역할을 하는것이 이벤트 루프(event loop)라고 한다. 이벤트 루프는 현재 실행중인 실행컨텍스트가 있는지 또는 태스크큐에 대기중인 함수가 있는지를 반복해서 확인한다. 이러한 반복적인 행동을 tick이라고 부른다.

지금과 같은 경우에는 타이머가 완료된 f1함수가 태스크큐에 남아있을테니, 이를 콜스택에 이동시킨다. 콜스택은 모든 실행 컨텍스트가 완료되어 pop되어있을거다. 이렇듯 모두 종료되면 비로소 콜스택에 push되어 f1이 실행된다.

왜 싱글 스레드일까??

브라우저를 편하게 사용할 수 있었던 이유는 싱글스레드 방식으로 동작하는 것이 브라우저가 아닌 브라우저에 내장된 자바스크립트 엔진이었기 때문이다. 브라우저는 멀티 스레드로 동작한다.

프로프램이 실행되고 있을때 존재하는 곳을 런타임 환경이라고 한다. 자바스크립트는 브라우저와 node.js를 런타임이라고 하며 이 환경은 멀티 스레드 환경이기 때문에 자바스크립트의 동시성을 보장해 줄 수 있는것이다.

콜백 비동기 처리의 문제점

콜백 헬(callback Hell)

이벤트 루프는 콜스택이 비어있는 상태에 콜백 큐에 남아있는 비동기 함수를 콜스택에 push하여 처리하기 때문에 원하는 값을 얻지 못헀다.

try {
    setTimeout(()=> {throw new Error('error!')},3000);
} catch {
    //에러를 캐치하지 못함
    console.error('에러:', e)
}

만약 try-catch 문으로 에러를 잡으려고 했는데, 콜스택에서 catch까지 모두 끝나버리면 아직 완료되지 않은 setTimeout과 같은 비동기 코드에서 던진 에러는 프로ㅓ그램에 반영되지 않을 것이다.
이 상황에서 비동기로 동작하는 코드의 처리결과를 외부로 늦게 반환하거나, 상위 스코프의 변수에 할당한다면 기대한대로 동작하지 않는다..!

만약 콜백안에 콜백안에 콜백... 으로 이루어져 있다면 더욱 최악이다.

이렇듯 비동기 처리 중 여러 콜백 함수들이 중첩되어, 에러처리가 어렵고 가독성이 떨어지는 상태를 콜백 지옥이라고 부릅니다.

👇

이러한 문제를 해결하기 위해 ES6에서는 Promise를 통해 비동기 처리 시점을 명확하게 표현할 수 있도록 했으며, ES8에서는 async/await 를 도입해서 비동기 처리를 마치 동기 처리처럼 구현할 수 있게 했습니다.

Promise

상태

콜백함수를 쓰지 않고 어떻게 비동기 처리를 할 수 있을까ㅏ

Promise란 자바스크립트 안에 내장되어있는 객체이다.

const promise = new Promise((resolve, reject)=> {
    if (/*비동기처리 성공*/) {
        resolve('result');
    } else { //비동기처리 실패
        reject('failure reason');
    }
});

비동기 처리 후 성공적으로 응답받으면 resolve함수를 호출하고, 실패하면 reject함수를 호출해 에러를 인자로 전달한다.

프로미스에서는 중요한 개념중 하나는 상태(state)이다.
상태정보에는 세가지가 있는데,

  • pending : 비동기처리가 아직 수행되지 않은 상태, 프로미스가 생성된 직후의 기본상태
  • fulfilled: 비동기 처리에 성공한 상태로, resolve 함수 호출 직후의 기본상태
  • rejected : 비동기 처리에 실패한 상태로, reject 함수 호출 직후 변경됨
    +fulfilled와 rejected 상태를 함께 settled 상태라고 부르기도 하며 이상태에서는 더는 다른 상태로 변화할 수 없다.

프로미스는 비동기 처리상태와 처리결과를 관리하는 객체이다.

fulfilled로 예를 들어보면 1이라는 PromiseResult 값도 같이 갖는것을 볼 수 있다.
rejected 상태라면 error 객체를 값으로 갖게된다.

반환된 프로미스 객체의 상태에 따라 성공했을때의 .then()메소드와 실패했을때의 .catch()메소드가 각각 실행된다. 물론 then메소드에서 error처리도 가능하다. then 메소드는 두개의 콜백함수를 인자로 받기 때문이다.

catch() = then(undefined, onRejected)

그러나 가독성을 위해 아래처럼 나누어서 작성하는것이 일반적

const promise = new Promise((resolve, reject) => {
    setTimeout(()=> {
        resolve('ok');
    }, 3000);
});

promise.then(
    function (result) {/* doing something... */}
).catch(
    function (error) {/* doing something... */}
).finally(
	console.log("done!")
)

추가로 프로미스의 상태 와 상관없이 무조건 호출되는 .finally 메소드도 있다. 이러한 메소드들로 후속처리를 하는것을 프로미스 체이닝(Promise chaining)이라고 한다.

fetch 와 axios

fetch함수는 HTTP요청 전송 기능을 제공하는 클라이언트 사이드 Web API로, 프로미스를 지원하고 있다.

fetch('url')
    .then(res => res.json())
    .then(json => console.log(json))
    .catch(() => console.log('error'))

fetch함수에 .then을 사용할 수 있었던 이유는 바로 fetch 함수가 반환하는 것이 HTTP 응답에 대한 Response객체를 래핑한 Promise 객체였기 때문이다.

//잘못된 url이라고 가정했을 때
const wrongURL = "/wrongURL/..."

fetch(wrongURL)
    .then(() => console.log("ok")) 
    .catch(() => console.log("error")) //error가 아닌 ok가 출력된다.

잘못된 url를 전송한다고 가정했을때, catch메소드에 적용되어 error가 출력될 것 같지만 실제로는 "ok"가 출력된다

.
왜냐면
fetch함수는 HTTP에러(404,500)에는 reject를 보내는것이 아닌, Response 객체의 ok상태를 false로 설정한 후에 resolve()에 적용시키기 때문이다.
resolve()이지만 HTTP 오류를 포함하고 있기 때문에 response.ok의 상태를 찍어 꼭 확인해주어야 한다.
아래처럼!

fetch('wrongURL')
    .then(response => {
        if(response.ok) { 
            return response.json()
        }
        return Promise.reject(response) 
  //false 이면 HTTP 오류이므로 reject 해주기!
    })
    .catch(() => console.log('error'))

fetch 함수의 에러는 네트워크 장애나 CORS 에러에 의한것만 reject해준다. 그래서 이에 대한 대응으로 fetch대신 axios를 사용한다. axios는 모든 HTTP에러를 reject하는 프로미스를 반환하기 때문에 우리가 원하는 .catch()메소드로 이동하게 된다.

axios 또한 promise를 기반으로한 HTTP 비동기 통신 라이브러리이다. 그러나 fetch는 js bulit-in 라이브러리 이지만 axios는 써드파티 라이브러리 이기 때문에 따로 설치가 필요하다는 단점이 있다. 그러나 에러를 핸들하기 수월하고 문법이 간결해서 많이 사용된다.

.
.
.
이처럼 프로미스는 연속적으로 메소드를 호출하는 프로미스체이닝(Promise chaining)이라고 하는 then,catch,finally 의 후속처리 메소드를 통해 콜백 지옥을 해결해준다. 그러나 각각의 후속처리 메소드 또한 콜백 함수를 인자로 받는 콜백패턴을 사용하고 있어, 아예 사용하지 않는건x

그래서 가족성 더 좋게하는 ES8에서는 비동기처리를 동기처리처럼 결과를 반환하는 async/await를 도입했다~~~

async/await과 promise 메소드

async/await

async/await를 통해 then/catch/finally 와 같은 후속 처리 메소드로 체이닝 할 필요 없이 마치 동기 처리처럼 프로미스를 사용할 수 있게 되었다.

async function fetchTodo() {
    const url = "url/..."
    
    const response = await fetch(url); //return Promise
    const todo = await response.json();
    console.log(todo);
}

fetchTodo();

이렇게 동기적인 코드처럼 작성할 수 있다.

await는 async 함수 내에서만 사용 가능하며 이는 프로미스가 해결될 때 까지 기다리고 있는 키워드이다.
해당 프로미스가 settled상태 (resolved 또는 rejected)가 될 때까지 대기하다가 settled 상태가 되면 프로미스가 resolve()한 결과를 반환하여 변수에 할당된다.
위의 예시 코드 같은 경우에는 response 와 todo라는 변수에 resolve() 결과가 할당된다.

이렇듯 await이 '대기하고 있는' 상태이기 때문에 다음 코드를 일시중지하고, 프로미스가 settled 상태가 되면 다시 재개한다.
이러한 특성은 동기적인, 즉 순서를 보장하는 코드를 작성할 수 있는 것이죠.


https://nayoungkim00.tistory.com/43

'🎭JavaScript' 카테고리의 다른 글

실행 컨텍스트, 어떻게 설명할 수 있을까?  (1) 2025.08.18
'🎭JavaScript' 카테고리의 다른 글
  • 실행 컨텍스트, 어떻게 설명할 수 있을까?
gprorogpfus
gprorogpfus
:- >
  • gprorogpfus
    gpfusdldks
    gprorogpfus
  • 전체
    오늘
    어제
    • 분류 전체보기 (55)
      • 🎭JavaScript (2)
      • 👚 CSS (1)
      • ⚛️ React (13)
      • 🌃 Next.js (5)
        • 🔜 next.js-study (3)
      • 🥏TypeScript (10)
      • 🏴알고리즘 (2)
      • 🌴트러블슈팅 (3)
      • ⛲ 프로젝트 (6)
        • 👖gproro-shop-app (8)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 글쓰기
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    react
    TypeScript
    JavaScript
    GIT
    Redux
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
gprorogpfus
Javascript 비동기 처리 동작원리
상단으로

티스토리툴바