티스토리 뷰

콜백 패턴부터 async/await 사용법을 TMDB API (V3)에서 인기 영화 목록을 가져오는 사례와 함께 알아봅시다. 모던 자바스크립트 Deep Dive의 비동기 처리 내용을 읽고 테스트 한 내용입니다.

 

도마뱀 책에게 감사를!

 

먼저 TMDB에 로그인하고 API key를 발급받습니다. 반복적으로 사용하는 URL과 Params, API key는 아래와 같이 상수로 정리했습니다.

// constant.js
const BASE_URL = "https://api.themoviedb.org/3";
const API_KEY = "your API key";
const BASE_PARAMS = "language=ko-KR&region=410";

export { BASE_URL, API_KEY, BASE_PARAMS };

이제 TMDB에서 인기 영화 목록을 가져와 아래처럼 화면에 나타내고, 영화 이미지를 클릭하면 모달에 영화 상세 정보를 표시할 겁니다. (모달 닫기 버튼은 안 만들었습니다..)

 

(좌)영화 목록 / (우)영화 상세 정보

 

HTML과 CSS는 다음처럼 작성했습니다.

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Asynchronous with TMDB</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main>
      <div id="movieList"></div>
    </main>
    <div id="modal"></div>
  </body>
  <script type="module" src="index.js"></script>
</html>
/* style.css */
body {
  margin: 0;
  background-color: #252744;
}

main {
  display: flex;
  justify-content: center;
}

main #movieList {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
}

#movieList img {
  width: 200px;
  height: 300px;
  background-color: grey;
  cursor: pointer;
}

#modal {
  z-index: 10;
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background-color: white;
}

1️⃣ 콜백 패턴 with XMLHttpRequest

콜백 패턴은 비동기 처리가 완료된 후 실행할 함수를 콜백 함수로 전달하는 방법입니다. 콜백 함수를 사용하는 이유는 다른 동기 함수가 종료된 후에 비동기 함수가 실행되기 때문입니다. 즉, 아래처럼 비동기 처리의 결과를 외부(상위 스코프)에 전달하려고 해도, 전역 컨텍스트가 종료된 후 setTimeout의 콜백 함수가 실행되기 때문에 원하는 결과를 얻을 수 없습니다.

let result = 0;

setTimeout(() => {
  result = 100;
  console.log(`내부: ${result}`);
}, 0);

console.log(`외부: ${result}`);

다음처럼 외부 함수가 먼저 실행된 후, 콜백 함수가 실행된 것을 확인할 수 있습니다.

 

외부 console.log 실행 후 비동기 처리

 

그러니 영화 목록을 가져오는 get 함수를 실행 한 후 화면에 영화 목록을 생성하려면, 영화 목록을 생성하는 작업을 콜백 함수로 전달하여 영화 데이터를 받은 후 사용해야합니다. 콜백 함수로 받은 json을 출력해 보면 영화 정보가 담겨 있는 것을 확인할 수 있습니다.

// index.js
import { BASE_URL, API_KEY, BASE_PARAMS } from "./constant.js";

const url = `${BASE_URL}/movie/popular?${BASE_PARAMS}&page=1`;

get(url, (json) => {
  console.log(json);
  // 응답 받은 json으로 화면에 영화 목록을 생성한다.
});

/* url에 GET 요청을 보내고, 처리 결과를 callback에 전달한다. */
function get(url, callback) {
  const xhr = new XMLHttpRequest();
  xhr.open("GET", url);

  // header 설정
  xhr.setRequestHeader("Authorization", `Bearer ${API_KEY}`);
  xhr.setRequestHeader("accept", "application/json");

  xhr.send();
  xhr.onload = function () {
    if (xhr.status === 200) {
      // callback에 결과 전달
      callback(JSON.parse(xhr.response));
    } else {
      console.error("Error", xhr.status, xhr.statusText);
    }
  };
}

JSON: 인기 영화 목록

 

화면에 영화 목록을 생성한 후에는 각 영화 img마다 클릭 이벤트를 추가해야 합니다. 사용자가 영화 img를 클릭하면, HTTP 요청을 보내 영화 id로 상세 정보를 가져오고, 그 정보를 모달에 추가하여 화면에 표시해야 합니다. 그러면 위 코드에서 최초로 실행한 get 함수는 다음과 같은 구조가 됩니다.

get(url, (json) => {
  // 응답 받은 json으로 화면에 영화 목록을 생성한다...
  // 각 영화 img에 클릭 이벤트를 추가한다...
  // 클릭 이벤트가 발생하면 영화 id로 상세 정보를 불러온다.
  get(`${BASE_URL}/movie/${movieId}?${BASE_PARAMS}`, (json) => {
      // ...
    });
});
// ...

지금은 간단한 기능만 구현하여 복잡하지 않지만 프로그램 규모가 커지고 기능이 복잡해지면, 콜백 함수를 받는 get 함수가 중첩되면서 코드 또한 복잡해지게 됩니다. 이런 현상을 콜백 지옥(Callback Hell)이라고 합니다. 하지만 복잡한 콜백 패턴은 후속 처리 메소드를 사용하는 Promise로 해결할 수 있게 되었습니다.

 

아래는 콜백 패턴을 사용한 전체 코드입니다.

import { BASE_URL, API_KEY, BASE_PARAMS } from "./constant.js";

const url = `${BASE_URL}/movie/popular?${BASE_PARAMS}&page=1`;

get(url, (json) => {
  createMovieList(json);

  // 각 영화 img에 클릭 이벤트를 추가한다.
  const imgs = document.querySelectorAll("img");
  imgs.forEach((img) => img.addEventListener("click", handleClick));

  /* 영화 목록을 화면에 표시한다. */
  function createMovieList(json) {
    const list = document.querySelector("#movieList");

    json.results.forEach((movie) => {
      const img = document.createElement("img");
      img.src = `https://image.tmdb.org/t/p/w200${movie.poster_path}`;
      img.alt = movie.title;
      img.dataset.id = movie.id;
      list.appendChild(img);
    });
  }

  /* 영화 img의 클릭 이벤트를 처리한다. */
  function handleClick(event) {
    const movieId = event.target.dataset.id;

    get(`${BASE_URL}/movie/${movieId}?${BASE_PARAMS}`, (json) => {
      // 모달을 생성하고 body에 추가한다.
      const modal = createModal();
      const body = document.querySelector("body");
      body.appendChild(modal);
    });
  }

  /* 영화 정보를 담은 모달을 생성하고 반환한다. */
  function createModal() {
    const modal = document.querySelector("#modal");
    const h2 = document.createElement("h2");
    const overview = document.createElement("p");

    modal.innerHTML = "";
    h2.innerText = json.title;
    overview.innerText = json.overview;
    modal.appendChild(h2);
    modal.appendChild(overview);

    modal.style.width = "60%";
    modal.style.height = "40%";
    modal.style.padding = "24px 32px";
    modal.style.borderRadius = "24px";

    return modal;
  }
});

/* url에 GET 요청을 보내고, 처리 결과를 callback에 전달한다. */
function get(url, callback) {
  const xhr = new XMLHttpRequest();
  xhr.open("GET", url);

  // header 설정
  xhr.setRequestHeader("Authorization", `Bearer ${API_KEY}`);
  xhr.setRequestHeader("accept", "application/json");

  xhr.send();
  xhr.onload = function () {
    if (xhr.status === 200) {
      callback(JSON.parse(xhr.response));
    } else {
      console.error("Error", xhr.status, xhr.statusText);
    }
  };
}

2️⃣ 콜백 패턴 with Promise

Promise는 ES6에서 등장한 생성자 함수입니다. Promise를 사용하면 비동기 작업을 동기처럼 처리할 수 있습니다. 앞서 살펴본 콜백 패턴은 비동기 처리 결과를 외부 변수에 전달할 수 없었지만, 비동기 함수가 Promise를 반환하도록 하면 외부에서 비동기 처리 결과를 받을 수 있습니다. 아래처럼 기존 get 함수가 Promise를 반환하도록 만들고 결과를 출력해 보면,

const promise = get(`${BASE_URL}/movie/popular?${BASE_PARAMS}&page=1`);
console.log(promise);

/* url에 GET 요청을 보내고, 처리 결과를 Promise로 반환한다. */
function get(url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", url);

    // header 설정
    xhr.setRequestHeader("Authorization", `Bearer ${API_KEY}`);
    xhr.setRequestHeader("accept", "application/json");

    xhr.send();
    xhr.onload = function () {
      if (xhr.status === 200) {
      	// 응답을 성공적으로 받으면 resolve에 json(처리 결과)을 넘긴다.
        resolve(JSON.parse(xhr.response));
      } else {
      	// 실패하면 reject에 실패 이유를 전달한다.
        reject(`Error ${xhr.status}: ${xhr.statusText}`);
      }
    };
  });
}

 

아래처럼 Promise 객체를 받은 것을 확인할 수 있습니다. PromiseState는 비동기 처리 상태를 나타내며, 처리 전이 "pending", 성공적으로 처리하면 "fullfilled", 에러가 발생하면 "rejected"가 됩니다.

 

Promise 반환 결과

 

후속 처리 메소드

인기 영화 목록을 Promise로 받아왔으니, 영화 상세 정보를 모달에 입력하여 화면에 표시해야 합니다. Promise의 then, catch 메소드로 응답 결과를 처리하거나 에러에 대응할 수 있습니다.

 

이전과 마찬가지로  인기 영화 목록을 화면에 표시하는 코드입니다. 함수를 분리하기 위해 Image Element를 중간에 프로미스로 반환했습니다.

const popular = get(`${BASE_URL}/movie/popular?${BASE_PARAMS}&page=1`);
const imgsPromise = popular
  .then((json) => {
    createMovieList(json);
    // Image element[] 반환 (프로미스로 반환됨)
    return document.querySelectorAll("img");

    function createMovieList(json) {
      const list = document.querySelector("#movieList");

      json.results.forEach((movie) => {
        const img = document.createElement("img");
        img.src = `https://image.tmdb.org/t/p/w200${movie.poster_path}`;
        img.alt = movie.title;
        img.dataset.id = movie.id;
        list.appendChild(img);
      });
    }
  })
  .catch(console.error);

원래는 get이 두 번 중첩되었으나 비동기 함수가 Promise를 반환하도록 만들었기 때문에, 다음처럼 각 영화 img에 클릭 이벤트를 이전 get 함수와 중첩 없이 추가할 수 있습니다.

//...
// 각 영화 img에 클릭 이벤트를 추가한다.
imgsPromise.then((imgs) => {
  imgs.forEach((img) => img.addEventListener("click", handleClick));

  function handleClick(event) {
    const id = event.target.dataset.id;
    const detail = get(`${BASE_URL}/movie/${id}?${BASE_PARAMS}`);

    detail
      .then((json) => {
        // 영화 정보를 담은 모달을 생성하고 body에 추가한다.
        const modal = createModal(json);
        const body = document.querySelector("body");
        body.appendChild(modal);
      })
      .catch(console.error);
  }

  function createModal(detail) {
    //...
  }
});

지금은 get 함수가 프로미스를 반환하도록 직접 구현했지만 HTTP 요청할 때는 프로미스를 반환하는 fetch 함수를 사용할 수 있습니다.

3️⃣ fetch

fetch 함수는 위에서 구현한 get 함수처럼 HTTP 응답을 프로미스로 반환합니다. 즉, fetch도 프로미스의 후속 처리 메소드를 사용할 수 있습니다. 아래는 fetch 함수를 사용하여 get 함수를 새로 정의한 코드입니다. 

function get(url) {
  const options = {
    method: "GET",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      accept: "application/json",
    },
  };
  return fetch(url, options)
    .then((res) => {
      if (res.ok) return res.json();
      throw new Error(res.statusText);
    })
}

fetch가 반환한 Response 객체의 구조는 다음과 같습니다.

 

fetch가 반환한 Response

 

fetch는 Response 객체를 반환하고, ok 속성에 응답 성공 여부를 나타냅니다. 응답 결과는 json 메소드를 사용하여 JSON으로 파싱할 수 있습니다. 이때, json도 프로미스를 반환하므로, 중간에 then 메소드를 사용하여 JSON을 반환하는 과정이 필요합니다.

 

ok로 응답 성공 여부를 확인했음에도 catch 메소드를 사용하는 이유는 fetch가 HTTP 에러는 reject 하지 않기 때문입니다. 따라서 then에서 한 번, catch에서 한 번 에러를 확인해줘야 합니다.

 

fetch does not reject on HTTP errors (출처: MDN)

 

아래처럼 Response.ok가 false면 에러를 던져서 catch에 전달해 줍시다.

// fetch 기본 구조
fetch(url)
    .then((res) => {
      if (res.ok) return res.json();
      throw new Error(); // HTTP 에러 던지기
    })
    .catch((error) => {
      // 에러 처리
});

4️⃣ async / await

프로미스의 후속 처리 메소드로 콜백 지옥에서 벗어났지만, 실행 결과 뒤로 계속 이어지는 콜백 패턴은 여전히 복잡해 보입니다. 이를 위해 후속 처리 메소드 없이도 비동기를 처리할 수 있는 async / await가 ES8에서 등장하게 됩니다.

 

async를 앞에 붙여서 함수를 정의하면, 해당 함수는 비동기로 작동하며 프로미스를 반환합니다. await는 async로 정의된 함수 내부에서 사용하며, 프로미스를 반환하는 함수가 resolve 한 값을 받아올 수 있습니다. 즉, 프로미스가 아니라 비동기 처리되어 fullfilled나 rejected 된 결과를 받게 됩니다.

 

이전에 작성한 get 함수에 async / await를 사용하면 아래와 같습니다.

async function get(url) {
  const options = {
    method: "GET",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      accept: "application/json",
    },
  };

  try {
    // fetch가 resolve 한 Response를 받아온다.
    const res = await fetch(url, options);
    return res.json();
  } catch (error) {
    console.error(error);
  }
}

에러 처리는 후속 처리 메소드 catch 대신 try...catch 문을 사용하면 됩니다. catch는 try 블록에서 예외가 발생하면 실행되며, 후속 처리 메소드와 달리 HTTP 에러도 처리합니다.

 

아래는 async로 정의한 get 함수를 사용하여 인기 영화 목록을 생성하는 코드입니다. 콜백 패턴을 사용하지 않아서 가독성이 더 좋아진 모습입니다.

(async function () {
  // 인기 영화 정보를 받아온다.
  const popular = await get(`${BASE_URL}/movie/popular?${BASE_PARAMS}&page=1`);
  createMovieList(popular);

  const imgs = document.querySelectorAll("img");
  imgs.forEach((img) => img.addEventListener("click", handleClick));

  function createMovieList(json) {
    // ...
  }

  async function handleClick(event) {
    const id = event.target.dataset.id;
    // 영화 상세 정보를 받아온다.
    const detail = await get(`${BASE_URL}/movie/${id}?${BASE_PARAMS}`);
    const modal = createModal(detail);
    const body = document.querySelector("body");
    body.appendChild(modal);
  }

  function createModal(detail) {
    // ...
  }
})();

5️⃣ 인기 영화 무한 스크롤 with Generator

XMLHttpRequest부터 async / await를 사용하여 비동기를 처리하는 방법을 알아봤습니다. 여기까지 온 김에 Generator를 사용하여 반복적으로 HTTP 요청을 보내 무한 스크롤을 만들어보겠습니다.

 

제너레이터 함수는 yield 표현식을 사용하여, 특정 시점에 코드를 실행했다가 일시정지 할 수 있습니다. MDN에 나와있는 Generator 함수의 예시를 보면, next 메소드를 실행할 때마다 yield 표현식을 실행하며, 차례대로 1, 2, 3을 반환하고 일시정지 하는 것을 확인할 수 있습니다.

// 출처: MDN
function* generator() {
  yield 1;
  yield 2;
  yield 3;
}

const gen = generator(); // "Generator { }"

console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
console.log(gen.next().value); // 3

 

인기 영화 목록을 보여주는 무한 스크롤을 만들려면, 사용자가 현재 영화 목록을 끝까지 스크롤했을 때(특정 시점) 영화 목록을 추가로 불러와야 합니다(yield). 즉, 스크롤 위치(scrollY)와 창의 내 높이(innerHeight)를 더한 값이 웹 콘텐츠 전체 길이(body.scrollHeight)와 같아지면, 다음 페이지의 영화 목록을 표시합니다.

무한 스크롤에 필요한 값 확인!

 

이전에 작성한 코드에 다음 제너레이터 함수를 추가합니다. 특정 시점이 되어 yield 표현식을 실행할 때마다 page 값을 증가시키며 인기 영화 목록을 요청합니다.

const popularGenerator = (async function* () {
  let page = 1;

  while (true) {
    if (page > 500) break; // page는 500 이하의 자연수
    // 다음 페이지 인기 영화 목록 가져오기
    yield await get(`${BASE_URL}/movie/popular?${BASE_PARAMS}&page=${++page}`);
  }
  return; // 종료
})();

사용자가 창을 스크롤했을 때 스크롤이 끝까지 내려갔는지 확인하기 위해, window에 scroll 이벤트 리스너를 추가합니다. 특정 시점은 앞서 설명한 대로 scrollY + innerHeight === body.scrollHeight 가 되었을 때입니다.

window.addEventListener("scroll", handleScroll);

async function handleScroll() {
  const body = document.querySelector("body");

  if (window.innerHeight + window.scrollY >= body.scrollHeight) {
    const { done, value } = await popularGenerator.next();
    
    // 영화 목록이 남아있으면
    if (!done) createMovieList(value);
  }
}

done을 통해 generator 함수가 종료(return)되었는지 확인할 수 있습니다. 함수가 종료되지 않았으면 done이 false이고, next 메소드로 전달받은 인기 영화 목록을 화면에 표시합니다.

 

사이트를 확인해보면 스크롤이 끝에 다다를 때마다 새로운 영화가 추가되고 있습니다!

 

인기 영화 무한 스크롤

 


이전에는 fetch와 async / await는 사용하면서도 그 이유를 생각해보지 못했습니다. 이번 글을 정리하며 콜백 패턴과 Promise에 대해 알게 되었고, 저와 같은 궁금증을 가지신 분들께 이 글이 도움이 되었으면 합니다 🥳

 

 

틀린 부분이 있다면 한 말씀 부탁드립니다. 감사합니다 :)

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함