[부스트캠프 웹·모바일 8기] 챌린지 11-12일차 학습 정리

허지예·2023년 7월 24일
post-thumbnail

Javascript에서 비동기 이해하기

🌐 자바스크립트의 핵심 '비동기' 완벽 이해 ❗를 참고함.

동기와 비동기

동기(Synchronous)

  • 프로그래밍을 하면서 일반적으로 각 함수와 코드들이 위에서 아래로 차례로 동작하는 방식.
  • 간단하고 직관적이지만, 작업이 오래 걸리거나 응답이 늦어지는 경우에는 전체적인 성능과 사용자 경험에 영향을 줄 수 있다.
  • 상황에 따라 중간에 프로그램 흐름이 멈추거나 지연될 수 있다.

비동기(Asynchronous)

  • 메인 스레드가 작업을 다른 곳에 인가하여 처리되게 하고, 그 작업이 완료되면 콜백 함수를 받아 실행하는 방식.
  • 특정 작업의 완료를 기다리지 않고, 다른 작업을 동시에 수행할 수 있게 한다.
  • 작업을 백그라운드에 요청하여 처리되게 하여 멀티로 작업을 동시에 처리하는 것

비동기의 병렬 처리

자바스크립트는 싱글 스레드 언어이지만, 자바스크립트가 실행되는 브라우저는 멀티 스레드가 가능하다.

비동기로 동작하는 핵심 요소는 자바스크립트가 아니라 브라우저라는 소프트웨어가 가지고 있다. Node.js에서는 libuv 내장 라이브러리가 처리한다.

이벤트 루프

싱글 스레드인 자바스크립트의 작업을 멀티 스레드로 돌려 작업을 동시에 처리시키게 하거나 여러 작업 중 어떤 작업을 우선으로 동작시킬 것인지 결정하는 세심한 컨트롤을 하기 위해서 존재하는 것이 이벤트 루프이다.

이벤트 루프는 브라우저 내부의 Call Stack, Callback, Queue, Web APIs 등의 요소들을 모니터링하면서 비동기 적으로 실행되는 작업들을 관리하고, 이를 순서대로 처리하여 프로그램의 실행 흐름을 제어한다.

이벤트 루프의 동작 과정을 간단히 살펴보면, 자바스크립트의 setTimeout이나 fetch와 같은 비동기 자바스크립트 코드를 브라우저 Web APIs에게 맡기고, 백그라운드 작업이 끝난 결과를 콜백 함수 형태로 큐(Callback Queue)에 넣고 처리 준비가 되면 호출 스택 (Call Stack)에 넣어 마무리 작업을 진행한다.

브라우저의 내부 구성도

  • Call Stack : 자바스크립트 엔진이 코드 실행을 위해 사용하는 메모리 구조
  • Heap : 동적으로 생성된 자바스크립트 객체가 저장되는 공간
  • Web APIs: 브라우저에서 제공하는 API 모음으로, 비동기적으로 실행되는 작업들을 전담하여 처리한다. (AJAX 호출, 타이머 함수, DOM 조작 등)
  • Callback Queue : 비동기적 작업이 완료되면 실행되는 함수들이 대기하는 공간
  • Event Loop : 비동기 함수들을 적절한 시점에 실행시키는 관리자
  • Event Table: 특정 이벤트(timeout, click, mouse 등)가 발생했을 때 어떤 callback 함수가 호출되야 하는지를 알고 있는 자료구조 (위 그림에는 없음)

비동기의 병렬 처리 원리

싱글 스레드 언어인 자바스크립트가 작업들을 비동기로 동시에 처리할 수 있는 원리?

자바스크립트를 실행하는 콜 스택은 싱글 스레드이지만, 서버에게 리소스를 요청하거나 파일 입출력 혹은 타이머 대기 작업을 실행하는 Web APIs들은 멀티 스레드이기 때문에 동시 작업 처리가 가능하기 때문이다.

즉, 브라우저라는 소프트웨어가 멀티 스레드이기 때문에 자바스크립트 스레드를 차단하지 않고, 다른 스레드를 사용하여 Web APIs의 작업들을 처리하여 동시 처리가 가능한 것이다.


setTimeout(() => {
	// 콜백 함수 자체는 나중에 call stack에서 싱글 스레드로 처리
	console.log("5초 대기 완료");
  
}, 3000); // 타이머 3초는 멀티 스레드로 처리

자바스크립트의 비동기는 완벽한 멀티 스레딩은 아니다. 왜냐하면 위에서 타이머만 3000ms만 병렬적으로 처리되고, 그 안의 콜백 함수 실행 코드는 추후에 이벤트 루프에 인해 콜 스택에 들어가 싱글 스레드로 처리되기 때문이다.

자바스크립트에서는 동시성 문제를 심플하게 처리하기 위해서 비동기 콜백 함수 방식을 채택하였다고 보면 된다.

자바스크립트에서 멀티 스레딩

Web workers를 사용하면 자바스크립트도 자바의 스레드와 같이 멀티 스레드 프로그래밍을 할 수 있다.

이 부분은 챌린지 9일차 학습 정리 학습함.

비동기 처리 기법

비동기와 콜백 함수

콜백 함수는 자바스크립트의 일급 객체 특성을 이용해 함수의 매개변수에 함수 자체를 넘겨, 함수 내에서 매개변수 함수를 실행하는 기법을 말한다.

비동기 방식은 요청과 응답의 순서를 보장하지 않기 때문에, 응답의 처리 결과에 의존하는 경우에는 콜백 함수를 이용하여 작업 순서를 간접적으로 끼워 맞출 수 있다.

function getDB(callback) {
    // 데이터베이스로부터 3초 후에 데이터 값을 받아온 후, 콜백 함수 호출
    setTimeout(() => {
        const value = 100;
        callback(value);
    }, 3000);
}

function main() {
    // 호출할 작업에 콜백 함수를 넘긴다
    getDB(function(value) {
        let data = value * 2;
        console.log('data의 값 : ', data);
    });
}
main();

하지만, 콜백 함수 방식은 코드의 복잡도를 증가시켜, 개발자가 어플리케이션의 흐름을 읽기 어려워지는 등의 문제가 있을 수 있어 잘못하면 콜백 지옥에 빠질 수 있다.

비동기와 프로미스 객체

콜백 함수의 한계점을 극복하기 위해 비동기 처리를 위한 전용 객체로서 Promise가 나타났다.

Promise는 비동기 작업의 작성 또는 실패와 그 결과값을 나타내는 객체이다.

※ 프로미스에 대해서는 뒤에서 자세히...

function getDB() {
  return new Promise((resolve) => {
    setTimeout(() => {
      const value = 100;
      resolve(value);
    }, 3000);
  });
}

function main() {
  getDB()
    .then((value) => {
      let data = value * 2;
      console.log('data의 값 : ', data);
    })
    .catch((error) => {
      console.error(error);
    });
}

main();

비동기와 async / await

프로미스도 지나친 then 핸들러 함수의 남용으로 인한 Promise Hell이 존재해, 프로미스가 여러 개 연결되면 코드가 길어지고 복잡해진다.

그래서 자바스크립트에는 async / await라는 문법이 또한 추가되었다.

async/await은 프로미스를 기반으로 하지만, 마치 동기 코드처럼 작성할 수 있게 해준다.

비동기 작업을 쉽게 읽고 이해할 수 있게 해주기 때문에 비동기 작업을 처리할 일이 있다면 대개 async/await 방식을 쓰는 것이 보통이다.

function getDB() {
    return new Promise((resolve, reject) => {
        // 데이터베이스에서 값을 가져오는 3초 걸린다고 가정 (비동기 처리)
        setTimeout(() => {
            const value = 100;
            resolve(value); // Promise 객체 반환
        }, 3000);
    });
}

async function main() {
    let data = await getDB(); // await 키워드로 Promise가 완료될 때까지 기다린다
    data *= 2;
    console.log('data의 값 : ', data);
}
main(); // 메인 스레드 실행

🤙 Promise

📚 자바스크립트 Promise 개념 & 문법 정복하기

자바스크립트에서 Primise 객체는 비동기 작업의 최종 완료 또는 실패를 나타내는 Array나 Object처럼 독자적인 객체라고 보면 된다.

비동기 작업이 끝날 때까지 결과를 기다리는 것이 아니라, 결과를 제공하겠다는 '약속'을 반환한다는 의미에서 Promise라고 한다.

Promise 사용법

Promise 객체 생성하기

Promise 생성자를 생성하면 된다. 생성 시에 두 개의 콜백 함수를 넣게 되는데, 첫번째는 작업이 성공했을 때를 알리는 resolve 객체, 두번째는 작업이 실패했을 때를 알리는 reject 객체이다.

Promise 안에 들어가는 콜백 함수를 executor라고 부른다.

const myPromise = new Promise((resolve, reject) => {
	// 비동기 작업 수행
    const data = fetch('서버로부터 요청할 URL');
    
    if(data)
    	resolve(data); // 만일 요청이 성공하여 데이터가 있다면
    else
    	reject("Error"); // 만일 요청이 실패하여 데이터가 없다면
})

Promise 객체 처리

위에서 생성된 Promise 객체는 작업이 완료된 이후에 다음 작업을 연결시켜 진행할 수 있다.

작업 결과에 따라 then()catch() 메서드 체이닝을 통해 다음 작업을 연결시켜 진행할 수 있다.

myPromise
    .then((value) => { // 성공적으로 수행했을 때 실행될 코드
    	console.log("Data: ", value); // 위에서 return resolve(data)의 data값이 출력된다
    })
    .catch((error) => { // 실패했을 때 실행될 코드
     	console.error(error); // 위에서 return reject("Error")의 "Error"가 출력된다
    })
    .finally(() => { // 성공하든 실패하든 무조건 실행될 코드
    	
    })

Promise 함수 등록

위와 같이 프로미스 객체를 변수에 바로 할당하는 방식을 사용할 수도 있지만, 보통은 다음과 같이 별도로 함수로 감싸서 사용한다.

// 프로미스 객체를 반환하는 함수 생성
function myPromise() {
  return new Promise((resolve, reject) => {
    if (/* 성공 조건 */) {
      resolve(/* 결과 값 */);
    } else {
      reject(/* 에러 값 */);
    }
  });
}

// 프로미스 객체를 반환하는 함수 사용
myPromise()
    .then((result) => {
      // 성공 시 실행할 콜백 함수
    })
    .catch((error) => {
      // 실패 시 실행할 콜백 함수
    });

위와 같이 생성된 프로미스 객체를 함수 반환값으로 얻어 사용한다. 이렇게 사용하는 이유는 다음과 같다.

  1. 재사용성 : 프로미스 객체를 함수로 만들면 필요할 때마다 호출하여 사용함으로써, 반복되는 비동기 작업을 효율적으로 처리할 수 있다.

  2. 가독성 : 프로미스 객체를 함수로 만들면 코드의 구조가 명확져, 비동기 작업의 정의와 사용을 분리하여 코드의 가독성을 높일 수 있다.

  3. 확장성 : 프로미스 객체를 함수로 만들면 인자를 전달하여 동적으로 비동기 작업을 수행할 수 있다. 또한 여러 개의 프로미스 객체를 반환하는 함수들을 연결하여 복잡한 비동기 로직을 구현할 수 있다.

Promise의 3가지 상태

다음과 같이 3가지 상태로 나뉜다.

  • Pending(대기) : 처리가 완료되지 않은 상태 (처리 진행중)
  • Fulfilled(이행/완료) : 성공적으로 처리가 완료된 상태
  • Eejected(거부) : 처리가 실패로 끝난 상태

Promise 핸들러

  • .then() : 프로미스가 이행(fulfilled)되었을 때 실행할 콜백 함수를 등록하고, 새로운 프로미스를 반환
  • .catch() : 프로미스가 거부(rejected)되었을 때 실행할 콜백 함수를 등록하고, 새로운 프로미스를 반환
  • .finally() : 프로미스가 이행되거나 거부될 때 상관없이 실행할 콜백 함수를 등록하고, 새로운 프로미스를 반환

Promise 체이닝

프로미스 체이닝이란, 프로미스 핸들러를 연달아 연결하는 것을 말한다. 이렇게 하면 여러 개의 비동기 작업을 순차적으로 수행할 수 있다는 특징이 있다.

function doSomething() {
  return new Promise((resolve, reject) => {
      resolve(100)
  });
}

doSomething()
    .then((value1) => {
        const data1 = value1 + 50;
        return data1
    })
    .then((value2) => {
        const data2 = value2 + 50;
        return data2
    })
    .then((value3) => {
        const data3 = value3 + 50;
        return data3
    })
    .then((value4) => {
        console.log(value4); // 250 출력
    })

이런식으로 체이닝이 가능한 이유는 then 핸들러에서 값을 리턴하면, 그 반환값은 자동으로 프로미스 객체로 감싸져 반환되기 때문이다.
그리고 다음 then 핸들러에서 반환된 프로미스 객체를 받아 처리하는 것이다.

Promise 정적 메서드

프로미스 객체는 생성자 함수 외에도 여러 가지 정적 메서드(static method)를 제공한다. 정적 메서드는 객체를 초기화 & 생성하지 않고도 바로 사용할 수 있기 때문에 비동기 처리를 보다 효율적이고 간편하게 구현할 수 있도록 도와준다.

Promise.resolve()

비동기와 상관없이 주어진 것으로 바로 resolve로 넘기는 Promise를 간편하게 생성하기 위해서 사용한다.

Promise.reject()

비동기와 상관없이 주어진 것으로 바로 reject로 넘기는 Promise를 간편하게 생성하기 위해서 사용한다.

Promise.all()

배열, Map, Set에 포함된 여러개의 프로미스 요소들을 한꺼번에 비동기 작업을 처리해야 할때 굉장히 유용한 프로미스 정적 메소드이다.

// 1. 서버 요청 API 프로미스 객체 생성 (fetch)
const api_1 = fetch("https://jsonplaceholder.typicode.com/users");
const api_2 = fetch("https://jsonplaceholder.typicode.com/users");
const api_3 = fetch("https://jsonplaceholder.typicode.com/users");

// 2. 프로미스 객체들을 묶어 배열로 구성
const promises = [api_1, api_2, api_3];

// 3. Promise.all() 메서드 인자로 프로미스 배열을 넣어, 모든 프로미스가 이행될 때까지 기다리고, 결과값을 출력
Promise.all(promises)
    .then((results) => {
      // results는 이행된 프로미스들의 값들을 담은 배열.
      // results의 순서는 promises의 순서와 일치.
      console.log(results); // [users1, users2, users3]
    })
    .catch((error) => {
      // 어느 하나라도 프로미스가 거부되면 오류를 출력
      console.error(error);
    });

Promise.allSettled()

Promise.all() 메서드의 업그레이드 버전으로, 주어진 모든 프로미스가 처리되면 모든 프로미스 각각의 상태와 값 (또는 거부 사유)을 모아놓은 배열을 반환한다.

 1초 후에 1을 반환하는 프로미스
const p1 = new Promise(resolve => setTimeout(() => resolve(1), 1000));

// 2초 후에 에러를 발생시키는 프로미스
const p2 = new Promise((resolve, reject) => setTimeout(() => reject(new Error('error')), 2000));

// 3초 후에 3을 반환하는 프로미스
const p3 = new Promise(resolve => setTimeout(() => resolve(3), 3000));

// 세 개의 프로미스의 상태와 값 또는 사유를 출력
Promise.allSettled([p1, p2, p3])
	.then(result => console.log(result));

Promise.any()

Promise.all() 메서드의 반대 버전으로, Promise.all() 이 주어진 모든 프로미스가 모두 완료해야만 결과를 도출한다면, Promise.any() 는 주어진 모든 프로미스 중 하나라도 완료되면 바로 반환하는 정적 메서드이다.

const promise1 = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject("promise1 failed");
  }, 3000);
});

const promise2 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("promise2 succeeded");
  }, 2000);
});

const promise3 = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject("promise3 failed");
  }, 1000);
});

// promise1, promise2, promise3은 각각 3초, 2초, 1초 후에 거부되거나 이행
Promise.any([promise1, promise2, promise3])
  .then((value) => {
    console.log(value); // "promise2 succeeded" 
  })
  .catch((error) => {
    console.error(error);
  });

위 코드를 보면 Promise.any() 메서드의 결과로 promise2의 처리가 가장 먼저 도출됨을 볼 수 있다.

오로지 첫번째로 이행(fulfilled) 된 프로미스만을 취급하기 때문에 나머지 promise1과 promise3의 거부(rejected)는 무시되게 된다.

Promise.race()

Promise.race() 는 Promise.any() 와 같이 여러 개의 프로미스 중 가장 먼저 처리된 프로미스의 결과값을 반환하지만, Promise.race() 는 fulfilled(이행), rejected(실패) 여부 상관없이 무조건 처리가 끝난 프로미스 결과값을 반환하다.

Event Emitter

The Node.js Event emitter | nodejs.dev

Node.js에서 이벤트를 처리하는 데 사용할 클래스를 제공하는 모듈이다.

on 메서드와 emit 메서드를 제공한다.

  • emit 이벤트를 트리거하는데 사용한다.
  • on 이벤트가 트리거될 때 실행될 콜백 함수를 추가하는 데 사용한다.
const EventEmitter = require('events');

const eventEmitter = new EventEmitter();

eventEmitter.on('start', () => {
  console.log('started');
});

eventEmitter.emit('start');

다음과 같이 매개변수도 넣을 수 있다.

eventEmitter.on('start', number => {
  console.log(`started ${number}`);
});

eventEmitter.emit('start', 23);

+) 추가로 제공하는 메서드

  • once: 일회성 리스너 추가
  • removeListener / off: 이벤트에서 이벤트 리스너 제거
  • removeAllListeners : 이벤트에 대한 모든 리스너 제거

객체지향 설계와 데이터 흐름

창시자 앨런 케이가 말하는, 객체 지향 프로그래밍의 본질

앨런 케이가 생각하는 OOP의 본질은 메시징, 캡슐화, 동적 바인딩이다.

  • 관련있는 데이터와 프로시져를 찾아서 묶고 다른 객체가 내부를 건드리지 못하게 한다. (캡슐화)

  • 다른 객체의 데이터나 프로시져가 필요할 때는 메시지를 요청한다. 메시지를 받는 객체는 스스로 처리 방법을 선택한다. (메시징)

  • 메시지를 받는 객체는 그때 그때 달라질 수 있다. (동적 바인딩)

이 세 가지가 합쳐지면 다음과 같은 효과가 나타난다.

1) 변경가능한 공유 데이터가 최소로 줄어든다.
2) How(구현) 부분을 쉽게 바꿀 수 있다.
3) 메시지를 실제로 처리하는 객체를 쉽게 바꿀 수 있다.

앨런 케이는 후에 '객체 지향 프로그래밍'이라는 이름을 잘 못 지은 거 같다면서 유감을 표시했다.

'객체 지향'이라는 말을 쓰다보니, 너무 객체나 클래스에 초점이 가게 된다.
OOP의 본질을 흐리는 것 같다. 진짜 핵심은 '메시징'이다. 라고 직접 말했다.

스레드 풀

(keep...)

+) 비동기 추가 정리

비동기 처리 디버깅 하기 (with 크롬 개발자 도구)

크롬 개발자 도구 소스 탭에서 비동기 처리를 디버깅할 수도 있고, vscode에서 크롬 디버거 확장 프로그램도 있다.

call stack에서 async를 체크하면 된다.

멀티 스레딩 vs 디버깅

비동기적 모델에서는 여러 일이 동시에 발생할 수 있다. 프로그램은 실행시간이 긴 함수를 실행할 때, 그 함수의 실행 흐름을 막지(block) 않고, 프로그램을 계속 실행한다. 그리고 그 함수가 끝났을 때, 프로그램은 실행결과에 접근한다.

멀티스레딩은 하나 이상의 순차적 명령어 세트(sequential set of instructions)를 동시/병렬적(concurrent/parallel)으로 실행하는 것을 말한다.

멀티스레딩 프로그래밍은 여러 다른 함수들을 동시에 실행하는 것이고, 비동기 프로그래밍은 여러 함수들이 non-blocking 으로 실행되는 것이다. 비동기적이라는 것은 단일 스레드, 멀티 스레드 모두에게 적용될 수 있는 것이다.

멀티스레딩은 작업자(Thread)에 관한 것이고, 비동기는 작업(Task)에 관한 것이다.

profile
대학생에서 취준생으로 진화했다가 지금은 풀스택 개발자로 2차 진화함

0개의 댓글