Javascript 비동기 로직과 콘솔 출력 순서 이해하기

김효일·2024년 9월 29일

개발 자료를 찾아보다 다음과 같은 질문을 발견 했는데, 깔끔하게 대답하지 못했다. 꼭 알고만 있어야 할 것 같은 질문처럼 보여서 공부하고 간략하게 정리했다.

① 여러 비동기 로직이 있을 때, 콘솔이 찍히는 순서를 설명하시오
② JavaScript에서 비동기 함수는 정확하게 3,000ms를 기다렸다가 실행이 되나요?
③ 만약 아니라면, 정확하게 3,000ms를 기다리게 할 수 있을까요? (Web API 관련 질문)

틀린 부분은 언제든지 고쳐주시면 감사하겠습니다 (꾸벅)

① 여러 비동기 로직이 있을 때, 콘솔이 찍히는 순서는?

자바스크립트는 단일 스레드 언어로, 동기적 코드와 비동기적 코드를 이벤트 루프(Event Loop)를 통해 관리한다. 콘솔 로그의 출력 순서는 이 이벤트 루프의 동작 방식에 따라 결정된다.

console.log("Start");

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

Promise.resolve().then(() => {
  console.log("Promise");
});

console.log("End");

콘솔 출력 순서

"Start"
"End"
"Promise"
"Timeout"

Javascript의 동작 순서이다.

  1. 동기 코드의 실행
    console.log("Start")와 console.log("End")는 동기적으로 즉시 실행

  2. 비동기 코드의 등록
    setTimeout의 callback 함수는 Task Queue에 등록된다. 지정된 시간(여기서는 1000ms)이 지난 후 콜백이 태스크 큐에 추가된다. Promise.resolve().then(...)의 콜백은 Microtask Queue에 등록된다. 이미 resolve된 Promise이므로 .then의 callback이 즉시 마이크로태스크 큐에 추가된다. then의 callback이 비동기로 실행 될 것이다.

  3. Event Loop 동작
    Eventloop는 현재 실행중인 Task가 있는지, Queue들에 적재된 Task가 있는지 주기적으로 확인하고, 만약 실행중인 Task가 Call Stack에 없다면 Queue에서 Task를 꺼내와 Call Stack에 올리고 실행시키는 역할을 한다. Call stack을 확인 했더니 아무것도 없기 때문에 큐에 적재된 것을 처리한다.

  4. Microtask Queue 처리
    Microtask Queue가 Task Queue보다 우선순위가 높기 때문에 동기 코드 실행 후, Promise의 .then 콜백이 실행되어 "Promise"가 출력된다.

  5. Task Queue 처리
    1000ms가 지난 후, Call Stack이 비어 있기 때문에 등록된 setTimeout의 콜백이 실행되어 "Timeout"이 출력된다.

Event loop에 의해 Microtask Queue에 있는 콘솔이 출력되고 Task Queue에 있는 콘솔이 마저 출력된다.

*Animation Frames: 사용자가 스크롤을 이동하거나 화면을 갱신하는 것과 같이 브라우저 렌더링에 관련된 Task들이 담기는 Queue. 우선순위는 Microtask Queue와 TaskQueue의 중간.

② JavaScript에서 비동기 함수는 정확하게 3,000ms를 기다렸다가 실행되나요?

결론부터 말하자면, 아니다.

setTimeout 함수는 지정한 시간이 지난 후 Callback 함수를 Task queue에 추가한다. 하지만 실제로 함수가 실행되는 시간은 다음과 같은 이유로 지연될 수 있다.

1. Event loop의 특성

  • 현재 실행 중인 동기 코드나 Microtask queue가 많다면, 콜백 함수의 실행이 지연된다.

2. 타이머의 최소 지연 시간

  • 브라우저 환경에서는 보안 및 성능상의 이유로 최소 지연 시간이 설정되어 있을 수 있다. 최소 지연 시간은 브라우저나 자바스크립트 엔진이 setTimeout이나 setInterval 함수에 적용하는 최소한의 대기 시간을 의미한다.
  • HTML5 표준에서는 지연 시간이 0ms로 설정되더라도 최소 지연 시간을 4ms로 유지하도록 권장하고 있다. 이는 너무 짧은 지연 시간으로 인한 CPU 과부하를 방지하고, 애플리케이션의 안정성을 높이기 위함이다.
console.log("Start");

setTimeout(() => {
  console.log("Executed after 0ms");
}, 0);

console.log("End");

결과

Start
End
Executed after 0ms

3. 시스템 리소스 및 브라우저 상태

  • CPU 부하, Memory부족, 탭의 활성화 여부 등에 따라 타이머의 정확성이 달라진다. 따라서 setTimeout은 지정한 시간 이후에 "최대한 빨리" 콜백을 실행하려고 노력하지만, 정확히 그 시간에 실행된다고 보장할 수는 없다.

CPU 부하로 인한 지연

  • CPU 사용량이 높을 경우 이벤트 루프의 처리가 지연되어 타이머 콜백의 실행이 늦어질 수 있다. 동기적인 무거운 작업이 실행 중이면 비동기 콜백 함수들이 대기하게 된다.
Copy code
console.log("Start");

setTimeout(() => {
  console.log("Timeout after 100ms");
}, 100);

const heavyTask = () => {
  const end = Date.now() + 500;
  while (Date.now() < end) {
    // 무거운 연산 수행 (예: 복잡한 계산)
  }
  console.log("Heavy task completed");
};

heavyTask()

예상 출력:

Start
Heavy task completed
Timeout after 100ms
  • heavyTask() 함수가 약 500ms 동안 CPU를 점유하므로, setTimeout의 콜백이 원래 설정한 100ms 후가 아닌 500ms 이후에 실행된다. 이는 이벤트 루프가 동기적인 작업이 끝날 때까지 비동기 콜백을 처리하지 못하기 때문이다.

메모리 부족으로 인한 지연

  • 메모리 사용량이 많아 가비지 컬렉션(Garbage Collection)이 빈번하게 발생하면, 애플리케이션의 성능이 저하되고 타이머의 실행이 지연될 수 있다.

브라우저 상태의 영향(탭이 비활성화된 경우)

  • 백그라운드 탭에서는 브라우저가 리소스 최적화를 위해 타이머의 최소 지연 시간을 늘린다.
  • 일반적으로 최소 지연 시간이 1000ms로 증가하여, 타이머 콜백의 실행 빈도가 낮아진다.

예시:

setInterval(() => {
  console.log(`Interval executed at ${new Date().toLocaleTimeString()}`);
}, 500);
  • 이 코드를 실행하고 해당 탭을 비활성화하면, 콘솔 로그가 500ms마다 출력되지 않고 1초 또는 그 이상 간격으로 출력된다.
  • 이는 브라우저가 백그라운드 탭의 타이머를 제한하여 CPU 및 배터리 사용량을 줄이기 때문이다.

절전 모드 및 배터리 절약 모드

  • 노트북이나 모바일 기기에서 절전 모드 또는 배터리 절약 모드가 활성화되면, 브라우저가 타이머의 실행 빈도를 낮춰 에너지를 절약한다.
  • 이로 인해 타이머 콜백의 실행이 지연될 수 있다.

시스템 시간 조정의 영향

  • 시스템 시간이 변경되면 타이머의 동작에 예상치 못한 영향이 있을 수 있다.

③ 그렇다면 정확하게 3,000ms를 기다리게 할 수 있을까요?

완벽하게 정확한 3,000ms 지연을 보장하기는 어렵지만, 타이머의 정확성을 높이기 위한 몇 가지 방법이 있다.

1. 작업 분할 및 최적화

  • 무거운 동기 작업을 비동기적으로 처리하거나 작은 단위로 분할하여 이벤트 루프를 차단하지 않도록 한다.
  • 예를 들어, 긴 반복 작업을 setTimeout이나 requestAnimationFrame을 사용하여 분할한다.

예시:

const largeArray = [...Array(1000000).keys()];
let index = 0;

function processChunk() {
  const chunkSize = 1000;
  const end = Math.min(index + chunkSize, largeArray.length);

  for (; index < end; index++) {
    // 데이터 처리
  }

  if (index < largeArray.length) {
    setTimeout(processChunk, 0);
  } else {
    console.log("Processing complete");
  }
}

processChunk();
  • 이렇게 하면 이벤트 루프가 차단되지 않아 타이머의 지연을 최소화할 수 있다

2. Web Worker 사용

  • Web Worker를 활용하여 메인 스레드와 별도로 작업을 수행하면 메인 스레드의 부하를 줄일 수 있다.
  • 하지만 Web Worker에서도 타이머의 최소 지연 시간은 적용될 수 있습니다.

예시:

  • 메인 스레드에서 Web Worker를 생성하고, 워커 내에서 타이머를 실행합니다.

main.js

const worker = new Worker('worker.js');

worker.onmessage = (event) => {
  console.log(`Message from worker: ${event.data}`);
};

worker.js

setTimeout(() => {
  postMessage('Timeout executed in worker');
}, 1000);
  • Web Worker를 사용하면 메인 스레드의 부하와 관계없이 타이머를 실행할 수 있지만, 시스템 리소스나 브라우저 상태에 따른 지연은 여전히 발생할 수 있다.

3. 고해상도 타이머 구현

  • performance.now()를 사용하여 고해상도 시간 측정을 통해 타이머의 정확성을 높일 수 있다.
  • 반복적으로 시간을 체크하여 지정한 시간에 근접하게 콜백을 실행한다.

예시:

function preciseTimeout(callback, delay) {
  const start = performance.now();

  function check() {
    const elapsed = performance.now() - start;
    if (elapsed >= delay) {
      callback();
    } else {
      requestAnimationFrame(check);
    }
  }

  requestAnimationFrame(check);
}

preciseTimeout(() => {
  console.log(`Precise timeout executed at ${performance.now()}ms`);
}, 3000);
  • 이 방법은 requestAnimationFrame을 사용하여 화면 리프레시 주기마다 시간을 체크하므로, 비교적 정확하게 지정한 시간에 콜백을 실행할 수 있다.
  • 그러나 화면이 리프레시되지 않는 상황(예: 탭이 비활성화된 경우)에서는 정확성이 떨어질 수 있다.

참조

profile
기록을 통한 성장

0개의 댓글