06. 비동기 처리와 데이터
서버에서 데이터를 가져오고, 기다리는 동안과 실패했을 때 무엇을 보여 줄지 정리합니다.
Promise와 async/await
async function loadTodos() {
const response = await fetch("/api/todos");
const todos = await response.json();
return todos;
}async 함수는 항상 Promise를 반환합니다. 호출하는 쪽도 기다려야 합니다.
const todos = loadTodos(); // Promise 객체입니다
const todos = await loadTodos(); // 실제 값오류는 try/catch로 받습니다.
try {
const todos = await loadTodos();
} catch (error) {
showError(error.message);
}await 없이 호출한 Promise의 오류는 try/catch에 잡히지 않습니다. 처리되지 않은 거부로 콘솔에만 남습니다.
fetch와 HTTP 오류 처리
fetch는 404나 500에도 거부되지 않습니다. 서버가 응답을 주기만 하면 성공으로 봅니다.
const response = await fetch("/api/todos");
// 404여도 여기까지 옵니다상태 코드를 직접 확인해야 합니다.
async function request(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
const message = await response.text().catch(() => "");
throw new Error(`${response.status} ${response.statusText} ${message}`);
}
return response.json();
}거부되는 경우는 네트워크 자체가 끊겼거나, CORS에 막혔거나, 요청이 취소됐을 때입니다.
POST는 본문과 헤더를 함께 보냅니다.
await request("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});Content-Type을 빠뜨리면 서버가 본문을 해석하지 못하는 경우가 많습니다. FormData를 보낼 때는 반대로 직접 지정하면 안 됩니다. 브라우저가 경계 문자열을 포함해 붙여야 합니다.
await fetch("/api/upload", { method: "POST", body: formData }); // 헤더 없이응답 본문은 한 번만 읽을 수 있습니다.
await response.json();
await response.text(); // TypeError: body stream already read로딩, 빈 결과, 성공과 실패 상태
화면이 가질 수 있는 상태는 넷입니다. 전부 다루지 않으면 사용자는 멈춘 화면을 보게 됩니다.
let state = { status: "idle", todos: [], error: null };
async function load() {
setState({ status: "loading", error: null });
try {
const todos = await request("/api/todos");
setState({ status: "success", todos });
} catch (error) {
setState({ status: "error", error: error.message });
}
}function render() {
if (state.status === "loading") return renderSkeleton();
if (state.status === "error") return renderError(state.error);
if (state.todos.length === 0) return renderEmpty();
return renderList(state.todos);
}성공했는데 비어 있는 상태를 빠뜨리기 쉽습니다. 목록이 0건일 때 빈 화면만 나오면 로딩이 안 끝난 것처럼 보입니다.
실패 화면에는 다시 시도할 방법을 둡니다. 새로고침만이 답인 화면은 불친절합니다.
<p>목록을 불러오지 못했습니다.</p>
<button type="button" data-action="retry">다시 시도</button>로딩 표시는 너무 빨리 띄우면 깜빡임이 됩니다. 200~300ms 이상 걸릴 때만 보여 주는 방법이 있습니다.
const timer = setTimeout(() => showSpinner(), 300);
try {
await load();
} finally {
clearTimeout(timer);
hideSpinner();
}요청 취소와 응답 순서로 인한 화면 오류
검색어를 입력할 때마다 요청을 보내면, 늦게 보낸 요청이 먼저 도착할 수 있습니다. 그러면 이전 검색어의 결과가 화면에 남습니다.
// 문제가 생기는 코드
input.addEventListener("input", async (event) => {
const todos = await search(event.target.value);
render(todos); // 어떤 요청의 결과인지 알 수 없습니다
});이전 요청을 취소하면 해결됩니다.
let controller;
input.addEventListener("input", async (event) => {
controller?.abort();
controller = new AbortController();
try {
const todos = await search(event.target.value, {
signal: controller.signal,
});
render(todos);
} catch (error) {
if (error.name === "AbortError") return; // 취소는 오류가 아닙니다
showError(error.message);
}
});취소 오류를 걸러내지 않으면 타이핑할 때마다 에러 메시지가 뜹니다.
입력마다 보내지 않도록 디바운스도 함께 씁니다.
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
input.addEventListener("input", debounce(onSearch, 300));여러 요청 다루기
서로 관계없는 요청을 await로 줄줄이 쓰면 불필요하게 기다립니다.
// 순차 — 합계 2초
const todos = await loadTodos();
const user = await loadUser();
// 병렬 — 합계 1초
const [todos, user] = await Promise.all([loadTodos(), loadUser()]);Promise.all은 하나라도 실패하면 전체가 거부됩니다. 일부가 실패해도 나머지를 보여 줘야 하면 allSettled를 씁니다.
const results = await Promise.allSettled([loadTodos(), loadStats()]);
const todos = results[0].status === "fulfilled" ? results[0].value : [];localStorage의 활용과 한계
localStorage.setItem("todos", JSON.stringify(todos));
const saved = JSON.parse(localStorage.getItem("todos") ?? "[]");쓸 때 주의할 점들입니다.
- 문자열만 저장됩니다. 객체는 직렬화해야 합니다
- 동기로 동작해서 큰 데이터를 다루면 화면이 멈춥니다
- 용량이 보통 5MB 정도입니다
- 시크릿 모드나 저장소 차단 설정에서 예외가 납니다
읽고 쓰는 것을 감싸 두는 편이 안전합니다.
function readStorage(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback; // 차단됐거나 형식이 깨졌습니다
}
}저장된 형식이 나중에 바뀔 수 있다는 점도 고려합니다. 예전 버전이 남아 있으면 앱이 깨집니다.
const saved = readStorage("todos", []);
const valid = Array.isArray(saved) ? saved.filter((t) => t?.id && t?.title) : [];큰 데이터나 비동기 저장이 필요하면 IndexedDB를 봅니다. 탭 간 동기화가 필요하면 storage 이벤트를 쓸 수 있습니다.
window.addEventListener("storage", (event) => {
if (event.key === "todos") reload();
});