JavaScript fetch 사용하기

예제 기준 · JavaScript : 브라우저 내장 Fetch API (별도 라이브러리 없음)

fetch()는 HTTP 요청을 보내고 응답을 받는 JavaScript 기본 API입니다. GET과 POST 요청, 요청 헤더, HTTP 상태 확인, 오류 처리와 요청 취소·시간 제한을 일반 함수와 Promise로 살펴보겠습니다.

각 절의 코드는 해당 실행 예제의 HTML 요소가 만들어진 뒤 따로 사용합니다. 결과는 pre#result에 표시합니다. 아래 코드는 일반 함수와 then()/catch()/finally()를 사용합니다. 실행 예제의 async/await 표현과 문법은 다르지만 요청 주소·데이터·버튼 동작은 같습니다. /api/로 시작하는 상대 경로는 코드를 실행하는 문서의 원점을 기준으로 요청하므로, 다른 사이트에 복사할 때는 해당 사이트에서 제공하는 API 주소로 바꿔야 합니다.

GET 요청으로 JSON 읽기

‘GET 요청’ 버튼 #run을 누르면 /api/playground/data를 요청합니다. method를 생략하면 GET입니다. fetch()는 Promise를 반환하고, then()에 전달되는 값은 응답 데이터 자체가 아닌 Response 객체입니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}

function showResponse(response) {
    return response.json().then(function (body) {
        show('ok=' + response.ok + ' | status=' + response.status + '\n'
            + JSON.stringify(body, null, 2));
    });
}

function showFailure(error) {
    show('요청 실패: ' + error.name + ' | ' + error.message);
}

const runButton = document.getElementById('run');

function finishRequest() {
    runButton.disabled = false;
}

runButton.addEventListener('click', function () {
    runButton.disabled = true;
    show('요청 중...');
    try {
        const request = fetch('/api/playground/data');
        request.then(showResponse).catch(showFailure).finally(finishRequest);
    } catch (error) {
        showFailure(error);
        finishRequest();
    }
});

Response에는 ok, status, headers와 본문을 읽는 메서드가 있습니다. response.json()도 Promise를 반환합니다. 그 Promise의 then()에서 JSON을 해석한 body를 받습니다. showResponse()가 이 Promise를 반환해야 다음 단계가 본문 처리의 완료를 기다립니다. 반환하지 않으면 finally()가 너무 일찍 실행될 수 있습니다.

성공 응답의 데이터는 다음과 같습니다. JSON 속성의 출력 순서는 달라도 의미는 같습니다.

{
  "message": "Hello playground",
  "items": [
    "Apple",
    "Banana",
    "Orange"
  ]
}

버튼은 요청 중 비활성화하고 finally()에서 복구합니다. 이 예제는 ok와 status를 표시만 하며 HTTP 오류를 직접 throw하지 않습니다. 상태 검사 방법은 뒤에서 다룹니다.

response.json()은 응답이 유효한 JSON일 때 사용합니다. 빈 본문이나 HTML 응답에는 JSON 파싱 오류가 날 수 있습니다. 같은 Response 본문을 반복해서 읽을 수도 없으므로 읽은 body를 재사용합니다.

POST 요청으로 JSON 보내기

‘테스트 이름’ 입력란 #nickname의 초기값은 Test User입니다. ‘POST 요청’ #run을 누르면 입력값을 name 필드에 담아 /api/playground/json으로 전송합니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}

function showResponse(response) {
    return response.json().then(function (body) {
        show('ok=' + response.ok + ' | status=' + response.status + '\n'
            + JSON.stringify(body, null, 2));
    });
}

function showFailure(error) {
    show('요청 실패: ' + error.name + ' | ' + error.message);
}

const runButton = document.getElementById('run');

function finishRequest() {
    runButton.disabled = false;
}

runButton.addEventListener('click', function () {
    runButton.disabled = true;
    show('요청 중...');
    try {
        const request = fetch('/api/playground/json', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({name: document.getElementById('nickname').value})
        });
        request.then(showResponse).catch(showFailure).finally(finishRequest);
    } catch (error) {
        showFailure(error);
        finishRequest();
    }
});

method: "POST"는 요청 방식을 지정하고, JSON.stringify()는 JavaScript 객체를 JSON 문자열로 변환합니다. Content-Type: application/json은 그 본문이 JSON 형식임을 서버에 알립니다. 헤더만 지정한다고 객체가 JSON으로 변환되지는 않습니다.

초기 입력값으로 요청하면 서버가 받은 JSON과 Content-Type을 다음 형태로 돌려줍니다. 이 예제는 데이터를 저장하지 않습니다.

{
  "received": {
    "name": "Test User"
  },
  "contentType": "application/json"
}

요청 body는 JSON 문자열이고, response.json()의 Promise가 이행되어 전달되는 body는 해석된 JavaScript 값입니다. 화면 출력에 사용하는 JSON.stringify(body, null, 2)는 다시 문자열로 만들어 들여쓰기하는 용도입니다.

요청 header 설정하기

‘헤더 포함 요청’ 버튼은 /api/playground/headers에 message: "Test"를 전송합니다. Content-Type과 함께 예제용 헤더 X-Playground-Demo를 설정합니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}

function showResponse(response) {
    return response.json().then(function (body) {
        show('ok=' + response.ok + ' | status=' + response.status + '\n'
            + JSON.stringify(body, null, 2));
    });
}

function showFailure(error) {
    show('요청 실패: ' + error.name + ' | ' + error.message);
}

const runButton = document.getElementById('run');

function finishRequest() {
    runButton.disabled = false;
}

runButton.addEventListener('click', function () {
    runButton.disabled = true;
    show('요청 중...');
    try {
        const request = fetch('/api/playground/headers', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-Playground-Demo': 'header-example'
            },
            body: JSON.stringify({message: 'Test'})
        });
        request.then(showResponse).catch(showFailure).finally(finishRequest);
    } catch (error) {
        showFailure(error);
        finishRequest();
    }
});

응답에는 contentType: "application/json", demoHeader: "header-example", received: {"message":"Test"}가 들어 있습니다. 서버가 받은 헤더를 응답으로 돌려주므로 요청 옵션이 어떻게 전달됐는지 확인할 수 있습니다.

Content-Type은 보내는 본문의 형식을 설명합니다. 응답으로 받고 싶은 형식을 알리는 Accept와는 역할이 다릅니다. 본문이 없는 GET에 JSON 응답을 기대한다는 이유만으로 Content-Type을 붙일 필요는 없습니다. 서버가 요구하는 헤더만 명시적으로 설정합니다.

다른 원점으로 JSON이나 사용자 정의 헤더를 보내면 CORS 사전 요청이 필요할 수 있습니다. 서버가 출처와 헤더를 허용해야 하며 mode: "no-cors"로 바꾸는 것은 JSON 응답을 읽기 위한 해결책이 아닙니다.

HTTP 상태 확인하기

‘응답 상태’ select #code에서 200·404·500을 선택하고 ‘상태 요청’을 누릅니다. /api/playground/status?code=404처럼 선택한 상태를 쿼리로 보냅니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}

function showResponse(response) {
    return response.json().then(function (body) {
        show('ok=' + response.ok + ' | status=' + response.status + '\n'
            + JSON.stringify(body, null, 2));
    });
}

function showFailure(error) {
    show('요청 실패: ' + error.name + ' | ' + error.message);
}

const runButton = document.getElementById('run');

function finishRequest() {
    runButton.disabled = false;
}

runButton.addEventListener('click', function () {
    runButton.disabled = true;
    show('요청 중...');
    try {
        const request = fetch('/api/playground/status?code=' + document.getElementById('code').value);
        request.then(showResponse).catch(showFailure).finally(finishRequest);
    } catch (error) {
        showFailure(error);
        finishRequest();
    }
});

200이면 ok=true, 404나 500이면 ok=false입니다. response.ok는 200~299 범위일 때 true이며, response.status는 실제 숫자 상태 코드입니다. 예제 응답 본문은 {"status":404,"message":"Demo response"}처럼 선택한 코드를 포함합니다.

HTTP 404·500 응답만으로 fetch Promise가 reject되지는 않습니다. 서버에서 오류 상태의 HTTP 응답을 받아도 Response가 반환됩니다. 위 코드는 비교를 위해 세 상태 모두 본문까지 읽고 표시합니다.

성공 응답만 다음 단계로 넘기려면 본문을 읽기 전에 직접 검사합니다.

function readData() {
    return fetch('/api/playground/data').then(function (response) {
        if (!response.ok) {
            throw new Error('HTTP ' + response.status);
        }
        return response.json();
    });
}

readData().then(function (body) {
    console.log(body);
}).catch(function (error) {
    console.error('요청 실패:', error.message);
});

네트워크 오류와 HTTP 오류 처리

요청을 시작하는 try/catch는 동기 예외를 처리합니다. 이후 Promise가 거부되거나 then() 안에서 예외가 발생하면 연결한 catch()가 처리합니다. 비동기 실패를 바깥 try/catch만으로 처리할 수는 없습니다.

‘HTTP 404 요청’ #http와 ‘전송 실패 재현’ #network를 비교합니다. ‘response.ok 검사 후 throw’ #inspect는 처음에 체크되어 있습니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}

function showResponse(response) {
    if (document.getElementById('inspect').checked && !response.ok) {
        throw new Error('직접 검사한 HTTP 오류: ' + response.status);
    }
    show('응답 처리: status=' + response.status + ', ok=' + response.ok
        + '\nHTTP 오류만으로는 catch에 진입하지 않습니다.');
}

function showFailure(error) {
    show('catch: ' + error.name + '\n' + error.message);
}

function runRequest(url) {
    show('요청 중...');
    const request = fetch(url);
    request.then(showResponse).catch(showFailure);
}

document.getElementById('http').addEventListener('click', function () {
    runRequest('/api/playground/status?code=404');
});

document.getElementById('network').addEventListener('click', function () {
    runRequest('playground-error://unavailable');
});

404 요청에서 체크를 켜면 직접 만든 Error를 throw하여 catch로 이동합니다. 체크를 끄면 status=404, ok=false인 Response를 받고 then()의 결과 표시까지 진행합니다. 실행 예제의 “try에서 정상 반환” 문구는 본문 코드에서 “응답 처리”로 표시합니다. 이 예제는 상태 비교에 집중하므로 응답 본문은 읽지 않습니다.

전송 실패 버튼의 playground-error://unavailable은 지원하지 않는 URL 프로토콜입니다. 실제 서버 장애나 인터넷 단절을 일으키지 않고 fetch의 거부를 재현합니다. 네트워크 연결 실패나 CORS 차단도 fetch 단계에서 거부될 수 있지만, TypeError만 보고 그 원인을 네트워크 단절이라고 단정할 수는 없습니다. 오류 메시지는 브라우저마다 다를 수 있습니다.

HTTP 오류는 Response를 받은 뒤 상태를 검사해서 처리하고, 전송 실패는 사용 가능한 Response를 얻지 못한 채 catch로 이동한다는 차이가 있습니다. 반환한 Promise에 JSON 파싱을 연결하면 그 오류도 catch()에 전달되므로 모든 catch를 “네트워크 오류”로 표시하지 않도록 구분합니다.

AbortController로 요청 취소하기

‘요청 시작’ #start는 약 2초 뒤 응답하는 /api/playground/slow를 호출합니다. 완료 전에 ‘요청 취소’ #cancel을 누르면 controller.abort()를 실행합니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}

let controller = null;
const startButton = document.getElementById('start');
const cancelButton = document.getElementById('cancel');

function showResponse(response) {
    if (!response.ok) {
        throw new Error('HTTP ' + response.status);
    }
    return response.json().then(function (body) {
        show(JSON.stringify(body, null, 2));
    });
}

function showFailure(error) {
    if (error.name === 'AbortError') {
        show('AbortError: 진행 중 요청을 취소했습니다.');
    } else {
        show('실패: ' + error.message);
    }
}

function finishRequest() {
    controller = null;
    startButton.disabled = false;
    cancelButton.disabled = true;
}

startButton.addEventListener('click', function () {
    controller = new AbortController();
    startButton.disabled = true;
    cancelButton.disabled = false;
    show('요청 중... 서버 응답은 약 2초 후 도착합니다.');
    try {
        const request = fetch('/api/playground/slow', {
            signal: controller.signal
        });
        request.then(showResponse).catch(showFailure).finally(finishRequest);
    } catch (error) {
        showFailure(error);
        finishRequest();
    }
});

cancelButton.addEventListener('click', function () {
    if (controller !== null) {
        controller.abort();
    }
});
cancelButton.disabled = true;

새 요청마다 AbortController를 만들고 signal을 fetch 옵션에 전달합니다. 인자 없이 abort()로 취소하면 진행 중인 fetch 또는 본문 읽기에서 AbortError를 받을 수 있습니다. 성공하면 {"message":"Completed after 2 seconds"}가 표시됩니다.

취소 버튼은 요청 중에만 활성화하며, 완료·실패·취소 모두 finally()에서 버튼 상태와 controller를 정리합니다. 한 번 취소된 signal은 다시 사용할 수 없으므로 다음 요청에는 새 controller가 필요합니다.

요청 취소는 클라이언트가 응답을 기다리는 작업을 중단하는 것입니다. 이미 서버에서 실행된 작업을 되돌리거나 서버의 처리가 반드시 멈춘다는 뜻은 아닙니다.

timeout을 요청 취소로 처리하기

‘제한 시간’ select #timeout은 500ms(0.5초)와 4000ms(4초)를 제공합니다. ‘시간 제한 요청’ #run은 같은 약 2초 응답을 요청하고, 제한 시간이 지나면 타이머에서 abort()를 호출합니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}

function showResponse(response) {
    if (!response.ok) {
        throw new Error('HTTP ' + response.status);
    }
    return response.json().then(function (body) {
        show('완료: ' + JSON.stringify(body));
    });
}

const button = document.getElementById('run');
button.addEventListener('click', function () {
    const controller = new AbortController();
    const limit = Number(document.getElementById('timeout').value);
    button.disabled = true;
    show('요청 중... 제한 ' + limit + 'ms');
    const timer = window.setTimeout(function () {
        controller.abort();
    }, limit);

    function showFailure(error) {
        if (error.name === 'AbortError') {
            show('시간 초과: ' + limit + 'ms 뒤 요청을 취소했습니다.');
        } else {
            show('실패: ' + error.message);
        }
    }

    function finishRequest() {
        window.clearTimeout(timer);
        button.disabled = false;
    }

    try {
        const request = fetch('/api/playground/slow', {
            signal: controller.signal
        });
        request.then(showResponse).catch(showFailure).finally(finishRequest);
    } catch (error) {
        showFailure(error);
        finishRequest();
    }
});

fetch 옵션에는 일반적인 timeout 항목이 없습니다. 이 예제는 setTimeout()과 AbortController를 연결해 제한 시간을 구현합니다. 정상적인 응답 속도라면 500ms에서는 시간 초과로 취소되고, 4000ms에서는 응답을 받을 수 있습니다. 네트워크 지연이 더 길면 4000ms도 초과할 수 있습니다.

타이머는 response.json()을 기다리는 동안에도 유지되므로 응답 본문을 읽는 과정까지 제한에 포함됩니다. 성공하거나 실패한 뒤에는 finally()에서 clearTimeout()으로 정리합니다. 타이머는 브라우저 실행 상태에 따라 지연될 수 있으므로 정확한 시각의 실행을 보장하는 장치는 아닙니다.

여기서는 타이머만 abort()를 호출하므로 AbortError를 시간 초과로 표시합니다. 사용자 취소도 함께 제공하는 화면이라면 별도의 취소 사유를 기록해 수동 취소와 시간 초과를 구분해야 합니다.

자주 사용하는 fetch 기본 형태

JSON 응답을 기대하는 요청은 요청 보내기 → HTTP 상태 검사 → 본문 해석 → 결과 사용 순서로 정리할 수 있습니다.

function readSuccessfulResponse(response) {
    if (!response.ok) {
        throw new Error('HTTP ' + response.status);
    }
    return response.json();
}

function useData(body) {
    console.log(body);
}

function showFailure(error) {
    console.error('요청 또는 응답 처리 실패:', error.name, error.message);
}

function loadData() {
    const request = fetch('/api/playground/data');
    const parsed = request.then(readSuccessfulResponse);
    return parsed.then(useData).catch(showFailure);
}
// 필요한 시점에 loadData()를 호출합니다.
역할 주요 설정·API 확인할 점
GET fetch(url) 기본 요청 방식, 조회할 URL 지정
POST JSON method, headers, body JSON.stringify()로 변환하고 Content-Type 지정
상태 검사 response.ok, response.status 404·500은 직접 검사
본문 해석 response.json().then(...) 비동기 처리이며 유효한 JSON 응답 필요
오류 처리 then() / catch() HTTP 오류와 전송·파싱 실패 구분
요청 취소 signal, controller.abort() 요청마다 새 controller 생성
시간 제한 setTimeout(), clearTimeout() 제한 시 취소하고 종료 시 타이머 정리

fetch의 Promise가 이행됐다는 것과 HTTP 성공 상태를 받았다는 것은 다릅니다. 상태를 검사한 뒤 응답 형식에 맞게 본문을 읽고, 화면의 요청 중 상태와 타이머는 finally()에서 정리하면 성공·실패·취소 흐름을 일관되게 관리할 수 있습니다.

참고자료