7 분 소요

웹뷰

배경 🧑🏻‍💻

이번에 기회가 되어 Vanlia JS로 SPA를 구현하는 챌린지에 참가하게 되었다! 항상 SPA가 이미 구현되어져 있는 React를 주로 사용하여 작업을 하다보니 SPA SPA 거리지만 어떤식으로 동작하는지 모르고 사용했던것 같다.
물론 React와 React-router-dom은 훨씬 더 복잡하게 구현되어 있을 것이지만 Vanlia JS로 SPA를 구현해보고 안해보고는 경험적으로 큰 차이가 있을 것 같아 시도해 보았다!

프로젝트 Github 링크


활용한 라이브러리와 그 이유 ⚙️

1. Typescript

아니 Vanlia JS로 구현한다면서 왜 타입스크립트냐? 라고 할 수 있지만 Typescript은 Javascript의 슈퍼셋이다. 가장 큰 장점은 기존 자바스크립트에서 정적 타입 시스템을 도입하여 예측이 가능한 프로그래밍을 할 수 있게 도와준다. 이는 자바스크립트에서 왕왕 발생하는 런타임 에러를 줄여줄 수 있다.
추가로 본인은 함수를 작성할때 매개변수 타입과 return 타입을 먼저 명시해줌으로써 함수가 어떤동작을 하게 될지 생각을 사전에 할수 있다는 점이 좋았다.


2. Sass (Syntactically Awesome StyleSheets)

CSS 전처리기는 전처리기의 자신만의 특별한 syntax (en-US)를 가지고 CSS를 생성하도록 하는 프로그램입니다. -MDN

CSS의 단점들(유지보수 어려움, 재사용 어려움, 거대한 파일)을 보완하기 위해 출시되었고 변수, 함수, 반복문 등으로 CSS를 조금 더 편안하게 작성할 수 있고, 재사용, 유지보수가 용이하다. 하지만 웹브라우저는 오직 CSS파일만 읽을 수 있기 때문에 결국 CSS파일로 컴파일 된다.

웹뷰 아직도 많은 곳에서 Sass를 쓰고 있었다.


3. Webpack (Syntactically Awesome StyleSheets)

모듈 번들러란 웹 애플리케이션을 구성하는 자원(HTML, CSS, Javscript, Images 등)을 모두 각각의 모듈로 보고 이를 조합해서 병합된 하나의 결과물을 만드는 도구를 의미한다. 즉 JS,CSS,Image파일을 하나의 JS파일로 번들하기 위해 사용하였고, 이를 통해 웹브라우저에서도 하나의 JS파일만 요청해서 image,css도 같이 렌더링 할 수 있다는 이점이 있다. 추가로 webpack-dev-server를 통해 이미 번들된 상태에서 개발할 수 있었다.


프로젝트 디렉토리 구조

파일 구조는 해당 프로젝트의 Readme.md 파일에 정리해 놓았다.


메인 로직 🧠

1. 라우터 router.ts

SPA 개발의 핵심 로직들은 router.ts에 있고 Class로 정의해 놓고 관리했다.
간단하게 Router Class는 routes : 페이지정보가 담긴 배열, rootElement : html의 진입점 <main id='root'><main>을 인자로 받고 내부 메서드들을 이용해 history.pushState()로 url이 바뀔때마다 해당 url에 매칭되는 페이지 컴포넌트를 렌더링 해준다.
추가로 Router Class가 인스턴스화 되면 window.addlistener 2개가 등록이 되는데 하나는 popState : 페이지 뒤로가기, 앞으로가기 할때 발생하는 이벤트, DomContentLoaded : html요소가 다 그려졌을때 이벤트가 발생하고 콜백함수인 router() 메서드가 실행된다. 추가로 Router Class는 싱글톤 패턴으로 프로젝트 전체에 하나의 인스턴스만 가지게 작성하였다.

전체 코드
import { Replace, Route, TargetPage } from "./types/index";
import NotFoundPage from "./pages/notFoundpage";

class Router {
  constructor(private mainRoute: Route[], private root: HTMLElement) {
    // 뒤로 가기 했을때, 앞으로 가기 했을때
    window.addEventListener("popstate", () => {
      this.router();
    });
    // 새로고침해도 해당페이지로 이동
    window.addEventListener("DOMContentLoaded", () => {
      this.router();
    });
  }
  // 페이지 이동할때 쓸 함수
  public navigate(url: string, replaceOption?: Replace) {
    if (replaceOption?.replace) {
      history.replaceState(null, "null", url);
    } else history.pushState(null, "null", url);
    this.router();
  }

  public handleNavigateBack() {
    history.back();
  }

  private pathToRegex(path: string) {
    return new RegExp(
      "^" + path.replace(/\//g, "\\/").replace(/:\w+/g, "(.+)") + "$"
    );
  }

  private getParameters(targetPage: TargetPage) {
    const values = targetPage?.result?.slice(1);
    const keys = Array.from(targetPage.route.path.matchAll(/:(\w+)/g)).map(
      (result) => result[1]
    );

    const paramsObj = Object.fromEntries(
      keys.map((key, index) => {
        return [key, values && values[index]];
      })
    );

    return paramsObj;
  }

  private async router() {
    const advancedRoutes = this.mainRoute?.map((route) => {
      return {
        route,
        result: location.pathname.match(this.pathToRegex(route.path)),
      };
    });

    let targetPage = advancedRoutes.find(
      (targetRoute) => targetRoute.result !== null
    );

    if (targetPage) {
      // 실제 타켓 페이지 인스턴스화
      const pageInstance = new targetPage.route.page(
        this.root,
        this.getParameters(targetPage)
      );
      await pageInstance.render();
    } else {
      new NotFoundPage(this.root).render();
    }
  }
}

export default Router;


  • router() 메서드 : SPA 구현의 핵심 메서드.
    • pathToRegex메서드로 각 페이지의 url을 Regex로 변경한다.
    • 현재 브라우저의 url이 Regex에 매치되는지(.match()메서드)를 확인하고, result 프로퍼티에 할당하여 advancedRoutes라는 배열을 생성한다.
    • array.find()메서드로 배열의 아이템(여기서는 객체)의 result 프로퍼티가 null이 아닌 아이템을 찾아 targetPage에 할당한다.
    • targetPage가 즉 현재 브라우저에서 열려있는 페이지를 의미한다.
    • targetPage가 있다면? targetPage객체의 route프로퍼티안의 page프로퍼티를 사용해 해당 페이지를 인스턴스화 시키면서 페이지가 렌더링된다.
const advancedRoutes = this.mainRoute?.map((route) => {
  return {
    route,
    result: location.pathname.match(this.pathToRegex(route.path)),
  };
});

let targetPage = advancedRoutes.find(
  (targetRoute) => targetRoute.result !== null
);


  • navigate(url, replaceOption?) 메서드 : SPA 구현의 핵심 두번째 메서드.
    • url은 변경할 url, replaceOption은 옵션으로 주게되면 이전 페이지와 대체된다. (react-router-dom의 navigate의 메서드에서 영감을 받음)
    • pushState로 url이 변경되고, 페이지 스택이 하나 쌓이게 된다.
  public navigate(url: string, replaceOption?: Replace) {
    if (replaceOption?.replace) {
      history.replaceState(null, "null", url);
    } else history.pushState(null, "null", url);
    this.router();
  }


  • getParameters(targetPage) 메서드 : SPA 구현의 핵심 세번째 메서드. 상세 페이지나, 수정하기 페이지같이 뒤에 parameter가 붙을때 /post/23, /edit/28 이런경우 뒤에 붙어있는 id값을 객체형태로 가져오기 위한 메서드
  private getParameters(targetPage: TargetPage) {
    const values = targetPage?.result?.slice(1);
    const keys = Array.from(targetPage.route.path.matchAll(/:(\w+)/g)).map(
      (result) => result[1]
    );

    const paramsObj = Object.fromEntries(
      keys.map((key, index) => {
        return [key, values && values[index]];
      })
    );

    return paramsObj;
  }


2. 페이지 mainpage.ts, detailpage.ts, editpage.ts, writepage.ts

페이지.ts 파일들은 실제 렌더링될 페이지들이다. 각각의 페이지마다 기능들은 다르지만 공통적으로 페이지를 그려줘야하기 때문에 html을 백틱안에 넣어 string으로 만들고 이것을 root에 innerHTML로 넣어주는 공통 작업을 진행한다.

  • makePageTemplate() : 실제 페이지를 만드는 공통 메서드.
  makePageTemplate() {
    return `
            ${CommonHeader.makeTemplate({
              title: 'Happy New Year 2023 🐰',
              subTitle: '무슨 인사들이 올라왔을까요? 😊',
            })}
            <section class='main-content'>
              <h1 class='visually-hidden'>게시글 목록 리스트</h1>
              <ul class='post-list'></ul>
            </section>
            <footer class='main-footer'>
              <button class='fab-button'>
                <i class='icon-pencil'></i>
              </button>
            </footer>
          `
  }
  • render() : 페이지 컴포넌트의 핵심 메서드. 실제 root요소에 innerHTML로 붙여넣어 주고, 각 페이지 로직들을 실행시킨다.
  • attchPostPreviews(posts, parentElement) : 게시글 리스트를 인자로 받아서 htmlTemplate으로 만들어 <ul>에 붙여주는 메서드
  • attachPreviewImage(imageUrl, args, parentElement) : 작성하기 페이지의 사진 미리보기를 htmlTemplate으로 만들어 부모 Elment에 붙여주는 메서드
  • attchComment(comments, parentElement) : 상세 페이지의 댓글 list을 htmlTemplate으로 만들어 부모 Element에 붙여주는 메서드


3. 기타 util 함수 utils.ts

프로젝트에서 공통적으로 쓰이는 기타 함수들은 따로 ultil로 빼서 import해서 사용했다.

  • stripHTML(text) : 사용자에게 input으로 받은 값에서 만약 html태그들 이나 script태그가 들어갔을때를 막기위하여 태그가 포함된 텍스트들은 제외해 주는 함수이다.
export const stripHTML = (text: string): string => {
  const regexForStripHTML = /(<([^>]+)>)/gi;
  return text.replace(regexForStripHTML, "");
};


유저의 특정행동을 고려한 대처 🤦🏻

1. 유저가 존재하지 않는 게시물을 조회했을때

커뮤니티 게시판이다 보니 게시물 삭제가 빈번하게 일어날 수 있다. 만약 유저가 이미 삭제된 게시글을 조회하려고 상세페이지로 진입시 에러메세지와 함께 handleNavigateBack()메서드로 강제로 뒤로가기를 실행시켰다.


2. 랜덤이미지생성, 게시글, 댓글을 제출할때 연타 금지

추가, 수정, 삭제시 보통 버튼을 눌러 요청을 보내는데 네트워크가 느리거나 할때 특정 유저들은 동작이 안되는줄 알고 버튼을 연타하는 경우가 있다. 이 경우 요청을 보내고 통신중일때는 버튼을 아예 비활성화 처리해서 누르지 못하도록 만들었다. UI 또한 button-disabled가 되면 버튼을 회색으로 css 속성으로 ` cursor: not-allowed` 아예 커서 반응하지 않도록 만들었다.


3. 유저의 입력값을 화면에 렌더링 시킬때 특정 코드 출력 금지

특히 유저가 입력하는 값들은 어떤 값들이 들어올지 모른다. XSS 공격같은 것이 보통 이러한 악성 케이스인데 정석적으로는 입력값을 바로 <p>${post.title}</p> 이런식으로 string template으로 넣는게 아닌 실제 DOM에 접근해서(여기서는 p태그) 값을 innerText로 넣어주는게 맞다.

const pTag = document.querySelector("p");
pTag.innerText = post.title;

하지만 나는 이 방식보다 아예 값에 <> 이러한 태그가 있으면 제거해주는 utility 함수를 선언해서 대응하였다.


4. 유저가 옳지 않은 url로 강제 요청을 보냈을때 404page

유저가 만약 routes 배열에 있는 url과 다른 url을 요청했을 경우에는 404page로 이동시키도록 만들었다.

어려웠던점 / 해결방법 🏋🏻‍♀️

기본 SPA 구현

처음에는 정말 어떤식으로 구현을 해야하는지 몰랐다. history.pushState를 사용해야 한다는것, window.popstate를 사용해야 한다는것등 감이 잡히지 않았지만, route 배열을 만들고 그 안에 객체로 url과, 렌더링할 페이지를 프로퍼티로 넣어 어떻게 조작하면 될 것 같다고 생각했다.
핵심은 url이 바뀔때마다 다른 페이지를 보여주면 된다는 것이었다.
추가로 뒤로가기나 앞으로가기, 새로고침을 했을때는 항상 router()메서드를 실행시켜 현재 페이지 url에 대한 페이지를 다시 렌더링 해주면 되게 만들었다.


React에 너무 익숙했다.

React는 변경될 값들은 state에 저장해 놓고 변경되면 자동으로 리렌더링이 이루어진다. 하지만 자바스크립트는 이를 지원하지 않고, 어떤 로직을 실행후 페이지 혹은 컴포넌트를 render()메서드로 렌더시켜 최신화 시켜주어 처리하였다.


Fetch 메서드의 에러핸들링

실무에서는 보통 Axios를 많이 사용하다보니 400,500에러를 쉽게 처리할 수 있었다. 하지만 이번에 페칭 라이브러리를 쓰지 않고 JS에서 제공하는 fetch함수를 써보았다. 400,500에러를 자동으로 잡아주지 않기 때문에 서버의 에러메시지를 받아서 처리하기 까다로웠다.
해결했던 방법은 fetch후 받아오는 res값에 따라 분기를 통해 해결하였다.

  private async handleAfterFetch(res: Response, callBackFunc?: () => void) {
    if (res.ok) {
      callBackFunc && callBackFunc()
      return res.json()
      // 만약 다른 에러라고 하면??
    } else if (res.status !== 200 || 201) {
      const error = await res.json()
      return Promise.reject(error)
    } else throw new Error('Network has something wrong......... 📡')
  }

그리고 Promise.reject(error) 한 부분을 한단계 위에서 catch로 잡아 throw Error를 실행해 주었다.

  // post 요청함수 예시
  public async post<T>(url: string, data: T, callBackFunc?: () => void) {
    return await fetch(url, {
      method: 'POST',
      headers: this.headers,
      body: JSON.stringify(data),
    })
      .then((res) => this.handleAfterFetch(res, callBackFunc))
      // 이 catch 부분에서 Promise.reject하는 부분을 error.message로 에러를 던진다.
      .catch((err) => {
        throw new Error(`${err.message}`)
      })
  }

실제로 fetching을 실행하는 부분에서 try, catch로 error를 잡아 alert(${err.message})로 실행하였다.

try {
  // fetch....
} catch (err) {
  alert(`😵${err}`);
} finally {
}


댓글 추가 삭제는 추가,삭제한 아이템만 렌더링. 전체 댓글list 렌더링 X

댓글 추가 삭제는 고민을 좀 해서 작성을 하였다. 댓글을 추가하고, 삭제할때 댓글 list를 다시 렌더링하는 것보다는 추가한 댓글만 그려주고, 삭제한 댓글만 삭제해주는 방식으로 렌더링을 했다. 아무래도 DOM을 다시 그릴때 reflow, repaint를 거쳐 다시 조립을하고 요소를 끼워넣는데, 많은 요소들을 리렌더시키면 그만큼 속도, 자원도 많이 들것같다는 생각에 이렇게 구현했다.
댓글 추가를 할때는 댓글을 추가하면 response로 방금 추가한 댓글이 내려오는데 이것을 바로 htmlTemplate에 정보를 넣고 ul태그에 붙였다.
댓글 삭제 할때는 댓글 삭제 API 요청후에 삭제한 댓글 Elment를 찾아서 ul태그에서 removeChild()했다.


공통 Header를 컴포넌트로?!

각 페이지에는 공통적으로 Header 영역이 들어간다. 이 부분을 계속 매 페이지마다 html로 작성하는 것보다 하나의 파일로 빼서 관리하여 React props 처럼 필요한 데이터를 그때그때 넘겨서 렌더링하는게 좋다고 생각하여 공통 컴포넌트로 CommonHeader.ts 파일을 분리 시켰다. 진짜 react의 컴포넌트가 된 느낌이랄까?… 신기했다.

type HeaderProps = {
  title: string;
  subTitle: string;
  buttonTemplate?: string;
};

class CommonHeader {
  static makeTemplate({
    title = "제목",
    subTitle = "소제목",
    buttonTemplate,
  }: HeaderProps) {
    return `
      <header class='main-header'>
        <nav>
          ${buttonTemplate ? buttonTemplate : "<button></button>"}
          <h1>${title}</h1>
        </nav>
        <div class='main-header-notice'>
          <p class='sub-title'>${subTitle}</p>
        </div>
      </header>
    `;
  }
}

export default CommonHeader;


전체적으로 느낀점 🙆🏼

이번에 Vanlia JS로 SPA를 구현하면서 기존에 놓치고 있었던 포인트들을 배울 수 있었다.
신기하게도 이 작업을 하는동안 회사에서 React를 가지고 일을 할때 조금 더 렌더링 흐름을 이해하는데 도움이 되었다. 앞으로도 Vanlia js로 React의 State만들어보기, Redux의 옵져버 패턴등을 구현해 볼 계획이다.
자바스크립트 다루는게 능숙하다면 프레임워크, 라이브러리에 국한되지 않고 코드를 작성할 수 있을 것같다는 생각이 들었다.


Reference 📚

타입 스크립트를 왜쓰는가?

댓글남기기