
AWS 실습 계정을 구하다
나는 AI SW 마에스트로(일명 소마)에서 프로젝트를 진행하고 있다. 운이 좋게도 담당 엑스퍼트와 팀 멘토 중 한 분 모두 AWS에서 오래 근무하신 분들이었다. 덕분에 AWS 실습을 위한 계정에 접근할 수 있게 되었고, AWS에서 만든 AI IDE인 Kiro도 꽤 넉넉하게 사용할 수 있게 됐다. 이게 정말 인맥의 힘인가 싶다.
덕분에 이번 프로젝트에서는 개인 계정이었다면 비용부터 계산기를 두드려봤을 리소스들을 직접 켜볼 수 있었다. Multi-AZ RDS, 가용 영역마다 하나씩 배치한 NAT Gateway, Aurora, Fargate 같은 것들이다.
물론 잠깐 실습하고 삭제하면 문제가 생기진 않지만 마음놓고 사용해도 걱정없다는 장치와 평소엔 하지못할 실험들을 할 수 있다는점에서 정말 좋은것 같다.평소라면 "이걸 켜면 한 달에 얼마지?"부터 생각했겠지만, 이번에는 "실제로 구성하면 어떻게 동작하지?"를 먼저 확인해볼 수 있었다.
그렇다고 비싼 서비스를 하나씩 구경만 하고 싶지는 않았다. 짧게라도 실제 서비스처럼 연결하고, 한쪽 가용 영역의 인스턴스를 종료하거나 RDS failover를 일으켰을 때 요청이 어떻게 깨지는지 보고 싶었다. 만드는 것보다 부숴보는 쪽이 더 궁금했다.
그러려면 먼저 장애를 주입할 기준점이 필요했다. 요청을 나눠 받을 로드밸런서, 두 가용 영역에 걸친 애플리케이션 서버, 반대편 가용 영역에 standby를 둔 데이터베이스가 모두 연결된 상태 말이다. 그래서 첫 번째 실습을 3티어 웹 아키텍처로 잡았다.
ALB, EC2, RDS를 한 흐름에 놓으면 네트워크 격리부터 부하 분산, 오토스케일링, DB 이중화까지 이후에 확인하고 싶은 문제들을 하나의 환경에서 이어갈 수 있었다. 이 글의 구성은 완성품이라기보다, 뒤에서 마음껏 부숴보기 위한 실험대에 가까웠다.
예전에 프론트엔드에서 Serverless API를 다뤘을 때는 Lambda와 API Gateway가 서버와 네트워크의 많은 부분을 대신 관리해줬다. 이번에는 반대로 VPC부터 라우팅 테이블까지 직접 만들어, 요청이 지나는 길을 눈에 보이는 상태로 남겨보기로 했다. ALB에서 EC2를 거쳐 RDS까지 요청이 통과하고, 다음 장애 실험을 시작할 수 있는 상태를 만들어보자.
왜 세 구간으로 나눴을까?

3티어는 서버를 정확히 세 대 둔다는 뜻이 아니다. AWS는 전통적인 3티어 웹 애플리케이션을 표현(presentation), 애플리케이션(application), 데이터 저장(persistence) 계층으로 설명한다. 사용자가 상호작용하는 부분, 실제 규칙과 계산을 처리하는 부분, 데이터를 저장하고 조회하는 부분을 서로 다른 책임으로 나눈 것이다. AWS의 전통적인 웹 호스팅 아키텍처에서도 같은 세 계층을 기준으로 확장성과 장애 대응을 설명한다.
여기서 layer와 tier는 비슷해 보여도 조금 다르다. layer는 코드 안에서 책임을 논리적으로 나눈 것이고, tier는 그 책임을 서로 다른 프로세스나 서버, 네트워크 경계에 물리적으로 배치한 것이다. Microsoft의 N-tier 아키텍처 설명도 애플리케이션을 논리적 layer와 물리적 tier로 나누며, 각 계층은 정해진 책임을 갖고 아래 계층에만 의존한다고 설명한다.
이번 실습에서 필요했던 것도 이 분리였다. 애플리케이션 서버만 종료해 ALB의 반응을 볼 수도 있고, DB만 failover해 애플리케이션이 겪는 장애 시간을 잴 수도 있어야 했다. 인터넷에 노출되는 범위도 ALB까지만 두고, EC2와 RDS에는 필요한 요청만 차례로 전달하고 싶었다. AWS Well-Architected Framework 역시 3티어 구조의 계층 경계마다 허용된 트래픽만 통과하도록 네트워크 제어를 배치하는 방식을 설명한다. (SEC05-BP01: 네트워크 계층 만들기)
물론 이 구조가 모든 서비스의 출발점일 필요는 없다. 계층 사이에 네트워크 통신이 추가되고, 배포할 리소스와 관리 지점도 늘어난다. Microsoft도 물리적인 tier 분리가 확장성과 복원력을 높이는 대신 추가 네트워크 지연을 만든다고 짚는다. 작은 서비스라면 하나로 시작하는 편이 더 단순할 수 있다.
내가 이번에 3티어를 택한 이유도 어디를 끊고, 어디에 부하를 주고, 어느 계층이 회복되는지 따로 관찰하기 좋은 구조였기 때문이다. 개념을 먼저 정하고 실습을 끼워 넣은 게 아니라, 해보고 싶은 장애 실험을 적고 나니 그 바닥에 3티어가 필요했다.
먼저 요청이 지나갈 길을 그렸다
남은 시간이 정해져 있었기 때문에 콘솔에서 리소스를 하나씩 만들며 구조를 고민할 여유는 없었다. 먼저 요청이 들어와 DB에 기록되기까지의 길부터 정했다.
인터넷
↓
Application Load Balancer
↓
EC2 Auto Scaling Group
↓
RDS MySQL이 글에서는 AWS 리소스의 배치와 요청 흐름을 다음 세 구간으로 단순화했다.
- public ingress 구간: 인터넷의 HTTP 요청을 받는 ALB
- 애플리케이션 계층: 요청을 처리하는 EC2
- 데이터 계층: 데이터를 저장하는 RDS MySQL
엄밀히 말하면 ALB 자체가 화면을 그리는 표현 계층은 아니다. 이번 실습에는 별도의 프런트엔드 서버가 없고 curl 클라이언트가 요청을 보냈기 때문에, ALB는 웹 요청이 들어오는 진입점만 맡았다. 또한 같은 public 서브넷의 NAT Gateway는 계층을 구현하는 서비스가 아니라 private EC2의 아웃바운드를 위한 네트워크 구성요소다. 논리적인 3계층과 AWS의 public/app/db 네트워크 구획을 완전히 같은 것으로 보면 안 됐다.
여기서 중요한 건 서비스를 세 개 사용했다는 사실이 아니라, 각 계층이 필요한 상대와만 통신하게 만드는 것이었다. 사용자는 ALB까지만 접근할 수 있고, EC2는 ALB의 요청만 받으며, RDS는 EC2의 요청만 받게 하기로 했다.
그리고 하나의 가용 영역에 전부 넣으면 그 영역에 문제가 생겼을 때 서비스 전체가 멈출 수 있다. 그래서 us-east-1a와 us-east-1b, 두 가용 영역에 같은 구조를 나눠 배치했다. 미국 리전을 사용한 이유는 실습 계정이 미국한정이기 때문이다.
VPC 10.0.0.0/16
us-east-1a us-east-1b
public ALB, NAT Gateway ALB, NAT Gateway
private-app EC2 EC2
private-db RDS standby RDS primary실제로는 각 가용 영역에 public, private-app, private-db 서브넷을 하나씩 만들어 총 6개 서브넷을 사용했다. EC2는 최소 2대에서 최대 6대까지 늘어날 수 있도록 Auto Scaling Group에 넣었고, RDS는 반대편 가용 영역에 대기 인스턴스를 두는 Multi-AZ로 만들었다.
이 구성을 AWS 아이콘으로 AI를 활용해 다시 그린 것이 글 맨 위의 다이어그램이다. 글자로만 나열했을 때보다 네트워크 경계가 잘 보였다. 사용자의 요청은 ALB와 EC2를 거쳐 RDS primary로 향한다. 주황색 점선은 EC2가 같은 가용 영역의 NAT Gateway로 보내는 아웃바운드 경로이고, 파란 점선은 primary와 standby 사이의 Multi-AZ 동기화 관계다. 서로 목적이 다른 세 경로를 분리해서 그렸다.
public과 private은 이름이 아니라 경로였다
AWS를 처음 공부할 때는 public 서브넷과 private 서브넷을 리소스의 종류처럼 외웠다. ALB는 public, EC2와 RDS는 private. 직접 구성하면서 알게 된 건 서브넷의 성격을 결정하는 것은 이름이 아니라 라우팅 테이블이라는 점이었다.
public 서브넷에는 인터넷 게이트웨이로 나가는 경로를 연결했다. 이곳의 ALB는 인터넷에서 요청을 받아야 한다. private-app 서브넷의 EC2에는 퍼블릭 IP를 주지 않았다. 사용자가 EC2에 바로 접근할 이유는 없지만, EC2가 처음 실행될 때 Python 패키지 등을 내려받을 길은 필요했다. 그래서 아웃바운드 트래픽만 NAT Gateway를 통해 인터넷으로 나가게 했다.
private-db 서브넷은 더 단순하게 만들었다. 인터넷으로 향하는 경로를 아예 두지 않았다. RDS는 애플리케이션의 요청만 처리하면 되기 때문이다.
resource "aws_route_table" "db" {
vpc_id = aws_vpc.main.id
}처음 이 코드를 봤을 때는 route가 하나도 없어서 덜 작성한 것처럼 느껴졌다. 하지만 이 비어 있음이 의도였다. DB에는 인터넷으로 나갈 길 자체가 필요하지 않았다.
NAT Gateway는 각 가용 영역에 하나씩 만들고, 애플리케이션 서브넷이 같은 가용 영역의 NAT를 사용하도록 연결했다. 개발 환경이라면 비용을 줄이기 위해 하나만 둘 수도 있다. 이번 실습에서는 가용 영역 하나에 문제가 생겨도 다른 영역의 아웃바운드 경로가 함께 사라지지 않는 구성을 직접 보고 싶어서 두 개를 사용했다.
개인적으로 여기서 3티어를 나누는 이유가 조금 선명해졌다. 단순히 그림을 보기 좋게 세 줄로 나누는 게 아니었다. 계층마다 열어둘 길과 닫아둘 길이 다르기 때문에 네트워크도 나누는 것이었다.
IP 대신 “누가 보냈는가”를 허용했다
라우팅으로 큰 길을 나눴다면, 보안그룹으로 각 리소스의 문을 정했다.

ALB는 인터넷에서 80번 포트로 들어오는 요청을 받는다. EC2의 8080번 포트는 ALB의 보안그룹에만 열었다. RDS의 3306번 포트는 애플리케이션 보안그룹에만 열었다.
처음에는 EC2의 개별 IP를 RDS에 허용하면 된다고 생각했다. 하지만 Auto Scaling으로 EC2가 새로 생기거나 사라지면 IP도 바뀐다. 서브넷 CIDR을 통째로 허용하는 방법도 있지만, 그러면 같은 대역에 있는 모든 리소스에 문이 열린다. IP 주소 대신 보안그룹을 참조하면 “이 주소에서 온 요청”이 아니라 “애플리케이션 역할을 가진 리소스의 요청”을 허용할 수 있다.
덕분에 EC2가 2대에서 6대로 늘어나더라도 RDS의 규칙을 다시 수정할 필요가 없다. 보안그룹이 단순한 방화벽을 넘어, 리소스의 역할을 표현할 수도 있다는 점이 재미있었다.
SSH 22번 포트는 열지 않았다. 대신 EC2를 private 서브넷에 두고 AWS Systems Manager Session Manager로 접속할 수 있도록 IAM 역할을 연결했다. 이 방식이라면 서버 관리 접속을 위해 Bastion 서버나 퍼블릭 IP를 추가하지 않아도 된다. DB 비밀번호도 코드나 시작 스크립트에 직접 적지 않고 SSM Parameter Store의 SecureString에 저장했다. EC2에는 /lab1/* 경로의 파라미터만 읽을 수 있는 IAM 권한을 부여했다.
완벽한 운영 환경을 만들었다고 보기는 어렵다. 자격 증명을 주기적으로 교체해야 하는 실제 서비스라면 Secrets Manager 같은 선택지도 함께 검토해야 한다. 다만 짧게 사용하는 실습 환경에서 비밀번호를 코드에 남기지 않고 IAM으로 읽는 흐름을 경험하기에는 충분했다.
Console 대신 Terraform
리소스가 하나둘 늘어나자 콘솔에서 직접 만드는 방식은 금방 부담스러워졌다. 서브넷 6개와 라우팅 테이블, NAT Gateway, 보안그룹, ALB, Auto Scaling Group, RDS까지 연결해야 했다. 클릭으로 만들면 성공하더라도 내가 무엇을 설정했는지 다시 확인하기 어려울 것 같았다.
그래서 전체 구성을 Terraform으로 작성했다. 결과적으로 terraform apply 한 번에 40개 리소스가 생성됐다. 그중 RDS 생성에만 15분 30초가 걸려 전체 과정의 대부분을 기다림으로 보냈다.
Apply complete! Resources: 40 added, 0 changed, 0 destroyed.기다리는 동안에는 내가 작성한 코드와 실제 콘솔의 구성이 같은지 하나씩 확인했다. 두 NAT Gateway가 각각 다른 public 서브넷에 생성됐고, RDS에는 multiAZ: True가 표시됐다. Terraform을 사용했다고 해서 AWS를 몰라도 되는 건 아니었다. 오히려 코드 한 줄이 어떤 리소스와 경로를 만드는지 계속 확인해야 했다.
사실 이번 실습은 Terraform을 잘 쓰는 것보다, 실패해도 같은 환경을 다시 만들 수 있다는 데 의미가 컸다. 계정이 사라져도 설계가 코드로 남고, 값을 바꿔 다시 실험할 수 있기 때문이다.

첫 요청이 세 계층을 통과했다
배포가 끝난 뒤 가장 먼저 ALB 주소로 요청을 보냈다. 간단한 Flask 애플리케이션은 응답한 EC2의 인스턴스 ID와 가용 영역을 반환하도록 만들었다.
$ curl ALB_URL/
{"az":"us-east-1b","instance":"i-01a0..."}
$ curl ALB_URL/
{"az":"us-east-1a","instance":"i-002a..."}
$ curl ALB_URL/
{"az":"us-east-1b","instance":"i-01a0..."}요청을 반복하자 두 가용 영역의 EC2가 번갈아 응답했다. ALB가 두 인스턴스를 정상 타겟으로 인식하고 요청을 전달하고 있다는 뜻이었다.
하지만 여기까지만 확인하면 웹 계층과 애플리케이션 계층만 연결된 것이다. 마지막으로 쓰기 API를 세 번 호출한 뒤 읽기 API로 결과를 확인했다.
$ curl ALB_URL/db/write
{"ms":30.9,"ok":true}
$ curl ALB_URL/db/write
{"ms":26.9,"ok":true}
$ curl ALB_URL/db/write
{"ms":30.0,"ok":true}
$ curl ALB_URL/db/read
{"count":3,"ms":21.5,"served_by":"i-002a..."}인터넷에서 보낸 요청이 public 서브넷의 ALB로 들어오고, private-app 서브넷의 EC2를 거쳐, private-db 서브넷의 RDS에 세 건의 데이터를 남겼다. 콘솔에서 초록색 상태를 보는 것보다 이 응답이 훨씬 반가웠다. 각자 따로 만들었던 리소스가 처음으로 하나의 서비스처럼 느껴졌다.
3티어 웹 아키텍처 실습 후기
실습 계정이라 실제로 내게 청구서가 날아오지는 않는다. 하지만 앞에서 “개인 계정이었다면 비용부터 계산했을 리소스들”이라고 했으니, 궁금해서 정말 계산해봤다.
이 구성을 개인 AWS 계정에서 한 달 내내 켜둔다면 얼마가 나올까? 내 친구 GPT에게 계산을 맡겨봤다. 기준은 2026년 8월, us-east-1 리전의 온디맨드 요금이고, 한 달을 730시간으로 잡았다. 다만 어디까지나 대략적인 비교를 위한 계산이다. NAT Gateway의 데이터 처리 비용, ALB의 LCU, EBS·RDS 스토리지, 공인 IPv4, 데이터 전송 비용 등 사용량에 따라 달라지는 요금은 제외하고 리소스를 켜두는 것 자체에 드는 고정 시간 요금만 계산했다. 따라서 실제 청구액은 아래에서 계산한 금액보다 더 커질 수 있다.
계산에 사용한 시간당 단가는 다음과 같다.
- NAT Gateway: $0.045/h
- RDS db.t3.medium MySQL Single-AZ: $0.068/h
- RDS db.t3.medium MySQL Multi-AZ: $0.136/h
- EC2 t3.small Linux: $0.0208/h
- ALB: $0.0225/h
요금 기준은 AWS의 VPC, RDS for MySQL, EC2 On-Demand, Elastic Load Balancing 공식 요금표를 참고했다.
이번에 만든 구조가 모든 서비스에 그대로 필요한 것은 아니다. 작은 개발 환경에 NAT Gateway 두 개와 Multi-AZ RDS를 두는 건 비용에 비해 과할 수 있다. 반대로 실제 서비스에서는 HTTPS, 배포 자동화, 모니터링, 백업과 복구 전략처럼 이번 실습에 빠진 고민이 더 많다.
그럼에도 직접 만들어본 뒤에는 아키텍처 그림을 읽는 방식이 달라졌다. 예전에는 ALB, EC2, RDS라는 서비스 이름부터 봤다. 이제는 사용자의 요청이 어느 경로로 이동하는지, 각 계층은 누구의 요청을 받아야 하는지, 하나의 가용 영역이 사라져도 남는 경로가 있는지를 먼저 보게 된다. 그리고 “보안을 위해 private에 둔다”는 말도 조금 더 구체적으로 이해하게 됐다. private은 체크박스 하나가 아니라 퍼블릭 IP, 라우팅 테이블, 보안그룹, IAM 권한을 함께 설계한 결과였다.
아직 이 아키텍처가 장애 상황에서도 잘 버티는지는 확인하지 않았다. RDS의 primary가 바뀌면 애플리케이션은 얼마나 멈출지, 한쪽 EC2를 종료하면 ALB는 언제 요청을 끊을지, 부하가 커지면 Auto Scaling은 제때 동작할지 궁금한 것이 남았다. 하지만 이 실습의 목표는 분명히 달성했다. ALB에서 시작한 요청이 두 가용 영역의 EC2를 오가고, 마지막에는 RDS에 데이터를 남겼다. 이제 RDS를 전환하고 한쪽 EC2를 종료해도, 무엇이 달라졌는지 비교할 기준이 생겼다.