JavaScript 비동기 프로그래밍 완벽 가이드 - 동기 vs 비동기 이해하기

JavaScript 개발에서 가장 중요한 개념 중 하나인 비동기 프로그래밍에 대해 써보려합니다.

웹 개발을 하다 보면 “동기"와 “비동기"라는 용어를 자주 접하게 되는데, 이 두 개념을 제대로 이해하는 것이 현대 JavaScript 개발의 핵심입니다.

동기 vs 비동기란?

기본 개념

JavaScript는 싱글 스레드(Single Thread) 언어입니다. 이는 한 번에 하나의 작업만 처리할 수 있다는 의미입니다. 하지만 실제로는 여러 작업이 동시에 처리되는 것처럼 보이는데, 이는 동기비동기 처리 방식의 차이 때문입니다.

동기(Synchronous) 처리

동기 처리는 현재 실행 중인 작업이 완료될 때까지 다음 작업이 대기하는 방식입니다.

// 동기 처리 예시
function syncTask() {
  console.log('1. 첫 번째 작업 시작');

  // 시간이 오래 걸리는 작업 (3초)
  const start = Date.now();
  while (Date.now() - start < 3000) {
    // 3초 동안 대기
  }

  console.log('2. 첫 번째 작업 완료');
}

function secondTask() {
  console.log('3. 두 번째 작업 실행');
}

syncTask();  // 3초 대기
secondTask(); // 첫 번째 작업 완료 후 실행

실행 결과:

1. 첫 번째 작업 시작
(3초 대기)
2. 첫 번째 작업 완료
3. 두 번째 작업 실행

비동기(Asynchronous) 처리

비동기 처리는 현재 실행 중인 작업이 완료되지 않아도 다음 작업을 바로 실행하는 방식입니다.

// 비동기 처리 예시
function asyncTask() {
  console.log('1. 비동기 작업 시작');

  // setTimeout은 비동기로 동작
  setTimeout(() => {
    console.log('2. 비동기 작업 완료');
  }, 3000);

  console.log('3. 비동기 작업 등록 완료');
}

function secondTask() {
  console.log('4. 두 번째 작업 실행');
}

asyncTask();  // 비동기 작업 등록
secondTask(); // 바로 실행됨

실행 결과:

1. 비동기 작업 시작
3. 비동기 작업 등록 완료
4. 두 번째 작업 실행
(3초 후)
2. 비동기 작업 완료

JavaScript의 실행 컨텍스트와 스택

실행 컨텍스트 스택 (Call Stack)

JavaScript 엔진은 실행 컨텍스트 스택을 통해 함수의 실행을 관리합니다.

function first() {
  console.log('첫 번째 함수');
  second();
}

function second() {
  console.log('두 번째 함수');
  third();
}

function third() {
  console.log('세 번째 함수');
}

first();

실행 컨텍스트 스택 동작:

1. first() 호출
   ┌─────────┐
   │ first() │ ← 현재 실행 중
   └─────────┘

2. second() 호출
   ┌─────────┐
   │second() │ ← 현재 실행 중
   ├─────────┤
   │ first() │
   └─────────┘

3. third() 호출
   ┌─────────┐
   │ third() │ ← 현재 실행 중
   ├─────────┤
   │second() │
   ├─────────┤
   │ first() │
   └─────────┘

4. third() 완료
   ┌─────────┐
   │second() │ ← 현재 실행 중
   ├─────────┤
   │ first() │
   └─────────┘

5. second() 완료
   ┌─────────┐
   │ first() │ ← 현재 실행 중
   └─────────┘

6. first() 완료
   ┌─────────┐
   │ (비어있음) │
   └─────────┘

싱글 스레드의 한계

JavaScript는 단 하나의 실행 컨텍스트 스택을 가지므로, 동시에 여러 함수를 실행할 수 없습니다.

// ❌ 문제가 되는 동기 처리 예시
function blockingTask() {
  console.log('무거운 작업 시작');

  // 5초 동안 블로킹
  const start = Date.now();
  while (Date.now() - start < 5000) {
    // 아무것도 하지 않음
  }

  console.log('무거운 작업 완료');
}

function userInteraction() {
  console.log('사용자 상호작용 처리');
}

blockingTask();  // 5초 동안 블로킹
userInteraction(); // 5초 후에 실행됨

이 경우 사용자는 5초 동안 아무것도 할 수 없게 됩니다!

비동기 처리의 필요성

실제 웹 애플리케이션에서의 문제

// ❌ 동기 방식의 API 호출 (문제가 있는 코드)
function fetchUserDataSync(userId) {
  console.log('사용자 데이터 요청 시작');

  // 실제로는 이런 동기 HTTP 요청은 불가능
  const response = fetch(`/api/users/${userId}`); // 이 부분이 블로킹됨
  const userData = response.json();

  console.log('사용자 데이터:', userData);
  return userData;
}

// 이 함수가 실행되면 전체 페이지가 멈춤
const userData = fetchUserDataSync(123);
console.log('다른 작업'); // 위 함수가 완료될 때까지 실행되지 않음

비동기 방식으로 해결

// ✅ 비동기 방식의 API 호출
function fetchUserDataAsync(userId) {
  console.log('사용자 데이터 요청 시작');

  return fetch(`/api/users/${userId}`)
    .then(response => response.json())
    .then(userData => {
      console.log('사용자 데이터:', userData);
      return userData;
    });
}

// 비동기 호출
fetchUserDataAsync(123);
console.log('다른 작업'); // 즉시 실행됨

JavaScript의 비동기 처리 방식

1. 콜백(Callback)

가장 기본적인 비동기 처리 방식입니다.

// 콜백을 사용한 비동기 처리
function fetchData(callback) {
  console.log('데이터 요청 시작');

  setTimeout(() => {
    const data = { id: 1, name: '김개발' };
    console.log('데이터 수신 완료');
    callback(data);
  }, 2000);
}

// 사용 예시
fetchData((data) => {
  console.log('받은 데이터:', data);
});

console.log('다른 작업 실행');

실행 결과:

데이터 요청 시작
다른 작업 실행
(2초 후)
데이터 수신 완료
받은 데이터: { id: 1, name: '김개발' }

2. Promise

콜백의 단점을 해결하기 위해 등장한 방식입니다.

// Promise를 사용한 비동기 처리
function fetchDataPromise() {
  return new Promise((resolve, reject) => {
    console.log('데이터 요청 시작');

    setTimeout(() => {
      const data = { id: 1, name: '김개발' };
      console.log('데이터 수신 완료');
      resolve(data);
    }, 2000);
  });
}

// 사용 예시
fetchDataPromise()
  .then(data => {
    console.log('받은 데이터:', data);
    return data.id;
  })
  .then(id => {
    console.log('사용자 ID:', id);
  })
  .catch(error => {
    console.error('오류 발생:', error);
  });

console.log('다른 작업 실행');

3. async/await

Promise를 더 쉽게 사용할 수 있게 해주는 문법입니다.

// async/await를 사용한 비동기 처리
async function fetchUserData() {
  try {
    console.log('사용자 데이터 요청 시작');

    const response = await fetch('/api/users/123');
    const userData = await response.json();

    console.log('사용자 데이터:', userData);
    return userData;
  } catch (error) {
    console.error('오류 발생:', error);
    throw error;
  }
}

// 사용 예시
async function main() {
  const userData = await fetchUserData();
  console.log('메인 함수에서 받은 데이터:', userData);
}

main();
console.log('다른 작업 실행');

실제 활용 사례

1. 파일 업로드 처리

// 파일 업로드 비동기 처리
class FileUploader {
  constructor() {
    this.uploadQueue = [];
    this.isUploading = false;
  }

  async uploadFile(file) {
    return new Promise((resolve, reject) => {
      const formData = new FormData();
      formData.append('file', file);

      fetch('/api/upload', {
        method: 'POST',
        body: formData
      })
      .then(response => response.json())
      .then(result => {
        console.log('파일 업로드 완료:', result);
        resolve(result);
      })
      .catch(error => {
        console.error('파일 업로드 실패:', error);
        reject(error);
      });
    });
  }

  async uploadMultipleFiles(files) {
    const uploadPromises = Array.from(files).map(file =>
      this.uploadFile(file)
    );

    try {
      const results = await Promise.all(uploadPromises);
      console.log('모든 파일 업로드 완료:', results);
      return results;
    } catch (error) {
      console.error('일부 파일 업로드 실패:', error);
      throw error;
    }
  }
}

// 사용 예시
const uploader = new FileUploader();
const fileInput = document.getElementById('fileInput');

fileInput.addEventListener('change', async (event) => {
  const files = event.target.files;

  try {
    const results = await uploader.uploadMultipleFiles(files);
    console.log('업로드 성공:', results);
  } catch (error) {
    console.error('업로드 실패:', error);
  }
});

2. 데이터베이스 작업

// 데이터베이스 비동기 작업
class UserService {
  async getUsers() {
    try {
      const response = await fetch('/api/users');
      return await response.json();
    } catch (error) {
      console.error('사용자 목록 조회 실패:', error);
      throw error;
    }
  }

  async createUser(userData) {
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(userData)
      });

      return await response.json();
    } catch (error) {
      console.error('사용자 생성 실패:', error);
      throw error;
    }
  }

  async updateUser(id, userData) {
    try {
      const response = await fetch(`/api/users/${id}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(userData)
      });

      return await response.json();
    } catch (error) {
      console.error('사용자 수정 실패:', error);
      throw error;
    }
  }

  async deleteUser(id) {
    try {
      await fetch(`/api/users/${id}`, {
        method: 'DELETE'
      });

      console.log('사용자 삭제 완료');
    } catch (error) {
      console.error('사용자 삭제 실패:', error);
      throw error;
    }
  }
}

// 사용 예시
const userService = new UserService();

async function manageUsers() {
  try {
    // 사용자 목록 조회
    const users = await userService.getUsers();
    console.log('사용자 목록:', users);

    // 새 사용자 생성
    const newUser = await userService.createUser({
      name: '김개발',
      email: '[email protected]'
    });
    console.log('생성된 사용자:', newUser);

    // 사용자 정보 수정
    const updatedUser = await userService.updateUser(newUser.id, {
      name: '김개발',
      email: '[email protected]'
    });
    console.log('수정된 사용자:', updatedUser);

  } catch (error) {
    console.error('사용자 관리 중 오류:', error);
  }
}

manageUsers();

3. 애니메이션과 비동기 처리

// 애니메이션과 비동기 처리
class AnimationController {
  async fadeIn(element, duration = 1000) {
    return new Promise((resolve) => {
      element.style.opacity = '0';
      element.style.display = 'block';

      let start = null;

      function animate(timestamp) {
        if (!start) start = timestamp;
        const progress = timestamp - start;
        const opacity = Math.min(progress / duration, 1);

        element.style.opacity = opacity;

        if (progress < duration) {
          requestAnimationFrame(animate);
        } else {
          resolve();
        }
      }

      requestAnimationFrame(animate);
    });
  }

  async fadeOut(element, duration = 1000) {
    return new Promise((resolve) => {
      let start = null;

      function animate(timestamp) {
        if (!start) start = timestamp;
        const progress = timestamp - start;
        const opacity = Math.max(1 - progress / duration, 0);

        element.style.opacity = opacity;

        if (progress < duration) {
          requestAnimationFrame(animate);
        } else {
          element.style.display = 'none';
          resolve();
        }
      }

      requestAnimationFrame(animate);
    });
  }

  async slideIn(element, direction = 'left', duration = 1000) {
    return new Promise((resolve) => {
      const originalTransform = element.style.transform;
      const originalDisplay = element.style.display;

      element.style.display = 'block';

      let start = null;
      const distance = direction === 'left' || direction === 'right' ?
        element.offsetWidth : element.offsetHeight;

      const translateValue = direction === 'left' ? -distance :
                           direction === 'right' ? distance :
                           direction === 'up' ? -distance : distance;

      element.style.transform = `translate${direction === 'left' || direction === 'right' ? 'X' : 'Y'}(${translateValue}px)`;

      function animate(timestamp) {
        if (!start) start = timestamp;
        const progress = timestamp - start;
        const ratio = Math.min(progress / duration, 1);

        const currentTranslate = translateValue * (1 - ratio);
        element.style.transform = `translate${direction === 'left' || direction === 'right' ? 'X' : 'Y'}(${currentTranslate}px)`;

        if (progress < duration) {
          requestAnimationFrame(animate);
        } else {
          element.style.transform = originalTransform;
          resolve();
        }
      }

      requestAnimationFrame(animate);
    });
  }
}

// 사용 예시
const animationController = new AnimationController();
const modal = document.getElementById('modal');

async function showModal() {
  await animationController.fadeIn(modal);
  console.log('모달 표시 완료');
}

async function hideModal() {
  await animationController.fadeOut(modal);
  console.log('모달 숨김 완료');
}

// 버튼 클릭 시 모달 표시/숨김
document.getElementById('showModal').addEventListener('click', showModal);
document.getElementById('hideModal').addEventListener('click', hideModal);

비동기 처리의 주의사항

1. 콜백 지옥(Callback Hell)

// ❌ 콜백 지옥 예시
fetchUserData((user) => {
  fetchUserPosts(user.id, (posts) => {
    fetchPostComments(posts[0].id, (comments) => {
      fetchCommentAuthor(comments[0].authorId, (author) => {
        console.log('작성자 정보:', author);
      });
    });
  });
});

// ✅ Promise로 해결
fetchUserData()
  .then(user => fetchUserPosts(user.id))
  .then(posts => fetchPostComments(posts[0].id))
  .then(comments => fetchCommentAuthor(comments[0].authorId))
  .then(author => console.log('작성자 정보:', author))
  .catch(error => console.error('오류:', error));

// ✅ async/await로 더 깔끔하게 해결
async function getCommentAuthor() {
  try {
    const user = await fetchUserData();
    const posts = await fetchUserPosts(user.id);
    const comments = await fetchPostComments(posts[0].id);
    const author = await fetchCommentAuthor(comments[0].authorId);

    console.log('작성자 정보:', author);
    return author;
  } catch (error) {
    console.error('오류:', error);
    throw error;
  }
}

2. 에러 처리

// 비동기 함수의 에러 처리
async function handleAsyncOperation() {
  try {
    const result = await riskyAsyncOperation();
    return result;
  } catch (error) {
    console.error('비동기 작업 실패:', error);

    // 에러 복구 시도
    try {
      const fallbackResult = await fallbackOperation();
      return fallbackResult;
    } catch (fallbackError) {
      console.error('복구 작업도 실패:', fallbackError);
      throw new Error('모든 작업이 실패했습니다');
    }
  }
}

// Promise.all의 에러 처리
async function handleMultipleOperations() {
  const promises = [
    fetch('/api/users'),
    fetch('/api/posts'),
    fetch('/api/comments')
  ];

  try {
    const results = await Promise.all(promises);
    return results.map(response => response.json());
  } catch (error) {
    console.error('일부 요청 실패:', error);

    // 일부 실패해도 성공한 결과는 처리
    const results = await Promise.allSettled(promises);
    const successfulResults = results
      .filter(result => result.status === 'fulfilled')
      .map(result => result.value);

    return successfulResults;
  }
}

3. 메모리 누수 방지

// 이벤트 리스너 정리
class EventManager {
  constructor() {
    this.listeners = new Map();
  }

  addEventListener(element, event, handler) {
    element.addEventListener(event, handler);

    if (!this.listeners.has(element)) {
      this.listeners.set(element, []);
    }
    this.listeners.get(element).push({ event, handler });
  }

  removeAllListeners(element) {
    const elementListeners = this.listeners.get(element);
    if (elementListeners) {
      elementListeners.forEach(({ event, handler }) => {
        element.removeEventListener(event, handler);
      });
      this.listeners.delete(element);
    }
  }

  cleanup() {
    this.listeners.forEach((listeners, element) => {
      this.removeAllListeners(element);
    });
  }
}

// 사용 예시
const eventManager = new EventManager();

// 이벤트 리스너 추가
eventManager.addEventListener(button, 'click', handleClick);
eventManager.addEventListener(input, 'input', handleInput);

// 컴포넌트 언마운트 시 정리
function cleanup() {
  eventManager.cleanup();
}

성능 최적화 팁

1. 병렬 처리

// 순차 처리 vs 병렬 처리
async function sequentialProcessing() {
  const start = Date.now();

  const result1 = await fetch('/api/data1'); // 1초
  const result2 = await fetch('/api/data2'); // 1초
  const result3 = await fetch('/api/data3'); // 1초

  console.log('순차 처리 시간:', Date.now() - start); // 약 3초
  return [result1, result2, result3];
}

async function parallelProcessing() {
  const start = Date.now();

  const [result1, result2, result3] = await Promise.all([
    fetch('/api/data1'), // 1초
    fetch('/api/data2'), // 1초
    fetch('/api/data3')  // 1초
  ]);

  console.log('병렬 처리 시간:', Date.now() - start); // 약 1초
  return [result1, result2, result3];
}

2. 디바운싱과 쓰로틀링

// 디바운싱
function debounce(func, delay) {
  let timeoutId;

  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

// 쓰로틀링
function throttle(func, delay) {
  let lastCall = 0;

  return function (...args) {
    const now = Date.now();

    if (now - lastCall >= delay) {
      lastCall = now;
      func.apply(this, args);
    }
  };
}

// 사용 예시
const debouncedSearch = debounce((query) => {
  console.log('검색 실행:', query);
}, 300);

const throttledScroll = throttle(() => {
  console.log('스크롤 이벤트 처리');
}, 100);

// 이벤트 리스너에 적용
input.addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});

window.addEventListener('scroll', throttledScroll);

핵심 포인트:

  • 동기: 순차적 실행, 블로킹 발생
  • 비동기: 비순차적 실행, 블로킹 없음
  • 콜백 → Promise → async/await 순으로 발전
  • 에러 처리메모리 관리에 주의
  • 성능 최적화를 위한 병렬 처리 활용