[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 로직을 간략하게 표현하면 아래와 같다.
- URI가
/_next/static/
, 또는/_nuxt/
으로 시작하면 그대로 출력- URI가
/atc/atc01001s0*/1234
형태인 경우/atc/atc01001s0*.html
출력
예시:/atc/atc01001s04/1
->/atc/atc01001s04.html
- 요청 URI 에 대응하는 파일이 존재하는 경우 그대로 출력
/api
로 시작하지 않고 요청 URI 에 대응하는 파일이 없는경우/index.html
출력/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. 다운로드
[프로젝트 원본 : 직접 빌드해 보고 싶은 경우 다운로드]
[빌드 결과물 : 원본 소스 없이 설정만 테스트 할 경우 다운로드]