모노레포 전환기.

#monorepo

#workspace

#infra

Written by Paul

yarn workspaces.

사실 나는 사이드프로젝트를 하면서 새로 github organization을 만들어서 그곳에서만 관리를 했었는데, 처음에 당면한 문제점들은 다음과 같았다.
  • vercel을 사용하여 배포를 하다보니 monorepo 형태로 관리를 하기가 좀 힘들었다.
  • 따라서 monorepo로 관리를 하진 않았고, 각각 흩어진 레포들로 관리를 해 왔다.
처음부터 모노레포를 염두에 두고 있긴 했는데 vercel을 배포용 서버로 쓰다보니 꽤나 복잡했다. 따라서 모노레포 형태를 포기하고 일반 레포 형태로 따로따로 관리를 하다가 모노레포로써의 형태로 물꼬를 트기 시작한 것은 EC2로 서버를 커스터마이징 해서 관리할 때 부터였다.
구조는 다음과 같다.
root |-- apps | |-- client | |-- server | |-- react-native-app |-- packages | |-- design-system | |-- notion-utils | |-- prisma-schema | |-- storybook |-- docker |-- terraform
apps의 경우 웹 앱, 모바일 앱, 서버 앱 등 실제 서비스를 위한 프로젝트들이 들어있다. 이 서비스들은 각각 EC2에서 docker와 함께 배포를 하게 되는데 그 부분은 조금 이따 다루어 보겠다.
packages의 경우 기존 흩어져 있던 util들과 디자인 시스템 등 재사용이 가능한 라이브러리들을 모아놓았다. 확실히 모노레포로 관리하게 되니, 굳이 npm에 배포를 하지 않아도 되어서 재사용성이 훨씬 편하다.
react-native monorepo
좀 특이한 점은 react-native 프로젝트를 monorepo의 패키지로써 추가한 점인데, 이 부분은 를 참고하였다. node_modules의 위치와 pod file등에서 참조하는 네이티브 모듈들의 위치만 잘 조정을 해주면 동작하는데 문제가 없었다.
react-native-monorepo-tools라는 npm package를 사용하여 metro.config.js를 잘 수정해주면 된다.
나는 다음과 같이 수정해 놓았다.
/* eslint-disable @typescript-eslint/no-var-requires */ const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config') const exclusionList = require('metro-config/src/defaults/exclusionList') const { getMetroTools, getMetroAndroidAssetsResolutionFix } = require('react-native-monorepo-tools') const monorepoMetroTools = getMetroTools() const androidAssetsResolutionFix = getMetroAndroidAssetsResolutionFix() /** * Metro configuration * https://reactnative.dev/docs/metro * * @type {import('metro-config').MetroConfig} */ const config = { transformer: { // Apply the Android assets resolution fix to the public path... publicPath: androidAssetsResolutionFix.publicPath, getTransformOptions: async () => ({ transform: { experimentalImportSupport: false, inlineRequires: false, }, }), }, server: { // ...and to the server middleware. enhanceMiddleware: (middleware) => { return androidAssetsResolutionFix.applyMiddleware(middleware) }, }, // Add additional Yarn workspace package roots to the module map. // This allows importing importing from all the project's packages. watchFolders: monorepoMetroTools.watchFolders, resolver: { // Ensure we resolve nohoist libraries from this directory. blockList: exclusionList(monorepoMetroTools.blockList), extraNodeModules: monorepoMetroTools.extraNodeModules, }, } module.exports = mergeConfig(getDefaultConfig(__dirname), config)
안드로이드의 경우는 app/build.gradle 파일에서 실제 패키지들을 참조하는 경로 등을 수정해주면 된다.

Docker와 docker-compose.

이제 모노레포로써의 모습이 완연해 졌으니, 실제 배포 CI/CD pipeline을 태워야 할 때라고 생각했다.
배포될 인프라는 원래는 LoadBalancer + EC2 조합으로 쓰고 있었는데, 생각보다 LoadBalancer의 비용이 너무 많이 나와서 (사이드 프로젝트 형태의 서비스이기 때문에, 24시간 동안 돌아갈 필요가 없었기도 했다) nginx와 EC2 조합으로 인프라 스택을 변경했다. 그러고 나니 LoadBalancer의 비용이 확실히 줄게 되어 금액적인 부분들도 많이 줄어들게 되었다.
Docker 파일들 같은 경우는 다음과 같이 네이밍 컨벤션을 작성해보았다.
notion image
각각의 서비스들의 이름으로 점으로 구분하여 작성된 Dockerfile이고 docker-compose를 사용하여 묶인다.
docker-compose.yml 파일은 다음과 같이 작성해보았다.
version: '3.8' services: coldsurf-io: platform: linux/amd64 build: context: ../ dockerfile: ./docker/Dockerfile.coldsurf-io args: GITHUB_TOKEN: '${GITHUB_TOKEN}' PORT: 4001 image: '${ECR_REMOTE_HOST}/coldsurf/coldsurf-io:latest' env_file: - ../apps/coldsurf-io/.env ports: - '4001:4001' blog-client: platform: linux/amd64 build: context: ../ dockerfile: ./docker/Dockerfile.blog-client args: GITHUB_TOKEN: '${GITHUB_TOKEN}' PORT: 4000 image: '${ECR_REMOTE_HOST}/coldsurf/blog-client:latest' env_file: - ../apps/blog-client/.env ports: - '4000:4000' wamuseum-client: platform: linux/amd64 build: context: ../ dockerfile: ./docker/Dockerfile.wamuseum-client args: GITHUB_TOKEN: '${GITHUB_TOKEN}' PORT: 4002 image: '${ECR_REMOTE_HOST}/coldsurf/wamuseum-client:latest' env_file: - ../apps/wamuseum-client/.env ports: - '4002:4002' billets-server: platform: linux/amd64 build: context: ../ dockerfile: ./docker/Dockerfile.billets-server args: GITHUB_TOKEN: '${GITHUB_TOKEN}' PORT: 3001 image: '${ECR_REMOTE_HOST}/coldsurf/billets-server:latest' env_file: - ../apps/billets-server/.env ports: - '3001:3001' wamuseum-server: platform: linux/amd64 build: context: ../ dockerfile: ./docker/Dockerfile.wamuseum-server args: GITHUB_TOKEN: '${GITHUB_TOKEN}' PORT: 3002 image: '${ECR_REMOTE_HOST}/coldsurf/wamuseum-server:latest' env_file: - ../apps/wamuseum-server/.env ports: - '3002:3002'
docker 빌드를 한 후, ECR에 컨테이너를 올리고 EC2에서 pull해서 쓰는 방식으로 작성했다.
또한 github actions를 사용하여 매뉴얼 하게 동작하는 방식으로 트리거링을 해서 배포를 하는 것으로 마무리 지었다. 당장은 버전관리, 개발용 서버 등이 필요하진 않았기 때문이다.
Terraform 맛보기.
잠깐 테라폼의 니즈가 있어서 테라폼도 라이트하게 사용해 보았다. 실제 github actions에서 ssh를 통해서 EC2에 원격 접속을 한후 몇 가지 cli를 돌려야 했었는데, 해당 부분에서 Terraform을 이용하니까 편했다.
어떤 부분이었냐면, ssh 22번 포트를 EC2 security group을 통해서 열어주어야 했는데, 기존에는 수동으로 MyIP를 사용하다가 github action의 runner의 ipv4를 열어주어야 하는 부분이었다.
해당 부분을 Terraform을 통해 다음과 같이 구현해보았다.
provider "aws" { region = "ap-northeast-2" # Change to your preferred region } # Fetch GitHub Actions IP ranges data "http" "myip" { url = "http://ipv4.icanhazip.com" } # Create Security Group resource "aws_security_group" "coldsurf-terraform-sg" { name = "coldsurf-terraform-sg" description = "Security group for coldsurf and terraform" ingress { from_port = 22 # Replace with the port you need (e.g., 22 for SSH) to_port = 22 protocol = "tcp" cidr_blocks = ["${chomp(data.http.myip.body)}/32"] } ingress { from_port = 4000 # Replace with the port you need (e.g., 22 for SSH) to_port = 4000 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "blog-client" } ingress { from_port = 3002 # Replace with the port you need (e.g., 22 for SSH) to_port = 3002 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "wamuseum-server" } ingress { from_port = 3001 # Replace with the port you need (e.g., 22 for SSH) to_port = 3001 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "billets-server" } ingress { from_port = 3000 # Replace with the port you need (e.g., 22 for SSH) to_port = 3000 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "wamuseum-client" } ingress { from_port = 443 # Replace with the port you need (e.g., 22 for SSH) to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "open https to public" } ingress { from_port = 80 # Replace with the port you need (e.g., 22 for SSH) to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "open http to public" } # Egress rules (outbound traffic rules) egress { from_port = 0 to_port = 0 protocol = "-1" # Allow all outbound traffic (default) cidr_blocks = ["0.0.0.0/0"] # Allow all outbound traffic } tags = { Name = "ColdSurf Security Group" } }
기존 보안그룹에서 public하게 열린 마이크로서비스 포트들과 함께 ssh에 필요한 22번 포트를 코드화 해서 관리하는 파일이다. 깃헙 액션 러너의 ip를 파싱하여 깃헙 액션에서 CI 실행 시, 해당하는 ip만을 22번에 열어준다.

EC2 Nginx 설정.

Docker에서 마이크로 서비스로 docker-compose를 통해 여러 서비스를 띄우다보니, root nginx 설정이 필요했다. Docker 자체에서 nginx를 설정하진 않았고, docker-compose로 띄워진 서비스의 포트를 종합하는 실제 물리적인 EC2 내부의 nginx를 설치후에 config를 설정해주었다.
먼저 도메인을 분기한다.
server { if ($host = api.billets.coldsurf.io) { return 301 https://$host$request_uri; } # managed by Certbot if ($host = api.wa-museum.coldsurf.io) { return 301 https://$host$request_uri; } if ($host = wamuseum.coldsurf.io) { return 301 https://$host$request_uri; } if ($host = blog.coldsurf.io) { return 301 https://$host$request_uri; } if ($host = coldsurf.io) { return 301 https://$host$request_uri; } server_name api.billets.coldsurf.io api.wa-museum.coldsurf.io wamuseum.coldsurf.io blog.coldsurf.io coldsurf.io; return 404; # managed by Certbot listen [::]:443 ssl ipv6only=on; # managed by Certbot listen 443 ssl; # managed by Certbot }
위와 같이 각각의 도메인을 기반으로 서버를 분기하여 해당 도메인에 접근 시 각각의 nginx 설정으로 우회시킨다.
해당하는 서비스의 nginx 예제는 다음과 같다.
server { server_name wamuseum.coldsurf.io; location / { proxy_pass http://127.0.0.1:4002; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /_next/static/ { proxy_pass http://127.0.0.1:4002; } listen 443 ssl; # Optional: Configure logging error_log /var/log/nginx/error.log; access_log /var/log/nginx/access.log; }
해당하는 서비스의 포트로 proxy pass를 걸어주고, nextjs 를 사용하는 경우에는 /_next/static 경로를 열어주었다.
nginx static 경로 열어주기
static 경로를 열어줄때에 403 에러가 날 수 있는데, nginx 프로세스가 사용하는 실제 파일시스템 권한이 없기 때문일 수 있다. 그럴때에는 EC2의 유저의 그룹에 www-data를 추가해주면 해결이 될 수도 있다.
다음과 같이 해결해 볼 수 있다.
$ groups ubuntu $ sudo usermod -aG ubuntu www-data $ sudo chmod g+rw /path/to/file_or_directory
443 https 인증서 관리
또한, 인증서 관리가 필요했는데 nginx로 직접 커스터마이징을 하다보니 기존에 LoadBalancer와 ACM기반으로 인증서 관리를 하던 부분을 대체하고, nginx와 Let’s Encrypt 기반으로 443 인증서 관리를 하는 것으로 변경했다.

마치며.

사실 Fargate 기반의 인프라로 서버를 대체할 예정이긴 하다. 이유는 24시간 떠있어야 하는 그런 서비스도 아니고, 계속 트래픽이 몰리는 서비스가 아직까지는 아니기 때문에, 사용량 기반으로 가격을 책정하는 Fargate container serverless 환경이 더 알맞는 인프라인 것 같다. 간헐적 사용이 많고 트래픽 변동이 있는 경우에는 다음과 같이 Fargate가 더 낫다고 한다.

1. EC2 + ECR 조합

  • EC2 비용: EC2를 사용하면 사용자는 인스턴스 타입을 직접 선택하고, 고정된 컴퓨팅 리소스를 계속 유지하게 됩니다. 이 방식은 24/7 운영이 필요한 경우 유리하며, 특정 시간에 계속 실행되어야 하는 워크로드에서는 Fargate보다 비용을 줄일 수 있습니다.
  • Auto Scaling: EC2 Auto Scaling을 통해 트래픽 변동에 따라 인스턴스 수를 조정할 수 있지만, 설정이 다소 복잡하고 과/부하 조정의 유연성은 Fargate에 비해 제한적입니다.
  • 장점: 장기 예약 인스턴스나 스팟 인스턴스를 사용하면 비용을 크게 줄일 수 있습니다.
  • 단점: 인프라를 관리하는 오버헤드가 있고, 유연성이 낮아 트래픽 변동이 크거나 짧은 시간에 트래픽이 몰리는 경우에는 비효율적일 수 있습니다.

2. Fargate

  • Serverless 관리형 서비스: Fargate는 관리형 서비스로, 서버 관리 및 설정이 필요 없이 컨테이너 기반 애플리케이션을 운영할 수 있습니다. 애플리케이션을 요청에 따라 시작하고 중지하므로, 트래픽이 불규칙적이거나 짧은 시간 동안 많은 트래픽을 받는 서비스에 유리합니다.
  • 과금 방식: Fargate는 사용한 리소스(예: CPU, 메모리)에 따라 초 단위로 과금되므로, 간헐적으로 사용되는 애플리케이션에서는 비용 효율적입니다.
  • Auto Scaling: 자동 스케일링이 더 유연하게 적용되어, 트래픽 변동에 따른 비용 최적화가 가능합니다.
  • 단점: Fargate는 EC2와 비교해 일정 수준 이상의 리소스를 계속해서 사용해야 하는 서비스에선 더 비싸질 수 있습니다.

결론 및 추천

  • 고정된 리소스와 24/7 운영이 필요한 경우: EC2와 ECR 조합이 유리합니다. 특히 예약 인스턴스나 스팟 인스턴스를 활용할 수 있다면, 비용 절감 효과가 큽니다.
  • 간헐적 사용이 많고 트래픽 변동이 큰 경우: Fargate가 유리합니다. 애플리케이션을 필요할 때만 가동할 수 있어 리소스 비용을 최소화할 수 있습니다.
아 참, 해당 모노레포가 혹시라도 궁금하신 분들은 를 방문해서 봐주시면 됩니다 🙂
 
← Go home