과제 한 줄 요약
로컬에서만 돌던 Spring Boot 앱을 AWS에 올려 "운영 가능한 상태"로 만드는 인프라 구축형 과제.네트워크 설계 → 서버 배포 → DB 분리 → 파일 저장소 연동까지 단계별로 수행
레벨별 요약 🔽
Lv 0. 비용 관리 — AWS Budgets로 월 $100 예산 설정, 80% 도달 시 이메일 알림. 클라우드 실습의 첫 안전망.
Lv 1. 네트워크 + 배포 — VPC를 Public/Private Subnet으로 분리하고 Public Subnet에 EC2 생성.
팀원 정보 CRUD API 구현, application.yml을 local(H2)/prod(MySQL)로 분리,
INFO/ERROR 로그 전략 수립, Actuator health 노출까지.
Lv 2. DB 분리 및 보안 연결 — MySQL RDS 생성.
인바운드 규칙에 IP가 아닌 EC2의 보안 그룹 ID를 등록하는 "보안 그룹 체이닝" 적용.
DB 접속 정보는 Parameter Store에 저장하고 앱 실행 시 주입.
team-name 값을 /actuator/info로 노출.
Lv 3. 파일 저장소 + 권한 관리 — 퍼블릭 액세스가 완전히 차단된 S3 버킷 생성.
Access Key 없이 IAM Role로만 접근. MultipartFile 업로드 API와 유효기간 7일의 Presigned URL 발급 API 구현.
핵심 학습 키워드 📍
VPC 서브넷 분리 · 보안 그룹 체이닝 · Parameter Store · IAM Role 임시 자격증명 · Presigned URL · Spring Profile 분리 · Actuator · 스왑 메모리
오늘 한 것 📒
- Lv 0. AWS Budgets 예산 알림 설정
- Lv 1. VPC 설계 + EC2 배포 + Members API + Profile 분리 + Actuator
- Lv 2. RDS 생성 + 보안 그룹 체이닝 + Parameter Store 연동
- Lv 3. S3 버킷 + IAM Role + 이미지 업로드/Presigned URL 발급
배운 것 ✍️
1. 클라우드에선 "켜두는 것"만으로 돈이 나간다
과제 Lv 0이 예산 설정이었을 때 "이걸 왜 제일 먼저 하지?" 싶었는데, 리소스를 만들면서 바로 이해했습니다.
NAT 게이트웨이 → 켜두기만 해도 시간당 과금. 한 달이면 수만 원
RDS Multi-AZ → 템플릿을 "프로덕션"으로 고르면 자동으로 켜짐
스토리지 자동 조정 → 모르는 사이 용량이 늘어남
Performance Insights 고급 모드 / RDS 프록시 → 유료
Secrets Manager → 시크릿당 월 $0.40 (Parameter Store는 무료)
로컬 개발에선 서버를 켜놔도 전기세 말곤 안 나가는데, 클라우드는 "존재 자체"가 비용.
VPC 만들 때 NAT 게이트웨이를 "없음"으로 두는 것, RDS 템플릿을 "프리 티어"로 고르는 것.
이 두 개만 놓쳐도 요금이 훅 뜁니다.
2. 보안 그룹 체이닝 — IP가 아니라 "신원"으로 허용하기
이번 과제에서 제일 실무적인 개념이었던것 같아요.
RDS 인바운드 규칙에 IP 주소를 적는 대신, EC2의 보안 그룹 ID를 소스로 등록하는 방식입니다.
0.0.0.0/0 → 전 세계 아무나 DB 접근 시도 가능
특정 IP → EC2 재시작으로 IP가 바뀌면 연결 끊김
보안 그룹 ID → IP와 무관하게 "저 그룹에 속한 인스턴스"를 허용
즉 주소가 아니라 신원으로 허용하는 거예요. EC2가 재시작돼 퍼블릭 IP가 바뀌어도 규칙을 고칠 필요가 없습니다.
그리고 같은 VPC 안에 있어도 보안 그룹이 막으면 통신이 안 된다는 것! 이것 때문에 저는 한참 헤맸습니다...
3. 비밀번호를 코드에 두지 않는 법 — Parameter Store
DB 접속 정보를 AWS Systems Manager Parameter Store에 저장했습니다.
/sparta/spring.datasource.url → String
/sparta/spring.datasource.username → String
/sparta/spring.datasource.password → SecureString (KMS 암호화)
/sparta/team-name → String
비밀번호만 SecureString으로 지정하면 KMS 키로 암호화돼서 저장되고,
조회할 때도 --with-decryption 옵션을 줘야 실제 값이 나와요.
무엇보다 좋았던 건 git에 올릴 파일에 비밀번호가 아예 존재하지 않는다는 점이었어요.
예전 과제에서 .gitignore로 application.properties를 막았던 게 "실수로 올리지 않기"였다면,
이건 애초에 올릴 게 없는 구조라 훨씬 마음이 편했습니다.
4. IAM Role — Access Key 없이 인증하기
EC2에 IAM Role을 붙이면 코드 어디에도 Access Key를 넣지 않고 AWS 서비스에 접근할 수 있습니다.
@Bean
public S3Client s3Client() {
return S3Client.builder()
.region(Region.AP_NORTHEAST_2)
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
}
DefaultCredentialsProvider가 EC2 메타데이터 서비스에서 임시 자격증명을 자동으로 받아와요.
직접 확인해보면 이렇게 나옵니다.
$ curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
aws-deploy-ec2-role
키를 저장하지 않으니 유출될 것도 없고, 자격증명이 주기적으로 자동 교체됩니다.
처음엔 "키도 없는데 어떻게 인증이 되지?" 싶었는데,
EC2가 자기 신원을 증명하고 임시 키를 받아오는 구조라는 걸 알고 나니 납득이 됐습니다.
5. Presigned URL의 유효기간은 "설정값"이 전부가 아니다
S3 버킷을 "모든 퍼블릭 액세스 차단"으로 만들었으니, 이미지를 받으려면 서명된 임시 URL이 필요합니다.
GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofDays(7))
.getObjectRequest(getObjectRequest)
.build();
발급된 URL을 보니 X-Amz-Expires=604800, 정확히 7일이었어요.
여기까진 좋았는데 URL에 X-Amz-Security-Token이 붙어 있는 게 눈에 띄었습니다.
AWS 문서를 찾아보니, 임시 자격증명으로 만든 Presigned URL은 설정한 만료 시간과 무관하게
그 자격증명이 만료될 때 함께 만료된다고 하더라구요.
IAM Role은 임시 자격증명을 쓰기 때문에 실질 유효기간이 최대 12시간 정도입니다.
7일을 온전히 보장하려면 IAM User의 장기 Access Key로 서명해야 하는데,
그건 "Access Key를 쓰지 마라"는 원칙과 정면으로 충돌하죠.
발제에서도 "IAM Role로 진행한 경우 URL 대신 접근 성공 스크린샷을 제출하라"고 안내되어 있었는데,
왜 그런 예외 조항이 있는지 직접 겪고 나서야 이해를 했네요.
보안과 편의의 트레이드오프를 코드 레벨에서 만난 첫 사례였습니다.
트러블슈팅 💥
1) Spring Boot 4.1 vs Spring Cloud AWS — 라이브러리가 못 따라온 경우
상황:
Parameter Store를 정석대로 연동하려고 spring-cloud-aws-starter-parameter-store 의존성을 넣고 spring.config.import를 설정했는데, 앱이 아예 뜨지를 않더라구요.....
APPLICATION FAILED TO START
The following method did not exist:
ConfigDataLocationResolverContext.getBootstrapContext()
The calling method's class,
io.awspring.cloud.autoconfigure.config.AbstractAwsConfigDataLocationResolver
원인:
Spring Boot 4.1에서 제거된 메서드를 Spring Cloud AWS 3.4가 호출하고 있었습니다.
제 코드 문제가 아니라 라이브러리가 아직 Spring Boot 4를 지원하지 못하는 상태였음...ㅜㅜㅜ
해결:
라이브러리를 걷어내고, 셸 스크립트가 값을 읽어 환경변수로 넘기는 방식으로 바꿨습니다.
#!/bin/bash
REGION=ap-northeast-2
export DB_URL=$(aws ssm get-parameter \
--name /sparta/spring.datasource.url \
--region $REGION --query Parameter.Value --output text)
export DB_PASSWORD=$(aws ssm get-parameter \
--name /sparta/spring.datasource.password \
--with-decryption \
--region $REGION --query Parameter.Value --output text)
nohup java -jar -Dspring.profiles.active=prod build/libs/app.jar > app.log 2>&1 &
애플리케이션 설정에서는 플레이스홀더로 받았습니다.
spring:
datasource:
url: ${DB_URL}
password: ${DB_PASSWORD}
배운 점:
막혔을 때 요구사항의 본질이 뭐였는지 다시 짚어본 게 도움이 됐어요.
"특정 라이브러리로 읽어라"가 아니라 "비밀번호를 코드에 두지 마라"였으니까, 방법은 바뀌어도 목적은 그대로였습니다.
최신 버전이 항상 정답은 아니라는 것도 배웠습니다.
덤으로 Parameter Store가 실제로 어떻게 동작하는지 더 잘 이해하게 된 계기가 된거같더라구요.
2) Communications link failure — 같은 VPC인데 왜 안 되지?
상황:
prod 프로필로 실행했는데 RDS 연결에서 타임아웃이 났어요.
com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure
Caused by: java.net.SocketTimeoutException: Connect timed out
Parameter Store에서 URL은 제대로 읽어왔고(MySQL 접속을 시도한 걸 보면), EC2와 RDS는 같은 VPC 안에 있었습니다.
원인:
RDS 보안 그룹 인바운드에 EC2 보안 그룹 규칙을 추가하지 않은 상태였어요.
같은 VPC 안에 있어도 보안 그룹이 막으면 통신이 안 됩니다.
해결:
RDS 보안 그룹에 MySQL/Aurora(3306) 규칙을 추가하고, 소스에 EC2 보안 그룹 ID를 지정했습니다.
앱을 띄우지 않고 네트워크만 확인하고 싶다면 이 방법이 빨라요.
timeout 5 bash -c 'cat < /dev/null > /dev/tcp/{RDS엔드포인트}/3306' && echo "연결 성공" || echo "연결 실패"
배운 점:
"같은 네트워크니까 되겠지"라고 넘어간 게 실수였어요.
VPC는 "같은 건물 안"이고 보안 그룹은 "각 방의 문"이라, 건물이 같아도 문이 잠겨 있으면 못 들어가는 거였습니다.
그리고 앱 로그로 디버깅하기 전에 네트워크 레이어를 따로 검증하는 방법을 알아두니 원인 범위를 훨씬 빨리 좁힐 수 있었어요.
3) 기존 보안 그룹 규칙의 소스를 바꾸려다 막힘
상황:
인바운드 규칙에 이미 내 IP가 등록되어 있길래, 그 행의 소스만 보안 그룹 ID로 바꾸려고 했더니 에러가 났어요.
기존 IPv4 CIDR 규칙에 참조된 그룹 ID를 지정할 수 없습니다
원인:
규칙 하나당 소스 타입(CIDR/보안그룹)이 고정이라, 기존 CIDR 규칙을 다른 타입으로 변경할 수 없습니다.
해결:
기존 행은 그대로 두고 "규칙 추가"로 새 행을 만들어 보안 그룹 ID를 지정했어요.
결과적으로 인바운드 규칙이 두 개가 됐는데, 오히려 이게 맞는 구성이었습니다.
sg-xxxxx (EC2 보안그룹) → EC2 → RDS 연결용
112.171.x.x/32 (내 IP) → 로컬 PC 접속 테스트용
배운 점:
과제 요구사항이 "0.0.0.0/0이 아닐 것"이었지 "IP가 하나도 없을 것"은 아니었어요.
Lv 2에 "로컬에서 접속 테스트가 가능해야 함"이라는 조건이 따로 있었으니,
두 규칙이 공존하는 게 오히려 요구사항을 다 만족하는 구성이더라고요.
4) MySQL 8.0이 지원 종료됐다는 걸 RDS 생성 실패로 알게 됨
상황:
"8.4는 인증 방식이 바뀌어서 삽질할 수 있다"는 얘기를 듣고 8.0을 골랐는데, 생성 요청이 실패했어요.
mysql-8.0.46 reached RDS end of standard support on Jul 31, 2026
and requires engine lifecycle support: open-source-rds-extended-support
원인:
MySQL 8.0이 2026년 7월 31일부로 RDS 표준 지원이 종료됐습니다. 계속 쓰려면 유료 확장 지원을 활성화해야 해요.
해결:
엔진 버전을 8.4로 올렸습니다. 걱정했던 인증 방식(caching_sha2_password) 문제는 최신 MySQL Connector/J가 지원해서 아무 일도 없었어요.
배운 점:
"안정적인 구버전"이라는 통념이 항상 맞는 게 아니더라고요. 지원 종료일이 지나면 오히려 구버전이 유료가 됩니다.
그리고 호환성 걱정은 실제로 부딪혀보기 전엔 알 수 없다는 것도요. 미리 겁먹고 8.0을 고른 게 오히려 돌아가는 길이었습니다.
5) 파라미터 이름에 aws를 쓸 수 없다
상황:
Parameter Store에 /aws-deploy/spring.datasource.url로 값을 저장하려 했더니 거부당했어요.
No access to reserved parameter name: aws-deploy/spring.datasource.url
원인:
aws, ssm으로 시작하는 경로는 AWS 내부 예약어입니다.
해결:
접두사를 /sparta/로 바꿨습니다. IAM 정책이 경로 무관하게 읽기 권한을 주고 있어서 다른 설정은 손댈 필요 없었어요.
배운 점:
프로젝트 이름을 그대로 네임스페이스로 쓰려다 막힌 케이스였어요.
AWS 리소스 이름을 정할 땐 예약어를 한 번 확인해보는 게 좋겠습니다.
6) t3.micro에서 Gradle 빌드가 죽는 문제
상황:
EC2에서 ./gradlew build를 돌리면 진행 중에 프로세스가 그냥 사라졌어요.
원인:
t3.micro의 RAM이 1GB뿐이라 Gradle 데몬이 메모리 부족으로 강제 종료된 거였습니다.
해결:
스왑 메모리 2GB를 잡아줬어요.
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
마지막 줄은 재부팅해도 스왑이 유지되게 등록하는 거예요. 이후 빌드 시간이 1분대로 안정화됐습니다.
배운 점:
로컬에선 신경 쓸 일이 없던 메모리 제약을 처음 체감했어요.
프리티어 사양에서 돌리려면 "빌드는 로컬에서 하고 jar만 올리기" 같은 전략도 고려할 만하겠더라고요.
7) git reset 후 실행 권한이 사라짐
상황:
EC2에서 git pull이 충돌나서 git reset --hard로 정리했더니, 이번엔 빌드가 안 됐어요.
$ ./gradlew clean build
-bash: ./gradlew: Permission denied
원인:
Git은 파일의 실행 권한을 완벽히 보존하지 않습니다. reset 과정에서 gradlew의 실행 비트가 날아간 거예요.
해결:
chmod +x gradlew start.sh
배운 점:
Windows에서 만든 파일을 Linux로 옮길 때 자주 생기는 문제라고 해요.
start.sh처럼 직접 만든 스크립트도 git을 거치면 권한이 날아갈 수 있으니, pull 후엔 chmod를 습관처럼 해주는 게 좋겠습니다.
8) 로컬 터미널과 EC2 터미널을 헷갈림
상황:
EC2에 접속된 창에서 로컬용 명령을 실행하거나, 반대로 로컬에서 start.sh를 실행하는 실수를 몇 번 했어요.
$ ./start.sh
./start.sh: line 4: aws: command not found
Exception in thread "main" java.lang.UnsupportedClassVersionError:
class file version 61.0, this version only recognizes up to 52.0
로컬엔 aws CLI도 없고 JDK도 8이라 이런 에러가 났습니다.
해결:
프롬프트로 구분하는 습관을 들였어요.
user@pc1 MINGW64 ... → 내 컴퓨터
ubuntu@ip-10-0-0-84:... → EC2 서버
배운 점:
사소해 보이지만 이것 때문에 "왜 안 되지?" 하며 날린 시간이 꽤 됐어요.
창을 여러 개 띄우고 작업할 땐 창 제목이나 프롬프트를 먼저 확인하는 게 결국 빠른 길이었습니다.
느낀 점
이번 과제는 코드를 짜는 시간보다 인프라를 설정하는 시간이 훨씬 길었어요....
Members API 자체는 예전에 해본 CRUD였는데, 그걸 "AWS 위에서 돌아가게" 만드는 게 진짜 과제였습니다.
가장 크게 느낀 건 에러 메시지가 원인을 직접 알려주지 않는다는 점이었어요.
Communications link failure는 코드가 아니라 보안 그룹 문제였고,
NoSuchMethodError는 제 실수가 아니라 라이브러리 버전 문제였습니다.
지난번 과제에서 "에러 로그는 가장 안쪽 Caused by부터 읽어야 한다"고 배웠는데,
이번엔 거기에 더해 "에러가 난 레이어와 원인이 있는 레이어가 다를 수 있다"는 걸 배웠어요.
그리고 인프라는 눈에 안 보이는 연결이 정말 많더라고요.
VPC, 서브넷, 라우팅 테이블, 보안 그룹, IAM Role...
하나만 어긋나도 전체가 멈추는데, 어디가 어긋났는지는 안 알려줍니다.
그래서 "네트워크만 따로 테스트해보기", "Parameter Store만 CLI로 먼저 확인해보기" 같은 레이어별 검증이 결국 제일 빠른 디버깅이었어요.
Presigned URL의 7일 제약처럼 보안과 편의가 충돌하는 지점을 직접 만난 것도 인상 깊었습니다.
"7일로 설정했는데 왜 12시간이지?"에서 시작해서 임시 자격증명의 동작 원리까지 파고들게 됐는데, 이런 게 문서만 읽어서는 안 남는 종류의 학습인 것 같아요.
담엔 Docker랑 CI/CD도 붙여보고 싶네요...! 지금은 배포할 때마다 SSH 접속해서 pull하고 build하고 있는데, 이게 자동으로 되면 진짜 편할 듯요 :)
'개인과제' 카테고리의 다른 글
| 개인과제 : Spring Advanced 과제 (에러 분석 / 코드 개선 / N+1 / 테스트 코드) (0) | 2026.07.27 |
|---|---|
| 개인과제 : CommercePJT (6인 팀프로젝트) (0) | 2026.07.23 |
| 개인과제 : CH 3 입문 Spring Boot 일정 관리 API 서버 구축 및 핵심 로직 분석 (0) | 2026.07.01 |
| 개인과제 : [git & IDEA] CH 2 계산기 과제 PART1. (0) | 2026.06.12 |
| 개인과제 : [git & github] 온보딩 주차 - 협업을 위한 git 활용 PART.2 (0) | 2026.06.12 |