마이크로 프론트엔드 - Ajax를 활용한 마이크로 프론트엔드 개발

루트 프로젝트 세팅

다음 명령어로 pnpm으로 루트 디렉토리에 프로젝트를 세팅해보자

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

 

그 다음에 각 팀별로 프로젝트를 세팅하기 위해서 pnpm의 workspace를 지정해주도록 한다.

# pnpm-workspace.yaml

packages:
  - "teams/*"

 

우리는 home 서비스 팀과 jobs 서비스 팀을 구분해서 작업을 진행할 것이고, home 팀은 vite로 프로젝트를 세팅하고 jobs 팀은 pnpm init으로 프로젝트 세팅을 진행할 것이다. 

 

먼저 루트 디렉토리에 teams 디렉토리를 생성해두자

 

team-home 프로젝트 세팅

pnpm create vite@latest team-home --template vanilla-ts

 

3001번 포트에서 실행하기 위해 dev 스크립트 명령어를 다음과 같이 바꿔주도록 한다.

# package.json
{
  "name": "team-home",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite --port 3001",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "typescript": "~5.7.2",
    "vite": "^6.3.1"
  }
}

 

team-jobs 프로젝트 세팅

mkdir team-jobs
pnpm init

 

3002번 포트에서 실행하기 위해서 serve 패키지를 설치하고, dev 스크립트 명령어를 바꿔주도록 한다.

pnpm --filter team-jobs add serve
{
  "name": "team-jobs",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "serve public -p 3002"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "serve": "^14.2.4"
  }
}

 

turbo.json 설정하기

두 프로젝트를 모두 한 번에 실행하기 위해서 루트에 turbo.json을 설정하도록 하자

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

 

여기까지 완료되었다면 다음과 같은 프로젝트 구조가 되었을 것이다.

 

아래의 명령어로 두 프로젝트를 모두 실행해보도록 하자

pnpm exec turbo dev

 

team-home에서 Framgent를 fetch 할 수 있도록 HTML 구조 잡기

team-home에서 fragment를 가져올 수 있도록 구조를 잡아보자.

 

main.ts를 다음과 같이 바꿔보도록 하자

import "./style.css";

document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
  <div id="main">메인입니다</div>
  <div
    id="team-jobs-recommendation"
    data-fragment="http://localhost:3002/jobs/fragments/recommendation"
  ></div>
`;

id가 team-jobs-recommendation인 div 태그에 다른 팀에서 작성한 Fragment가 들어오는 곳이며, custom attribute 속성에 있는 url로부터 html을 불러오도록 하게 된다.

 

그리고 스타일(style.css)도 추가해주도록 하자.

#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;
}

 

team-jobs에서 Framgent를 만들기

team-jobs에 html을 서빙하기 위해서 public/jobs/fragments/recommendation 폴더를 만들고, index.html을 만든다.

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

 

 

이제 여기까지 완료했다면 로컬 환경의 3001번 포트와 3002번 포트에서 각각 아래와 같은 이미지를 볼 수 있게 된다.

http://localhost:3001번일 때의 화면
http://localhost:3002번일 때의 화면

 

 

Team-Home 에서 Team-Jobs의 Fragment를 로드하기

team-home에서 team-jobs의 fragment를 로드하도록 하는 함수를 작성해보도록 하자

export async function loadFragment(root: HTMLElement) {
  const template = root.getAttribute("data-fragment")!;

  const htmlUrl = `${template}/index.html`;

  try {
    // html 로드
    const html = await window.fetch(htmlUrl).then((res) => res.text());
    root.innerHTML = html;
  } catch (error) {
    // TODO: 리포트 서버로 에러를 전송한다.

    // 에러 화면을 띄운다.
    root.innerHTML = `<div class="error">에러입니당</div>`;
  }
}

 

그리고 이 함수를 이용해서 main.ts에서 Fragment를 불러오도록 하자

import "./style.css";
import { loadFragment } from "./utils/fragment";

document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
  <div id="main">메인입니다</div>
  <div
    id="team-jobs-recommendation"
    data-fragment="http://localhost:3002/jobs/fragments/recommendation"
  ></div>
`;

loadFragment(
  document.querySelector<HTMLDivElement>("#team-jobs-recommendation")!
);

 

이렇게 되면 잘 불러올 줄 알았는데, 아래 화면과 같은 오류가 발생한다.

 

왜냐하면 브라우저 정책상 다른 리소스로부터 데이터를 가져올 수 없기 때문에 발생한 것이다. 이를 해결하려면 어떻게 해야할까..? 

나는 Proxy 서버를 구축하는 방향으로 문제를 해결했다.

 

CORS 문제 해결

team-home에 vite.config.ts를 생성하여 proxy 서버를 구축해주도록 하자

import { defineConfig } from "vite";

// CORS 처리
export default defineConfig({
  server: {
    proxy: {
      "/jobs/fragments/recommendation": {
        target: "http://localhost:3002",
        changeOrigin: true,
        rewrite: (path) =>
          path.replace(
            /^\/jobs\/fragments\/recommendation/,
            "/jobs/fragments/recommendation"
          ),
      },
    },
  },
});

 

그리고 main.ts의 data-fragment 속성값을 다음과 같이 수정하도록 하자

document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
  <div id="main">메인입니다</div>
  <div
    id="team-jobs-recommendation"
    data-fragment="/jobs/fragments/recommendation"
  ></div>
`;

 

이렇게 Proxy를 통해 CORS 문제를 해결할 수 있었다.

 

 

Team-Home에서 Team-Jobs의 CSS, JS를 실행시키기

Team-Jobs의 public/jobs/fragments/recommendation에 index.css, index.js를 만들자.

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

#jobs-fragment-recommendation h2 {
  margin: 0;
  padding: 20px;
}
fetch("http://localhost:3002/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("");
  });

 

그리고 public/jobs에 api 디렉토리를 생성하고 recommendations.json 파일을 만들자.

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

 

Fragment를 불러오는 코드를 다음과 같이 수정해보도록 하자

export async function loadFragment(root: HTMLElement) {
  const template = root.getAttribute("data-fragment")!;

  const htmlUrl = `${template}/index.html`;
  const styleUrl = `${template}/index.css`;
  const scriptUrl = `${template}/index.js`;

  try {
    // html 로드
    const html = await window.fetch(htmlUrl).then((res) => res.text());
    root.innerHTML = html;

    // css 로드
    const link = document.createElement("link")!;
    link.rel = "stylesheet";
    link.href = styleUrl;
    root.appendChild(link);

    // script 로드
    const script = document.createElement("script")!;
    script.src = scriptUrl;
    root.appendChild(script);
  } catch (error) {
    // TODO: 리포트 서버로 에러를 전송한다.

    // 에러 화면을 띄운다.
    root.innerHTML = `<div class="error">에러입니당</div>`;
  }
}

 

마지막으로 style.css에 다음 코드를 추가해서 스타일링을 진행해보도록 하자.

#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;
}

 

그럼 다음과 같은 화면을 구축할 수 있게 된다.

 

여기서 team-jobs가 마주하는 문제점이 있는데 innerHTML로 삽입된 script는 dead script로 취급이 되기 때문에 실행되지 않는다는 점이 있다. 즉, 이러한 방식으로는 team-jobs는 별도의 로컬환경에서 테스트를 진행해볼 수가 없다는 뜻이다. 

 

team-jobs의 index.html에 <link>와 <script>를 추가하여 내부적으로도 테스트를 하고, Fragment를 필요로하는 곳에서도 적용될 수 있도록 바꿔보도록 하자

 

Team-jobs 내부 테스트가 가능하도록 수정하기

기존에는 localhost:3002 에 접속하면 style과 script를 받아올 수가 없었다. (등록을 하지 않았으니깐)

다음과 같이 수정을 하게 되면 localhost:3002에서도 데이터를 불러오고 스타일링을 적용할 수 있게 된다.

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

 

그리고 Team-home의 fragment 로드 함수에 style과 script 관련 로드 함수가 필요없어졌으므로 제거해주도록 하자. (왜냐하면 inline-html로 들어갔으니깐!)

 

export async function loadFragment(root: HTMLElement) {
  const template = root.getAttribute("data-fragment")!;

  const htmlUrl = `${template}/index.html`;

  try {
    // html 로드
    const html = await window.fetch(htmlUrl).then((res) => res.text());
    root.innerHTML = html;

  } catch (error) {
    // TODO: 리포트 서버로 에러를 전송한다.

    // 에러 화면을 띄운다.
    root.innerHTML = `<div class="error">에러입니당</div>`;
  }
}

 

이렇게 바꾸게 되니 css를 잘 적용이 되었으나 script 가 제대로 로드가 되지 않는 것 같다. 위에서 언급했던 dead script가 되었기 때문이다. (Element 탭을 보니 script는 잘 가져왔으나 이것이 실행되지는 않게 되는 것 같다.)

 

그렇다면 fragment로드 함수에서 script 함수를 부활시켜줄 필요가 있다. 

다음 코드를 추가해서 script 함수를 다시 생성해주도록 하자.

export async function loadFragment(root: HTMLElement) {
  const template = root.getAttribute("data-fragment")!;

  const htmlUrl = `${template}/index.html`;


  try {
    // html 로드
    const html = await window.fetch(htmlUrl).then((res) => res.text());
    root.innerHTML = html;

    const scripts = root.querySelectorAll("script");
    scripts.forEach((oldScript) => {
      const newScript = document.createElement("script");

      if (oldScript.src) {
        // 상대경로 src라면 보정해준다
        const src = oldScript.getAttribute("src")!;
        if (src.startsWith("./") || src.startsWith("../")) {
          const baseUrl = template; // ex: http://localhost:3002/jobs/fragments/recommendation
          newScript.src = new URL(src, baseUrl + "/").toString();
        } else {
          newScript.src = src;
        }
      } else {
        newScript.textContent = oldScript.textContent;
      }

      oldScript.replaceWith(newScript);
    });
  } catch (error) {
    // TODO: 리포트 서버로 에러를 전송한다.

    // 에러 화면을 띄운다.
    root.innerHTML = `<div class="error">에러입니당</div>`;
  }
}

 

이렇게 script 링크를 다시 연결시켜주니 이전과 같이 동작하게 된다. 이렇게 되면 Fragment를 사용하는 쪽과 만드는 쪽 모두 테스트를 할 수 있게 된다.