자바스크립트 실전 #3 fetch와 비동기 UI

#2 이벤트 핸들링과 위임 에서 사용자 입력을 받는 도구를 봤습니다. 이번엔 그 입력에 따라 외부 데이터를 받아와 화면에 반영하는 비동기 UI의 실전 패턴들을 다룹니다.

가장 단순한 형태부터 #

HTML
<button id="load">유저 불러오기</button>
<ul id="user-list"></ul>
가장 단순
const button = document.querySelector('#load');
const list = document.querySelector('#user-list');

button.addEventListener('click', async () => {
  const res = await fetch('/api/users');
  const users = await res.json();

  list.innerHTML = '';
  for (const user of users) {
    const li = document.createElement('li');
    li.textContent = user.name;
    list.append(li);
  }
});

중급 #6 fetch API + 실전 #1 DOM 조작 의 조합. 동작은 하지만, 실제 UI 에서는 로딩 표시, 에러 처리, 중복 클릭 방지가 빠져 있습니다.

로딩과 에러 — 세 가지 상태 #

데이터 로딩에는 보통 다음 상태가 있습니다.

  • idle — 아직 안 부름
  • loading — 진행 중
  • success — 데이터 있음
  • error — 실패

이걸 UI에 반영하는 패턴.

로딩과 에러 처리
button.addEventListener('click', async () => {
  list.innerHTML = '<li class="loading">불러오는 중...</li>';
  button.disabled = true;

  try {
    const res = await fetch('/api/users');
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const users = await res.json();

    list.innerHTML = '';
    for (const user of users) {
      const li = document.createElement('li');
      li.textContent = user.name;
      list.append(li);
    }
  } catch (err) {
    list.innerHTML = `<li class="error">에러: ${err.message}</li>`;
  } finally {
    button.disabled = false;
  }
});

세 가지가 추가됐습니다.

  1. 로딩 표시<li class="loading">불러오는 중...</li>
  2. 에러 메시지<li class="error">...
  3. 버튼 비활성화button.disabled = true로 중복 클릭 방지, finally에서 복구

finally 블록이 핵심입니다. 성공/실패 무관하게 버튼 복구가 보장됩니다.

검색창 — 실시간 입력에 반응 #

검색 자동완성 같은 경우. 입력할 때마다 fetch를 부르면 너무 많은 요청이 갑니다.

HTML
<input id="search" placeholder="검색...">
<ul id="results"></ul>

가장 단순한 (그러나 비효율적인) 버전 #

매 키 입력마다 fetch
input.addEventListener('input', async (e) => {
  const q = e.target.value;
  if (!q) {
    results.innerHTML = '';
    return;
  }

  const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
  const items = await res.json();
  // ... DOM 갱신
});

문제 둘:

  1. ‘h’, ‘he’, ‘hel’, …, ‘hello’ 매 키마다 fetch — 트래픽 낭비
  2. ‘h’ 응답이 늦게 와서 ‘hello’ 결과를 덮어쓸 수 있음

디바운스 — 연속 입력을 한 번으로 합치기 #

debounce 함수
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

const onSearch = debounce(async (q) => {
  if (!q) {
    results.innerHTML = '';
    return;
  }
  const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
  const items = await res.json();
  // ... DOM 갱신
}, 300);

input.addEventListener('input', (e) => onSearch(e.target.value));

고급 #1 클로저 에서 본 패턴입니다. 입력이 멈춘 뒤 300ms가 지나야 실제 호출. 빠르게 타이핑하면 한 번만 갑니다.

AbortController — 늦은 응답 막기 #

디바운스만으로는 두 번째 문제(늦은 응답이 새 결과를 덮어씀)를 못 막습니다. 이전 fetch를 취소해야 합니다.

AbortController 로 이전 요청 취소
let currentController = null;

const onSearch = debounce(async (q) => {
  if (!q) {
    results.innerHTML = '';
    return;
  }

  // 이전 요청 취소
  if (currentController) currentController.abort();
  currentController = new AbortController();

  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
      signal: currentController.signal,
    });
    const items = await res.json();
    // ... DOM 갱신
  } catch (err) {
    if (err.name === 'AbortError') return;   // 취소된 요청은 무시
    console.error(err);
  }
}, 300);

input.addEventListener('input', (e) => onSearch(e.target.value));

새 요청이 들어오면 이전 요청을 abort. 늦게 오는 응답은 AbortError가 되어 catch에서 걸러져요.

이 두 도구(디바운스 + AbortController)가 검색 UI의 표준 조합입니다.

AbortSignal.timeout — 타임아웃까지 #

요청이 너무 오래 걸리면 자동 취소.

타임아웃
const res = await fetch(url, {
  signal: AbortSignal.timeout(5000),
});

중급 #6 에서 본 도구. 디바운스/AbortController와 결합해 더 견고한 검색 UI를 만들 수 있습니다.

두 signal 합치기 — AbortSignal.any #

타임아웃 + 사용자 취소 둘 다 처리하고 싶으면.

AbortSignal.any
const userController = new AbortController();
const signal = AbortSignal.any([
  userController.signal,
  AbortSignal.timeout(5000),
]);

const res = await fetch(url, { signal });
// userController.abort() 또는 5초 초과 둘 중 하나로 취소됨

ES2024의 AbortSignal.any가 최신 표준 도구입니다.

중복 요청 방지 — 인플라이트 캐시 #

같은 데이터를 여러 곳에서 동시에 요청할 때, 한 번만 가게 만들기.

간단한 인플라이트 캐시
const inflight = new Map();

function fetchOnce(url) {
  if (inflight.has(url)) {
    return inflight.get(url);
  }
  const promise = fetch(url)
    .then((r) => r.json())
    .finally(() => inflight.delete(url));
  inflight.set(url, promise);
  return promise;
}

// 동시에 두 곳에서 부름
const a = fetchOnce('/api/me');
const b = fetchOnce('/api/me');
// 실제 fetch 는 한 번만, a 와 b 는 같은 Promise 공유

Map에 진행 중인 Promise를 넣어두고 — 같은 URL을 또 요청하면 그 Promise를 그대로 돌려줍니다. TanStack Query 같은 라이브러리가 내부적으로 하는 일 중 하나입니다.

페이지네이션 / 무한 스크롤 패턴 #

더 보기 버튼
let page = 1;

async function loadMore() {
  loadMoreBtn.disabled = true;
  const res = await fetch(`/api/posts?page=${page}`);
  const posts = await res.json();

  for (const p of posts) {
    const li = document.createElement('li');
    li.textContent = p.title;
    list.append(li);
  }

  page++;
  loadMoreBtn.disabled = false;

  if (posts.length === 0) {
    loadMoreBtn.remove();   // 더 없으면 버튼 제거
  }
}

loadMoreBtn.addEventListener('click', loadMore);
loadMore();   // 처음에 한 번

무한 스크롤 — IntersectionObserver #

스크롤이 끝에 가까워지면 자동 로드.

IntersectionObserver
const sentinel = document.querySelector('.sentinel');

const observer = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    loadMore();
  }
});

observer.observe(sentinel);

.sentinel은 리스트 끝에 둔 빈 엘리먼트. 그게 화면에 들어오면 콜백이 실행됩니다. 옛날 scroll 이벤트로 직접 좌표 계산하던 패턴보다 훨씬 효율적입니다.

낙관적 업데이트 (Optimistic Update) #

서버 응답을 기다리지 않고 UI를 먼저 업데이트하는 패턴. 즉각적인 피드백을 주는 데 유용합니다.

좋아요 버튼
likeBtn.addEventListener('click', async () => {
  // 낙관적으로 즉시 UI 변경
  likeBtn.classList.add('liked');
  countEl.textContent = parseInt(countEl.textContent) + 1;

  try {
    await fetch('/api/like', { method: 'POST' });
  } catch (err) {
    // 실패하면 되돌리기
    likeBtn.classList.remove('liked');
    countEl.textContent = parseInt(countEl.textContent) - 1;
    alert('좋아요 실패');
  }
});

성공이 압도적으로 많은 액션(즐겨찾기, 북마크)에 적합합니다. 실패가 자주 나는 경우에는 부적합 — 되돌리는 깜빡임이 거슬립니다.

에러 처리의 단계 #

1) 네트워크 단계 #

네트워크 에러
try {
  const res = await fetch(url);
  // ...
} catch (err) {
  if (err.name === 'TypeError') {
    // 네트워크 자체가 안 됨 (오프라인, DNS 등)
    showOffline();
  } else if (err.name === 'AbortError') {
    // 취소
    return;
  } else {
    throw err;
  }
}

2) HTTP 상태 #

상태 코드 분기
if (!res.ok) {
  if (res.status === 401) return redirectToLogin();
  if (res.status === 404) return showNotFound();
  if (res.status >= 500) return showServerError();
  throw new Error(`HTTP ${res.status}`);
}

3) 응답 본문 파싱 / 검증 #

본문 검증
const data = await res.json();
if (!data || typeof data.id !== 'string') {
  throw new Error('잘못된 응답 형식');
}

세 단계 모두 처리 지점이 다르고 처리 방식도 다릅니다. 작은 wrapper 함수로 정리하면 모든 호출부가 깔끔해져요.

작은 wrapper — 자기 앱의 fetch 약속 #

api.js
export async function api(url, options = {}) {
  let res;
  try {
    res = await fetch(url, {
      headers: { 'Content-Type': 'application/json', ...options.headers },
      ...options,
    });
  } catch (err) {
    if (err.name === 'AbortError') throw err;
    throw new Error(`네트워크 오류: ${err.message}`);
  }

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }

  return res.json();
}

export const apiGet = (url, signal) => api(url, { signal });
export const apiPost = (url, data, signal) => api(url, {
  method: 'POST',
  body: JSON.stringify(data),
  signal,
});

이 정도만 만들어도 호출부가 매우 깔끔해집니다.

사용처
import { apiGet } from './api.js';

try {
  const users = await apiGet('/api/users');
  // ...
} catch (err) {
  showError(err.message);
}

마무리 #

이번 글에서 정리한 내용:

  • 로딩/에러/성공 세 상태를 UI에 반영
  • try/catch/finally + disabled 토글로 중복 클릭 방지
  • 디바운스로 입력 빈도 줄이기
  • AbortController로 늦은 응답 취소
  • AbortSignal.timeout / AbortSignal.any로 타임아웃 합성
  • 인플라이트 캐시로 중복 요청 막기
  • IntersectionObserver로 무한 스크롤
  • 낙관적 업데이트 패턴
  • 에러는 네트워크 / 상태 / 본문 세 단계
  • 작은 wrapper 함수로 호출부 정리

다음 글(#4 폼 다루기)에서는 폼 입력의 검증, FormData 활용, 그리고 제출 시점의 패턴들을 다룹니다.

X