마이크로 프론트엔드 - SSI (Server Side Includes)를 이용한 프레그먼트 통합

시나리오

1. Team Home이 운영하는 정적 파일을 제공하는 웹 서버 (localhost:3001)

    - pages: /index.html

2. Team Jobs이 운영하는 정적 파일을 제공하는 웹 서버 (localhost:3002)

    - fragments: /jobs/fragment/recommendation/index.html

3. Nginx Reverse Proxy Server (localhost:3000)

    - Nginx를 Docker로 실행

 

루트 프로젝트 초기 세팅

다음 명령어를 순차적으로 실행해서 프로젝트를 구축한다.

pnpm init
corepack use pnpm@8.10.0
pnpm add turbo -D


 `pnpm-workspace.yaml`을 통해 워크스페이스 설정을 추가한다.

packages:
  - "teams/*"
  - "apps/*"

 

그리고 모든 워크스페이스를 한번에 실행할 수 있도록 turbo.json을 추가해준다.

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

 

nginx-ssi-app 프로젝트 세팅

루트 프로젝트에 apps/nginx-ssi-app 디렉토리를 만들고 프로젝트를 초기화한다.

pnpm init

 

proxy-server.conf 파일을 추가한다.

upstream team_home {
    server host.docker.internal:3001;
}

upstream team_jobs {
    server host.docker.internal:3002;
}

server {
    listen 3000;
    ssi on;

    location /jobs/ {
        proxy_pass http://team_jobs;
    }

    location / {
        proxy_pass http://team_home;
    }
}

 

Dockerfile를 만든다.

FROM nginx:1.25.3-alpine

COPY proxy-server.conf /etc/nginx/conf.d/proxy-server.conf

EXPOSE 3000

 

package.json에 build와 dev 명령어를 추가한다.

{
  "name": "nginx-ssi-app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "docker build -t nginx-ssi-app .",
    "dev": "docker run -p 3000:3000 nginx-ssi-app",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

 

아래의 명령어를 통해 Dockerfile을 기반으로 이미지를 빌드하자

pnpm --filter nginx-ssi-app build

 

teams 워크스페이스 세팅

루트 디렉토리에 teams, teams/team-home teams/team-jobs 디렉토리를 각각 만들고 각각의 워크스페이스(team-home, team-jobs)를 `pnpm init`으로 초기화한다.

 

그리고 아래의 명령어를 통해 serve 패키지를 각 워크스페이스에 설치를 한다.

pnpm --filter team-home add serve
pnpm --filter team-jobs add serve

 

그리고 각 워크스페이스에 dev 명령어를 추가해준다.

# team/team-home
{
  "name": "team-home",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "serve public -p 3001",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "serve": "^14.2.4"
  }
}

# team/team-jobs
{
  "name": "team-jobs",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "serve public -p 3002",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "serve": "^14.2.4"
  }
}

 

#include를 포함한 index.html 만들기

team-home의 public 디렉토리에 index.html과 index.css를 추가한다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link rel="stylesheet" href="/index.css" />
  </head>
  <body>
    <div id="app">
      <div id="main">
        <h1>Home</h1>
        <nav>
          <ul>
            <li><a href="/">Home</a></li>
            <li><a href="/jobs/">Jobs</a></li>
          </ul>
        </nav>
      </div>
      <div id="team-jobs-recommendation">
        <!-- #include virtual="/jobs/fragments/recommendation" -->
      </div>
    </div>
  </body>
</html>

웹서버 (nginx)가 `<!--#include virtual="/jobs/fragments/recommendation" -->

` 부분을 보고 다른 경로의 HTML Fragment를 서버에서 불러와서 삽입해준다.

즉, 서버가 페이지를 클라이언트(브라우저)로 보내기 전에 조립해버린다. (즉, 서버단에서 Fragment를 붙이는 방식이다)

:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-text-size-adjust: 100%;
}

a {
  font-weight: 500;
  color: #646cff;
  text-decoration: inherit;
}
a:hover {
  color: #535bf2;
}

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

h1 {
  font-size: 3.2em;
  line-height: 1.1;
}

#app {
  max-width: 1280px;
  margin: 0 auto;

  display: flex;
  flex-direction: row;
  justify-content: center;
  gap: 20px;
}

#main {
  width: 600px;
  height: 600px;

  background-color: white;

  border-radius: 10px;

  color: black;

  text-align: center;
}

#team-jobs-recommendation {
  width: 400px;
  height: 220px;
  background-color: white;
  border-radius: 10px;
}

#team-jobs-recommendation .error {
  color: black;
}

 

#include로 포함될 템플릿 만들기

team-jobs/public/jobs/fragments/recommendation 디렉토리에 index.html를 추가한다.

<div id="jobs-fragment-recommendation">
  <h2>추천 채용공고</h2>
  <div class="recommendations"></div>
</div>

 

이 상태로 실행을 하면 다음과 같이 Fragment가 `<!--#include >` 부분에 삽입 된다.

 

 

#include로 포함될 템플릿에 css와 js 추가하기

public/jobs/fragments/recommendation에 index.css를 추가하자.

#jobs-fragment-recommendation {
  width: 400px;
  height: 220px;
  background-color: white;
  border-radius: 10px;
  color: black;
}

#jobs-fragment-recommendation h2 {
  margin: 0;
  padding: 20px;
}

#jobs-fragment-recommendation .recommendations {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

#jobs-fragment-recommendation .recommendations div {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  align-items: center;

  padding: 10px 20px;

  background-color: black;
  color: white;
}

 

그리고 index.css를 html에 연동해주도록 하자. 이로써 스타일을 추가할 수 있게 되었다.

<link rel="stylesheet" href="/jobs/fragments/recommendation/index.css" />
<div id="jobs-fragment-recommendation">
  <h2>추천 채용공고</h2>
  <div class="recommendations"></div>
</div>

 

그리고 public/jobs/fragments/recommendation에 index.js를 추가해주고 html에 연동해주도록 하자. 

fetch("/jobs/api/recommendations.json")
  .then((res) => res.json())
  .then((recommendations) => {
    document.querySelector(
      "#jobs-fragment-recommendation .recommendations"
    ).innerHTML = recommendations
      .map(
        ({ name, url }) =>
          `<div><a href="${url}" target="_blank">${name}</a></div>`
      )
      .join("");
  });

 

<link rel="stylesheet" href="/jobs/fragments/recommendation/index.css" />
<div id="jobs-fragment-recommendation">
  <h2>추천 채용공고</h2>
  <div class="recommendations"></div>
</div>
<script src="/jobs/fragments/recommendation/index.js"></script>

 

마지막으로 public/jobs/api에 recommendations.json을 추가해주도록 하자.

[
  { "name": "패스트캠퍼스", "url": "https://fastcampus.co.kr/" },
  {
    "name": "네이버",
    "url": "https://recruit.navercorp.com/"
  },
  { "name": "카카오", "url": "https://careers.kakao.com/" }
]

 

그 결과 다음과 같은 화면을 볼 수 있게 되었다.

 

 

Ajax와의 차이점

클라이언트로 응답을 보낸 후에 Fragment를 가져오는 Ajax와 달리 서버에서 모든 처리를 한 후에 응답을 내려준다.