AWS 입문 가이드 | EC2, S3, RDS 실전 사용법

이 글의 핵심

콘솔에서 버튼만 눌러 인스턴스를 띄우면 당장은 동작하지만, 루트 계정 사용이나 과하게 열린 보안 그룹, 무료 티어를 넘긴 요금 같은 문제가 뒤늦게 드러납니다. 리전과 가용 영역, 라우팅 테이블과 NAT 배치, S3 스토리지 클래스와 일관성 모델, RDS 장애 조치 방식을 함께 설명해 처음부터 비용과 보안을 고려한 구성을 만들 수 있게 합니다.

이 글의 핵심

EC2로 서버를 올리고, S3에 정적 파일을 두고, RDS로 데이터베이스를 붙이는 흐름을 한 번에 잡습니다. 각 단계에서 처음부터 신경 써야 할 보안 설정(IAM, 보안 그룹, 퍼블릭 액세스)과 비용 함정(무료 티어 범위, NAT 게이트웨이, 퍼블릭 IPv4 요금)을 함께 짚습니다.


기초 개념

클라우드와 서비스 모델

온프레미스는 서버를 직접 사서 데이터 센터에 두고 유지보수까지 맡는 방식이고, 클라우드는 필요한 만큼 빌려 쓰고 쓴 만큼 비용을 내는 방식입니다. 초기 투자가 거의 없고 몇 분 만에 서버를 늘리거나 줄일 수 있는 대신, 설정을 잘못하면 사용하지 않는 리소스에도 요금이 계속 나간다는 점이 다릅니다.

클라우드 서비스는 관리 범위에 따라 나뉩니다. IaaS는 가상 서버·스토리지·네트워크를 빌려주고 OS부터는 사용자가 관리합니다(EC2, EBS). PaaS는 애플리케이션 실행 환경까지 제공합니다(Elastic Beanstalk, Heroku). SaaS는 완성된 소프트웨어를 쓰는 형태입니다(Gmail, Slack). RDS나 Lambda처럼 AWS에는 그 중간에 해당하는 관리형 서비스가 많습니다.

이 글에서 다루는 서비스

영역서비스역할
컴퓨팅EC2, Lambda가상 서버, 이벤트 기반 함수
스토리지S3, EBS객체 저장소, EC2에 붙는 블록 디스크
데이터베이스RDS관리형 관계형 DB(PostgreSQL, MySQL 등)
네트워크VPC, CloudFront, Route 53가상 네트워크, CDN, DNS
보안IAM, Secrets Manager권한 관리, 비밀 값 저장

리전과 가용 영역

리전은 서울(ap-northeast-2), 도쿄, 버지니아처럼 지리적으로 떨어진 AWS 거점이고, 각 리전은 서로 독립적으로 운영됩니다. 가용 영역(AZ)은 한 리전 안에서 전원·네트워크·냉각이 분리된 하나 이상의 데이터 센터 묶음입니다. 서울 리전에는 현재 네 개의 AZ가 있습니다.

Seoul Region (ap-northeast-2)
┌──────────┬──────────┬──────────┬──────────┐
│  AZ  a   │  AZ  b   │  AZ  c   │  AZ  d   │
└──────────┴──────────┴──────────┴──────────┘
한 AZ에 장애가 나도 다른 AZ의 리소스는 계속 동작

ap-northeast-2a 같은 AZ 이름은 계정마다 실제 물리 AZ에 다르게 매핑될 수 있습니다. 여러 계정 사이에서 같은 AZ를 맞춰야 한다면 apne2-az1 같은 AZ ID를 기준으로 해야 합니다. 고가용성은 리소스를 둘 이상의 AZ에 나눠 두는 것에서 시작합니다.


계정 생성과 기본 보안 설정

계정과 무료 티어

https://aws.amazon.com/ko/ 에서 이메일, 결제 수단, 전화 인증으로 계정을 만듭니다. 지원 플랜은 기본(무료)으로 충분합니다.

무료 티어 조건은 가입 시점에 따라 다릅니다. 오랫동안 쓰인 방식은 가입 후 12개월 동안 EC2 t2.micro/t3.micro 월 750시간, RDS db.t3.micro 월 750시간, S3 5GB 같은 한도를 무료로 주는 것이었고, 2025년 7월 이후 새로 만든 계정에는 크레딧을 주는 새 무료 플랜이 적용됩니다. Lambda 월 100만 요청, DynamoDB 25GB 같은 상시 무료 항목도 따로 있습니다. 정확한 조건은 계정의 Billing 콘솔에서 확인하는 것이 가장 확실합니다.

무료 티어와 관계없이 처음부터 요금이 붙는 항목도 알아 둬야 합니다. NAT 게이트웨이는 켜 두는 것만으로 시간당 요금이 붙고, 2024년 2월부터는 EC2에 붙은 퍼블릭 IPv4 주소(Elastic IP 포함)에도 시간당 요금이 붙습니다.

루트 계정 대신 쓸 자격 증명

가입에 쓴 이메일 계정은 모든 권한을 가진 루트 사용자입니다. 루트 사용자에는 곧바로 MFA를 걸고, 결제 정보 변경처럼 루트만 할 수 있는 작업 외에는 쓰지 않습니다. 루트 사용자의 액세스 키는 만들지 않습니다.

평소 콘솔 작업에는 IAM Identity Center로 사용자를 만들어 로그인하는 것이 현재 AWS가 권장하는 방식입니다. 혼자 쓰는 계정이라면 MFA를 건 IAM 사용자를 하나 만들어도 됩니다. CLI도 오래 유지되는 액세스 키 대신 aws configure sso로 Identity Center에 로그인해 임시 자격 증명을 받는 편이 안전합니다. 액세스 키가 필요하다면 최소 권한만 주고 주기적으로 교체하며, 절대 Git 저장소에 넣지 않습니다.

예산 알림

계정을 만든 직후 Billing 콘솔의 Budgets에서 월 예산(예: 10달러)과 이메일 알림을 설정합니다. 실수로 큰 인스턴스를 켜 두거나 NAT 게이트웨이를 지우지 않은 경우를 가장 빨리 알아챌 수 있는 장치입니다.


VPC 네트워킹 기초

VPC(Virtual Private Cloud)는 계정 전용 가상 네트워크입니다. EC2, RDS, VPC에 연결한 Lambda가 모두 이 경계 안에서 통신합니다. 각 리전에는 기본 VPC가 미리 만들어져 있어 바로 시작할 수 있지만, 운영 환경을 생각하면 서브넷·라우팅·게이트웨이가 어떻게 연결되는지 그릴 수 있어야 합니다.

CIDR, 서브넷, 라우트 테이블

VPC (10.0.0.0/16)
├── 퍼블릭 서브넷 (10.0.1.0/24, AZ a)  ── 라우트: 0.0.0.0/0 → 인터넷 게이트웨이
├── 퍼블릭 서브넷 (10.0.2.0/24, AZ b)
├── 프라이빗 앱 서브넷 (10.0.11.0/24, AZ a) ── 라우트: 0.0.0.0/0 → NAT 게이트웨이
└── 프라이빗 DB 서브넷 (10.0.21.0/24, AZ a) ── 기본 라우트 없음

CIDR는 VPC와 서브넷의 IP 주소 범위입니다. 나중에 다른 VPC나 사내 네트워크와 연결할 가능성이 있다면 대역이 겹치지 않게 잡아야 합니다. 서브넷은 하나의 AZ에 속하는 IP 범위이고, 서브넷마다 연결된 라우트 테이블이 “이 목적지는 어디로 보낸다”는 규칙을 정합니다. 서브넷이 퍼블릭인지 프라이빗인지는 이름이 아니라 라우트 테이블에 인터넷 게이트웨이로 가는 경로가 있는지로 결정됩니다.

[퍼블릭 서브넷 rtb-public]
10.0.0.0/16  → local
0.0.0.0/0    → igw-0abc...

[프라이빗 앱 서브넷 rtb-private-app]
10.0.0.0/16  → local
0.0.0.0/0    → nat-0def...   # 패키지 설치, 외부 API 호출용 아웃바운드

[프라이빗 DB 서브넷 rtb-private-db]
10.0.0.0/16  → local
# 기본 라우트 없음: 인터넷으로 나가는 경로 자체가 없음

인터넷 게이트웨이와 NAT

인터넷 게이트웨이(IGW)는 VPC와 인터넷 사이의 양방향 통로이고, 퍼블릭 서브넷의 웹 서버나 로드 밸런서가 씁니다. NAT 게이트웨이는 프라이빗 서브넷의 인스턴스가 인터넷으로 나가는 연결만 허용하는 장치로, 퍼블릭 서브넷에 두고 프라이빗 서브넷의 기본 라우트를 NAT로 향하게 합니다. 외부에서 프라이빗 인스턴스로 먼저 연결을 열 수는 없습니다.

NAT 게이트웨이는 시간당 요금과 처리한 데이터 양에 따른 요금이 붙어, 트래픽이 적은 개발 환경에서는 비용의 상당 부분을 차지하기도 합니다. S3나 DynamoDB처럼 AWS 서비스로 가는 트래픽은 아래의 VPC 엔드포인트로 NAT를 거치지 않게 하면 비용을 줄일 수 있습니다.

보안 그룹과 네트워크 ACL

보안 그룹은 인스턴스(정확히는 네트워크 인터페이스)에 붙는 상태 저장(stateful) 방화벽입니다. 허용 규칙만 작성하고, 허용한 인바운드 요청의 응답은 자동으로 나갑니다. 네트워크 ACL은 서브넷 경계에 걸리는 상태 비저장(stateless) 필터로, 번호 순서로 허용·거부 규칙을 평가하고 응답 트래픽도 따로 허용해야 합니다. 트래픽은 두 계층을 모두 통과해야 합니다. 대부분의 경우 보안 그룹으로 충분하고, NACL은 특정 대역을 서브넷 단위로 막아야 할 때 씁니다.

계층 간에는 IP 대역 대신 보안 그룹 ID를 소스로 지정하는 것이 좋습니다.

sg-alb : 인바운드 443  ← 0.0.0.0/0
sg-app : 인바운드 8080 ← sg-alb
sg-rds : 인바운드 5432 ← sg-app

이렇게 하면 앱 서버가 늘거나 IP가 바뀌어도 규칙을 고칠 필요가 없고, “DB에는 앱 서버만 접근한다”는 의도가 규칙 자체에 드러납니다.

VPC 엔드포인트와 그 밖의 연결

VPC 엔드포인트는 S3·DynamoDB 같은 AWS 서비스로 가는 트래픽을 인터넷이나 NAT를 거치지 않고 AWS 네트워크 안에서 보냅니다. S3와 DynamoDB는 라우트 테이블에 연결하는 Gateway 엔드포인트를 무료로 쓸 수 있고, 다른 서비스는 시간당 요금이 있는 Interface 엔드포인트를 씁니다. 엔드포인트 정책으로 접근 가능한 버킷을 제한할 수도 있습니다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
      "Resource": ["arn:aws:s3:::my-app-bucket", "arn:aws:s3:::my-app-bucket/*"]
    }
  ]
}

이 정책은 엔드포인트를 통과할 수 있는 요청의 범위를 제한할 뿐, 요청자의 IAM 권한을 대신하지는 않습니다. 실제 접근에는 IAM 정책과 버킷 정책도 함께 허용해야 합니다.

VPC 내부 이름 해석은 Route 53 프라이빗 호스팅 영역으로, VPC 사이나 사내 네트워크와의 연결은 VPC 피어링이나 Transit Gateway로 처리합니다. 처음에는 기본 VPC와 최소한으로 연 보안 그룹으로 시작하고, 서비스가 커지면 퍼블릭/프라이빗 서브넷 분리, NAT, 엔드포인트 순으로 다듬으면 됩니다.


EC2 - 가상 서버

인스턴스 만들기

EC2 콘솔에서 “인스턴스 시작”을 누르고 다음을 정합니다.

항목예시 값설명
AMIUbuntu Server 24.04 LTS운영체제 이미지
인스턴스 유형t3.micro (또는 계정의 무료 티어 대상)vCPU 2개, 메모리 1GB
키 페어새로 만들기, .pem 다운로드SSH 접속용. 다시 받을 수 없으므로 잘 보관
보안 그룹SSH 22는 내 IP만, HTTP 80·HTTPS 443은 전체콘솔 기본값이 SSH를 0.0.0.0/0으로 여는 경우가 있으니 확인
스토리지gp3 8~30GB

SSH 포트를 전 세계에 열어 두면 몇 분 안에 무차별 대입 시도가 들어옵니다. 내 IP만 허용하거나, 아예 22번을 닫고 Systems Manager Session Manager나 EC2 Instance Connect로 접속하는 편이 안전합니다.

# Linux/macOS (Windows PowerShell의 OpenSSH도 동일)
chmod 400 my-key.pem
ssh -i my-key.pem ubuntu@<Public-IP>

웹 서버 구성

# 패키지 업데이트
sudo apt update && sudo apt upgrade -y

# Node.js LTS 설치 (NodeSource 저장소)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs

# 애플리케이션 배포
git clone https://github.com/your-repo/app.git
cd app
npm ci
npm run build

# PM2로 프로세스 관리
sudo npm install -g pm2
pm2 start npm --name "my-app" -- start
pm2 startup   # 출력되는 sudo 명령을 그대로 복사해 한 번 실행해야 재부팅 후 자동 시작됨
pm2 save

# Nginx 설치
sudo apt install -y nginx

Nginx를 리버스 프록시로 앞에 두면 80/443 포트 처리와 TLS 종료를 Nginx가 맡고, Node 앱은 3000번 포트에서 실행할 수 있습니다.

# /etc/nginx/sites-available/default
server {
    listen 80;
    server_name your-domain.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        # WebSocket이 필요할 때만 아래 두 줄 추가
        # proxy_set_header Upgrade $http_upgrade;
        # proxy_set_header Connection "upgrade";
    }
}
sudo nginx -t && sudo systemctl reload nginx

nginx -t로 설정 문법을 먼저 검사하면 잘못된 설정으로 Nginx가 내려가는 일을 막을 수 있습니다. HTTPS는 도메인을 연결한 뒤 Certbot으로 Let’s Encrypt 인증서를 받거나, 로드 밸런서에 ACM 인증서를 붙여 처리합니다.

Nitro 시스템

최신 EC2 인스턴스 패밀리는 대부분 AWS Nitro System 위에서 동작합니다. 예전 Xen 기반 가상화에서는 네트워크와 스토리지 가상화를 호스트 CPU가 처리했지만, Nitro는 VPC 네트워킹과 EBS I/O를 전용 하드웨어(Nitro 카드)로 넘기고 하이퍼바이저를 가볍게 만들어, 호스트 자원 대부분을 고객 인스턴스에 줍니다. 같은 vCPU 수라도 세대에 따라 네트워크 대역폭과 EBS 성능이 다르므로, 인스턴스를 고를 때는 인스턴스 유형 페이지의 네트워크 성능과 EBS 대역폭 항목도 함께 봅니다.

인스턴스 패밀리와 크기 고르기

워크로드 성격에 따라 범용(M), 컴퓨팅 최적화(C), 메모리 최적화(R), 스토리지 최적화(I, D) 패밀리 중에서 고릅니다. 이름의 g(예: m7g)는 AWS Graviton(ARM) 프로세서로, 같은 크기의 x86 인스턴스보다 대체로 저렴해서 애플리케이션과 의존성이 ARM을 지원한다면 좋은 선택지입니다.

T 시리즈(t3, t4g)는 평소 CPU를 적게 쓰다가 가끔 튀는 워크로드를 위한 버스트형입니다. 기준 성능 이상으로 CPU를 쓰면 CPU 크레딧을 소모합니다. t3와 t4g는 기본적으로 unlimited 모드로 시작하므로, 크레딧이 바닥나도 성능이 떨어지는 대신 초과 사용분이 추가 요금으로 청구됩니다. CPU를 꾸준히 높게 쓰는 서비스를 T 시리즈에 올리면 예상보다 요금이 커질 수 있으므로, 그런 경우는 M·C 패밀리가 비용과 성능 모두 예측하기 쉽습니다. CloudWatch의 CPUCreditBalance와 CPUSurplusCreditsCharged 지표로 이 상황을 확인할 수 있습니다.

목적흔한 출발점메모
개인 블로그·데모t3.micro / t4g.micro크레딧 소모와 unlimited 과금에 주의
소규모 웹·APIt3.small~medium, m7g.large부하가 꾸준해지면 M으로 이동
CPU 집약 작업c7i / c7g인코딩, 배치 연산
메모리가 많이 필요한 앱r7i / r7g큰 JVM 힙, 인메모리 캐시

디스크는 EBS 볼륨 타입과 인스턴스의 EBS 대역폭을 함께 봅니다. gp3는 용량과 별개로 IOPS와 처리량을 설정할 수 있어 대부분의 경우 기본 선택이고, 지연에 민감한 고성능 DB 볼륨이라면 io2를 검토합니다.


S3 - 객체 스토리지

S3(Simple Storage Service)는 파일을 객체 단위로 저장하는 서비스입니다. 용량을 미리 정할 필요가 없고(객체 하나는 최대 5TB), 설계상 99.999999999%(11 nines)의 내구성을 목표로 데이터를 여러 AZ에 나눠 저장합니다. 이미지·동영상 저장, 정적 웹사이트 원본, 백업, 로그 보관에 주로 씁니다.

버킷 만들기

S3 콘솔에서 “버킷 만들기”를 누르고 이름(전 세계에서 유일해야 함)과 리전을 정합니다. “모든 퍼블릭 액세스 차단”은 기본값대로 켜 둡니다. 2023년 4월부터 새 버킷은 퍼블릭 액세스 차단이 켜지고 ACL이 비활성화된 상태로 만들어지며, 접근 권한은 IAM 정책과 버킷 정책으로 관리하는 것이 기본입니다.

AWS CLI로 파일 다루기

AWS CLI는 공식 설치 프로그램으로 버전 2를 설치합니다. 배포판 패키지 관리자의 awscli는 오래된 버전 1이거나 아예 제공되지 않는 경우가 있습니다.

# Linux (x86_64)
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zip
unzip awscliv2.zip && sudo ./aws/install
# macOS: brew install awscli, Windows: 공식 MSI 설치 프로그램

# IAM Identity Center로 로그인 (권장)
aws configure sso

# 파일 업로드·동기화·목록·다운로드
aws s3 cp image.jpg s3://my-app-bucket/images/
aws s3 sync ./dist s3://my-app-bucket/website/
aws s3 ls s3://my-app-bucket/
aws s3 cp s3://my-app-bucket/images/image.jpg ./

Node.js에서 S3 사용

npm install @aws-sdk/client-s3
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { createReadStream, createWriteStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';

// 자격 증명은 환경(역할, SSO 프로필)에서 자동으로 찾음
const s3 = new S3Client({ region: 'ap-northeast-2' });

export async function uploadFile(filePath, key, contentType) {
  const { size } = await stat(filePath);
  await s3.send(new PutObjectCommand({
    Bucket: 'my-app-bucket',
    Key: key,
    Body: createReadStream(filePath),
    ContentLength: size, // 스트림 업로드 시 길이를 알려야 함
    ContentType: contentType,
  }));
}

export async function downloadFile(key, outputPath) {
  const { Body } = await s3.send(new GetObjectCommand({
    Bucket: 'my-app-bucket',
    Key: key,
  }));
  // 파일 쓰기가 끝날 때까지 기다림
  await pipeline(Body, createWriteStream(outputPath));
}

await uploadFile('./image.jpg', 'uploads/image.jpg', 'image/jpeg');
await downloadFile('uploads/image.jpg', './downloaded.jpg');

다운로드에서 Body.pipe(writeStream)만 하고 끝내면 함수는 파일 쓰기가 끝나기 전에 반환되고 오류도 잡히지 않습니다. pipeline을 await하면 완료와 오류를 모두 기다릴 수 있습니다. 100MB가 넘는 큰 파일은 @aws-sdk/lib-storage의 Upload로 멀티파트 업로드를 하는 편이 안정적입니다.

S3 정적 웹사이트 호스팅

버킷의 “속성” 탭에서 정적 웹사이트 호스팅을 켜고 인덱스 문서(index.html)와 오류 문서를 지정하면 http://my-app-bucket.s3-website.ap-northeast-2.amazonaws.com 같은 웹사이트 엔드포인트가 생깁니다. 다만 이 엔드포인트는 HTTPS를 지원하지 않고, 쓰려면 퍼블릭 액세스 차단을 끄고 아래와 같은 퍼블릭 읽기 버킷 정책을 붙여야 합니다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-app-bucket/*"
    }
  ]
}

그래서 실제 서비스에서는 버킷을 비공개로 두고 CloudFront + OAC로 HTTPS와 캐시를 함께 처리하는 구성이 일반적입니다(아래 CloudFront 절 참고). 배포할 때 aws s3 sync ./dist s3://my-app-bucket --delete를 쓰면 로컬에서 지운 파일도 버킷에서 지워집니다.

S3 일관성 모델

예전 S3는 덮어쓰기와 삭제에 대해 최종 일관성(eventual consistency)만 보장했기 때문에, “S3는 eventual consistency”라는 설명이 오래된 자료에 많이 남아 있습니다. 2020년 12월부터 S3는 모든 리전에서 객체의 PUT·DELETE와 목록 조회에 대해 강한 read-after-write 일관성을 추가 비용 없이 제공합니다. 방금 올린 객체를 바로 GET하면 새 내용을 받습니다.

그런데도 “올렸는데 안 보인다”는 문제는 흔합니다. 원인은 대부분 S3가 아니라 그 앞의 계층입니다. CloudFront나 브라우저가 이전 파일을 캐시하고 있거나(무효화하거나 파일 이름에 해시를 붙여 해결), 다른 리전으로의 복제(CRR)는 비동기라 지연이 있거나, 중간 프록시가 응답을 캐시하는 경우입니다. 사용자에게 보이는 일관성은 이 계층들까지 포함해 설계해야 합니다.

스토리지 클래스와 수명 주기

S3는 접근 빈도와 복구 시간 요구에 따라 스토리지 클래스를 나눕니다. 저장 단가가 낮은 클래스일수록 조회 요금, 최소 보관 기간, 최소 과금 객체 크기 같은 조건이 붙으므로 접근 패턴을 먼저 파악하고 골라야 합니다.

클래스적합한 데이터주의할 조건
Standard자주 읽는 데이터, CDN 원본기본값
Standard-IA드물게 읽지만 바로 필요한 데이터최소 30일 보관, 128KB 미만은 128KB로 과금, 조회 요금
Intelligent-Tiering접근 패턴을 모르는 데이터객체당 모니터링 요금, 128KB 미만 객체는 자동 이동 대상 아님
Glacier Instant Retrieval분기에 한 번 정도 읽는 아카이브밀리초 단위 조회, 최소 90일 보관
Glacier Flexible Retrieval백업·규제 보관복구에 몇 분~몇 시간, 최소 90일
Glacier Deep Archive1년에 한두 번 접근하는 장기 보관가장 저렴, 복구에 최대 12시간 이상, 최소 180일

작은 객체가 많은 버킷을 Standard-IA로 옮기면 128KB 최소 과금 때문에 오히려 비싸질 수 있다는 점이 흔한 함정입니다. 수명 주기 규칙으로 시간이 지나면 자동으로 클래스를 바꾸거나 삭제할 수 있습니다.

{
  "Rules": [
    {
      "ID": "uploads-tiering",
      "Status": "Enabled",
      "Filter": { "Prefix": "uploads/" },
      "Transitions": [
        { "Days": 60, "StorageClass": "STANDARD_IA" },
        { "Days": 365, "StorageClass": "GLACIER_IR" }
      ],
      "NoncurrentVersionTransitions": [
        { "NoncurrentDays": 30, "StorageClass": "GLACIER" }
      ]
    },
    {
      "ID": "logs-expire",
      "Status": "Enabled",
      "Filter": { "Prefix": "logs/" },
      "Expiration": { "Days": 90 }
    }
  ]
}

버전 관리를 켠 버킷에서는 NoncurrentVersionTransitions로 이전 버전만 저렴한 클래스로 보내고 현재 버전은 그대로 둘 수 있습니다. 버전 관리 버킷에서 객체를 지우면 이전 버전이 계속 남아 요금이 나가므로, NoncurrentVersionExpiration으로 이전 버전을 언젠가 삭제하는 규칙도 함께 두는 것이 좋습니다. 감사 목적으로 삭제·덮어쓰기를 막아야 한다면 S3 Object Lock을 씁니다.

개별 객체의 클래스를 바꿀 때는 같은 키로 복사하면서 클래스를 지정합니다.

aws s3 cp s3://my-app-bucket/reports/2026/q1.csv s3://my-app-bucket/reports/2026/q1.csv \
  --storage-class INTELLIGENT_TIERING

이때 --metadata-directive REPLACE를 붙이면 Content-Type 같은 기존 메타데이터를 다시 지정하지 않는 한 지워지므로 주의해야 합니다. 객체가 많다면 한 건씩 복사하지 말고 수명 주기 규칙이나 S3 Batch Operations를 씁니다.


RDS - 관리형 데이터베이스

RDS(Relational Database Service)는 PostgreSQL, MySQL, MariaDB 등을 설치·백업·패치까지 AWS가 관리해 주는 서비스입니다. EC2에 DB를 직접 설치하면 백업 스크립트, 장애 복구, 보안 패치를 모두 직접 챙겨야 하지만, RDS는 자동 백업과 시점 복원, 마이너 버전 자동 업그레이드, 기본 모니터링을 제공합니다. 대신 OS 접근은 할 수 없고 일부 확장이나 설정이 제한됩니다.

인스턴스 만들기

RDS 콘솔의 “데이터베이스 생성”에서 다음을 정합니다.

항목예시 값
엔진PostgreSQL (지원되는 최신 메이저 버전)
템플릿프리 티어 또는 개발/테스트
DB 인스턴스 식별자my-database
마스터 사용자 이름postgres (기본값)
마스터 암호Secrets Manager에서 관리 선택 권장
인스턴스 클래스db.t3.micro 또는 db.t4g.micro
스토리지gp3 20GB
퍼블릭 액세스아니오 (로컬에서 테스트해야 할 때만 일시적으로 예)
VPC 보안 그룹새로 만들고 앱 서버 보안 그룹만 허용

마스터 암호를 “AWS Secrets Manager에서 관리”로 만들면 암호를 사람이 알 필요가 없고 교체도 자동화됩니다. 로컬 PC에서 바로 붙어 보려고 퍼블릭 액세스를 켜는 경우가 많은데, 이때는 보안 그룹 소스를 반드시 내 IP 하나로 제한하고 테스트가 끝나면 끕니다.

Node.js 연결

npm install pg
# RDS 루트 인증서 번들 다운로드
curl -o global-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
import { readFileSync } from 'node:fs';
import pg from 'pg';

const pool = new pg.Pool({
  host: process.env.DB_HOST, // my-database.xxxxx.ap-northeast-2.rds.amazonaws.com
  port: 5432,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: 'postgres',
  ssl: {
    ca: readFileSync('./global-bundle.pem', 'utf8'), // RDS 인증서를 신뢰하도록 지정
    rejectUnauthorized: true,
  },
});

const { rows } = await pool.query('SELECT id, name FROM users LIMIT 10');
console.log(rows);

예제에서 흔히 보이는 ssl: { rejectUnauthorized: false }는 암호화는 하지만 서버 인증서를 검증하지 않아 중간자 공격을 막지 못합니다. 반대로 rejectUnauthorized: true만 두고 CA를 지정하지 않으면, Node.js가 RDS 인증 기관을 기본으로 신뢰하지 않기 때문에 연결이 실패합니다. 위처럼 RDS 인증서 번들을 ca로 넘기는 것이 올바른 설정입니다.

백업과 복원

자동 백업을 켜면 매일 스냅샷을 만들고 트랜잭션 로그를 보관해, 보존 기간(기본 7일, 최대 35일) 안의 임의 시점으로 복원할 수 있습니다. 릴리스 직전처럼 특정 시점을 오래 보관하고 싶을 때는 수동 스냅샷을 만듭니다. 수동 스냅샷은 직접 지우기 전까지 남습니다. 어느 쪽이든 복원하면 기존 인스턴스를 덮어쓰지 않고 새 인스턴스가 만들어지므로, 애플리케이션의 접속 주소를 새 엔드포인트로 바꾸는 절차까지 미리 정해 두어야 합니다.

Multi-AZ와 장애 조치

Multi-AZ 인스턴스 배포를 켜면 다른 AZ에 대기(standby) 인스턴스가 만들어지고, 프라이머리의 쓰기가 대기 쪽 스토리지에 동기식으로 복제됩니다. 대기 인스턴스는 읽기 요청을 받지 않습니다.

[정상]
  애플리케이션 → 엔드포인트 DNS → 프라이머리(AZ a)
                                    ↓ 동기 복제
                              대기(AZ b): 읽기 불가, 대기만 함

[프라이머리 장애]
  RDS가 감지 → 대기 인스턴스를 승격 → 같은 DNS 이름이 새 프라이머리를 가리킴
  → 애플리케이션은 연결 문자열을 바꾸지 않고 재접속

장애 조치는 문서 기준으로 보통 1~2분 정도 걸립니다. 그동안 연결이 끊기므로 애플리케이션은 재시도와 커넥션 풀 재연결을 견디도록 만들어야 하고, DNS 캐시 시간이 긴 클라이언트는 새 주소를 늦게 받을 수 있습니다. 읽기를 받는 대기 인스턴스 두 개를 두는 Multi-AZ DB 클러스터 배포도 있지만, 지원 엔진과 인스턴스 클래스가 제한되므로 문서를 확인해야 합니다.

DB 서브넷 그룹과 보안 그룹

프로덕션에서는 RDS를 프라이빗 서브넷에 두고, 퍼블릭 액세스를 끄고, 앱 서버 보안 그룹에서만 DB 포트를 허용하는 것이 기본입니다. DB 서브넷 그룹은 RDS가 네트워크 인터페이스를 만들 수 있는 서브넷 목록이며, Multi-AZ를 쓰려면 서로 다른 AZ의 서브넷이 두 개 이상 필요합니다. 서브넷 그룹에 퍼블릭 서브넷이 섞여 있으면 퍼블릭 액세스 설정 하나로 DB가 인터넷에 노출될 수 있으므로, 서브넷의 라우트 테이블까지 확인합니다.

sg-app-prod (EC2/ECS)
  아웃바운드: 5432 → sg-rds-prod

sg-rds-prod (RDS)
  인바운드: 5432 ← sg-app-prod
  인바운드: 5432 ← sg-bastion  (관리용 점프 서버가 있다면)

파라미터 그룹과 읽기 복제본

DB 파라미터 그룹에서 max_connections, work_mem, log_min_duration_statement 같은 엔진 설정을 바꿀 수 있습니다. 기본 파라미터 그룹은 수정할 수 없으므로 사용자 정의 그룹을 만들어 연결하고, 재부팅이 필요한 정적 파라미터는 유지보수 시간에 적용합니다. RDS PostgreSQL의 shared_buffers 기본값은 인스턴스 메모리의 약 25%로 이미 계산되어 있어 처음에는 바꿀 필요가 거의 없습니다. 연결 하나가 메모리를 쓰므로 max_connections를 무작정 키우기보다 애플리케이션 풀 크기의 합이 넘지 않게 맞추는 것이 중요합니다.

읽기 전용 복제본(Read Replica)은 비동기 복제로 프라이머리의 데이터를 받아 읽기 부하를 나눕니다. 애플리케이션은 쓰기를 프라이머리 엔드포인트로, 읽기를 복제본 엔드포인트로 보냅니다.

import { readFileSync } from 'node:fs';
import pg from 'pg';

const ssl = { ca: readFileSync('./global-bundle.pem', 'utf8'), rejectUnauthorized: true };
const base = {
  user: process.env.RDS_USER,
  password: process.env.RDS_PASSWORD,
  database: process.env.RDS_DB,
  ssl,
};

const writer = new pg.Pool({ ...base, host: process.env.RDS_WRITER_HOST, max: 20 });
const reader = new pg.Pool({ ...base, host: process.env.RDS_READER_HOST, max: 50 });

export function createUser(name) {
  return writer.query('INSERT INTO users (name) VALUES ($1) RETURNING id, name', [name]);
}

export function listUsers() {
  return reader.query('SELECT id, name FROM users ORDER BY id DESC LIMIT 100');
}

복제가 비동기이므로 방금 쓴 데이터를 바로 읽어야 하는 경우(예: 저장 직후 상세 화면)에는 프라이머리에서 읽어야 합니다. 읽기 복제본은 읽기 확장용이고, 자동 장애 조치를 해 주는 것은 Multi-AZ입니다. 둘의 역할을 혼동하지 않아야 합니다.

모니터링

CloudWatch에서 DatabaseConnections(최대 연결 수에 근접하는지), FreeStorageSpace(디스크 부족), ReplicaLag(복제 지연), CPUUtilization 알람을 걸어 두면 대부분의 장애를 미리 알 수 있습니다. 스토리지 자동 확장을 켜면 디스크가 부족할 때 자동으로 늘어나지만, 최대 한도를 함께 정해 두어야 비용이 예상 밖으로 커지지 않습니다. Performance Insights(또는 그 후속인 CloudWatch Database Insights)를 켜면 SQL별 부하와 대기 이벤트를 볼 수 있어 느린 쿼리와 락을 찾기 쉽습니다.


Lambda - 서버리스 함수

Lambda는 서버를 띄워 두지 않고 이벤트가 올 때만 코드를 실행하는 서비스입니다. 실행 시간과 메모리 설정에 따라 과금되고, 요청이 없으면 비용이 거의 없습니다. 한 번 실행은 최대 15분으로 제한됩니다.

함수 만들기와 API 연결

Lambda 콘솔에서 “함수 생성” → “새로 작성”을 고르고, 런타임은 지원되는 최신 Node.js LTS(예: Node.js 22.x)를 선택합니다.

export const handler = async (event) => {
  const name = event.queryStringParameters?.name || 'World';
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: `Hello, ${name}!` }),
  };
};

함수의 “트리거 추가”에서 API Gateway의 HTTP API를 만들면 다음과 같은 엔드포인트가 생깁니다.

curl "https://xxxxx.execute-api.ap-northeast-2.amazonaws.com/default/hello-world?name=Alice"
# {"message":"Hello, Alice!"}

트리거를 만들 때 보안을 “열기”로 두면 누구나 호출할 수 있으므로, 공개 API가 아니라면 IAM 인증이나 JWT 권한 부여자를 붙입니다.

예제: S3 업로드 시 이미지 리사이즈

import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import sharp from 'sharp';

const s3 = new S3Client({});
const OUTPUT_PREFIX = 'resized/';

export const handler = async (event) => {
  for (const record of event.Records) {
    const bucket = record.s3.bucket.name;
    // 이벤트의 키는 URL 인코딩되어 있음 (공백은 +)
    const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));

    // 결과물이 다시 이벤트를 일으켜 무한 루프가 되지 않도록 방어
    if (key.startsWith(OUTPUT_PREFIX)) continue;

    const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
    const input = await Body.transformToByteArray();

    const resized = await sharp(input)
      .resize(800, 600, { fit: 'inside' })
      .jpeg({ quality: 80 })
      .toBuffer();

    await s3.send(new PutObjectCommand({
      Bucket: bucket,
      Key: `${OUTPUT_PREFIX}${key.replace(/\.[^.]+$/, '')}.jpg`,
      Body: resized,
      ContentType: 'image/jpeg',
    }));
  }
};

이 패턴에서 가장 위험한 실수는 같은 버킷에 결과를 쓰면서 트리거를 버킷 전체에 거는 것입니다. 리사이즈 결과가 다시 업로드 이벤트를 일으켜 함수가 끝없이 호출되고 요금이 계속 쌓입니다. 트리거에 uploads/ 같은 접두사 필터를 걸고, 결과는 다른 접두사나 별도 버킷에 쓰고, 코드에서도 위처럼 한 번 더 확인하는 것이 안전합니다. 또 sharp는 네이티브 모듈이라 Lambda 아키텍처(x86_64 또는 arm64)용 Linux 바이너리로 패키징해야 하고, 이벤트의 객체 키는 URL 인코딩되어 있으므로 디코딩해서 써야 합니다.


CloudFront - CDN

CloudFront는 전 세계 엣지 위치에 콘텐츠를 캐시해 사용자와 가까운 곳에서 응답하는 CDN입니다. 한국 사용자가 미국 리전의 서버에 요청하면 매 요청이 태평양을 왕복하지만, 엣지에 캐시된 파일은 서울 엣지에서 바로 응답합니다. HTTPS 연결은 TCP·TLS 핸드셰이크 때문에 첫 응답까지 여러 번 왕복하므로 거리의 영향이 생각보다 큽니다. 반대로 캐시되지 않는 API 응답이나 캐시 적중률이 낮은 콘텐츠는 이득이 작으므로, 도입 전후를 직접 측정해 보는 것이 좋습니다.

S3 원본으로 배포 만들기

CloudFront 콘솔에서 배포를 만들고 원본으로 S3 버킷(my-app-bucket.s3.ap-northeast-2.amazonaws.com)을 고릅니다. 원본 액세스는 OAC(Origin Access Control)를 선택하고, 콘솔이 제시하는 버킷 정책을 버킷에 붙여 CloudFront만 객체를 읽을 수 있게 합니다. 예전 자료에 나오는 OAI(Origin Access Identity)는 레거시 방식이고, 새 배포에는 OAC가 권장됩니다. 뷰어 프로토콜 정책은 “Redirect HTTP to HTTPS”, 캐시 정책은 정적 파일이라면 CachingOptimized로 시작합니다. 단일 페이지 앱이라면 기본 루트 객체를 index.html로 지정하고 403/404 응답을 index.html로 돌려주는 오류 페이지 설정이 필요합니다.

커스텀 도메인 연결

배포에 cdn.example.com 같은 대체 도메인 이름을 추가하려면 그 도메인의 ACM 인증서가 필요합니다. CloudFront용 인증서는 반드시 버지니아 북부(us-east-1) 리전의 ACM에서 발급해야 하는데, 서울 리전에서 발급하면 배포 설정 화면에 나타나지 않습니다. DNS는 Route 53을 쓴다면 CNAME 대신 별칭(Alias) A/AAAA 레코드로 배포를 가리키는 것이 좋습니다. 별칭 레코드는 루트 도메인(example.com)에도 쓸 수 있고 조회 요금이 없습니다.


아키텍처 예시

3계층 구성

Internet
   │
   ▼
CloudFront (정적 파일) ──► S3 (OAC, 비공개 버킷)
   │
   ▼
ALB (퍼블릭 서브넷, 다중 AZ)
   │
   ▼
Auto Scaling 그룹: EC2 또는 ECS (프라이빗 앱 서브넷, NAT로 아웃바운드)
   │
   ▼
RDS 프라이머리 (프라이빗 DB 서브넷, Multi-AZ)
   └── 읽기 복제본 (보고서·조회용 별도 엔드포인트)

트래픽이 거의 없는 단계에서는 EC2 한 대에 Nginx와 앱을 올리고 RDS 하나를 붙이는 것으로 충분합니다. 서비스가 커지면서 단일 인스턴스 장애가 문제가 되면, 로드 밸런서 뒤에 여러 AZ의 인스턴스를 두는 구성으로 옮겨 갑니다.

서버리스 구성

정적 프런트엔드는 S3 + CloudFront로, API는 API Gateway + Lambda로, 데이터는 DynamoDB로 구성하면 요청이 적을 때 비용이 매우 낮고 서버 관리가 거의 없습니다. 대신 Lambda의 콜드 스타트, 15분 실행 제한, DynamoDB의 쿼리 모델 제약을 받아들여야 하므로, 서비스의 접근 패턴이 이 모델에 맞는지 먼저 확인해야 합니다.

운영에서 반복되는 원칙

사람과 애플리케이션 모두 IAM 역할로 필요한 API만 허용하는 최소 권한, DB 암호 같은 비밀을 코드 대신 Secrets Manager나 Parameter Store에 두는 비밀 분리, 웹 계층만 퍼블릭에 두고 앱과 DB는 프라이빗 서브넷에 두는 네트워크 분리가 기본입니다. 로드 밸런서 헬스 체크와 Auto Scaling으로 불량 인스턴스를 자동 교체하고, CloudWatch 지표와 로그로 원인을 추적하며, CloudTrail로 누가 어떤 API를 호출했는지 남깁니다. 배포는 한 번에 전부 바꾸지 않고 롤링이나 블루/그린으로 진행하고, DB 스키마는 이전 버전 코드와 호환되는 순서로 바꿔 롤백 여지를 남깁니다.

백업은 실제로 복원해 봐야 의미가 있습니다. 분기마다 스냅샷을 새 인스턴스로 복원하고 애플리케이션을 붙여 보는 연습을 문서화해 두면, 실제 장애 때 복구 시간을 크게 줄일 수 있습니다.


보안 기본기

IAM 최소 권한

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::my-app-bucket/uploads/*"
    }
  ]
}

"Action": "*", "Resource": "*" 같은 정책은 키가 유출되었을 때 계정 전체를 넘겨주는 것과 같습니다. 애플리케이션에 필요한 동작과 리소스만 허용하고, EC2·Lambda에는 액세스 키 대신 IAM 역할을 붙입니다. 역할을 쓰면 SDK가 임시 자격 증명을 자동으로 받아 오므로 코드에 키를 넣을 필요가 없습니다.

비밀 정보 관리

aws secretsmanager create-secret \
  --name myapp/database \
  --secret-string '{"username":"app","password":"<생성한 강력한 암호>"}'
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({ region: 'ap-northeast-2' });

export async function getSecret(secretId) {
  const res = await client.send(new GetSecretValueCommand({ SecretId: secretId }));
  return JSON.parse(res.SecretString);
}

const db = await getSecret('myapp/database');
// 비밀 값은 로그에 출력하지 않음

셸 히스토리에 비밀 값이 남지 않도록 --secret-string file://secret.json처럼 파일로 넘기는 방법도 있습니다. 시작할 때 한 번 읽어 캐시하고, 암호 교체를 자동화했다면 인증 실패 시 다시 읽어 오도록 만들면 됩니다.


비용 관리

계정을 만들자마자 Budgets로 예산 알림을 걸고, Cost Explorer로 서비스별 비용을 주기적으로 확인합니다. 처음 몇 달 동안 예상 밖 요금의 원인은 대부분 켜 둔 채 잊은 리소스입니다. NAT 게이트웨이, 사용하지 않는 Elastic IP와 퍼블릭 IPv4, 중지된 인스턴스에 붙어 있는 EBS 볼륨과 스냅샷, 지운 줄 알았던 로드 밸런서가 대표적입니다.

# 사용하지 않는 EC2 중지 (컴퓨팅 요금은 멈추지만 EBS 요금은 계속 발생)
aws ec2 stop-instances --instance-ids i-xxxxx
aws ec2 start-instances --instance-ids i-xxxxx

인스턴스를 중지해도 EBS 볼륨과 퍼블릭 IPv4(Elastic IP) 요금은 계속 나갑니다. 다시 쓰지 않을 리소스는 중지가 아니라 종료하고 남은 볼륨과 스냅샷까지 정리해야 합니다.

꾸준히 쓰는 워크로드라면 1년 또는 3년 약정으로 할인받는 Compute Savings Plans(EC2·Lambda·Fargate)와 RDS 예약 인스턴스를 검토합니다. 할인율은 인스턴스 유형, 리전, 선결제 방식에 따라 달라지므로 요금 페이지에서 직접 비교하고, 3년 약정은 그 사이 더 저렴한 새 세대가 나와도 묶인다는 점을 감안해야 합니다.


모니터링

CloudWatch는 EC2의 CPU·네트워크, RDS의 연결 수·스토리지 같은 기본 지표를 자동으로 수집합니다. EC2의 메모리와 디스크 사용률은 기본 지표에 없으므로 CloudWatch 에이전트를 설치해야 볼 수 있습니다. 알람은 콘솔의 CloudWatch → 알람 생성에서 지표(예: EC2 CPU 사용률), 조건(5분 동안 80% 초과), 알림 대상(SNS 주제와 이메일)을 정해 만듭니다.

# Lambda 등의 로그를 실시간으로 확인
aws logs tail /aws/lambda/my-function --follow

로그 그룹은 기본 보존 기간이 “무기한”이라 로그가 계속 쌓이며 요금이 늘어납니다. 로그 그룹마다 보존 기간을 정해 두는 것을 잊지 마세요.


GitHub Actions로 배포

# .github/workflows/deploy.yml
name: Deploy to AWS
on:
  push:
    branches: [main]

permissions:
  id-token: write   # OIDC 토큰 발급에 필요
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - run: npm ci
      - run: npm run build

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy
          aws-region: ap-northeast-2

      - name: Deploy to S3
        run: aws s3 sync ./dist s3://my-app-bucket --delete

      - name: Invalidate CloudFront
        run: aws cloudfront create-invalidation --distribution-id E123456 --paths "/*"

GitHub Secrets에 액세스 키를 저장하는 대신, IAM에 GitHub OIDC 공급자를 등록하고 특정 저장소·브랜치만 맡을 수 있는 역할을 만들어 role-to-assume으로 지정하면 오래 유지되는 키가 필요 없습니다. 역할의 신뢰 정책에서 sub 조건을 repo:my-org/my-repo:ref:refs/heads/main처럼 좁혀야 다른 저장소가 이 역할을 맡을 수 없습니다.


실전: 풀스택 앱 배포

프런트엔드는 S3 + CloudFront, 백엔드는 API Gateway(HTTP API) + Lambda, 데이터베이스는 RDS PostgreSQL로 구성하는 예입니다.

프런트엔드

npm run build
aws s3 sync ./build s3://my-app-frontend --delete
# CloudFront 배포는 앞의 절 참고

백엔드 Lambda

// handler.js
import pg from 'pg';

// 핸들러 밖에서 만들어 같은 실행 환경의 다음 호출에서 재사용
const pool = new pg.Pool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  max: 1, // Lambda 실행 환경 하나는 동시에 요청 하나만 처리
  ssl: { rejectUnauthorized: true, ca: process.env.RDS_CA_BUNDLE },
});

export const handler = async (event) => {
  // HTTP API(페이로드 2.0)는 메서드와 경로를 requestContext.http와 rawPath에 담음
  const method = event.requestContext.http.method;
  const path = event.rawPath;

  if (method === 'GET' && path === '/api/users') {
    const { rows } = await pool.query('SELECT id, name, email FROM users ORDER BY id');
    return { statusCode: 200, body: JSON.stringify(rows) };
  }

  if (method === 'POST' && path === '/api/users') {
    const { name, email } = JSON.parse(event.body ?? '{}');
    if (!name || !email) {
      return { statusCode: 400, body: JSON.stringify({ error: 'name and email are required' }) };
    }
    const { rows } = await pool.query(
      'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email',
      [name, email],
    );
    return { statusCode: 201, body: JSON.stringify(rows[0]) };
  }

  return { statusCode: 404, body: JSON.stringify({ error: 'Not Found' }) };
};

REST API(페이로드 1.0) 예제에서 보던 event.httpMethod, event.path는 HTTP API의 기본 페이로드 2.0에는 없어서, 그대로 쓰면 모든 요청이 404로 떨어집니다. RDS가 프라이빗 서브넷에 있다면 Lambda도 같은 VPC의 프라이빗 서브넷에 연결하고, Lambda 보안 그룹을 RDS 보안 그룹의 인바운드 소스로 허용해야 합니다. 동시 실행 수가 늘면 실행 환경마다 DB 연결이 하나씩 생기므로, 트래픽이 많아지면 RDS Proxy를 앞에 두어 연결 수를 관리합니다.

데이터베이스

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

INSERT INTO users (name, email) VALUES
  ('Alice', '[email protected]'),
  ('Bob', '[email protected]');

트러블슈팅

EC2에 SSH로 접속되지 않으면 먼저 보안 그룹의 22번 포트 소스가 현재 내 IP인지 확인합니다. 집이나 카페에서는 IP가 바뀌므로 어제 되던 규칙이 오늘은 안 맞을 수 있습니다. 그다음 인스턴스가 퍼블릭 서브넷에 있고 퍼블릭 IP가 붙어 있는지, 키 파일 권한이 chmod 400인지, 사용자 이름이 AMI에 맞는지(Ubuntu는 ubuntu, Amazon Linux는 ec2-user) 봅니다.

S3 객체에 접근이 거부되면(403) 요청자의 IAM 권한, 버킷 정책, 퍼블릭 액세스 차단, KMS 키 권한(SSE-KMS로 암호화된 경우)을 차례로 확인합니다. 존재하지 않는 키를 요청했는데 s3:ListBucket 권한이 없으면 404 대신 403이 오므로, 403이 꼭 권한 문제만은 아니라는 점도 기억해 두세요. CloudFront를 통한 접근이라면 OAC 버킷 정책의 배포 ARN이 맞는지 봅니다.

RDS에 연결되지 않으면 연결 시간 초과인지 인증 실패인지부터 구분합니다. 시간 초과라면 보안 그룹의 5432 포트 소스, 클라이언트와 DB가 같은 VPC에 있는지(또는 퍼블릭 액세스와 라우팅), 서브넷 라우트 테이블을 확인합니다. 인증이나 SSL 오류라면 사용자 이름·암호와 SSL 인증서 설정을 확인합니다.


같이 보면 좋은 글