JavaScript fetch 오류 처리: Promise부터 404까지

··설명 약 9분·작성: 해비·블로그 소개

JavaScript fetch에서 404가 catch로 넘어가지 않을 때 어떻게 처리하나요?

fetch는 404 응답만으로 catch로 넘어가지 않으므로 response.ok를 검사합니다. 서버의 HTTP 오류, 요청 자체의 실패, JSON 해석 실패를 나누어 처리해야 실패 단계를 알 수 있습니다.

적용 조건·범위: 실행 검증: Node.js에서 위 출력과 일치했습니다. 이 예제는 네트워크 대신 결정적인 fetcher를 주입해 실행합니다. 실제 HTTP·브라우저 로딩 UI는 미확인입니다.

공식 문서 참고: MDN: fetch 응답 상태·JSON 해석·요청 취소

혼자 실행해 보는 fetch 오류와 응답 상태

JavaScript fetch 오류 처리: Promise부터 404까지 · 독립 실습

주입한 성공·404·JSON 오류 응답이 서로 다른 결과를 내는지 확인하세요.

예제 크게 보기 (새 탭)

독자 질문: fetch가 404 응답을 받으면 왜 catch로 자동 이동하지 않는가?

js-12-example.mjs로 저장하고 node js-12-example.mjs을 실행하세요. 외부 패키지는 필요하지 않습니다.

async function loadTasks(fetcher) {
  const response = await fetcher();
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const data = await response.json();
  if (!Array.isArray(data)) throw new Error('배열이 아닙니다');
  return data;
}
for (const response of [
  { ok: true, status: 200, json: async () => [{ id: '1' }] },
  { ok: false, status: 404, json: async () => ({}) },
  { ok: true, status: 200, json: async () => { throw new SyntaxError('JSON 오류'); } },
]) {
  try { console.log('성공:', (await loadTasks(async () => response)).length); }
  catch (error) { console.log('실패:', error.message); }
}

예상 출력:

성공: 1
실패: HTTP 404
실패: JSON 오류

실행 검증: Node.js에서 위 출력과 일치했습니다. 이 예제는 네트워크 대신 결정적인 fetcher를 주입해 실행합니다. 실제 HTTP·브라우저 로딩 UI는 미확인입니다.

직접 풀어볼 문제

성공 응답의 json()을 객체로 바꿔 배열 검증 오류를 확인하세요. 예상 문구는 ‘배열이 아닙니다’입니다.

Promise는 미래의 결과를 표현합니다

Promise는 다음 세 상태 중 하나를 가집니다.

pending
fulfilled
rejected

await는 Promise가 처리될 때까지 현재 async 함수의 다음 코드를 잠시 미룹니다.

async function loadMessage() {
  const response = await fetch('./message.json');
  const data = await response.json();


  return data.message;
}

fetch는 404에서 자동으로 reject되지 않습니다

많이 헷갈리는 부분입니다.

const response = await fetch('/missing.json');


console.log(response.ok);
console.log(response.status);

서버가 정상적으로 HTTP 응답을 돌려줬다면 404라도 fetch Promise 자체는 fulfilled가 될 수 있습니다.

따라서 HTTP 성공 범위를 직접 확인합니다.

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

네트워크 실패는 catch로 들어옵니다

DNS 문제, 연결 실패처럼 응답 자체를 받지 못한 경우에는 fetch가 reject될 수 있습니다.

try {
  const response = await fetch('/api/tasks');
} catch (error) {
  console.error('네트워크 요청 실패', error);
}
잘못된 HTTP 응답과 네트워크 연결 실패는 구분해서 처리해야 합니다.
개념 설명용 일러스트. 잘못된 HTTP 응답과 네트워크 연결 실패는 구분해서 처리해야 합니다. 이미지 원본 열기

JSON 파싱도 실패할 수 있습니다

응답이 200이어도 실제 본문이 올바른 JSON이 아니면 response.json()에서 오류가 발생합니다.

따라서 ‘HTTP 성공’과 ‘사용 가능한 데이터’는 같은 의미가 아닙니다.

finally에서 공통 정리

async function loadData() {
  return { message: '완료' };
}
let loading = true;


try {
  await loadData();
} catch (error) {
  console.error(error);
} finally {
  loading = false;
}

성공·실패와 관계없이 로딩 상태를 끝내야 할 때 유용합니다.

외부 서비스에 학습을 의존시키지 않습니다

독립 예제는 고정 응답을 반환하는 fetcher를 주입하므로 네트워크나 외부 API가 없어도 성공·404·JSON 오류를 비교할 수 있습니다. 하단 프로젝트는 로컬 data/tasks.json을 HTTP 서버로 제공해 실제 fetch 흐름을 연습합니다.

브라우저 fetch는 로컬 서버에서 실행해야 합니다.

자주 발생하는 오류

fetch()가 성공했으니 200이라고 생각합니다

response.ok와 response.status를 확인합니다.

로딩 상태가 실패 뒤에도 남습니다

finally에서 공통 정리를 처리합니다.

catch에서 모든 오류를 같은 메시지로 숨깁니다

사용자에게는 간결한 안내를 보여주고 개발자 Console에는 원래 error를 남겨 원인을 확인할 수 있게 합니다.

학습 완료 체크리스트

  • Promise의 세 상태를 설명할 수 있습니다.
  • fetch의 HTTP 오류 처리 방식을 이해했습니다.
  • response.ok를 확인할 수 있습니다.
  • async/await와 try/catch/finally를 사용할 수 있습니다.
  • JSON 파싱 실패를 별도 문제로 이해했습니다.

독립 실습은 여기서 마칠 수 있습니다. 아래 선택 실습은 TaskFlow 프로젝트 파일로 진행합니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 JavaScript로 화면에 동작을 붙입니다.

이번 체크포인트에서 추가되는 데이터 파일, service 모듈, 로딩 상태 연결은 다음과 같습니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

JavaScript fetch 오류 처리: Promise부터 404까지 · 단계별 TaskFlow

작업 추가·완료 전환·삭제와 현재 단계의 필터/출력을 확인하세요. 검색 조건을 바꿔 가상 URL을 확인하세요.

예제 크게 보기 (새 탭)

변경 설명과 부분 예제를 읽은 뒤, 시작·완료 파일과 적용 순서에서 필요한 선언·import·화면 연결을 포함한 전체 파일을 확인하세요. 이번 단계의 변경 이유와 적용 위치도 함께 확인합니다.

시작본과 이번 변경

JavaScript 실행 컨텍스트 기준: 스코프 호이스팅 클로저 연결하기의 완료 체크포인트를 시작본으로 사용합니다.

js/task-service.js와 data/tasks.json을 새로 만들고, js/task-view.js에 renderLoadState를 추가합니다. js/app.js에서는 기존 store와 render를 유지하면서 initializeTasks만 연결하세요. createTaskStore 호출과 이벤트 바인딩을 중복 추가하지 않습니다. 정상 목록·빈 배열·404·잘못된 JSON 순서로 확인합니다.

연속 프로젝트 선수 조건

  • Promise를 반환하는 비동기 함수의 기본 의미
  • createTaskStore()와 replaceTasks()
  • 로컬 서버에서 TaskFlow를 실행하는 방법
  • javascript-step-11-execution-context 체크포인트

이전 체크포인트

javascript-step-11-execution-context

이번 회차 변경 코드와 전체본 연결

이번 체크포인트에서 추가되는 데이터 파일, service 모듈, 로딩 상태 연결은 다음과 같습니다.

전체 파일·코드 보기

data/tasks.json

[
  {
    "id": "server-1",
    "title": "서버 작업 불러오기",
    "completed": false,
    "createdAt": "2026-09-20T02:00:00.000Z",
    "dueAt": null
  },
  {
    "id": "server-2",
    "title": "fetch 상태 확인",
    "completed": true,
    "createdAt": "2026-09-20T03:00:00.000Z",
    "dueAt": null
  }
]

js/task-service.js

function isTaskArray(value) {
  return (
    Array.isArray(value) &&
    value.every((task) =>
      task &&
      typeof task.id === 'string' &&
      typeof task.title === 'string' &&
      typeof task.completed === 'boolean'
    )
  );
}


export async function loadTasks() {
  const response = await fetch('./data/tasks.json');


  if (!response.ok) {
    throw new Error(`작업 요청 실패: HTTP ${response.status}`);
  }


  const data = await response.json();


  if (!isTaskArray(data)) {
    throw new Error('작업 데이터 형식이 올바르지 않습니다.');
  }


  return data;
}

TypeScript 과정에서는 외부 데이터를 unknown으로 받아 더 체계적으로 검증합니다. 여기서는 JavaScript에서도 외부 JSON을 무조건 신뢰하지 않는 원칙만 먼저 적용합니다.

task-view.js에 상태 함수 추가

export function renderLoadState(message) {
  const element = document.querySelector('#task-load-state');


  if (element) {
    element.textContent = message;
  }
}

js/app.js 초기 로딩

import { createTaskStore } from './task-store.js';
import { loadTasks } from './task-service.js';
import { renderLoadState } from './task-view.js';


const store = createTaskStore();


async function initializeTasks() {
  renderLoadState('작업을 불러오는 중입니다.');


  try {
    const tasks = await loadTasks();
    store.replaceTasks(tasks);
    render();


    const summary = store.getTaskSummary();
    renderLoadState(
      summary.total === 0 ? '불러온 작업이 없습니다.' : '작업을 불러왔습니다.',
    );
  } catch (error) {
    console.error(error);
    renderLoadState('작업을 불러오지 못했습니다. 다시 시도해 주세요.');
  }
}


initializeTasks();

여기서 render()는 11회차까지 사용한 기존 함수 그대로 유지합니다.

로딩 상태 표시 요소

작업 데모 section 안에 추가합니다.

<p id="task-load-state" aria-live="polite"></p>

실패 검증

  1. tasks.json 파일명을 임시로 바꿔 404 확인
  2. response.ok에서 오류가 발생하는지 확인
  3. JSON 문법을 임시로 깨뜨려 파싱 오류 확인
  4. 정상 파일로 되돌린 뒤 성공 상태 확인

검증이 끝나면 실패용 변경은 반드시 원래대로 되돌립니다.

중간부터 시작하는 전체 파일과 적용 순서

이번 글 시작 ZIP · 이번 글 완료 ZIP

시작 ZIP은 직전 프로젝트 단계 javascript-step-11-execution-context의 실제 완료 파일입니다. 프로젝트 순서를 이어가는 기준이며 사이트의 기술 읽기 순서와는 구분합니다.

  1. 시작 ZIP을 새 폴더에 풉니다. 이 폴더가 작업 루트이며 기존 체크포인트 파일을 별도로 찾을 필요가 없습니다.
  2. 로컬 data/tasks.json과 service를 추가합니다. app에 기존 이벤트 연결을 유지한 채 초기 로더를 넣습니다.
  3. 아래 변경 파일을 같은 상대 경로에 추가하거나 전체 교체합니다. 표시하지 않은 파일과 images는 시작본 그대로 유지합니다. package-lock.json은 완료 ZIP에 있는 파일을 함께 적용합니다.
  4. 작업 루트에서 명령을 실행하고 예상 결과를 확인합니다. 완료 ZIP은 직접 적용한 결과를 비교하는 기준입니다.

추가·교체 파일

  • index.html — 전체 교체
  • CHANGES.md — 전체 교체
  • README.md — 전체 교체
  • js/app.js — 전체 교체
  • js/task-view.js — 전체 교체
  • data/tasks.json — 추가
  • js/task-service.js — 추가

실행과 확인

npm start
npm test

개발 서버는 실행 상태로 두고 다른 터미널에서 검사 명령을 실행하세요. Node.js 24.18.0에서 검증했습니다. JavaScript는 http://127.0.0.1:4173, TypeScript는 Vite 실행 출력의 주소로 접속합니다.

예상 결과: 로딩 후 서버 작업 2개·완료 1개. HTTP 실패와 잘못된 JSON 구조에서는 오류 안내가 표시됩니다. 저장 서버는 제공하지 않습니다.

실제 검증: JavaScript 모듈·DOM·경계값 실행 검사 통과. 시작 ZIP에 이 글의 변경 파일만 적용한 결과와 완료 ZIP의 모든 파일 내용이 일치했습니다. 브라우저 화면 배치·실제 기기·외부 CDN 가용성은 이 검사에 포함하지 않았습니다.

이 절의 전체 파일은 ZIP과 일치합니다. 위쪽 원문의 짧은 예제나 생략 표시가 있는 부분 코드는 개념·변경 위치 설명용입니다. 서버 저장과 인증은 제공하지 않으며 JSON fixture는 로컬 읽기 실습용입니다.

복사할 변경 파일 전체 코드

index.html — 전체 파일

<!DOCTYPE html>
<html lang="ko">
 <head>
  <meta charset="utf-8"/>
  <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
  <title>
   TaskFlow - 간단한 작업 정리 서비스
  </title>
  <link href="./style.css" rel="stylesheet"/>
  <meta content="TaskFlow의 주요 기능과 요금제, 문의 방법을 확인할 수 있는 작업 관리 서비스 소개 페이지입니다." name="description"/>
 </head>
 <body>
  <a class="skip-link" href="#main">
   본문으로 바로가기
  </a>
  <header class="site-header">
   <div class="page site-header__inner">
    <a class="brand" href="#home">
     TaskFlow
    </a>
    <nav aria-label="주요 메뉴" class="nav">
     <a href="#features">
      기능
     </a>
     <a href="#pricing">
      요금제
     </a>
     <a href="#contact">
      문의
     </a>
     <a href="#faq">
      FAQ
     </a>
    </nav>
   </div>
  </header>
  <main class="page" id="main" tabindex="-1">
   <div class="service-layout">
    <div>
     <section class="hero" id="home">
      <div>
       <h1>
        해야 할 일을 한눈에 정리하세요
       </h1>
       <p>
        TaskFlow는 오늘의 작업을 빠르게 정리하는 서비스입니다.
       </p>
       <a class="button-link" href="#contact">
        시작 문의하기
       </a>
      </div>
      <img alt="TaskFlow 서비스 소개 페이지의 기능·문의 화면" class="hero__image" height="800" src="./images/hero.webp" width="1200"/>
     </section>
     <section id="features">
      <h2>
       주요 기능
      </h2>
      <div class="feature-grid">
       <article class="card feature-card">
        <span class="feature-card__badge">
         NEW
        </span>
        <h3>
         빠른 작업 추가
        </h3>
        <p>
         해야 할 일을 바로 기록할 수 있습니다.
        </p>
        <img alt="TaskFlow 기능 카드 세 개" class="feature-card__image" height="450" loading="lazy" src="./images/feature-board.webp" width="800"/>
       </article>
       <article class="card">
        <h3>
         간단한 분류
        </h3>
        <p>
         해야 할 일을 구분합니다.
        </p>
       </article>
       <article class="card">
        <h3>
         진행 확인
        </h3>
        <p>
         완료 여부를 확인합니다.
        </p>
       </article>
       <article class="card task-preview">
        <div class="task-preview__content">
         <h3>
          아주 긴 작업 제목과 URL
         </h3>
         <p>
          https://example.com/very-long-task-url-without-normal-break-points-123456789
         </p>
        </div>
        <a href="#contact">
         문의
        </a>
       </article>
      </div>
     </section>
     <section class="faq" id="faq">
      <h2>
       자주 묻는 질문
      </h2>
      <details>
       <summary>
        TaskFlow는 어떤 서비스인가요?
       </summary>
       <p>
        해야 할 일을 빠르게 정리하는 작업 관리 서비스 예제입니다.
       </p>
      </details>
      <details>
       <summary>
        모바일에서도 볼 수 있나요?
       </summary>
       <p>
        작은 화면부터 설계한 반응형 레이아웃을 사용합니다.
       </p>
      </details>
      <details>
       <summary>
        문의는 어디서 하나요?
       </summary>
       <p>
        아래 문의 폼을 이용할 수 있습니다.
       </p>
      </details>
     </section>
     <section id="pricing">
      <h2>
       요금제
      </h2>
      <div class="pricing-grid">
       <article class="card">
        <h3>
         Basic
        </h3>
        <p>
         개인 학습용
        </p>
       </article>
       <article class="card">
        <h3>
         Pro
        </h3>
        <p>
         더 많은 프로젝트
        </p>
       </article>
      </div>
      <div aria-label="요금제 비교표 가로 스크롤" class="table-scroll" role="region" tabindex="0">
       <table class="pricing-table">
        <caption>
         TaskFlow 요금제 기능 비교
        </caption>
        <thead>
         <tr>
          <th scope="col">
           항목
          </th>
          <th scope="col">
           Basic
          </th>
          <th scope="col">
           Pro
          </th>
         </tr>
        </thead>
        <tbody>
         <tr>
          <th scope="row">
           프로젝트
          </th>
          <td>
           3개
          </td>
          <td>
           무제한
          </td>
         </tr>
         <tr>
          <th scope="row">
           작업 보관
          </th>
          <td>
           30일
          </td>
          <td>
           무제한
          </td>
         </tr>
         <tr>
          <th scope="row">
           팀 공유
          </th>
          <td>
           없음
          </td>
          <td>
           지원
          </td>
         </tr>
        </tbody>
       </table>
      </div>
     </section>
     <section id="contact">
      <h2>
       문의하기
      </h2>
      <form>
       <div class="form-field">
        <label for="name">
         이름
        </label>
        <input autocomplete="name" id="name" name="name" type="text"/>
       </div>
       <div class="form-field">
        <label for="email">
         이메일
        </label>
        <input autocomplete="email" id="email" name="email" type="email"/>
       </div>
       <div class="form-field">
        <label for="message">
         문의 내용
        </label>
        <textarea id="message" name="message" rows="5"></textarea>
       </div>
       <button type="submit">
        문의 보내기
       </button>
      </form>
     </section>
     <div class="feature-slot">
      <article class="card feature-card">
       <img alt="TaskFlow 기능 카드 세 개" class="feature-card__image" height="450" loading="lazy" src="./images/feature-board.webp" width="800"/>
       <div>
        <h3>
         작업 카드
        </h3>
        <p>
         넓은 공간에서는 이미지와 설명을 나란히 배치합니다.
        </p>
       </div>
      </article>
     </div>
    </div>
    <aside aria-label="기능 미리보기">
     <div class="feature-slot">
      <article class="card feature-card">
       <img alt="TaskFlow 기능 카드 세 개" class="feature-card__image" height="450" loading="lazy" src="./images/feature-board.webp" width="800"/>
       <div>
        <h3>
         작업 카드
        </h3>
        <p>
         넓은 공간에서는 이미지와 설명을 나란히 배치합니다.
        </p>
       </div>
      </article>
     </div>
    </aside>
   </div>
  <section
  class="task-demo"
  id="task-demo"
  aria-labelledby="task-demo-title"
>
  <h2 id="task-demo-title">작업 관리 데모</h2>
  <p>JavaScript 과정에서 이 영역을 단계적으로 동작하게 만듭니다.</p>


  <form class="task-form" id="task-form" novalidate>
    <div class="form-field">
      <label for="task-title">작업 이름</label>
      <input
        id="task-title"
        name="title"
        type="text"
        maxlength="80"
        required
        aria-describedby="task-message"
      />
    </div>


    <button type="submit">작업 추가</button>
  <div class="form-field">
  <label for="task-due-at">마감 시각</label>
  <input
    id="task-due-at"
    name="dueAt"
    type="datetime-local"
  />
</div>
</form><div class="task-controls">
  <label for="task-status">상태</label>
  <select id="task-status">
    <option value="all">전체</option>
    <option value="pending">미완료</option>
    <option value="completed">완료</option>
  </select>


  <label for="task-search">검색</label>
  <input id="task-search" type="search" />
</div>



  <p id="task-message" aria-live="polite"></p>


  <div id="task-summary" aria-live="polite"></div>
  <ul class="task-list" id="task-list"></ul>
</section>
<p id="task-load-state" aria-live="polite"></p></main>
  <footer class="site-footer">
   <div class="page">
    <p>
     © TaskFlow
    </p>
   </div>
  </footer>
  <a class="help-button" href="#contact">
   문의
  </a>
 <script type="module" src="./js/app.js"></script></body>
</html>

js/app.js — 전체 파일

import { loadTasks } from './task-service.js';
import { renderLoadState } from './task-view.js';
import { createTaskStore } from './task-store.js';
const store = createTaskStore();


import {
  bindTaskControls,
  bindTaskForm,
  bindTaskList,
  getTaskControls,
  renderTasks,
  setTaskControls,
} from './task-view.js';


import { readUrlState, writeUrlState } from './url-state.js';


function render() {
  const controls = getTaskControls();
  const tasks = store.filterTasks(controls.status, controls.query);


  renderTasks(tasks, store.getTaskSummary());
}


const initialUrlState = readUrlState();
setTaskControls(initialUrlState);


bindTaskForm(({ title, dueAt }) => {
  store.addTask(title, dueAt);
  render();
});


bindTaskControls(() => {
  const controls = getTaskControls();
  writeUrlState(controls);
  render();
});


bindTaskList(({ id, action }) => {
  if (action === 'toggle') store.toggleTask(id);
  if (action === 'delete') store.deleteTask(id);
  render();
});



render();
async function initializeTasks() {
  renderLoadState('작업을 불러오는 중입니다.');


  try {
    const tasks = await loadTasks();
    store.replaceTasks(tasks);
    render();


    const summary = store.getTaskSummary();
    renderLoadState(
      summary.total === 0 ? '불러온 작업이 없습니다.' : '작업을 불러왔습니다.',
    );
  } catch (error) {
    console.error(error);
    renderLoadState('작업을 불러오지 못했습니다. 다시 시도해 주세요.');
  }
}


initializeTasks();

js/task-view.js — 전체 파일

import { toIsoFromLocal, formatKstDateTime } from './date.js';
export function bindTaskForm(onAdd) {
  const form = document.querySelector('#task-form');
  const titleInput = document.querySelector('#task-title');
  const message = document.querySelector('#task-message');


  if (!form || !titleInput || !message) {
    throw new Error('TaskFlow 작업 폼 요소를 찾을 수 없습니다.');
  }


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


    const title = titleInput.value.trim();


    if (!title) {
      titleInput.setAttribute('aria-invalid', 'true');
      message.textContent = '작업 이름을 입력해 주세요.';
      titleInput.focus();
      return;
    }


    const dueAtInput = document.querySelector('#task-due-at');
    onAdd({ title, dueAt: toIsoFromLocal(dueAtInput.value) });
    dueAtInput.value = '';
    titleInput.value = '';
    titleInput.setAttribute('aria-invalid', 'false');
    message.textContent = '작업을 추가했습니다.';
    titleInput.focus();
  });
}


export function bindTaskControls(onChange) {
  const status = document.querySelector('#task-status');
  const search = document.querySelector('#task-search');


  if (!status || !search) {
    throw new Error('TaskFlow 필터 요소를 찾을 수 없습니다.');
  }


  status.addEventListener('change', onChange);
  search.addEventListener('input', onChange);
}


export function getTaskControls() {
  return {
    status: document.querySelector('#task-status').value,
    query: document.querySelector('#task-search').value,
  };
}


export function bindTaskList(onAction) {
  const list = document.querySelector('#task-list');


  if (!list) {
    throw new Error('task-list를 찾을 수 없습니다.');
  }


  list.addEventListener('click', (event) => {
    const button = event.target.closest('button[data-action]');
    const item = button?.closest('[data-task-id]');


    if (!button || !item) return;


    onAction({
      id: item.dataset.taskId,
      action: button.dataset.action,
    });
  });
}


export function renderTasks(tasks, summary) {
  const list = document.querySelector('#task-list');
  const summaryElement = document.querySelector('#task-summary');


  list.replaceChildren();
  summaryElement.textContent =
    `전체 ${summary.total} · 완료 ${summary.completed} · 미완료 ${summary.pending}`;


  if (tasks.length === 0) {
    const empty = document.createElement('li');
    empty.textContent = '조건에 맞는 작업이 없습니다.';
    list.append(empty);
    return;
  }


  tasks.forEach((task) => {
    const item = document.createElement('li');
    item.dataset.taskId = String(task.id);


    const title = document.createElement('span');
    title.textContent = task.title;


    const toggleButton = document.createElement('button');
    toggleButton.type = 'button';
    toggleButton.dataset.action = 'toggle';
    toggleButton.textContent = task.completed ? '미완료로 변경' : '완료';


    const deleteButton = document.createElement('button');
    deleteButton.type = 'button';
    deleteButton.dataset.action = 'delete';
    deleteButton.textContent = '삭제';


    const meta = document.createElement('small');
meta.textContent =
  `생성 ${formatKstDateTime(task.createdAt)} · 마감 ${formatKstDateTime(task.dueAt)}`;

    item.append(title, meta, toggleButton, deleteButton);
    list.append(item);
  });
}

export function setTaskControls({ status, query }) {
  document.querySelector('#task-status').value = status;
  document.querySelector('#task-search').value = query;
}

export function renderLoadState(message) {
  const element = document.querySelector('#task-load-state');


  if (element) {
    element.textContent = message;
  }
}

data/tasks.json — 전체 파일

[
  {
    "id": "server-1",
    "title": "서버 작업 불러오기",
    "completed": false,
    "createdAt": "2026-09-20T02:00:00.000Z",
    "dueAt": null
  },
  {
    "id": "server-2",
    "title": "fetch 상태 확인",
    "completed": true,
    "createdAt": "2026-09-20T03:00:00.000Z",
    "dueAt": null
  }
]

js/task-service.js — 전체 파일

function isTaskArray(value) {
  return (
    Array.isArray(value) &&
    value.every((task) =>
      task &&
      typeof task.id === 'string' &&
      typeof task.title === 'string' &&
      typeof task.completed === 'boolean'
    )
  );
}


export async function loadTasks() {
  const response = await fetch('./data/tasks.json');


  if (!response.ok) {
    throw new Error(`작업 요청 실패: HTTP ${response.status}`);
  }


  const data = await response.json();


  if (!isTaskArray(data)) {
    throw new Error('작업 데이터 형식이 올바르지 않습니다.');
  }


  return data;
}

완료 체크포인트

javascript-step-12-fetch

다음 학습

다음 글에서는 await, Promise, setTimeout이 어떤 순서로 실행되는지 추적하고 TaskFlow 로딩·렌더링 순서를 직접 관찰합니다.

다음 글: JavaScript 이벤트 루프: Promise와 await 실행 순서 추적하기

글 소개·학습 준비·전체 목차
JavaScript fetch 오류 처리: Promise부터 404까지 표지

JavaScript · 12 / 15 · 필수 오류 해결 · fetch와 Promise

fetch()는 404 같은 HTTP 응답만으로 자동 실패 처리되지 않으므로 response.ok를 확인해야 합니다. 네트워크 요청 자체의 실패와 JSON 파싱 오류는 await와 try/catch에서 별도로 확인합니다.

네트워크 연결 자체가 실패한 경우와 서버가 404·500을 응답한 경우, JSON 파싱이 실패한 경우는 서로 다른 문제입니다.

이번 글에서는 Promise와 async/await의 기본 흐름을 이해하고 TaskFlow의 초기 작업을 JSON 파일에서 불러오며 loading·success·empty·error 상태를 분리합니다.

이번 글에서 배우는 내용

  • Promise의 상태
  • fetch()
  • response.ok
  • HTTP 오류와 네트워크 오류
  • async / await
  • try / catch / finally
  • JSON 파싱 오류
  • 로딩·오류 UI

먼저 알아야 할 내용

함수 호출, 객체 프로퍼티, try/catch를 알고 있으면 됩니다. Promise·async/await는 본문에서 설명합니다. 단독 예제는 Node.js에서 실행하며 TaskFlow나 외부 API가 필요하지 않습니다.

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 15개 · 필수 14개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기