Earn this, Earn it.

JavaScript로 직접 SPA 구현하면서 느낀 React 본문

[개발 공부]

JavaScript로 직접 SPA 구현하면서 느낀 React

Narastro 2021. 9. 22. 10:58

이번 프로젝트에서는 JavaScript만을 사용해서 SPA를 구현해야하는 제한 사항이 주어졌습니다.

저는 이전까지는 리액트를 많이 써보지 않아서 리액트의 편리함을 제대로 느껴본 적이 없었는데, 이번 기회에 제대로 느끼게 되었다고 생각합니다...하하

오늘 포스팅에서는 직접 JavaScript로 SPA 구현하면서 느낀 React의 역할과 장점 등에 대해 얘기해볼까 합니다.

(오로지 혼자 고민해보고 적용해보고 이런저런 실패기를 겪으며 고민한 내용에 대해 포스팅해보려 합니다.)

 

 

JavaScript로 View Component를 구현해보자

가장 처음 이러한 제한 사항을 받고 검색한 결과 아래 유튜브 링크를 찾을 수 있었습니다.

https://www.youtube.com/watch?v=6BozpmSjk-Y 

이 영상은 약 30분짜리 영상으로, JS로 Single Page App을 구현하는 것을 내용으로 합니다.

간략히 살펴보자면 View Component들은 인터페이스 역할을 하는 추상 클래스를 상속하여 구현하고

각 View Component들은 템플릿 리터럴로 이루어진 getHtml이라는 메서드를 통해 html을 리턴합니다.

 

 

소스코드로 보자면 아래와 같습니다.

import AbstractView from "./AbstractView.js";

export default class extends AbstractView {
    constructor(params) {
        super(params);
        this.setTitle("Posts");
    }

    async getHtml() {
        return `
            <h1>Posts</h1>
            <p>You are viewing the posts!</p>
        `;
    }
}

 

또한 index.js에서 SPA 라우팅을 담당합니다.

라우터 함수만 살펴보자면 아래와 같습니다

const router = async () => {
    const routes = [
        { path: "/", view: Dashboard },
        { path: "/posts", view: Posts },
        { path: "/posts/:id", view: PostView },
        { path: "/settings", view: Settings }
    ];

    // Test each route for potential match
    const potentialMatches = routes.map(route => {
        return {
            route: route,
            result: location.pathname.match(pathToRegex(route.path))
        };
    });

    let match = potentialMatches.find(potentialMatch => potentialMatch.result !== null);

    if (!match) {
        match = {
            route: routes[0],
            result: [location.pathname]
        };
    }

    const view = new match.route.view(getParams(match));

    document.querySelector("#app").innerHTML = await view.getHtml();
};

즉 현재 path 정보에 맞는 컴포넌트를 갈아 끼우는 형태인거죠.

이는 페이지 전체의 재랜더링이 일어난다고 볼 수 있습니다. 만약 리액트처럼 가상 DOM을 만들어서 변경된 부분만 재랜더링할 수 있다면 좋겠지만 이러한 가상 DOM을 제작하는 것은 2주 안에 무리였습니다. (그리고 이때까지만 해도 가상 DOM을 만들어야겠다는 생각조차 못하긴 했지만요...)

 그보다 당장 시급한 문제는 상태가 정의되어 있지 않아서 페이지 단위로만 움직인다는 것입니다. 여기까지 구현을 마쳤을 때(상태 관리에 대한 개념이 아직 제대로 있지 않을 때) 저는 굳이 SPA를 구현할 때 리액트가 필요한가? 라는 의문이 들게 되었습니다.

 

 

상태... 그게 뭔가요??

이번 프로젝트 미션은 AirB** 클론 코딩 미션으로, 한 페이지 내에서 다양한 상태가 존재합니다. 날짜를 선택할 수 있는 모달창이 열리거나 닫히고, 금액 범위를 선택할 수 있는 모달창이 열리거나 닫히고, 인원을 선택할 수도 있죠. 

리액트를 잘 모르던 시절의 저는, '상태 관리'란 단어 자체의 의미를 이해하지 못했습니다.
'그냥 각각의 DOM에 이벤트 달아주고 핸들러 메서드 달아주면 되는거 아닐까?'

네. 아니었습니다. 그때 이렇게 생각했던 제가 부끄러울 정도로 상태 관리는 굉장히 중요한 부분이었습니다.

왜냐하면 날짜가 선택되어 있으면 지역이나 금액대를 설정할 수 있어야 하고 지역이 선택되면 해당 지역의 금액별 분포를 알아야할 수도 있으니까요. 한 마디로 각 컴포넌트에서 모든 상태 변화를 분기해서 처리하는 것은 매우매우 비효율적이라는 것입니다.

또한 데이터에 대한 처리도 마찬가지였습니다. AJAX로 받아온 데이터를 관리해줄 무언가가 필요했습니다.

지금 이대로 코드를 작성하다 보면 중복 코드와 복잡한 스파게티가 탄생할 것이 분명했습니다.

그래서 저는 고전적인 디자인 패턴인 MVC 패턴으로 이들을 정리해보고자 하였습니다.

하지만 프론트엔드에서 MVC 패턴을 어떻게 적용해야할지, JavaScript로 어떻게 구현할 수 있을지 고민이었습니다.

 

 

 

프론트엔드에서 MVC패턴이란?

이미지 출처 :&amp;amp;amp;amp;amp;amp;nbsp;https://www.miraeweb.com/single-post/2016/11/17/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%9B%B9%EC%95%A0%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98-%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90-%EB%B9%84%EA%B5%90%EB%B6%84%EC%84%9D-mvc%EC%99%80-mvvm

이때 예전 백엔드에서 자주 써먹던 MVC패턴이 떠올랐습니다. 실제로 SPA 프레임워크들이 MVC, MVVM, MVP와 같은 디자인 패턴으로 이루어졌다는 사실을 알게되었습니다. 그 중 가장 고전적인 MVC 패턴입니다.

 

저는 무작정 따라하는 것이 아닌, 각각의 역할에 대해 고민해보고 적용해보려하였습니다.

하지만 프론트엔드에서 MVC패턴은 제가 예전에 사용하던 백엔드의 MVC패턴과는 다르게 Model과 Controller를 어떻게 나눠야할지에 대한 고민이 생겼습니다.

흔히 Model은 데이터에 대한 저장소를, Controller는 View와 Model 중간 다리 역할을 합니다.

이전의 백엔드 MVC패턴 같은 경우 Model에서는 DB스키마를 정해주고, Controller에서는 라우팅 함수들이 있었다면,

프론트엔드에서는 Model을 어떻게 정의해야할지 난감했습니다.

고민 끝에, 백엔드의 데이터는 DB에서 가져오니까 DB스키마를 썼고

프론트엔드의 AJAX 요청으로 받아온 데이터를 저장하는 저장소를 Model로 지정하면 되겠다! 라고 결론짓고 시도해보았습니다.

그리고 Controller는 사용자의 입력을 받아 핸들러 함수를 실행하고

Model(Store)에서 데이터를 받아와 이를 View에게 전달해주는,

즉, View와 Model을 이어주고 상태 관리를 담당해주는 역할을 하도록 하면 되겠다 라고 생각하고 시도하였습니다.

 

 

하지만 구현하면 할수록 뭔가 불편하다...

무작정 구현에 뛰어들었을 때, View 컴포넌트의 상태 변화에 따라 Controller에 해당 View 컴포넌트를 불러와 일일이 render 메서드(저는 getHtml이라 하였습니다)를 호출하는 것은 너무나 반복적이고 따분한 작업이었습니다.

그래서 더 좋은 방법을 생각해보았습니다.

구독하고 있는 View 컴포넌트를 한 번에 렌더링 시키는 방법... 뭐가 있을까요...

이에 대해서 공부해보던 중, 옵저버(Observer) 패턴이라는 것을 알게 되었습니다.

이미지 출처 :&amp;amp;amp;amp;amp;amp;nbsp;https://dailyscat.gitbook.io/twis/designpattern/js-mvc+observer

View 컴포넌트들을 관찰하다가 상태가 변경되었을 때, Controller를 통해 Model에 알리고, Model은 관찰 중인 모든 View 컴포넌트에게 알려 상태를 업데이트 시키는 방식입니다.

 

하지만 위와 같은 흐름 속에서 View가 Controller를 호출하는 방식에 대한 고민이 생겼습니다.

왜냐하면 View가 Controller의 함수들을 직접 불러와 이를 호출하는 것은 양방향 의존성이 생기는 부분이기 때문입니다.

모듈간 의존성이 생기면, 변경에 대해 관대해져 애플리케이션이 커질수록 버그를 만들기 쉽고 유지 보수를 어렵게 하는 문제가 생길 것이라 생각했습니다.

 

 

이벤트 에미터를 이용해볼까?

그러다 문득 이전에 배웠던 이벤트 에미터 방식이 떠올랐습니다. 이는 단방향으로 호출할 수 있는 대표적인 방법이자 서로 간의 의존성을 줄일 수 있는 방법이기도 했습니다.

저는 아래와 같이 controller를 다시 수정했습니다.

controller.js

import Event from "events";
import { openCalendarModal } from "./handleCalendarModal.js";
import { openFeeModal } from "./handleFeeModal.js";
import { openPeopleModal } from "./handlePeopleModal.js";
import { renderResultPage } from "./handleSearchBtn.js";
import { getFormData } from "./handleResultPage.js";

const event = new Event();

const closeContent = (e) => {
  const isForm = e.target.closest(".form-bar");
  const isContent = e.target.closest(".content");
  const isBtn = e.target.closest(".move-btn");

  if (!isContent && !isForm && !isBtn) {
    const content = document.querySelector(".content");
    content.innerHTML ? (content.innerHTML = "") : null;
  }
};

export const activateSearchBtn = () => {
  const searchBtn = document.querySelector(".search-btn");
  searchBtn.classList.add("activation");
  searchBtn.addEventListener("click", renderResultPage);
};

event.on("click", (e) => closeContent(e));
event.on("openCalendarModal", openCalendarModal);
event.on("openFeeModal", openFeeModal);
event.on("openPeopleModal", openPeopleModal);
event.on("getFormData", getFormData);

export default event;

 여기서 이벤트 에미터를 활용하여 컨트롤러를 호출하도록 하였고 이는 의존성을 줄이고 코드 복잡도가 높아졌을 때 유지보수나 관리 측면에서 이점을 얻을 수 있었습니다.

 

 

그렇게 완성된 나만의 MVC패턴

이로써 프론트엔드에서 MVC 패턴을 얼추 적용해볼 수 있었습니다.

아래는 VSCode 확장앱인 디펜던시 크루저를 이용해 디렉토리 구조를 출력한 그림입니다.

 

간단한 설명

  • 진입점인 app.js에서 싱글페이지 라우팅을 담당
  • view는 html과 이벤트 등록만을 담당
  • controller는 view에서 event-emitter로 호출됨 + model의 데이터를 조회하고 이벤트 핸들러 함수들로 구성됨
  • model은 데이터를 저장하고 분석하는 역할

장점

  • MVC 패턴을 적용하여 최대한 각 역할에 맞게 구분되어 있다는 것. 그렇기 때문에 디버깅시 손봐야할 곳이 어딘지 쉽게 알 수 있음.
  • 이벤트 에미터를 적용하여 단방향으로 Controller 호출이 가능하다는 점
  • View에서 렌더링과 이벤트 등록만을 담당하기 때문에 상태 관리가 수월하다는 점 (코드가 모여있는 것보다 상대적으로 수월하다는 말이지 상태 관리가 한 눈에 들어오지는 않습니다..)
  • 모듈을 작은 단위로 나눠서 모듈 하나의 코드 길이가 길지 않다는 것

단점 (아직 장점보다 단점이 더 많네요... 부족한 내 코드ㅠㅠ)

  • 모듈이 세분화되어있어서 특정 요소를 찾을 때 깊이 들어가야할 수도 있다는 것 (디렉토리 깊이가 깊어질 우려)
  • 새로운 구성을 추가할 경우 각 디렉토리에 뿔뿔히 흩어져 있는 모듈들을 일일이 찾아서 수정하거나 추가해줘야한다는 점
  • 추상클래스를 제대로 활용하지 못했다는 점 (모달창의 경우 추상클래스를 적용하지 못했음 -> 확장성이 부족함)
  • 상태관리를 제대로 활용하지 못했다는 점 (특정 컴포넌트가 상태마다 달라질 수 있도록 컨트롤하지 못했음 -> 새로운 구성 추가시마다 새로운 파일을 생성하고 작업할게 꽤 있다는 점)
  • 코드가 많아질수록 Controller가 비대해진다는 점

 

그래서 React의 역할이 뭔데?

 

일단 제가 느낀 장점은 아래와 같이 크게 두 가지입니다.

 

 

1. 내 코드에서의 Controller 역할을 해주고 있었구나 (M과 V 사이 무언가의 역할을 하는구나!!)

 

(리액트가 MVC 패턴이라는 것은 아닙니다. 이는 구현하기 나름이라고 생각합니다. )

여기까지 진행하고 난 뒤 리액트를 떠올려보면, 리액트는 각 컴포넌트의 상태가 변경될 때마다 자동으로 그와 연관된 컴포넌트를 다시 렌더링 해줍니다. 위에서 보면 Controller의 역할이죠. 

저의 경우는 옵저버 패턴과 이벤트 에미터를 이용한 단방향 호출로써 저만의 방식으로 렌더링에 대한 반복 작업을 처리했지만 리액트는 우리가 모르는 뒷단에서 자동으로 이를 처리해줍니다.

 

 

2. DOM API로 직접 DOM을 조작하지 않아도 되는 구나

 

위와 같이 리액트가 반복 작업을 처리해줌으로써 얻는 이점은 무엇일까요?

리액트는 가상 DOM을 통해 실제 DOM과 비교해 변화가 있는 부분만 재렌더링합니다. 이 역시 리액트가 제공하는 강력한 장점 중 하나이죠. 제 코드에서는 가상 DOM과 같이 DOM 최적화에 대한 부분은 신경쓰지 못했지만, 만약에 이를 신경써야 한다고 하더라도 실제로 DOM API를 통해 DOM을 조작하는 일을 신경써야하는 셈이 되는 것입니다. 즉, 우리는 리액트를 씀으로써 DOM API로 DOM을 조작하는 일을 신경쓰지 않고 상태 관리나 비즈니스 로직에 더 집중할 수 있게 되겠죠. 이는 실제로 겪어보았을 때, (보일러 플레이트가 없다고 했을 때) 편리함이 크게 체감되는 부분이었습니다. 만약 잘 짜여진 보일러 플레이트가 있다면 이러한 단점을 보완할 수 있겠지만, 그보다는 막강한 생태계를 가진 리액트를 쓰는 것이 좋겠지요? ㅎㅎ 

 

 

평소에 단지 글로만 보던 리액트의 역할과 장점을 실제 바닐라 JavaScript로 구현하면서 뼈저리게 느끼게 되는 순간이었습니다..😁 개선의 시작은 불편함을 느끼는 것이라는 말처럼, 실제로 2주 동안 불편함을 느껴보니 왜 리액트가 등장하게 되었고 그토록 각광 받는지 이해하는 데에 도움이 되었습니다.

 

2주 프로젝트를 진행하며 상태 관리에 대한 부분을 신경 쓰지 못해서 아쉽지만,

추후 다른 프로젝트에서는 리액트 상태 관리에 대한 부분도 신경 쓰며 진행해보고 싶네요

이상 포스팅을 마치겠습니다 ( _ _ )