본문 바로가기
[Study] 개발 공부

[Vanilla-JS] 고양이 사진 검색 사이트😺: 상세 기능 구현

by 지공A 2025. 3. 13.

시작 전, 문제를 보고 기능 요구사항을 정리해보자

 

1. HTML, CSS

더보기
  • 시맨틱 태그 사용
  • 반응형

2. 다크모드

더보기
  • 체크박스
  • OS 반영

3. 이미지 모달

더보기
  • [필수] 닫기 기능
  • /api/cats/:id
  • 반응형
  • [추가] fade in/out

4. 검색

더보기
  • [필수] 검색 결과가 없을 때 예외사항 UI
  • [필수] 랜덤 버튼 추가(/api/cats/random50)
  • [필수] 로딩 UI 추가
  • input el 수정
  • 최근 검색 키워드
  • 새로고침 시 마지막 검색 결과 유지
  • lazy load
  • 스크롤 페이징
  • 각 아이템 클릭 이벤트를 Event Delegation 기법 사용하여 수정
  • [추가] 마우스 오버 시 고양이 이름

5. 랜덤 고양이 배너 섹션 추가 

더보기
  • /api/cats/random50
  • 5개만 노출하며 좌, 우 슬라이드 버튼
  • [선택] 트랜지션

6. 코드 구조 관련

더보기
  • [필수] API status code에 따른 에러메시지 분리
  • ES6 module
  • API fetch 코드를 async/await 문으로 변경 및 에러 처리

코드 구조 관련

1. [필수] API의 status code에 따라 에러 메시지를 분리하여 작성해야 합니다.

const request = async (url) => {
  try {
    const res = await fetch(`${API_ENDPOINT}/api/cats/${url}`);
    if (!res.ok) {
      throw new Error(getErrorMsg(res.status));
    }
    const data = await res.json();
    return data;
  } catch (err) {
    console.error("Error fetching data: ", err.message);
    return { data: [], error: err.message };
  }
};

const getErrorMsg = (status) => {
  switch (status) {
    case 400:
      return "🚨 잘못된 요청입니다. 다시 확인해주세요.";
    case 401:
      return "🔒 인증이 필요합니다. 로그인 후 시도해주세요.";
    case 403:
      return "⛔ 접근이 거부되었습니다.";
    case 404:
      return "❌ 찾을 수 없는 데이터입니다.";
    case 500:
      return "🔥 서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.";
    case 503:
      return "⚠️ 현재 서버가 점검 중입니다. 잠시 후 다시 시도해주세요.";
    default:
      return `❗ 알 수 없는 오류 (Error Code: ${status})`;
  }
};

 

2. ES6 module 형태로 코드를 변경합니다.

  • type="module" 사용하여 ES6 모듈 사용을 명시
  • 이후, 각 script를 import/export 문으로 관리한다.
<!-- index.html -->
<script type="module" src="src/main.js"></script>
더보기
// App.js
import SearchResult from "./SearchResult.js";
import Loading from "./Loading.js";
import api from "./api.js";
import ImageInfo from "./ImageInfo.js";
import Header from "./Header.js";
import RandomBanner from "./RandomBanner.js";

export default class App { ...

 

3. API fetch 코드를 async, await 문을 이용하여 수정해주세요. 해당 코드들은 에러가 났을 경우를 대비해서 적절히 처리가 되어있어야 합니다.

더보기
// api.js
const request = async (url) => {
  try {
    const res = await fetch(`${API_ENDPOINT}/api/cats/${url}`);
    if (!res.ok) {
      throw new Error(getErrorMsg(res.status));
    }
    const data = await res.json();
    return data;
  } catch (err) {
    console.error("Error fetching data: ", err.message);
    return { data: [], error: err.message };
  }
};

HTML, CSS

1. 현재 HTML 코드가 전체적으로 <div>로만 이루어져 있습니다. 이 마크업을 시맨틱한 방법으로 변경해야 합니다.

  • header, section, article 태그 사용
더보기
// App.js
  setupLayout() {
    this.$header = document.createElement("header");
    this.$target.appendChild(this.$header);

    // 섹션 레이아웃을 잡아줌
    this.$bannerWrapper = document.createElement("section");
    this.$bannerWrapper.className = "BannerWrapper";
    this.$target.appendChild(this.$bannerWrapper);

    this.$searchResult = document.createElement("section");
    this.$searchResult.className = "SearchResult";
    this.$target.appendChild(this.$searchResult);
  }

 

2. 유저가 사용하는 디바이스의 가로 길이에 따라 검색결과의 row당 column 개수를 적절히 변경해주어야 합니다.

  • 992px 이하: 3개
  • 768px 이하: 2개
  • 576px 이하: 1개
.SearchResult {
  margin-top: 10px;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  grid-gap: 10px;
}

다크모드

1. 다크 모드(Dark mode)를 지원하도록 CSS를 수정해야 합니다. 모든 글자 색상은 #FFFFFF, 배경 색상은 #000000로 한정합니다. 기본적으로는 OS의 다크모드의 활성화 여부를 기반으로 동작하게 하되, 유저가 테마를 토글링 할 수 있도록 좌측 상단에 해당 기능을 토글하는 체크박스를 만듭니다.

 

  1. 다크모드 레이아웃 생성
  2. OS 다크모드 확인
  3. 체크박스 이벤트 리스너 등록
더보기
/* style.css */
.root {
  --bg-color: white;
  --text-color: black;
}

/* dark mode 처리 */
/* 아래 미디어 쿼리는 OS 다크모드를 감지하여 스타일 지정이 가능 */
@media (prefers-color-scheme: dark) {
  :root {
    --bg-color: black;
    --text-color: white;
  }
}

/* data-set 사용할 것이기에, data-theme가 dark일 때 다크모드 컬러 지정 */
[data-theme="dark"] {
  --bg-color: black;
  --text-color: white;
}

/* 라이트 모드 컬러 지정 */
[data-theme="light"] {
  --bg-color: white;
  --text-color: black;
}

/* 하위 모든 요소들에게도 적용 */
body,
body * {
  background-color: var(--bg-color);
  color: var(--text-color);
}
  setTheme() {
    // 이 로직은 생략가능: 사용자가 지정해둔 테마가 있었는지 확인
    const userTheme = localStorage.getItem("theme");

    // 2. OS 다크모드 확인
    // theme: userTheme가 있다면 그 값(dark or light), 없다면 os값을 data-set theme에 지정해줌
    const theme =
      userTheme ||
      // window.matchMedia로 OS 다크모드가 설정되어 있는지 확인
      (window.matchMedia("(prefers-color-scheme: dark)").matches
        ? "dark"
        : "light");
    // window.matchMedia 값에 따라 dark 또는 light를 data-theme에 저장해준다.
    // document.documentElement => html 태그에 data-theme 지정해줄 것
    document.documentElement.setAttribute("data-theme", theme);
    // 만약 다크모드면 체크처리
    this.$darkModeCheckBox.checked = theme === "dark";
  }
  
  addEvent() {
    // 3. 체크박스 이벤트리스너 추가
    this.$darkModeCheckBox.addEventListener("change", () => {
      const theme = this.$darkModeCheckBox.checked ? "dark" : "light";
      // 다크모드 체크되면, 다크모드 활성화
      document.documentElement.setAttribute("data-theme", theme);
      localStorage.setItem("theme", theme);
    });
  }

이미지 모달

1. [필수] 이미지를 검색한 후 결과로 주어진 이미지를 클릭하면 모달이 뜨는데, 모달 영역 밖을 누르거나 / 키보드의 ESC 키를 누르거나 / 모달 우측의 닫기(x) 버튼을 누르면 닫히도록 수정해야 합니다.

// ImageInfo.js

// 클릭 또는 ESC keydown 이벤트 시, visible을 false로 해서 모달을 닫는다.
// 클릭의 경우, 모달 밖 영역 클릭 시(contains ImageInfo) 또는 close 버튼 클릭 시 이벤트 동작
  addEvent() {
    this.$imageInfo.addEventListener("click", (e) => {
      if (
        e.target.classList.contains("ImageInfo") ||
        e.target.classList.contains("close")
      ) {
        this.setState({ visible: false });
      }
    });
    document.addEventListener("keydown", (e) => {
      if (e.key === "Escape") {
        this.setState({ visible: false });
      }
    });
  }

 

2. 모달에서 고양이의 성격, 태생 정보를 렌더링합니다. 해당 정보는 /cats/:id를 통해 불러와야 합니다.

더보기
// ImageInfo.js
// 특정 이미지 클릭 시 다음이 실행되어 여기로 올 것
/* onClick: (image) => {
        this.imageInfo.setState({
          visible: true,
          image,
        });
      },
*/
// 따라서 visible의 true일 때에만 api 호출해옴
// 렌더링할 데이터를 클릭한 고양이의 정보로 업데이트한 뒤, 렌더링  
  async setState(nextData) {
    this.data = nextData;
    if (this.data.visible) {
      this.isLoading.setState(true);
      try {
        const catInfo = await api.fetchCatInfo(this.data.image.id);
        if (catInfo.error) {
          this.data.image.temperament = catInfo.error;
          this.data.image.origin = catInfo.error;
          throw new Error(catInfo.error);
        }
        if (catInfo) {
          this.data.image.temperament = catInfo.data.temperament;
          this.data.image.origin = catInfo.data.origin;
          this.isLoading.setState(false);
        }
      } catch (err) {
        this.isLoading.setState(false);
      }
    }
    this.render();
  }

 

3. 디바이스 가로 길이가 768px 이하인 경우, 모달의 가로 길이를 디바이스 가로 길이만큼 늘려야 합니다.

더보기
/* style.css */
.ImageInfo .content-wrapper {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  background-color: #fff;
  border: 1px solid #eee;
  border-radius: 5px;
  max-height: 100vh; /* 최대 높이를 화면의 80%로 설정 */
  overflow-y: auto; /* 스크롤 가능하게 설정 */
  /* 미디어쿼리 사용 문제 */
  @media screen and (max-width: 768px) {
    width: 100vw;
  }
}

 

4. [추가] 모달 열고 닫기에 fade in/out을 적용해 주세요.

더보기
/* style.css */
/* fadeout 애니메이션을 위해 display: none을 할 때, setTimeout 0.3s를 주었다. */
@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes fadeOut {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

.ImageInfo.show {
  animation: fadeIn 0.3s ease forwards;
}

.ImageInfo.hide {
  animation: fadeOut 0.3s ease forwards;
}

 


검색 페이지 관련

더보기
  • [추가] 마우스 오버 시 고양이 이름

1. [필수] 검색 결과가 없는 경우, 유저가 불편함을 느끼지 않도록 UI적인 적절한 처리가 필요합니다.

// SearchResult.js
  render() {
    if (this.data) {
      // 검색 결과 배열의 길이가 0이면 검색 결과가 없으므로 예외사항 처리 먼저 해준다.
      if (this.data.length === 0) {
        this.$target.innerHTML = `<div class="result-msg"><p>❌ 검색 결과가 없습니다.</p></div>`;
      } else {
        this.$target.innerHTML = this.data
          .map(
            (cat) => `
            <article class="item">
               <img class="lazy-img" title=${cat.name} data-src=${cat.url} alt=${cat.name}  />
            </article>
          `
          )
          .join("");
      }
    }

 

2. [필수] SearchInput 옆에 버튼을 하나 배치하고, 이 버튼을 클릭할 시 /api/cats/random50을 호출하여 화면에 뿌리는 기능을 추가합니다. 버튼의 이름은 마음대로 정합니다.

// App.js
  // 검색 결과를 state에 저장하고, 로컬스토리지에도 저장
  setFetchedData(data) {
    this.setState({ data });
    localStorage.setItem("lastSearch", JSON.stringify(data));
  }
  
  setupHeader() {
    // 헤더 안에 다크모드와 검색창을 넣어줄 것
    this.$header = new Header({
      $target: this.$header,
      onSearch: (keyword) =>
        this.fetchData(
          api.fetchCats,
          keyword,
          (data) => this.setFetchedData(data),
          (err) => (this.$searchResult.innerHTML = ERROR_MSG(err))
        ),
      // 랜덤 버튼의 클릭 이벤트 리스너로 등록되어 있는 onRandom
      onRandom: () =>
        this.fetchData(
          api.fetchRandomCats,
          "",
          (data) => this.setFetchedData(data),
          (err) => (this.$searchResult.innerHTML = ERROR_MSG(err))
        ),
    });
  }

 

3. [필수] 데이터를 불러오는 중일 때, 현재 데이터를 불러오는 중임을 유저에게 알리는 UI를 추가해야 합니다.

export default class Loading {
  constructor({ $target }) {
    this.$target = $target;
    this.render();
  }

  render() {
    // 로딩 오버레이 생성
    this.$loadingOverlay = document.createElement("div");
    this.$loadingOverlay.classList.add("loading-overlay");
    this.$loadingOverlay.style.display = "none";

    // 로딩 스피너 컨테이너 (스피너 + 텍스트) 생성
    this.$loadingContainer = document.createElement("div");
    this.$loadingContainer.classList.add("loading-container");

	// 로딩 스피너 생성
    this.$loadingSpinner = document.createElement("div");
    this.$loadingSpinner.classList.add("loading-spinner");
    this.$loadingSpinner.style.display = "none";

    // 스피너 안에 고양이 텍스트
    this.$loadingText = document.createElement("span");
    this.$loadingText.classList.add("loading-text");
    this.$loadingText.innerText = "┙"; // 고양체에서 고양이 모양으로 사용

    // 로딩 컨테이너에 스피너 & 텍스트 추가
    this.$loadingContainer.appendChild(this.$loadingSpinner);
    this.$loadingContainer.appendChild(this.$loadingText);
    this.$loadingOverlay.appendChild(this.$loadingContainer);
    this.$target.appendChild(this.$loadingOverlay);
  }

  // 로딩 스테이트에 따라 로딩 오버레이+스피너 활성화 또는 숨기기 해준다
  setState(isLoading) {
    if (isLoading) {
      this.$loadingSpinner.style.display = "block";
      this.$loadingOverlay.style.display = "flex"; // 오버레이 활성화
    } else {
      this.$loadingSpinner.style.display = "none";
      this.$loadingOverlay.style.display = "none"; // 오버레이 숨기기
    }
  }
}
더보기
/* style.css */
/* 로딩 오버레이: 화면 전체 덮기 */
.loading-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

/* 스피너와 텍스트 포함하는 부모 컨테이너: relative */
.loading-container {
  position: relative;
  width: 80px;
  height: 80px;
  background-color: transparent;
}

/* 스피너: 스핀 애니메이션 적용 */
/* 보더탑을 투명화하면 로딩 스피너 형태가 된다. */
.loading-spinner {
  width: 80px;
  height: 80px;
  border: 4px solid white;
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
  background-color: transparent;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

/* 스피너 중앙의 텍스트: 스피너 안에 위치하기 위해 absolute */
.loading-text {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: 40px;
  background-color: transparent;
  color: white;
  animation: bounce 0.8s infinite alternate ease-in-out;
}

/* 고양이 바운스 애니메이션: 기준 축(-50%, -50%)에서 위아래로 */
@keyframes bounce {
  from {
    transform: translate(-50%, -55%);
  }
  to {
    transform: translate(-50%, -45%);
  }
}

 

4. 페이지 진입 시 포커스가 input에 가도록 처리하고, 키워드를 입력한 상태에서 input을 클릭할 시에는 기존에 입력되어 있던 키워드가 삭제되도록 만들어야 합니다.

더보기
  // SearchInput.js
  render() {
    const fragment = document.createDocumentFragment();
    // 1. input el 생성
    this.$searchInput = this.createInputEl();
    fragment.appendChild(this.$searchInput);
    ...
    // 페이지 진입 시 포커스가 input에 가도록 처리
    this.$searchInput.focus();
  }

  createInputEl() {
    const $input = document.createElement("input");
    $input.className = "SearchInput";
    $input.placeholder = "고양이를 검색해보세요.|";
    
    return $input;
  }
  
  addEvent() {
    // input 창 클릭 시, 입력 내용 초기화
    this.$searchInput.addEventListener("click", (e) => {
      e.target.value = "";
    });
  }

 

5. 최근 검색한 키워드를 SearchInput 아래에 표시되도록 만들고, 해당 영역에 표시된 특정 키워드를 누르면 그 키워드로 검색이 일어나도록 만듭니다. 단, 가장 최근에 검색한 5개의 키워드만 노출되도록 합니다.

더보기
export default class SearchHistory {
  // Input의 onSearch 공유
  constructor({ $target, onSearch }) {
    this.$target = $target;
    this.onSearch = onSearch;
    // 로컬에 저장 기록이 있다면 가져와서 배열로 저장
    this.keywordsHistory = localStorage.getItem("searchKeywords")
      ? localStorage.getItem("searchKeywords").split(",")
      : [];

    this.setLayout();
    this.render();
  }

  setLayout() {
    this.$searchKewordContainer = document.createElement("div");
    this.$searchKewordContainer.className = "history-wrapper";
    this.$target.appendChild(this.$searchKewordContainer);
  }

  // 로컬에 기록이 있다면 검색 키워드 노출면 검색 키워드 노출
  render() {
    if (this.keywordsHistory.length > 0) {
      this.$searchKewordContainer.innerHTML = this.keywordsHistory
        .map((keyword) => `<span class="searchHistory">${keyword}</span>`)
        .join("");
    }
    this.addEvent();
  }

  // 검색 이벤트가 발생하면 실행되는 함수
  addHistory(keyword) {
    // 이미 저장된 키워드가 5개 이상이면, 맨 뒤 키워드를 없앤다.
    if (this.keywordsHistory.length >= 5) {
      this.keywordsHistory = this.keywordsHistory.slice(0, 4);
    }
    // 현재 키워드를 맨 앞에 저장(unshift)
    this.keywordsHistory.unshift(keyword);
    // 키워드 배열을 로컬에 저장하고 리렌더링
    localStorage.setItem("searchKeywords", this.keywordsHistory);
    this.render();
  }

  // 각 키워드 클릭 시, 그 키워드로 검색한다.
  addEvent() {
    document.querySelectorAll(".searchHistory").forEach(($keyword) => {
      $keyword.addEventListener("click", (e) => {
        this.onSearch(e.target.innerHTML);
      });
    });
  }
}

 

6. 페이지를 새로고침해도 마지막 검색 결과 화면이 유지되도록 처리합니다.

더보기
// App.js
  // 검색 이벤트 발생 시 실행되는 함수
  setFetchedData(data) {
    this.setState({ data });
    // 마지막 검색 결과 로컬스토리지에 저장
    localStorage.setItem("lastSearch", JSON.stringify(data));
  }
  
  
// SearchResult.js
  constructor({ $target, initialData, onClick }) {
    this.$target = $target;
    // 마지막 검색 결과가 있다면, 로컬스토리지에서 가져와서 파싱하여 이를 렌더링
    this.data = JSON.parse(localStorage.getItem("lastSearch")) || initialData;
    this.onClick = onClick;

    this.render();
  }

 

6. lazy load 개념을 이용하여, 이미지가 화면에 보여야 할 시점에 load 되도록 처리해야 합니다.

7. 스크롤 페이징 구현: 검색 결과 화면에서 유저가 브라우저 스크롤 바를 끝까지 이동시켰을 경우, 그 다음 페이지를 로딩하도록 만들어야 합니다.

8. [추가] 검색 결과 각 아이템에 마우스 오버시 고양이 이름을 노출합니다.

  • 시간이 없을 시, 이전 포스팅의 getBoundingClientRect() 사용
더보기
// 스크롤 이벤트 추가
window.addEventListener("scroll", function () {
  document.querySelectorAll(".lazy-img").forEach((img) => {
    /* img.getBoundingClientRect().top: 현재 이미지의 화면에서의 위치
       window.innerHeight: 현재 보이는 화면 높이*/
    // data-src 속성이 현재 화면에 들어오면,   
    if (img.dataset.src && img.getBoundingClientRect().top < window.innerHeight) {
      img.src = img.dataset.src; // 실제 이미지 로드
      img.removeAttribute("data-src"); // 중복 실행 방지
    }
  });
});
  • IntersectionObserver를 사용하는 방식
더보기
export default class SearchResult {
  // 3. lazy load 동작 핵심 로직
  handleLazyLoad = (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        // 뷰포트에 들어오면
        const img = entry.target;
        img.src = img.dataset.src; // data-src 속성에 저장되어 있는 실제 이미지 URL 가져와서 적용
        observer.unobserve(img); // 로딩 후 감지 중지
      }
    });
  };

  observeImgs() {
    // 2. lazy load를 위한 Intersection Observer
    this.observer = new IntersectionObserver(this.handleLazyLoad, {
      root: null, // viewport 기준으로 감지하겠다다
      rootMargin: "0px", // 뷰포트 경계에서 추가 여백 없이 바로 감지
      threshold: 0.1, // 이미지가 10% 이상 보일 때 로드
    });

    // 모든 .lazy-img 요소를 Intersection Observer로 감지
    // 뷰포트에 나타나면 handleLazyLoad 실행 → 실제 이미지 로딩
    /* observe(img) → 해당 요소를 감시 시작
      unobserve(img) → 감시 대상에서 제외 */
    this.$target.querySelectorAll(".lazy-img").forEach((img) => {
      this.observer.observe(img);
    });
  }

  setState(nextData) {
    this.data = nextData.data;
    this.render();
  }

  // 1. src를 data-src 속성으로 변경 
  render() {
    if (this.data) {
      if (this.data.length === 0) {
        this.$target.innerHTML = `<div class="result-msg"><p>❌ 검색 결과가 없습니다.</p></div>`;
      } else {
        this.$target.innerHTML = this.data
          // title 속성으로 마우스 오버 시, 고양이 이름 노출
          .map(
            (cat) => `
            <article class="item">
               <img class="lazy-img" title=${cat.name} data-src=${cat.url} alt=${cat.name}  />
            </article>
          `
          )
          .join("");
      }
    }
    
    // lazy load 적용
    this.observeImgs();
    this.addEvent();
  }
}

 

9. SearchResult에 각 아이템을 클릭하는 이벤트를 Event Delegation 기법을 이용해 수정해주세요.

더보기
  addEvent() {
    // Event Delegation 기법 사용하기
    // 1. 부모 요소에 클릭 이벤트 등록
    this.$target.addEventListener("click", (e) => {
      // 2. 클릭한 지점에서 가장 가까운 item 찾기
      const $item = e.target.closest(".item");
      if (!$item) return; // item 없으면 return

      // 3. querySelectorAll 반환값은 유사배열
      // => Array.from() 사용하여 배열로 만들어줌
      // => 모든 item 중에 선택된 $item el의 index 가져옴
      const index = Array.from(this.$target.querySelectorAll(".item")).indexOf(
        $item
      );
      
      // 해당 index의 데이터를 가져와서 onClick 함수 실행
      if (index !== -1) {
        this.onClick(this.data[index]);
      }
    });
  }

랜덤 고양이 배너 섹션 추가 

1. 현재 검색 결과 목록 위에 배너 형태의 랜덤 고양이 섹션을 추가합니다.

2. 앱이 구동될 때 /api/cats/random50 api를 요청하여 받는 결과를 별도의 섹션에 노출합니다.

3. 검색 결과가 많더라도 화면에 5개만 노출하며 각 이미지는 좌, 우 슬라이드 이동 버튼을 갖습니다.

4. 좌, 우 버튼을 클릭하면, 현재 노출된 이미지는 사라지고 이전 또는 다음 이미지를 보여줍니다.(트렌지션은 선택)

더보기
// App.js
  // 앱에서 random50 api 호출 결과를 banner에 전달 
  async setupBanner() {
    this.fetchData(
      api.fetchRandomCats,
      "",
      (data) => {
        this.bannerData = data;
        this.banner = new RandomBanner({
          $target: this.$bannerWrapper,
          data: this.bannerData,
        });
      },
      (error) => {
        this.$bannerWrapper.innerHTML = ERROR_MSG(
          "배너 이미지 요청 중 오류가 발생하였습니다."
        );
      }
    );

    this.setSearchResult();
  }
export default class RandomBanner {
  constructor({ $target, data }) {
    this.$target = $target;
    this.data = data;
    this.currentIndex = 0;
    this.totalPages = 5;

    this.render();
    this.updateButtonState();
    this.addEvent();
  }

  render() {
    // 배너 전체 감싸는 컨테이너
    this.$bannerContainer = document.createElement("div");
    this.$bannerContainer.className = "banner-container";

    // 배너 아이템들이 들어갈 내부 div
    this.$bannerInner = document.createElement("div");
    this.$bannerInner.className = "banner-inner";

    this.$prevBtn = document.createElement("button");
    this.$prevBtn.innerHTML = "<";
    this.$nextBtn = document.createElement("button");
    this.$nextBtn.innerHTML = ">";

    this.$target.appendChild(this.$prevBtn);
    this.$bannerContainer.appendChild(this.$bannerInner);
    this.$target.appendChild(this.$bannerContainer);
    this.$target.appendChild(this.$nextBtn);
  }

  // 1. 데이터 중, 5개만 잘라서 배너에 렌더링
  setBannerImg() {
    if (this.data) {
      this.$bannerInner.innerHTML = this.data
        .slice(0, 5)
        .map(
          (cat) => `
          <div class="banner-item">
             <img class="lazy-img" title="${cat.name}" src="${cat.url}" alt="${cat.name}" />
          </div>
        `
        )
        .join("");
    }

    // 2. 배너 전체의 가로 길이를 페이지 개수에 맞게 조정
    // 5개를 배치할 것이기 때문에 500%로 지정된다.
    this.$bannerInner.style.width = `${this.totalPages * 100}%`;
  }
 
  // 1개 씩 이동하도록 translateX 설정
  // ex. 5개중 1개를 오른쪽으로 이동 시, x축 -20% 이동
  updateBannerPosition() {
    this.$bannerInner.style.transform = `translateX(-${
      (this.currentIndex * 100) / this.totalPages
    }%)`;
  }

  addEvent() {
    // 마지막 페이지가 아닐 때, 다음 버튼 클릭 시 index+1 후 translateX 설정
    this.$nextBtn.addEventListener("click", () => {
      if (this.currentIndex < this.totalPages - 1) {
        this.currentIndex += 1;
        this.updateBannerPosition();
      }
      // 버튼 상태 업데이트
      this.updateButtonState();
    });

    // 첫 페이지가 아닐 때, 이전 버튼 클릭 시 index-1 후 translateX 설정
    this.$prevBtn.addEventListener("click", () => {
      if (this.currentIndex > 0) {
        this.currentIndex -= 1;
        this.updateBannerPosition();
      }
      this.updateButtonState();
    });
    this.updateButtonState();
  }

  // 첫 페이지면 이전 버튼 비활성, 마지막 페이지면 다음 버튼 비활성 
  updateButtonState() {
    this.$prevBtn.disabled = this.currentIndex === 0;
    this.$nextBtn.disabled = this.currentIndex === this.totalPages - 1;
  }
}
/* style.css */
.BannerWrapper {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  margin: 1em auto;
  width: 80%;
  min-width: 500px;
  min-height: 380px;
}

.BannerWrapper button {
  border: none;
  font-size: 3rem;
  cursor: pointer;
  margin: 0 1rem;
}

.BannerWrapper button:disabled {
  color: #aaa;
  cursor: not-allowed;
}

.banner-container {
  width: 100%;
  overflow: hidden;
}

.banner-inner {
  display: flex;
  transition: transform 0.5s ease-in-out;
}

.banner-item {
  /* 부모 요소의 20%를 차지, 1개의 아이템만 보이도록 */
  flex: 20%;
}

.banner-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  max-height: 380px;
}