Fetch

서버가 HTTP API로 자원을 제공하면 브라우져는 AJAX 기법으로 자원을 얻는다. 실무에서는 axios나 superagent 같은 전용 라이브러리를 사용했다. 모두 브라우저가 제공하는 XMLHttpRequst 객체, 혹은 fetch 함수로 구현되었다.

fetch는 거의 모든 웹 브라우져가 지원하는 함수다. 크롬, 사파리, 파이어폭스는 2015년부터 이미 이 기능을 제공한다. 굳이 라이브러리를 사용하지 않더라도 fetch라는 브라우져 기본 함수만으로도 비동기 HTTP 요청을 만드는 데 충분하다.

이 글에서는 fetch 사용법을 정리하겠다.

fetch

함수를 호출하는 즉시 브라우져는 HTTP 요청을 생성한다.

// GET url 요청을 만든다.
fetch("/get")

URL만 전달해 함수를 실행할 수 있다. GET 메소드로 자원을 요청할 것이다.

다른 메소드는 두 번째 options 인자로 설정한다.

fetch(url, options)

이 객체는 헤더, 본문 등 구체적인 요청 정보를 지정하는 데 사용한다.

  • method: 요청 메소드
  • headers: 요청 헤더
  • body: 요청 본문

전체 목록은 여기를 참고하라.

json 업로드

POST 요청을 만들어 보자. 서버 API로 가장 많이 사용되는 json 데이터를 요청 본문으로 사용해 보겠다.

fetch('/post, {
  // 메서드
  method: 'POST',
  // 헤더
  headers: { 'Content-Type': 'application/json' },
  // 제이슨 본문
  body: JSON.stringify({ foo: 'bar' })
}
  • method 필드에 사용할 메소드를 지정했다.
  • headers 필드에 여러 헤더를 객체로 지정할 수 있다. 컨텐트 타입을 설정했다.
  • body 필드에 제이슨 문자열을 지정해 요청 본문을 설정했다.

브라우져는 지정한 값으로 HTTP 요청을 만들 것이다.

파일 업로드

선택한 파일을 fetch 함수로 업로드해 보겠다.

<body>
  <input type="file" onchange="submit()" />
  <script>
    async function submit() {
      // 사용자가 선택한 파일을 폼 데이터로 준비한다.
      const formData = new FormData();
      const field = $('input[type=file]')
      formData.append('file', field.files[0])

      fetch('/post, {
        // 메서드
        method: 'POST',
        // 폼 데이터 본문
        body: formData
      })
    }
  </script>
</body>
  • 파일 입력 필드를 준비했다. 파일을 선택하면 submit() 함수를 실행한다.
  • 선택한 파일로 FormData 객체를 준비해 fetch 함수의 옵션 인자 body 필드로 전달한다.
  • 브라우져는 컨탠츠 타입을 multipart/form-data로 설정하고 본문을 요청할 것이다.

브라우져는 선택한 파일을 본문에 실어 HTTP 요청을 만들 것이다.

Response

fetch 함수가 만든 HTTP 요청은 지정한 원격 서버로 전달된다. 서버는 HTTP 응답을 브라우져로 되돌려 줄 것이다. 이 응답을 어떻게 접근할 수 있을까?

바로 얻을 것이라 생각했다면 그렇지가 않다. 요청한 원격 서버는 물리적으로 떨어졌기 때문이다. 그만큼 시간이 걸린다. 브라우져는 응답시 실행할 콜백 함수만 등록하고 다음 코드를 실행한다.

그래서 fetch 함수는 응답 대신 프라미스 객체를 반환한다. 프라미스가 이행되면 Response 객체를 얻는데 여기에 HTTP 응답 정보가 담겨 있다.

Response 객체의 주요 필드는 다음과 같다.

  • status: 상태 코드
  • ok: 상태 코드가 200에서 299 사이인지 여부
  • headers: 응답 헤더

전체 인터페이스는 여기를 참고하라.

status나 ok 필드로 성공이나 실패를 판단한다.

try {
  const response = await fetch("/fail")
  if (!response.ok) {
    // http 성공 처리
  } else {
    // http 실패 처리
  }
} catch (e) {
  // http가 아닌 오류 처리
}
  • ok가 true이면 HTTP 요청을 성공한 것이다.
  • false이면 HTTP 실패 응답이다. 상태 코드가 400에서 599 사이다.
  • catch 구문은 HTTP가 아닌 오류다. 가령 네트웍 연결이 안되는 경우.

응답 본문

응답 헤더를 확인했다면 이제 응답 본문에 접근할 차례다. Response는 요청한 자원의 형식에 따라 전용 메소드를 제공한다.

  • text(): 텍스트로 변환
  • json(): 제이슨으로 변환
  • blob(): 이진 데이터로 변환

전체 목록은 여기를 참고하라

json 응답

서버 API로 가장 많이 사용되는 JSON 형태의 응답 본문을 조회해 보겠다.

try {
  const response = await fetch("/json")
  if (!response.ok) throw new Error("HTTP 오류")

  // 응답 본문을 제이슨으로 읽는다.
  const json = await response.json()
} catch (e) {
  console.error("오류:", e)
}
  • text() 메소드를 호출하면 제이슨 문자만 조회할 것이다.
  • json() 메소드를 호출해야 제이슨 형식의 자바스크립트 객체를 얻을 수 있다.

서버 API는 JSON을 주로 사용하기 때문에 json() 메소드를 많이 사용할 것이다.

파일 응답

서버가 파일 내용을 읽어 응답한다면 브라우저가 이 형식에 맞게 처리할 수 있어야한다.

try {
  const response = await fetch("/image")
  if (!response.ok) throw new Error("HTTP 오류")

  // 응답 본문을 이진 데이터로 읽는다.
  const blob = await response.blob()
  // 이미지 앨리먼트를 준비한다.
  const imageEl = document.createElement("img")
  // 이진 데이터를 이미지 앨리먼트에 표시한다.
  imageEl.src = URL.createObjectURL(blob)
  document.body.appendChild(imageEl)
} catch (e) {
  console.error("오류:", e)
}
  • 이전처럼 text()나 json() 메소드를 호출하면 오류가 발생한다.
  • blob() 메소드로 이진 데이터 형식인 Blob 객체를 얻는다.

브라우져는 비동기로 얻은 이진 데이터를 이미지로 표시할 것이다.

왜 프라미스를 두 번 사용할까?

fetch 함수를 사용하는데 이 부분이 어색했다. 프라미스 기반의 라이브러리인 axios나 superagnt를 사용할 때는 헤더와 함께 본문을 한 번에 받았기 때문이다.

axios 예제 코드:

const response = await axios.get(url)
// 상태 코드: response.status
// 헤더: response.headers
// 본문: response.data

fetch는 두 개의 프라미스 객체가 이행되기를 기다려야 한다. 헤더와 본문을 따로 수신하는 이유가 궁금했다.

스택오버플로우에도 비슷한 질문과 답변을 확인했다.

Because you receive the response as soon as all headers have arrived. Calling .json() gets you another promise for the body of the http response that is yet to be loaded. See also Why is the response object from JavaScript fetch API a promise?.

정리하면 이렇다.

  • 브라우져가 헤더를 모두 받으면 response 객체를 만든다. fetch() 함수가 반환된 첫 번째 프라미스가 이행될 때.
  • 브라우져가 바디를 모두 받으면 데이터를 만든다. text() 등 본문 조회용 메소드가 반환한 두 번째 프라미스가 이행될 때.

테스트해 보자.

시간을 지연하면서 본문을 청크 단위로 응답하는 서버다.

// 헤더 응답.
res.writeHead(200)

// 1초씩 지연하면서 청크 응답.
for await (const i of Array(5).keys()) {
  res.write(`chunk ${i}\n`)
  await new Promise(res => setTimeout(res, 1000))
}

// 응답 종료.
res.end()

cURL로 요청해보자.

➜  ~ curl -v localhost:3000/chunk

< HTTP/1.1 200 OK
< Transfer-Encoding: chunked
<
chunk 0  // 1초 후 수신
chunk 1  // 2초 후 수신
chunk 2  // 3초 후 수신
chunk 3  // 4초 후 수신
chunk 4  // 5초 후 수신

헤더가 먼저 도착하고 1초 간격으로 바디를 청크 단위로 수신한다.

브라우져의 fetch 함수는 어떻게 동작할까?

const response = await fetch("/chunk")
// 헤더를 수신하면 Response 객체를 만든다.
console.log("respnose:", response)

const text = await response.text()
// 5초후에 모든 바디를 수신하면 데이터를 만든다.
console.log("text:", text)

Response 객체를 먼저 만든다. 서버가 헤더를 실어서 먼저 응답하기 때문이다. 일정 시간이 지연된 뒤 모든 바디가 수신되면 다음 text 로그가 찍힌다. 모든 청크를 수신했을 때 text() 메소드가 반환한 프라미스가 이행된다는 말이다.

  • 헤더를 받고 Response 객체를 만든다.
  • 모든 데이터를 받을 때 까지 기다린다. (content-length가 0이면 종료)
  • 모든 데이터를 받으면 본문 데이터를 만든다.

결론

fetch 함수를 호출하면 브라우져가 비동기 HTTP 요청을 만든다. 요청할 원격 자원의 URL 뿐만 아니라 자세한 요청 정보를 options 인자로 지정할 수 있다.

함수는 Response 객체로 이행되는 프라미스를 반환한다. 두 단계로 응답을 처리한다. ok나 status 필드로 응답 성공 여부를 확인하다. 성공이라면 요청한 자원의 형식에 따라 메소드를 호출한다. 이 메소드는 응답 본문 데이터로 이행되는 프라미스를 반환한다.

프라미스를 두 번에 걸쳐 나누는 이유는 헤더가 먼저 도착하고 본문이 모두 도착하기를 기다리기 위해서다.

fetch는 거의 모든 브라우져에서 지원하는 함수다. 라이브러리 없이 브라우져 기본 함수만으로도 충분히 ajax 웹 어플리케이션을 만들 수 있을 것이다.

참고