파크로그
article thumbnail

 

진행 기간

22.02.14 ~ 22.02.27

사용 기술

- Javascript (VanilaJS)

- webpack, babel

- gh-pages

 

데모 : https://healtheloper.github.io/fe-kakaopage/

Git : https://github.com/healtheloper/fe-kakaopage

TL;DR

- Component 를 템플릿 리터럴을 반환하여 생성

  - 해당 컴포넌트가 생성된 이후 이벤트 리스너를 달아주기 어려운 문제가 생김 

  - Vanila JS 로 Rendering 흐름을 Prototype , 생성자 함수 패턴으로 개선하여 사용

- 메인 배너 Carousel 의 클릭 이벤트를 연타하면 이미지가 깨지기도 함

  - 의도하지 않은 사용을 방지하기 위하여 클릭 이벤트에 Throttle 을 사용

프로젝트를 시작하며

길었던 CS10 을 마치고 마스터즈코스의 첫 클래스 미션이었다. 

어려운 CS 지식들을 이해하기도 어려웠던 상황에서, 그것을 직접 구현하는 것이 너무 힘들다고 생각이 들 때쯤, 클래스 과정이 시작되었다.

첫 미션은 카카오 페이지를 클론하는 것이었다. 

이전에 프로그래머스 과제관(고양이 사진첩, 커피주문페이지) 이나 웹 프론트엔드 데브매칭 과제에서 VanilaJS 로 SPA 를 만들기 관련 학습했던 경험이 있었는데, VanilaJS 로 SPA 를 만든다는 개념 자체가 어떻게 접근해야할 지 완벽히 이해하지 못한 상태였다. 

  - 커피주문페이지는 시도조차 하지 않은 상태였고, 고양이 사진첩은 프로그래머스에서 제공하는 해설과 바닐라 자바스크립트로 SPA 컴포넌트 작성하기에 가장 유명했던 황준일 개발자님의 글을 참고하면서 2번에 걸쳐서 풀었던 기억이 있다.

 

완벽하게 알지 못하는 개념을 구현할 수 있는 미션이라니, 가려웠던 곳이 시원하게 긁힌 기분이었고, 굳이 처음부터 이전에 이해하지 못했던 로직을 가져다 사용하기 보다는 VanilaJS 에서 제공하는 DOM API 를 최대한 활용하며 구현해보기로 마음을 먹고 프로젝트를 시작하였다.

 

1주차

  • 첫 주차에는 HTML, CSS 만을 사용해서 클론 ( UI 짜기 ) 을 진행하고, 각 HTML 태그들을 Component 로 분리하여 상위 태그를 querySelector 하여 innerHTML 로 HTML 태그를 넣어주는 작업을 진행했다. 
  • 당시 코드는 필요로 하는 HTML Element 를 불러와서 필요로 하는 로직을 작성하여 render 하는 함수를 작성해서 사용했다.
const genres = {
  home: {
    home: { name: "홈", screen: DummyGenre },
  },
  webtoon: {
    home: { name: "홈", screen: HomeGenre },
    days: { name: "요일연재", screen: DaysGenre },
    webtoon: { name: "웹툰", screen: WebtoonGenre },
    boy: { name: "소년", screen: BoyGenre },
    drama: { name: "드라마", screen: DramaGenre },
    romance: { name: "로맨스", screen: RomanceGenre },
    rofan: { name: "로판", screen: RofanGenre },
    action: { name: "액션무협", screen: ActionGenre },
    bl: { name: "BL", screen: BLGenre },
  },
  webnovel: {
    home: { name: "홈", screen: DummyGenre },
    ...
  }
};

export default genres;

다양한 Category 와 Category 별 존재하는 Genres, 각각의 template 를 지정해서 사용해야했다.

  • 카카오 페이지에 존재하는 다양한 Genre 별 Return 할 template 를 지정하였다.
const HomeGenre = (webtoons) => {
  const today = new Date().getDay() - 1;
  const days = ["월", "화", "수", "목", "금", "토", "일", "완결"];

  return `
    ${MainBanner(webtoons.filter((wt) => wt.isMain.home))}
    ${NavDetail()}
    ${SubBanner()}
    ${DaysTop(today, webtoons)}
    ${BigCardList(
      "기대 신작 TOP",
      webtoons.filter((wt) => wt.status === "N" && wt.imageHorizontalUrl)
    )}
    ${GenreTop(
      "로판",
      webtoons.filter((wt) => wt.genre.includes("로판"))
    )}
    ${GenreTop(
      "드라마",
      webtoons.filter((wt) => wt.genre.includes("드라마"))
    )}
    ${DateTop(webtoons.filter((wt) => wt.days.includes(days[today])))}
    ${RecommendEvent()}
`;
};

export default HomeGenre;
  • HomeGenre 컴포넌트는 아래와 같이 컴포넌트들을 템플릿 리터럴 내부에서 함수를 호출하는 형태로 사용하였다.
import { formatUserCount } from "../../src/utils.js";

const MainBanner = (webtoons) => {
  const webtoon = webtoons[0]; // 일단 한개만, 나중에 슬라이더
  return `
    <li class="mainBox main__mainBanner">
        <div class="banner__imgBox">
            <img src="${webtoon.imageHorizontalUrl}" />
            <div class="imgBox__info">
                <div class="infoTitle">
                    <span>${webtoon.title}</span>
                </div>
                <div class="infoBody">
                    <span class="info-event">${webtoon.event}</span>
                    <span class="info-category">
                        ${
                          webtoon.waitForFree
                            ? "<i class='fas fa-clock'></i>"
                            : ""
                        } 웹툰
                    </span>
                    <span class="span-bar"> | </span>
                    <span class="info-users">
                        <i class="fas fa-user"></i> ${formatUserCount(
                          webtoon.userCount
                        )}
                    </span>
                </div>
            </div>
            <div class="imgBox__order">
                <span class="orderNum">1</span>
                <span class="orderBar">/</span>
                <span class="orderNum">3</span>
            </div>
        </div>
        <div class="banner__message">
            <span>${webtoon.mainDesc}</span>
        </div>
    </li>
    `;
};

export default MainBanner;
  • JSX 문법 스러운 템플릿 리터럴을 반환하여 사용하였다.
const main = document.querySelector(".main");
const navGenre = document.querySelector(".main__navGenre");
const contentsBox = main.querySelector(".main__contentsBox");
import { handleNavDays } from "./nav-days.js";
import { handleNavGenre } from "./nav-genre.js";

const renderCategoryContents = (categoryContents) => {
  navGenre.innerHTML = categoryContents;
  [...navGenre.children].forEach((genreNode) => {
    genreNode.addEventListener("click", handleNavGenre);
  });
};
const renderGenreContents = (genreContents) => {
  contentsBox.innerHTML = genreContents;
  const daysNav = document.querySelector(".contents__daysNav");
  if (daysNav) {
    [...daysNav.children].forEach((day) => {
      day.addEventListener("click", handleNavDays);
    });
  }
};

const renderDaysContents = (daysContents) => {
  const contentsCard = document.querySelector(".contentsCard");
  contentsCard.innerHTML = daysContents;
};
export { renderGenreContents, renderCategoryContents, renderDaysContents };

 

  •  어떤 Element 는 Event Listener 를 달아줘야해서, innerHTML 을 실행한 이후 children 을 불러와 eventListener 를 달아주는 식으로 코드를 작성했었다. 

2주차

컴포넌트 렌더 방식 개선 -> return ``  에서 생성자함수로

2주차에서는 기존에 사용했던 템플릿 리터럴을 return 하는 형식에서 마음의 숙제처럼 남아있던 황준일 개발자님의 글을 이해하고 해당 렌더 로직을 사용해보려 시도했다. 일단 render 하는 함수들이 범용적으로 사용되어지기보다 컴포넌트에 종속적으로 사용되어지는 부분이 마음에 들지 않았고, 템플릿 리터럴을 반환하는 컴포넌트에 이벤트를 먼저 달아주는 방법에 애로사항이 생겼기 때문이다.

 

그렇다고 해서 바로 블로그에 있는 로직을 ctrl+c -> ctrl+v 해서 사용하고 싶진 않았고, 글의 기반은 클래스니까 prototype 으로 도전해보고, 내가 필요로 하다고 느껴지는 로직만 가져와서 쓰고, 필요하다고 느껴지는 것을 추가해서 쓰는식으로 사용했다.

 

Core 로직이 되는 컴포넌트 생성자함수를 만들고, 이벤트를 달아주는 로직을 render 함수 내부에서 함수를 차례대로 실행해서 달아줄 수 있도록 사용했다.

 

이벤트도 내가 원하는 Element 에 직접 달아주기보다, 이벤트 위임을 활용하여 사용할 수 있도록 개선하였다.

function Component(target, state) {
  this.target = target;
  this.state = state || {};
  this.eventTypes = [];
}

Component.prototype.render = function () {
  this.target.innerHTML = this.template();
  this.removeEvent();
  this.setEvent();
};

Component.prototype.template = function () {
  return ``;
};

Component.prototype.removeEvent = function () {
  this.eventTypes.forEach(({ type, listener }) => {
    this.target.removeEventListener(type, listener);
  });
};

Component.prototype.addEvent = function (eventType, selector, callback) {
  const children = [...this.target.querySelectorAll(selector)];
  const isTarget = (target) =>
    children.includes(target) || target.closest(selector);

  const handleEventListener = (event) => {
    if (!isTarget(event.target)) return false;
    callback(event);
  };
  this.eventTypes.push({ type: eventType, listener: handleEventListener });
  this.target.addEventListener(eventType, handleEventListener);
};

Component.prototype.setEvent = function () {};

Component.prototype.setState = function (newState) {
  this.state = { ...this.state, ...newState };
  this.render();
};

export default Component;

백엔드 서버 생성

  • Data 를 요청하는 과정을 단순히 js 파일을 import 해서 사용하기 보다 fetch 를 사용해보는 경험이 있는게 좋겠다는 생각이 들어서 back 디렉터리를 별도로 파서 fetch logic 을 사용해보았다.
  • 해당 과정에서 비동기요청 (async-await) 을 여러번, 순차적으로 하는 과정이 있었는데, 해당 과정이 꼭 순차적으로 이루어져야하는지? 에 대한 리뷰도 받았다. 생각해보니 무지성으로 async-await 을 사용해왔지 그것이 꼭 순차적으로 이루어져야하는 것인지 에 대한 고려를 하지 않은 코드였다. 해당 코드는 Promise.all 을 활용해서 데이터를 요청하도록 수정하였다. 해결과정
// Before
const { results: categories } = await getJson("categories");
const { results: genres } = await getJson("genres");
const { results: webtoons } = await getJson("webtoons");

// After
const paths = ["categories", "genres", "webtoons"];
const [{ results: categories }, { results: genres }, { results: webtoons }] =
  await Promise.all(paths.map((path) => getJson(path)));

이전
이후

 

  • 데모페이지를 위한 배포툴로 heroku 를 사용하고 있었는데, back 폴더와 front 폴더를 별도로 나누니 배포를 하는 데 불편사항이 있어서 따로 백엔드서버를 위한 레포지토리를 파서 관리해야겠다고 생각했다. 별도의 레포지토리를 파고 해당 코드를 heroku 배포하여 사용하려고 하니 자연스럽게 CORS 문제가 발생했고, CORS 를 해결하기 위해서 어떤 조치를 해야하는지도 공부할 수 있어서 좋았다.

3주차

  • 2주차에서 백엔드 서버를 별도로 파서 사용하고 있었는데, heroku 서버가 초기로딩이 긴 이슈가 있어서 개발 환경에서는 개발 환경만 사용하도록 fetch url 을 분기하여 사용하고 있었다.
    • 개발 환경에서는 localhost 에서 쓰는 백엔드서버를 사용
    • Github pages 에 Deploy 하는 환경에서는 Heroku 에 올린 백엔드서버를 사용
  • 이 과정에서 크롱이 webpack string replace 관련 키워드를 주었고, 공부해볼겸 webpack 을 적용해봐야겠다고 생각이 들었다.
  • 이벤트를 적용하는 것은 render 함수 내부에서 innerHTML 을 한다음 addEvent 를 하면 되었는데, 자식 컴포넌트를 생성하여 붙일 방법에 대해서 애로사항이 있었다. 컴포넌트 생성자함수가 render 가 된 이후 자식컴포넌트를 붙여야 하는데 그걸 컴포넌트 내부 코드에서 사용할만한 방법이 있을지에 대한 고민이 컸다. 이 부분도 컴포넌트의 렌더링 로직에서 힌트를 얻었는데 template 가 render 된 이후의 로직인 mount 를 활용해서 template 가 렌더링 된 이후 해당 element 를 querySelector 로 가져와서 그 element 를 target node(부모)으로 설정하여 사용하도록 하였다.
  • 생성자패턴과 프로토타입 패턴에 대한 완전히 잘못된 생각을 갖고있던 부분이 있었다. 해당 부분들을 모두 프로토타입패턴을 사용하도록 변경하였다.
function GenreTop(target, state) {
  Component.call(this, target, state);

  const webtoons = JSON.parse(localStorage.getItem("webtoons"));
  const genreCards = webtoons
    .filter((wt) => wt.genre.includes(this.state.genre))
    .map((cardInfo) => new Card("_", cardInfo));

  this.template = function () {
    return `
        <ul class="contentsCard">
          ${getComponentsTemplate(genreCards)}
        </ul>`;
  };
}
  • 해당 생성자함수의 this.template 메서드는 GenreTop 으로 생성된 인스턴스들이 각각 가지게 된다. new GenreTop() 으로 두 개의 genreTop 인스턴스를 생성하면 두 개의 template 함수가 만들어지는 것이다.
  • 여기서 prototype 패턴을 활용하면 GenreTop 의 인스턴스는 프로토타입체인을 따라 GenreTop 의 prototype 에 존재하는 template 메서드를 사용하게 되고, 인스턴스는 별도의 template 함수를 생성하지 않게된다.
function GenreTop(target, state) {
  Component.call(this, target, state);
}

createExtendsRelation(GenreTop, Component);

GenreTop.prototype.template = function () {
  const webtoons = JSON.parse(localStorage.getItem("webtoons"));
  const genreCards = webtoons
    .filter((wt) => wt.genre.includes(this.state.genre))
    .map((cardInfo) => new Card("_", cardInfo));

  return `
        <ul class="contentsCard">
          ${getComponentsTemplate(genreCards)}
        </ul>`;
};

export default GenreTop;

 

해당 프로젝트를 하면서

VanilaJS SPA

프론트에 대한 다양한 기술들을 사용해보았지만 과제관 테스트를 봤을 때 아무것도 하지 못했다는 후기를 남긴적이 있다.

그 때 구글링을 해보면서 많은 분들이 참고하고 있는 황준일 개발자님의 글은 처음 읽었을 때에는 정말 이해하기 어려웠다.

 

해당 프로젝트의 1주차는 그런 렌더링 로직에 대해 전혀 고려하지않고 내가 짜고싶은대로 짰다. 그리고 2주차에서는 그 코드를 생성자함수를 통해서 공통된 로직을 사용할 수 있도록 개선하였다.

 

생성자함수를 사용하여 컴포넌트를 짜보니 this 와도 여러번 부딪히고, this 에 해당하는 값을 설정해주기 위하여 bind 등의 메서드도 진하게 사용하였다. ( 책으로만 공부했을 때에는 그냥 끄덕끄덕,, 했던 개념들이었는데 직접 부딪히니 훨씬 와닿았던 기억이 난다. )

 

내 마음대로 렌더링 로직을 짰을 당시에는 HTML 태그를 템플릿 리터럴로 반환하여 사용 -> 이후 이벤트 리스너 달아줌 의 순서로 이어졌었는데, 그 로직이 블로그 글에 사용되고 있는 로직과 비슷하게 짜여져 있다는 것에 신기해 했던 기억이 있다. 직접 나의 생각대로 짜고 다시 그 글을 읽으니 왜 이 로직이 필요한지에 대해서 이해를 할 수 있었고, 프로그래머스 과제관을 같이 풀어보자고 이야기 했던 스터디원에게 그 글을 공유했을 때 어려워하던 멤버들에게 왜 해당 로직이 필요한지에 대해서 설명할 수 있는 정도가 되었다.

 

지금 와서 코드를 다시 읽어보니, 2주차에 바로 생성자함수의 로직으로 가지 않고 함수형으로도 충분히 컴포넌트의 렌더링 로직을 구현할 수 있지 않았을까? 란 도전의식이 생겼다. 기회가 된다면 클래스형으로 작성되는 SPA 로직 말고도, 함수형으로도 SPA 과제들을 풀 수있도록 나만의 로직을 담은 코드를 만들어서 공유하고 싶다는 생각도 들었다.

git 과 찐한 만남

호기롭게 시작한 첫 프로젝트 답게 처음 브랜치를 파는 부분에서부터 잘못했다. 

미션은 Codesquad 미션 레포지토리에 내 코드를 PR보낼 branch(park) 를 생성하고, 해당 레포지토리를 fork 해와서 생성한 branch(park) 에 PR 을 보내는 식으로 진행하는데, fork 를 해오고 코드 작업을 park 브랜치에서 그대로 진행했다.

당연히 첫 PR 에서는 codesquad(park) <- heatheloper(park) 이기에 문제는 없었지만 이어지는 작업에서 park 브랜치가 squash merge 가 되고, 해당 브랜치로 PR 을 보내려니 commit log 가 이상하게 다 쌓이는 듯한 경험을 했다.

 

아, 나는 git 에 대해서 완전히 무지하구나 싶었다. 코드스쿼드에서 PR 을 보내기위한 github 사용법을 공유하였지만 당시에 rebase 가 어떤 역할을 하는지, upstream 이라는 단어는 무엇인지에 대해서 전혀 무지했기에 구글링과 시행착오를 겪으며 두번째 PR 을 commit 로그를 정돈하고, Conflict가 없이 보내는 것을 성공할 수 있었다.

 

이 때 github 의 branch base 의 개념 등을 이해하고 rebase 가 무엇인지 등을 알아서, 멤버분들 중 conflict 관련으로 문제를 겪으시는 분들에게 도움을 드리기도 했다.

 

이렇게 github 에 대한 대략적인 사용법도 익혔지만, 그럼에도 '나 협업을 위한 github, 잘해요.' 라고 당당히 말하기엔 민망한 느낌이 있었다.

카카오페이지 구현을 진행하며 주말에도 가끔 게더타운에 접속해서 그 곳에 계신 분과 이런 저런 얘기를 나누는 것을 좋아했는데

밀리와 이야기하다가 감사하게도 git study 를 진행할 예정인데 참여할 생각이 있는지 여쭤봐 주셨다.

안할이유가 없다고 생각이 들어서 당연히 참여했고, 해당 스터디는 앞으로의 프로젝트를 진행하는 데 있어서 정말 큰 도움이 되었다.

밀리, 쥬, 햄디, 가이드 해주신 ✨어텀✨ 정말정말 감사합니다. 

 

깃 스터디를 진행하며 작성한 노션

 

글을 작성하고 있는 지금은 그래도 깃을 사용할 때 어떻게 동작할지 예상이 되어서 GUI 의 도움없이 CLI 로만 git 을 다룰 수 있는 정도가 되었지만, 지금와서 생각해보면 이런 시행착오가 있었기에 가능한게 아닐까 싶다. 최근에 코드스쿼드 슬랙에서 사내에서 git 초급자를 대상으로 강연을 하신다는 분이 계셔서 내가 초보때는 어떤게 어려웠는지도 공유드렸다.

 

 

profile

파크로그

@파크park

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!