From 2ca2cd1f66a6139c1ebce4a228d102639a6b584d Mon Sep 17 00:00:00 2001 From: chjpooh2002 Date: Wed, 11 Nov 2026 09:47:52 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20=EB=B0=B0=ED=8F=AC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/deploy-design.md | 339 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 docs/deploy-design.md diff --git a/docs/deploy-design.md b/docs/deploy-design.md new file mode 100644 index 0000000..9852043 --- /dev/null +++ b/docs/deploy-design.md @@ -0,0 +1,339 @@ +# 배포 파이프라인 설계 + +스닙텍 백엔드를 실제 사용자에게 내보내기 위한 배포 구조와 그 근거를 정리한다. 각 결정은 **우리 상황 → 선택지 → 고른 것 → 대가** 순서로 적는다. + +- 작성일: 2026-11-11 +- 범위: S1(대시보드) 출시용 운영 서버 1대 +- 구현 이슈: #88 ~ #96 (마지막 절 참고) +- 비용 숫자는 모두 **추정치**다. 실제 값은 구축 시점의 AWS 가격표로 다시 확인한다. + +## 1. 전제 + +| 항목 | 내용 | +| --- | --- | +| 서비스 | 스프링 부트 4.1(자바 21) API 서버 1개, MySQL 8.4, 플라이웨이 | +| 사용자 규모 | 출시 직후 소수. 트래픽보다 **월 비용·운영 부담**이 더 큰 제약 | +| 개발 인원 | 소수. 운영 전담 없음 | +| AWS 계정 | 무료 플랜. **2027-01-17에 닫힘** → 그 전에 유료 전환하거나 새 계정으로 옮겨야 함 | +| 출시 목표 | 2027-01-17 이전 | +| 도메인 | `sniptext.kr`(가비아에서 구매). 네임서버는 Route 53으로 위임. API는 `api.sniptext.kr` | +| 저장소 | `SnipText-Developer/backend`, **공개 저장소** | +| 확정 사항 | 도커 이미지 → GHCR → EC2 한 대(docker compose), 서버 1대(개발 서버 없음), 서울 리전, OIDC + SSM 명령 실행(장기 키·SSH 금지), 비밀값은 SSM 파라미터 저장소, HTTPS는 서버 안 Caddy | + +## 2. 전체 구조 + +```mermaid +flowchart LR + dev[개발자] -->|PR| gh[GitHub
SnipText-Developer/backend] + gh -->|PR 검사| ci[CI
빌드·테스트컨테이너·이미지 빌드 확인] + gh -->|main 푸시| cd[배포 워크플로
환경 prod] + cd -->|이미지 푸시
태그=커밋 해시| ghcr[(GHCR
공개 패키지)] + cd -->|OIDC 임시 권한| iam[IAM 배포 역할] + iam -->|배포 파일 업로드| s3d[(S3 배포 버킷)] + iam -->|SendCommand| ssm[SSM 명령 실행] + + subgraph aws[AWS 서울 리전 · 공개 서브넷, NAT 없음] + subgraph ec2[EC2 t4g.small · 고정 IP] + caddy[Caddy
80·443, 인증서 자동] --> app[앱 컨테이너
127.0.0.1:8080] + deploy[deploy.sh] + end + rds[(RDS MySQL 8.4
db.t4g.micro · 비공개)] + param[(SSM 파라미터 저장소
/sniptext/prod/*)] + end + + ssm --> deploy + deploy -->|받기| s3d + deploy -->|이미지 받기| ghcr + deploy -->|환경 변수 읽기| param + app -->|3306, EC2 보안 그룹만| rds + user[사용자·프론트] -->|https://api.sniptext.kr| r53[Route 53
sniptext.kr] --> caddy +``` + +## 3. 배포 흐름 + +```mermaid +sequenceDiagram + autonumber + participant D as 개발자 + participant GH as GitHub Actions + participant R as GHCR + participant A as AWS(OIDC·SSM·S3) + participant S as EC2 deploy.sh + participant App as 앱 컨테이너 + + D->>GH: PR (develop 또는 main 대상) + GH->>GH: ./gradlew build (테스트컨테이너 MySQL 8.4로 전체 마이그레이션 검증) + 이미지 빌드 확인 + D->>GH: develop → main 병합 + GH->>R: arm64 이미지 빌드·푸시 (태그 = 커밋 해시) + GH->>A: OIDC 토큰으로 배포 역할 위임 + GH->>A: deploy/ 파일을 S3 <커밋 해시>/ 에 업로드 + GH->>A: SSM SendCommand (운영 인스턴스 태그 대상) + A->>S: 배포 파일 받기 → deploy.sh <커밋 해시> + S->>A: /sniptext/prod/* 읽어 .env 작성 (권한 600) + S->>R: docker compose pull + S->>App: up -d app (기존 컨테이너 교체, 20~40초 끊김) + loop 최대 180초 + S->>App: GET 127.0.0.1:8080/actuator/health + end + alt 정상 + S-->>GH: 성공 (현재 태그 기록) + GH->>GH: https://api.sniptext.kr/actuator/health 바깥 확인 + else 실패 + S->>App: previous_tag 로 다시 띄움 + S-->>GH: 실패 코드 → 워크플로 실패 + end +``` + +- **되돌리기**: 자동(상태 확인 실패 시 이전 태그)과 수동(배포 워크플로를 이전 커밋 해시로 다시 실행) 두 가지다. `latest`로는 배포하지 않는다. 어느 커밋이 서버에 있는지 항상 태그로 알 수 있어야 하기 때문이다. +- **브랜치**: `develop`은 통합 브랜치(배포 없음), `main`이 배포 브랜치다. `main`은 첫 배포 때 만든다. + +## 4. 결정 기록 + +### 4-1. 서버: EC2 한 대 + docker compose + +- **상황**: 사용자 수가 적고 비용이 가장 큰 제약. 운영 전담이 없다. +- **선택지**: EC2 + compose / ECS Fargate / Elastic Beanstalk / 쿠버네티스 +- **고른 것**: EC2 **t4g.small**(ARM, 2GB) 한 대, Amazon Linux 2023, 공개 서브넷 + 고정 IP. 인스턴스 크기는 변수. +- **대가**: 서버가 죽으면 서비스가 멈춘다(자동 복구는 EC2 상태 확인 재시작 수준). 배포 중 잠깐 끊긴다. ARM이라 이미지를 arm64로 만들어야 한다 → Dockerfile 빌드 단계는 `--platform=$BUILDPLATFORM`으로 실행기에서 그대로 빌드하고 실행 단계만 arm64로 만든다(자바 바이트코드는 아키텍처와 무관). 공개 저장소라 GitHub의 ARM 실행기도 무료로 쓸 수 있다. +- t3.small(x86) 대비 같은 메모리에서 약 20% 싸다. + +### 4-2. 이미지 저장소: GHCR 공개 패키지 + +- **상황**: 서버가 이미지를 받으려면 저장소 인증이 필요하다. 인스턴스 역할(IAM)로는 GHCR에 로그인할 수 없고, 개인 접근 토큰을 서버에 두면 "장기 키 금지"에 어긋난다. +- **선택지**: GHCR 비공개 + 서버에 토큰 / GHCR 공개 / ECR(인스턴스 역할로 받기) +- **고른 것**: **GHCR 공개 패키지.** 저장소가 이미 공개이고, 이미지에는 비밀값이 없으며(실행 시 주입), 서버는 자격 증명 없이 받는다. +- **대가**: 저장소를 비공개로 바꾸면 ECR로 옮겨야 한다(배포 역할에 ECR 푸시 권한, 인스턴스 역할에 받기 권한 추가로 끝난다). + +### 4-3. 서버 명령: OIDC + SSM 명령 실행 + +- **상황**: 22번 포트와 장기 접근 키를 쓰지 않기로 했다. +- **선택지**: SSH 배포 / CodeDeploy / SSM 명령 실행 +- **고른 것**: GitHub Actions가 OIDC로 배포 역할을 위임받아 `aws ssm send-command`로 `deploy.sh <태그>`를 실행한다. 역할 신뢰 조건은 `repo:SnipText-Developer/backend:environment:prod`로 좁히고, 권한은 운영 인스턴스(태그로 한정) 명령 실행·결과 조회·배포 버킷 쓰기뿐이다. +- **대가**: SSH보다 결과 확인이 번거롭다(명령 완료를 기다리고 출력을 가져오는 단계가 필요). 출력 크기 제한이 있어 긴 로그는 서버에서 따로 본다. + +### 4-4. 배포 파일 전달: S3 배포 버킷 + +- **상황**: compose 파일·Caddyfile·`deploy.sh`도 코드처럼 리뷰받고 바뀌어야 한다. +- **선택지**: user_data에 포함 / 서버에서 git clone / SSM 명령 인자에 직접 / S3 버킷 +- **고른 것**: 워크플로가 `deploy/`를 S3 `<커밋 해시>/`에 올리고 서버가 받는다. 이미지 태그와 설정이 같은 커밋으로 묶인다. +- **대가**: 버킷이 하나 늘어난다(비용은 사실상 0). user_data에 넣으면 바꿀 때마다 인스턴스를 교체해야 하고, git clone은 서버에 저장소 접근이 필요하며, 명령 인자는 크기 제한이 있다. + +### 4-5. DB: RDS db.t4g.micro + +- **상황**: 개발 서버가 없어 마이그레이션 실수가 곧 운영 데이터 문제다. 서버 메모리는 2GB뿐이다. +- **선택지**: RDS / EC2 안 도커 MySQL +- **고른 것**: **RDS MySQL 8.4 db.t4g.micro**, 단일 AZ, gp3 20GB 암호화, 공개 접근 끔(EC2 보안 그룹에서만 3306), 삭제 방지 + 최종 스냅샷, 자동 백업. +- **대가**: 월 약 $20 추가(추정). 다중 AZ가 아니라 장애 조치는 없다. 로컬에서 접속하려면 SSM 포트 포워딩을 거쳐야 한다. 대신 시점 복구·스냅샷이 공짜에 가깝게 따라오고, 앱과 DB가 메모리를 나눠 쓰지 않는다. +- 처음에는 마스터 계정으로 접속한다(앱 전용 계정은 결정 필요 항목). + +### 4-6. 비밀값: SSM 파라미터 저장소, 상태 파일에 남기지 않기 + +- **상황**: 테라폼 상태 파일에는 리소스 속성이 평문으로 저장된다. 비밀값이 들어가면 상태 버킷 접근만으로 키가 샌다. +- **선택지**: 테라폼 변수로 값 넣기 / Secrets Manager / 파라미터 저장소 + 사람이 값 입력 / 쓰기 전용 인자 +- **고른 것** + - DB 비밀번호: 테라폼 **임시(ephemeral) 난수 → RDS `password_wo`, SSM `value_wo`** 쓰기 전용 인자. 상태 파일에 값이 남지 않는다. 바꿀 때는 버전 숫자를 올린다. + - 그 밖의 비밀(JWT 서명 키, 본문 암호화 키, 구글 클라이언트 비밀, AI 키): 테라폼은 SecureString 자리를 `CHANGE_ME`로 만들고 `ignore_changes = [value]`. 실제 값은 사람이 `aws ssm put-parameter --overwrite`로 넣는다. `deploy.sh`는 `CHANGE_ME`가 남아 있으면 기동하지 않는다. + - 비밀이 아닌 설정은 테라폼이 값까지 넣는다. +- **대가**: 쓰기 전용 인자는 테라폼 1.11 이상, AWS 공급자 6.x가 필요하다. 사람 손이 한 번 들어간다. RDS 관리형 마스터 비밀번호(Secrets Manager)는 7일마다 자동 교체되어 실행 중인 앱이 접속을 잃을 수 있고 비밀 하나당 월 $0.4가 붙어 고르지 않았다. + +### 4-7. 테라폼 상태 저장소 + +- **고른 것**: `infra/terraform/bootstrap/`이 S3 상태 버킷(버전 관리, 암호화, 공개 차단, 삭제 방지)을 만든다. 잠금은 S3 네이티브 잠금(`use_lockfile`)으로 하고 DynamoDB 테이블은 만들지 않는다. 버킷 이름에 계정 ID를 붙여 계정 이전 시 겹치지 않게 한다. +- **대가**: bootstrap 자체의 상태는 로컬에 둔다(커밋하지 않음). 잃어버리면 import로 되살린다. +- 모든 리소스에 `project=sniptext`, `env=prod`, `managed-by=terraform` 태그를 공급자 `default_tags`로 붙인다. 테라폼·공급자·액션 버전은 고정한다. +- 변경 흐름: `fmt` → `validate` → `plan` 요약 보고 → 확인 뒤 `apply`. 삭제·교체가 있는 plan은 따로 강조하고 멈춘다. + +### 4-8. 네트워크 + +- **고른 것**: VPC 1개, 공개 서브넷 2개(가용 영역 2개, RDS 서브넷 그룹 요건), 인터넷 게이트웨이. **NAT 게이트웨이 없음.** EC2 보안 그룹은 80·443만 열고, RDS 보안 그룹은 EC2 보안 그룹에서 오는 3306만 연다. +- **대가**: EC2가 공개 서브넷에 있다. 대신 열린 포트가 80·443뿐이고 앱 포트는 `127.0.0.1`에만 묶는다. NAT 게이트웨이는 월 $40 이상이라 이 규모에서 비용 대비 이득이 없다. +- RDS는 공개 서브넷에 두되 공개 접근을 끄고 보안 그룹으로 막는다. + +### 4-9. HTTPS: Caddy + +- **선택지**: ALB + ACM 인증서 / 서버 안 nginx + certbot / 서버 안 Caddy +- **고른 것**: Caddy가 `api.sniptext.kr` 인증서를 자동 발급·갱신하고 앱으로 넘긴다. +- **대가**: 인증서 발급 전에 DNS가 맞아 있어야 한다(가비아 → Route 53 네임서버 위임이 선행). 인증서 저장 볼륨을 잃으면 다시 발급받는다(발급 횟수 제한에 주의). +- 앱은 프록시 뒤에 있으므로 `server.forward-headers-strategy`를 설정해 https 여부를 올바르게 판단하게 한다(재발급 토큰 `Secure` 쿠키에 필요). + +### 4-10. 메모리 + +- t4g.small 2GB에 Caddy(약 50MB)와 앱이 함께 뜬다. +- 앱 컨테이너 `mem_limit` 약 1.2GB, `JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75`로 힙을 컨테이너 한도 기준으로 잡는다. +- user_data에서 스왑 2GB를 만든다. 스왑을 쓰기 시작하면 느려지므로, 지속적으로 쓰이면 인스턴스 크기 변수를 올린다. + +### 4-11. 배포 중 끊김 + +- **상황**: 사용자 수가 적고 출시 일정이 우선이다. +- **고른 것**: 컨테이너 교체 방식. 새 버전이 뜨는 20~40초 동안 503이 날 수 있다. 상태 확인에 실패하면 이전 태그로 되돌린다. +- **대가**: 배포마다 짧은 끊김. 무중단(구·신 컨테이너 교대)은 #96에서 다룬다. + +### 4-12. 되돌리기와 마이그레이션 + +- 이미지는 되돌릴 수 있지만 **DB 스키마는 되돌아가지 않는다.** +- 플라이웨이는 기본 설정에서 "이 코드가 모르는 더 새로운 마이그레이션"을 무시하므로 이전 이미지도 뜬다. 다만 `ddl-auto: validate`가 있어 이전 코드가 기대하는 컬럼이 사라졌으면 기동에 실패한다. +- 그래서 규칙: **한 번의 배포에서는 추가만 한다.** 컬럼 삭제·이름 변경은 "추가 → 옮김 → 삭제"를 여러 번의 배포로 나눈다(#94에서 CLAUDE.md에 반영, #96에서 구체화). +- 배포 직전 RDS 스냅샷(#95)이 마지막 안전장치다. + +### 4-13. 승인 + +- 배포 작업은 GitHub 환경 `prod`에서 돈다(배포 브랜치는 `main`만). 승인자를 둘지는 결정 필요 항목이다. 두면 배포마다 클릭 한 번이 늘고, 대신 실수로 병합된 변경이 바로 나가지 않는다. + +## 5. 환경 변수·비밀값 목록 + +모두 `/sniptext/prod/<이름>`에 두고, `deploy.sh`가 서버의 `/opt/sniptext/.env`(권한 600)로 옮겨 앱 컨테이너에 넣는다. + +| 이름 | 쓰는 프로필 | 종류 | 누가 넣나 | 비고 | +| --- | --- | --- | --- | --- | +| `SPRING_PROFILES_ACTIVE` | 전체 | 일반 | 테라폼 | 출시 전 `dev`, 출시 후 `prod` | +| `DB_URL` | dev·prod | 일반 | 테라폼 | 출시 전 `sniptext_dev` 스키마, 출시 후 `sniptext` | +| `DB_USERNAME` | dev·prod | 일반 | 테라폼 | | +| `DB_PASSWORD` | dev·prod | SecureString | 테라폼(쓰기 전용 인자) | 상태 파일에 남지 않음 | +| `JWT_SECRET` | 전체 | SecureString | 사람 | 32바이트 이상. 출시 전·후 값 다르게 | +| `CONTENT_ENCRYPTION_KEY` | 전체 | SecureString | 사람 | Base64 32바이트. **잃으면 본문 복구 불가** → 따로 안전하게 보관. 출시 전·후 값 다르게 | +| `GOOGLE_CLIENT_ID` | 전체 | 일반 | 테라폼(변수) | | +| `GOOGLE_CLIENT_SECRET` | 전체 | SecureString | 사람 | | +| `GOOGLE_REDIRECT_URIS` | 전체 | 일반 | 테라폼(변수) | 프론트 주소 확정 필요 | +| `AI_PROVIDER` | 전체 | 일반 | 테라폼 | prod에서는 `fake` 불가 | +| `AI_MODEL` | 전체 | 일반 | 테라폼(변수) | | +| `AI_API_KEY` | 전체 | SecureString | 사람 | | + +워크플로·서버 쪽 값: + +| 이름 | 위치 | 비고 | +| --- | --- | --- | +| `APP_TAG` | `deploy.sh`가 `.env`에 씀 | 커밋 해시 | +| `API_DOMAIN` | 테라폼 → 파라미터 | Caddyfile에서 사용, `api.sniptext.kr` | +| 배포 역할 ARN, 리전, 배포 버킷 이름 | GitHub 환경 변수(비밀 아님) | 테라폼 출력값 | +| 디스코드 웹훅 | GitHub 환경 비밀값 | #95 | +| 예산 경보 이메일 | bootstrap 실행 시 `TF_VAR_budget_email` | 파일에 쓰지 않음 | + +## 6. 비용 추정 (서울 리전, 월, 추정치) + +| 항목 | 월 추정 | +| --- | --- | +| EC2 t4g.small (상시) | 약 $15 | +| EBS gp3 20GB | 약 $2 | +| 공인 IPv4(고정 IP) | 약 $3.7 | +| RDS db.t4g.micro 단일 AZ | 약 $18 | +| RDS 저장소 gp3 20GB (자동 백업은 저장소 크기만큼 무료) | 약 $2.6 | +| Route 53 호스팅 영역 1개 + 조회 | 약 $0.5 | +| S3(상태·배포 파일), SSM 표준 파라미터, Budgets, GHCR 공개 | 약 $0 | +| 데이터 전송(월 100GB까지 무료) | 약 $0 | +| **합계** | **약 $42** | + +- 무료 플랜 기간에는 크레딧에서 차감된다. 유료 전환 뒤부터 실제 청구된다. +- 배포 전 수동 스냅샷(#95)은 보존 개수만큼 저장 비용이 붙는다. +- 예산 경보(제안): 월 $50, 실제 80%·100%, 예측 100%에서 메일. +- 가격 확인일을 구축 시점에 이 절에 적는다. + +## 7. 출시 전 → 출시 전환 + +### 출시 전 + +- 같은 서버·같은 RDS에서 `SPRING_PROFILES_ACTIVE=dev`, 스키마 `sniptext_dev`로 띄운다. +- dev 프로필이라 시드 회원·갤러리(`db/seed`)와 개발용 임시 토큰(`POST /api/v1/auth/dev-token`)을 쓸 수 있다. 프론트 연동에 쓴다. +- 주의: 임시 토큰 경로가 공개 인터넷에 열린다. 시드·테스트 데이터뿐이라 허용하되, 실제 개인정보를 넣지 않는다(결정 필요 항목). + +### 출시일 절차 + +1. 배포 전 RDS 스냅샷을 만든다. +2. RDS에 새 스키마 `sniptext`를 만든다(SSM 포트 포워딩으로 접속). +3. 새 `JWT_SECRET`·`CONTENT_ENCRYPTION_KEY`를 만들어 파라미터에 넣는다. **출시 전 값과 다르게** 한다(개발 중 발급된 토큰이 운영에서 통하지 않게, 개발 데이터 키와 섞이지 않게). 본문 키는 따로 안전하게 보관한다. +4. `DB_URL`을 `sniptext`로, `SPRING_PROFILES_ACTIVE`를 `prod`로 바꾼다(테라폼 변수 → plan → apply). +5. 현재 커밋 해시로 배포 워크플로를 다시 실행한다. 빈 스키마에 플라이웨이가 `db/migration`만 적용한다(시드 없음). +6. 확인: `/actuator/health` UP, 스웨거 꺼짐, `dev-token` 경로 없음(404 또는 401), 구글 로그인 → 가입 → 템플릿 생성, 로그에 비밀값·본문 없음. +7. 하루 이상 문제가 없으면 `sniptext_dev` 스키마를 지운다. + +### 되돌리기 + +- 4~6단계에서 문제가 나면 `DB_URL`·프로필·키를 이전 값으로 돌리고 다시 배포한다. 출시 전 스키마는 7단계 전까지 그대로 있다. + +## 8. 2027-01-17 계정 처리 + +무료 플랜 계정은 이날 닫힌다. **출시 전에 A·B 중 하나를 정한다.** B라면 이전을 먼저 하고 출시한다(사용자가 생긴 뒤 옮기면 데이터 이전과 끊김이 생긴다). + +### A. 유료 전환 + +1. 콘솔에서 유료 플랜으로 전환(손작업, 버튼 한 번). 남은 크레딧은 만료일까지 쓴다. +2. 예산 경보가 오는지 확인한다. +3. 리소스는 그대로다. 코드 변경 없음. + +### B. 새 계정으로 이전 + +1. 새 계정 생성, 루트 2단계 인증, 결제 수단 등록(손작업). +2. `bootstrap` apply → `prod` apply(계정 ID·도메인 등은 변수). +3. 새 호스팅 영역의 네임서버 4개를 가비아에 다시 등록(반영까지 최대 하루, 이 동안 이전 서버를 유지). +4. 비밀값 입력. **`JWT_SECRET`·`CONTENT_ENCRYPTION_KEY`는 반드시 이전 계정과 같은 값**(본문 키가 다르면 저장된 본문을 읽지 못한다). +5. 이전 계정에서 쓰기를 멈추고(배포 중단 공지) SSM 포트 포워딩으로 `mysqldump` → 새 계정 RDS로 복원. 기본 KMS 키로 암호화된 RDS 스냅샷은 다른 계정과 공유할 수 없어 덤프를 쓴다. +6. GitHub 환경 변수(역할 ARN 등)를 새 값으로 바꾸고 배포. +7. 확인 후 이전 계정 리소스를 `terraform destroy`(삭제 방지 해제 포함)로 정리. + +## 9. 개발 서버가 없어서 생기는 위험과 대응 + +| 위험 | 대응 | +| --- | --- | +| 마이그레이션이 운영 DB에서 처음 실행됨 | CI가 테스트컨테이너 MySQL 8.4(운영과 같은 버전)로 모든 마이그레이션을 매 PR마다 적용·검증 | +| 마이그레이션 실수로 데이터 손상 | 배포 직전 RDS 스냅샷(#95), 자동 백업 시점 복구, "한 배포에 추가만" 규칙 | +| 새 버전이 뜨지 않음 | 상태 확인 실패 시 이전 태그로 자동 되돌리기, 수동 재배포 | +| 운영 설정(환경 변수·프록시)에서만 나는 문제 | 출시 전 기간을 같은 서버의 dev 프로필로 운영해 미리 겪는다 | +| 프론트가 붙어 볼 곳이 없음 | 출시 전에는 서버의 dev 프로필, 출시 후에는 로컬 compose(`compose.yaml` + local 프로필) | +| 배포 결과를 놓침 | 디스코드 알림(#95) | + +## 10. 고려했지만 고르지 않은 것 + +| 선택지 | 고르지 않은 이유 | +| --- | --- | +| CodeDeploy | 에이전트 설치·appspec·배포 그룹이 추가된다. 서버 한 대에서 SSM 명령 한 번으로 되는 일에 비해 과하다 | +| ECS Fargate | 같은 사양 기준 EC2보다 비싸고, 공개 접속에 부하 분산기(월 약 $20)나 공인 IP 관리가 따라온다 | +| 부하 분산기(ALB) | 서버 한 대에선 분산할 대상이 없다. 월 고정비가 서버 비용과 비슷하다. 무중단·다중 서버가 필요해지면 다시 본다 | +| 쿠버네티스(EKS 등) | 컨트롤 플레인 비용(월 약 $70)과 운영 부담이 서비스 규모에 비해 크다 | +| SSH 배포 | 22번 포트 개방과 장기 SSH 키 보관이 필요하다 | +| ECR | 비공개 저장소라면 고른다. 공개 저장소라 GHCR 공개 패키지로 자격 증명 없이 충분하다 | +| EC2 안 도커 MySQL | 비용은 낮지만 백업을 직접 만들어야 하고 2GB 메모리를 앱과 나눠 써야 한다 | +| RDS 관리형 마스터 비밀번호 | 7일 자동 교체로 실행 중인 앱이 접속을 잃을 수 있고 Secrets Manager 비용이 붙는다 | +| NAT 게이트웨이 + 사설 서브넷 | 월 $40 이상. 열린 포트를 80·443으로 제한하는 것으로 대신한다 | +| AWS Organizations | 무료 플랜 계정이 가입하면 즉시 유료로 전환된다 | +| develop 자동 배포 개발 서버 | 서버 비용이 두 배가 된다. 위 9절의 대응으로 대신한다 | + +## 11. 결정 필요 + +| 항목 | 제안 | 언제까지 | +| --- | --- | --- | +| 1/17 처리: A 유료 전환 / B 새 계정 이전 | 비용 차이가 없다면 A가 단순하다 | 출시 전 (#94 전) | +| 프론트 출처 도메인 (CORS 허용, `GOOGLE_REDIRECT_URIS`) | 예: `sniptext.kr` 또는 `app.sniptext.kr` | #91 전 | +| GitHub 환경 `prod` 승인자 설정 | 출시 전에는 끄고, 출시 후 켬 | #93 | +| 예산 금액·경보 기준 | 월 $50, 80%·100%·예측 100% | #89 | +| 출시 전 dev 프로필의 임시 토큰 공개 허용 | 허용(시드 데이터뿐). 실제 개인정보 금지 | #92 | +| 앱 전용 DB 계정 | 처음엔 마스터 계정, 출시 전 앱 전용 계정으로 분리 검토 | 출시 전 | + +## 12. 손으로 해야 하는 작업 + +| 작업 | 시점 | 이슈 | +| --- | --- | --- | +| 루트 계정 2단계 인증, 결제 수단 확인 | 시작 전 | #89 | +| bootstrap 최초 실행용 관리자 자격 준비(장기 접근 키 대신 IAM Identity Center 또는 임시 자격) | 시작 전 | #89 | +| 예산 경보 구독 메일 확인 | bootstrap 후 | #89 | +| 가비아 `sniptext.kr` 네임서버를 Route 53 4개로 변경 | 호스팅 영역 생성 후 | #90 | +| SecureString 실제 값 입력(JWT, 본문 키, 구글 비밀, AI 키), 본문 키 별도 보관 | RDS·파라미터 생성 후 | #91 | +| GitHub 환경 `prod` 생성, `main` 브랜치 생성·보호 | 배포 워크플로 전 | #93 | +| 첫 푸시 뒤 GHCR 패키지 공개로 변경 | 첫 배포 | #93 | +| 디스코드 웹훅 생성·등록 | 알림 작업 | #95 | +| 출시 전환 절차 실행 | 출시일 | #94 | +| 1/17 처리(유료 전환 또는 이전) | 출시 전 | #94 | + +## 13. 구현 이슈 + +| 이슈 | 내용 | 선행 | +| --- | --- | --- | +| #88 | Dockerfile·이미지 빌드 확인 + 이 문서 | – | +| #89 | 테라폼 bootstrap(상태 저장소) + 예산 경보 | #88 | +| #90 | 테라폼 본체: 네트워크·EC2·고정 IP·DNS·배포 버킷 | #89 | +| #91 | 테라폼: RDS + 파라미터 저장소 뼈대 | #90 | +| #92 | 서버 실행 구성: compose 운영 파일·Caddy·배포 스크립트 | #88, #91 | +| #93 | GitHub OIDC 역할 + 배포 워크플로 | #92 | +| #94 | 운영 문서 + 출시 전환 절차 + CLAUDE.md 브랜치 규칙 | #93 | +| #95 | (후속) 배포 전 RDS 스냅샷 + 디스코드 알림 — 출시 전 권장 | #93 | +| #96 | (후속) 단일 서버 무중단 배포 + 마이그레이션 호환 규칙 | #93 | + +테라폼 본체를 네트워크·EC2(#90)와 RDS·비밀(#91)로 나눈 것은 PR 하나를 400줄 안팎으로 맞추기 위해서다. 배포 워크플로(#93)는 서버 쪽 `deploy.sh`(#92)가 있어야 끝까지 확인할 수 있어 그 뒤에 둔다. From 9d52c74da30ac78c08a8cfe327428adeba0a4354 Mon Sep 17 00:00:00 2001 From: chjpooh2002 Date: Wed, 11 Nov 2026 10:01:52 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20=EB=B0=B0=ED=8F=AC=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EA=B2=B0=EC=A0=95=20=EC=82=AC=ED=95=AD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20(=EC=9C=A0=EB=A3=8C=20=EC=A0=84=ED=99=98,=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=20=EC=A3=BC=EC=86=8C,=20=EC=98=88?= =?UTF-8?q?=EC=82=B0,=20=EC=95=B1=20=EC=A0=84=EC=9A=A9=20DB=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95)=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/deploy-design.md | 109 +++++++++++++++++++++++++++--------------- 1 file changed, 71 insertions(+), 38 deletions(-) diff --git a/docs/deploy-design.md b/docs/deploy-design.md index 9852043..79be5b0 100644 --- a/docs/deploy-design.md +++ b/docs/deploy-design.md @@ -14,7 +14,7 @@ | 서비스 | 스프링 부트 4.1(자바 21) API 서버 1개, MySQL 8.4, 플라이웨이 | | 사용자 규모 | 출시 직후 소수. 트래픽보다 **월 비용·운영 부담**이 더 큰 제약 | | 개발 인원 | 소수. 운영 전담 없음 | -| AWS 계정 | 무료 플랜. **2027-01-17에 닫힘** → 그 전에 유료 전환하거나 새 계정으로 옮겨야 함 | +| AWS 계정 | 무료 플랜. **2027-01-17에 닫힘** → 출시 전에 **유료 전환**한다(8절) | | 출시 목표 | 2027-01-17 이전 | | 도메인 | `sniptext.kr`(가비아에서 구매). 네임서버는 Route 53으로 위임. API는 `api.sniptext.kr` | | 저장소 | `SnipText-Developer/backend`, **공개 저장소** | @@ -124,15 +124,18 @@ sequenceDiagram - **선택지**: RDS / EC2 안 도커 MySQL - **고른 것**: **RDS MySQL 8.4 db.t4g.micro**, 단일 AZ, gp3 20GB 암호화, 공개 접근 끔(EC2 보안 그룹에서만 3306), 삭제 방지 + 최종 스냅샷, 자동 백업. - **대가**: 월 약 $20 추가(추정). 다중 AZ가 아니라 장애 조치는 없다. 로컬에서 접속하려면 SSM 포트 포워딩을 거쳐야 한다. 대신 시점 복구·스냅샷이 공짜에 가깝게 따라오고, 앱과 DB가 메모리를 나눠 쓰지 않는다. -- 처음에는 마스터 계정으로 접속한다(앱 전용 계정은 결정 필요 항목). +- **앱은 처음부터 전용 계정 `sniptext_app`으로 접속한다.** 마스터 계정은 관리 작업에만 쓴다. + - 권한은 앱 스키마(`sniptext_dev`, 출시 후 `sniptext`)에만 준다: `SELECT, INSERT, UPDATE, DELETE`와 플라이웨이에 필요한 `CREATE, ALTER, DROP, INDEX, REFERENCES` + - 앱이 잘못돼도 다른 스키마를 지우거나 사용자·권한을 바꿀 수 없다 + - 대가: 계정 생성 SQL을 사람이 한 번 실행한다(SSM 포트 포워딩으로 접속, #91). 테라폼 MySQL 공급자로 만들 수도 있지만 공급자가 DB에 직접 닿아야 해서(RDS 비공개) 쓰지 않는다 ### 4-6. 비밀값: SSM 파라미터 저장소, 상태 파일에 남기지 않기 - **상황**: 테라폼 상태 파일에는 리소스 속성이 평문으로 저장된다. 비밀값이 들어가면 상태 버킷 접근만으로 키가 샌다. - **선택지**: 테라폼 변수로 값 넣기 / Secrets Manager / 파라미터 저장소 + 사람이 값 입력 / 쓰기 전용 인자 - **고른 것** - - DB 비밀번호: 테라폼 **임시(ephemeral) 난수 → RDS `password_wo`, SSM `value_wo`** 쓰기 전용 인자. 상태 파일에 값이 남지 않는다. 바꿀 때는 버전 숫자를 올린다. - - 그 밖의 비밀(JWT 서명 키, 본문 암호화 키, 구글 클라이언트 비밀, AI 키): 테라폼은 SecureString 자리를 `CHANGE_ME`로 만들고 `ignore_changes = [value]`. 실제 값은 사람이 `aws ssm put-parameter --overwrite`로 넣는다. `deploy.sh`는 `CHANGE_ME`가 남아 있으면 기동하지 않는다. + - DB 마스터 비밀번호: 테라폼 **임시(ephemeral) 난수 → RDS `password_wo`, SSM `value_wo`(`DB_MASTER_PASSWORD`)** 쓰기 전용 인자. 상태 파일에 값이 남지 않는다. 바꿀 때는 버전 숫자를 올린다. 앱은 이 값을 쓰지 않는다(`deploy.sh`가 `.env`로 옮기지 않음). + - 그 밖의 비밀(앱 DB 비밀번호, JWT 서명 키, 본문 암호화 키, 구글 클라이언트 비밀, AI 키): 테라폼은 SecureString 자리를 `CHANGE_ME`로 만들고 `ignore_changes = [value]`. 실제 값은 사람이 `aws ssm put-parameter --overwrite`로 넣는다. `deploy.sh`는 `CHANGE_ME`가 남아 있으면 기동하지 않는다. - 비밀이 아닌 설정은 테라폼이 값까지 넣는다. - **대가**: 쓰기 전용 인자는 테라폼 1.11 이상, AWS 공급자 6.x가 필요하다. 사람 손이 한 번 들어간다. RDS 관리형 마스터 비밀번호(Secrets Manager)는 7일마다 자동 교체되어 실행 중인 앱이 접속을 잃을 수 있고 비밀 하나당 월 $0.4가 붙어 고르지 않았다. @@ -156,28 +159,41 @@ sequenceDiagram - **대가**: 인증서 발급 전에 DNS가 맞아 있어야 한다(가비아 → Route 53 네임서버 위임이 선행). 인증서 저장 볼륨을 잃으면 다시 발급받는다(발급 횟수 제한에 주의). - 앱은 프록시 뒤에 있으므로 `server.forward-headers-strategy`를 설정해 https 여부를 올바르게 판단하게 한다(재발급 토큰 `Secure` 쿠키에 필요). -### 4-10. 메모리 +### 4-10. 프론트 주소·출처 허용·쿠키 + +- **상황**: 웹 재발급 토큰은 HttpOnly·Secure 쿠키로만 주고받는다. 화면과 API의 주소 관계에 따라 쿠키의 `SameSite`와 출처 허용(CORS)이 정해진다. 출시 전에는 프론트가 로컬(`localhost:5173`)에서 서버에 붙어야 한다. +- **고른 것**: 화면은 `https://sniptext.kr`(랜딩·대시보드), API는 `https://api.sniptext.kr`. 둘은 같은 사이트라 출시 후에는 `SameSite=Lax`로 충분하다. 허용 출처·쿠키 `SameSite`는 **프로필별 설정값**으로 둔다. + +| 프로필 | 허용 출처 | 쿠키 `SameSite` | `GOOGLE_REDIRECT_URIS` | +| --- | --- | --- | --- | +| dev(출시 전 서버) | `https://sniptext.kr`, `http://localhost:5173` | `None` (+`Secure`) | `https://sniptext.kr/auth/google/callback`, `http://localhost:5173/auth/google/callback` | +| prod | `https://sniptext.kr` | `Lax` | `https://sniptext.kr/auth/google/callback` | + +- **대가**: dev 기간에 로컬 화면 → 서버 API는 다른 사이트 쿠키라서 사파리 등 다른 사이트 쿠키를 막는 브라우저에서는 재발급이 안 될 수 있다(크롬으로 연동 확인). 출처 허용·쿠키 코드는 아직 없다. 들어올 때(`global` 수정이라 해당 이슈에서 허락 후) 위 값을 설정으로 받게 한다. S2의 크롬 확장 출처는 그때 추가한다. + +### 4-11. 메모리 - t4g.small 2GB에 Caddy(약 50MB)와 앱이 함께 뜬다. - 앱 컨테이너 `mem_limit` 약 1.2GB, `JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75`로 힙을 컨테이너 한도 기준으로 잡는다. - user_data에서 스왑 2GB를 만든다. 스왑을 쓰기 시작하면 느려지므로, 지속적으로 쓰이면 인스턴스 크기 변수를 올린다. -### 4-11. 배포 중 끊김 +### 4-12. 배포 중 끊김 - **상황**: 사용자 수가 적고 출시 일정이 우선이다. - **고른 것**: 컨테이너 교체 방식. 새 버전이 뜨는 20~40초 동안 503이 날 수 있다. 상태 확인에 실패하면 이전 태그로 되돌린다. - **대가**: 배포마다 짧은 끊김. 무중단(구·신 컨테이너 교대)은 #96에서 다룬다. -### 4-12. 되돌리기와 마이그레이션 +### 4-13. 되돌리기와 마이그레이션 - 이미지는 되돌릴 수 있지만 **DB 스키마는 되돌아가지 않는다.** - 플라이웨이는 기본 설정에서 "이 코드가 모르는 더 새로운 마이그레이션"을 무시하므로 이전 이미지도 뜬다. 다만 `ddl-auto: validate`가 있어 이전 코드가 기대하는 컬럼이 사라졌으면 기동에 실패한다. - 그래서 규칙: **한 번의 배포에서는 추가만 한다.** 컬럼 삭제·이름 변경은 "추가 → 옮김 → 삭제"를 여러 번의 배포로 나눈다(#94에서 CLAUDE.md에 반영, #96에서 구체화). - 배포 직전 RDS 스냅샷(#95)이 마지막 안전장치다. -### 4-13. 승인 +### 4-14. 승인 -- 배포 작업은 GitHub 환경 `prod`에서 돈다(배포 브랜치는 `main`만). 승인자를 둘지는 결정 필요 항목이다. 두면 배포마다 클릭 한 번이 늘고, 대신 실수로 병합된 변경이 바로 나가지 않는다. +- 배포 작업은 GitHub 환경 `prod`에서 돈다(배포 브랜치는 `main`만). +- **출시 전에는 승인자를 두지 않고, 출시 전환 때 켠다.** 출시 전에는 프론트 연동으로 배포가 잦아 승인이 부담만 된다. 출시 후에는 배포마다 클릭 한 번이 늘지만, 실수로 병합된 변경이 바로 사용자에게 나가지 않는다. ## 5. 환경 변수·비밀값 목록 @@ -187,13 +203,14 @@ sequenceDiagram | --- | --- | --- | --- | --- | | `SPRING_PROFILES_ACTIVE` | 전체 | 일반 | 테라폼 | 출시 전 `dev`, 출시 후 `prod` | | `DB_URL` | dev·prod | 일반 | 테라폼 | 출시 전 `sniptext_dev` 스키마, 출시 후 `sniptext` | -| `DB_USERNAME` | dev·prod | 일반 | 테라폼 | | -| `DB_PASSWORD` | dev·prod | SecureString | 테라폼(쓰기 전용 인자) | 상태 파일에 남지 않음 | +| `DB_USERNAME` | dev·prod | 일반 | 테라폼 | `sniptext_app` | +| `DB_PASSWORD` | dev·prod | SecureString | 사람 | 앱 전용 계정 비밀번호. 계정 만들 때 같이 정함 | +| `DB_MASTER_PASSWORD` | – (앱이 안 씀) | SecureString | 테라폼(쓰기 전용 인자) | 관리 작업 전용. 상태 파일에 남지 않음. `.env`로 옮기지 않음 | | `JWT_SECRET` | 전체 | SecureString | 사람 | 32바이트 이상. 출시 전·후 값 다르게 | | `CONTENT_ENCRYPTION_KEY` | 전체 | SecureString | 사람 | Base64 32바이트. **잃으면 본문 복구 불가** → 따로 안전하게 보관. 출시 전·후 값 다르게 | | `GOOGLE_CLIENT_ID` | 전체 | 일반 | 테라폼(변수) | | | `GOOGLE_CLIENT_SECRET` | 전체 | SecureString | 사람 | | -| `GOOGLE_REDIRECT_URIS` | 전체 | 일반 | 테라폼(변수) | 프론트 주소 확정 필요 | +| `GOOGLE_REDIRECT_URIS` | 전체 | 일반 | 테라폼(변수) | 프로필별 값은 4-10 표 | | `AI_PROVIDER` | 전체 | 일반 | 테라폼 | prod에서는 `fake` 불가 | | `AI_MODEL` | 전체 | 일반 | 테라폼(변수) | | | `AI_API_KEY` | 전체 | SecureString | 사람 | | @@ -222,9 +239,11 @@ sequenceDiagram | 데이터 전송(월 100GB까지 무료) | 약 $0 | | **합계** | **약 $42** | -- 무료 플랜 기간에는 크레딧에서 차감된다. 유료 전환 뒤부터 실제 청구된다. +- 무료 플랜 기간과 유료 전환 뒤 남은 크레딧이 있는 동안은 크레딧에서 차감된다. 크레딧이 끝나면 실제 청구된다. - 배포 전 수동 스냅샷(#95)은 보존 개수만큼 저장 비용이 붙는다. -- 예산 경보(제안): 월 $50, 실제 80%·100%, 예측 100%에서 메일. +- 예산 경보: **월 $60, 실제 80%·100%, 예측 100%에서 메일. 크레딧 제외(`include_credit = false`).** + - $50으로 잡으면 80%($40)가 예상 비용($42)보다 낮아 매달 경보가 울려 무시하게 된다. + - 크레딧을 포함하면 차감 후 금액(거의 $0)을 보므로, 크레딧 기간에 사용량이 늘어도 경보가 울리지 않는다. 크레딧을 빼야 실제 사용량 기준으로 경보가 온다. - 가격 확인일을 구축 시점에 이 절에 적는다. ## 7. 출시 전 → 출시 전환 @@ -233,38 +252,47 @@ sequenceDiagram - 같은 서버·같은 RDS에서 `SPRING_PROFILES_ACTIVE=dev`, 스키마 `sniptext_dev`로 띄운다. - dev 프로필이라 시드 회원·갤러리(`db/seed`)와 개발용 임시 토큰(`POST /api/v1/auth/dev-token`)을 쓸 수 있다. 프론트 연동에 쓴다. -- 주의: 임시 토큰 경로가 공개 인터넷에 열린다. 시드·테스트 데이터뿐이라 허용하되, 실제 개인정보를 넣지 않는다(결정 필요 항목). +- 임시 토큰 경로가 공개 인터넷에 열리는 것은 **허용한다.** + - 이 기간의 데이터(`sniptext_dev`)는 출시일에 스키마를 바꾸며 모두 버린다. 서명 키도 출시 때 새로 바꿔 이 기간에 발급된 토큰은 운영에서 통하지 않는다. + - 실제 AI 키 남용은 회원별 하루 5회·출력 토큰 상한 4000으로 묶여 있다(시드 회원 2명 기준 하루 최대 10회). + - 조건: 이 기간에는 실제 개인정보를 넣지 않는다. ### 출시일 절차 1. 배포 전 RDS 스냅샷을 만든다. -2. RDS에 새 스키마 `sniptext`를 만든다(SSM 포트 포워딩으로 접속). +2. RDS에 새 스키마 `sniptext`를 만들고 `sniptext_app`에 같은 권한을 준다(SSM 포트 포워딩, 마스터 계정). 3. 새 `JWT_SECRET`·`CONTENT_ENCRYPTION_KEY`를 만들어 파라미터에 넣는다. **출시 전 값과 다르게** 한다(개발 중 발급된 토큰이 운영에서 통하지 않게, 개발 데이터 키와 섞이지 않게). 본문 키는 따로 안전하게 보관한다. -4. `DB_URL`을 `sniptext`로, `SPRING_PROFILES_ACTIVE`를 `prod`로 바꾼다(테라폼 변수 → plan → apply). +4. `DB_URL`을 `sniptext`로, `SPRING_PROFILES_ACTIVE`를 `prod`로, `GOOGLE_REDIRECT_URIS`를 prod 값(4-10 표)으로 바꾼다(테라폼 변수 → plan → apply). 5. 현재 커밋 해시로 배포 워크플로를 다시 실행한다. 빈 스키마에 플라이웨이가 `db/migration`만 적용한다(시드 없음). -6. 확인: `/actuator/health` UP, 스웨거 꺼짐, `dev-token` 경로 없음(404 또는 401), 구글 로그인 → 가입 → 템플릿 생성, 로그에 비밀값·본문 없음. -7. 하루 이상 문제가 없으면 `sniptext_dev` 스키마를 지운다. +6. 확인: `/actuator/health` UP, 스웨거 꺼짐, `dev-token` 경로 없음(404 또는 401), `localhost` 출처 거절, 구글 로그인 → 가입 → 템플릿 생성, 로그에 비밀값·본문 없음. +7. GitHub 환경 `prod`에 승인자를 켠다(4-14). +8. 하루 이상 문제가 없으면 `sniptext_dev` 스키마를 지우고 `sniptext_app`의 해당 권한을 회수한다. ### 되돌리기 -- 4~6단계에서 문제가 나면 `DB_URL`·프로필·키를 이전 값으로 돌리고 다시 배포한다. 출시 전 스키마는 7단계 전까지 그대로 있다. +- 4~6단계에서 문제가 나면 `DB_URL`·프로필·키를 이전 값으로 돌리고 다시 배포한다. 출시 전 스키마는 8단계 전까지 그대로 있다. ## 8. 2027-01-17 계정 처리 -무료 플랜 계정은 이날 닫힌다. **출시 전에 A·B 중 하나를 정한다.** B라면 이전을 먼저 하고 출시한다(사용자가 생긴 뒤 옮기면 데이터 이전과 끊김이 생긴다). +무료 플랜 계정은 이날 닫힌다. **A안(유료 전환)으로 정했고, 출시 전(#94 작업 전)에 전환한다.** B안은 유료 전환이 막히거나 나중에 계정을 옮겨야 할 때를 위한 예비 절차로 남긴다. + +### A. 유료 전환 (채택) -### A. 유료 전환 +- **일찍 전환하는 이유**: 전환해도 남은 크레딧은 만료일까지 그대로 쓰여 일찍 바꿔도 손해가 없다. 무료 플랜의 서비스·인스턴스 종류 제한도 풀려, 출시 직전에 제한에 걸리는 일을 막는다. -1. 콘솔에서 유료 플랜으로 전환(손작업, 버튼 한 번). 남은 크레딧은 만료일까지 쓴다. -2. 예산 경보가 오는지 확인한다. +1. 콘솔에서 유료 플랜으로 전환(손작업, 버튼 한 번). +2. 예산 경보(크레딧 제외 기준)가 정상인지 확인한다. 3. 리소스는 그대로다. 코드 변경 없음. -### B. 새 계정으로 이전 +### B. 새 계정으로 이전 (예비) + +출시 뒤에 옮기면 데이터 이전과 끊김이 생기므로, 써야 한다면 가능한 한 출시 전에 한다. + 1. 새 계정 생성, 루트 2단계 인증, 결제 수단 등록(손작업). 2. `bootstrap` apply → `prod` apply(계정 ID·도메인 등은 변수). 3. 새 호스팅 영역의 네임서버 4개를 가비아에 다시 등록(반영까지 최대 하루, 이 동안 이전 서버를 유지). -4. 비밀값 입력. **`JWT_SECRET`·`CONTENT_ENCRYPTION_KEY`는 반드시 이전 계정과 같은 값**(본문 키가 다르면 저장된 본문을 읽지 못한다). +4. 앱 전용 DB 계정 생성, 비밀값 입력. **`JWT_SECRET`·`CONTENT_ENCRYPTION_KEY`는 반드시 이전 계정과 같은 값**(본문 키가 다르면 저장된 본문을 읽지 못한다). 5. 이전 계정에서 쓰기를 멈추고(배포 중단 공지) SSM 포트 포워딩으로 `mysqldump` → 새 계정 RDS로 복원. 기본 KMS 키로 암호화된 RDS 스냅샷은 다른 계정과 공유할 수 없어 덤프를 쓴다. 6. GitHub 환경 변수(역할 ARN 등)를 새 값으로 바꾸고 배포. 7. 확인 후 이전 계정 리소스를 `terraform destroy`(삭제 방지 해제 포함)로 정리. @@ -296,16 +324,20 @@ sequenceDiagram | AWS Organizations | 무료 플랜 계정이 가입하면 즉시 유료로 전환된다 | | develop 자동 배포 개발 서버 | 서버 비용이 두 배가 된다. 위 9절의 대응으로 대신한다 | -## 11. 결정 필요 +## 11. 정한 것 (2026-11-11) -| 항목 | 제안 | 언제까지 | -| --- | --- | --- | -| 1/17 처리: A 유료 전환 / B 새 계정 이전 | 비용 차이가 없다면 A가 단순하다 | 출시 전 (#94 전) | -| 프론트 출처 도메인 (CORS 허용, `GOOGLE_REDIRECT_URIS`) | 예: `sniptext.kr` 또는 `app.sniptext.kr` | #91 전 | -| GitHub 환경 `prod` 승인자 설정 | 출시 전에는 끄고, 출시 후 켬 | #93 | -| 예산 금액·경보 기준 | 월 $50, 80%·100%·예측 100% | #89 | -| 출시 전 dev 프로필의 임시 토큰 공개 허용 | 허용(시드 데이터뿐). 실제 개인정보 금지 | #92 | -| 앱 전용 DB 계정 | 처음엔 마스터 계정, 출시 전 앱 전용 계정으로 분리 검토 | 출시 전 | +설계 초안에서 "결정 필요"로 남겼던 항목을 아래처럼 정했다. + +| 항목 | 정한 것 | 근거 | 반영 이슈 | +| --- | --- | --- | --- | +| 1/17 계정 처리 | A 유료 전환, 출시 전에 전환 | 8절 | #94 | +| 프론트 주소 | 화면 `https://sniptext.kr`, API `https://api.sniptext.kr`. 출처·쿠키·redirect는 프로필별 값 | 4-10 | #91, #92 | +| `prod` 승인자 | 출시 전 끔, 출시 전환 때 켬 | 4-14 | #93, #94 | +| 예산 | 월 $60, 실제 80%·100%·예측 100%, 크레딧 제외 | 6절 | #89 | +| 출시 전 임시 토큰 공개 | 허용, 실제 개인정보 금지 | 7절 | #92, #94 | +| 앱 DB 계정 | 처음부터 `sniptext_app` 분리, 마스터는 관리 전용 | 4-5 | #91, #92 | + +남은 확인: 대시보드를 `sniptext.kr`이 아닌 별도 주소(예: `app.sniptext.kr`)로 둘 계획이 생기면 4-10 표만 바꾼다. ## 12. 손으로 해야 하는 작업 @@ -315,12 +347,13 @@ sequenceDiagram | bootstrap 최초 실행용 관리자 자격 준비(장기 접근 키 대신 IAM Identity Center 또는 임시 자격) | 시작 전 | #89 | | 예산 경보 구독 메일 확인 | bootstrap 후 | #89 | | 가비아 `sniptext.kr` 네임서버를 Route 53 4개로 변경 | 호스팅 영역 생성 후 | #90 | -| SecureString 실제 값 입력(JWT, 본문 키, 구글 비밀, AI 키), 본문 키 별도 보관 | RDS·파라미터 생성 후 | #91 | +| 앱 전용 DB 계정 `sniptext_app`·스키마 `sniptext_dev` 생성(SSM 포트 포워딩) | RDS 생성 후 | #91 | +| SecureString 실제 값 입력(앱 DB 비밀번호, JWT, 본문 키, 구글 비밀, AI 키), 본문 키 별도 보관 | RDS·파라미터 생성 후 | #91 | | GitHub 환경 `prod` 생성, `main` 브랜치 생성·보호 | 배포 워크플로 전 | #93 | | 첫 푸시 뒤 GHCR 패키지 공개로 변경 | 첫 배포 | #93 | | 디스코드 웹훅 생성·등록 | 알림 작업 | #95 | -| 출시 전환 절차 실행 | 출시일 | #94 | -| 1/17 처리(유료 전환 또는 이전) | 출시 전 | #94 | +| 유료 플랜 전환 | 출시 전 (#94 작업 전) | #94 | +| 출시 전환 절차 실행, `prod` 승인자 켜기 | 출시일 | #94 | ## 13. 구현 이슈 From d72ee0140efb9132194a2ccc177baf655bf60b93 Mon Sep 17 00:00:00 2001 From: chjpooh2002 Date: Wed, 11 Nov 2026 11:11:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20Dockerfile=C2=B7CI=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B9=8C=EB=93=9C=20=ED=99=95=EC=9D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 20 ++++++++++++++++++++ .github/workflows/ci.yml | 14 ++++++++++++++ Dockerfile | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b10835 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# 이미지 빌드에 필요한 것만 보낸다 (gradlew, gradle/, settings.gradle, build.gradle, src/) +.git +.github +.gradle +.idea +.vscode +.claude +build +out +bin +docs +infra +deploy +*.md +*.iml +.env +.env.* +compose*.yaml +Dockerfile +.dockerignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 261389e..3f306b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,17 @@ jobs: - name: 빌드 및 테스트 run: ./gradlew build + + # 운영 서버(EC2 t4g, ARM)용 이미지가 만들어지는지만 확인한다. 푸시는 배포 워크플로에서 한다 + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: 이미지 빌더 설정 + uses: docker/setup-buildx-action@v4 + + # x86 실행기에서 arm64 이미지를 빌드한다. 빌드 단계는 실행기 아키텍처에서 돌고 + # 실행 단계에는 RUN이 없어 에뮬레이터(QEMU) 설정 없이 빌드된다 (Dockerfile 주석 참고) + - name: arm64 이미지 빌드 확인 + run: docker buildx build --platform linux/arm64 . diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5aea85d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 +# 스닙텍 API 서버 이미지 (설계: docs/deploy-design.md) +# - 빌드 단계는 빌드하는 기계의 아키텍처(BUILDPLATFORM)에서 그대로 돈다. 자바 바이트코드는 아키텍처와 무관해 +# arm64 이미지를 만들 때도 에뮬레이션 없이 빌드된다. +# - 실행 단계에는 RUN이 없어서 대상 아키텍처(arm64 등)용 에뮬레이터가 필요 없다. +# - 기본 이미지는 태그와 다이제스트를 함께 고정한다. 올릴 때는 두 값을 같이 바꾼다. + +FROM --platform=$BUILDPLATFORM eclipse-temurin:21-jdk-noble@sha256:114cabb73033ca4ce7ceec3deb62396bc1b716282bae051ed3c1d82e38fcab2d AS build +WORKDIR /workspace + +# 의존성 내려받기를 소스와 분리해 캐시한다 (빌드 설정이 그대로면 소스만 바뀌어도 다시 받지 않는다) +COPY gradlew settings.gradle build.gradle ./ +COPY gradle gradle +RUN --mount=type=cache,target=/root/.gradle ./gradlew dependencies --no-daemon -q > /dev/null + +# 테스트는 CI(./gradlew build)에서 이미 돌리므로 여기서는 실행 가능한 jar만 만든다 +COPY src src +RUN --mount=type=cache,target=/root/.gradle ./gradlew bootJar --no-daemon -q \ + && cp "$(find build/libs -name '*.jar' ! -name '*-plain.jar')" app.jar \ + && java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted + +FROM eclipse-temurin:21-jre-noble@sha256:0eda5830ed7b669abc246a8dc520ea8a23cab06f9a8d4b6964109e2f2394e46f +# 컨테이너 OS 시각(로그 등)도 한국 시간으로 맞춘다. JVM 기본 시간대는 앱이 시작할 때 Asia/Seoul로 고정한다 +ENV TZ=Asia/Seoul +WORKDIR /app + +# 기본 이미지에 있는 일반 사용자(ubuntu, uid 1000)로 실행한다. 사용자를 새로 만들면 RUN이 생겨 에뮬레이터가 필요해진다 +# 자주 바뀌는 층을 뒤에 둔다 (의존성 → 로더 → 스냅숏 의존성 → 앱) +COPY --from=build --chown=1000:1000 /workspace/extracted/dependencies/ ./ +COPY --from=build --chown=1000:1000 /workspace/extracted/spring-boot-loader/ ./ +COPY --from=build --chown=1000:1000 /workspace/extracted/snapshot-dependencies/ ./ +COPY --from=build --chown=1000:1000 /workspace/extracted/application/ ./ +USER 1000:1000 + +EXPOSE 8080 +# 메모리 등 JVM 옵션은 실행할 때 JAVA_TOOL_OPTIONS로 넣는다 (#92) +ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]