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

[Vanilla-JS] 고양이 사진 검색 사이트😺

by 지공A 2025. 3. 10.

본 과제는 라이브러리나 프레임워크 없이 Vanilla JS(ES6)로만 구현하였으며, 기능 요구 사항의 풀이 과정을 기록하였습니다.

저장소: https://github.com/petitesize/search-cat

 

본 포스팅에서는 과제 문제를 풀이하기 위해 추가적인 공부가 필요한 큰 개념 위주로 정리하였습니다. 세부 문제 풀이는 복기를 목적으로 다음 포스팅에 정리하기로 하고,

시작!


 

1. 시맨틱한 태그 이용하기

출처:[HTML] <section>과 <article>의 차이

 

먼저 시맨틱한 태그를 이용하기 위해, header 태그에 다크모드와 검색창을 넣어주었다.

그 외로는, section, article 태그를 결과창에 넣어주었다.

class App {
  constructor($target) {
    this.$target = $target;

    // 헤더 안에 다크모드와 검색창을 넣어줄 것
    this.$header = document.createElement("header");
    this.$target.appendChild(this.$header);

    this.darkMode = new DarkMode({ $target: this.$header });

    this.searchInput = new SearchInput({
      $target: this.$header,
      onSearch: (keyword) => {
        api.fetchCats(keyword).then(({ data }) => this.setState(data));
      },
    });
    ...

 


2. ES6 module 형태로 코드를 변경하기

ES6 모듈이란?

  • 파일 간의 코드를 독립적으로 관리할 수 있도록 분리하는 방식
  • import & export 문법을 사용하여 모듈을 가져오고 내보낼 수 있음
  • 모든 코드가 전역 스코프에서 실행되지 않고, 필요한 파일에서만 사용 가능
  • 번들러(Webpack, Parcel) 없이도 브라우저에서 type="module"을 사용하면 실행 가능

1. 기존 index.html 코드를 변경해주기

<!-- 기존 방식: 모든 스크립트를 하나의 파일에서 실행 -->
<script src="api.js"></script>
<script src="main.js"></script>
...

<!-- ES6 모듈 적용 -->
<script type="module" src="src/main.js"></script>

 

2. 각 파일에서 export default로 내보내고, import를 사용하여 필요한 파일에서 가져오기

...
export default api; // 모듈 내보내기

--------------------------------------------------

import api from "./api.js"; // API 모듈 가져오기
...

 


3. 유저가 사용하는 디바이스의 가로 길이에 따라 검색결과의 row당 column 개수를 적절히 변경하기

  .SearchResult {
    display: grid;
    grid-template-columns: repeat(4, minmax(250px, 1fr));
  }

 

위의 코드를 변경해야 하는데, 처음엔 미디어 쿼리를 이용해서 했지만, auto-fit 을 사용하면 훨씬 간결하게 표현할 수 있다!

미디어 쿼리 코드를 주석으로 표시해보니, 코드가 얼마나 간결해졌는지 알 수 있다.

  .SearchResult {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
    /* @media screen and (max-width: 992px) {
      grid-template-columns: repeat(3, minmax(250px, 1fr));
    }
    @media screen and (max-width: 768px) {
      grid-template-columns: repeat(2, minmax(250px, 1fr));
    }
    @media screen and (max-width: 576px) {
      grid-template-columns: repeat(1, minmax(250px, 1fr));
    } */
  }

 


4. 다크모드 구현하기

기본적으로는 OS의 다크모드의 활성화 여부를 기반으로 동작하게 하되, 
유저가 테마를 토글링 할 수 있도록 좌측 상단에 해당 기능을 토글하는 체크박스를 만듭니다.

 

먼저, 다크 모드 ON/OFF를 구현하기 위해 .dark-mode 클래스를 토글하는 방식을 사용하였다.

하지만, 이 방식은 OS의 다크 모드가 활성화된 상태에서는 다크 모드를 OFF할 수 없는 문제가 발생했다.

 

사용자의 OS 방식을 우선하되, 사용자가 변경 가능한 방법을 찾다가 data-theme 속성을 사용하면 쉽게 해결 가능한 것을 발견하였다.

그래서 data-theme란?

data-theme는 HTML 요소에 다크 모드같은 테마를 지정할 때 사용하는 커스텀 속성이다.
브라우저가 기본적으로 제공하는 속성이 아니라, 개발자가 자유롭게 정의하고 활용할 수 있는 데이터 속성(data-*)이라고 한다.

 

1. 먼저 css에 data-theme의 각 속성에 따른 css를 지정한다. 나는 dark에 다크모드 light에 라이트모드를 지정해줬다.

  @media (prefers-color-scheme: dark) {
  :root {
    --bg-color: black;
    --text-color: white;
  }
  }
  [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);
  }

 

2. 그리고, OS에 설정된 다크모드가 있는지 확인하고, 있다면 그 값을 data-theme에 지정해준다.

또한, 체크박스 이벤트리스너에 체크 활성화 여부에 따라 data-theme를 바꿔준다.

✅ OS 다크모드는 어떻게 체크하는데?

window.matchMedia("(prefers-color-scheme: dark)")는 사용자의 OS에서 다크 모드를 활성화했는지 여부를 확인하는 코드다.

여기서 .matches 속성을 사용하면 dark 모드에 따라 true 또는 false를 반환하므로 이를 이용해서 OS 다크모드를 확인했다.

setTheme() {
    const userTheme = localStorage.getItem("theme");

    // 2. OS 다크모드 확인
    // theme: userTheme가 있다면 그 값(dark or light), 없다면 os값을 data-set theme에 지정해줌
    const theme =
      userTheme ||
      (window.matchMedia("(prefers-color-scheme: dark)").matches
        ? "dark"
        : "light");
    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);
    });
  }

 

💡 다크 모드 사용자 경험(UX) 개선 아이디어

1. 테마 전환 시 부드러운 애니메이션 적용 (ease 효과 추가)

  • 구현하면서 단순 흑백 컬러를 반복 전환하다 보니 눈에 피로감이 상당했다. OS 다크모드가 변경되는 것 처럼 전환 시 ease 효과를 주면 눈에 피로감이 덜 할 것 같다.

2. 사용자의 테마 설정을 localStorage에 저장하여 새로고침 후에도 유지

3. 자동 다크 모드 기능 제공 (스케줄 설정)

  • 네비게이션처럼 6시, 18시를 기준으로 자동 다크모드 ON/OFF 가 전환되는 아이디어
  • 기존 다크모드 사용자들은 낮에도 다크모드를 유지하고 싶은 사람도 많을 것으로 예상됨

4. 테마 변경 시 아이콘 추가 (직관적인 UX)

  • ☀️,🌙 같이 아이콘으로 직관적으로 다크모드 유무를 보여줘도 좋을 것 같다.

 


5. lazy load(레이지 로드)

Lazy Load란?

💡 Lazy Load(=지연 로딩)은 필요할 때만 데이터를 불러오는 기법이다.
💡 특히 이미지 같은 무거운 리소스를 한꺼번에 로딩하지 않고, 사용자가 화면을 스크롤할 때 필요한 것만 로딩하도록 최적화하는 방식이다.

👉 현재, 고양이 사진을 한 번에 매우 많이 불러오고 있기 때문에 lazy load를 적용하라는 것 같다.

 

📌 페이지 로딩 속도를 빠르게 하기 위해 사용
📌 불필요한 네트워크 요청을 줄여 성능을 개선
📌 사용자가 보지 않는 이미지는 불러오지 않기 때문에 렌더링 속도가 향상됨

 

Lazy Load를 적용하는 방법

1️⃣ HTML에서 loading="lazy" 속성을 사용하기
2️⃣ Intersection Observer API를 활용하여 직접 구현하기

 

💡getBoundingClientRect() 사용하기?

  • getBoundingClientRect() 또한 lazy load의 방법 중 하나이다.
  • 구형 브라우저도 지원하나, 성능면에서 좋지 않다.
  • lazy load는 성능 개선의 목적 또한 가지고 있기에, 성능이 저하되는 방법인 것 같아 자세히 알아보지 않았다.
  • 하지만 Intersection Observer API를 아직 외워서 하기에는 무리가 있기 때문에 사용하는 법만 알아보았다.
  • src 속성을 data-src 속성으로 바꾸는건 동일하고, 아래 스크롤 이벤트만 추가하면 된다.
window.addEventListener("scroll", function () {
  document.querySelectorAll(".lazy-img").forEach((img) => {
    if (img.dataset.src && img.getBoundingClientRect().top < window.innerHeight) {
      img.src = img.dataset.src;
      img.removeAttribute("data-src");
    }
  });
});

 

1️⃣ loading="lazy" 속성 사용하기

📌 img 태그에 loading="lazy" 속성을 추가하면 브라우저가 자동으로 레이지 로드를 적용한다.

<img src="https://example.com/cat.jpg" alt="고양이" loading="lazy" />

 

사파리 구버전에서 지원되지 않을 수 있지만, 현재(2024년)를 기준으로는 웬만한 최신 브라우저에서는 모두 적용이 가능하다.

 

2022년 이후로는 최신 사파리에서도 적용 가능한 것을 확인할 수 있다.

참고: https://caniuse.com/?search=loading%20lazy

 

  •  iOS Safari 15.0+ (2021년 이후 버전)
  •  macOS Safari 16.0+ (2022년 이후 버전)

 사파리 구버전에서 지원되지 않을 수 있지만, 현재(2024년)를 기준으로는 웬만한 최신 브라우저에서는 모두 적용이 가능하다.

이를 사용하기 위해서는 html에 width, height를 지정해주자. 여기서부터 좋은 방법인지 의심이 든다.

✔ 하지만 후에 스크롤 페이징을 구현해야 하기 때문에 2번 방법을 써보기로 한다!

 

2️⃣ Intersection Observer API를 활용하여 Lazy Load 직접 구현하기

https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API

 

Intersection Observer API - Web API | MDN

Intersection Observer API는 상위 요소 또는 최상위 문서의 viewport와 대상 요소 사이의 변화를 비동기적으로 관찰할 수 있는 수단을 제공합니다.

developer.mozilla.org

 

📌 Intersection Observer API는 특정 요소가 화면에 나타나는지를 감지하는 기능을 제공함.
✔ 이미지가 화면에 보일 때만 src 속성을 설정하여 이미지를 로드하는 방식으로 최적화 가능.
✔ 구형 브라우저에서도 사용할 수 있음

 

1. 기본적으로 img 태그의 src 속성을 설정하지 않고 data-src 속성으로 이미지 경로를 저장
2. 화면에 보이지 않는 이미지들은 로드되지 않음
3. Intersection Observer API가 동작하여 사용자가 스크롤하면 이미지가 보이는 시점에 src 속성을 설정하여 로드됨
4. 한 번 로드된 이미지는 더 이상 감지할 필요가 없으므로 observer.unobserve(img);를 사용하여 감지 해제

class SearchResult {
  constructor({ $target, initialData }) {
	...
    this.observer = new IntersectionObserver(this.handleLazyLoad.bind(this), {
      root: null, //viewport
      rootMargin: "0px",
      threshold: 0.1, // 10% 이상 보일 때 로드
    });
    this.render();
  }

  handleLazyLoad = (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src; // 실제 이미지 로드
        observer.unobserve(img); // 로딩 후 감지 중지
      }
    });
  };
  
  ...

  render() {
    this.$searchResult.innerHTML = this.data
      .map(
        (cat) => `
      <div class="item">
        <img class="lazy-img" data-src="${cat.url}" alt="${cat.name}" />
      </div>
    `
      )
      .join("");

    // Lazy Load 적용
    document.querySelectorAll(".lazy-img").forEach((img) => {
      this.observer.observe(img);
    });
  }
}

 


6. Event Delegation(이벤트 위임) 기법

이벤트 위임(Event Delegation)은 부모 요소에 이벤트 리스너를 등록하여 자식 요소의 이벤트를 처리하는 기법이다.

  • 여러 개의 자식 요소 각각에 이벤트 리스너를 붙이는 대신, 공통된 부모 요소에 이벤트 리스너를 하나만 붙이는 방식
  • 부모 요소에 이벤트 리스너를 등록하고, 이벤트가 버블링을 통해 전달되도록 하는 방식
  • 성능 최적화와 유지보수성이 뛰어난 방법으로, 특히 동적 요소 추가가 많은 경우에 필수적인 기법

❌ 이벤트를 각각 등록하는 방식 (비효율적인 방식)

const buttons = document.querySelectorAll(".button");
buttons.forEach((button) => {
  button.addEventListener("click", (event) => {
    console.log(`${event.target.textContent} 버튼 클릭됨!`);
  });
});

/** 이 프로젝트에서 **/
addEvent() {
  this.$target.querySelectorAll(".item").forEach(($item, index) => {
    $item.addEventListener("click", () => {
      this.onClick(this.data[index]);
    });
  });
}

 

  • .button 요소가 많아질수록 addEventListener가 여러 번 실행되면서 메모리 낭비가 발생함.
  • 새로운 .button 요소가 동적으로 추가되면, 기존에 등록된 이벤트 리스너가 적용되지 않음.

✅ 이벤트 위임(Event Delegation) 사용 (효율적인 방식)

document.querySelector(".button-container").addEventListener("click", (event) => {
  if (event.target.classList.contains("button")) {
    console.log(`${event.target.textContent} 버튼 클릭됨!`);
  }
}); 

/** 이 프로젝트에서 **/
addEvent() {
  this.$target.addEventListener("click", (event) => {
    const $clickedItem = event.target.closest(".item"); // 클릭한 요소의 가장 가까운 .item 찾기
    if (!$clickedItem) return; // .item이 아닌 곳을 클릭하면 무시

    // 클릭한 .item 요소의 인덱스 찾기
    const index = Array.from(this.$target.querySelectorAll(".item")).indexOf($clickedItem);

    if (index !== -1) {
      this.onClick(this.data[index]); // 해당 인덱스를 사용해 데이터 전달
    }
  });
}

 

 

1️⃣ 이벤트 리스너를 부모 요소에 하나만 추가 → 성능 최적화
2️⃣ 동적으로 추가된 요소도 자동으로 이벤트 적용 가능
3️⃣ DOM 요소가 많아질수록 효율적인 방식

 


😺완성!😺

 


테스트 후기

상세 기능을 한 번씩 직접 구현해본 뒤, 4시간짜리 테스트를 보았다.

필수 기능들은 쉽게 구현할 수 있었고, 다크모드 등 새롭게 공부한 개념들은 바로 구현하기 쉽지 않아서 시간이 부족했다.

또, lazy load는 loading="lazy"로 해결하였고, 스크롤 페이징까진 구현하지 못했다..

랜덤 배너섹션은 시간이 부족해서 구현하지 못했는데, 트랜지션을 제외하고는 시간 분배를 좀 더 했다면 구현 자체는 하기 쉬웠을 것 같다!

요약하자면, 스크롤 페이징과 랜덤 배너섹션 외에는 구현은 했지만 잘 써보지 않은 기본 내장 함수들을 생각하는 데에 시간을 예상보다 더 소모했기 때문에 기초를 다지고 구현하지 못했던 부분은 로직을 한 번 더 공부해보면 좋을 것 같다!