Algorithm/과제

[과제] FE - 고양이 사진 검색 사이트

by somida 2021. 5. 13.

[프론트엔드] 고양이 사진 검색 사이트

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

더보기

주제(시나리오)

고양이를 좋아하는 당신은 고양이 사진 전용 검색 웹사이트를 운영하고 있었습니다. 지금까지는 혼자 소소하게 운영해왔는데, 생각보다 고양이 사진을 원하는 사람들이 많아지면서 해결해야 할 문제들이 하나씩 드러나기 시작했어요. 몇 개의 문제는 금세 고칠 수 있지만, 기존 코드를 자세히 봐야만 고칠 수 있는 문제들도 있어서 조금 골치아픈 상황! 심지어 최대 4시간 내에 수정한 뒤 배포를 해야만 합니다. 당신이라면 기존 서비스의 여러 버그를 제한시간 내에 고치고, 유저를 위한 추가 기능까지 구현해볼 수 있을까요? 도전해보세요!

과제 설명

  • thecatapi 에서 크롤링한 데이터를 이용해 이미지를 검색하는 베이스 코드가 주어집니다.
  • 베이스 코드는 모두 ES6 클래스 기반으로 작성되어 있으며, 이 코드에는 여러 개의 버그가 존재합니다. 요구사항을 잘 읽고, 버그를 하나씩 해결해주세요.

수행 기술

  • JavaScript(ES6)
  • 설치되어있는 모듈(node_modules) 외에 다른 외부 라이브러리는 사용하지 않도록 합니다. 예를들어 jQuery, Webpack, Lodash, Axios, Angular, React, Vue, Immutable-js, Ramda 등을 사용할 수 없습니다.

요구사항

참고 요구사항의 순서는 난이도와 상관이 없음

 

HTML, CSS 관련

  • 현재 HTML 코드가 전체적으로 <div> 로만 이루어져 있습니다. 이 마크업을 시맨틱한 방법으로 변경해야 합니다.
  • 유저가 사용하는 디바이스의 가로 길이에 따라 검색결과의 row 당 column 갯수를 적절히 변경해주어야 합니다.
    • 992px 이하: 3개
    • 768px 이하: 2개 
    • 576px 이하: 1개
/* style.css */
/* 기본 */
.SearchResult {
  margin-top: 10px;
  display: grid;
  grid-template-columns: repeat(4, minmax(250px, 1fr));
  grid-gap: 10px;
}

/* 1. HTML, CSS 관련 - (2) 반응형 */
@media (max-width:992px) {
  .SearchResult {
    grid-template-columns: repeat(3, minmax(250px, 1fr));
  }
}

@media (max-width:768px) {
  .SearchResult {
    grid-template-columns: repeat(2, minmax(250px, 1fr));
  }
}

@media (max-width:576px) {
  .SearchResult {
    grid-template-columns: repeat(1, minmax(250px, 1fr));
  }
}
  • 다크 모드(Dark mode)를 지원하도록 CSS를 수정해야 합니다.
    • CSS 파일 내의 다크 모드 관련 주석을 제거한 뒤 구현합니다.
    • 모든 글자 색상은 #FFFFFF , 배경 색상은 #000000 로 한정합니다.
    • 기본적으로는 OS의 다크모드의 활성화 여부를 기반으로 동작하게 하되, 유저가 테마를 토글링 할 수 있도록 좌측 상단에 해당 기능을 토글하는 체크박스를 만듭니다.
/* style.css */

.dark {
  background-color: #000;
  color:white;
}

body.dark .ImageInfo .content-wrapper {
  background-color: #000;
  color: white;
}

.light {
  background-color: white;
  color:#000;
}

body.light .ImageInfo .content-wrapper {
  background-color: white;
  color:#000;
}

/* dark mode 처리 */
@media (prefers-color-scheme: dark) {
  body {
    background-color: #000;
    color: white;
  }
  .ImageInfo .content-wrapper {
    background-color: #000;
    color: white;
  }
}

@media (prefers-color-scheme: light) {
  body {
    background-color: white;
    color: #000;
  }
  .ImageInfo .content-wrapper {
    background-color: white;
    color: #000;
  }
}
// SearchInput.js
class SearchInput {
	constructor({ $target, ... {
      ...
      // 1. HTML, CSS 관련 - (3) 다크 모드
      const $themeMode = document.createElement("input");
      $themeMode.type = "checkbox";

      $themeMode.addEventListener("click", () => {
        const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
        if (theme && !$themeMode.checked) {
          document.body.classList.remove("light")
          document.body.classList.add("dark")
        } else if (theme && $themeMode.checked) {
          document.body.classList.remove("dark")
          document.body.classList.add("light")
        }
      })
      ...
      
      $searchHeader.appendChild($themeMode)
    }
}

 

이미지 상세 보기 모달 관련

  • 디바이스 가로 길이가 768px 이하인 경우, 모달의 가로 길이를 디바이스 가로 길이만큼 늘려야 합니다.
/* style.css */
@media (max-width:768px) {
  ...
  /* 2.이미지 상세 보기 모달 관련 - (1) */
  .ImageInfo .content-wrapper{
    width : 100%
  }
}
  • 필수 이미지를 검색한 후 결과로 주어진 이미지를 클릭하면 모달이 뜨는데, 모달 영역 밖을 누르거나 / 키보드의 ESC 키를 누르거나 / 모달 우측의 닫기(x) 버튼을 누르면 닫히도록 수정해야 합니다.
// ImageInfo.js
class ImageInfo {
	...
	render() {
    	if (this.data.visible) {
        	...
            // 2. 이미지 상세 보기 모달 관련 - (2) 모달 영역 밖, 모달 우측의 닫기(X) 버튼
            document.addEventListener("click", e => {
              if (e.target == document.querySelector(".ImageInfo") || e.target === document.querySelector(".close")) {
                this.$imageInfo.style.display = "none";
              }
            })

            // 2. 이미지 상세 보기 모달 관련 - (2) ESC키 누르기
            document.addEventListener("keydown", e => {
              if (e.keyCode == 27) {
                this.$imageInfo.style.display = "none";
              }
            })
        }
    }
}
  • 모달에서 고양이의 성격, 태생 정보를 렌더링합니다. 해당 정보는 /cats/:id 를 통해 불러와야 합니다.
// api.js
...
const api = {
	...
    // 2. 이미지 상세 보기 모달 관련 - (3) 모달에서 정보 렌더링
    fetchCatDetails: id => {
      return fetch(`${API_ENDPOINT}/api/cats/${id}`).then(res => res.json());
    }
}
// App.js
class App {
	...
    this.searchResult = new SearchResult({
      ...
      onClick: image => {

        // 2. 이미지 상세 보기 모달 관련 - (3) 모달 고양이 정보 랜더링
        api.fetchCatDetails(image.id).then(({data}) => 
          this.imageInfo.setState({
            visible: true,
            image: data
          })
        )
      }
    });
}
  • 추가 모달 열고 닫기에 fade in/out을 적용해 주세요.
/* style.css */
/* 2. 이미지 상세 보기 모달 관련 - (5) fade-in fade-out  */
.ImageInfo {
  ...
  animation: fadein 0.5s;
  -moz-animation: fadein 0.5s; /* Firefox */
  -webkit-animation: fadein 0.5s; /* Safari and Chrome */
  -o-animation: fadein 0.5s; /* Opera */
}

@keyframes fadein {
    from {
        opacity: 0;
    }
    to {
        opacity: 1;
    }
}
@-moz-keyframes fadein { /* Firefox */
    from {
        opacity: 0;
    }
    to {
        opacity: 1;
    }
}
@-webkit-keyframes fadein { /* Safari and Chrome */
    from {
        opacity: 0;
    }
    to {
        opacity: 1;
    }
}
@-o-keyframes fadein { /* Opera */
    from {
        opacity: 0;
    }
    to {
        opacity: 1;
    }
}

.ImageInfo.fade {
  animation: fadeout 1s;
  -moz-animation: fadeout 1s; /* Firefox */
  -webkit-animation: fadeout 1s; /* Safari and Chrome */
  -o-animation: fadeout 1s; /* Opera */
  animation-fill-mode: forwards;
}

@keyframes fadeout {
  from {
      opacity: 1;
  }
  to {
      opacity: 0;
      display:none;
      visibility: hidden;
  }
}
@-moz-keyframes fadeout { /* Firefox */
  from {
      opacity: 1;
  }
  to {
      opacity: 0;
      display:none;
      visibility: hidden;
  }
}
@-webkit-keyframes fadeout { /* Safari and Chrome */
  from {
      opacity: 1;
  }
  to {
      opacity: 0;
      visibility: hidden;
      display:none;
  }
}
@-o-keyframes fadeout { /* Opera */
  from {
      opacity: 1;
  }
  to {
      opacity: 0;
      visibility: hidden;
      display:none;
  }
}
// ImageInfo.js
class ImageInfo {
  ...
  render() {
    if (this.data.visible) {
      this.$imageInfo.classList.remove("fade")  
      
      ...

      document.addEventListener("click", e => {
        if (e.target == document.querySelector(".ImageInfo") || e.target === document.querySelector(".close")) {
          // 2. 이미지 상세 보기 모달 관련 - (4) fade-in fade-out
          this.$imageInfo.classList.add("fade")
        }
      })

      document.addEventListener("keydown", e => {
        if (e.keyCode == 27) {   
          // 2. 이미지 상세 보기 모달 관련 - (4) fade-in fade-out
          this.$imageInfo.classList.add("fade")  
        }
      })
    } else {
      this.$imageInfo.style.display = "none";
    }
  }
}

 

검색 페이지 관련

  • 페이지 진입 시 포커스가 input 에 가도록 처리하고, 키워드를 입력한 상태에서 input 을 클릭할 시에는 기존에 입력되어 있던 키워드가 삭제되도록 만들어야 합니다.
// SearchInput.js
class SearchInput {
  constructor({ $target, onSearch }) {
    ...

    // 3. 검색 페이지 관련 - (1) 페이지 진입 시 포커스가 input에 가도록
    $searchInput.autofocus = "true";
    
    // 3. 검색 페이지 관련 - (1) 키워드를 입력한 상태에서 input을 클릭할 때
    $searchInput.addEventListener("click", e => {
      if (e.target.value.length > 0) {
        e.target.value = '';
      }
    })
    
    ...
  }
  render() {}
}
  • 필수 데이터를 불러오는 중일 때, 현재 데이터를 불러오는 중임을 유저에게 알리는 UI를 추가해야 합니다.
// App.js
class App {
  ...
  constructor($target) {
    ...
    this.searchInput = new SearchInput({
      $target,
      onSearch: keyword => {
        // 3. 검색 페이지 관련 - (2) 로딩 중
        this.setState({
          data: null,
          loading: false
        })

        // 3. 검색 페이지 관련 - (2) 로딩 완료
        api.fetchCats(keyword).then(({ data }) => this.setState({
          data,
          loading: true
        }));
      }
    });
	...
  }
}
// SearchResult.js
class SearchResult {
  ...
  
  loading = false;
  
  ...
  
  setState(nextData) {
    this.data = nextData.data;
    this.loading = nextData.loading;
    ...
  }

  render() {
    if (this.loading) {
      ...
    } 
    // 3. 검색 페이지 관련 - (2) 데이터를 불러오는 중일 때, 현재 데이터를 불러오는 중
    else if (!this.loading && this.data === null) {
      this.$searchResult.innerHTML = `
        <h3>Loading...</h3>
      `
    } 
    // 3. 검색 페이지 관련 - (2) 데이터를 불러오지 않고, 첫 화면일 때는 Loding이 아닌 빈 화면
    else if (!this.loading && this.data === []) {
      this.$searchResult.innerHTML = ``
    }
  }
}
  • 필수 검색 결과가 없는 경우, 유저가 불편함을 느끼지 않도록 UI적인 적절한 처리가 필요합니다.
// SearchResult.js
class SearchResult {
  ...

  render() {
    if (this.loading) {
      // 3. 검색 페이지 관련 - (3) 검색 결과가 없을 때
      if (!this.data.length) {
      	this.$searchResult.innerHTML = `
          <h3>검색 결과가 없습니다.</h3>
        `
      } else {
      	...
      }
      ...
    } 
    ...
  }
}
  • 최근 검색한 키워드를 SearchInput 아래에 표시되도록 만들고, 해당 영역에 표시된 특정 키워드를 누르면 그 키워드로 검색이 일어나도록 만듭니다. 단, 가장 최근에 검색한 5개의 키워드만 노출되도록 합니다.
// SearchInput.js
class SearchInput {
  lastKeywords = [];
  constructor({ $target, onClick, onSearch }) {
    ...

    // 3. 검색 페이지 관련 - (4) 최근 검색한 키워드 
    const $lastKeywords = document.createElement("div");
    $lastKeywords.className = "LastKeywords";

    $searchInput.addEventListener("keyup", e => {
      if (e.keyCode === 13) {
        const keyword = e.target.value
        onSearch(keyword)
        // 3. 검색 페이지 관련 - (4) 최근 검색한 키워드 5개 표시
        this.$searchInput.value = ''
        this.set_lastKeywords(keyword, 1)
        this.get_lastKeywords($lastKeywords)
      }
    })

    // 3. 검색 페이지 관련 - (4) 키워드 선택했을 때 검색되는 것
    $lastKeywords.addEventListener("click", e => {
      if (e.target.nodeName === "BUTTON") {
        onSearch(e.target.innerText)
        this.$searchInput.value = ''
        this.set_lastKeywords(e.target.innerText, 2)
        this.get_lastKeywords($lastKeywords)
      }
    })

    // 3. 검색 페이지 관련 - (4) 새로고침 했을 때 최근 검색한 키워드 표시
    this.get_lastKeywords($lastKeywords)

    $target.appendChild($lastKeywords);

    ...
  }

  // 3. 검색 페이지 관련 - (4) 세션스토리지에 최근 검색한 키워드 저장
  set_lastKeywords(key, ver) {
    // 3. 검색 페이지 관련 - (4) 최근 검색한 키워드 직접 입력 검색할 때
    if (ver === 1) {
      if (this.lastKeywords.length == 5) {
        this.lastKeywords.shift();
      }
      this.lastKeywords.push(key);
      sessionStorage.setItem("keywords", JSON.stringify(this.lastKeywords));
    } 
    // 3. 검색 페이지 관련 - (4) 최근 검색한 키워드 선택해서 검색할 때
    else {
      this.lastKeywords = this.lastKeywords.filter(e => e != key)
      this.lastKeywords.push(key);
      sessionStorage.setItem("keywords", JSON.stringify(this.lastKeywords));
    }
  }

  // 3. 검색 페이지 관련 - (4) 세션 스토리지의 키워드 가져오기
  get_lastKeywords(lastkeywords) {
    while (document.querySelector(".Keyword")) {
      const tmp_last = document.querySelector(".Keyword")
      console.log(tmp_last)
      lastkeywords.removeChild(tmp_last)
    }
    const keywords = JSON.parse(sessionStorage.getItem("keywords"));
    if (keywords) {
      this.lastKeywords = keywords;
      for (var i=0;i<this.lastKeywords.length; i++) {
        const $keyword = document.createElement("button");
        $keyword.className = "Keyword";
        $keyword.innerText = this.lastKeywords[i];
        lastkeywords.appendChild($keyword);
      }
    }
  }

  render() {}
}
  • 페이지를 새로고침해도 마지막 검색 결과 화면이 유지되도록 처리합니다.
// App.js
class App {
  ...

  setState(nextData) {
    ...
    // 3. 검색 페이지 관련 - (5) sessionstorage에 저장
    if (this.data.data) sessionStorage.setItem("data", JSON.stringify(this.data.data));
  }
}
// SearchResult.js
class SearchResult {
  ...
  constructor({ $target, initialData, onClick }) {
    ...
    // 3. 검색 페이지 관련 - (5) 새로고침해도 유지(sessionData)
    this.get_sessionData();
    ...
  }

  // 3. 검색 페이지 관련 - (5) 새로고침해도 유지(sessionData)
  get_sessionData() {
    const data = JSON.parse(sessionStorage.getItem("data"));
    if (data) {
      this.setState({
        data,
        loading:true
      })
    }
  }
  ...
}
  • 필수 SearchInput 옆에 버튼을 하나 배치하고, 이 버튼을 클릭할 시 /api/cats/random50 을 호출하여 화면에 뿌리는 기능을 추가합니다. 버튼의 이름은 마음대로 정합니다.
// SearchInput.js
class SearchInput {
  constructor({ $target, onSearch, onClick }) {
    // 3. 검색 페이지 관련 - (6) 랜덤 버튼 만들기(header로 input과 button 감싸기)
    const $searchHeader = document.createElement("header");
    $searchHeader.className = "SearchHeader";

    ...
    
    $searchHeader.appendChild($searchInput);
	
    // 3. 검색 페이지 관련 - (6) 랜덤 버튼 만들기
    const $randomButton = document.createElement("button");
    $randomButton.innerText = "랜덤";
    $randomButton.className = "RandomButton";
    $searchHeader.appendChild($randomButton);

    $target.appendChild($searchHeader);

    ...
	
    // 3. 검색 페이지 관련 - (6) 랜덤 버튼 클릭 이벤트
    $randomButton.addEventListener("click", () => onClick());

  }
  render() {}
}
/* style.css */
/* 3. 검색 페이지 관련 - (6) 랜덤 버튼 */
.SearchInput {
  display: inline-block;
  width: 80%;
  margin-right: 10px;
  padding: 10px 15px;
}

/* 3. 검색 페이지 관련 - (6) 랜덤 버튼 */
.RandomButton {
  display: inline-block;
  padding: 10px 15px;
}
// api.js
const api = {
  ...
  // 3. 검색 페이지 관련 - (6) 랜덤 고양이 50개
  fetchRandomCat: () => {
    return fetch(`${API_ENDPOINT}/api/cats/random50`).then(res => res.json());
  }
};
// App.js
class App {
  ...
  constructor($target) {
    this.$target = $target;
    this.searchInput = new SearchInput({
      $target,
      ...
      
      // 3. 검색 페이지 관련 - (6) 랜덤 버튼
      onClick: () => {
        // 로딩 중
        this.setState({
          data:null,
          loading:false
        })
        /// 로딩 완료
        api.fetchRandomCat().then(({data}) => this.setState({
          data,
          loading: true
        }))
      }
    });
    
    ...
    
  }
  ...
}
  • lazy load 개념을 이용하여, 이미지가 화면에 보여야 할 시점에 load 되도록 처리해야 합니다.
  • 추가 검색 결과 각 아이템에 마우스 오버시 고양이 이름을 노출합니다.
// SearchResult.js
class SearchResult {
  ...

  render() {
    console.log(this.data, this.loading)
    if (this.loading) {
      ...
      } else {
        this.$searchResult.innerHTML = this.data
          .map(
            // 3. 검색 페이지 관련 - (8) mouse over시 고양이 이름 노출
            cat => `
              <div class="item" title=${cat.name}>
                <img src=${cat.url} alt=${cat.name} />
              </div>
            `
          )
          .join("");
    
        ...
      }
    } 
	...
  }
}

 

스크롤 페이징 구현

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

 

랜덤 고양이 배너 섹션 추가

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

 

코드 구조 관련

  • ES6 module 형태로 코드를 변경합니다.
    • webpack , parcel 과 같은 번들러를 사용하지 말아주세요.
    • 해당 코드 실행을 위해서는 http-server 모듈을(로컬 서버를 띄우는 다른 모듈도 사용 가능) 통해 index.html 을 띄워야 합니다.
  • API fetch 코드를 async , await 문을 이용하여 수정해주세요. 해당 코드들은 에러가 났을 경우를 대비해서 적절히 처리가 되어있어야 합니다.
  • 필수 API 의 status code 에 따라 에러 메시지를 분리하여 작성해야 합니다. 아래는 예시입니다.
더보기

const request = async (url: string) => {
    try {
        const result = await fetch(url);
        return result.json();
    } catch (e) {
        console.warn(e);
    }
}

const api = {
    fetchGif: keyword => {
        return request(`${API_ENDPOINT}/api/gif/search?q=${keyword}`);
    },
    fetchGifAll: () => {
        return request(`${API_ENDPOINT}/api/gif/all`);
    }
};

// 6. 코드 구조 관련 - (2, 3) async, await 문을 이용해 수정, Status Code에 따라 에러 메세지 분리
const request = async (url) => {
  try {
    const result = await fetch(url);
    if (result.status < 300) return result.json()
    else if (result.status < 400) return console.warn(`Redirection Error Code ${result.status}`)
    else if (result.status < 500) return console.warn(`Client Error Code ${result.status}`)
    else if (result.status < 600) return console.warn(`Server Error Code ${result.status}`)
  } catch(err) {
    console.warn(err)
  }
}

const api = {
  // 고양이 검색
  fetchCats: (keyword) => {
    return request(`${API_ENDPOINT}/api/cats/search?q=${keyword}`);
  },
  // 2. 이미지 상세 보기 모달 관련 - (3) 모달에서 정보 렌더링
  fetchCatDetails: (id) => {
    return request(`${API_ENDPOINT}/api/cats/${id}`);
  },
  // 3. 검색 페이지 관련 - (6) 랜덤 고양이 50개
  fetchRandomCat: () => {
    return request(`${API_ENDPOINT}/api/cats/random50`);
  }
};
  • SearchResult 에 각 아이템을 클릭하는 이벤트를 Event Delegation 기법을 이용해 수정해주세요.
  • 컴포넌트 내부의 함수들이나 Util 함수들을 작게 잘 나누어주세요.

 

더보기

API

1. GET /cats/random50

Request parameter

None

 

Query paramter

None

 

Response

Success 200

Field name Type Description
data Array 랜덤한 50개의 고양이 사진 목록입니다.
HTTP/1.1 200 OK
{
  "data": [{
    id: string
    url: string
    name: string
  }]
}

 

2. GET /cats/search

Request parameter

None

 

Query paramter

Field name Type Description
q string 고양이의 품종(영어/한글)

Response

Success 200

Field name Type Description
data Array Keyword로 검색된 고양이 사진 목록입니다.
HTTP/1.1 200 OK
{
  "data": [{
    id: string
    url: string
    name: string
  }]
}

 

3. GET /cats/:id

Request parameter

Field name Type Description
id string 고양이 사진의 id값 입니다.

 

Query paramter

None

 

Response

Success 200

Field name Type Description
data Object Id로 검색된 고양이 사진 입니다.
HTTP/1.1 200 OK
{
  "data": {
    name: string
    id: string
    url: string
    width: number
    height: number
    temperament: string
    origin: string
  }
}

 


시멘틱웹

"의미론적인 웹"

NON-시멘틱 구조 태그

<div id="header">헤더입니다.</div>

시멘틱 구조 태그

<header>헤더입니다</header>
  • 거의 같은 결과를 나타내지만, 시멘틱 구조의 경우 개발자가 의도한 요소의 의미가 명확해 코드의 가독성 증가

 

 

 


References

 

반응형

'Algorithm > 과제' 카테고리의 다른 글

[과제] FE - 고양이 사진첩 애플리케이션  (1) 2021.05.15

댓글