Skip to main content

비동기 JavaScript

Views 34약 11분생성형 AI 활용
Summary

기다림이 필요한 순간

앞 장에서 우리는 fetch()로 외부 데이터를 가져오는 첫 문을 열었습니다. 그런데 그 문을 여는 순간, 이상한 일이 하나 생깁니다.

const response = fetch('https://example.com/data');
console.log(response);

우리는 서버에서 가져온 데이터가 바로 출력되리라 기대할 수 있습니다. 하지만 실제로 콘솔에 찍히는 것은 데이터 자체가 아니라 Promise라는 객체입니다. 처음 보면 조금 당황스럽습니다. "나는 데이터를 달라고 했는데, 왜 약속을 주지?"라는 기분이 들 수 있습니다.

이 장의 핵심은 바로 이 기다림입니다. JavaScript는 버튼 클릭, 타이머, 서버 요청처럼 시간이 걸리는 작업을 자주 다룹니다. 웹페이지는 사용자가 스크롤하고, 입력하고, 클릭하는 동안 멈추면 안 됩니다. 서버에서 데이터를 가져오는 데 1초가 걸린다고 해서 전체 화면이 얼어붙으면 사용자는 금방 떠나 버릴 것입니다.

그래서 JavaScript는 시간이 걸리는 작업을 다루기 위해 비동기라는 방식을 사용합니다. 비동기는 "지금 당장 끝나지 않는 작업을 맡겨 두고, 나머지 코드를 먼저 실행한 뒤, 작업이 끝났을 때 이어서 처리하는 방식"입니다.

단순한 문법을 넘어서 실제 웹앱을 만들고 싶다면 비동기 처리는 반드시 넘어야 할 다리입니다. API에서 날씨를 가져오고, 검색어에 맞는 결과를 불러오고, 저장된 데이터를 화면에 보여 주는 일은 모두 기다림과 관련되어 있습니다.

비동기 JavaScript

동기 코드: 위에서 아래로 차례대로 실행된다

먼저 익숙한 코드부터 보겠습니다.

console.log('첫 번째');
console.log('두 번째');
console.log('세 번째');

이 코드는 예상한 대로 실행됩니다.

첫 번째
두 번째
세 번째

JavaScript 코드는 기본적으로 위에서 아래로 한 줄씩 실행됩니다. 앞줄이 끝나야 다음 줄로 넘어갑니다. 이런 방식을 동기 처리라고 합니다.

동기 처리는 이해하기 쉽습니다. 순서가 분명하기 때문입니다. 하지만 "기다리는 동안 아무것도 못 한다면" 문제가 생깁니다. 웹페이지가 서버 응답을 기다리는 동안 클릭도 안 되고, 스크롤도 안 되고, 입력도 안 된다면 사용자는 브라우저가 고장 났다고 느낄 것입니다.

그래서 JavaScript는 시간이 오래 걸릴 수 있는 작업을 다른 방식으로 처리합니다.

비동기 코드: 맡겨 두고 다음 일을 한다

다음 코드를 보겠습니다.

console.log('시작');

setTimeout(() => {
  console.log('타이머 완료');
}, 1000);

console.log('끝');

출력 순서는 다음과 같습니다.

시작
끝
타이머 완료

코드상으로는 setTimeout()이 console.log('끝')보다 위에 있습니다. 그런데 "타이머 완료"는 가장 마지막에 출력됩니다. 이유는 setTimeout()이 비동기 작업이기 때문입니다.

JavaScript는 setTimeout()을 만나면 이렇게 행동합니다.

1초 뒤에 이 함수를 실행해야 하는구나.
일단 맡겨 두고, 나는 다음 줄을 실행할게.

그래서 console.log('끝')이 먼저 실행되고, 1초가 지난 뒤에 타이머 안의 함수가 실행됩니다.

비동기 처리는 식당에 비유하면 이해하기 쉽습니다. 손님이 파스타를 주문했습니다. 직원이 주방 앞에서 파스타가 완성될 때까지 멍하니 서 있으면 다른 손님을 응대할 수 없습니다. 좋은 식당이라면 주문을 주방에 전달하고, 그동안 다른 손님을 응대합니다. 파스타가 완성되면 그때 다시 가져다줍니다.

JavaScript도 비슷합니다. 서버 요청이나 타이머처럼 시간이 걸리는 작업을 맡겨 두고, 현재 할 수 있는 일을 먼저 처리합니다. 작업이 끝나면 등록해 둔 코드를 실행합니다.

비동기가 필요한 대표적인 상황

웹페이지에서 비동기 처리가 필요한 순간은 생각보다 많습니다.

  • 서버에서 상품 목록 가져오기

  • 사용자가 입력한 검색어로 결과 불러오기

  • 일정 시간이 지난 뒤 알림 숨기기

  • 여러 이미지를 불러와 화면에 표시하기

  • 사용자의 위치나 날씨 정보 가져오기

이런 작업은 즉시 끝난다고 보장할 수 없습니다. 사용자의 네트워크가 느릴 수도 있고, 서버가 바쁠 수도 있고, 요청이 실패할 수도 있습니다. 그래서 "성공했을 때 무엇을 할지"와 "실패했을 때 무엇을 할지"를 함께 생각해야 합니다.

바로 여기서 비동기 코드의 진짜 난이도가 시작됩니다.

콜백으로 비동기 처리하기

비동기 작업이 끝난 뒤 실행할 함수를 미리 전달할 수 있습니다. 이런 함수를 콜백 함수라고 부릅니다. 콜백은 "나중에 다시 불러 주세요"라는 뜻으로 이해하면 됩니다.

setTimeout(() => {
  console.log('1초가 지났습니다.');
}, 1000);

여기서 setTimeout()의 첫 번째 인수로 전달한 화살표 함수가 콜백입니다. JavaScript는 1초 뒤 이 함수를 실행합니다.

콜백 자체는 나쁘지 않습니다. 이벤트에서도 자연스럽게 사용했습니다.

button.addEventListener('click', () => {
  console.log('버튼을 클릭했습니다.');
});

문제는 비동기 작업이 여러 단계로 이어질 때 생깁니다.

콜백이 깊어질 때 생기는 문제

예를 들어 사용자 정보를 가져온 뒤, 그 사용자의 주문 목록을 가져오고, 주문 목록을 가져온 뒤 배송 정보를 가져와야 한다고 해 봅시다. 콜백 방식으로 표현하면 이런 모양이 되기 쉽습니다.

getUser(userId, (user) => {
  getOrders(user.id, (orders) => {
    getDelivery(orders[0].id, (delivery) => {
      console.log(delivery);
    });
  });
});

코드가 오른쪽으로 점점 밀려 들어갑니다. 한 단계만 보면 어렵지 않지만, 단계가 늘어날수록 읽기 힘들어집니다. 실패 처리까지 넣으면 더 복잡해집니다.

getUser(userId, (userError, user) => {
  if (userError) {
    console.log('사용자 정보를 가져오지 못했습니다.');
    return;
  }

  getOrders(user.id, (orderError, orders) => {
    if (orderError) {
      console.log('주문 정보를 가져오지 못했습니다.');
      return;
    }

    getDelivery(orders[0].id, (deliveryError, delivery) => {
      if (deliveryError) {
        console.log('배송 정보를 가져오지 못했습니다.');
        return;
      }

      console.log(delivery);
    });
  });
});

이런 코드는 실행 순서를 따라가기 어렵고, 어디에서 실패했는지 관리하기도 어렵습니다. 흔히 콜백 지옥이라고 부릅니다. JavaScript는 이 문제를 더 읽기 좋은 방식으로 해결하기 위해 Promise를 제공합니다.

Promise: 미래의 결과를 담는 약속

Promise는 아직 끝나지 않은 비동기 작업의 결과를 나타내는 객체입니다. 이름 그대로 "나중에 결과를 주겠다"는 약속입니다.

const result = fetch('https://example.com/data');

console.log(result);

fetch()는 서버에 요청을 보내고, 그 결과를 바로 반환하지 않습니다. 서버 응답이 아직 도착하지 않았기 때문입니다. 대신 Promise를 반환합니다.

Promise에는 중요한 세 가지 상태가 있습니다.

  • pending: 아직 작업이 끝나지 않은 대기 상태

  • fulfilled: 작업이 성공적으로 끝난 상태

  • rejected: 작업이 실패한 상태

음식 배달 앱을 떠올려 보세요. 주문 직후에는 pending입니다. 음식이 도착하면 fulfilled입니다. 가게 사정으로 주문이 취소되면 rejected입니다.

비동기 작업은 언제나 이 흐름 중 하나를 따라갑니다. 아직 기다리는 중인지, 성공했는지, 실패했는지를 구분해야 안정적인 웹앱을 만들 수 있습니다.

then과 catch로 Promise 다루기

Promise가 성공했을 때 실행할 코드는 .then()에 작성합니다. 실패했을 때 실행할 코드는 .catch()에 작성합니다.

fetch('https://example.com/data')
  .then((response) => {
    console.log('응답을 받았습니다.');
    return response.json();
  })
  .then((data) => {
    console.log('데이터:', data);
  })
  .catch((error) => {
    console.log('문제가 발생했습니다:', error);
  });

여기서 중요한 부분은 response.json()도 Promise를 반환한다는 점입니다. 서버에서 받은 응답 본문을 JavaScript 객체로 변환하는 데에도 시간이 걸릴 수 있기 때문입니다.

흐름을 천천히 따라가 봅시다.

  1. 첫 번째 .then()은 서버 응답이 도착했을 때 실행됩니다.

  2. 여기서 response.json()을 반환합니다.

  3. 다음 .then()은 JSON 변환이 끝난 뒤 실행됩니다.

  4. 마지막 .catch()는 앞 과정 중 하나라도 실패하면 실행됩니다.

이 방식은 콜백을 깊게 중첩하는 것보다 훨씬 읽기 좋습니다. 코드가 아래로 이어지기 때문입니다.

Promise를 직접 만들어 보기

보통은 fetch()처럼 이미 Promise를 반환하는 기능을 많이 사용합니다. 그래도 Promise가 어떻게 생겼는지 직접 만들어 보면 개념이 더 선명해집니다.

const orderCoffee = new Promise((resolve, reject) => {
  const hasBeans = true;

  setTimeout(() => {
    if (hasBeans) {
      resolve('커피가 준비되었습니다.');
    } else {
      reject('원두가 없습니다.');
    }
  }, 1000);
});

orderCoffee
  .then((message) => {
    console.log(message);
  })
  .catch((error) => {
    console.log(error);
  });

resolve()는 작업이 성공했음을 알립니다. reject()는 작업이 실패했음을 알립니다.

실제 웹앱에서 Promise를 직접 만드는 일은 초반에는 많지 않습니다. 하지만 Promise를 반환하는 코드를 읽고 다루는 일은 매우 많습니다. 그래서 resolve, reject, then, catch의 관계를 이해해 두면 좋습니다.

async와 await: 비동기를 동기 코드처럼 읽기

.then() 방식은 콜백보다 읽기 좋지만, 코드가 길어지면 여전히 체인이 길어질 수 있습니다. JavaScript는 Promise를 더 편하게 다룰 수 있도록 async와 await를 제공합니다.

async function loadData() {
  const response = await fetch('https://example.com/data');
  const data = await response.json();

  console.log(data);
}

loadData();

이 코드는 마치 위에서 아래로 차례대로 실행되는 동기 코드처럼 보입니다. 하지만 실제로는 비동기 코드입니다.

await는 Promise가 처리될 때까지 다음 줄로 넘어가지 않고 기다립니다. 단, JavaScript 전체를 멈추는 것이 아니라 해당 async 함수 안의 흐름만 잠시 기다리게 합니다. 브라우저는 그동안 다른 이벤트를 처리할 수 있습니다. 사용자는 여전히 스크롤하고, 클릭하고, 입력할 수 있습니다.

await를 사용하려면 보통 함수 앞에 async를 붙입니다.

async function 함수이름() {
  const result = await 어떤Promise;
}

화살표 함수에서도 사용할 수 있습니다.

const loadData = async () => {
  const response = await fetch('https://example.com/data');
  const data = await response.json();

  console.log(data);
};

await는 아무 곳에서나 쓸 수 없다

초보자가 자주 만나는 오류 중 하나가 이것입니다.

const response = await fetch('https://example.com/data');

이 코드가 일반 스크립트의 함수 바깥에 있으면 오류가 날 수 있습니다. 모듈 환경에서는 top-level await를 사용할 수 있는 경우도 있지만, 입문 단계에서는 await는 async 함수 안에서 사용한다고 기억하는 편이 안전합니다.

async function main() {
  const response = await fetch('https://example.com/data');
  const data = await response.json();

  console.log(data);
}

main();

앞으로 실전 프로젝트에서는 main(), init(), loadProducts()처럼 시작 함수를 만들고 그 안에서 await를 사용하는 패턴을 자주 보게 됩니다.

try/catch로 실패 처리하기

네트워크 요청은 언제든 실패할 수 있습니다. 인터넷 연결이 끊길 수도 있고, 주소가 틀렸을 수도 있고, 서버가 응답하지 않을 수도 있습니다. 그래서 API 요청 코드에는 실패 처리가 꼭 필요합니다.

async/await에서는 try/catch를 사용해 실패를 처리합니다.

async function loadData() {
  try {
    const response = await fetch('https://example.com/data');
    const data = await response.json();

    console.log(data);
  } catch (error) {
    console.log('데이터를 불러오지 못했습니다.', error);
  }
}

loadData();

try 블록에는 시도할 코드를 넣습니다. 중간에 오류가 발생하면 흐름이 catch 블록으로 이동합니다.

이것은 사용자 화면 처리와도 바로 연결됩니다. 콘솔에만 오류를 찍는 대신, 실제 웹페이지에서는 "데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요." 같은 메시지를 보여 줄 수 있습니다.

fetch에서 꼭 확인해야 할 response.ok

fetch()를 사용할 때 주의할 점이 있습니다. 서버가 404나 500 같은 실패 응답을 보내도, fetch()가 항상 catch로 이동하는 것은 아닙니다. 네트워크 자체가 실패한 경우에는 catch로 이동하지만, 서버가 "요청은 받았지만 결과는 실패야"라고 응답한 경우에는 응답 객체가 정상적으로 도착한 것으로 볼 수 있습니다.

그래서 보통 response.ok를 확인합니다.

async function loadData() {
  try {
    const response = await fetch('https://example.com/data');

    if (!response.ok) {
      throw new Error('서버 응답이 올바르지 않습니다.');
    }

    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.log('요청 실패:', error.message);
  }
}

throw new Error()는 직접 오류를 발생시키는 코드입니다. 이렇게 하면 catch 블록에서 실패를 한곳에서 처리할 수 있습니다.

초반에는 다음 흐름을 공식처럼 기억해도 좋습니다.

async function 요청함수() {
  try {
    const response = await fetch('주소');

    if (!response.ok) {
      throw new Error('요청 실패');
    }

    const data = await response.json();
    return data;
  } catch (error) {
    console.log(error.message);
  }
}

이 구조는 앞으로 API 기반 웹앱을 만들 때 계속 반복해서 사용하게 됩니다.

로딩 상태와 실패 상태 표현하기

비동기 코드에서 중요한 것은 데이터를 가져오는 것만이 아닙니다. 사용자가 기다리는 동안 무엇을 보여 줄지도 중요합니다.

버튼을 눌렀는데 아무 반응이 없다면 사용자는 다시 버튼을 누를 수 있습니다. 검색을 했는데 화면이 멈춘 것처럼 보이면 사이트가 고장 났다고 생각할 수 있습니다. 그래서 비동기 작업에는 보통 세 가지 화면 상태가 필요합니다.

  • 로딩 상태: 데이터를 불러오는 중

  • 성공 상태: 데이터를 불러와 화면에 표시한 상태

  • 실패 상태: 데이터를 불러오지 못해 오류 메시지를 보여 주는 상태

<button id='loadButton'>데이터 불러오기</button>
<p id='message'></p>
<ul id='list'></ul>
const loadButton = document.querySelector('#loadButton');
const message = document.querySelector('#message');
const list = document.querySelector('#list');

function renderUsers(users) {
  list.innerHTML = '';

  users.forEach((user) => {
    const item = document.createElement('li');
    item.textContent = user.name;
    list.append(item);
  });
}

async function loadUsers() {
  message.textContent = '불러오는 중입니다...';
  list.innerHTML = '';
  loadButton.disabled = true;

  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');

    if (!response.ok) {
      throw new Error('사용자 목록을 불러오지 못했습니다.');
    }

    const users = await response.json();

    renderUsers(users);
    message.textContent = '';
  } catch (error) {
    message.textContent = error.message;
  } finally {
    loadButton.disabled = false;
  }
}

loadButton.addEventListener('click', loadUsers);

이 예제에는 DOM 요소 선택, 클릭 이벤트, fetch(), async/await, try/catch, 로딩과 실패 메시지가 함께 들어 있습니다. 이벤트 리스너에 loadUsers()가 아니라 loadUsers를 전달한 점도 다시 확인해 주세요. 괄호를 붙이면 페이지가 로드되는 순간 함수가 바로 실행됩니다.

날씨 데이터 불러오기

이번에는 조금 더 실제 앱처럼 느껴지는 예제를 만들어 보겠습니다. 사용자가 도시 이름을 입력하고 버튼을 누르면 날씨 정보를 가져오는 흐름입니다.

실제 날씨 API는 보통 API 키가 필요합니다. API 키는 서비스를 사용할 수 있도록 발급받는 비밀번호 같은 값입니다. 여기서는 API 주소가 이미 준비되어 있다고 가정하고 코드 구조에 집중하겠습니다.

<form id='weatherForm'>
  <input id='cityInput' type='text' placeholder='도시 이름' />
  <button type='submit'>날씨 보기</button>
</form>

<p id='weatherMessage'></p>
<div id='weatherResult'></div>
const weatherForm = document.querySelector('#weatherForm');
const cityInput = document.querySelector('#cityInput');
const weatherMessage = document.querySelector('#weatherMessage');
const weatherResult = document.querySelector('#weatherResult');

async function fetchWeather(city) {
  const encodedCity = encodeURIComponent(city);
  const response = await fetch(`/api/weather?city=${encodedCity}`);

  if (!response.ok) {
    throw new Error('날씨 정보를 불러오지 못했습니다.');
  }

  return await response.json();
}

function renderWeather(weather) {
  weatherResult.innerHTML = '';

  const title = document.createElement('h3');
  const temperature = document.createElement('p');
  const description = document.createElement('p');

  title.textContent = weather.city;
  temperature.textContent = `현재 기온: ${weather.temperature}°C`;
  description.textContent = `날씨: ${weather.description}`;

  weatherResult.append(title, temperature, description);
}

weatherForm.addEventListener('submit', async (event) => {
  event.preventDefault();

  const city = cityInput.value.trim();

  if (city === '') {
    weatherMessage.textContent = '도시 이름을 입력해 주세요.';
    weatherResult.innerHTML = '';
    return;
  }

  weatherMessage.textContent = '날씨 정보를 불러오는 중입니다...';
  weatherResult.innerHTML = '';

  try {
    const weather = await fetchWeather(city);
    renderWeather(weather);
    weatherMessage.textContent = '';
  } catch (error) {
    weatherMessage.textContent = error.message;
  }
});

fetchWeather()는 데이터를 가져오는 함수입니다. renderWeather()는 데이터를 화면에 그리는 함수입니다. 이벤트 리스너는 사용자의 제출 행동을 받아 전체 흐름을 조정합니다.

또한 사용자가 입력한 도시 이름은 URL에 들어가기 전에 encodeURIComponent()로 변환했습니다. 공백이나 특수 문자가 들어가도 주소가 깨지지 않도록 하기 위해서입니다.

검색 기능 만들기

검색 기능은 비동기 JavaScript를 배우기에 아주 좋은 예제입니다. 사용자가 검색어를 입력하면 서버에 요청을 보내고, 응답으로 받은 목록을 화면에 보여 줍니다.

<form id='searchForm'>
  <input id='searchInput' type='text' placeholder='검색어를 입력하세요' />
  <button type='submit'>검색</button>
</form>

<p id='searchMessage'></p>
<div id='cardList'></div>
const searchForm = document.querySelector('#searchForm');
const searchInput = document.querySelector('#searchInput');
const searchMessage = document.querySelector('#searchMessage');
const cardList = document.querySelector('#cardList');

async function searchCards(keyword) {
  const encodedKeyword = encodeURIComponent(keyword);
  const response = await fetch(`/api/cards?query=${encodedKeyword}`);

  if (!response.ok) {
    throw new Error('검색 결과를 불러오지 못했습니다.');
  }

  return await response.json();
}

function renderCards(cards) {
  cardList.innerHTML = '';

  if (cards.length === 0) {
    const emptyMessage = document.createElement('p');
    emptyMessage.textContent = '검색 결과가 없습니다.';
    cardList.append(emptyMessage);
    return;
  }

  cards.forEach((card) => {
    const article = document.createElement('article');
    const title = document.createElement('h3');
    const description = document.createElement('p');

    article.classList.add('card');
    title.textContent = card.title;
    description.textContent = card.description;

    article.append(title, description);
    cardList.append(article);
  });
}

searchForm.addEventListener('submit', async (event) => {
  event.preventDefault();

  const keyword = searchInput.value.trim();

  if (keyword === '') {
    searchMessage.textContent = '검색어를 입력해 주세요.';
    cardList.innerHTML = '';
    return;
  }

  searchMessage.textContent = '검색 중입니다...';
  cardList.innerHTML = '';

  try {
    const cards = await searchCards(keyword);
    renderCards(cards);
    searchMessage.textContent = `${cards.length}개의 결과를 찾았습니다.`;
  } catch (error) {
    searchMessage.textContent = error.message;
  }
});

이 코드 역시 세 가지 역할로 나누어 볼 수 있습니다. 서버에서 데이터를 가져오는 함수, 데이터를 화면에 그리는 함수, 사용자 이벤트를 처리하는 함수입니다. 서버에서 온 문자열도 화면에 넣을 때는 textContent를 사용했습니다. 신뢰할 수 없는 문자열을 innerHTML에 바로 넣지 않는 습관은 중요합니다.

순차 처리: 앞 작업의 결과가 다음 작업에 필요할 때

비동기 작업이 여러 개 있을 때는 두 가지 방식으로 처리할 수 있습니다. 하나는 순차 처리이고, 다른 하나는 병렬 처리입니다.

순차 처리는 앞 작업이 끝난 뒤 다음 작업을 시작하는 방식입니다. 앞 작업의 결과가 다음 작업에 필요할 때 사용합니다.

예를 들어 사용자 정보를 가져온 뒤, 그 사용자의 ID로 주문 목록을 가져와야 한다고 해 봅시다.

async function loadUserOrders(userId) {
  try {
    const userResponse = await fetch(`/api/users/${userId}`);

    if (!userResponse.ok) {
      throw new Error('사용자 정보를 불러오지 못했습니다.');
    }

    const user = await userResponse.json();

    const orderResponse = await fetch(`/api/orders?userId=${user.id}`);

    if (!orderResponse.ok) {
      throw new Error('주문 정보를 불러오지 못했습니다.');
    }

    const orders = await orderResponse.json();

    console.log(user.name, orders);
  } catch (error) {
    console.log(error.message);
  }
}

여기서는 두 번째 요청에 user.id가 필요합니다. 따라서 사용자 정보를 먼저 받아야 합니다. 이런 경우에는 await를 차례대로 사용하는 것이 자연스럽습니다.

병렬 처리: 서로 기다릴 필요가 없을 때

반대로 서로 의존하지 않는 요청이라면 동시에 시작하는 편이 더 빠릅니다. 예를 들어 오늘의 날씨, 추천 상품, 공지사항을 각각 다른 API에서 가져온다고 해 봅시다. 세 요청은 서로의 결과를 기다릴 필요가 없습니다.

이럴 때는 Promise.all()을 사용할 수 있습니다.

async function loadHomeData() {
  try {
    const [weatherResponse, productsResponse, noticesResponse] = await Promise.all([
      fetch('/api/weather'),
      fetch('/api/products/recommended'),
      fetch('/api/notices')
    ]);

    if (!weatherResponse.ok || !productsResponse.ok || !noticesResponse.ok) {
      throw new Error('홈 화면 데이터를 불러오지 못했습니다.');
    }

    const [weather, products, notices] = await Promise.all([
      weatherResponse.json(),
      productsResponse.json(),
      noticesResponse.json()
    ]);

    console.log(weather, products, notices);
  } catch (error) {
    console.log(error.message);
  }
}

Promise.all()은 여러 Promise가 모두 성공할 때까지 기다립니다. 모두 성공하면 결과를 배열로 반환합니다. 하나라도 실패하면 전체가 실패로 처리됩니다.

여러 요청을 처리할 때는 "이 작업이 이전 결과를 필요로 하는가?"를 먼저 생각하면 됩니다.

  • 이전 요청의 결과가 필요하다면 순차 처리

  • 서로 독립적이라면 병렬 처리

  • 하나라도 실패하면 전체를 멈춰야 한다면 Promise.all() 고려

  • 일부가 실패해도 가능한 결과를 보여 주고 싶다면 개별 try/catch 고려

비동기 코드는 단순히 문법 문제가 아닙니다. 어떤 작업들이 서로 의존하는지 판단하는 설계의 문제이기도 합니다.

너무 빠른 검색 요청 다루기

검색 기능에는 현실적인 문제가 하나 더 있습니다. 사용자가 검색어를 입력할 때마다 요청을 보낸다고 해 봅시다. j, ja, jav, java, javascript를 입력하는 동안 요청이 여러 번 발생할 수 있습니다.

이때 네트워크 상황에 따라 나중에 보낸 요청보다 먼저 보낸 요청이 더 늦게 도착할 수도 있습니다. 예를 들어 javascript 검색 결과를 기다리는 중에, 이전에 보낸 java 검색 결과가 늦게 도착해 화면을 덮어쓰는 일이 생길 수 있습니다.

초반에는 제출 버튼을 눌렀을 때만 검색하도록 만들면 이런 문제를 크게 줄일 수 있습니다. 입력할 때마다 자동 검색을 만들고 싶다면 디바운스 같은 기법을 사용합니다. 디바운스는 사용자가 입력을 멈춘 뒤 일정 시간이 지나면 요청을 보내는 방식입니다.

let timerId;

searchInput.addEventListener('input', () => {
  clearTimeout(timerId);

  timerId = setTimeout(() => {
    console.log('검색 요청:', searchInput.value);
  }, 500);
});

사용자가 입력할 때마다 기존 타이머를 취소하고 새 타이머를 등록합니다. 0.5초 동안 추가 입력이 없으면 그때 검색을 실행합니다.

버튼 중복 클릭 막기

비동기 작업 중에 사용자가 버튼을 여러 번 누르면 같은 요청이 반복해서 발생할 수 있습니다. 주문하기 버튼을 여러 번 눌렀는데 주문이 여러 번 들어간다면 큰일입니다.

간단한 방법은 요청이 진행되는 동안 버튼을 비활성화하는 것입니다.

const submitButton = document.querySelector('#submitButton');

async function submitOrder() {
  submitButton.disabled = true;
  submitButton.textContent = '처리 중...';

  try {
    const response = await fetch('/api/orders', {
      method: 'POST'
    });

    if (!response.ok) {
      throw new Error('주문에 실패했습니다.');
    }

    console.log('주문이 완료되었습니다.');
  } catch (error) {
    console.log(error.message);
  } finally {
    submitButton.disabled = false;
    submitButton.textContent = '주문하기';
  }
}

submitButton.addEventListener('click', submitOrder);

finally는 성공하든 실패하든 마지막에 실행됩니다. 그래서 버튼을 다시 활성화하는 코드처럼 반드시 정리해야 하는 작업을 넣기 좋습니다.

async 함수는 Promise를 반환한다

마지막으로 async 함수의 중요한 특징을 하나 짚고 넘어가겠습니다. async 함수는 항상 Promise를 반환합니다.

async function getNumber() {
  return 10;
}

const result = getNumber();
console.log(result); // Promise

겉으로는 10을 반환하는 것처럼 보이지만, 실제로는 10으로 fulfilled되는 Promise가 반환됩니다. 값을 꺼내려면 await를 사용하거나 .then()을 사용해야 합니다.

async function main() {
  const number = await getNumber();
  console.log(number); // 10
}

main();

이 특징을 이해하면 async 함수를 다른 함수에서 호출할 때 왜 다시 await가 필요한지 자연스럽게 이해할 수 있습니다.

이번 장에서 기억할 것

비동기 처리는 시간이 걸리는 작업을 맡겨 두고, 나머지 코드를 먼저 실행한 뒤, 작업이 끝났을 때 이어서 처리하는 방식입니다. 타이머, 이벤트, 서버 요청은 모두 비동기 흐름과 관련됩니다.

콜백은 나중에 실행할 함수를 전달하는 방식입니다. 하지만 여러 단계가 깊어지면 코드가 복잡해질 수 있습니다. Promise는 비동기 작업의 미래 결과를 나타내고, then/catch로 성공과 실패를 처리할 수 있습니다.

async/await는 Promise 기반 비동기 코드를 동기 코드처럼 읽기 쉽게 만들어 줍니다. await는 보통 async 함수 안에서 사용하고, 실패 처리는 try/catch로 감쌉니다. fetch()를 사용할 때는 HTTP 오류를 놓치지 않도록 response.ok를 확인해야 합니다.

실전 API 코드는 로딩·성공·실패 상태를 화면에 표현해야 합니다. 사용자가 입력한 값을 URL에 넣을 때는 encodeURIComponent()를 사용하고, 서버나 사용자에게서 온 문자열을 화면에 넣을 때는 가능한 한 textContent를 사용하세요.

다음 장에서는 코드가 길어졌을 때의 구조를 다룹니다. 비동기 요청, 상태, 렌더링 함수, 이벤트 핸들러를 어떻게 나누고 연결해야 유지보수하기 쉬운 앱이 되는지 살펴보겠습니다.

책 목록

처음 만나는 JavaScript: 웹페이지를 살아 움직이게 만드는 언어

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기