본문 바로가기

TOPIC

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

반응형

https://programmers.co.kr/skill_check_assignments/4

 

프로그래머스

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

programmers.co.kr

 

과제 전형 문제인데 알고리즘과 다르게 정답은 없고 주어진 시간안에 해당 요구조건을 충족시키는 문제이다.

깃허브에 소스코드는 올려두었습니다.

 

https://github.com/Hongjeongmin/webJsCode/tree/cat_search

 

Hongjeongmin/webJsCode

Js 프론트 프레임워크 기본 구조 구현. Contribute to Hongjeongmin/webJsCode development by creating an account on GitHub.

github.com

 

기본적으로 어느정도 구현이 되어있고 형식에 맞게 나머지 코드를 작성하는 방법입니다. 디렉토리 구성은

main.js에서 App을 생성하고 App에서 하위 컴포넌트들을 동작시키는 방법입니다.

 

App에서 여러 컴포넌트들을 불러오고 상황에 따라 App 내의 A,B,C 컴포넌트들은 서로 반응해야함으로 느슨하게 결합하는게 중요합니다.

콜백함수의 원리를 이용해서 App에서 함수 자체를 넘겨주는 형식으로 작업하니다. 또한 setState가 되는 순간에 render()를 실행하는 구조입니다.

 

api.js

 

AS-IS

const API_ENDPOINT =
  "https://oivhcpn8r9.execute-api.ap-northeast-2.amazonaws.com/dev";

const api = {
  fetchCats: keyword => {
    return fetch(`${API_ENDPOINT}/api/cats/search?q=${keyword}`).then(res =>
      res.json()
    );
  }
};


TO-BE

const API_ENDPOINT =
  "https://oivhcpn8r9.execute-api.ap-northeast-2.amazonaws.com/dev";

const api = {
  fetchCats: async keyword => {
    try{
      const response = await fetch(`${API_ENDPOINT}/api/cats/search?q=${keyword}`);
      console.log(response);
      if(response.ok)
        return await response.json();
    }catch(e){
      console.error(e);
    }
  },

  // [D] 자세히보기
  catsId: async id => {
    try{
      const response = await fetch(`${API_ENDPOINT}/api/cats/${id}`);
      if(response.ok)
        return await response.json();
    }catch(e){
      console.error(e);
    }

  },

  // [D] random50 
  random50: async () => {
    try{
      const response = await fetch(`${API_ENDPOINT}/api/cats/random50`);
      if(response.ok)
        return await response.json();
    }catch(e){
      console.error(e);
    }

  }
};

 

기존 fetch함수를 이용해서 callBack 형식의 비동기 함수를 async await로 변경하였습니다. 변경함으로서 try catch 문을 이용해서 깊지않은 deeps로 소스코드를 변경하였습니다. 이외의 필요한 api를 작성합니다. response.ok를 통해 상태 200(성공) 일경우에만 리턴하도록 합니다. 이외의 상태코드에 대해서도 if 구문을 통해서 분류할 수가 있습니다. (함수를 만들어서 깔끔하게 분리도 할 수 있습니다.)

 

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/async_function

 

async function - JavaScript | MDN

async function 선언은 AsyncFunction객체를 반환하는 하나의 비동기 함수를 정의합니다. 비동기 함수는 이벤트 루프를 통해 비동기적으로 작동하는 함수로, 암시적으로 Promise를 사용하여 결과를 반환

developer.mozilla.org

 

이제는 각각의 컴포넌트들을 먼저 살펴본다음 그것을 중재하는 App.js를 보도록 하겠습니다.

 

SearchResult.js

 

AS-IS

class SearchResult {
  $searchResult = null;
  data = null;
  onClick = null;

  constructor({ $target, initialData, onClick }) {
    this.$searchResult = document.createElement("div");
    this.$searchResult.className = "SearchResult";
    $target.appendChild(this.$searchResult);

    this.data = initialData;
    this.onClick = onClick;

    this.render();
  }

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

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

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

 

TO-BE

class SearchResult {
    $searchResult = null;
    data = null;
    onClick = null;
  
    constructor({ $target, initialData, onClick }) {
      this.$searchResult = document.createElement("div");
      this.$searchResult.className = "SearchResult";
      $target.appendChild(this.$searchResult);
  
      this.data = initialData;
      this.onClick = onClick;
  
      this.render();
  
      window.addEventListener('scroll', (e) => {
        let scrollLocation = document.documentElement.scrollTop; // 현재 스크롤바 위치
        let windowHeight = window.innerHeight; // 스크린 창
        let fullHeight = document.body.scrollHeight; //  margin 값은 포함 x
      
        if(scrollLocation + windowHeight >= fullHeight){
          // [D] 페이지 위치에대한 간격이 없으므로 똑같은 데이터 렌더링 한번 더실행.
          console.log('downPage');
          this.appendPage();
          
        }
      });
    }
  
    //[D] Data추가
    appendPage() {
      const list = this.data
      .map(
        cat => {
          const div = document.createElement('div');
          div.className = "item";
          div.innerHTML =
          `
        <img src=${cat.url} alt=${cat.name} />
        <div class="hover">${cat.name}</div>
        `;
        this.$searchResult.appendChild(div);
      });
      
      // [D] 개선해야함
      this.$searchResult.querySelectorAll(".item").forEach(($item, index) => {
        $item.addEventListener("click", () => {
          this.onClick(this.data[index]);
        });
      });
    }
  
    setState(nextData) {
      this.data = nextData;
      this.render();
    }
  
    render() {
      if(this.data.length !== 0){
        this.$searchResult.innerHTML = this.data
          .map(
            cat => `
              <div class="item">
                <img src=${cat.url} alt=${cat.name} />
                <div class="hover">${cat.name}</div>
              </div>
            `
          )
          .join("");
    
        this.$searchResult.querySelectorAll(".item").forEach(($item, index) => {
          $item.addEventListener("click", () => {
            this.onClick(this.data[index]);
          });
        });
      }else {
        this.$searchResult.innerHTML =`
            <strong>검색된 결과가 없습니다</strong>
        `
      }
    }
  }

- render부근에 검색된 결과가 없을 경우 다른 문자열을 반환합니다. data의 길이로 판단합니다.

- 스크롤바가 끝에 도달하였을 경우 data의 페이지를 한번더 추가합니다. (이 부분은 현재 store에 서치된 페이지의 양 백엔드와 협업해야하는 부분이지만 그냥 현재있는 데이터를 그대로 추가했습니다. 문제요구 조건에 정확한 데이터가 없기 때문에...)

- onClcick이라는 매서드를 생성할 때 받아옵니다. (App.js 내부에서 현재 컴퍼넌트를 생성하고 App에서 컨트롤할 수 있기때문에 다른 컴포넌트간의 콜백이 수월해집니다. 만약 다른 컴포넌트 자체를 지금 컴포넌트로 가져올려고하면 확장시난 변경시 많은 변경이 필요하고 꼬이게됩니다.)

 

SearchInput.js

AS-IS

const TEMPLATE = '<input type="text">';

class SearchInput {
  constructor({ $target, onSearch }) {
    const $searchInput = document.createElement("input");
    this.$searchInput = $searchInput;
    this.$searchInput.placeholder = "고양이를 검색해보세요.|";

    $searchInput.className = "SearchInput";
    $target.appendChild($searchInput);

    $searchInput.addEventListener("keyup", e => {
      if (e.keyCode === 13) {
        onSearch(e.target.value);
      }
    });

    console.log("SearchInput created.", this);
  }
  render() {}
}

TO-BE

const TEMPLATE = '<input type="text">';

class SearchInput {
  constructor({ $target, onSearch, onClick }) {
    const box = document.createElement('div');
    box.className = 'box';

    const $searchInput = document.createElement("input");
    this.$searchInput = $searchInput;
    this.$searchInput.placeholder = "고양이를 검색해보세요.|";

    // [D] autofocus 추가
    this.$searchInput.setAttribute('autofocus','autofocus');

    $searchInput.className = "SearchInput";

    this.$randomButton = document.createElement('button');
    this.$randomButton.className ='button';
    this.$randomButton.innerHTML = 'Random 50';

    box.appendChild(this.$searchInput);
    box.appendChild(this.$randomButton);
    $target.appendChild(box);

    $searchInput.addEventListener("keyup", e => {
      if (e.keyCode === 13) {
        onSearch(e.target.value);
      }
    });

    this.$randomButton.addEventListener("click", () => {
      onClick();
    });
 
  }
  render() {}
}

- autofoucs를 이용해서 렌더링식 포커스가 가게합니다. 

- random 50 button을 추가합니다. (이 버튼은 onClick을 호출하게 되어있고 마찬가지로 App.js에서 컨트롤합니다.)

 

ImageInfo.js

 

AS-IS

class ImageInfo {
  $imageInfo = null;
  data = null;

  constructor({ $target, data }) {
    const $imageInfo = document.createElement("div");
    $imageInfo.className = "ImageInfo";
    this.$imageInfo = $imageInfo;
    $target.appendChild($imageInfo);

    this.data = data;

    this.render();
  }

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

  render() {
    if (this.data.visible) {
      const { name, url, temperament, origin } = this.data.image;

      this.$imageInfo.innerHTML = `
        <div class="content-wrapper">
          <div class="title">
            <span>${name}</span>
            <div class="close">x</div>
          </div>
          <img src="${url}" alt="${name}"/>        
          <div class="description">
            <div>성격: ${temperament}</div>
            <div>태생: ${origin}</div>
          </div>
        </div>`;
      this.$imageInfo.style.display = "block";
    } else {
      this.$imageInfo.style.display = "none";
    }
  }
}

TO-BE

class ImageInfo {
    $imageInfo = null;
    data = null;
  
    constructor({ $target, data }) {
      const $imageInfo = document.createElement("div");
      $imageInfo.className = "ImageInfo";
      this.$imageInfo = $imageInfo;
      $target.appendChild($imageInfo);
  
      this.data = data;
  
      this.render();
  
      //
      this.$imageInfo.addEventListener('click', (e) => {
        const className = e.target.className;
        if(className ==='ImageInfo' || className === 'close') {
          // [D] fade out 적용
          this.fadeOut(this.$imageInfo);
        }
      })
  
      // [D] ESC 종료
      window.onkeyup = (e) => {
        var key = e.keyCode ? e.keyCode : e.which;
        if(key === 27){
          if(this.$imageInfo.style.display === 'block')
            this.fadeOut(this.$imageInfo);
        }
      }
    }
  
    setState(nextData) {
      this.data = nextData;
      this.render();
    }
  
    fadeOut(element) {
      var op = 1;  // initial opacity
      var timer = setInterval(function () {
          if (op <= 0.1){
              clearInterval(timer);
              element.style.display = 'none';
          }
          element.style.opacity = op;
          element.style.filter = 'alpha(opacity=' + op * 100 + ")";
          op -= op * 0.1;
      }, 50);
    }
  
    fadeIn(element) {
      var op = 0.1;  // initial opacity
      element.style.display = 'block';
      var timer = setInterval(function () {
          if (op >= 1){
              clearInterval(timer);
          }
          element.style.opacity = op;
          element.style.filter = 'alpha(opacity=' + op * 100 + ")";
          op += op * 0.1;
      }, 10);
    }
  
    render() {
      if (this.data.visible) {
        const { name, url, temperament, origin } = this.data.image;
  
        this.$imageInfo.innerHTML = `
          <div class="content-wrapper">
            <div class="title">
              <span>${name}</span>
              <div class="close">x</div>
            </div>
            <img src="${url}" alt="${name}"/>        
            <div class="description">
              <div>성격: ${temperament}</div>
              <div>태생: ${origin}</div>
            </div>
          </div>`;
        // this.$imageInfo.style.display = "block";
        this.fadeIn(this.$imageInfo);
  
        // [D]fade in 적용
  
      } else {
        this.$imageInfo.style.display = "none";
      }
    }
  }

- fadeIn, FadeOut은 스택오버플로우에서 가져왔습니다. 

- 기존 소스코드에서 유지했습니다. 기존 소스코드는 데이터를 가져오면 렌더링을 그린후 none상태로 만들었다가 block되는 형식입니다.

- 기존 소스는 그대로하고 none상태에서 fadeIn 호출하여 서서히 들어나게 합니다.

- X 표시 esc  바깥쪽 클릭은 onClick 함수를 이용합니다.

 

나머지는 LoadingInfo.js

 

Loading.js

const template = `
    <div class="loading"></div>
    <div id="loading-text">loading</div>
`;

class LoadingInfo {
    $loadingInfo = null;
    data = null;
  
    constructor({ $target, data }) {
        const $loadingInfo = document.createElement("div");
        $loadingInfo.className = "loading-container";
        this.$loadingInfo = $loadingInfo;
        this.data = data;
        this.$loadingInfo.style.display = this.data.visible ? 'block' : 'none';
        $target.appendChild($loadingInfo);
    
        this.render();
  
    }

    onChange() {
        this.data.visible = !this.data.visible;
        this.$loadingInfo.style.display = this.data.visible ? 'block' : 'none';
    }
  
    render() {
        this.$loadingInfo.innerHTML = template;
    }
  }

생성시에 target에 렌더링됩니다. data값은 초기 값을 설정합니다. (외부에서 필요없는 값이니 실제로는 private 변수로 선언해서 객체 내부에서만 접근하도록 하는게 좋아보입니다.)

 

onChange는 로딩을 표시하거나 표시하지않는 toggle구조입니다. 이 함수를 api를 실행하기전 .<----> api실행 끝에 넣으면됩니다.

 

api을 실행하는 중에 오류가 나서 실행 못할 수도 이승니 finally 부근에 넣으면 무조건 실행되니 오류를 걱정안해도됩니다.

 

마지막으로 이러한 컴포넌트들의 소통을 하는 App.js를 살펴보겠습니다.

 

App.js

 

AS-IS

console.log("app is running!");

class App {
  $target = null;
  data = [];

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

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

    this.searchResult = new SearchResult({
      $target,
      initialData: this.data,
      onClick: image => {
        this.imageInfo.setState({
          visible: true,
          image
        });
      }
    });

    this.imageInfo = new ImageInfo({
      $target,
      data: {
        visible: false,
        image: null
      }
    });
  }

  setState(nextData) {
    console.log(this);
    this.data = nextData;
    this.searchResult.setState(nextData);
  }
}

TO-BE

console.log("app is running!");

class App {
  $target = null;
  data = [];

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

    this.banner = new Banner( {
      $target
    });

    try{
        const {data} = await api.random50();
        this.banner.setState(data)
    }catch(e){
        console.error(e);
    }
   

    this.searchInput = new SearchInput({
      $target,
      onSearch: async keyword => {
        try{
          this.loadingInfo.onChange();
          const {data} = await api.fetchCats(keyword);
          this.setState(data);
        }catch(e){
          console.error(e);
        }finally{
          this.loadingInfo.onChange();
        }
      },
      onClick: async ()=> {
        try{
          this.loadingInfo.onChange();
          const {data} = await api.random50();
          this.setState(data);
        }catch(e){
          console.error(e);
        }finally{
          this.loadingInfo.onChange();
        }
      }
    });

    this.searchResult = new SearchResult({
      $target,
      initialData: this.data,
      onClick: async image => {
        try{
          this.loadingInfo.onChange();
          const {data} = await api.catsId(image.id);
          this.imageInfo.setState({
            visible: true,
            image: data
          });
        }catch(e){
          console.error(e);
        }finally{
          this.loadingInfo.onChange();
        }
      }
    });

    this.imageInfo = new ImageInfo({
      $target,
      data: {
        visible: false,
        image: null
      }
    });

    this.loadingInfo = new LoadingInfo({
      $target,
      data: {
        visible: false,
      }
    });
  }

  setState(nextData) {
    console.log('app');
    this.data = nextData;
    this.searchResult.setState(nextData);
  }
}

App.js 밑으로 순차적으로 컴퍼넌트들을 추가합니다.

onClick 함수 구현부에서는 모든 조건은 api를 이용해서 데이터를 불러오고 해당 컴포넌트의 setData를 이요해서 Data를 갱신합니다.

setData는 render함수를 불로오면서 Data가 갱신되게 됩니다.

반응형

'TOPIC' 카테고리의 다른 글

접근성 : ARIA-TAB  (0) 2021.07.21
JavaScript 모든 this의 바인딩 상황  (1) 2021.07.18
mac 구매후 설치방법(개발자용)  (0) 2021.06.08
JOIN 안에 SUB쿼리를 JOIN으로 바꿔보자  (1) 2021.01.17
Junit5 기능 정리  (0) 2021.01.14