Front-End/Javascript

자바스크립트: 작업 처리 방식의 두 얼굴 (동기와 비동기)

__sum__ 2025. 5. 29. 21:36

1. 동기 (Synchronous)

자바스크립트는 원래 한 번에 하나의 작업만 처리할 수 있는 언어입니다. 마치 한 줄씩 순서대로 코드를 읽고 실행하는 것처럼요. 이걸 동기(Synchronous) 방식이라고 부릅니다. 코드가 위에서 아래로 차례대로 동작하는 거죠.

 

자바스크립트 엔진에는 호출 스택이라는 것이 있어서, 현재 실행 중인 함수나 코드가 무엇인지 기록합니다. 함수가 호출되면 스택의 맨 위에 쌓이고, 함수 실행이 끝나면 스택에서 제거됩니다. 이 스택은 한 번에 하나의 작업(스택 프레임)만 처리할 수 있습니다.

 

이 동기 방식은 코드가 간단할 때는 괜찮습니다. 하지만 만약 어떤 작업이 끝나는 데 시간이 아주 오래 걸린다면 어떻게 될까요? 예를 들어, 서버에 데이터를 달라고 요청하고 응답이 올 때까지 기다려야 하는 작업 같은 거요. 자바스크립트는 싱글 스레드라서 이 작업이 끝날 때까지 다른 어떤 작업도 하지 못하고 멈춰서 기다리게 됩니다. 마치 한 사람이 하나의 일만 하느라 다른 급한 일들을 전혀 처리하지 못하는 것과 같아요.

 

이렇게 되면 웹 페이지가 멈추거나 사용자가 아무런 조작도 할 수 없게 되어 사용자 경험이 나빠집니다. 브라우저는 페이지를 제대로 보여주지도 못하고, 심지어는 응답이 너무 없다고 페이지를 종료할지 물어보기도 합니다.

동기(Synchronous)와 비동기(Asynchronous)

 

 

2. 비동기(Asynchronous)

이런 문제를 해결하고 여러 작업을 동시에 처리하는 것처럼 보이게 하기 위해 등장한 개념이 바로 비동기(Asynchronous)입니다. 비동기 방식은 특정 작업이 끝날 때까지 기다리지 않고 일단 다음 코드를 먼저 실행하는 방식입니다. 서버에 데이터를 요청하더라도, 그 응답이 올 때까지 기다리지 않고 바로 다른 작업을 계속하는 거죠.

 

2.1 어떻게 가능한가?

그렇다면 자바스크립트는 싱글 스레드인데 어떻게 비동기 처리가 가능할까요? 여기에는 자바스크립트 엔진 외에 다른 친구들의 도움이 필요합니다. 웹 브라우저나 Node.js 같은 런타임 환경이 제공하는 기능들 덕분이죠.

런타임 환경(Runtime Environment)이란, 프로그램이 실행되는 실제 환경을 의미합니다. 쉽게 말해, 코드를 작성한 후 그 코드를 실행하고 동작시키는 시스템이나 플랫폼을 뜻합니다.

 

2.1.1 Web API

특히 브라우저 환경에서는 Web API라는 것들이 있습니다. 이 Web API는 타이머(예: setTimeout), 네트워크 요청(예: fetchAjax), 파일 입출력 같은 웹 환경에서 필요한 기능들을 제공해 줍니다. 중요한 점은 이 Web API들은 자바스크립트의 메인 스레드와 별개로 백그라운드에서 동작할 수 있다는 것입니다. 실제로 브라우저의 Web API는 멀티 스레드로 구현되어 있습니다.

 

그래서 시간이 오래 걸리는 비동기 작업(예: setTimeout으로 몇 초 기다리기, fetch로 서버에서 데이터 가져오기)이 발생하면, 자바스크립트 엔진은 이 작업을 Web API에게 "이거 좀 처리해줘"라고 넘겨버립니다. 그러면 Web API는 메인 자바스크립트 스레드를 방해하지 않고 자체적으로 백그라운드에서 작업을 처리합니다. 그 사이에 메인 자바스크립트 스레드는 다른 코드들을 계속 실행하며 바쁘게 움직일 수 있습니다.

 

2.2.2 콜백 큐(Callback Queue)와 이벤트 루프(Event Loop)

작업이 완료되면 Web API는 해당 결과를 가지고 와서 콜백 큐(Callback Queue)라는 곳에 넣어둡니다. 그리고 이벤트 루프(Event Loop)라는 감시자가 메인 자바스크립트 스레드가 다른 일을 모두 마치고 한가해지는지(호출 스택이 비워지는지) 계속 확인합니다. 메인 스레드가 준비되면 이벤트 루프는 콜백 큐에 있던 작업을 꺼내어 메인 스레드의 호출 스택으로 옮겨서 실행하게 합니다.

 

아래는 자바스크립트의 비동기 처리 원리를 간단?하게 이해하기 위한 코드입니다. 처음에는 무슨소리인지 전혀 모르겠지만 콘솔에서 직접 결과를 보면서 천천히 읽다보면 이해가 되실겁니다.

// 호출 스택 역할: 메인 스레드가 실행 중인 함수들 저장
let callStack = [];

// 콜백 큐 역할: Web API가 완료한 콜백 대기 공간
let callbackQueue = [];

// 이벤트 루프 역할: 호출 스택이 비었는지 감시하고 콜백 큐에서 꺼내 실행
function eventLoop() {
  // console.log(
  //   "  [이벤트 루프] 호출 스택 확인 중:",
  //   callStack.length,
  //   "콜백 큐:",
  //   callbackQueue.length
  // ); // 디버깅용
  if (callStack.length === 0 && callbackQueue.length > 0) {
    const callback = callbackQueue.shift();
    console.log("  [이벤트 루프] 콜백 큐에서 콜백을 호출 스택으로 이동!");
    callStack.push("콜백 함수"); // 콜백 함수가 스택에 들어간 것을 시뮬레이션
    callback(); // 콜백 실행
    callStack.pop(); // 콜백 실행 완료 후 호출 스택에서 제거
    console.log("  [이벤트 루프] 콜백 실행 완료.");
  } else if (callStack.length === 0 && callbackQueue.length === 0) {
    // 모든 작업이 끝났을 때만 메시지를 보고 싶다면 (옵션)
    console.log('  [이벤트 루프] 모든 작업 완료. 대기 중...');
  }
}

// Web API 시뮬레이션 (비동기 작업)
function webAPI(callback) {
  console.log("  [Web API] 작업 시작 (setTimeout 호출)");
  setTimeout(() => {
    console.log("  [Web API] 작업 완료, 콜백 큐에 추가");
    callbackQueue.push(callback); // 작업 완료 후 콜백 큐에 콜백 추가
  }, 1000); // 1초 후 완료
}

// 메인 스레드에서 실행될 작업들
function mainThreadTasks() {
  callStack.push("mainThreadTasks 함수"); // mainThreadTasks 자체가 스택에 들어간 것을 시뮬레이션

  console.log("--- 메인 스레드 작업 시작 ---");

  callStack.push("동기 작업 1");
  console.log("  [메인 스레드] 동기 작업 1 실행");
  callStack.pop();

  webAPI(() => {
    console.log("🎉 [콜백 실행] Web API를 통해 실행된 콜백 함수");
  });

  callStack.push("동기 작업 2");
  console.log("  [메인 스레드] 동기 작업 2 실행");
  callStack.pop();

  // 메인 스레드를 오래 바쁘게 만드는 동기 작업 시뮬레이션
  callStack.push("오래 걸리는 동기 작업");
  console.log(
    "⏳ [메인 스레드] 오래 걸리는 동기 작업 시작... (이 시간 동안 콜백 실행 안됨)"
  );
  for (let i = 0; i < 2000000000; i++) {
    // CPU를 많이 사용하는 루프
  }
  console.log("✅ [메인 스레드] 오래 걸리는 동기 작업 완료!");
  callStack.pop();

  console.log("--- 메인 스레드 작업 종료 ---");
  callStack.pop(); // mainThreadTasks 함수가 스택에서 나감
}

// 시뮬레이션 시작
console.log("자바스크립트 런타임 시뮬레이션 시작!");

// 이벤트 루프를 주기적으로 실행 (실제 런타임처럼)
const eventLoopInterval = setInterval(eventLoop, 10); // 10ms마다 이벤트 루프 감시

// 메인 스레드 작업 실행
mainThreadTasks();

// 모든 작업이 완료되면 이벤트 루프 중지 (선택 사항)
// 이 시뮬레이션에서는 setTimeout이 있으므로 바로 중지시키기 어려움
// 실제로는 큐가 비고 호출 스택이 비면 더 이상 작업이 없음을 알 수 있음
setTimeout(() => {
  clearInterval(eventLoopInterval);
  console.log('시뮬레이션 종료!');
}, 5000); // 충분한 시간 후 종료

이런 과정을 통해 자바스크립트는 싱글 스레드임에도 불구하고 여러 작업을 동시에 처리하는 것처럼 보이며, 이를 통해 성능을 높이고 사용자 경험을 크게 개선할 수 있습니다.

 

하지만 비동기 방식에도 어려운 점이 있습니다. 어떤 작업은 이전 비동기 작업의 결과가 꼭 필요한 경우가 있어요. 그런데 비동기 작업은 완료될 때까지 기다려주지 않기 때문에, 결과가 오기 전에 다음 코드가 먼저 실행되어버리는 문제가 생길 수 있습니다. 마치 '물을 마시려면 컵에 물을 따라야 되는데, 컵을 가져오기도 전에 물을 따르는 것'과 같아요.

 

 

2.2 비동기 처리 방식의 발전

이런 비동기 작업의 순서나 결과를 제대로 다루기 위해 여러 방법이 발전했습니다.

 

2.2.1 콜백 함수 (Callback Functions)

비동기 작업이 끝났을 때 실행될 함수를 미리 정해두는 방식입니다. 비동기 작업이 완료되면 결과를 이 콜백 함수로 넘겨줘서 처리하게 합니다. 단순할 때는 좋지만, 비동기 작업이 여러 개 얽히면 코드가 복잡해져서 "콜백 지옥(Callback Hell)"에 빠질 수 있습니다.

// 1. 비동기 작업을 수행하고 콜백을 호출하는 함수
function fetchData(url, successCallback, errorCallback) {
  console.log(`[시작] ${url} 에서 데이터 요청 중...`);

  // 실제로는 네트워크 요청이겠지만, 여기서는 setTimeout으로 비동기 상황 시뮬레이션
  setTimeout(() => {
    const data = { message: '데이터 로드 성공!', status: 200 };
    const error = null; // 에러가 없다고 가정

    if (error) {
      errorCallback(error); // 에러 발생 시 에러 콜백 호출
    } else {
      successCallback(data); // 성공 시 성공 콜백 호출
    }
  }, 1500); // 1.5초 후 완료
}

// 2. 성공 시 실행될 콜백 함수
function handleSuccess(data) {
  console.log(`[성공] 데이터 수신: ${data.message}`);
}

// 3. 에러 시 실행될 콜백 함수
function handleError(error) {
  console.error(`[에러] 문제 발생: ${error.message}`);
}

// 4. fetchData 함수 호출 시 두 콜백 함수를 전달
console.log('--- 애플리케이션 시작 ---');

fetchData('https://api.example.com/data', handleSuccess, handleError);

console.log('--- 데이터 요청 후, 다음 작업 바로 진행 ---');
console.log('이 메시지는 데이터 로딩이 끝나기 전에 먼저 출력됩니다.');

 

2.2.2 프로미스 (Promise)

콜백 지옥을 개선하기 위해 등장한 비동기 처리를 좀 더 깔끔하게 다루는 방법입니다. 비동기 작업의 성공/실패와 그 결과를 나타내는 객체로, .then()으로 성공했을 때 할 일을, .catch()로 실패했을 때 할 일을 정해서 코드를 체인처럼 연결할 수 있게 해줍니다. 이것도 체인이 너무 길어지면 복잡해질 수 있습니다.

// Promise를 반환하는 비동기 함수 정의
function fetchDataWithPromise(url) {
  console.log(`[시작] ${url} 에서 데이터 요청 중...`);

  // Promise 객체 생성
  return new Promise((resolve, reject) => {
    // 비동기 작업 시뮬레이션 (네트워크 요청 등)
    setTimeout(() => {
      const success = true; // 성공 여부 가정 (true: 성공, false: 실패)

      if (success) {
        const data = { message: 'Promise: 데이터 로드 성공!', status: 200 };
        resolve(data); // 작업 성공 시 resolve 호출 (성공 결과 전달)
      } else {
        const error = new Error('데이터 로드 실패!');
        reject(error); // 작업 실패 시 reject 호출 (실패 결과 전달)
      }
    }, 1500); // 1.5초 후 완료
  });
}

// Promise 사용
console.log('--- 애플리케이션 시작 ---');

fetchDataWithPromise('https://api.example.com/data')
  .then(response => {
    // Promise가 성공적으로 이행(resolve)되었을 때 실행
    console.log(`[성공] 데이터 수신: ${response.message}`);
    return response.status; // 다음 .then으로 값 전달 가능
  })
  .then(status => {
    console.log(`[연속 처리] HTTP 상태 코드: ${status}`);
  })
  .catch(error => {
    // Promise가 실패(reject)했을 때 실행
    console.error(`[에러] 문제 발생: ${error.message}`);
  })
  .finally(() => {
    // 성공/실패 여부와 관계없이 항상 실행
    console.log('[종료] Promise 작업 완료.');
  });

console.log('--- 데이터 요청 후, 다음 작업 바로 진행 ---');
console.log('이 메시지는 데이터 로딩이 끝나기 전에 먼저 출력됩니다.');

 

2.2.3 async / await

프로미스를 더 읽기 쉽고 동기 코드처럼 보이게 만들어주는 문법입니다. async 함수 안에서 await 키워드를 사용하면, 해당 프로미스가 완료될 때까지 기다렸다가 다음 코드를 실행하게 합니다. 마치 순서대로 실행되는 동기 코드처럼 느껴져서 비동기 코드를 훨씬 쉽게 작성하고 이해할 수 있게 해줍니다. 에러 처리도 try...catch로 자연스럽게 할 수 있어 편리합니다.

이 세 가지 방법은 각각 장단점이 있어서, 상황에 따라 적절한 방식을 선택하는 것이 중요합니다. 콜백은 단순한 작업에, 프로미스는 여러 비동기 작업을 연결할 때, async/await는 비동기 코드를 간결하게 만들고 싶을 때 유용합니다.

// 실패할 가능성이 있는 Promise를 반환하는 함수
function getFailingData(id) {
  console.log(`[Promise] 실패 가능성 있는 데이터 ID: ${id} 요청 중...`);
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id === 100) { // ID가 100이면 실패 시뮬레이션
        reject(new Error(`ID ${id} 데이터는 찾을 수 없습니다.`));
      } else {
        resolve({ id: id, status: 'ok' });
      }
    }, 500);
  });
}

async function processFailingData() {
  console.log('\n--- 실패 시나리오 (async/await) ---');
  try {
    const data1 = await getFailingData(10);
    console.log('[성공] 첫 번째 요청:', data1);

    const data2 = await getFailingData(100); // 여기서 에러 발생
    console.log('[성공] 두 번째 요청:', data2); // 이 라인은 실행되지 않음

  } catch (error) {
    // await한 Promise가 reject되면 catch 블록으로 이동
    console.error(`[에러] 비동기 작업 중 오류 발생: ${error.message}`);
  } finally {
    console.log('--- 실패 시나리오 완료 ---');
  }
}

processFailingData();

 

오늘은 동기와 비동기에 대해 알아봤는데요. 동기와 비동기는 자바스크립트에서 중요하면서 기본인 이론이기에 이번 기회에 정리해봤습니다. 도움이 되셨길 바랍니다 :)