-
Notifications
You must be signed in to change notification settings - Fork 0
팀 어썸 오렌지가 폴더 구조를 2번 리팩토링하게 된 사연
저희는 폴더 구조를 2번 마이그레이션한 경력을 가지고 있어요. 도대체 왜 폴더 구조를 2번 마이그레이션하게 되었는지, 어떤 점이 문제였는지를 소개할게요.
팀 어썸 오렌지는 feature 기반 폴더 구조를 채택하고 있어요. feature 기반 폴더를 왜 채택하게 되었는지는 다음의 문서를 참조하세요.
.
├── common/
│ ├── auth
│ └── scroll
├── introSection
├── header
├── simpleInformation
├── interaction
├── detailInformation
├── comments
├── fcfs
├── qna
└── footer
저희의 맨 처음 폴더 구조는 다음과 같았어요. 가장 폴더의 깊이가 얕아서 작은 규모에서 개발이 편하다는 장점이 있었지만, 프로젝트가 커지니, 다음과 같은 문제가 생기기 시작했어요.
- common 폴더를 찾기 어려웠어요. 루트 src 폴더 내에 폴더가 매우 많이 증식하기 때문에, 최상단 폴더 관리가 어려워졌어요. 여기에 더해, common 폴더와 각각의 feature 폴더가 동일한 위계에 존재했기 때문에, common 폴더를 찾기 위해 수많은 폴더들을 뒤져야 하는 상황이 찾아오게 되었어요.
- 어드민 페이지의 feature와 메인 페이지의 feature가 동일한 위계에 있을 수 있어요. 향후 확장성을 고려할 때, 페이지가 2개 이상으로 증식하는 경우가 있는데, 현재 상황에서는 메인 페이지의 feature와 admin 페이지의 feature가 동일한 위계에 존재하게 되어서, 차후 유지보수에 혼란이 있을 것이라고 생각했어요.
.
├── common/
│ ├── components
│ ├── hooks
│ ├── dataFetch
│ └── modal
├── mainPage/
│ ├── shared/
│ │ ├── components
│ │ ├── hooks
│ │ └── auth
│ └── features/
│ ├── introSection
│ ├── header
│ ├── interaction
│ └── fcfs
└── adminPage/
├── shared
└── features
프론트엔드 간 논의 끝에, 폴더 구조를 위와 같이 변경했어요. 변경된 폴더 구조를 설명하자면, 다음과 같아요.
- 최상단 폴더를 3개로 그룹지었어요. 메인 페이지와 어드민 페이지에서 공통적으로 쓰이는 feature인 common, 그리고 각각의 페이지에서 쓰이는 feature인 mainPage와 adminPage로 구분되도록 했어요. 메인 페이지와 어드민 페이지의 기능을 분리해서 페이지 간 기능이 섞이는 혼란을 방지하고자 했어요.
- 각 페이지는 shared 폴더와 feature 폴더로 구분되도록 했어요.
- feature 폴더는 페이지의 거대한 주요 기능이자, 특징적인 기능을 의미해요. 페이지에서 단 1번만 쓰이도록 구성했어요. 이렇게 제약을 둔 이유는 기능의 혼합을 막고, 기능 간의 의존성을 줄이기 위해서에요.
- shared 폴더는 각 feature에서 2번 이상 참조되는 기능을 의미해요. 페이지에서 여러 번 사용될 수 있어요.
이렇게 구성하니, 루트 폴더가 전의 것보다 깔끔해지면서 common 폴더를 찾기 쉬워지고, 메인 페이지와 어드민 페이지의 폴더적 관심사도 분리되었다는 이점이 있었어요.
하지만, 막판에 들어서면서, 어드민 페이지를 배포하려고 하니, 어드민 페이지가 배포가 안 되는 문제점이 발견되었어요. 저희는 메인 페이지에서 어드민 페이지의 기능을 탐색해서는 안 된다고 생각했기 때문에, 어드민 페이지와 메인 페이지를 다른 경로에 배포하고 싶었어요. 하지만, 현재 상황에서는 메인 페이지를 배포할 때 루트 디렉토리를 참조하면서 배포되기 때문에, 어드민 페이지를 배포할 때 루트 폴더에 있던 배포 설정과 충돌할 수 있을 것 같았어요.
또한, 메인 페이지와 어드민 페이지는 개발의 편의성을 위해서 public 폴더와 모듈 종속성을 공유하고 있는데, 이렇게 되면 실제로 배포할 때 사용하지 않는 상대 편 페이지의 자산이 배포되어서 배포 서버의 자원을 잡아먹을 수 있을 것이라고 생각했어요. 만약, 어드민 페이지에 있던 자산 중 민감한 개인정보가 있는 자산이 메인 페이지 경로에 노출된다면, 보안상으로도 문제가 될 것이라고 생각했어요.
그래서 저희는 아예 배포 시의 루트 디렉토리를 메인 페이지와 어드민 페이지가 다르게 가져가도록 리팩토링을 수행하기로 했어요.
.
├── packages/
│ ├── common/
│ │ ├── src
│ │ └── package.json
│ ├── mainPage/
│ │ ├── src
│ │ ├── public
│ │ ├── index.html
│ │ └── package.json
│ └── adminPage/
│ ├── src
│ ├── public
│ ├── index.html
│ └── package.json
├── public
└── package.json
오랜 고민 끝에, 저희는 모노레포 구조를 차용하기로 했어요. 모노레포 구조를 따르면, 하나의 레포지토리에 2개의 프로젝트(메인 페이지, 어드민 페이지)가 있는 상황에서, 2개의 프로젝트 자체를 별개의 폴더로 관리하면서도, 중복된 의존성을 분리할 수 있어요. 저희가 모노레포로 얻은 이점은 총 3가지에요.
- 메인 페이지와 어드민 페이지가 완전히 별개의 프로젝트로 이루어진 폴더로 구성되어 있기 때문에, 메인 페이지와 어드민 페이지의 배포 전략을 혼선 없이 다르게 세울 수 있다는 이점이 있어요. 이는 다시 말하면 어드민 페이지가 메인 페이지와 별개로 배포가 가능하다는 거에요.
- 정적 자원도 메인 페이지의 것과 어드민 페이지의 것을 분리할 수 있어서, 어드민 페이지의 자원이 메인 페이지에 배포되는 현상을 막을 수 있어서 배포 서버의 자원을 절약할 수 있어요.
- 또한, 의존성 역시 공통 의존성과 메인 페이지에서만 존재하는 의존성, 어드민 페이지에서만 존재하는 의존성을 분리할 수 있다는 이점이 있어요.
하지만, 저희는 한 가지 문제를 마주하게 되었어요. 그것은 바로, 공통으로 참조해야 하는 정적 자원이었어요. 폰트나 일부 아이콘, 파비콘은 메인 페이지와 어드민 페이지가 공통으로 참조하기 때문에, 이들을 하나로 관리할 필요가 있었어요.
저희는 모노레포 환경에서 공통 정적 자원을 public 폴더 내에서 관리하기를 원했지만, 전역 public 폴더는 각각의 프로젝트 폴더의 public 폴더와 다른 계층에 있었기에, 어떻게 dev 서버가 전역의 public 폴더를 전역 자원으로 취급하게 만들지, 어떻게 빌드 시의 public 폴더의 자원을 실제 빌드되는 자원의 public의 것으로 옮길지가 관건이었어요.
저희는 dev 서버에서는 vite의 플러그인을 이용해 퍼블릭 자원을 참조하려고 하면 공통 정적 자원의 것을 우선적으로 탐지하도록 하고, 빌드 시에는 각각의 프로젝트 폴더의 dist 폴더에 공통 정적 자원을 붙여넣는 방식으로 해결하고자 했어요.
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import { createReadStream, existsSync } from "node:fs";
import { lookup } from "mime-types";
const __dirname = fileURLToPath(new URL("../../", import.meta.url));
export default function sharedAssetRouter(paths) {
function foundRoute(path) {
for (let [symbol, assetPath] of paths) {
if (path.startsWith(symbol)) {
const postfix = path.slice(symbol.length);
return join(assetPath, postfix);
}
}
return null;
}
return {
name: "shared-assets",
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const originPath = foundRoute(req.url);
if (originPath === null) return next();
const filePath = join(__dirname, originPath);
if (!existsSync(filePath)) return next();
const stream = createReadStream(filePath);
stream.on("error", (err) => {
if (err.code === "ENOENT") {
res.statusCode = 404;
res.end("Send Fallback");
} else {
res.statusCode = 500;
res.end("Server error");
}
});
const contentType = lookup(filePath);
res.setHeader("Content-Type", contentType);
stream.pipe(res);
});
},
};
}
우선, 저희는 개발 서버가 공통 public 자원을 참조할 수 있도록, vite 개발 서버에서 동작하는 플러그인을 개발했어요. 이 플러그인은 다음과 같은 방식으로 동작해요.
- url이 사전에 설정한 public path에 맞는지 확인하고, 이를 변환시켜요. 만약 사전에 설정한 public path와 일치하지 않는다면, vite의 기본 동작을 따르도록 했어요.
- 레포지토리의 루트 path를 기준으로, 공통 퍼블릭 자원이 있는지 확인해요. 만약 존재하지 않는다면, vite의 기본 동작을 따라요.
- 공통 퍼블릭 자원에 대해, 스트림을 생성하고, mime type을 생성한 뒤, 응답으로 스트림을 전송해요.
async function copyFolder(src, dest) {
// 대상 폴더가 존재하지 않으면 생성
await mkdir(dest, { recursive: true });
// src 폴더 안의 모든 항목 가져오기
const entries = await readdir(src, { withFileTypes: true });
// 모든 항목을 순회하며 복사
for (let entry of entries) {
const srcPath = resolve(src, entry.name);
const destPath = resolve(dest, entry.name);
if (entry.isDirectory()) {
// 디렉토리인 경우 재귀적으로 복사
await copyFolder(srcPath, destPath);
} else {
// 파일인 경우 복사
await copyFile(srcPath, destPath);
}
}
}
async function processBuild() {
await Promise.all([buildClient(), buildSSG()]);
await Promise.all([
copyFolder("../../public/font", `./dist/font`),
copyFolder("../../public/favicon", `./dist/favicon`),
copyFolder("../../public/icons", `./dist/shared/icons`),
]);
await injectSSGToHtml();
}
빌드 시에는 공통 public 브랜치에 있던 폰트, 파비콘, 아이콘 자원을 dist 폴더로 옮기는 코드를 구성했어요. 이를 이용하면, dist 폴더 내에 공통 퍼블릭 자원이 위치하게 되어서, 빌드 후 배포 시에도 적절한 공통 자원을 찾을 수 있어요.
-
🎯 기술적 선택 이유
-
✨ UX 및 접근성
-
#️⃣ 코드 퀄리티
-
🛠️ 구현