PUBLISHED
나의 AWS 요금 감량기
작성일: 2025.05.09
이번 달 초, AWS 요금 청구서를 확인하고 자빠질 뻔했다. 프리티어가 끝난 데다 원달러 환율이 치솟으면서 12만 원이 넘는 요금이 결제된 것이다. AWS에 배포해 둔 프로젝트는 나만의 작은 독후감 작성 및 공유 사이트인 onef.co.kr 뿐인데, 아무리 애정이 가는 프로젝트라 해도 한 달에 12만 원은 참을 수 없다. 그리하여 나는 내가 가진 모든 AWS 지식을 긁어모아 ─ 마침 이번달에 AWS SAA C03을 취득했다. 만세! ─ 요금을 최대한 줄여나가보기로 했다.
AWS Cost Explorer에 들어가면 어떤 서비스에서 얼마나 청구되었는지 자세히 확인해볼 수 있다. 내 경우는 EC2-인스턴스와 ELB, 그리고 VPC가 요금의 대부분을 차지하고 있다. 그럴 수 밖에 없는 게 지금 내 서비스 아키텍쳐가 아래와 도표와 같기 때문이다. 우선 AZ 두 개에 각각 프라이빗/퍼블릭 서브넷을 만들어놓고 ELB를 통해 요청을 분산하고 있다. 퍼블릭 서브넷에는 또 두 개의 인스턴스(Next.js Nest.js)를 두고 프라이빗 서브넷에는 Postgresql DBMS를 두어 총 여섯 개의 인스턴스가 24시간 가동되고 있다. 프리티어일 때는 ELB와 EC2 모두 요금을 할인받을 수 있는 만큼 괜찮았지만, 이제는 ─ 가용성이 굳이 중요하지 않은만큼 ─ 이렇게까지 해둘 필요가 없을 것 같다.

그래서 새로운 아키텍처는 아래와 같은 구조를 따르게 할 생각이다. 우선 ELB를 없애고 단일 AZ에 딱 두 개의 인스턴스를 올린다. Route53에서 A 레코드로 EC2 인스턴스 중 하나의 퍼블릭 IP를 지정하고, 이 인스턴스에 Nginx Reverse Proxy 및 SSL 인증서를 설정하여 HTTPS를 처리하도록 구성한다. 프라이빗 서브넷에 배치한 인스턴스는 내부적으로만 통신하며, 외부에서 직접 접근할 수 없도록 설정한다.

원래는 프리티어 요금을 위해 t2.micro를 썼지만, 새로운 인스턴스에는 시간 당 요금이 조금 더 저렴하면서 성능은 더 좋은 t3.micro를 사용하기로 했다.
EC2 instance 설정
우선 certbot으로 SSL 인증을 받아 HTTPS 통신을 구축해보자.
# certbot 설치
sudo yum install -y certbot
# onef.co.kr에 대한 인증서 요청
sudo certbot certonly --standalone -d onef.co.kr
# 매달 자동으로 인증서 갱신
0 3 1 * * certbot renew --quiet --post-hook "docker compose restart nginx"이전에는 인스턴스 내부에 직접 node와 Next.js 프로젝트를 설치해서 사용했지만, 이번에는 docker를 배웠으니 docker와 docker compose를 통해 여러 프로젝트를 관리해볼 생각이다. 일단 docker를 설치하고 ─ amazon linux v1을 써서 자동으로 설치되지 않는 ─ docker compose v2도 함께 설치해주자.
# 인스턴스에 있는 모든 패키지를 업데이트
sudo yum update -y
# yum으로 Docker 설치
sudo yum install docker -y
# Docker 버전 확인
docker -v
# Docker 실행
sudo service docker start
# Docker 그룹에 sudo 추가 (인스턴스 접속 후 도커 바로 제어할 수 있도록)
sudo usermod -aG docker ec2-user
# 인스턴스 재접속 후 Docker 명령어 실행
docker run hello-world# Docker Compose v2 플러그인을 위한 디렉토리 생성 (홈 디렉토리 기준)
sudo mkdir -p ~/.docker/cli-plugins
# Compose v2 바이너리 다운로드 (v2.24.6 버전)
curl -SL https://github.com/docker/compose/releases/download/v2.24.6/docker-compose-linux-x86_64 \
-o ~/.docker/cli-plugins/docker-compose
# 바이너리에 실행 권한 부여
sudo chmod +x ~/.docker/cli-plugins/docker-compose
# docker compose 명령어가 정상 동작하는지 확인
docker compose version
docker compose를 쓰면 좋은 점이 많다. 우선 한 번에 필요한 컨테이너를 전부 실행 시킬 수 있다는 점. 그리고 각각의 컨테이너에서 포트를 노출하지 않더라도 내부적인 네트워크를 통해 서로 통신할 수 있다는 점 등등.
일단은 내 맥북에서 수동으로 이미지를 만들어서 AWS ECR로 이미지를 수동으로 업로드하고 있다. 조만간 github action을 사용하여 자동으로 이미지를 만들고 ECR에 업로드하고 docker compose를 처리할 생각이다. nginx는 nginx:alpine 버전을 사용했다.
services:
nginx:
container_name: nginx
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
back:
container_name: back
image: 345586318646.dkr.ecr.ap-northeast-2.amazonaws.com/onef_back:latest
restart: always
env_file:
- .env
environment:
- NODE_ENV=production
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
front:
container_name: front
image: 345586318646.dkr.ecr.ap-northeast-2.amazonaws.com/onef_front:latest
restart: always
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
혹시나 궁금한 사람이 있을까 해서 올려보는 nginx.conf 파일이다. 80으로 들어오는 요청은 443으로 리디렉션 시키고, 433으로 들어오는 요청은 prefix에 따라 적절하게 라우팅시키고 있다.
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# 🔐 HTTPS 서버 블록
server {
listen 443 ssl;
server_name onef.co.kr;
ssl_certificate /etc/letsencrypt/live/onef.co.kr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/onef.co.kr/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ {
proxy_pass http://back:3000/api/;
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 /socket.io/ {
proxy_pass http://back:3000/socket.io/;
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 /ws/ {
proxy_pass http://back:3000/ws/;
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 / {
proxy_pass http://front:3000;
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;
}
}
# 🌐 HTTP 요청을 HTTPS로 리디렉션
server {
listen 80;
server_name onef.co.kr;
return 301 https://$host$request_uri;
}
}
Eventbridge Scheduler 설정
인프라의 크기를 줄였으나 이것만으로 끝이 아니다! EC2 인스턴스는 '시간당' 요금을 책정하고 있기에 아무도 찾지 않는 새벽에 인스턴스를 켜두는 건 비용 낭비에 지나지 않는다. 따라서 나는 매일 저녁 8시에 자동으로 인스턴스를 종료하고, 매일 아침 9시에 자동으로 인스턴스를 실행시키기로 결정했다.
AWS SAA를 취득한 덕분에 나는 이런 경우에 꼭 알맞는 솔루션을 하나 알고 있다. 바로 AWS Eventbridge Scheduler이다. AWS 인프라에 대한 cron 작업을 아주 간단하게 처리할 수 있는 서비스로, 복잡한 설정 없이도 원하는 시간에 리소스를 자동으로 시작하거나 중지할 수 있다. 또한 IAM 역할을 연동해 실행 권한까지 설정할 수 있어 보안 측면에서도 안심할 수 있다.
나는 우선 AWS CLI를 통해 EC2를 켜고 끌 수 있는 권한을 가진 IAM 역할을 하나 생성했다.
# Trust Policy 파일 생성
cat > trust-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "scheduler.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
EOF
# permissions policy 파일 생성
cat > permissions-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances",
],
"Resource": "*"
}
]
}
EOF
# IAM Role 생성
aws iam create-role \
--role-name SchedulerEC2Role \
--assume-role-policy-document file://trust-policy.json
# IAM Role에 정책 연결
aws iam put-role-policy \
--role-name SchedulerEC2Role \
--policy-name EC2ControlPolicy \
--policy-document file://permissions-policy.json
Eventbridge Scheduler로 반복 일정을 생성하는 건 ─ 거짓말 조금 보태면 ─ 눈 감고도 할 수 있는 수준이라 굳이 여기에 그 방법을 남기지 않겠다. 아무튼 이를 통해 매일 아침 9시와 저녁 8시마다 EC2 인스턴스를 자동으로 켜고 끌 수 있게 되었다.
EC2 인스턴스를 자동으로 켜고 끄는 작업이 가능해졌다면, 이를 보다 체계적으로 관리하기 위해 systemd를 활용한 설정이 필요하다. systemd는 리눅스 시스템에서 서비스와 프로세스를 관리하는 데 사용되는 초기화 시스템이며, systemctl 명령어를 통해 서비스의 시작, 중지, 자동 실행 등을 제어할 수 있다.
EC2 인스턴스 상에서 특정 스크립트를 부팅 시 자동으로 실행하거나, 종료 전에 특정 작업을 수행하도록 설정하고 싶다면, systemd 유닛 파일을 작성하여 등록하면 된다. 이를 통해 EC2 인스턴스의 상태 변화에 따라 필요한 작업을 자동화할 수 있으며, 안정적이고 예측 가능한 서비스 운영이 가능해진다.
postgresql의 경우 간단히 등록할 수 있다.
sudo systemctl enable postgresql
docker의 경우 시작할 때 특정 명령어를 특정한 ─ docker-compose.yml이 있는 ─ 위치에서 실행해야 하므로, .service 파일을 통해 사용자 서비스 정의를 생성해야 한다.
# docker-compose-app.service 파일 생성
sudo vim /etc/systemd/system/docker-compose-app.service
# docker-compose-app.service 파일 내용
[Unit]
Description=Docker Compose Application Service
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/ec2-user
ExecStart=/usr/local/bin/docker-compose up -d
ExecStop=/usr/local/bin/docker-compose down
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
# systemd에 새 서비스를 인식시키고 활성화
sudo systemctl daemon-reload
sudo systemctl enable docker-compose-app.service
이렇게 설정해두고 저녁 먹으러 나갔다. 8시가 넘은 걸 확인하고는 아이폰으로 AWS EC2 대시보드에 들어가보았다. 다행히 두 개의 EC2 모두 문제 없이 중지되어있었다. 내가 무언가 실수를 한 게 아닌 이상 아침 9시에 확인해보면 EC2는 자동으로 실행될 것이었다.
EIP 설정
9시가 되자마자 AWS에 접속했다. 나는 AWS 콘솔 홈 최상단에 비용 및 사용량 정보를 띄워놓는데, ─ 아직 하루밖에 안 지났음에도 ─ 예상 월말 비용이 지난달 대비 11퍼센트나 낮아졌다는 기분좋은 소식을 들을 수 있었다. EC2 인스턴트는 둘 다 멀쩡하게 돌아가고 있었다. onef에 접속해보았다. 그러나 크롬은 페이지를 찾을 수 없었다. 나는 당황에서 ssh로 EC2에 접속해 이것저것 확인해보았지만 모든 게 문제 없이 돌아가고 있었다. 그저 onef에 접속만 안될 뿐이었다.
잠깐 고민해보았고, 금방 답을 알 수 있었다. EC2가 중지되었다가 다시 실행되었다. 탄력적 IP를 설정하지 않은 까닭에 퍼블릭 IPv4 주소가 변경되어버린 것이다! Elastic IP를 만들어 인스턴스에 연결해주고, Route53을 수정하자 문제 없이 onef에 접속할 수 있게 되었다.
CI/CD 설정
이전까지만 해도 main branch로 git push가 발생하면 github action이 전체 코드를 압축하여 S3로 보내고, 이를 CodeDeploy가 처리하는 식으로 CI/CD를 처리했었다. 그러나 전체 아키텍처가 도커를 사용하는 방식으로 변경되었기 때문에 CI/CD도 그에 맞춰 변경될 필요가 있다.
새로운 CI/CD는 ain branch로 git push가 발생하면 github action이 dockerfile을 토대로 도커 이미지를 만들고, 이를 AWS ECR로 전송한다. 이후 github action이 ec2에 접속해서 미리 지정한 명령어(ECR에서 이미지 가져오고 docker compose 새로 시작하기)를 수행한다.
name: 🚀 Build and Deploy to EC2 from ECR
on:
pull_request:
types: [closed]
branches:
- main
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v3
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2 # 서울 리전
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push Docker image to ECR
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.ECR_REGISTRY }}/${{ secrets.ECR_REPOSITORY }}:latest
build-args: |
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
NEXT_PUBLIC_SOCKET_URL=${{ secrets.NEXT_PUBLIC_SOCKET_URL }}
- name: SSH and deploy on EC2
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_PRIVATE_KEY }}
script: |
cd /home/ec2-user
# 먼저 실행 중인 컨테이너 중지
docker compose down
# 모든 Docker 이미지 강제 삭제
# docker rmi $(docker images -q) -f
# 프론트 이미지만 삭제
docker rmi ${{ secrets.ECR_REGISTRY }}/${{ secrets.ECR_REPOSITORY }}:latest -f || true
# 미사용 리소스 정리
docker system prune -a -f --volumes
# ECR 로그인
aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${{ secrets.ECR_REGISTRY }}
# 이미지 새로 받고 실행
docker compose pull
docker compose up -d
결과
이러한 여러 요금 절감 및 CI/CD 구조 개선을 통해 지난달 같은 기간 대비 79%의 비용 절감 효과를 볼 수 있었다. 예상하는 월말 비용은 20달러를 넘지 않을 듯하다. 야호!
