
VPC(Virtual Private Cloud)는 AWS 안에 만드는 논리적으로 격리된 사설 네트워크입니다. 이 안의 리소스들은 하나의 네트워크에 속하고, 밖에서는 우리가 허용한 경로로만 접근할 수 있습니다. EC2나 RDS 같은 리소스는 반드시 어떤 VPC에 속해야 생성됩니다. 그리고 VPC 단위로 IP 대역을 갖기 때문에, 내부 통신이 사설 IP로 이루어져 인터넷을 거치지 않습니다.
이름: nagasseum-vpc
CIDR: 10.0.0.0/16 → 사용 가능 IP 약 65,536개
IPv6: 없음
테넌시: 기본값
DNS 호스트 이름: 활성화
CIDR을 /16으로 잡은 이유는 서브넷을 여러 개 나눠야 하고, 나중에 확장할 여지를 두기 위함입니다. /16이면 /24 서브넷을 256개까지 만들 수 있어 충분합니다. 그리고 DNS 호스트 이름을 활성화했습니다. RDS와 ElastiCache는 접속 주소를 도메인 형태로 제공하는데(nagasseum-db.xxxx.rds.amazonaws.com), 이 옵션이 꺼져 있으면 EC2가 그 도메인을 IP로 변환하지 못해 DB 연결이 실패합니다. 나중에 원인을 찾기 어려운 문제라 처음에 켜두었습니다.
서브넷(Subnet)은 VPC의 IP 대역을 쪼갠 구획입니다. 리소스는 서브넷 단위로 배치되고, 서브넷마다 라우팅 규칙을 다르게 줄 수 있습니다. 서브넷을 6개 생성하기로 결정한 이유는 우리 인프라 구조를 3계층, 가용영역 2개로 결정했기 때문입니다.
여기서 계층을 나눈 것은 인터넷에서 직접 닿을 수 있는 범위를 최소화하기 위함입니다. 인프라 멘토링에서 "서버가 퍼블릭 서브넷에 있을 필요가 없다", "DB를 서비스와 같이 띄우지 말라" 는 지적을 받은 데 대한 대응입니다. 가용영역을 2개로 나눈 것은 데이터센터 하나가 통째로 장애가 나도 서비스가 이어지도록 하기 위함입니다. 또한 ALB와 RDS 서브넷 그룹은 최소 2개 AZ를 요구하므로 기술적 필수 요건이기도 합니다.
| 이름 | AZ | CIDR | 용도 |
|---|---|---|---|
| nagasseum-public-2a | 2a | 10.0.1.0/24 | NAT, ALB |
| nagasseum-public-2c | 2c | 10.0.2.0/24 | NAT, ALB |
| nagasseum-app-2a | 2a | 10.0.11.0/24 | API #1, Batch |
| nagasseum-app-2c | 2c | 10.0.12.0/24 | API #2 |
| nagasseum-data-2a | 2a | 10.0.21.0/24 | RDS, Redis |
| nagasseum-data-2c | 2c | 10.0.22.0/24 | RDS 대기, Redis 복제본 |
세 번째 옥텟을 1, 11, 21로 나눈 이유는 십의 자리로 계층을 구분하면 IP만 보고 어느 계층인지 즉시 알 수 있습니다. 실제로 검증 단계에서 RDS가 10.0.22.241로 해석되는 것을 보고 Data 계층에 제대로 들어갔다는 것을 바로 확인할 수 있었습니다. 각 계층에 9개까지 여유가 있어 확장에도 대비됩니다.
그리고 NAT 인스턴스가 공인 IP를 받아야 하기 때문에 퍼블릭 서브넷 2개에만 퍼블릭 IPv4 주소 자동 할당을 활성화했습니다. App, Data는 비활성화하여 공인 IP 자체가 없는 상태를 만들었습니다.
엄밀히 말하면 이 시점에는 6개 모두 사실상 프라이빗입니다. 이름만 퍼블릭일 뿐, 라우팅 테이블에 IGW 경로가 들어가야 비로소 퍼블릭이 됩니다.
IGW(Internet Gateway)는 VPC와 인터넷을 잇는 관문입니다. VPC에 하나만 붙일 수 있고, 이게 없으면 어떤 리소스도 인터넷과 통신할 수 없습니다. 라우팅 테이블(Routing Table)은 특정 목적지로 가는 트래픽은 어디로 보낼지를 정의한 이정표입니다. 서브넷마다 하나씩 연결되며, 여기 무엇이 적혀 있느냐가 그 서브넷의 성격을 결정합니다.

퍼블릭 라우팅 테이블 (1개)
라우팅 테이블 이름: nagasseum-routing-table-public
10.0.0.0/16 → local (VPC 내부 통신)
0.0.0.0/0 → IGW ← 이것이 퍼블릭의 정의
연결: public-2a, public-2c
프라이빗 라우팅 테이블 (2개, AZ별)
라우팅 테이블 이름: nagasseum-routing-table-private-2a
10.0.0.0/16 → local
0.0.0.0/0 → NAT-2a (아웃바운드 전용)
pl-xxxx(S3) → VPC Endpoint
연결: app-2a, data-2a
라우팅 테이블 이름: nagasseum-routing-table-private-2c
0.0.0.0/0 → NAT-2c
연결: app-2c, data-2c
프라이빗을 AZ별로 나눈 이유가 핵심입니다. 하나로 합치면 두 AZ가 같은 NAT를 쓰게 되어 AZ 간 데이터 전송 요금이 발생하고, 그 NAT가 있는 AZ가 죽으면 반대편 AZ까지 인터넷이 끊겨 이중화의 의미가 사라집니다.
검증 결과, api-2a는 공인 IP 54.180.xxx.xxx, api-2c는 52.79.xxx.xxx로 나갔습니다. 서로 다른 IP라는 것이 각 AZ가 자기 NAT를 쓰고 있다는 증거입니다.
NAT Instance는 프라이빗 서브넷의 리소스가 인터넷으로 나갈 수 있게 해주는 일종의 중계 장치입니다. 자기 공인 IP를 빌려주고, 응답이 오면 원래 요청자에게 돌려줍니다.
우리 백엔드는 공인 IP가 없어 인터넷과 직접 통신할 수 없습니다. 그런데 밖으로 나갈 요청은 많습니다.
Batch 서버 → CODEF, 국토부 실거래가 API
API 서버 → 카카오 로그인, CODEF 수동 동기화
전체 → SSM 에이전트가 AWS 엔드포인트에 등록
NAT는 안에서 밖으로 나가는 요청만 처리합니다. 밖에서 안으로 들어오는 것은 불가능합니다. 그래서 외부 API는 호출할 수 있지만, 외부에서 우리 백엔드로 접속할 수는 없기 때문에 보안을 유지될 수 있습니다.
| NAT Gateway 2대 | NAT Instance 2대 | |
|---|---|---|
| 월 비용 | ~$90 | ~$8 |
| 데이터 처리 요금 | GB당 $0.059 | 없음 |
| 관리 | 완전 관리형 | 직접 설정, 복구 |
| 자동 복구 | 있음 | 없음 |
NAT Instance로 전환하면 NAT Gateway를 사용할 때보다 약 월 80달러를 절감할 수 있었습니다. 우리 아웃바운드 트래픽은 배치의 외부 API 호출 정도로 적고, NAT가 죽어도 서비스 전체가 아니라 외부 호출만 막히므로 감수할 만한 트레이드오프라고 판단했습니다.
이름: nagasseum-nat-2a, nagasseum-nat-2c
AMI: Amazon Linux 2023 (ARM64)
인스턴스 유형: t4g.nano
서브넷: 각 AZ의 Public Subnet
퍼블릭 IP: 활성화
보안그룹: 모든 트래픽 ← 10.0.0.0/16 ← VPC 내부 요청을 받아야 함
소스/대상 확인 중지
소스/대상 확인 중지가 반드시 필요합니다. EC2는 기본적으로 출발지나 목적지가 자기 IP가 아닌 패킷을 폐기합니다. NAT는 남의 패킷을 대신 전달하는 역할이므로 이 검사를 꺼야 합니다. 이 설정을 빼먹으면 NAT는 동작하지 않습니다.
OS 레벨 설정 2가지
# ① IP 포워딩 — 패킷을 다른 인터페이스로 넘길 수 있게
net.ipv4.ip_forward = 1
# ② MASQUERADE — 출발지 IP를 NAT 자신의 IP로 바꿔치기
iptables -t nat -A POSTROUTING -o ens5 -j MASQUERADE
VPC Gateway Endpoint는 VPC 안에서 특정 AWS 서비스로 가는 전용 통로를 여는 기능입니다. Gateway 유형은 S3와 DynamoDB만 지원하며 요금이 없습니다.
S3는 우리 VPC 안에 있는 리소스가 아니라 AWS가 운영하는 공용 서비스입니다. 주소도 인터넷 도메인(s3.ap-northeast-2.amazonaws.com)이라, 기본적으로는 NAT를 거쳐 인터넷으로 나가야 접근 가능합니다.
[기존] 백업 서버 → NAT → 인터넷 → S3 (NAT 데이터 처리 요금 발생)
[변경] 백업 서버 → Gateway Endpoint → S3 (무료, AWS 내부망)
비용 절감보다는 보안 이점이 큽니다. 우리 DB 백업 파일에는 회원 정보와 암호화된 Connected ID가 들어 있는데, 이 트래픽이 공용 인터넷을 거치지 않고 AWS 내부망에만 머뭅니다.
서비스: com.amazonaws.ap-northeast-2.s3 (Gateway 유형)
VPC: nagasseum-vpc
라우팅: private-2a, private-2c ← 프라이빗만
생성하면 해당 라우팅 테이블에 pl-로 시작하는 prefix list 항목이 자동 추가됩니다. AWS가 S3의 IP 대역을 관리해주므로 우리가 IP를 직접 적을 필요가 없습니다.
보안그룹(Security Group)은 인스턴스 단위의 가상 방화벽입니다. 어디서 오는 무슨 포트를 허용할지를 정의합니다. 상태 저장(stateful) 방식이라, 아웃바운드를 허용하면 그 응답은 자동으로 들어옵니다.
security-group-alb 인바운드: 80, 443 ← 0.0.0.0/0 (인터넷 전체)
security-group-app 인바운드: 8080 ← security-group-alb (ALB에서만)
security-group-rds 인바운드: 3306 ← security-group-app (App 계층에서만)
security-group-redis 인바운드: 6379 ← security-group-app (App 계층에서만)
AWS 리소스에 IP 대역이 아니라 다른 보안그룹 ID를 지정했습니다. 이 방식의 이점이 세 가지입니다.
인스턴스 IP가 바뀌어도 규칙이 유효합니다. EC2를 재생성하거나 오토스케일링으로 늘려도 그 보안그룹에 속하기만 하면 접근이 허용됩니다.
의도가 코드처럼 읽힙니다. "10.0.11.0/24에서 3306 허용" 보다 "App 계층에서 3306 허용" 이 명확합니다.
우회가 구조적으로 불가능합니다. 누군가 프라이빗 서브넷에 인스턴스를 만들어도, sg-app을 붙이지 않으면 DB에 접근할 수 없습니다.
만드는 순서에 주의가 필요했습니다. sg-app이 sg-alb를 참조하려면 sg-alb가 먼저 존재해야 하므로, 빈 보안그룹 4개를 먼저 만들고 규칙을 나중에 채우는 방식을 썼습니다. 그리고 SSH(22)는 어디에도 열지 않았습니다. SSM Session Manager를 쓰기로 했기 때문입니다. 이는 CIS AWS Foundations Benchmark의 네트워킹 권고와도 일치합니다.
인프라 리뷰에서 "DB를 서비스와 같이 띄우지 마라, 장애의 흔한 원인" 이라는 지적을 받았습니다. RDS를 구축해서 관리형으로 전환한 근거는 세 가지입니다.
장애 격리: 백엔드와 같은 인스턴스에 있으면, 백엔드가 메모리를 다 쓸 때 DB도 죽고 재배포로 재시작하면 DB도 함께 내려갑니다. 한쪽 문제가 다른 쪽으로 전파되는 구조를 끊었습니다.
복구 시간: 직접 운영하면 장애 시 사람이 인스턴스를 살리고 컨테이너를 다시 띄워야 합니다. 새벽에 죽으면 아침까지 서비스가 멈춥니다. Multi-AZ는 1~2분 안에 대기 인스턴스가 자동 승격합니다.
백업과 복구 지점: 직접 하면 mysqldump 스크립트 / cron / S3 업로드를 다 만들어야 하고, 하루 한 번이면 최대 24시간치를 잃습니다. RDS는 자동 백업에 더해 특정 시점 복구(PITR)를 지원합니다.
엔진: MySQL 8.0
인스턴스: db.t3.micro
가용성: 다중 AZ DB 인스턴스 ← 동기 복제
스토리지: gp3 20GB (자동 조정 비활성화)
서브넷 그룹: data-2a, data-2c
퍼블릭 액세스: 아니요
보안그룹: security-group-rds
초기 DB 이름: independence
자동 백업: 7일
퍼블릭 액세스를 "아니요" 로 한 것이 프라이빗 배치의 핵심입니다. 이 설정이 "예" 면 프라이빗 서브넷에 있어도 인터넷에서 접근 가능한 엔드포인트가 생깁니다.
초기 데이터베이스 이름을 반드시 입력해야 합니다. 비워두면 인스턴스만 생기고 데이터베이스가 없어서 나중에 직접 CREATE DATABASE를 해야 합니다.
인스턴스 (Multi-AZ) ~$36 ← Single-AZ 대비 2배
스토리지 (×2) ~$5
백업 ~$2
────────────────────────────
합계 ~$43/월
API 서버가 2대로 늘면서 프로세스 간에 공유해야 하는 상태가 생겼습니다. 자바 메모리는 프로세스별로 격리되어 있어 쓸 수 없습니다.
CODEF 액세스 토큰: 액세스 토큰의 ****유효기간은 7일입니다. 서버마다 따로 발급받으면 낭비이고 발급 횟수 제한도 있습니다.
분산 락: 자산 연동 시 Connected ID 중복 발급을 막습니다. synchronized는 한 JVM 안에서만 유효해 다른 서버의 요청을 막지 못합니다. 대체 불가능한 용도입니다.
비동기 작업 상태: 자산 동기화를 비동기로 전환하면서, 클라이언트가 폴링으로 진행 상태를 조회합니다. ALB가 요청을 분산하므로 폴링이 다른 서버로 갈 수 있어 공유 저장소가 필수입니다.
집계 캐시: 또래 비교 집계는 매월 1일 기준으로 고정된 값이라 재사용이 자연스럽습니다.
엔진: Redis 7.1
클러스터 모드: 비활성화 ← 단순 구성
노드 유형: cache.t3.micro
복제본: 1개
다중 AZ: 활성화 ← 자동 장애 조치
저장 중 암호화: 활성화 (기본 키)
전송 중 암호화: 비활성화 ← VPC 내부 통신
자동 백업: 비활성화 ← 캐시라 불필요
유지 관리: 일요일 21:00 UTC ← 새벽 배치와 회피
클러스터 모드를 활성화하면 샤딩 구조가 되어 Lettuce 클라이언트 설정이 복잡해집니다. 우리 용도에는 단일 노드 + 복제본이면 충분합니다.
자동 백업 옵션은 비활성화했습니다. Redis에 저장하는 것은 토큰 / 락 / 캐시 / 작업상태로, 없어져도 다시 만들 수 있는 데이터입니다. 백업 스토리지 요금만 나가고 실익이 없습니다. Redis에서 복제본을 두는 이유는 데이터 보존이 아니라 가용성입니다. Redis가 죽으면 데이터를 잃는 게 아니라 기능이 멈춥니다.

주의할 점은 엔드포인트가 Primary와 Reader 두 개라는 점입니다. 우리 용도는 전부 쓰기 작업이므로 반드시 Primary를 써야 합니다. Reader로 설정하면 SET, SETNX가 모두 실패합니다.
| 이름 | 유형 | 서브넷 | 역할 |
|---|---|---|---|
| nagasseum-api-2a | t3.small | app-2a | Tomcat (api 프로필) |
| nagasseum-api-2c | t3.small | app-2c | Tomcat (api 프로필) |
| nagasseum-batch-2a | t3.micro | app-2a | Tomcat (batch 프로필) |
API가 t3.small인 이유는 3계층 전환으로 MySQL, Redis가 빠져 EC2가 가벼워졌지만, 톰캣 JVM이 550MB 정도를 쓰고 OS 및 Docker를 합치면 830MB입니다. t3.micro(1GB)는 여유가 170MB뿐이라 GC나 요청 집중 시 위험합니다. Batch가 t3.micro인 이유는 사용자 요청을 받지 않고 새벽에만 동작하므로 부하가 낮습니다.
세 대 모두 프라이빗 서브넷에 배치했기 때문에 퍼블릭 IP는 없습니다.
Bastion Host 대신 SSM을 사용했을 때의 이점에는 4가지 정도가 있었습니다.
역할명: nagasseum-ec2-ssm-role
정책: AmazonSSMManagedInstanceCore (SSM 접속), AmazonS3FullAccess (백업용, 추후 축소 예정)
신뢰 관계: ec2.amazonaws.com
AmazonS3FullAccess는 권한 범위가 넓어 정석은 아닙니다. 백업 버킷만 접근하는 커스텀 정책으로 좁히는 것이 맞으며, 개선 예정입니다.

SSM Agent unable to acquire credentials
Systems Manager's instance management role is not configured
원인은 인스턴스 생성 시 IAM 인스턴스 프로파일을 지정하지 않아 발생하는 문제였습니다. SSM 에이전트는 IAM 역할로 AWS API에 인증하는데, 역할이 없으니 자격증명을 얻지 못했습니다.
따라서 작업 → 보안 → IAM 역할 수정으로 역할을 연결하고 인스턴스를 재부팅해서 해결했습니다. 에이전트가 자격증명 획득에 실패하면 재시도 간격을 늘리므로, 역할을 나중에 붙이면 즉시 반영되지 않습니다.
프라이빗 서브넷의 EC2가 SSM에 등록되지 않고, 외부 인터넷도 되지 않았습니다.
1. SSM 접속 불가 → IAM 역할은 붙어 있음
2. 프라이빗 서버가 AWS 엔드포인트에 닿지 못한다고 추정 → NAT 의심
3. NAT 인스턴스에 접속(퍼블릭이라 접속 가능)해 상태 확인
cat /proc/sys/net/ipv4/ip_forward → 1 (정상)
iptables -t nat -L POSTROUTING -n → 비어 있음 (여기가 문제!!!)
4. cloud-init 로그 확인
/var/log/cloud-init-output.log
원인은 사용자 데이터 스크립트에서 iptables 명령을 먼저 실행하고 패키지 설치를 나중에 배치해서 발생한 문제였습니다. Amazon Linux 2023에는 iptables가 기본 설치되어 있지 않아 명령이 실패했고, 그 결과 MASQUERADE 규칙이 누락되었습니다. 따라서 IP 포워딩만 켜진 상태였으므로 패킷은 나가지만 출발지 IP가 사설 IP라 응답이 돌아오지 못하는 상황이었습니다.
part-001: line 8: iptables: command not found
part-001: line 9: iptables: command not found
아래와 같이 명령어를 입력하면 됩니다.
IFACE=$(ip -o -4 route show to default | awk '{print $5}')
sudo iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
sudo iptables -F FORWARD
sudo service iptables save # 재부팅 후에도 유지
sudo systemctl enable iptables
따라서 사용자 데이터는 의존성 설치를 먼저 배치해야 합니다. 그리고 실패해도 인스턴스는 정상 기동하므로, /var/log/cloud-init-output.log를 확인하는 습관이 필요합니다. 증상과 원인의 거리가 멀었다는 점도 기록해 둘 만합니다. "SSM 접속 불가" 라는 증상에서 "사용자 데이터의 명령 실행 순서" 라는 근본 원인까지, 계층을 세 번 내려가야 했습니다.
nagasseum-api-2a에서 SSM으로 접속해 확인했습니다.

$ curl -I https://oauth.codef.io
HTTP/1.1 200 ...
TRUEIP: 54.180.xxx.xxx ← NAT-2a 경유 확인
$ docker --version
Docker version 29.1.3
$ nc -zv nagasseum-db.xxxx.rds.amazonaws.com 3306
Connection to ... (10.0.22.xxx) 3306 succeeded! ← Data 서브넷
$ nc -zv nagasseum-cache.xxxx.cache.amazonaws.com 6379
Connection to ... (10.0.22.xxx) 6379 succeeded! ← Data 서브넷
nagasseum-api-2c에서도 확인했습니다.
$ curl -I https://oauth.codef.io
TRUEIP: 52.79.xxx.xxx ← NAT-2c 경유 (다른 IP)
여기서 중요한 점은 공인 IP가 서로 다르다는 것입니다. ****각 AZ가 자기 NAT를 통해 나가고 있습니다. AZ별 라우팅 테이블 분리가 실제로 동작하며, AZ 간 데이터 전송 요금이 발생하지 않고 한쪽 NAT 장애가 반대편에 영향을 주지 않습니다.
Region: ap-northeast-2 (서울)
└── VPC: nagasseum-vpc (10.0.0.0/16)
│
├── Public Subnet: 10.0.1.0/24 (2a), 10.0.2.0/24 (2c)
│ ├── NAT Instance 2대 (t4g.nano)
│ └── [예정] ALB
│
├── Private App: 10.0.11.0/24 (2a), 10.0.12.0/24 (2c)
│ ├── nagasseum-api-2a (t3.small)
│ ├── nagasseum-api-2c (t3.small)
│ └── nagasseum-batch-2a (t3.micro)
│
└── Private Data: 10.0.21.0/24 (2a), 10.0.22.0/24 (2c)
├── RDS MySQL 8.0 Multi-AZ → 10.0.22.241
└── ElastiCache Redis 7.1 복제 → 10.0.22.195