자바스크립트 실전 #3 fetch와 비동기 UI
#2 이벤트 핸들링과 위임 에서 사용자 입력을 받는 도구를 봤습니다. 이번엔 그 입력에 따라 외부 데이터를 받아와 화면에 반영하는 비동기 UI의 실전 패턴들을 다룹니다.
가장 단순한 형태부터 #
<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;
}
});세 가지가 추가됐습니다.
- 로딩 표시 —
<li class="loading">불러오는 중...</li> - 에러 메시지 —
<li class="error">... - 버튼 비활성화 —
button.disabled = true로 중복 클릭 방지,finally에서 복구
finally 블록이 핵심입니다. 성공/실패 무관하게 버튼 복구가 보장됩니다.
검색창 — 실시간 입력에 반응 #
검색 자동완성 같은 경우. 입력할 때마다 fetch를 부르면 너무 많은 요청이 갑니다.
<input id="search" placeholder="검색...">
<ul id="results"></ul>가장 단순한 (그러나 비효율적인) 버전 #
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 갱신
});문제 둘:
- ‘h’, ‘he’, ‘hel’, …, ‘hello’ 매 키마다 fetch — 트래픽 낭비
- ‘h’ 응답이 늦게 와서 ‘hello’ 결과를 덮어쓸 수 있음
디바운스 — 연속 입력을 한 번으로 합치기 #
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를 취소해야 합니다.
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
#
타임아웃 + 사용자 취소 둘 다 처리하고 싶으면.
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 #
스크롤이 끝에 가까워지면 자동 로드.
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 약속 #
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 활용, 그리고 제출 시점의 패턴들을 다룹니다.