Node 서버를 사용하지 않고 정적 빌드 후 SPA 띄우기

Table of Contents

[TOC]

0. 잡설

스터디 레포팅을 마치고 잠시 이것저것 정리하다보니 포스팅이 게을러 졌다..

프로젝트 쉬는중인데 일할 때 보다 더 바쁜건 왜인지....

여튼.. 가보자!

히위고~~

1. 개요

vuejs / reactjs 를 비롯한 node 프로젝트는 보통 npm run build 명령을 통해 빌드하고 npm run start 명령으로 구동한다.

하지만 일반적으로는 정적빌드(html, js, css 셋으로 빌드)하는 방법도 제공하고 있다.

정적빌드를 수행하면 html, js, css 만 생성 되므로 별도의 웹서버에 올려줘야 작동한다.

## 정적빌드 결과물 예시
├── assets
│   ├── images
│   │   └── test.gif
│   └── lottie
│       └── hello.json
... 중략 ...
├── smp
│   ├── smp01001s01.html
│   ├── smp01001s02.html
│   ├── smp01001s03.html
│   └── smp01001s04.html
├── usr
│   ├── usr01001s01.html
│   ├── usr01001s02.html
│   └── usr01001s03.html
├── index.html
└── favicon.ico

다만. SPA 특성상 URL 로 직접 접근하는 경우 작동하지 않을수도 있다

무슨소리인가 하면. 보통의 SPA 프로젝트는 단 한개의 index.html 을 진입점(entry-point)으로 가지고 있고

페이지 이동시 실제 페이지 URL을 이동하지 않고 router 를 조작해 해당 페이지를 javascript 로 렌더링 하는 방식을 사용한다.

stateDiagram-V2
direction TB
state renderer <<choice>>
웹브라우저 --> index.html
index.html --> renderer:render
renderer --> 회원가입
renderer --> 마이페이지
renderer --> 게시판
renderer --> 기타
note left of index.html
entry point.
페이지 이동시 실제 URL을 이동하지 않고 라우터를 조작하여
이동할 페이지 정보만 읽어와 자바스크립트로 렌더링을 수행한다
end note
note right of 회원가입 /usr/usr01001s01 end note
note right of 마이페이지 /usr/usr01001s02 end note
note right of 게시판 /atc/atc01001s01 end note
note right of 기타 ... end note

따라서 예전방식의 SPA 에서는 회원가입, 로그인, 게시판 등 페이지를 URL로 직접 접근할 수 없었으며 반드시 /index.html 을 거쳐가야만 했었다

근래에는 node 프레임워크 (nuxtjs, nextjs) 에서 SSG 등의 기법들을 활용해 직접 URI 에 대응하는 html 파일을 만들어 주지만,
이마저도 동적라우팅 을 사용하는 경우 웹서버에서 별도의 설정을 해 주어야 한다.

2. 정적빌드 수행방법

정적빌드는 각 프레임워크 에서 지원해 주는 방법을 사용한다. 이번 포스팅에서는 NUXTJS(vue)NEXTJS(react) 를 기준으로 설명하겠다.

2-3. NUXTJS (vue)

  • NUXTJS 는 별도의 설정없이 npm run generate 명령으로 간단하게 정적빌드를 수행할 수 있다.

$ npm run generate

> generate
> nuxt generate
Nuxi 3.6.5
Nuxt 3.6.5 with Nitro 2.5.2

... 중략 ...

ℹ .nuxt/dist/server/_nuxt/entry-styles-2.mjs-34e9c52b.js      226.91 kB │ map:   0.12 kB
ℹ ✓ built in 3.86s
✔ Server built in 3884ms
✔ Generated public .output/public
ℹ Initializing prerenderer
ℹ Prerendering 6 initial routes with crawler
  ├─ /200.html (207ms)
  ├─ /404.html (210ms)
  ├─ / (234ms)
  ├─ /board/list (242ms)
  ├─ /user/login (243ms)
  ├─ /user/register (240ms)
  ├─ /_payload.json (1ms)
  ├─ /board/list/_payload.json (4ms)
  ├─ /user/login/_payload.json (2ms)
  ├─ /user/register/_payload.json (2ms)
✔ You can preview this build using npx serve .output/public
✔ You can now deploy dist to any static hosting!

  • 결과물이 생성될 폴더를 지정하고 싶은경우 아래와 같이 nuxt.config.js 를 셋팅한다. (예제에서는 /dist 폴더)

/** nuxt.config.js */
import path from 'path'
import fs from 'fs'
... 중략 ...
/** 결과물 생성 폴더 (dist) */
const distdir = path.join(__dirname, 'dist')
if (!fs.existsSync(distdir)) { fs.mkdirSync(distdir) }

export default defineNuxtConfig({
  ... 중략 ...
  /** 위에서 지정된 폴더 */
  nitro: { output: { publicDir: distdir } },
  ... 중략 ...
})

2-2. NEXTJS (react)

  • NEXTJS 에서는 아래와 같이 next.config.mjs, package.json 파일을 수정해 주어야 한다.

/** next.config.mjs */
const nextConfig = {
  ... 중략 ...
  /** npm 커맨드가 generate 인 경우 정적빌드 수행 */
  output: /generate/.test(process.env.npm_lifecycle_event) ? 'export' : undefined,

  /** 빌드결과물 생성위치 : /dist */
  distDir: 'dist',
  ... 중략 ...
}


/** package.json */
{
  ... 중략 ...
  "scripts": {
    ... 중략 ...
    "build": "next build",
    "generate": "next build",
    ... 중략 ...
  },
  ... 중략 ...
}

  • 이후 NUXTJS 와 같이 npm run generate 명령으로 정적빌드가 가능하다.

$ npm run generate

> my-app@0.1.0 generate
> next build

... 중략 ...

├ ○ /usr/usr01001s02                       539 B           863 kB
└ ○ /usr/usr01001s03                       1.9 kB          864 kB
+ First Load JS shared by all              900 kB
  ├ chunks/framework-9620da855a94eb57.js   45.2 kB
  ├ chunks/main-729863165484c3af.js        42.9 kB
  ├ chunks/pages/_app-365a755f224c9897.js  773 kB
  ├ css/pages/_app.css                     37.6 kB
  └ other shared chunks (total)            1.78 kB

○  (Static)  prerendered as static content

3. 웹서버에 올리기

자 이제 정적빌드는 완료했고. 빌드된 결과물을 웹서버에 올려보자. (예제에서는 /var/www/html 경로를 기준으로 하겠다)

기존에 많이 쓰는 apache, nginx, webtob 그리고 웹서버 없이 스프링부트에 내장하는 방법까지 다루어 보도록 하겠다

내용물은 스터디 레포트에서 다루었던 react 게시판 프로젝트... 에서 조금(많이) 개조한 프로젝트를 사용하겠다.

사용한 프로젝트는 아래 링크에 연결해 두겠다. (기존 gitlab 에 공개한 프로젝트에서 많은 부분 변경되었음...)

[프로젝트 원본 : 직접 빌드해 보고 싶은 경우 다운로드]

[빌드 결과물 : 원본 소스 없이 설정만 테스트 할 경우 다운로드]

웹서버 rewrite 로직을 간략하게 표현하면 아래와 같다.

  1. URI가 /_next/static/, 또는 /_nuxt/ 으로 시작하면 그대로 출력
  2. URI가 /atc/atc01001s0*/1234 형태인 경우 /atc/atc01001s0*.html 출력
    예시: /atc/atc01001s04/1 -> /atc/atc01001s04.html
  3. 요청 URI 에 대응하는 파일이 존재하는 경우 그대로 출력
  4. /api 로 시작하지 않고 요청 URI 에 대응하는 파일이 없는경우 /index.html 출력
  5. /api 로 시작하는 URI 는 api 서버로 reverse-proxy 수행

3-1. Apache2 에서의 설정방법

  • 일단 필요한 모듈부터 활성화 해 두자 (proxy, rewrite 모듈)

$ a2enmod proxy rewrite

  • apache site config 파일을 아래와 같이 작성한다.

<VirtualHost *:80>
  ... 중략 ...
  ## 결과물을 읽어올 폴더
  DocumentRoot /var/www/html
  ... 중략 ...
  <IfModule mod_rewrite.c>
  RewriteEngine On
  ## next 스크립트
  RewriteRule ^(.*/(_next/static|_nuxt)/.+\.[a-zA-Z0-9_-]+)$ $1 [L] 
  ## 확장자를 가진 것들은 그대로 출력
  RewriteRule ^(.*/.+\.[a-zA-Z0-9_-]+)$ $1 [L] 
  ## 동적 라우팅을 사용하고 있는 페이지들
  RewriteRule ^(.*/atc/atc01001s(02|03|04))/[^/^.]+/?$ $1.html [L] 
  ## 나머지 URL 은 파일이 존재하지 않는 경우 /index.html 을 읽어 내보낸다.
  RewriteCond %{REQUEST_URI} !error 
  RewriteCond %{REQUEST_FILENAME} !-f 
  RewriteCond %{REQUEST_FILENAME} !-d 
  RewriteCond %{REQUEST_URI} !/api
  RewriteRule . /index.html [L] 
  </IfModule>
  <IfModule mod_proxy.c>
    <Location /api>
    ## 작동하고 있는 API 서버 URL 을 적어준다. (자바 서버가 없다면 그대로 사용해도 무방.)
    ProxyPass http://devwas.ntiple.com/study202403/api
    ProxyPassReverse http://devwas.ntiple.com/study202403/api
    </Location>
  </IfModule>
</VirtualHost>

3-2. Nginx 에서의 설정방법

  • Nginx 의 경우 아래와 같이 설정하고 재시작하면 된다. (Nginx 최고!)

http {
  ... 중략 ...
  server {
    listen            80;
    server_name       localhost;

    location / {
      ## 결과물을 읽어올 폴더
      root /var/www/html;
      index index.html;
      ## next 스크립트
      rewrite ^(.*/(_next/static|_nuxt)/.+\.[a-zA-Z0-9_-]+)$ $1 break;
      ## 확장자를 가진 것들은 그대로 출력
      rewrite ^(.*/.+\.[a-zA-Z0-9_-]+)$ $1 break;
      ## 동적 라우팅을 사용하고 있는 페이지들
      rewrite ^(.*/atc/atc01001s(02|03|04))/[^/^.]+/?$ $1.html break;
      ## 나머지 URL 은 파일이 존재하지 않는 경우 /index.html 을 읽어 내보낸다.
      try_files $uri $uri/index.html $uri.html /index.html;
    }
    ## API 서버 Reverse proxy
    location /api  {
      proxy_pass http://devwas.ntiple.com/study202403/api;
    }
    ... 중략 ...
  }
}

3-3. Webtob 에서의 설정방법

  • 웹투비의 경우 http.m 파일 이외 rewrite.conf 파일을 별도 작성해 준 후 설정 컴파일 하고 재시작 해야 한다. (귀찮다. 웹투비를 왜쓰는지 모르겠음.....)

## http.m
... 중략 ...
## VHOST절이 아닌 NODE절에 설정해도 된다.
*VHOST
vhost1
  ## 결과물을 읽어올 폴더
  DOCROOT = "/var/www/html",
  HOSTNAME= "127.0.0.1",
  HOSTALIAS = "localhost,192.168.0.2",
  ServiceOrder = "ext,uri",
  URLRewrite = Y,
  ## Rewrite 설정 읽어오기 (rewrite.conf 빌도 파일)
  URLRewriteConfig = "config/rewrite.conf",
  PORT = "8080"
... 중략 ...
*REVERSE_PROXY
## API 서버 Reverse proxy
rp-api    
  VhostName = "vhost1",
  PathPrefix = "/api",
  ServerPathPrefix = "/study202403/api",
  ServerAddress = "devwas.ntiple.com:80"
... 중략 ...


## rewrite.conf
## next 스크립트
RewriteRule ^(.*/(_next/static|_nuxt)/.+\.[a-zA-Z0-9_-]+)$ $1 [L]
## 확장자를 가진 것들은 그대로 출력
RewriteRule ^(.*/.+\.[a-zA-Z0-9_-]+)$ $1 [L]
## 동적 라우팅을 사용하고 있는 페이지들
RewriteRule ^(.*/atc/atc01001s(02|03|04))/[^/^.]+/?$ $1.html [L]
## 나머지 URL 은 파일이 존재하지 않는 경우 /index.html 을 읽어 내보낸다.
RewriteCond %{REQUEST_URI} !error 
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !/api
RewriteRule . /index.html [N] 

이후 설정 컴파일 수행하고 재시작 한다.

$ wscfl -i http.m

Current configuration:
        Number of client handler(HTH) = 1
        Supported maximum user per node = 16359
        Supported maximum user per handler = 16359
Successfully created the configuration file (/app/webtob/config/wsconfig) for node node1.

$ wsboot

3-4. 스프링부트 내장 방법

웹서버 없이 spring boot 에 내장하는 방법이다.

spring boot 기본 설정에서 /src/main/resources/static 폴더에 html 파일등 웹 리소스를 넣어두면 읽어올 수 있다.

다만. SPA 설정을 위해서는 RequestFilter 를 구현해 주어야 한다.

/** WebResourceFilter.java */
package my.was.mywas.commons;

import static com.ntiple.commons.Constants.CHARSET;
import static com.ntiple.commons.Constants.CTYPE_HTML;
import static com.ntiple.commons.Constants.UTF8;
import static com.ntiple.commons.ConvertUtil.cat;
import static com.ntiple.commons.IOUtils.istream;
import static com.ntiple.commons.IOUtils.passthrough;
import static com.ntiple.commons.IOUtils.safeclose;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.springframework.stereotype.Component;
import org.springframework.web.filter.GenericFilterBean;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

@Slf4j @Component
public class WebResourceFilter extends GenericFilterBean {

  /** 확장자를 가진 URI 인지 판단 */
  private static Pattern PTN_HAS_EXT = Pattern.compile("\\/[^.^\\/]+$");
  /** 동적 라우팅을 사용하고 있는 페이지들 */
  private static Pattern PTN_ATC = Pattern.compile("^(.*/atc/atc01001s(02|03|04))/[^/^.]+/?$");

  @Override public void doFilter(ServletRequest sreq, ServletResponse sres, FilterChain chain)
    throws IOException, ServletException {
    HttpServletRequest req = (HttpServletRequest) sreq;
    HttpServletResponse res = (HttpServletResponse) sres;
    File base = null;
    File file = null;
    String uri = req.getRequestURI();
    Matcher mat = null;
    try {
      /** /src/main/resources/static 폴더위치 */
      URL resroot = this.getClass().getClassLoader().getResource("static");
      base = new File(resroot.getFile());
      file = new File(base, uri);
    } catch (Exception ignore) { }
    if (
      /** 필터링 하지 않을 URI 경로들 (그대로 출력) */
      base == null ||
      uri.startsWith("/api/") ||
      uri.startsWith("/_next/") ||
      (file != null && file.exists()) ||
      !PTN_HAS_EXT.matcher(uri).find()
      ) {
      chain.doFilter(sreq, res);
      return;
    } else if (
      /** 동적 라우팅 페이지들 은 해당 페이지.html 로 변경 출력 */
      (mat = PTN_ATC.matcher(uri)).find() &&
      (file = new File(base, cat(mat.group(1), ".html"))) != null && file.exists()
    ) {
      writeStream(res, file);
    } else {
      /** 파일이 없는 페이지 요청은 /index.html 출력 */
      if (file != null && !file.exists()) { file = new File(base, cat(uri, ".html")); }
      if (file != null && !file.exists()) { file = new File(base, "index.html"); }
      log.debug("FILTER-URI:{} {}", uri, file);
      if (file != null && file.exists()) {
        writeStream(res, file);
      } else {
        chain.doFilter(sreq, res);
      }
    }
  }

  /** 파일출력 메소드 */
  public static void writeStream(HttpServletResponse res, File file) {
    InputStream istream = null;
    OutputStream ostream = null;
    try {
      if (file != null) {
        res.setContentType(cat(CTYPE_HTML, "; ", CHARSET, "=", UTF8));
        res.setContentLength((int) file.length());
        istream = istream(file);
        ostream = res.getOutputStream();
        passthrough(istream, ostream);
        ostream.flush();
      }
    } catch (Exception e) {
      log.debug("E:{}", e.getMessage());
    } finally {
      safeclose(istream);
      safeclose(ostream);
    }
  }
}

이후 /src/main/resources/static 폴더에 정적빌드 결과물 을 복사해 주고 bootRun 실행해 준다

$ sh gradlew bootRun

> Task :bootRun
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.2.4)
... 중략 ...

4. 결과

Apache 서버에 정적 빌드 수행하여 올린 결과 이다

URL 이동 후 새로고침 하여 URL 직접 접근시 SSG된 페이지가 바로 보여진다.




5. 추가사항

글을 적고나서 nuxt-js 를 빌드 하여 테스트 해 본 결과 rewrite 대상이 약간씩 다른점을 확인 하여 추가한다.

next-js 의 경우 정적빌드 수행 시 동적 url 에 해당하는 html 목록은 아래와 같다.

└── atc
    ├── atc01001s01.html
    ├── atc01001s02
    │   └── [articleid].html
    ├── atc01001s02.html
    ├── atc01001s03
    │   └── [articleid].html
    ├── atc01001s03.html
    └── atc01001s04
        └── [pagenum].html

이경우 URL /atc/atc01001s04/1 에 대한 파일 맵핑은 /atc/atc01001s04.html 에 대응하면 된다.

따라서 Rewrite 규칙은 아래와 같이 정의 했다 (Apache 기준)

  RewriteRule ^(.*/atc/atc01001s(02|03|04))/[^/]+/?$ $1.html [L] 

nuxt-js 의 경우 정적빌드 수행후 결과물 html 목록은 아래와 같다.

└── atc
    ├── atc01001s01
    │   └── index.html
    ├── atc01001s02
    │   └── index.html
    ├── atc01001s03
    │   └── index.html
    └── atc01001s04
        └── index.html

이경우 URL /atc/atc01001s04/1 에 대한 파일 맵핑은 /atc/atc01001s04/index.html 에 대응해야 하므로

Rewrite 규칙은 아래와 같이 작성해야 한다.

  RewriteRule ^(.*/atc/atc01001s(02|03|04))/[^/]+/?$ $1/index.html [L] 

6. 다운로드

[프로젝트 원본 : 직접 빌드해 보고 싶은 경우 다운로드]

[빌드 결과물 : 원본 소스 없이 설정만 테스트 할 경우 다운로드]

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다