AWS 배포 전 개념 정리
2026년 7월 29일
배포 과정을 말하는 블로그를 작성하기 이전에, 용어들과 이론에 대해 공부하고 넘어가자.

참고 : https://github.com/big-cute-team/plick-frontend/blob/develop/docs/deploy.md
1. SSM과 SSH 차이?
1-1. SSH는 뭘까
SSH(Secure Shell)는 원격 서버에 터미널로 접속하는 고전적인 방법임.
동작 방식:
서버가 22번 포트를 열어 두고 접속을 기다린다.
내가 ssh -i key.pem ubuntu@1.2.3.4처럼 서버의 IP로 직접 연결한다.
서버에 미리 등록해 둔 키 페어(pem 파일)로 인증한다.
즉 SSH가 되려면 세 가지가 필요:
서버에 닿을 수 있는 네트워크 경로(보통 퍼블릭 IP),
열려 있는 22번 포트,
내 손에 있는 비밀키 파일.
이 셋이 각각 다 관리 부담이자 공격 표면임.
1-2. SSM은 뭔데 그럼
AWS Systems Manager의 약자. 원래는 EC2 인스턴스들을 관리하는 큰 서비스인데, 여기서 말하는 건 그중 두 기능:
Session Manager: 브라우저나 aws ssm start-session 명령으로 EC2 셸을 여는 기능. SSH 터미널의 대체품.
Run Command (send-command): 접속 없이 "이 명령 실행해"라고 원격으로 명령만 던지는 기능. 우리 배포가 이걸 씀.
1-3. 차이 : 연결 방향
SSH는 내가 서버로 들어가는(inbound) 방식. 그래서 서버가 밖에서 닿을 수 있어야 하고, 포트를 열어야 함.
SSM은 서버 안에 깔린 SSM Agent가 AWS 서비스 쪽으로 먼저 나가서(outbound) 연결을 걸어 두는 방식이에요.
에이전트가 AWS의 SSM 엔드포인트와 상시 연결을 유지하고 있다가, 내가 "세션 열어줘"라고 AWS API에 요청하면 그 열려 있는 연결을 통해 셸이 이어짐. 서버 입장에서 들어오는 연결 없음.
SSH: 나 ──(22번 포트로)──▶ EC2 ← 서버가 문을 열어둬야 함
SSM: 나 ──▶ AWS API ◀──(에이전트가 먼저 접속)── EC2 ← 서버는 문이 닫혀 있음
1-4. 장점
퍼블릭 IP가 필요 없습니다. 우리 넥스트 EC2는 프라이빗 서브넷에 있어서 인터넷에서 아예 안 보임. SSH였다면 접속 자체가 불가능하고, 배스천(중계 서버)을 한 대 더 둬야 했을 것.
22번 포트를 안 염. 전 세계 봇들이 22번 포트를 무차별 스캔하고 브루트포스하면 비용 난리날 수 있음
pem 키 파일이 없다. SSH 키는 유출되면 끝이고, 팀원 온보딩/오프보딩 때마다 키 배포와 회수해야함.
SSM은 대신 IAM 권한으로 접근을 통제. "이 IAM 유저는 이 인스턴스에 세션을 열 수 있다"를 정책으로 관리하고, 누가 언제 접속했는지 CloudTrail에 다 남음.
CI 배포가 깔끔해짐. GitHub Actions 러너는 프라이빗 EC2에 ssh/scp로 닿을 수 없음. 대신 러너가 aws ssm send-command로 AWS API에 명령을 넣으면, EC2 안의 에이전트가 그걸 받아 실행. 러너와 EC2가 직접 통신X.
2. NAT 는 뭔가
2-1. 프라이빗 서브넷의 딜레마
EC2는 프라이빗 서브넷에 있고 퍼블릭 IP가 없음.
- 밖에서 못 들어온다 ✅ (원하던 것 — 보안)
- 근데 안에서 밖으로도 못 나간다 ❌ (곤란한 것)
2.2 NAT가 하는 일
NAT 게이트웨이는 퍼블릭 서브넷에 앉아서 사설 IP를 대신해 주는 중계인.
- 프라이빗 EC2(10.0.2.15)가 인터넷으로 요청을 보낸다.
- NAT 게이트웨이가 그 패킷을 받아서 발신자 주소를 자기 퍼블릭 IP로 바꿔치기해서 내보낸다.
- 응답이 NAT의 퍼블릭 IP로 돌아오면, NAT가 "아 이건 아까 10.0.2.15가 보낸 요청의 답이지" 하고 기억해 뒀다가 안쪽으로 전달한다.
안→밖으로 시작된 통신의 왕복만 허용하고, 밖에서 먼저 들어오는 연결은 매칭되는 기록이 없으니 그냥 버려짐. "나갈 수는 있는데 들어올 수는 없는" .
사실 집 공유기가 하는 일도 똑같은 NAT. 집 안 기기들은 다 192.168.x.x 사설 IP인데 인터넷이 되는 게, 공유기가 하나의 퍼블릭 IP로 주소 변환을 해 주고 있는거.
2-3. NAT 또는 VPC 엔드포인트
NAT 게이트웨이: 인터넷 전체로 나가는 범용 통로. SSM도 되고 apt도 되고 외부 API 호출도 다 됨. 대신 시간당 요금 + 트래픽 요금 (한 달에 대략 4~5만 원부터).
VPC 엔드포인트: 인터넷을 거치지 않고 AWS 내부망으로 특정 AWS 서비스(ssm, ssmmessages, ec2messages)에만 직통으로 연결하는 전용선. SSM만 필요하면 이걸로 충분하고 더 쌈.
"SSM이 붙는 것과 인터넷이 되는 건 별개"
VPC 엔드포인트만 있는 구성이면 SSM 세션은 잘 열리는데, 그 안에서 apt update나 외부 다운로드는 안 됨.
SSM은 전용선으로 붙었을 뿐 인터넷 통로(NAT)는 없는 상태.
우리는 프라이빗 ec2에서 Node.js 설치 같은 일이 필요하니, NAT 게이트웨이가 필요함
3. ALB는 뭔데
3-1. ALB란
ALB(Application Load Balancer)는 AWS의 L7(HTTP 계층) 로드밸런서.
원래 용도는 이름대로 "트래픽을 여러 서버에 분산"이지만, HTTP를 이해하는 문지기라서 실무에선 그 이상의 역할을 함.
요청의 호스트명, 경로, 헤더를 읽고 규칙에 따라 다른 과녁(타겟 그룹)으로 보내고, TLS를 대신 처리하고, 뒤에 있는 서버가 살아 있는지 헬스체크를 하는 등.
3-2 우리팀의 퍼블릭 ALB
프라이빗 EC2의 대리인. SSM/NAT 얘기랑 이어지는 지점인데, Next EC2는 퍼블릭 IP가 없어서 사용자가 직접 닿을 방법이 없음. 그래서 인터넷에 보이는 건 ALB뿐이고, Route 53의 plick.co.kr도 EC2가 아니라 ALB를 가리킴. 들어오는 사용자 트래픽은 ALB가, 들어오는 관리 접속은 SSM이, 나가는 통신은 NAT가 맡는 3분할 구조예요.
TLS 종료(termination). 브라우저와의 HTTPS 암호화를 ALB가 대신 처리함. ACM 인증서를 ALB에 붙여 두면, 브라우저↔ALB 구간은 HTTPS이고 ALB↔EC2 구간은 평문 HTTP(3000/3001). EC2에 인증서를 설치하고 갱신할 필요가 없어져요. VPC 내부 구간이라 평문이어도 괜찮음.
호스트 기반 라우팅. EC2 한 대에 앱이 두 개(web :3000, mobile :3001) 떠 있는데, ALB가 요청의 Host 헤더를 읽어서 plick.co.kr이면 tg-front-web(:3000)으로, m.plick.co.kr이면 tg-front-mobile(:3001)로 갈라 보냄. 이게 L7 로드밸런서라서 가능한 일. L4(NLB)였다면 HTTP 헤더를 못 읽어서 도메인별 분기가 안 됨
헬스체크. 타겟이 살아 있는지 주기적으로 확인하고 죽은 타겟은 트래픽에서 뻄. 단 헬스체크 경로를 BE 데이터가 필요한 페이지로 잡으면 BE 장애 때 프론트까지 unhealthy 판정이 나서 ALB가 프론트를 통째로 내려버릴 수 있음. 그래서 헬스체크는 BE 의존 없는 경로로 잡기.
3-3. 우리팀 프라이빗 ALB
내부 ALB: VPC 안에서만 쓰는 BE의 문
internal-...elb.amazonaws.com DNS를 가진, 인터넷에서 아예 안 보이는 ALB. 프론트 EC2가 BE(Spring)를 호출할 때만 거침.
안정적인 주소 제공. EC2의 사설 IP는 재시작이나 교체 때 바뀜. IP를 하드코딩하면 BE 서버가 바뀔 때마다 프론트 설정을 고쳐야 됨. 내부 ALB의 DNS 이름을 API_BASE_URL로 쓰면 BE 인스턴스가 몇 대로 늘든 교체되든 프론트는 몰라도 됨. BE 팀과 프론트 팀 사이의 고정된 계약 주소 역할을 하게 됨 (단 그 DNS가 풀리는 IP 자체는 AWS가 예고 없이 바꾸니 DNS 이름으로 불러야지 IP를 박으면 안됨.)
BE 확장과 무중단 배포. BE가 인스턴스를 늘리거나 롤링 배포를 해도, 내부 ALB가 헬스체크로 산 놈만 골라 트래픽을 주니까 프론트 입장에선 아무 일도 없음.
보안 경계.

각 단계가 바로 앞 단계에게만 문염.
BE에 닿으려면 반드시 퍼블릭 ALB → Next → 내부 ALB를 순서대로 거쳐야 하고, BE API를 인터넷에서 직접 두드릴 방법이 없음.
장애 사례 중 "BE 데이터 화면이 전부 비어 있음"은 보통 이 체인에서 내부 ALB SG에 Next EC2 SG를 안 넣은 거임.
4. 넥스트 배포 과정

요약 : 러너가 EC2에 직접 접속하지 않음. EC2는 프라이빗이라 러너가 닿을 수 없으니(SSH 없음), 파일은 S3를 경유하고 명령은 SSM을 경유.
4-1 트리거와 빌드
main에 푸시되면(develop→main 병합이 곧 배포 버튼) web과 mobile 두 잡이 동시에 돔.
빌드를 러너(ubuntu x86_64)에서 하는 이유가 있는데, 산출물에 sharp 같은 OS·CPU 종속 네이티브 바이너리가 딸려 들어가서 맥에서 빌드한 걸 올리면 EC2에서 깨짐. 서버와 같은 환경에서 빌드해야 함.
Next의 standalone 빌드는 CDN 배포를 전제로 해서 .next/static(JS/CSS)과 public/(이미지)을 산출물에 안 담음. 우리는 CDN 없이 EC2가 직접 서빙하니까 이 둘을 직접 채워서 tar파일로 말아버림. 빠뜨리면 페이지는 뜨는데 CSS와 이미지가 전부 404인 앙상한 화면됨.
standalone이 뭐임?
next build의 기본 산출물(.next)은 혼자서는 못 돔.
서버에서 next start로 실행하려면 node_modules 전체가 옆에 있어야 함.
그런데 모노레포 node_modules는 수백 MB에 devDependencies까지 뒤섞여 있어서, 이걸 통째로 서버에 올리는 건 낭비.
next.config.js에 output: "standalone"을 켜면,
Next가 빌드하면서 실제로 import되는 파일만 추적해서
.next/standalone/ 폴더에 만듬.
안에는:
server.js — 실행 진입점. next start도 필요 없이 node server.js로 바로 뜸
서버 코드가 진짜 쓰는 node_modules만 추려서 복사한 것
그래서 서버에서 pnpm install도 next CLI도 필요 없고, Node만 있으면 이 폴더 하나로 앱이 뜸.
함정: 두 가지는 일부러 빼놓는다
standalone은 "정적 파일은 CDN이 서빙할 것"을 전제로 해서 두 폴더를 안 담아요:
.next/static — 빌드된 JS/CSS 청크
public/ — 이미지 같은 정적 자산
우리는 CDN 없이 EC2의 Next 서버가 직접 다 서빙하니까,
워크플로에서 이 둘을 standalone 폴더 안에 손으로 복사해 넣고 tar로 말았음.
4-2. OIDC(OpenID Connect)로 권한 획득
러너가 S3에 올리고 SSM 명령을 치려면 AWS 권한이 필요한데, 액세스 키를 시크릿에 박아두는 대신 OIDC 쓰기.
GitHub가 "이 워크플로는 진짜 이 리포의 main에서 돌고 있음"이라는 서명된 토큰을 발급하면, AWS가 그걸 믿고 임시 자격증명을 내주는 방식.
유출될 영구 키 자체가 없음. SSH 키를 없앤 것과 같은 철학.
4-3 S3 업로드
tarball과 release.sh를 s3://plick-deploy/frontend/<커밋sha>/에 올림.
스크립트를 매번 같이 올리는 게 포인트인데, EC2에 스크립트를 미리 깔아두면 스크립트를 고칠 때마다 서버에 들어가야 하지만, 커밋마다 같이 올리면 항상 그 커밋 시점의 스크립트가 실행. 배포 로직도 코드와 함께 버전 관리되는 거예요
tarball? release.sh?
1. tarball
여러 파일과 폴더를 파일 하나로 묶은 꾸러미.
tar라는 유닉스 명령으로 만들어서 tarball.
보통 gzip 압축까지 같이 해서 확장자가 .tar.gz
리눅스 세계의 zip인 셈
standalone 빌드 산출물은 수천 개 파일임.
이걸 낱개로 S3에 올리면 느리고 중간에 일부만 올라가는 사고 가능.
하나로 말면 전송이 빠르고, "다 왔거나 안 왔거나" 둘 중 하나라 어중간한 상태가 없음.
우리 배포에서는 러너가 standalone 산출물(+ 손으로 채운 .next/static, public/)을 web.tar.gz, mobile.tar.gz로 말아 S3에 올리고, EC2가 내려받아 다시 품.
2. release.sh
이 저장소의 scripts/deploy/release.sh에 있는,
EC2에서 실행되는 배포 절차서.
97줄짜리 셸 스크립트고, "서버 쪽 흐름" 전체가 이 파일 하나
flock으로 잠금을 걸어 web/mobile 배포가 동시에 겹치지 않게 직렬화하고
S3에서 tarball을 내려받아 releases/<커밋sha>/에 풀고
current 링크를 새 릴리스로 바꾸고 pm2 재시작
/api/health 헬스체크, 실패면 링크를 되돌려 자동 롤백
성공이면 옛 릴리스를 최근 5개만 남기고 청소
매 배포마다 tarball과 함께 S3에 올라가고, SSM 명령이 "S3에서 release.sh 받아서 실행해"라고 시킴. 그래서 스크립트를 고치고 싶으면 서버에 들어갈 필요 없이 리포에서 파일을 고쳐 커밋하면 되고, 항상 그 커밋 시점의 절차서가 실행됨. 배포 절차 자체도 코드처럼 버전 관리되는 형식.
스크립트는 명령어들을 파일에 적어 한 번에 실행하는 것,
tarball은 수천 개 빌드 파일을 하나로 만 꾸러미(리눅스판 zip),
release.sh는 EC2에서 그 꾸러미를 받아 풀고 링크 교체·헬스체크·롤백까지 하는 이 프로젝트의 배포 절차서.
러너는 꾸러미와 절차서를 창고(S3)에 넣고, 서버는 절차서대로 꾸러미를 까는 분업.
4-4. SSM 무전
aws ssm send-command로 "S3에서 release.sh 받아서 실행해"라는 명령 넣음.
그런데 이 명령은 비동기라 접수증(CommandId)만 주고 바로 리턴.
그래서 워크플로가 5초 간격으로 결과를 폴링하면서 서버 stdout을 Actions 로그에 그대로 찍고, 실패면 exit 1.
이게 없으면 서버에서 배포가 터져도 Actions는 초록불인 최악의 상황이 생김.
4-5. 내려받기
S3에서 tarball을 받아 releases/<커밋sha>/ 폴더에 품. 배포마다 새 폴더가 생기고, 기존에 돌던 버전은 안건드림.
4-6. 링크 교체
pm2는 앱을 current라는 심볼릭 링크(바로가기) 경로로 실행하는데,
배포란 곧 이 링크가 가리키는 곳을 새 릴리스 폴더로 바꾸는 것뿐.
releases/
-
abc123/ ← 이전 버전 (그대로 남아 있음)
-
def456/ ← 새 버전
current → releases/def456 ← 링크만 바꿈
파일을 덮어쓰는 게 아니라 링크만 바꾸니까 교체가 순간이고, 이전 버전이 폴더째 남아 있으니 되돌리기도 링크만 다시 돌리면 됨.
pm2를 왜 쓰지?
왜 필요한가: node server.js만으로는 안 되는 이유
standalone 빌드를 풀면 node server.js로 앱이 뜸.
그런데 운영 서버에서 이렇게만 실행하면 세 가지 문제 생김.
터미널을 닫으면 죽음. SSM 세션에서 node server.js를 치면 앱이 그 세션에 매여 있어서, 세션을 끊는 순간 앱도 같이 죽음.
죽으면 그걸로 끝임. 메모리 부족이나 처리 안 된 예외로 프로세스가 죽으면, 누가 다시 띄워줄 때까지 서비스가 내려가 있음.
서버가 재부팅되면 안 뜸. EC2가 재시작되면 앱을 누군가 다시 실행해 줘야함.
pm2는 이 셋을 다 해결.
앱을 터미널과 무관한 백그라운드 프로세스로 띄우고, 죽으면 자동으로 재시작하고, pm2 startup 설정으로 서버 부팅 때도 자동으로 올라오게 함.
우리 프로젝트에서 어떻게 쓰이나?
EC2 한 대에 pm2가 프로세스 두 개를 이름 붙여 관리

release.sh가 current 링크를 바꾼 뒤 하는 "재기동"이 pm2한테 "plick-mobile 다시 띄워"라고 시키는 거.
자주 쓰는 명령은 pm2 list(상태 보기), pm2 logs plick-mobile(로그 보기), pm2 restart plick-web(재시작).
배포 검증 때 SSM 세션에서 pm2 list부터 치는 이유죠.
구조: 데몬과 클라이언트
pm2는 사실 두 부분:
데몬: 백그라운드에 상주하면서 앱들을 실제로 붙들고 있는 본체. 유저 홈의 ~/.pm2에 소켓을 만듬.
클라이언트: 내가 치는 pm2 list 같은 명령. 그 소켓으로 데몬에게 말을 거는 리모컨.
근데 데몬이 유저별임.
ubuntu의 데몬은 /home/ubuntu/.pm2에 있는데, SSM 세션은 ssm-user로 열리니까 그 상태로 pm2 list를 치면 ssm-user의 (텅 빈) 데몬을 새로 띄워서 "프로세스 없음"이 나옴.
앱이 죽은 게 아니라 다른 유저의 데몬을 보고 있는 거.
세션 열자마자 sudo su - ubuntu부터 하는 이유고, 워크플로가 sudo -u ubuntu -H로 스크립트를 돌리는 이유(-H가 HOME을 잡아줘야 소켓을 찾아요).
그리고 클라이언트는 데몬이 없으면 자기가 띄움.
편리한 기능인데, web/mobile 두 배포가 같은 순간에 들어와 둘 다 "데몬 없네, 내가 띄우자"를 하면 한쪽이 소켓을 기다리며 영원히 매달릴 수 있음. 실제로 mobile 잡이 9분 매달렸고, release.sh 첫머리의 flock(한 번에 하나만 통과하는 잠금)이 그 재발 방지.
4-7. 헬스체크와 자동 롤백
pm2로 재기동한 뒤 /api/health가 200을 줄 때까지 최대 40초 기다림.
끝내 안 뜨면 current를 직전 릴리스로 되돌리고 실패로 종료.
새 버전이 고장이어도 사용자는 이전 버전을 계속 보고, Actions에는 빨간불이 떠서 개발자가 알게 됨.
성공하면 릴리스 폴더를 최근 5개만 남기고 청소.
4-8 실제 발생 사고
root vs ubuntu. SSM 명령은 root로 실행되는데 pm2는 ubuntu 소유라, sudo -u ubuntu -H로 유저를 바꿔 실행. -H가 빠지면 pm2가 자기 데몬(~/.pm2)을 못 찾음.
set -e 없으면 가짜 초록불. SSM은 마지막 명령의 종료 코드만 봐서, 중간에 S3 다운로드가 실패해도 Success로 찍힘. 스크립트 첫 줄 set -e가 그 방지책.
pm2 동시 스폰 경합. web/mobile 두 일이 같은 순간 pm2를 건드리면 한쪽이 영원히 매달릴 수 있음. release.sh 첫머리의 flock이 릴리스를 한 번에 하나씩만 돌게 직렬화.