Nginx 를 이용하여 URL Path 기반으로 여러개의 SPA 페이지를 제공하는 방법
하나의 서버에 두개의 각기 다른 서비스의 정적 페이지가 존재한다고 했을 때, Path 기반으로 두 정적 페이지를 구분하여 제공하는 방법을 알아보겠습니다.
환경
- Nginx 1.18
정적 페이지가 하나일 때
Path 기반으로 두개의 정적 페이지 제공하기
를 알아보기 전에 정적 페이지가 하나 일 경우를 먼저 확인해보겠습니다.
server {
listen 9000 ssl;
listen [::]:9000 ssl;
server_name www.my.com;
ssl_certificate "server.pem";
ssl_certificate_key "server.key";
charset utf-8;
root /home/woosub/myservice/;
index index.html;
location / {
try_files $uri /index.html;
}
}
이럴 경우 Client 가 Web 브라우저에서 https://<서버 주소>:9000
URL 로 접속을 시도하면 /home/woosub/myservice/index.html
을 제공받게됩니다.
자 그럼 이제 본격적으로 정적 페이지 두개인 상황을 보겠습니다.
Path 기반으로 두개의 정적 페이지 제공하기
저희는 URL Path
기반으로 페이지를 분리해야하니 조건을 확인해보겠습니다.
Client 의 접속 URL 예시 | 제공받아야 하는 파일 |
---|---|
https://<서버 주소>:9000 | A 서비스의 index.html |
https://<서버 주소>:9000/B | B 서비스의 index.html |
실제 서버상에서 각 index.html
들은 아래와 같은 경로에 위치되었다고 가정합니다.
A 서비스의 index.html 경로 | B 서비스의 index.html 경로 |
---|---|
/home/woosub/myservice/index.html | /home/woosub/myservice/b/web/index.html |
위 조건을 반영하여 nginx 설정 파일을 아래와 같이 변경합니다.
server {
listen 9000 ssl;
listen [::]:9000 ssl;
server_name www.my.com;
ssl_certificate "server.pem";
ssl_certificate_key "server.key";
charset utf-8;
root /home/woosub/myservice/;
index index.html;
location / {
try_files $uri /index.html;
}
// `indexB.html` 을 위하여 추가된 부분!
location /B {
alias /home/woosub/myservice/b/web/;
}
}
alias 키워드
location /B {
alias /home/woosub/myservice/b/web/;
~~~
}
alias
는 요청된 URL에서 location /B
와 같이 적중된 Path
를 변경하는 키워드입니다.
주의할 점은 Path 전체가 바뀌는 것이 아니라 적중된 부분만 변경되는 점 입니다.
그 외의 부분은 alias로 변경된 부분 뒤에 자동으로 붙게됩니다.
요청 URL Path | Alias 가 반영된 Path |
---|---|
/B | /home/woosub/myservice/b/web/ |
/B/custom | /home/woosub/myservice/b/web/custom |
/B/c/d/e | /home/woosub/myservice/b/web/c/d/e |
위 예시와 같이 location /B
와 일치하는 /B
가 alias 에 명시된 /home/woosub/myservice/b/web/
로 변경됩니다.
이때 최상단에 index index.html;
가 이미 명시되어있으니
client 요청 URL에 따라 제공받는 index.html이 다르게됩니다.
Client 의 접속 URL 예시 | 제공받는 index.html |
---|---|
https://<서버 주소>:9000 | /home/woosub/myservice/index.html |
https://<서버 주소>:9000/B | /home/woosub/myservice/b/web/index.html |
어림없지! 동작 안함
위와 같이 설정하면 바로 동작할 것 같지만 현실은 녹록치 않습니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>myservice</title>
<script type="module" crossorigin src="/assets/index-vwe0aiAc.js"></script>
</head>
<body>
<div id="root"></div>
<script src="/config.jsx"></script>
</body>
</html>
위와 같은 html파일이 브라우저에서 로드될 때 외부 리소스를 참조하는 키워드들이 존재할 경우
브라우저는 서버에게 필요한 리소스를 요청하게됩니다. (href="/vite.svg"
, src="/assets/index-vwe0aiAc.js"
, src="/config.jsx"
등)
href="/vite.svg"
를 예시로 브라우저는 https://<서버 주소>:9000/vite.svg
와 같은 URL을 통하여 서버측에 리소스를 요청하게되는데
이 URL에는 /B
가 미포함
되어있습니다.
이 요청을 받은 서버의 Nginx
는 /B
가 미포함되어있으니
location / {
try_files $uri /index.html;
}
를 실행시키게 될 것이고, 결과적으로 /home/woosub/myservice/vite.svg
경로에서 리소스를 찾기 때문에 파일 찾기에 실패합니다.
404 Not Found
가 바로 발생하면 오히려 땡큐지만 try_files $uri /index.html;
코드로 인하여 파일을 못찾았을 시 /index.html
를 기본적으로 반환하기 때문에
브라우저가 요청한 vite.svg
가 아닌 A 서비스의 index.html
이 반환되고 상태코드가 200 OK
인 흑마법이 발생합니다.
겉만 봐서는 요청에 대한 상태코드가 다 200 OK
인데 페이지가 정상적으로 안보여서 사용자는 머리에 피가 쏠리기 시작합니다.
sub_filter 키워드
이때 사용할 수 있는 키워드가 sub_filter
입니다.
sub_filter
는 Nginx에서 응답 내용 중 특정 문자열을 다른 문자열로 대체할 때 사용됩니다.
index.html 에서 외부 리소스를 참조하는 부분은
href="/vite.svg"
src="/assets/index-vwe0aiAc.js"
src="/config.jsx"
위와 같이 세가지 항목이기에 아래와 같이 Nginx 설정을 변경합니다.
location /B {
alias /home/woosub/myservice/b/web/;
sub_filter assets/ B/assets/;
sub_filter config.jsx B/config.jsx;
sub_filter vite.svg B/vite.svg;
}
위 설정을 적용하고 다시 index.html을 요청해보면
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/B/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gx-log-linker-web-console</title>
<script
type="module"
crossorigin
src="/B/assets/index-vwe0aiAc.js"
></script>
</head>
<body>
<div id="root"></div>
<script src="/B/config.jsx"></script>
</body>
</html>
위와 같이 /B
가 추가된 index.html 을 제공받게 됩니다.
sub_filter_once
그러면 sub_filter
만 사용하면 문제 없을까요?
이건 상황에 따라 달라집니다.
만약 index.html이 아래와 같다고 가정합니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>myservice</title>
<script type="module" crossorigin src="/assets/index-vwe0aiAc.js"></script>
</head>
<body>
<div id="root"></div>
<script src="/config.jsx"></script>
</body>
</html>
href="/vite.svg"
가 세개나 존재합니다.
이때 sub_filter vite.svg B/vite.svg;
를 사용했으니 문제 없겠지 라고 생각할 수 있지만
실제로 배포되는 index.html을 보면
<link rel="icon" type="image/svg+xml" href="/B/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
와 같이 첫번째로 발견되는 vite.svg
만 /B/vite.svg
로 바뀌고 나머지는 그대로입니다.
이 이유는 sub_filter
가 기본적으로 첫 번째 일치한 부분만 교체하고 그 이후는 교체하지 않도록 설정되어있기 때문입니다.
일치하는 모든 부분을 교체하기 위해서는 sub_filter_once
를 사용해야하며 옵션은 on
과 off
두개가 존재합니다.
sub_filter_once 옵션 | 설명 |
---|---|
on | 응답 내에서 일치하는 첫 번째 항목만 대체됩니다. 이는 기본 설정값입니다. |
off | 응답 내의 모든 일치하는 항목 이 대체됩니다. |
위 옵션을 아래와 같이 Nginx 설정에 적용해보겠습니다.
location /B {
alias /home/woosub/myservice/b/web/;
sub_filter assets/ B/assets/;
sub_filter config.jsx B/config.jsx;
sub_filter vite.svg B/vite.svg;
sub_filter_once off;
}
이후에 제공되는 index.html을 살펴보면
<link rel="icon" type="image/svg+xml" href="/B/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/B/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/B/vite.svg" />
와 같이 모든 vite.svg
가 /B/vite.svg
로 바뀐걸 확인할 수 있습니다.
sub_filter_types
Client에게 전달되는 파일이 html이 아닌 다른 형식일 수도 있습니다.
이때 해당 파일에 /vite.svg
와 같은 경로가 명시되어있을 가능성이 존재하므로
해당 파일들에 들어있는 /vite.svg
도 /B/vite.svg
와 같이 변경해줘야할 가능성이 있습니다.
이때 sub_filter_types
키워드를 사용하여 sub_filter
가 적용될 타입을 지정할 수 있습니다.
sub_filter_types 사용 예시 | 설명 |
---|---|
sub_filter_types text/html; | sub_filter 를 text/html MIME 타입의 응답에만 적용합니다. 이는 sub_filter 의 기본 동작 방식입니다. |
sub_filter_types application/javascript; | sub_filter 를 application/javascript MIME 타입의 응답에 적용합니다. |
sub_filter_types text/css; | sub_filter 를 text/css MIME 타입의 응답에 적용합니다. |
sub_filter_types text/html application/javascript text/css; | sub_filter 를 여러 MIME 타입의 응답에 적용합니다. 서로 다른 종류의 콘텐츠에 대해 문자열 대체를 수행할 수 있습니다. |
sub_filter_types *; | 모든 응답 타입에 sub_filter 를 적용합니다. |
위 옵션을 참고하여 아래와 같이 Nginx 설정에 적용해보겠습니다.
location /B {
alias /home/woosub/myservice/b/web/;
sub_filter assets/ B/assets/;
sub_filter config.jsx B/config.jsx;
sub_filter vite.svg B/vite.svg;
sub_filter_once off;
sub_filter_types *;
}
sub_filter
가 적용될 타입을 *
로 지정하였기에 모든 타입에 대해서 sub_filter
가 동작하게 됩니다.
SPA 에서 라우터를 이용하지 않고 바로 서버에 요청을 보냈을 때
SPA 에서는 Path
변경 시 서버에 페이지 요청을 보내지 않고 라우터를 통하여 필요한 컴포넌트들을 렌더링하는 (Client-Side Rendering) 방식을 사용합니다.
이에 대한 전재조건이 필요한데 초기에 서버로부터 최소한의 HTML 구조와 함께 렌더링을 위한 JavaScript
를 제공받아야한다는 것입니다.
일반적으로 index.html
을 받으면서 위 전재조건이 해결되는데
사용자가 만약 index.html
을 받을 수 있는 URL인 https://<서버 주소>:9000/B
을 거치지 않고,
바로 https://<서버 주소>:9000/B/subpath
로 요청을 보냈을 때 서버에는 해당 페이지가 존재하지 않기 때문에 404 Not Found
가 발생하게 됩니다.
그렇다면 서버는 존재하지 않는 페이지에 대한 요청
을 받을 경우 직접 렌더링해서 쓰시오 하면서 index.html
을 던져주면됩니다.
위 내용을 참고하여 아래와 같이 Nginx 설정에 적용해보겠습니다.
location /B {
alias /home/woosub/myservice/b/web/;
sub_filter assets/ B/assets/;
sub_filter config.jsx B/config.jsx;
sub_filter vite.svg B/vite.svg;
sub_filter_once off;
sub_filter_types *;
try_files $uri $uri/ /B/index.html;
}
자 이제 Client가 https://<서버 주소>:9000/B
을 거치지 않고 https://<서버 주소>:9000/B/subpath
로 바로 요청을 보내도 index.html
이 제공되기때문에 문제없이 동작하게 됩니다.
완성된 Nginx default.conf
위 내용들을 전부 반영한 default.conf 는 아래와 같습니다.
server {
listen 9000 ssl;
listen [::]:9000 ssl;
server_name www.my.com;
ssl_certificate "server.pem";
ssl_certificate_key "server.key";
charset utf-8;
root /home/woosub/myservice/;
index index.html;
location / {
try_files $uri /index.html;
}
// `indexB.html` 을 위하여 추가된 부분!
location /B {
alias /home/woosub/myservice/b/web/;
sub_filter assets/ B/assets/;
sub_filter config.jsx B/config.jsx;
sub_filter vite.svg B/vite.svg;
sub_filter_once off;
sub_filter_types *;
try_files $uri $uri/ /B/index.html;
}
}
부록) SPA 에서 라우트된 URL에는 /B
가 존재하지 않는다 어떻게 해야하나
이 내용은 Client 개발자 입장에서의 내용입니다.
https://<서버 주소>:9000/B
URL 을 통하여 정상적으로 index.html
과 js
, 초기 리소스
들을 다운받아서
사용할 때 약간의 문제가 발생합니다.
location /B
라는 내용은 /B 로 시작하는 Path를 가진 요청은 ~과 같이 처리하겠다
라는 서버 입장
에서의 처리 입니다.
언제든지 서버의 마음이 바뀌면 /B
를 /A
로 또는 /C
로 더 복잡하게는 /B/A/C/123/
로 바꿀 수 있습니다.
Client 코드상에서는 이 /B
,/A
,/C
와 같은 분기에 대한 코드가 어디에도 들어있지 않죠
그러면 아래와 같은 현상이 발생합니다.
- 웹 브라우저에서
https://<서버 주소>:9000/B
를직접
입력합니다. - 메인 페이지가 렌더링됩니다.
회원가입
버튼을 클릭하여 페이지 이동(라우팅) 이 발생합니다.- URL 이
https://<서버 주소>:9000/join
로 변경됩니다.
위 예시를 보면
최초 URL은 https://<서버 주소>:9000/B
였으나 라우트되면서 https://<서버 주소>:9000/join
로 URL이 바뀌었습니다.
/B
라는 Path
의 시작점이 사라졌습니다
하지만 페이지는 문제없이 동작할 것입니다.
라우트는 실제로 서버에다가 https://<서버 주소>:9000/join
에 해당하는 페이지를 요청한게 아니라, 웹 브라우저상에서 보여지는 URL이 변경되고, 그에 맞는 컴포넌트가 렌더링되는 Client 범위의 동작
이기 때문입니다.
문제는 해당 /B가 제외된 URL
이 사용자에게 그대로 노출된다는 것입니다.
사용자가 노출된 URL을 그대로 웹 브라우저 URL에 복사->붙여넣기를 하고 엔터를 치면 어떻게 될까요?
이때는 Client 범위의 동작이 아닌 서버와의 통신
이 이뤄집니다.
https://<서버 주소>:9000/join
에 해당하는 페이지를 서버에게 요청하게되죠
URL에는 /B
키워드가 없어진 상태니 어림없지! 동작 안함 에서 발생했던 문제가 그대로 발생합니다
라우트 URL에 basePath 를 지정하는 방법
그렇다고 언제 바뀔지 모르는 /B
를 라우트 URL에 상수값으로 고정
하기에는 무리가 있습니다.
그래서 /B
라는 값을 동적으로 얻어올 수 있는 방법을 찾아야하는데
최초 한번은 1. 웹 브라우저에서 https://<서버 주소>:9000/B 를 직접 입력합니다.
선행 작업이 필수로 이뤄져야한다는 전재조건을 활용하여
최초에는 항상 Path가 /B
로 끝날태니, 이를 저장해놓으면 문제를 해결할 수 있습니다.
아래는 그 예시입니다.
// ...
const MANAGE_STREAM_ROUTE_URL_PATH = "management/stream";
const createUrlForPath = (path) => {
// 현재 window.location.href에서 basePath를 동적으로 추출합니다.
const basePath = new URL(window.location.href).pathname;
// path가 undefined이거나 null, 또는 문자열이 아닌 경우의 처리
if (path == null || typeof path !== "string") {
console.error(
"Invalid path: path is either undefined, null, or not a string."
);
return basePath;
}
// URL이 '/'로 시작하는지 확인하고, 맞다면 첫 번째 문자를 제거합니다.
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
// basePath가 이미 path를 포함하는지 확인하여 중복 추가를 방지합니다.
// 브라우저에서 새로고침을 누르면 URL이 입력되있는 상태에서 다시 렌더링되니
// basePath 에 이미 path가 포함된 상태일 수 있습니다.
// 그렇기 때문에 중복되지 않도록 분기처리가 필요합니다.
if (basePath.endsWith(normalizedPath)) {
return basePath;
} else {
// basePath와 normalizedPath를 조합하여 최종 URL을 생성합니다.
// 여기서는 basePath와 normalizedPath 사이에 중복 '/'가 없도록 조심합니다.
return `${
basePath.endsWith("/") ? basePath.slice(0, -1) : basePath
}/${normalizedPath}`;
}
};
// ...
const App = () => {
// ...
<Route path={createUrlForPath(MANAGE_STREAM_ROUTE_URL_PATH)} />;
// ...
};