JavaScript와 Webpack을 이용한 SPA만들기

hoon·2023년 3월 21일
0

왜 SPA(Single Page Application) 일까?

SPA는 전통적인 Multi Page Application (MPA)와 달리 페이지를 다시 로드하지 않고, 동적으로 페이지의 일부분만 변경하는 웹 어플리케이션이다. 이것은 사용자 경험을 향상시키고, 더 빠르고 반응성 있는 웹 애플리케이션을 만드는 데 매우 유용하다.

SPA는 대개 JavaScript 프레임워크 또는 라이브러리 (React, Vue.js, Angular 등)와 함께 사용되며, 클라이언트 측에서 모든 렌더링 및 데이터 처리를 처리한다. 이것은 서버 측에서 데이터를 가져 오는 대신 API를 통해 데이터를 가져 오기 때문에, SPA를 구현할 때 RESTful API가 필요하다는 것을 의미한다.

따라서 SPA를 구현하려면 JavaScript 프레임워크나 라이브러리를 사용하여 뷰 렌더링, 데이터 처리 및 라우팅을 처리하는 것이 좋다. 이번에 구현하려는 SPA는 Vanilla JS로 만들어질 것이며, 이를 위해 뷰 렌더링, 데이터 처리 및 라우팅 기능을 모두 수동으로 작성할 것이다.

SPA는 한 개의 페이지에서 필요한 데이터만을 받아와서 화면을 동적으로 갱신하는 웹 어플리케이션을 의미하며, SPA를 만드는 방법은 여러 가지가 있지만, 대표적으로는 다음과 같은 기술을 활용한다.

  1. 라우팅(Routing)
    SPA는 페이지 전환 없이 화면을 갱신하므로, URL이 변경되어도 페이지를 새로 로드하지 않아야 한다. 이를 가능하게 해주는 것이 라우팅(Routing)이다.
  2. AJAX 및 RESTful API
    SPA는 필요한 데이터만을 서버로부터 받아와서 화면을 갱신한다. 이를 가능하게 해주는 것이 AJAX(Asynchronous JavaScript and XML)이다. AJAX를 사용하여 서버로부터 데이터를 받아오기 위해서는 서버 API가 필요하다. 서버 API는 대부분 RESTful API를 사용한다.
  3. 모듈 번들러(Module bundler)
    SPA는 대부분 모듈 시스템을 사용하여 코드를 구성한다. 모듈 시스템을 사용하면 코드를 작성할 때 필요한 모듈만을 가져와서 사용할 수 있다. 이를 가능하게 해주는 것이 모듈 번들러(Module bundler)입니다. 대표적으로 Webpack이 있으며, 해당 프로젝트에서도 Webpack을 사용한다.
  4. 상태 관리 라이브러리(State management library)
    SPA에서는 데이터를 컴포넌트 간에 전달해야 하는 경우가 많다. 이를 가능하게 해주는 것이 상태 관리 라이브러리입이며 대표적으로 React의 Redux, Vue의 Vuex 등이 있다. 하지만 해당 프로젝트는 JavaScript만으로 구현할 것이기 대문에 옵저버 패턴과 Proxy를 통해서 상태관리를 하도록 하겠다.

SPA 초기 설계를 위한 파일 간단히 살펴보기

SPA를 구현하기 위한 파일들과 각 파일들의 역할에 대해서 간단히 살펴보자.

  • src/index.js:웹팩 설정 파일에서 entry point로 설정되어 있으며, 애플리케이션의 초기 설정을 담당한다.
  • src/app.js: App 객체를 생성하고, Router 객체를 생성하여 초기화하고, App 객체를 렌더링한다.
  • src/router.js: 라우팅 로직을 구현하고, 해당하는 컴포넌트를 가져와서 HTML에 렌더링하는 역할을 한다.
  • src/routes.js : 라우팅 로직에 필요한 routes 객체 모듈을 관리한다.
  • src/components/app.js: 애플리케이션의 논리를 구현하는 코드를 담고 있으며, 애플리케이션의 구성 요소인 컴포넌트들을 정의하고, 라우팅 로직 등을 구현하는 역할을 한다.
  • src/components/Header/Header.js: 웹 페이지 상단에 위치하는 헤더를 생성하는 코드를 담고 있다.
  • src/components/Home/Home.js: 웹 페이지의 메인 화면에 보여지는 컴포넌트를 생성하는 코드를 담고 있다.
  • src/components/Footer/Footer.js: 웹 페이지 하단에 위치하는 푸터를 생성하는 코드를 담고 있다.
  • src/components/Login/Login.js: 로그인 화면에 보여지는 컴포넌트를 생성하는 코드를 담고 있다.
  • src/components/Error404/Error404.js: 404 에러 페이지에 보여지는 컴포넌트를 생성하는 코드를 담고 있다.

SPA 초기 설계를 위한 파일 자세히 살펴보기

이제 각 파일의 역할에 대해 자세히 알아보자

src/index.js

// src/index.js

import './style.scss';
import '../public/assets/images/beverage.png';
import '../public/assets/images/chair.png';

...

import App from './app.js';
import Router from './router';

const router = new Router();

// 애플리케이션 시작
const app = new App();
app.render();

src/index.js는 웹팩 설정 파일에서 entry point로 설정되어 있으며, 그 역할은 애플리케이션의 초기 설정을 담당하는 것이다. 이 파일에서는 애플리케이션을 구동하기 위해 필요한 라이브러리나 모듈 등을 import하고, DOM에 애플리케이션의 컴포넌트를 렌더링한다.

src/app.js

// src/app.js

import Router from './router.js';
import App from './components/app.js';

// 라우터 초기화
Router();

export default App;

src/app.js은 SPA에서 애플리케이션의 진입점을 담당하는 파일이다. 이 파일에서는 Router()함수를 호출하여 라우팅을 초기화하고, App컴포넌트를 내보내어 애플리케이션의 뷰를 렌더링한다. 따라서 App컴포넌트는 이 파일에서 정의된 라우팅에 따라 동적으로 렌더링된다.

src/router.js

// src/router.js

import Home from './components/Home/Home';
import Login from './components/Login/Login';
import Error404 from './components/Error404/Error404';
import routes from './routes';

const Router = () => {
  const content = document.getElementById('app');

  const init = () => {
    const component = getComponent();
    content.innerHTML = `
      ${component()}
    `;
  };

  const getComponent = () => {
    const path = window.location.pathname.toLowerCase();
    let component;

    if (path in routes) {
      component = routes[path];
    } else {
      component = routes['/error404'];
    }
    return component;
  };

  init();

  window.addEventListener('popstate', init);

  return {
    getComponent,
    init
  };
};

export default Router;

src/router.js은 SPA에서 라우팅(Routing)을 처리하는 기능을 담당한다.

Router() 함수 내부에는 init() 함수와 getComponent() 함수가 정의되어 있고, init() 함수는 초기화될 때 실행되며, getComponent() 함수를 호출하여 현재 URL에 해당하는 컴포넌트를 가져와 화면에 렌더링된다.

getComponent() 함수는 현재 URL 경로를 파싱하여 routes 객체에서 해당하는 컴포넌트를 가져와 반환하며, 만약 URL 경로가 routes 객체에 정의되어 있지 않은 경우, 404 에러 페이지를 보여준다.

또한 window.addEventListener('popstate', init)를 통해 브라우저 history가 변경될 때마다 init() 함수가 실행되어 URL에 해당하는 컴포넌트를 다시 로드한다.

src/routes.js

// src/routes.js

import Home from './components/Home/Home';
import Login from './components/Login/Login';
import Error404 from './components/Error404/Error404';

const routes = {
  '/': Home,
  '/login': Login,
  '/error404': Error404
};

export default routes;

src/routes.js은 SPA에서 사용될 각 경로에 해당하는 컴포넌트들을 매핑해 놓은 파일이다. 예를 들어 /
경로에 해당하는 컴포넌트는 Home이고, /login경로에 해당하는 컴포넌트는 Login 이다. 이런 식으로 경로와 컴포넌트를 매핑해놓은 라우팅 정보를 객체 형태로 내보내는 역할을 한다. 따라서, 이 파일이 있어서 Router
컴포넌트에서 경로에 따른 컴포넌트를 가져와서 렌더링할 수 있다.

src/components/app.js

// src/components/app.js

import Router from '../router';
import Header from './Header/Header';
import Footer from './Footer/Footer';
import routes from '../routes'; 

const App = () => {
  const router = new Router();

  const render = () => {
    const appElement = document.getElementById('app');

    appElement.innerHTML = `
      ${Header()}
      ${router.getComponent()()} 
      ${Footer()}
    `;
  };

  window.addEventListener('load', render);
  window.addEventListener('popstate', render);

  const app = {
    render
  };

  return app;
};

export default App;

src/components/app.js은 SPA의 컨테이너 역할을 한다. 먼저, 이 파일에서는 Router 모듈을 가져와 Router 인스턴스를 생성하고, Header, Footer, routes 모듈을 가져와 각각의 컴포넌트를 렌더링한다. 이후 렌더링된 컴포넌트들을 하나의 뷰로 합쳐서 반환한다. 이때 ${router.getComponent()()}/login 처럼 특정 url로 접근할때 해당 컴포넌트를 렌더링한다.

이렇게 반환된 뷰는 index.js에서 렌더링된다. 이 파일은 또한 window 객체의 load 이벤트와 popstate 이벤트를 감지하여 뷰를 렌더링하는 render 함수를 실행한다. 마지막으로, app 객체를 생성하여 render 함수를 속성으로 가지고 있으며, 이를 외부에서 사용할 수 있도록 내보낸다.

src/components/Home/Home.js

// src/components/Home/Home.js

import './Home.scss';

const Home = () => {
  const markup = `
  <main>
    <div class="posts-container">
   ...
    </div>
  </main>

  `;
  return markup;
};

export default Home;

이 파일은 Home 컴포넌트를 정의하는 파일이며, Home 컴포넌트는 카페 리스트를 보여주는 페이지이다.

마크업 문자열을 변수 markup에 할당한다. 이 문자열은 화면에 보여질 HTML 구조를 포함하고 있다. 이 컴포넌트의 역할은 Home.scss 파일에서 정의된 스타일을 적용한 마크업 문자열을 반환하는 것이다.

같은 방식으로 Header, Footer, Login, Error404 컴포넌트를 각각 구현한다.

SPA 구현 결과

http://localhost:9000/로 접속했을때, Header컴포넌트와 Footer 컴포넌트 그리고 Home 컴포넌트를 렌더링 해준다. (웹팩 데브 서버의 port가 9000으로 설정 되어 있다)

즉, router.js에서 작성한 /로 접속했을때 Home컴포넌트를 렌더링하는 로직이 정상적으로 작동한 것을 확인할 수 있다.

이번에는 http://localhost:9000/login 으로 접속해 보자 Header컴포넌트와 Footer컴포넌트가 공통으로 렌더링되며/login으로 접속했을때 Home컴포넌트 대신 Login컴포넌트를 렌더링하는 router.js의 로직이 정상적으로 작동하는 것을 확인할 수 있다.

트러블 슈팅

JavaScript와 Webpack을 활용하여 SPA를 구현하면서 겪었던 가장 큰 트러블 슈팅은 라우팅을 하는 과정에서 발생했다. / 로 접속했을 때는 Header와 Footer, Home 컴포넌트가 각각 정상적으로 렌더링 되었지만 /login 으로 접속하자 404에러가 발생했기 때문이다.

이 문제를 해결하기 위해서 src/components/app.js를 비롯하여 src/router.js, src/routes.js 등의 파일을 찾아가며 문제를 해결하고자 했으나 역시 원인을 알 수 없었다.

하지만 라우팅 관련된 로직이 잘못되지 않았다면 웹팩설정에서 문제가 있을 수 있다는 생각이 되어 webpack.config.js 파일에서 웹팩설정을 다시 살펴 보았다.

// webpack.config.js

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebPackPlugin = require('html-webpack-plugin');

module.exports = {
 ...

  devServer: {
    static: path.resolve(__dirname, 'build'),
    historyApiFallback: true, // 모든 요청을 index.html로 리다이렉트
    port: 9000, 
    hot: true 
  }
};

devServer 설정에서 historyApiFallback설정을 true로 설정해 주면서 모든 요청에 index.html로 리다이렉트하므로 404 에러가 발생하지 않고, 클라이언트 측에서 SPA를 구현하는데 필요한 초기 설정이 수행할 수 있게 되었다.

따라서 /login경로로 접속하면, 해당 경로의 리소스를 찾지 못하는 대신 index.html 파일이 로드되고, SPA 애플리케이션을 초기화하게 되어 Login 컴포넌트가 정상적으로 렌더링되게 되었던 것이다.

profile
프론트엔드 학습 과정을 기록하고 있습니다.

0개의 댓글