비동기처리


Promise, async, await

Promise, async, await

1. 비동기처리


# 동기 처리

// 동기적 코드에는 어떤 것들이 있을지!

// 선언문
let a = 10

// 동기적 실행
console.log("a : ", a)

// 반복문
function foo(num) {
  for (let i = 0; i < 10; ++i) {
    console.log(num)
  }
} 

// 함수 호출
foo(num)

//등이 있다!


# 비동기 처리

let a = 10

setTimeout(function callback() { 
  	console.log('a : ', a)
}, 3000) 

console.log('Finished')

//Finished
//a: 10


# 이벤트루프


# 비동기 처리의 필요성

웹 애플리케이션에서는 종종 서버에서 데이터를 받아와야 하는 경우가 있다. 이때 데이터를 요청하고, 서버가 응답을 줄 때까지 기다리는 동안 다른 작업이 멈추면 안 되기 때문에 비동기 처리가 필요하다.

웹사이트에 접속했는데 비동기 처리가 안된다면?

페이지 로딩이 지연되는 문제가 발생할 수 있다. 웹 페이지에서 네트워크 요청(예: API 호출)이 동기적으로 처리되면, 그 요청이 완료될 때까지 다른 작업이 실행되지 않기 때문에 화면에 페이지 데이터가 로딩되는 데 시간이 걸리거나, 웹 애플리케이션이 멈추는 것처럼 보일 수 있다.


# 비동기 처리 예시

1. API 요청 (웹에서 서버로 데이터 요청)

웹 애플리케이션에서는 종종 서버에서 데이터를 받아와야 하는 경우가 있습니다. 이때 데이터를 요청하고, 서버가 응답을 줄 때까지 기다리는 동안 다른 작업이 멈추면 안 되기 때문에 비동기 처리가 필요합니다

function fetchDataFromAPI() {
    console.log("데이터 요청 중...");
    // 3초 후에 서버 응답을 받는 것처럼 시뮬레이션
    setTimeout(() => {
        console.log("서버 응답 완료!");
        // 데이터 처리
    }, 3000);
    
    console.log("다른 작업 실행 중...");
}

fetchDataFromAPI();

<출력결과>

데이터 요청 중...
다른 작업 실행 중...
(3초 후)
서버 응답 완료!

그래서 데이터를 받아오는 동안 다른 작업을 계속 실행할 수 있도록 비동기 처리가 필요하다.


2. 파일 읽기 (서버 사이드 예시)

서버에서 파일을 읽는 작업도 시간이 걸리기 때문에 비동기적으로 처리하여, 읽는 동안 다른 작업을 처리할 수 있도록 해야 한다.

<예시> (Node.js 환경)

const fs = require('fs');

console.log("파일 읽기 시작");

fs.readFile('example.txt', 'utf8', (err, data) => {
    if (err) {
        console.log("파일 읽기 실패:", err);
    } else {
        console.log("파일 내용:", data);
    }
});

console.log("파일 읽는 동안 다른 작업 수행");

<실행결과>

파일 읽기 시작
파일 읽는 동안 다른 작업 수행
(파일이 읽히고 나서)
파일 내용: (파일 내용이 출력됩니다)

파일을 읽는 동안 서버는 다른 작업을 처리할 수 있어야 한다. 만약 동기적으로 처리된다면, 파일을 읽는 동안 서버가 멈추고 다른 요청을 처리하지 못할 것이다.


3. 사용자 입력 처리

사용자가 버튼을 클릭하거나 폼을 제출할 때 시간이 오래 걸리는 작업이 있을 수 있다. 예를 들어, 입력을 받은 후 서버로 데이터를 보내고 응답을 기다리는 동안 UI가 멈추지 않도록 비동기 처리가 필요하다.

<예시>

document.getElementById("submitButton").addEventListener("click", function() {
    console.log("폼 제출 시작...");
    
    // 비동기적으로 데이터를 서버로 보내는 예시
    setTimeout(() => {
        console.log("서버에 데이터 전송 완료!");
    }, 2000);

    console.log("폼 제출 대기 중...");
});

<실행결과>

폼 제출 시작...
폼 제출 대기 중...
(2초 후)
서버에 데이터 전송 완료!

사용자가 폼을 제출하는 동안 다른 작업을 할 수 있어야 한다. 만약 동기적으로 처리된다면 서버 응답을 기다리는 동안 화면이 멈추게 되기 때문이다.


# 비동기 처리 모델

=> 메인 스레드가 콜스택을 읽고 코드를 실행함, 콜스택은 아래부터 차례로 실행 컨텍스트가 쌓이게 된다, 예를들어 setTimeout은 WEB API에 등록되었을 때 몇 초 뒤 종료되면 task queue로 들어가도록 설정 되어있다.

image.png

request("user-data", (userData) => { 
  	console.log("userData 로드") 
  	saveUsers(userData)
});

console.log("DOM 변경") 
console.log("유저 입력")

request는 비동기적으로 동작해서 비동기 처리가 끝났을 때, userData라는 콜백함수 task queue에 넣어주는 역할을 한다.



Promise, async, await

2. Callback 함수


1. 콜백함수(Callback)

콜백 함수란?

콜백 함수는 다른 함수에 인수로 전달되어, 특정 작업이 끝난 후 호출되는 함수를 말한다.

function sayHello() {
  console.log("Hello!");
}

function executeCallback(callback) {
  console.log("Before callback");
  callback(); // 콜백 함수 호출
  console.log("After callback");
}

executeCallback(sayHello);

<실행흐름>

  1. executeCallback 함수가 호출되면, sayHello 함수가 콜백 함수로 전달
  2. console.log("Before callback")이 실행
  3. sayHello()가 실행되면서 "Hello!"가 출력
  4. console.log("After callback")이 실행

<출력결과>

Before callback
Hello!
After callback


2. 콜스택(Call Stack) 이란?

콜 스택(Call Stack)

콜 스택은 현재 실행 중인 함수와 대기 중인 함수들을 저장하는 구조이다.

자바스크립트는 코드를 실행할 때, 함수 호출이 발생하면 이를 콜 스택에 쌓고, 실행이 끝나면 콜 스택에서 제거한다.

간단하게 비유하면 콜 스택은 접시를 쌓는 스택처럼 동작한다. 함수가 호출되면 접시를 쌓듯이 위에 추가되고 함수 실행이 끝나면 위에서 제거된다.


3. 콜백 큐(Callback Queue)란?

콜백 큐(Callback Queue)

콜백 큐는 비동기 작업이 완료된 후 실행할 콜백 함수들이 대기하는 장소이다.

콜백 큐에 있는 함수는 이벤트 루프가 콜 스택이 비었을 때 가져와 실행한다.

console.log("Start");

setTimeout(() => {
  console.log("Timeout callback");
}, 1000);

console.log("End");

<실행 흐름>

  1. console.log("Start") 실행 → "Start" 출력
  2. setTimeout 호출 → 비동기 함수로 동작 → 콜백 함수는 콜백 큐에 저장
  3. console.log("End") 실행 → "End" 출력
  4. 1초 후, 콜백 큐에 있던 함수가 콜 스택으로 이동 → "Timeout callback" 출력

<출력 결과>

Start
End
Timeout callback


4. 이벤트 루프

콜 스택과 콜백 큐의 관계

동작 원리

콜 스택이 비어 있으면, 이벤트 루프가 콜백 큐를 확인

콜백 큐에 대기 중인 함수가 있다면 이를 콜 스택으로 이동시켜 실행


콜백 함수 + 콜백 큐 + 콜 스택 예제

console.log("Start");

setTimeout(() => {
  console.log("Callback 1");
}, 1000);

setTimeout(() => {
  console.log("Callback 2");
}, 500);

console.log("End");

<실행 흐름>

  1. console.log("Start") 실행 → 콜 스택에서 실행 후 제거 → "Start" 출력
  2. setTimeout 호출 → 비동기 함수로 콜백을 콜백 큐에 등록 (500ms와 1000ms)
  3. console.log("End") 실행 → 콜 스택에서 실행 후 제거 → "End" 출력
  4. 500ms 후, 첫 번째 콜백 함수(Callback 2)가 콜백 큐에서 콜 스택으로 이동 → 실행 후 제거 → "Callback 2" 출력
  5. 1000ms 후, 두 번째 콜백 함수(Callback 1)가 콜백 큐에서 콜 스택으로 이동 → 실행 후 제거 → "Callback 1" 출력

<출력 결과>

Start
End
Callback 2
Callback 1


# 요약

위에서 정리한 내용을 간단 요약함

1. 콜 스택(Call Stack)

JavaScript는 싱글 스레드 기반으로 동작하며, 현재 실행 중인 코드와 함수 호출은 콜 스택(Call Stack)에 추가됨. 즉 콜 스택에는 현재 실행중인 함수와 대기 중인 함수가 쌓이고 제거된다.

2. 콜백 큐(Callback Queue)

콜백 큐는 비동기적으로 실행되는 작업(예: setTimeout, Promise)이 완료된 후 콜 스택에 추가될 준비가 된 작업들을 관리한다. 즉, 콜백큐는 비동기 함수의 콜백이 대기하는 장소이고 콜 스택이 비면 실행된다. 그렇기 때문에 동기 함수만 사용하는 코드에는 콜백 큐와 이벤트 루프가 관여하지 않는다.

3. 이벤트 루프(Event Loop)

이벤트 루프는 콜 스택이 비어 있는지 확인한 후, 콜백 큐에서 대기 중인 작업을 콜 스택으로 이동시킨다.

4. 콜백함수(Callback 함수)

다른 함수에 전달되어 특정 작업이 끝난 후 실행되는 함수.


# 예시코드

콜스택 예제

function asd(asdf, callback) {
  console.log("a");
  callback(); // sdf() 호출
  console.log("b");
}

function sdf() {
  console.log("c");
}

asd("name", sdf); // 호출

실행 흐름(콜 스택)

asd("name", sdf) 호출: 함수 asd 콜스택 추가 -> console.log("a") 실행: "a" 출력 -> 실행 후, console.log는 콜 스택에서 제거

callback() 호출: callback()은 전달받은 sdf 함수이기 때문에 sdf() 실행 -> sdf 함수가 콜 스택에 추가 -> sdf 함수 내부의 console.log("c")가 실행되어 "c"가 출력됨. ->실행 후, sdf는 콜 스택에서 제거됨.

다시 asd 함수로 돌아와 console.log("b")가 실행됨. -> "b" 출력 -> console.log("b")는 콜 스택에서 제거 -> asd 함수의 실행이 완료되어 콜 스택에서 제거

<출력결과>

a
c
b

콜백 큐는 비동기 함수(setTimeout, Promise.then, fetch)에 의해 생성된 작업을 처리하는 구조이기 때문에 이 코드의 모든 함수는 동기적으로 실행된다. 따라서, 콜백 큐와 이벤트 루프는 작동하지 않고 콜 스택만으로 모든 작업이 처리된다.


콜 스택 상태 변화

asd 호출

asd

console.log("a") 실행

asd
console.log

sdf 호출

asd
sdf

console.log("c") 실행

asd
sdf
console.log

sdf 완료 → console.log("b") 실행

asd
console.log

asd 완료

(빈 상태)



Promise, async, await

3. Callback -> Promise -> async/await


1. 콜백지옥

다음과 같이 비동기로 작동되는 함수가 있다.

이 비동기 함수는 2초 뒤에 Dain라는 이름을 인자로 받은 콜백함수의 인자로 넘겨준다.

function getName(cb) {
    setTimeout(() => {
        cb("Dain");
    }, 2000);
}

앞선 함수를 실행하려면 다음과 같이 getName 함수에 콜백함수를 넣어서 사용할 수 있다.

getName((name) => {
    console.log(name);
})
// 2초 후 Dain

만약 getName 함수를 이용해서 Dain이라는 이름을 3번 출력하려면 어떻게 해야할까?

다음과 같이 getName 함수를 절차적으로 실행시키면 2초 뒤에 Dain 이라는 이름이 나온다.

getName((name) => {
    console.log(name);
})

getName((name) => {
    console.log(name);
})

getName((name) => {
    console.log(name);
})
// 2초 후
// Dain
// Dain
// Dain

하지만 앞선 방법으로 콜백함수를 호출하면 각 함수에 대한 데이터를 사용할 수 없다.

예를들어 이름, 나이, 주소가 저장된 데이터를 비동기적으로 가져와야 한다고 가정해 보자.

function getName(cb) {
    setTimeout(() => {
        cb("Dain");
    }, 2000);
}

function getAge(cb) {
    setTimeout(() => {
        cb(30);
    }, 2000);
}

function getAddress(cb) {
    setTimeout(() => {
        cb("Seoul");
    }, 2000);
}

해당 정보 출력하고자 한다. 하지만 console.log를 한 번만 사용해야한다. 어떻게 해야할까?

getName((name) => {
    getAge((age) => {
        getAddress((address) => {
            console.log(name, age, address)
        })
    })
})

이렇게 콜백함수 안에 콜백 함수를 반복하여 호출해야 name, age, address에 한번에 접근할 수 있다.

비동기 함수가 3개 쓰이고, 각 2초씩 걸리기 때문에 6초 뒤에 Dain 30 Seoul이라는 log가 출력될 것이다.

바로 이것을 콜백 지옥이라고 부른다. 비동기 함수를 3개만 썼을 뿐인데도, 코드가 정말 복잡해지고 느리다.

비동기 함수가 더 많다면 관리하기 더 힘들겠죠?

2. Promise

Promise를 이용하여 콜백 지옥을 보기 좋게 바꿀 수 있다.

앞서 사용한 비동기 함수인 getName, getAge, getAddress를 가져오고 Promise 객체를 만들어서 다음과 같이 수정 해 볼 수 있다.

function getName() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("Dain");
        }, 2000);
    })
}

function getAge() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(30);
        }, 2000);
    })
}

function getAddress() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("Seoul");
        }, 2000);
    })
}

각 함수는 Promise 객체를 리턴한다. 그리고 Promise 객체는 항상 2초 후에 resolve 되고 이름, 나이, 주소에 대한 정보를 주도록 했다.

이것을 다음과 같이 호출해서 사용할 수 있다.

getName().then((res) => {
    console.log(res);
})

getAge().then((res) => {
    console.log(res);
})

getAddress().then((res) => {
    console.log(res);
})

하지만 이렇게 사용하면 정보를 하나의 함수에서 제어하기 힘들​어진다.

그래서 아래와 같이 다시 수정해 줄 수 있다.

Promise
    .all([getName(), getAge(), getAddress()])
    .then((res) => {
        const [name, age, address] = res;
        console.log(name, age, address)
    })

Promise.all은 첫번째 인자에 배열을 받는다. 그리고 그 배열의 원소는 모두 프로미스 객체이다.

getName, getAge, getAddress 함수는 모두 프로미스 객체를 반환하기 때문에 Promise.all에서 사용할 수 있다. 또한, Promise.all은 병렬적으로 배열의 원소에 있는 프로미스를 동시에 실행시킨다. 따라서 결과적으로 2초 후에 Dain 30 Seoul을 출력할 수 있다. 즉, 동시에 Promise 객체를 반환하는 함수들을 실행할 수 있는 것이므로 매우 편리하다. 콜백함수로는 할 수 없는 일이다.

3. async/await

위 과정을 더 간단하게 사용하는 방법이 있다.

프로미스를 더 간단하게 사용하려면 즉시실행 함수 형태에 async 화살표 함수를 이용해 코드를 작성한다.

await 키워드에서 프로미스가 resolve 될 때까지 기다린 후 다음 순서로 넘어가기 때문에 6초 후에 Dain 30 Seoul 이 콘솔에 출력된다.

(async () => {
    const name = await getName();
    const age = await getAge();
    const address = await getAddress();

    console.log(name, age, address);
})();


결론

Promise와 async/await는 효과적으로 사용될 수 있는 상황이 다르다. 그래서 두 방법 모두 잘 알고 있어야 한다. 메소드 체이닝이 많이 사용되는 코드에서는 Promise가 코드에 일관성을 지켜서 더 깔끔하게 보일 수 있고, 개별 함수를 호출하여 값을 받아오는 경우에는 asyne/await이 효과적이다.