
다른 포스팅과 달리 공부 일지 기록에 가까운 포스팅입니다.
디테일한 내용을 찾으신다면 다른 포스팅을 참고하시길 바랍니다.
이 포스팅에서 사용된 아키텍처는 마이스터넷 지방기능경기대회 클라우드 부분 2024 1과제를 참고하였으며, 저작권은 마이스터넷(한국산업인력공단)에 있음을 미리 알립니다.
사용된 자료는 마이스터넷 "시행자료 및 공개 과제"를 참고하였습니다.
배포 파일은 따로 제공하지 않아 직접 배포할 코드를 작성하였고, 아래의 깃허브 레포지토리에서 확인할 수 있습니다.

편의를 위해 과제에 나온 아키텍처는 유지하면서 서브넷에 CIDR 정보를 추가하였고, 여러가지 부가적인 내용도 추가하였다. 또한 기존엔 EKS를 사용하는 아키텍처였으나, ECS로 대체하였다.
대충 설명하자면 아래와 같다.
다음으로 DB와 관련된 설명이다.
그 외의 서비스는 아래와 같다.
아키텍처를 만들어보기 전, NestJS 코드부터 간단히 보고 넘어가겠다. 크게 Secret Manager를 통한 세션 시크릿 키 관리 로직과 DocumentDB와 호환되는 MongoDB 연동, 그리고 Redis 연동을 살펴보겠다.
코드의 퀄리티를 중점으로 한것이 아니기 때문에 대충 보고 넘어가자. 아래의 3가지는 Board API Service와 Auth API Service 모두 공통되기 때문에 두 서비스를 구분하지 않는다.
AWS SDK 버전은 V3를 사용한다. 때문에 아래와 같은 라이브러리를 설치해야 한다.
npm i @aws-sdk/client-secrets-manager
그리고 Secret Manager에서 키를 가져오는 코드는 아래와 같다. 서버를 켰을 때 한번만 실행되면 되니 NestJS 서비스 DI를 구현하진 않았다.
async function getSecret(): Promise<Record<string, string>> {
const client = new SecretsManagerClient({
region: process.env.AWS_REGION,
})
const command = new GetSecretValueCommand({ SecretId: process.env.SESSION_SECRET_NAME })
const response = await client.send(command)
if (!response.SecretString) {
throw new Error('SecretString not found')
}
return JSON.parse(response.SecretString)
}
그리고 main.ts에서 아래와 같이 사용하였다.
const secret = await getSecret()
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: secret['session_secret_key'],
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24, // 1 day
},
})
)
참고로 세션 로직을 담당하는 라이브러리는 express-session을 사용하였다. 간단하게 사용할 수 있고, Express 용으로 만들어졌으나 NestJS가 Express를 기반으로 작동할 수 있기 때문에 미들웨어로 사용하였다.
다음으로 DocumentDB는 MongoDB 용으로 코드를 작성하였다. 그래도 서로 호환이 되므로 상관 없다.
MongoDB 클라이언트는 NestJS에서 공식적으로 지원한다. (@nestjs/mongoose)
아래와 같이 모듈에서 import하여 사용할 수 있다.
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuthService } from './auth.service'
import { AuthController } from './auth.controller'
import { User, UserSchema } from './user.entity'
@Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
providers: [AuthService],
controllers: [AuthController],
})
export class AuthModule {}
스키마는 아래와 같이 작성하였다. (포스트 스키마는 코드를 따로 참조하길 바람)
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
@Schema()
export class User extends Document {
@Prop({ required: true, unique: true })
username: string
@Prop({ required: true })
password: string
}
export const UserSchema = SchemaFactory.createForClass(User)
Redis는 이 프로젝트에선 단순히 세션 저장용으로 사용하기 때문에 서비스로 만들거나 하진 않았다.
때문에 아래와 같이 express-session에 등록해주는 방식으로 사용하였다.
const redisClient = redis.createClient({
url: process.env.REDIS_URL!,
})
redisClient.on('error', (err) => {
console.error('Redis error:', err)
})
redisClient.on('connect', () => {
console.log('Connected to Redis')
})
await redisClient.connect()
const secret = await getSecret()
app.use(
session({
store: new RedisStore({ client: redisClient }), // 세션 저장에 Redis 사용
secret: secret['session_secret_key'],
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24, // 1 day
},
})
)
환경변수는 아래와 같다. (Auth API Service, Board API Service 공통)
# PORT=3000
MONGODB_URI=mongodb://localhost:27017/board
REDIS_URL=redis://localhost:6379
SESSION_SECRET_NAME=TestSecret
여기서 MONGODB_URI, REDIS_URL은 추후 DB 구축 후 바꿔 넣도록 하고, SESSION_SECRET_NAME은 AWS Secret Manager에서 만들 시크릿 이름이다.
그리고 포트를 따로 환경변수로 지정하며 배포 시엔 3000번으로 통일할 것이나, 개발 환경에선 Auth API Service는 3001번, Board API Service는 3002번으로 설정하였다.
첫번째로 Secret Manager 설정부터 해보자. 참고로 시크릿 하나당 월 0.4$가 고정적으로 청구되고 API 요청 수에 따라 추가적으로 청구되는데, 계정에서 시크릿 생성 후 처음 30일은 무료이니 참고하자. (시크릿 안에 여러개의 키가 있는 구조)

접속해보면 새 보안 암호 저장 버튼이 있다.

보안 암호 유형 선택에선 다른 유형의 보안 암호를 선택한다. RDS나 DocumentDB 등의 자격 증명을 만들 수 도 있는데, 이 포스팅에선 다루지 않는다.

키엔 session_secret_key, 값엔 아무 값이나 넣는다. (추후 랜덤한 문자열로 로테이션되게 설정할 예정)
그 외에 필요한 키가 있다면 추가하도록 하고, 다음 버튼을 누른다.

그러면 교체 구성(로테이션 설정)을 할 수 있는데, 나중에 하도록 하고 일단 시크릿을 만든다.

그리고 검토 후 저장을 클릭하여 시크릿을 만든다.

이제 테스트를 해보자. NodeJS로 간단하게 테스트해보겠다. (AWS CLI 등으로 로그인하여 자격 증명이 있어야함)

잘 나오는 것을 볼 수 있다. 이제 키 로테이션 설정을 해볼 수 있으나, 분량 상 길어지고 주제의 범위에 해당되지 않으므로 아래의 포스팅을 참고해보도록 하자.
https://velog.io/@yulmwu/aws-secrets-manager-key-rotation-lambda
VPC는 CIDR은 아래와 같이 설정한다.
10.0.0.0/1610.0.0.0/24 (ap-northeast-2a)10.0.1.0/24 (ap-northeast-2a)10.0.2.0/24 (ap-northeast-2a)10.0.10.0/24 (ap-northeast-2c)10.0.11.0/24 (ap-northeast-2c)10.0.12.0/24 (ap-northeast-2c)NAT Gateway는 Public Subnet A, Public Subnet B에 2개를 배치한다. (하나만 써도 되지만 고가용성을 위해선 각 AZ에 두는것이 좋다)
서브넷 A는 ap-northeast-2a, 서브넷 B는 ap-northeast-2c AZ에 두도록 하였다.

VPC 설정은 됐다. 그리고 프라이빗 서브넷 10.0.2.0/24, 10.0.12.0/24는 Protected로 사용한다. NAT Gateway를 두지 않은 상태로 완전히 고립시킬 수 있지만, 일단은 연결을 해주었다. 원할 시 라우팅 테이블에서 끊어주기만 하면 된다.
Bastion Host가 뭔지 모르겠다면 아래의 포스팅에서 대충 참고해보자.
https://velog.io/@yulmwu/ec2-bastion-host


네트워크 설정에서 만들어뒀던 VPC로 두고, 퍼블릭 서브넷(A, B 선택)에 배치한다. 그리고 보안그룹에서 SSH를 활성화해야 한다. (기본 값)


잘 된다. 추후 문제가 발생했다면 이 Bastion Host로 접속해서 확인해보거나 하면 된다.
이제 데이터베이스 설정을 해보자. 먼저 DocumentDB 구축부터 해보겠다. Protected Subnet A에 Primary DB를 두고, Protected Subnet B에 ReadOnly 레플리카를 두는 형식으로 해보겠다.
먼저 클러스터를 만들기 전, 서브넷 그룹을 만들어주겠다. 여기에 있는 서브넷들을 바탕으로 클러스터가 구성된다.

그런 다음 클러스터를 만들자.

먼저 인스턴스 기반으로 선택한다.

클러스터의 인스턴스 개수는 Primary + Replica 2개로 선택해주었다.

Connectivity는 첫번째를 선택한다.

그리고 인증 부분에서 비밀번호를 직접 입력하였는데, AWS Secrets Manager 등으로 관리하는 것도 좋은 방법이다.

네트워크 설정에서 서브넷 그룹은 아까 만들어뒀던걸 선택하였다.

그럼 잘 만들어진다. 자동으로 기본 인스턴스(Primary) 하나와 복제본 인스턴스(ReadOnly Replica)를 만들게 된다.


Bastion Host로 접속해보면 잘 되는걸 볼 수 있다. 다음으로 ElastiCache 설정을 해보자.
ElastiCache도 서브넷 그룹을 만들어야 하는데, 기존 서브넷 그룹을 그대로 쓸 수 있으니 넘어가자.
이제 Redis 클러스트를 만들어보자. (Valkey를 사용해도 좋지만, 일단은 Redis OSS로 해보겠다)

그리고 직접 구성하기 위해 클러스터 모드는 비활성화 해주었다.



그리고 복제본 개수는 1개로 만든다. (Primary + Replica 1개)

서브넷 그룹 설정은 기존에 만들어뒀던 그룹으로 선택한다.

그리고 마지막으로 가용 영역 배치에선 Primary는 2a, Replica는 2c에 배치해뒀다. (어디에 두던 상관은 없음)
나머지 설정은 알아서 해보거나 스킵하고, 클러스터 노드가 만들어질 때 까지 기다려보자.


Bastion Host에서도 잘 접속되는 것을 볼 수 있다. (DocumentDB, Redis 각각 보안 그룹에서 27017, 6379 포트를 열어줘야 한다)
이제 DB 설정은 마쳤다. 자격 증명을 Secrets Manager로 관리할 수 있으나, 귀찮으니 스킵하였다.
Auth API Service 이미지를 올려둘 ECR을 만들고 푸시하자.



참고로 맥이라면 빌드 시 --platform linux/amd64 옵션을 붙여줘야 한다. 애플 실리콘 맥에서 빌드하면 ARM 이미지로 빌드된다.
다음으로 ALB(ELB)를 먼저 만들어보겠다. ECS, EC2 부터 만든 후 ALB를 만들어도 되지만 여기선 ALB부터 세팅해보겠다.



대상 그룹은 Board API Service 용으로 만들어둔다. 나중에 EC2 ASG 설정 시 연결만 하면 된다.

Health Check 엔드포인트도 대충 설정해준다.

ALB 설정은 끝났다. 다음으로 Board API Service의 AMI를 만들고 ASG를 통해 오토스케일링 해보자.
먼저 AMI를 생성할 EC2 인스턴스 하나를 만들자. 이 EC2에 대해선 VPC를 따로 설정하거나 하진 않아도 된다. SSH로 접속만 할 수 있으면 된다.
인스턴스를 만들고 접속을 했다면 NodeJS, NPM, PM2를 설치해줘야 한다. 아래의 명령어를 통해 설치하자.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
nvm install 22
node -v
npm -v

그리고 PM2를 설치해주자.
npm i -g pm2

다음으로 소스 코드를 가져와서 빌드하고 환경 변수를 세팅한 다음 실행해보자.

잘 작동한다. 만약 Secrets Manager에서 에러가 난다면 EC2 IAM 설정을 해주고, Redis 커넥션에서 무한로딩이 걸린다면 TLS 설정이 되어있는지 확인해보자. 되어있다면 Redis 클라이언트에서 TLS 옵션을 켜줘야한다.
이제 PM2로 무중단 서비스를 해보겠다.
pm2 start dist/main.js --name board-api-service
pm2 save
pm2 startup systemd -u $USER --hp $HOME
# 이후 나오는 명령어 그대로 입력

됐다. 리부팅해도 잘 실행되는 모습을 볼 수 있다. 이제 AMI와 시작 템플릿을 만들고 ASG를 만들어보자.


시작 템플릿부터 만들자.


AMI 이미지는 방금 만들었던 AMI로 선택한다. 그 외(네트워크 등) 설정은 따로 건들지 않았다. 가만히 냅두면 추후 ASG에서 생성할 때 알아서 맞춰준다.
다만 Secrets Manager를 위한 IAM 하나만 선택해주었다.

이제 오토스케일링 그룹을 만들자.


프라이빗 서브넷 A, B 두개를 선택해주자.

로드밸런서 설정도 기존에 만들어둔 대상 그룹을 선택해준다.
그 외엔 알아서 설정해주면 되고, 오토스케일링이 잘 되는지 확인해보자.

잘 된다면 프로비저닝된 후 ALB에 접속해보자.

잘 나온다. 다만 코드에 실수로 모든 경로에 대해 인증 여부를 체크해서 401이 반환되는 문제가 있는데, 일단 작동하긴 하니 넘어가자.. 대상 그룹에서 Health Check에서 정상 반환 코드를 401로 설정해주면 임시로 되긴한다.

이제 마지막으로 Auth API Service를 ECS Fargate로 배포해보자.
ECS Fargate를 만들기 전 태스크 정의부터 만들어야한다.

그리고 사진엔 없으나 Secrets Manager 권한을 포함한 IAM 설정도 해주자.

포트는 내부적으로 3001으로 설정해뒀으니 80번 HTTP와 컨테이너의 3001 포트를 매핑시켜줬다.

환경변수도 적당히 넣어줬다. 이제 ECS Fargate 클러스터를 만들어보자.

그리고 만든 태스크 정의를 사용하는 서비스를 만들자.


네트워크 설정에서 VPC와 프라이빗 서브넷 A, B를 선택하고, 퍼블릭 IP는 꺼주자. 돈나간다.


그리고 로드밸런싱 설정도 해주었다. 나머지 설정은 옵션으로 하고, 서비스를 생성해보자.



잘 작동한다. 이로써 Auth API Service도 ECS Fargate에 배포를 완료하였다.
테스트로 글을 회원가입/로그인 후 글을 작성하고 불러와보자.







잘 작동한다.
시간이 없어 대충한 점과 EKS를 ECS로 대체했다는 점에서 아쉬움이 남았던 포스팅이다.
언젠간 더욱 디테일하고 퀄리티있게, 그리고 EKS 까지 사용하는 포스팅으로 다시 작성해보도록 하겠디.
끝.