[프로그래머스 과제관] 고양이 사진 검색 사이트

엘리(Ellie)·2023년 3월 5일
0

프로그래머스 과제관의 고양이 사진 검색 사이트 문제를 풀며 중요하다고 생각하는 문제와 해결 방법을 기록하려고 합니다.
전체 코드는 여기에서 확인할 수 있습니다.

문제 별 해결방법

다크 모드

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

다음과 같은 방법으로 구현 하였습니다.

  1. window.matchMedia('(prefers-color-scheme: dark)').matches 코드를 이용해 현재 OS의 다크모드 활성화 여부를 가져옵니다.
  2. 현재 활성화 여부에 따라 다크모드 체크박스의 checked 상태를 설정합니다.
  3. 체크박스가 checked 상태일 때만 <body> 태그에 dark 클래스를 설정합니다.
constructor() {
  ...
  
  this.currentMode = window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark' : '';
  this.render();
  
  const $checkBox = document.querySelector('.dark-mode-btn');
  $checkBox.checked = this.currentMode === 'dark';
  $checkBox.addEventListener('change', (e) => {
    this.currentMode = e.target.checked ? 'dark' : '';
  	this.render();
  });
}

render() {
  const body = document.querySelector('body');
  body.className = this.currentMode;
}

css 코드

:root {
  --color-bg: white;
  --color-text: black;
}

.dark {
  --color-bg: black;
  --color-text: white;
}

body {
  background: var(--color-bg);
  color: var(--color-text);
  transition: background 300ms ease-in-out, color 300ms ease;
}

모달 열고 닫기에 fade in/out 적용

모달 열 때 fade in

css로 'fade-in' 애니메이션을 만들어서 모달의 animation으로 설정해 줍니다. 이렇게 하면 .ImageInfo 클래스의 요소가 브라우저에 그려질 때 'fade-in' 애니메이션과 함께 그려집니다.

 .ImageInfo {
   animation: fade-in 300ms;
 }
 
 @keyframes fade-in {
   from {
     opacity: 0;
   }
   to {
     opacity: 1;
     display: block;
     visibility: visible;
   }
 }

모달 닫을 때 fade out

'fade-in'과 마찬가지로 'fade-out' 애니메이션을 만들어주고 .ImageInfofadeOut클래스를 가질 때 'fade-out' 애니메이션을 실행하도록 합니다.

animation-fill-mode는 애니메이션 실행과 관련해서 CSS 스타일을 언제까지 적용할 것인지 결정하는 옵션입니다. (MDN 문서)

  • none: 애니메이션 실행 도중에만 스타일을 적용합니다. (기본 값)
  • forwards: 애니메이션 실행 중/후에만 스타일을 적용합니다.
  • backwards: 애니메이션 실행 전/중에만 스타일을 적용합니다.
  • both: 애니메이션 실행 전/중/후 모두 스타일을 적용합니다.

'fade-out'은 모달이 사라진 뒤에 그 상태 그대로 유지되어야 하니 forwards로 설정해 줍니다.

 .ImageInfo.fadeOut {
   animation: fade-out 300ms;
   animation-fill-mode: forwards;
 }
 
 @keyframes fade-out {
   from {
     opacity: 1;
   }
   to {
     opacity: 0;
     display: none;
     visibility: hidden;
   }
 }

이제 모달을 show 할 때는 'fadeOut' 클래스를 지워주고, close 시점에 'fadeOut' 클래스를 추가하면 모달을 닫을 때 'fade-out' 시킬 수 있습니다.

 onShow() {
   this.$imageInfo.classList.remove('fadeOut');
 }

 onClose() {
   this.$imageInfo.classList.add('fadeOut');
 }

이미지 Lazy Loading

Lazy Loading은 <img> 태그의 data-src 속성과 IntersectionObserver를 이용해 구현하였습니다.

  1. lazy loading 대상 <img>lazy 클래스를 설정하고 보여줄 이미지 소스를 data-src에 설정합니다.
    • lazy 클래스는 lazy loading을 적용할 요소 마킹용
    • data-src는 소스 경로 저장용
  2. IntersectionObserver를 다음과 같이 생성합니다.
    • 화면에 보이는 시점에 data-src의 값을 src로 복사해서 이미지 로딩 시작
    • 옵저빙 타겟에서 제거 (lazy 클래스 제거 & unobserve)
  3. img.lazy 요소를 대상으로 생성한 IntersectionObserver를 붙여줍니다.
export default function lazyLoading() {
  const lazyImageObserver = new IntersectionObserver((entries, _) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        let lazyImage = entry.target;
        lazyImage.src = lazyImage.dataset.src;
        lazyImage.classList.remove('lazy');
        lazyImageObserver.unobserve(lazyImage);
      }
    });
  });

  const lazyImages = Array.from(document.querySelectorAll('img.lazy'));
  lazyImages.forEach((lazyImage) => lazyImageObserver.observe(lazyImage));
}

스크롤 페이징(무한 스크롤) 구현

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

무한 스크롤을 구현하기 위해서는 스크롤을 통해 페이지의 끝에 도달했는지를 먼저 알 수 있어야 합니다. 페이지의 끝에 도달했을 때 이벤트를 발생시키는 infiniteScroll() 함수를 아래와 같이 만듭니다.

// infiniteScroll.js
import throttle from './throttle.js';

export default function infiniteScroll(onScrollEnd) {
  window.addEventListener('scroll', () => {
    throttle(() => {
      const endOfPage =
        window.innerHeight + window.pageYOffset >= document.body.offsetHeight;
      if (endOfPage) {
        onScrollEnd();
      }
    }, 1000);
  });
}

여기서 throttle()은 아래와 같이 구현한 유틸 함수입니다. 스크롤 이벤트가 너무 자주 발생하기 때문에 이를 1초에 한 번으로 제한하기 위해 사용했습니다.

// throttle.js
let throttleTimer;
const throttle = (callback, time) => {
  if (throttleTimer) return;
  
  throttleTimer = true;
  setTimeout(() => {
    callback();
    throttleTimer = false;
  }, time);
};

export default throttle;

즉, infiniteScroll()은 스크롤을 했을 때 1초에 한 번씩 페이지 끝에 도달했는지 체크하고, 페이지 끝에 도달했다면 인자로 받은 onScrollEnd 콜백을 호출하는 역할을 합니다.

infiniteScroll()을 이용해 무한 스크롤을 구현해 봅시다.

// SearchResult 클래스
constructor() {
  ...
  
  infiniteScroll(async () => {
    // 페이지 끝에 도달하면 다음 50개의 고양이 데이터를 받아옵니다.
    const nextData = await fetchNextData();
    if (!nextData) return;

    // (*) 새로 들어온 데이터를 HTML 요소로 만들어 검색 결과 리스트 뒤에 추가합니다.
    const { data } = this.state;
    this.$searchResult.innerHTML += this.createCatCards(
      nextData,
      data.length
    );
    this.state.data = [...data, ...nextData];

    // 새로 추가된 아이템의 <img>를 대상으로 Lazy Loading 옵저버를 달아줍니다.
    lazyLoading();
  });
}

// data 리스트를 받아 고양이 카드 요소를 만들어냅니다.
createCatCards(data, startOffset) {
  return data
    .map(
      (cat, i) => `
        <li class="item" title="${cat.name}" data-index=${startOffset + i}>
          <img class="lazy" data-src="${cat.url}" alt="${cat.name}" />
        </li>
      `
    )
    .join('');
}

여기서 (*) 표시한 부분이 중요한데, 기존에 보여주던 고양이 아이템을 유지한 채로 새로운 데이터를 뒤에 붙여서 보여주어야 하기 때문에 this.$searchResult.innerHTML+=로 새로 만든 HTML 요소를 추가해야 합니다.

이미지 배너 구현

요구 사항

  • 현재 검색 결과 목록 위에 배너 형태의 랜덤 고양이 섹션을 추가합니다.
  • 앱이 구동될 때 /api/cats/random50 api를 요청하여 받는 결과를 별도의 섹션에 노출합니다.
  • 검색 결과가 많더라도 화면에 5개만 노출하며 각 이미지는 좌, 우 슬라이드 이동 버튼을 갖습니다.
  • 좌, 우 버튼을 클릭하면, 현재 노출된 이미지는 사라지고 이전 또는 다음 이미지를 보여줍니다. (트랜지션은 선택)

랜덤 고양이 이미지 보여주기

ImageBanner 클래스를 만들어 아래와 같이 구현 해 줍니다.

startPos = 0;

constructor() {
  ...
  
  (async () => {
    // random 고양이 데이터 가져오기
    this.data = await fetchRandomCats();
    // 로딩 텍스트 없애고 이미지 리스트 보여주기
    $loadingText.style.display = 'none';
    $imgList.style.display = 'block';
    this.render();
  })();
}

render() {
  // 받아온 데이터를 5개까지만 이미지 리스트에 표시하기
  this.$imgList.innerHTML = this.data
    .slice(this.startPos, this.startPos + 5)
    .map(
      (cat) =>
        `<li><img src="${cat.url}" alt="${cat.name}" title="${cat.name}" /></li>`
    )
    .join('');
}

좌우 버튼 클릭 이벤트

좌우 버튼을 클릭하면 한 칸씩 이동하며 고양이 이미지를 보여줍니다. (트랜지션은 구현하지 않았습니다.)

$prevButton.addEventListener('click', () => {
  if (this.startPos === 0) return;
  this.startPos -= 1;
  this.render();
});

$nextButton.addEventListener('click', () => {
  if (this.startPos + 5 === this.data.length) return;
    this.startPos += 1;
    this.render();
});

es6 모듈 적용

main.js 스크립트를 아래와 같이 추가해 줍니다.

<script type="module" src="src/main.js" />

이제 당연하다는 듯이 사용했던 외부 클래스를 명시적으로 import 해 주어야 합니다. 여기서 주의할 점은, CRA로 만든 리액트 앱에서는 파일 확장자를 생략해서 import 할 수 있었지만 Vanilla JS에서는 파일 확장자까지 넣어줘야 한다는 점입니다.

import App from './components/App.js';

new App(document.querySelector('#App'));

Event delegation

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

변경 전 코드

$searchResult 하위의 item 클래스를 가지는 모든 요소를 찾아 클릭 이벤트를 설정합니다.

this.$searchResult.querySelectorAll('.item').forEach(($item, index) => {
  $item.addEventListener('click', () => {
    this.onClick(this.data[index]);
  });
});

변경 후 코드

$searchResult 자체에 클릭 이벤트를 설정합니다. e.target.closest를 이용해 클릭된 대상으로부터 가장 가까운 item 클래스를 가진 요소를 찾아 onClick 콜백을 호출합니다.

this.$searchResult.addEventListener('click', (e) => {
  const $searchItem = e.target.closest('.item');
  const { index } = $searchItem.dataset;
  this.onClick(this.data[index]);
});
profile
신기하고 재미있는 것 만들기를 좋아합니다 :)

0개의 댓글