[서버 개발 일지 #14] 배포 계획

KimGwangmin·2026년 10월 26일

이번 글은 만든 서버를 실제로 운영하기 위한 배포 계획과, 그 첫 단계인 AWS 인프라 구축 과정을 정리한다. 운영과 배포는 이번 부트캠프에서 개념으로만 접했고 구체적인 절차를 몰랐기 때문에, 코드를 올리기에 앞서 무엇을 어떤 순서로 준비해야 하는지부터 계획으로 세웠다. 계획의 결정 사항, 코드 쪽 준비, 계정 정리부터 EC2, RDS, ECR 구축과 검증까지의 과정, 그리고 앞으로의 단계를 남긴다.

1. 배경과 목표

목표

  • 서버를 EC2에, DB를 RDS(MySQL) 에 올려 Unity 클라이언트가 인터넷으로 접속할 수 있게 한다.
  • main에 병합하면 테스트 → 빌드 → 배포가 자동으로 이루어지는 CI/CD까지 적용한다.
  • 범위 밖: 오토스케일링, 다중 서버, 무중단(blue/green) 배포, Kubernetes. 서버 1대로 시작하고, 필요해지면 확장한다.

운영 관점에서 달라지는 것

개발할 때는 내 PC에서 내가 직접 켜고 끄고, 문제가 생기면 화면을 보고 고친다. 운영은 이 전제가 모두 깨진다.

개발운영
내가 켜고 끈다아무도 지켜보지 않아도 24시간 돌아야 한다
내 PC 환경에서 돈다남의 서버에서 돈다. 환경이 다르다
비밀번호를 .env에 둬도 된다비밀값이 새면 사고다
죽으면 다시 켠다스스로 살아나야 하고, 죽었다는 것을 알아야 한다
고치고 껐다 켜면 된다사용자가 쓰는 중에 새 버전으로 교체해야 한다

그래서 운영 환경에서는 다음 질문에 답을 할 수 있어야 한다.

  1. 어디서든 똑같이 실행되는가? (환경 재현)
  2. 설정과 비밀값을 코드 밖에서 안전하게 넣을 수 있는가? (설정 분리)
  3. 서버가 정상인지 기계가 확인할 수 있는가? (헬스체크)
  4. 교체할 때 요청을 끊지 않고 내려갈 수 있는가? (정상 종료)
  5. 죽으면 자동으로 다시 뜨는가? (재시작)
  6. 문제가 생기면 원인을 볼 수 있는가? (로그)
  7. 이 모든 것을 사람 손 없이 반복할 수 있는가? (자동화)

서버의 현재 상태

항목현재 상태배포에서 알아야 할 점
런타임Java 21, Spring Boot 4.1.1, Gradle Wrapper실행 가능한 jar 하나로 빌드된다
DBMySQL 8.4, Flyway, ddl-auto=validateRDS도 8.4로 맞춘다. 스키마는 서버가 시작할 때 Flyway가 적용한다
설정DB_URL, DB_USERNAME, DB_PASSWORD, JWT_SECRET 환경변수로 덮어쓰기 가능코드 수정 없이 주입만 하면 된다
게임 콘텐츠resources/content/가 jar에 포함되고, 시작할 때 검증한다콘텐츠가 잘못되면 서버가 시작하지 않는다
CICheckstyle만 실행빌드·테스트 CI는 "후속 단계"로 남겨 둔 상태였다
헬스체크없음배포 직후 정상 기동을 확인할 수단이 필요하다

2. 결정 사항

계획을 세우면서 갈림길마다 정한 것들이다.

항목결정근거
배포 방식Docker 이미지 + ECR실행 환경이 어디서든 같고, 이미지 태그로 되돌릴 수 있다
조금 더 간단한 방법도 있는 듯했지만, 학습 목적으로 이 방식을 택했다
접속 방식GitHub OIDC + SSM (SSH 포트·장기 키 없음)열린 로그인 경로와 관리할 키를 없앤다
HTTPS도메인 + Nginx + Let's Encrypt아래 참고
배포 트리거main 푸시기본 브랜치는 dev지만 배포 기준은 main이다
리전 / 환경서울 / 운영 1개데모 및 평가 용도에는 충분하다
CPU 아키텍처x86_64아래 참고
비용월 예산 알림 + 크레딧 잔액 확인크레딧 기반의 무료 플랜 계정이다

x86을 택한 이유

학습할 때는 ARM(Graviton) 인스턴스가 동급 x86보다 성능이 좋고 시간당 약 20% 저렴하다고 배웠다. 이 서버는 순수 Java라서 ARM에서도 돌아가고, 소형 인스턴스 1대 기준으로는 월 몇 달러 차이다. 그렇지만 첫 배포는 x86으로 정했고, 근거는 다음과 같다.

  • 빌드하는 곳과 실행하는 곳의 CPU가 같으면 단순하다. GitHub Actions 기본 러너와 내 PC가 모두 x86이라 이미지를 그대로 만들어 올릴 수 있다. ARM이면 ARM 러너나 멀티 플랫폼 빌드를 CI에 추가해야 한다.
  • 아키텍처가 어긋나면 컨테이너가 exec format error로 시작조차 못 한다. 처음 구축하는 파이프라인에서 원인 찾기가 어려운 오류다.
  • Dockerfile은 두 아키텍처 모두에서 빌드할 수 있어서, 안정화한 뒤에 ARM으로 옮기는 선택지는 남아 있다.

도메인이 필요한 이유

클라이언트가 웹 주소를 입력해서 접속하는 구조가 아니니 IP로 바로 연결하면 되지 않느냐는 의문이 있었다. 기술적으로는 가능하지만 도메인을 쓰기로 했다.

  • HTTPS로 통신해야 한다. 서버는 게스트 로그인과 리프레시 토큰을 주고받으므로 토큰이 평문으로 오가면 안 된다. 또 이 게임은 모바일이 타겟이다. Android 9 이상과 iOS는 기본 설정에서 평문 HTTP 호출을 막는다.
  • HTTPS 인증서는 도메인 기준이다. IP 주소에 대한 인증서 발급도 가능은 하지만, Let's Encrypt 기준 IP 주소에 대한 인증서는 짧은 유효기간(약 6일)으로만 발급된다. 이는 도메인에 비해 IP 주소의 변동성이 크기 때문에 보안을 고려한 설계로 보인다.
  • 서버 주소를 클라이언트에 박아 두면 서버를 옮길 때 이미 배포된 클라이언트가 끊긴다. 도메인이면 DNS만 바꾸면 된다.

정식 도메인은 팀 논의 후 구매하기로 하고, 그동안은 임시 무료 도메인(DuckDNS) 으로 HTTPS까지 검증한다. 클라이언트의 서버 주소는 하드코딩되지 않는 설정값이라, 도메인을 바꿔도 설정 한 줄만 바꾸면 된다. 전환 기간에는 두 도메인을 함께 서비스한다.

3. 목표 구조

Unity 클라이언트
      │ HTTPS (443)
      ▼
[EC2: Nginx(리버스 프록시) → Docker 컨테이너(Spring Boot, 8080)]   ← public subnet
      │ MySQL (3306, 앱 서버의 보안그룹에서 오는 연결만 허용)
      ▼
[RDS MySQL 8.4]                                                    ← private subnet, 외부 접근 불가

GitHub Actions: main 푸시 → 테스트 → 이미지 빌드 → ECR 푸시 → (SSM) EC2에서 pull·교체 → 헬스체크

원칙은 세 가지다.

  • DB는 인터넷에서 직접 접근할 수 없게 하고, 앱 서버의 보안그룹에서 오는 연결만 받는다.
  • 비밀값은 이미지·저장소·로그 어디에도 넣지 않고, Parameter Store에서 실행할 때 주입한다.
  • 서버에 장기 키를 두지 않는다. 서버와 CI는 모두 임시 자격(역할)으로만 AWS를 호출한다.

4. 사전 작업: 기존 AWS 계정 정리

학습 중에 쓰던 계정(무료 크레딧이 제공되는 무료 플랜)을 그대로 쓰기로 했는데, 실습 때 만든 리소스가 남아 있었다. 새로 만들기 전에 먼저 계정을 정리했다. 이 부분은 이번 프로젝트와 직접적인 연관은 없지만, 추후 참고할 수 있도록 과정을 남겨둔다.

조사

  • 서울 이외의 리전에는 기본 VPC만 있었다(무료).
  • 서울에는 실행 중인 RDS 1개, 수동 스냅샷 1개, 직접 만든 VPC 1개(서브넷·라우팅 테이블·보안그룹·인터넷 게이트웨이 포함)와 S3 게이트웨이 엔드포인트, 이미 종료된 EC2 1개가 있었다.
  • EBS 볼륨, AMI, 탄력적 IP, S3 버킷, ECR은 없었다.
  • 크레딧 잔액은 약 US$157.

삭제 순서와 주의점

실행 중인 RDS가 가장 먼저 크레딧을 소모하므로 RDS부터 지웠다. 남길 데이터가 없어서 최종 스냅샷과 자동 백업 보존도 만들지 않았다.

  1. RDS 인스턴스를 삭제했다. "삭제 중" 상태로 몇 분 걸렸다.
  2. 수동 스냅샷을 삭제했다.
  3. VPC 엔드포인트를 삭제했다.
  4. VPC를 삭제했다. 하위 리소스 13개가 함께 지워졌다.

VPC는 RDS가 완전히 사라진 뒤에야 지울 수 있다. RDS가 VPC 안에 네트워크 인터페이스를 가지고 있기 때문이다. 그래서 RDS가 "삭제 중"인 동안에는 기다렸다가 확인한 뒤 VPC를 삭제했다. 삭제는 항목마다 삭제 직전 화면을 확인하고 진행했다.

놓쳤다가 나중에 발견한 것

IAM 사용자 s3-camp-health-user는 S3 권한만 있었고 대상 버킷이 이미 없어서 액세스 키를 비활성화한 뒤 정책과 함께 삭제했다. 그런데 이때까지 IAM 역할, RDS 서브넷 그룹, Parameter Store 파라미터는 점검 대상에 넣지 않았다. 새로 만든 역할 목록에서 이전 실습의 역할(test-ec2-ssm-readonly)이, 서브넷 그룹 목록과 Parameter Store에서 이전 실습의 서브넷 그룹과 /webcraft/prod/*가 보였다. 비용은 없지만 정리가 덜 된 상태라 미정리 항목으로 계획서에 기록해 두었다. 특히 /webcraft/prod/DB_PASSWORD는 평문 String으로 저장되어 있어서(삭제된 DB의 값이긴 하다) 지우는 것이 맞다.

정리할 때는 "컴퓨팅·네트워크·DB"만 보지 말고 IAM, 설정 저장소, 서브넷 그룹까지 목록을 넓혀야 한다는 것을 배웠다.

5. 0단계: 계정 준비

루트와 IAM 사용자

루트는 계정을 만든 이메일 로그인으로 권한에 제한이 없다. 평소 작업은 IAM 사용자로 하고 루트는 잠가 두는 것이 기본이다. 루트 자격이 유출되면 계정과 결제 전체가 위험하지만, IAM 사용자는 정책 범위와 삭제로 피해를 막을 수 있다.

신분쓰는 쪽방식이번 계획에서
IAM 사용자나(콘솔 작업)비밀번호 + MFA0~4단계
EC2 인스턴스 역할서버(EC2)역할을 인스턴스에 부여, 키 없음2단계
GitHub Actions용 역할CI/CDOIDC로 임시 자격, 키 없음6단계

사람의 신분과 기계의 신분을 나누고, 기계에는 장기 액세스 키를 주지 않는다는 것이 이 계획의 핵심이다.

한 일

  • 루트 MFA와 루트 액세스 키 없음을 확인했다(이미 되어 있었다).
  • IAM 사용자 gwangmin을 만들고 그룹(admins)에 소속시켰다. 사용자에 정책을 직접 붙이지 않고 그룹에 붙이는 편이, 나중에 조회 전용 팀원이 생겨도 viewers 그룹만 추가하면 되어 확장하기 쉽다. 콘솔 로그인과 가상 MFA를 설정했고, 액세스 키는 만들지 않았다.
  • 예산 알림은 이미 있던 월 US$30 예산(실제 85%·100%, 예상 100%)을 그대로 쓰기로 했다. 이 예산은 크레딧 차감 전 비용 기준이다.

권한은 개인 학습 계정이라 AdministratorAccess로 두었다. 실무라면 필요한 서비스만 허용하는 정책으로 줄이는 것이 맞고, 선택 과제로 남겨 두었다.

6. 1단계: 서버를 배포 가능한 상태로 만들기

AWS에 올리기 전에 코드 쪽에서 준비한 것들이다. 작업 단위로 나눠 커밋했다.

헬스체크 (Actuator)

배포한 뒤 새 서버가 정말 정상인지 기계가 물어볼 창구가 필요하다. GET /actuator/health를 추가했다.

  • 기본적으로 DB 연결까지 확인한다. DB에 붙지 못하면 503 DOWN을 반환한다.
  • show-details=never로 DB 종류, 디스크 같은 내부 정보를 숨기고, 웹에는 health 하나만 노출한다. Actuator에는 환경변수나 설정을 보여 주는 위험한 경로가 있어서다.
  • 보안 설정에서 /actuator/health/**만 토큰 없이 열었다. 배포 스크립트는 토큰이 없기 때문이다. 나머지 경로는 그대로 토큰이 필요하다.
  • 열려 있어야 하는 것과 닫혀 있어야 하는 것을 테스트로 고정했다(/actuator/health는 200이고 상세 정보가 없다. /actuator/env는 401이다).

운영 설정

  • server.shutdown=graceful, 종료 대기 20초: 배포는 곧 컨테이너를 종료하고 교체하는 것이다. 종료 신호를 받으면 처리 중인 요청을 마친 뒤 내려가서 결산 저장 같은 요청이 중간에 끊기지 않는다.
  • server.forward-headers-strategy=framework: 앞단의 Nginx가 넘기는 X-Forwarded-* 헤더로 원래 클라이언트 정보를 복원한다. 앱 포트(8080)를 외부에 직접 열지 않는 것이 전제다. 열려 있으면 헤더를 위조할 수 있기 때문이다.

Dockerfile과 .dockerignore

FROM eclipse-temurin:21-jdk AS build        # 빌드: JDK + bootJar
...
FROM eclipse-temurin:21-jre                 # 실행: JRE + jar만
RUN useradd --system ... blackhole
USER blackhole
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=70", "-XX:+ExitOnOutOfMemoryError", "-jar", "/app/app.jar"]
  • 멀티 스테이지: 빌드 도구와 소스는 실행에 필요 없고 크기와 공격 표면만 키우므로, 완성된 jar만 실행 이미지에 담는다.
  • 비루트 사용자로 실행한다.
  • 비밀값은 이미지에 넣지 않는다. DB 접속 정보와 JWT 키는 실행할 때 환경변수로 주입한다. .dockerignore로 .env가 빌드 컨텍스트에 들어가지 않게 했다.
  • MaxRAMPercentage=70은 컨테이너 메모리의 70%까지만 힙으로 쓰게 한다. ExitOnOutOfMemoryError는 메모리가 바닥나면 어정쩡하게 살아 있지 않고 즉시 죽게 해서, 재시작 정책이 다시 띄우게 한다. 반쯤 살아 있는 서버가 가장 위험하다.
  • java가 PID 1이 되도록 exec 형식을 써서, 종료 신호(SIGTERM)를 직접 받아 graceful shutdown이 동작한다.
  • bootJar의 파일명을 app.jar로 고정해서 버전이 바뀌어도 Dockerfile을 고치지 않게 했다.

로컬 검증

설정이 맞아 보여도 돌려 보기 전에는 모른다. AWS에 올리기 전에 내 PC에서 운영 환경과 비슷하게 확인했다.

  • ./gradlew build가 통과했다(테스트 361개).
  • 이미지를 빌드하고(약 585MB) 임시 MySQL에 붙여 실행하자 Flyway가 V1~V3을 적용하고 콘텐츠 276개 노드를 불러온 뒤 헬스체크가 UP을 반환했다. 이미지 안에 .env가 없고 비루트로 실행됨을 확인했다.
  • DB를 끊어 보았다. 503 DOWN이 나왔고, 다시 켜면 자동으로 UP이 됐다. 다만 DB가 끊겼을 때 응답까지 약 30초가 걸렸다(DB 연결 대기 기본값). 6단계의 배포 스크립트는 헬스체크 타임아웃을 40초 이상으로 잡아야 한다.
  • 종료 신호를 보내자 "Graceful shutdown complete" 로그와 함께 1초 만에 내려갔다.

7. 2단계: AWS 인프라 구축

콘솔에서 하나씩 직접 만들었다. 각 구성요소의 역할을 이해하려는 목적이다. 리소스 이름은 blackhole- 접두사로 통일했다.

7.1 네트워크 (VPC)

서울 리전에는 기본 VPC가 없어서 VPC를 새로 만들었다. 콘솔의 "VPC 등" 마법사로 만들었다.

항목값
범위10.0.0.0/16
가용 영역2개
서브넷퍼블릭 2 + 프라이빗 2
게이트웨이인터넷 게이트웨이 + S3 게이트웨이 엔드포인트(무료)
NAT 게이트웨이없음

NAT 게이트웨이는 시간당 요금이 커서 만들지 않았다. 앱 서버는 퍼블릭 서브넷에 두어 퍼블릭 IP로 인터넷에 나가고, 인터넷에서 직접 접근할 필요가 없는 DB만 프라이빗 서브넷에 둔다. 프라이빗 서브넷이 외부로 나갈 일이 없으므로 NAT가 필요하지 않다.

7.2 보안그룹

보안그룹은 인스턴스 앞의 가상 방화벽이다.

이름허용하는 인바운드비고
blackhole-sg-app80, 443 (전체)SSH(22)는 열지 않았다
blackhole-sg-db3306, 출처가 blackhole-sg-appIP가 아니라 보안그룹을 출처로 지정

DB의 출처를 IP 대역이 아니라 앱 서버의 보안그룹으로 지정했다. 그러면 앱 서버의 IP가 바뀌거나 서버가 늘어나도 규칙을 고칠 필요가 없고, 그 보안그룹이 붙은 인스턴스만 DB에 닿는다.

7.3 RDS

먼저 DB 서브넷 그룹을 만들었다. RDS를 어느 서브넷들에 둘지 정하는 것으로, 서로 다른 AZ의 서브넷 2개가 필요하다. 마법사가 자동으로 만드는 서브넷 그룹은 퍼블릭 서브넷까지 포함할 수 있어서, 프라이빗 서브넷 2개만 골라 직접 만들었다.

항목값
엔진MySQL 8.4.11, 단일 AZ
인스턴스db.t4g.micro (RDS는 관리형이라 앱 이미지의 CPU 아키텍처와 무관)
스토리지gp2 20GiB, 암호화
네트워크위 VPC·서브넷 그룹, 퍼블릭 액세스 끔, 보안그룹은 blackhole-sg-db만
초기 DBfeedablackhole
보호삭제 방지 켬

무료 플랜 계정이라 몇 가지가 정해져 있었다. 템플릿은 "프리 티어"와 단일 AZ로 고정되고(Aurora와 일부 구성은 선택할 수 없다), 자동 백업 보존 기간은 1일까지만 허용됐다. 계획대로 7일로 만들려다가 생성이 실패했고(The specified backup retention period exceeds the maximum available to free tier customers) 1일로 낮춰 다시 만들었다.

별도의 파라미터 그룹은 만들지 않았다. 기본 default.mysql8.4가 이미 문자셋 utf8mb4, 시간대 UTC이기 때문이다. 나중에 직접 확인해서 이 가정이 맞는 것도 확인했다.

마스터 비밀번호는 RDS가 생성 후에는 보여 주지 않으므로 20자 무작위 값을 직접 만들어 입력하고 비밀번호 관리자에 저장했다.

7.4 ECR

이미지 저장소(feedablackhole-server)를 만들었다.

  • 태그 변경 불가(immutable): 같은 태그를 덮어쓸 수 없어서, 롤백할 때 "그 태그가 그 이미지"라는 것이 보장된다.
  • 수명 주기 규칙: 태그와 무관하게 이미지가 10개를 넘으면 오래된 것부터 만료시킨다. 이미지는 쌓이면 저장 비용이 들고, 롤백용 최근 N개는 남겨야 한다.
  • "푸시 시 스캔"은 리포지토리 설정에서 사라지고 레지스트리 단위 설정으로 옮겨져 있어서 설정하지 않았다.

7.5 IAM 역할

EC2가 쓸 역할(blackhole-ec2-role)을 만들었다. 서버가 AWS를 호출할 때 키 없이 임시 자격을 받는 신분이다.

권한목적
AmazonEC2ContainerRegistryReadOnlyECR에서 이미지를 받는다
AmazonSSMManagedInstanceCoreSSM으로 서버를 관리하고 명령을 받는다
blackhole-read-parameters (직접 작성)Parameter Store의 /blackhole 경로 파라미터를 읽는다

직접 만든 정책은 읽기 동작(GetParameter, GetParameters, GetParametersByPath)을 blackhole 경로 아래 리소스로만 제한했다. 이 정책은 나중에 한 번 고쳤다(8장).

7.6 EC2

항목값이유
AMIAmazon Linux 2023 (x86_64)위의 아키텍처 결정
유형t3.small (2vCPU, 2GiB)t3.micro(1GiB)는 JVM과 Docker에 빠듯하다
네트워크퍼블릭 서브넷, 퍼블릭 IP, blackhole-sg-app
키 페어없음접속은 SSM으로만 한다
스토리지20GiB gp3, 암호화이미지 10개와 로그를 고려
인스턴스 프로파일blackhole-ec2-role
보호종료 방지, IMDSv2만 허용실수로 인한 종료 방지, 메타데이터 탈취 공격 방지
크레딧 사양표준아래 참고
사용자 데이터Docker 설치, 로그 크기 제한, 부팅 시 자동 시작
  • T3 "무제한" 크레딧 기본값을 "표준"으로 바꿨다. 무제한은 CPU를 기준선 이상으로 오래 쓰면 추가 요금이 붙는다. 크레딧이 한정된 계정에서 예상치 못한 청구를 막기 위해서다.
  • 마법사가 기본으로 만드는 보안그룹은 SSH(22)를 전체에 허용한다. 쓰지 않고 위에서 만든 blackhole-sg-app을 선택했다.
  • 사용자 데이터에는 Docker 설치와 함께 daemon.json으로 컨테이너 로그를 10MB × 3개로 제한하는 설정을 넣었다. 제한하지 않으면 로그가 디스크를 채울 수 있다.

탄력적 IP도 할당해 연결했다. 자동 할당 퍼블릭 IP는 인스턴스를 중지했다가 시작하면 바뀌지만, 탄력적 IP는 고정이다. 지금은 퍼블릭 IPv4 주소 자체에 요금이 붙어서 둘의 비용은 거의 같다. 단 인스턴스를 삭제할 때 탄력적 IP를 반납하지 않으면 계속 과금되니 정리할 때 확인해야 한다.

7.7 Parameter Store

앱이 환경변수로 받을 값을 SecureString으로 저장했다(암호화 키는 기본 aws/ssm).

이름값비고
/blackhole/DB_URLRDS 엔드포인트와 sslMode=REQUIRED연결 암호화
/blackhole/DB_USERNAME앱 전용 계정
/blackhole/DB_PASSWORD앱 전용 계정의 비밀번호직접 입력
/blackhole/JWT_SECRET32자 이상 무작위 문자열직접 입력. 로컬 .env와 다른 값

비밀값 두 개는 화면을 보고 있는 도구가 아니라 내가 직접 입력했다. 비밀번호 관리자에도 저장했다. JWT_SECRET은 바꾸면 발급된 액세스 토큰이 모두 무효가 되므로 한 번 정하면 함부로 바꾸지 않는다.

7.8 앱 전용 DB 계정

마스터 계정을 앱이 그대로 쓰면 앱이 뚫렸을 때 DB 서버 전체가 넘어간다. 그래서 마스터는 관리용으로만 쓰고, 앱 전용 계정 blackhole_app 을 따로 만들었다.

CREATE USER IF NOT EXISTS 'blackhole_app'@'10.0.%' IDENTIFIED BY '...';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, DROP, INDEX, REFERENCES
  ON feedablackhole.* TO 'blackhole_app'@'10.0.%';
  • 호스트를 10.0.%로 한정해서 VPC 안에서만 접속할 수 있다.
  • 권한은 feedablackhole 스키마에 한정했고 서버 전체 권한은 없다. Flyway가 앱 계정으로 테이블을 만들기 때문에 DML뿐 아니라 CREATE, ALTER, DROP, INDEX, REFERENCES도 포함했다. 실제로 이 권한으로 충분한지는 3단계에서 앱을 띄워 보면 드러난다.
  • 비밀번호는 Parameter Store에 넣은 값과 같은 값이어야 한다. 그래서 SQL에 사람이 다시 입력하지 않고, 서버에서 Parameter Store의 값을 읽어 변수에 담아 넣었다. 마스터 비밀번호는 read -s로 화면에 보이지 않게 받아 환경변수로만 썼다. 그래서 명령 기록에는 값이 아니라 변수 이름만 남는다.

7.9 접속과 검증

EC2에는 SSH 대신 Session Manager로 접속했다. 서버 안의 SSM 에이전트가 SSM 서비스로 먼저 연결을 맺어 두고, 브라우저가 IAM 권한으로 그 연결을 통해 셸을 얻는 방식이다. 그래서 서버에 들어오는 포트를 열 필요도 키 파일도 없고, 접속 이력은 CloudTrail에 남는다. 이 서버는 퍼블릭 서브넷에 퍼블릭 IP가 있어서 NAT 없이도 에이전트가 SSM에 닿는다.

확인한 것은 다음과 같다.

확인결과의미
docker --version, systemctl is-active docker25.0.14, active사용자 데이터가 성공했고 부팅 시 자동 시작된다
aws sts get-caller-identityassumed-role/blackhole-ec2-role서버가 액세스 키 없이 역할로 호출한다
ECR 로그인Login SucceededECR 읽기 권한과 네트워크 경로가 정상이다
파라미터 이름 조회4개정책이 적용됐다(값은 출력하지 않는다)
마스터로 접속해 계정 생성SHOW GRANTS 확인EC2 → RDS 경로(보안그룹 규칙)가 정상이고 권한 범위가 의도대로다
앱 계정으로 접속blackhole_app@10.0.%, utf8mb4, UTC, 8.4.11SSM의 비밀번호가 실제 DB 계정과 일치한다. 문자셋·시간대 기본값도 맞다

내 PC에서는 반대 방향도 확인했다. RDS 주소는 프라이빗 IP로 풀리고 3306에 접속되지 않으며, EC2의 22, 8080, 3306도 닫혀 있다. 열어 둔 것은 80/443뿐이고 아직 그 포트에서 응답하는 프로세스는 없다.

8. 겪은 문제와 배운 점

문제원인해결
RDS 생성이 실패했다무료 플랜은 자동 백업 보존이 1일까지만 허용된다1일로 낮춰 다시 생성. 보존 기간이 짧아 복구 가능 시점이 하루뿐이라는 점은 인지해 둔다
get-parameters-by-path가 거부됐다이 호출은 parameter/blackhole(경로 자체)를 리소스로 검사하는데, 정책에는 parameter/blackhole/*만 허용했다리소스에 경로 자체를 추가한 새 버전을 기본으로 지정했다. 배포 스크립트도 이 호출을 쓸 수 있어서 계획 단계에서 잡은 것이 다행이다
EC2 마법사 기본값새 보안그룹이 22번을 전체 허용하고, T3는 "무제한" 크레딧이 기본이다기존 보안그룹을 선택하고 크레딧을 "표준"으로 변경
보안그룹 출처에 이름을 입력해도 오류출처는 CIDR 또는 보안그룹 ID만 받는다sg-… ID로 검색해 목록에서 선택
IAM 정책 설명에서 오류설명에는 / 같은 문자를 쓸 수 없다허용되는 문자만 사용
세션 기록에 "종료 중"이 남았다팝업 창을 종료하지 않고 닫았다활성 세션은 없고 종료 시각도 기록되어 있어 이미 끝난 세션의 표시 문제다. 연결이 끊긴 세션도 유휴 시간이 지나면(기본 20분) 자동 종료된다
docker login 경고인증 정보가 config.json에 암호화 없이 저장된다는 안내저장되는 것은 12시간짜리 읽기 전용 ECR 토큰이다. 배포 스크립트에서 배포할 때마다 로그인하고 끝나면 로그아웃하게 한다
  • IAM은 "어느 리소스에 어느 동작"의 조합이라서, 호출마다 검사 대상 리소스가 무엇인지 확인해야 한다. 같은 파라미터 읽기라도 한 건 조회와 경로 조회는 리소스가 다르다.
  • 무료 플랜은 가격만이 아니라 선택지가 제한된다. 백업 보존, 템플릿, 인스턴스 유형 같은 항목이 정해져 있어서, 계획한 값이 그대로 들어가지 않을 수 있다.
  • 세션 내용은 어디에도 저장되지 않는다. 세션 로깅이 꺼져 있어서 터미널에 입력한 내용이 남지 않는다. 나중에 로깅을 켜게 되면 세션에서 비밀값을 입력하지 않는 습관이 중요해진다.

9. 앞으로의 계획

단계내용검증
3. 수동 배포내 PC에서 이미지를 빌드해 ECR에 올린다(커밋 SHA 같은 고유 태그). EC2에서 Parameter Store 값으로 env 파일을 만들어 컨테이너를 실행한다. 새 태그로 교체하고 이전 태그로 되돌리는 것을 손으로 해 본 뒤 deploy.sh <태그>로 정리한다컨테이너가 Flyway를 적용하고 헬스체크가 UP이다. EC2를 재부팅해도 컨테이너가 다시 뜬다. 앱 계정의 권한(DDL)이 충분한지도 여기서 확인된다
4. 도메인과 HTTPSDuckDNS 서브도메인을 탄력적 IP에 연결한다. Nginx로 443 → 8080을 프록시하고, certbot으로 인증서를 발급·자동 갱신한다. 앱 포트는 127.0.0.1에만 바인딩한다. 정식 도메인은 구매 후 같은 IP로 연결하고 두 도메인을 함께 서비스하다가 전환한다curl https://도메인/actuator/health 성공. 클라이언트의 게스트 로그인이 동작한다
5. CI 확장PR에서 ./gradlew build(테스트 포함)를 실행하는 워크플로를 추가한다. Gradle 캐시를 적용하고, main 브랜치 보호(Checkstyle·빌드 통과 필수, 리뷰 1명)를 건다테스트가 깨지는 PR은 병합이 막힌다
6. CDAWS에 GitHub OIDC 공급자와 배포용 역할(저장소·브랜치로 신뢰 범위 제한, 최소 권한)을 만든다. main 푸시 시 테스트 → 이미지 빌드 → ECR 푸시 → SSM Run Command로 EC2에서 교체 → 헬스체크(타임아웃 40초 이상) → 실패 시 이전 태그로 자동 롤백한다. 동시 실행을 막는다사소한 변경을 병합하면 사람 손 없이 배포되고, 일부러 헬스체크가 실패하게 하면 롤백된다
7. 운영 점검로그 확인법, CloudWatch 알람(EC2 상태, RDS 저장공간·연결 수), 백업 복원 시험, 운영 런북(배포·롤백·비밀값 교체·DB 접속), 비용 확인 주기복원 절차를 한 번 실제로 해 본다

함께 남은 일들이다.

  • 이전 실습에서 남은 IAM 역할, RDS 서브넷 그룹, Parameter Store 파라미터 정리
  • Flyway 마이그레이션은 되돌릴 수 없으므로, 스키마를 바꾸는 배포를 롤백할 때는 이전 버전의 앱이 새 스키마에서도 돌아야 한다. 컬럼을 지우는 변경은 두 번에 나눠 배포한다.
  • 서버 1대 구성이라 배포 중 몇 초간 접속이 끊긴다. 클라이언트의 재시도 동작은 따로 확인한다.
  • RDS 백업 보존이 1일뿐이라, 중요한 시점에는 수동 스냅샷을 남기거나 플랜을 올리는 것을 검토한다.

10. 정리

항목선택이유
배포 방식Docker 이미지 + ECR환경이 어디서든 같고 태그로 롤백한다
비밀값이미지에 넣지 않고 Parameter Store(SecureString)에서 실행할 때 주입이미지는 여러 곳에 저장되고 공유된다
접속SSH 없이 SSM Session Manager, 장기 키 없음로그인 경로와 관리할 키를 없앤다
네트워크DB는 프라이빗 서브넷, 보안그룹을 출처로 3306 허용앱 서버만 DB에 닿는다
DB 계정마스터는 관리용, 앱은 스키마 한정 계정앱이 뚫려도 서버 전체가 넘어가지 않는다
아키텍처x86빌드하는 곳과 실행하는 곳의 CPU를 맞춰 변수를 줄인다
도메인임시 DuckDNS → 정식 도메인 구매 후 전환HTTPS 인증서와 모바일 클라이언트의 HTTP 제한
  • 운영을 준비한다는 것은 코드를 올리는 것이 아니라 재현, 설정 분리, 상태 확인, 정상 종료, 재시작, 로그, 자동화의 일곱 가지 질문에 답할 수 있게 만드는 일이었다. 1단계는 그중 앞의 다섯 가지를 코드 쪽에서, 2단계는 그 코드가 올라갈 AWS 쪽을 준비한 것이다.
  • 서버와 CI에는 장기 키를 주지 않고 역할로 임시 자격만 쓴다는 원칙이 이번 구성 전체를 관통한다. 보안그룹 참조, SSM 접속, 인스턴스 역할, 비밀값 주입이 모두 이 원칙을 따른다.
  • 로컬에서 먼저 돌려 본 것(DB 끊김, 종료 신호)과 서버에서 먼저 확인한 것(앱 계정 접속, 포트 차단)처럼, 한 번도 안 해 본 상황은 미리 해 보는 것이 가장 싼 보험이었다.
  • 다음 글에서는 3단계인 첫 수동 배포를 다룬다. 이미지를 ECR에 올리고, EC2에서 실제로 컨테이너를 띄워 Flyway와 헬스체크까지 확인하는 과정이다.

0개의 댓글