[JS] RIDI웹툰 클론 프로젝트

rlorxl·2022년 9월 4일
0

회고

목록 보기
1/2
post-thumbnail

리디웹툰 사이트를 클론했다. 이번 프로젝트는 약 세달동안 학습해온 것들을 총동원하여 라이브러리 없이 최대한 클론 코딩하는것이 목표였다. SPA구현에 가장 중점을 두었고 지금까지 해보지 못했던 로그인 기능구현과 유저 정보에 따른 컨텐츠가 필터링되어 보이도록 구현했다.

메인2x

프로젝트 소개

자바스크립트만 공부하면서 주요기능만 하나 있는 작은 프로젝트만 진행하다가 갑자기 팀프로젝트를 하자니 뭔가 지금까지와는 다른 것을 만들어내야한다는 부담감이 생겼었다. 그냥 재미위주의 새로운 어플리케이션을 만들고 싶기도 하고 트렐로와 같은 일정관리 서비스를 구현해 보고도 싶었는데 의견 조율 끝에 결국 공부단계에서는 이미 잘 되어 있는 사이트를 클론하는것만큼 좋은게 없다고 생각되었고 팀원 중 한명이 평소 도서 사이트에 관심이 있어 리디의 웹툰 페이지를 SPA로 구현하게 되었다.

주요 기능 구현 리스트

  • 레이아웃 - 메인,회차,뷰어,마이페이지,로그인/회원가입 페이지를 구현하기로 정하고 전부 반응형으로 구현했다.
  • 라우터 - history API를 사용해 라우터를 구현했다.
  • 캐러셀 기능 - 메인페이지에 다양한 모양의 캐러셀이 있어 라이브러리를 쓰지 않고 최대한 비슷하게 구현했다.
  • 유저정보를 기반으로 노출되는 컨텐츠 필터링 - 로그인 유/무, 성인 유저인지 아닌지 여부를 기반으로 메인페이지에서 노출되는 컨텐츠에 변화가 있어 이를 구현했다.
  • 계정별 마이페이지 로드 구현, 최근 조회 목록 구현 - 유저정보를 관리하는 데이터를 분리하여 로그인한 유저의 마이페이지와 최근 조회한 목록을 나타내도록 했다.
  • JWT 토큰 & express를 이용해 로그인이 되도록 했다.
  • 웹툰 검색 기능
  • 웹툰 페이지 별점/리뷰 기능 - 웹툰 페이지 하단의 별점, 리뷰를 다는 기능을 구현했다.
  • 뷰어페이지 화면 테마 전환 및 화면 확대 기능구현

기여한 부분

  • 메인 Carousel 기능
  • 로그인/회원가입 페이지 마크업 및 스타일 적용
  • JWT 토큰 & express를 이용한 로그인 기능
  • 화면 전환 및 새로고침 시 JWT 토큰 인증을 통한 로그인 유지
  • 회원가입 폼 유효성 검사
  • 뷰어 페이지 마크업 및 스타일 적용
  • 웹툰 mok data 생성

기능 구현

1. 폴더 구조 & 컴포넌트화

스크린샷 2022-10-05 오후 2 42 20

폴더 구조는 최상위 폴더 아래에 pages와 components폴더가 있다. pages는 라우팅 될 페이지들을 가지고 있고 component는 각 페이지안에있는 컴포넌트 파일들이 또 해당 페이지의 폴더 하위에 들어있다. 페이지 파일은 컴포넌트를 불러오고 createElement함수로 DOM string을 반환한다.DOM string을 반환하는 방식을 선택한 것은 가독성 때문도 있지만 추후 diff 알고리즘 적용 가능성을 위해 그렇게 한 것도 있다.

만들다 보니 프로젝트의 규모가 작기도 하고 페이지가 한 화면에서 변경이 일어나는 경우가 크게 없고 이벤트는 거의 라우팅으로 이어지고 있어 결국 전체 화면 렌더링이 되는 경우가 많아 크게 관련이 없게 되었다.

그리고 createElement함수를 통해 templete의 innerHTML로 화면에 추가되게 했다. (템플릿은 콘텐츠 조각을 나중에 사용하기 위해 담아놓는 컨테이너로써 사용된다.)

컴포넌트는 컴포넌트를 너무 작게 쪼개는 것은 지양하고 재사용 여부를 기준으로 분리했다.
그리고 이벤트 중복을 방지하기 위해 이벤트는 모두 이벤트 위임으로 처리하고, 각각의 페이지가 이벤트를 바인딩하는 함수도 호출한 뒤 렌더하도록 구성이 되어있다.

// app.js
const createElement = string => {
  const $temp = document.createElement('template');
  $temp.innerHTML = string;
  return $temp.content;
};
// Home.js
 return createElement(`
  ${Header()}
  <div class="main-container">
    ${HomeNav()}
    <main class="main">
      ${CarouselSection(mainCarousel)}
      ${NewArrivalSection()}
      ${RankingSection(rank, mainTitle[0])}
      ${WebtoonSection(free, mainTitle[1])}
      ${WebtoonSection(sunday, mainTitle[2])}
      ${RankingSection(bestSeller, mainTitle[3])}
      ${EventView(mainTitle[4])}
      ${WebtoonSection(highRating, mainTitle[5])}
      ${WebtoonSection(bestReview, mainTitle[6])}
      ${WebtoonSection(switchOn, mainTitle[7])}
      ${WebtoonSection(yummy, mainTitle[8])}
      ${WebtoonSection(wanted, mainTitle[9])}
      ${EventOnly(mainTitle[10])}
      ${WebtoonSection(wait, mainTitle[11])}
      ${TopButton()}
    </main>
  </div>
  ${Footer()}
  `);

2. 라우터

라우터는 링크를 클릭했을 때와 뒤로가기, 앞으로가기 버튼을 눌렀을 때로 나뉘어진다.

a태그를 클릭했을 때는 기본동작을 막고 href어트리뷰트 값을 변수에 저장하여 render함수로 보내준다.
render 함수는 변수(path)를 받아 경로와 해당 컴포넌트를 호출하는 함수를 value로 가지는 routes배열에서 path와 같은 key를 찾고, 배열의 every()메서드를 이용해 '/'를 제외한 모든 요소가 동일한지 true,false를 반환하여 true이면 해당 객체의 component값(함수)을 replaceChildren으로 #root의 노드들을 교체하여 렌더링한다.

if (targetPath.every((string, index) => string === routePath[index])) check = true;
else check = false;

뒤로가기, 앞으로가기 버튼을 누르는 경우는 pushStatepopstate이벤트를 사용했다. a링크를 클릭했을 때 저장되는 url경로를 window.history.pushState로 브라우저의 세션 기록 스택에 추가한다.
(브라우저는 URL또는 앵커가 달라질 때마다 이를 리스트처럼 관리하고 있고 뒤로가기나 앞으로 가기를 할 때 이 리스트를 기준으로 이동하게 되는데 pushState로 state,title,url을 지정할 수 있다.)

우리는 state와 title은 필요하지 않기 때문에 url만 저장한 변수값으로 지정했다.
그리고 popstate이벤트가 발생할때(뒤로가기, 앞으로가기) window.location.pathname을 render함수의 매개변수로 넘겨 render로직으로 페이지가 렌더링될 수 있도록 구성했다.

window.addEventListener('popstate', async () => {
  const { data: auth } = await axios.get('/auth');
  if (!auth) localStorage.removeItem('token');

  render(window.location.pathname);
});

2-1. 화면 깜빡임 문제

라우팅은 제대로 동작하지만 라우팅되어 페이지를 렌더링 했을 때 페이지 자체는 실제로 변환이 없기 때문에 전 페이지에서 클릭을 했던 스크롤의 위치가 유지되어 화면 전환이 어색하게 보였다. 이를 해결하기 위해 라우팅이 될 때 마다 #root의 child를 교체해주기 전에 window.scroll(0,0)을 주어 스크롤의 위치만 맨 위로 올라가있게끔 수정했고 이 때 화면이 깜빡이는 문제가 생겼고 마치 새로고침이 되는 것처럼 보였다.

이 부분은 사실 왜 발생하는 것인지 정확한 문제를 알아내지는 못하고 결론적으로 root를 교체한 후 스크롤을 설정해야 이런 문제가 발생하지 않았다. 상식적으로 생각했을 때는 당연히 교체 전에 스크롤을 먼저 설정해야 하는 것 아닌가 싶어 그 부분에 대해서는 의심하지 않았는데 교체한 이후에 설정해야 깜빡임이 발생하지 않는것을 볼 수 있어서 약간의 의문이 남는 부분이다.


3. 메인 캐러셀

캐러셀에 렌더될 이미지와 데이터는 컴포넌트가 동적으로 그려질 수 있도록 json파일에 캐러셀 전용으로 데이터를 추가하고 map으로 돌면서 렌더링했다.
그리고 무한 루프로 돌 수 있게끔 하기 위해 현재 슬라이드의 뒤에 두개의 슬라이드가 보여지고 클릭할 때 슬라이드가 이동되는 동작이 보이게 해야하기 때문에 앞에는 cloneNode를 하나 추가하고, 뒤에 cloneNode를 3개 더 추가해주었다.

// CarouselSection

const CarouselSection = mainCarousel =>`
  <section class="main__carousel">
    <div class="main__carousel__inner">
      <ul class="main__carousel__wrap">
        ${[mainCarousel[mainCarousel.length - 1], 
          ...mainCarousel, 
          { ...mainCarousel[0], current: !mainCarousel[0].current }, 
          {...mainCarousel[1], next: !mainCarousel[1].next}, 
          {...mainCarousel[2], next: !mainCarousel[2].next}].map((item, i, carousel) => 
            CarouselSectionItem(item, i, carousel))
            .join('')}
      </ul>
    </div>
    ${CarouselButton()}
  </section>
  `;

그리고 클릭할 때마다 현재 보여지는 슬라이드들에 스타일이 적용된 클래스가 동적으로 적용되도록 setClass함수를 만들어서 슬라이드마다 달린 클래스를 전부 삭제 후 현재 슬라이드 번호를 나타내는 변수와 동일한 인덱스를 가진 노드에 클래스를 부여했다.

const setClass = () => {
  const $carouselItems = document.querySelectorAll('.main__carousel__item');

  [...$carouselItems].forEach((_, i, self) => {
    if (currentSlide === i) {
      self.forEach(item => item.classList.remove('currentSlide'));
      self[i].classList.add('currentSlide');

      self.forEach(item => item.classList.remove('nextSlide'));
      self[i + 1].classList.add('nextSlide');
      self[i + 2]?.classList.add('nextSlide');
    }

    // currentSlide가 6일때 (끝에서 두번째)
    if (currentSlide === self.length - 2) {
      self.forEach(item => item.classList.remove('nextSlide'));
      self[self.length - 2].classList.add('nextSlide');
      self[3].classList.add('nextSlide');
    }
  });
};

스타일 잡는것에서 생각보다 시간을 들였는데 초기에 생각했던 flex-grow와 flex-basis로 잡는 방향에서 flex-grow가 가진 속성이 화면 너비에서 다른요소들의 영역을 뺀 나머지 영역만 차지하기때문에 숨겨져야할 요소들이 밀리지않고 화면안으로 들어와서 결국 width로 값을 계산해서 스타일을 잡았다.

&.currentSlide {
	min-width: calc(100% - 108px); // slideItem의 min-width가 45px
}

4. 로그인 / 회원가입

회원가입

로그인과 회원가입은 jwt토큰으로 구현하고 각 페이지로 라우팅 될 때마다 인증을 거친다.

4-1. 로그인

로그인버튼을 눌렀을 때 new Formdata를 통해 input의값들을 페이로드로 생성하여 서버에 post요청을 보내고, 서버에서는 받은 request에 원하는 값이 없으면 401에러가 발생하고 인풋아래에 에러메시지가 뜨도록했다. 그리고 저장된 회원 데이터 중에서 일치하는 정보를 찾아서 있으면 로그인이 성공되고 메인페이지로 이동된다.
로그인이 성공하면 동시에 토큰생성과 같이 쿠키가 저장이되고, 만든 토큰에서 payload만 받아와서 나중에 다시 디코딩해서 쓸 수 있도록 로컬스토리지에 따로 저장해주었다.

const request = async e => {
  e.preventDefault();
  if (!e.target.closest('.login-form') || !validate()) return;

  const $signinForm = document.querySelector('.login-form');

  const payload = [...new FormData($signinForm)].reduce(
    (obj, [key, value]) => ((obj[key] = value), obj),
    {}
  );

  try {
    const { data: user } = await axios.post('/login', payload);
    const token = user.accessToken.split('.')[1];
    localStorage.setItem('token', token);

    console.log('LOGIN SUCCESS!');

    if (user) {
      window.history.pushState({}, null, '/');
      render('/');
    }
  } catch (e) {
    console.log('LOGIN FAILURE..');
    document.querySelector('.login__error-message').textContent = '! 아이디 또는 비밀번호를 확인해주세요.';
  }
};

4-2. 회원가입

회원가입은 입력할 때마다 회원가입 스키마를 통해 유효성검사를 먼저 하게 되고 맞았을 때만 우측에 체크 표시가 뜨도록 했다.
스키마를 모두 통과하면 버튼이 활성화되고 버튼을 누르면 Formdata로 input값을 서버에 post요청을 통해 보내주고, 받은 값에서 필요한 데이터인 userId, password, birth, userEmail만 받아서 기존에 있던 유저정보 배열에 추가하는 방식으로 구현했다.

4-3. JWT 토큰 적용 시 발생한 문제

jwt 토큰을 이용한 로그인 기능을 프로젝트에 적용하기 위해 진행 중이던 프로젝트를 클론 해서 jwt 토큰을 적용해 보았고 정상작동을 확인했다. 테스트 후 진행 중인 프로젝트에 jwt 토큰을 적용해 보았을 땐 기존에 테스트했던 환경과 달리 라우터가 적용된 상태였기 때문에 jwt 인증으로 로그인 유지하는 부분에서 어려움을 겪게 되었는데 화면전환 및 새로고침 시 항상 jwt 인증만을 거치는 요청을 보내게 하고 응답으로 true, false만 반환하게 하여 인증이 되게 했다.


5. 컨텐츠 필터링 / 조회 목록

프로세스플로우-02

유저정보를 기반으로 노출되는 컨텐츠 필터링 - 로그인 유/무, 성인 유저인지 아닌지 여부를 기반으로 메인페이지에서 노출되는 컨텐츠에 변화가 있어 이를 구현했다.

로그인 인증이 필요한 부분과 성인 인증이 필요한 부분이 나뉘어져있어 어떤 부분에서 어떻게 구현해나갈지 헷갈리는 부분이었는데 크게 메인페이지에서는 항상 로그인 인증 후 인증이 완료되면 유저정보의 age를 가져와서 조건부로 렌더링 하게 하고 성인 컨텐츠를 클릭했을 때도 유저 데이터에 따라 라우팅 유/무를 결정짓는다. 마이페이지로 라우팅 될 때에도 인증을 걸쳐 로그인 페이지로 이동될지 마이페이지로 이동될 지를 먼저 결정하고 마이페이지가 렌더링 된다면 최근 조회했던 목록도 같이 보여준다.

로그인시 로컬스토리지에 저장된 토큰의 페이로드에서 isAdult값이 true인지, false인지에 따라 보여지는 것을 다르게 한다.

const getPayload = () => {
  const decodeToken = localStorage.getItem('token');
  if (decodeToken) {
    const payload = JSON.parse(window.atob(decodeToken));
    const isAdult = new Date().getFullYear() - payload.birth >= 19;
    return { payload, isAdult };
  }
};

조회 목록도 아이디별로 로컬스토리지에서 관리하도록 했는데 리스트가 중복으로 남지 않도록 선택한 책 정보가 추가된 후 중복제거를 거쳐 다시 로컬스토리지에 저장하는식으로 최근 조회 목록을 만들었다.

const uniqData = recentData.filter((book, idx, arr) => 			 		
   arr.findIndex(data => data.title === book.title)
=== idx); // findIndex에서 반환한 인덱스와 filter의 인덱스가 같은 요소만 남긴다.
localStorage.setItem(payload.userId, JSON.stringify(uniqData));

회고

생각보다 다양한 것들을 경험하고 체득해 볼 수 있었던 프로젝트였던 것 같다. 큰 틀은 바닐라로 SPA구현이었지만 다른 기능들도 한번에 경험해 볼 수 있었던 점이 좋았다. 처음에는 화면을 컴포넌트별로 쪼개는 것이나 라우팅, 로그인 이렇게 큼직한 세가지만 해도 막막했는데 겪어보니 사실 그것들은 그냥 기본 베이스로 구현하고 가야하는 것처럼 느껴져서 프로젝트 자체가 약간 싱거운 맛?으로 느껴졌는데.. 애초에 우리 프로젝트 목적 자체가 그거였다.😅

배운 것 & 아쉬운 점

방향을 잘 잡아야

대학생 때부터 나는 항상 기획하는 것에 엄청 공을 들였다. 대학생 때 과제를 할 때도 진행방향이 명확하지 않으면 그 단계에서 진행을 하지 않았고 창작물은 항상 이유가 있고 의도가 분명해야 한다고 생각해서인지 기획단계가 거의 프로젝트 기간의 반을 잡아먹을 때도 있었다.(물론 계속 빠꾸먹어서 그런것도 있다..)

개발을 공부하고 있는 지금은 그 때와 동일한 상황이 아니지만 다른 의미에서 기획단계를 잘 짚고 넘어갈 필요가 있겠다고 생각이 든 건 프로젝트 초반에 해보지 않았던 것들을 공부하면서 이게 될지 안될지, 지금 하고자 하는 프로젝트에 적용해도 시간상 적절한지 맞는지 등 대충 머리속으로만 생각하고 안일하게 '되겠지~' 라고 생각했던 것이다. 이게 참 무서운게 잘못 설계 했을 때 작게는 시간상의 문제도 생길 수 있고 만약 프로젝트에서 중점으로 기획했던 부분이 불가능하게 되거나 하는 일이 생긴다면 아예 기획부터 뜯어 고쳐야 하는 상황이 일어날 수도 있겠다고 생각이 들었다.

하지만 이런 기본적인 문제는 다들 처음해보기 때문에 어쩔 수 없는 일이라고 생각이 들지만 지금 생각해보면 손코딩부터 들어가기 전에 모르는 분야에 대해 먼저 공부하고 의견을 나누는 시간을 기획단계로 묶어서 진행했으면 어땠을까 하는 생각이 든다. 물론 첫 단계가 길어져서 약간 초조할 수는 있겠지만 코딩하다가 갑자기 중간에 막히는것보단 그게 나을 것 같다.

서버가 두개 ?!

상대적으로 데이터를 대량으로 가지고 있는 사이트이다보니 초반에 mok데이터의 구조를 어떤 모양으로 설계할 것인지에 대한 고민도 필요했다. 그리고 파이어베이스를 사용하면 안된다는 규칙이 있어서 유저 데이터를 유지하게 하고싶어 서버를 2개 구축하는 실수를 저질렀다. 지금 생각해도 정말 이상했던것 같다.. 결국엔 오픈 되어도 상관없고 사용자를 구별할 수 있게 해주는 데이터만 뽑아서 한번더 토큰으로 변환해서 로컬스토리지에 저장하는 방법이 최선이었다고 생각한다. (그리고 백엔드가 없기 때문에 애초에 데이터를 유지하고 싶다는 것 자체가 욕심이었다.)

팀 프로젝트 어렵네...

생각보다 팀 프로젝트가 쉽지는 않았다. 팀 프로젝트를 하며 느낀 어려웠던 점 세가지.

(1) 커뮤니케이션

개발을 하면서 가장 크게 느끼는 것 중 하나가 커뮤니케이션이 생각보다 정말 힘들다는 것이다. 각자 다른 언어를 쓰는 것도 아닌데 쓰는 개발 용어나 단어가 상대방과 내가 생각하는게 서로 맞는지 항상 확인해야한다. 사실 어딜 가든 커뮤니케이션 능력은 중요하겠지만 개발을 하면서 더 크게 느끼고 있다. 모두 공부를 하고 있음에도 개개인이 가지고 있는 지식이 완전히 동일할 수가 없기 때문에 더욱 어려운 것 같기도 하다. 이번에 프로젝트를 하면서도 비슷하게 어렵다고 느꼇다.

(2) 각자 가진 속도가 다르다

각자 코드를 짜는 속도가 똑같지 않기 때문에 항상 어떤 사람은 금방 끝나고 어떤 사람은 상대적으로 조금 느릴 수 있다. 그렇다고 일찍 끝난 사람이 나머지를 다 할 수도 없는 노릇이기에 처음에 나누었던 역할 분담이 약간 애매해짐을 느꼇다. 중간중간 서로의 진행정도에 대해 모여서 회의하는 시간을 가지고 그 때마다 일의 분담을 다시 나누었다.

(3) 다수가 하나의 코드를 짠다는 것

프로젝트를 시작하면서 가장 간과한 점은 코딩 컨벤션의 중요성을 몰랐다는 것이다. 사실 부끄럽지만 나는 코딩 컨벤션이 뭔지도 몰랐다.. 😳 다수가 모여 하나의 코드를 짠다는 것은 생각보다 복잡한 일이었고 자칫하면 중구난방이 될 수 있어 일관성있는 코드를 짜기 위해서는 처음부터 세세하게 규칙을 정하는 일이 필요했다. 하지만 우리는 그 중요한걸 몰랐기에.. 의도치 않게 각자가 가진 다양성을 볼 수 있게 되었다^^.. 그래도 한번 겪었으니까 다음엔 처음부터 꼼꼼하게 짚고 넘어갈 수 있을 것이다.

기록을 해 두자.

처음으로 해본 팀 프로젝트이다 보니 배운 점도 많지만 아쉬운 점이 더더욱 많게 느껴지는 것 같다.
프로젝트를 진행하면서 새롭게 배운 내용들이 많았는데 그 내용들을 각자 완전히 이해하고 기록할 시간을 공통으로 갖기는 시간상 부담스럽게 느껴져 공통으로 기록해 두진 못했다. 그래도 회고를 쓰면서 개인적으로 다시금 복기해 볼 수 있는 시간이 되었던 것 같다. 지금 어렵다고 느끼고 부족하다고 느끼는 점이 많기 때문에 하나하나 배워가는것이 더욱 뜻깊다. 머리에도 남지만 모호했던 것들을 글로 써두었을 때 더 뚜렷해지는 것 같다. 그런 면에서 회고를 작성하는 시간이 프로젝트 중 가장 중요한 단계가 아닌가하는 생각도 든다.


/ -- Fin -- /

profile
즐겜하는거죠

0개의 댓글