diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e5de657
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,28 @@
+# 빌드 산출물 / 로컬 캐시 (이미지 빌드 컨텍스트에서 제외 → 전송량·빌드시간 절감)
+build/
+.gradle/
+
+# VCS / IDE
+.git/
+.github/
+.idea/
+*.iml
+.vscode/
+
+# 시크릿 / 로컬 설정 — 이미지에 비밀값이 들어가지 않게 한다
+.env
+.env.*
+.secrets/
+*.pem
+*.key
+*.p12
+*.jks
+src/main/resources/application-local.*
+src/main/resources/application-secret.*
+
+# 기타
+logs/
+*.log
+HELP.md
+CLAUDE.md
+docs/
diff --git a/.gitattributes b/.gitattributes
index 8af972c..abdc36e 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,4 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
+*.sh text eol=lf
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
new file mode 100644
index 0000000..2fe4760
--- /dev/null
+++ b/.github/workflows/cd.yml
@@ -0,0 +1,99 @@
+name: CD
+
+# main 에 push(= PR 머지) 되면 이미지 빌드 → Docker Hub push → EC2 배포
+on:
+ push:
+ branches: [main]
+ workflow_dispatch: # Actions 탭에서 수동 실행도 허용 (재배포용)
+
+# 배포가 겹치면 EC2 컨테이너 교체가 꼬인다 → 한 번에 하나씩, 진행 중인 배포는 끊지 않는다
+concurrency:
+ group: cd
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: 소스 체크아웃
+ uses: actions/checkout@v5
+
+ - name: Docker Hub 로그인
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Buildx 설정
+ uses: docker/setup-buildx-action@v3
+
+ - name: 이미지 빌드 & 푸시
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: |
+ ${{ secrets.DOCKERHUB_USERNAME }}/itssum-be:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/itssum-be:${{ github.sha }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: docker-compose.yml 전송
+ uses: appleboy/scp-action@v0.1.7
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ${{ secrets.EC2_USER }}
+ key: ${{ secrets.EC2_SSH_KEY }}
+ source: docker-compose.yml
+ target: ~/itssum-app
+
+ - name: EC2 배포
+ uses: appleboy/ssh-action@v1.2.0
+ with:
+ host: ${{ secrets.EC2_HOST }}
+ username: ${{ secrets.EC2_USER }}
+ key: ${{ secrets.EC2_SSH_KEY }}
+ script: |
+ set -e
+ cd ~/itssum-app
+
+ # 시크릿으로 .env 생성 (compose 가 이미지 태그·DB 설정을 여기서 읽음)
+ # 이미지는 latest 가 아니라 커밋 SHA 로 고정한다 → 이전 CD 실행을 Re-run 하면 그 버전으로 롤백된다.
+ # EC2 의 .env 를 직접 고치면 다음 배포에 이 블록이 덮어써서 되돌아온다.
+ cat > .env <<'EOF'
+ DOCKER_IMAGE=${{ secrets.DOCKERHUB_USERNAME }}/itssum-be:${{ github.sha }}
+ DB_URL=${{ secrets.DB_URL }}
+ DB_USERNAME=${{ secrets.DB_USERNAME }}
+ DB_PASSWORD=${{ secrets.DB_PASSWORD }}
+ EOF
+ chmod 600 .env
+
+ # 시크릿 누락은 빈 값으로 조용히 통과한다 → 컨테이너 띄우기 전에 여기서 끊는다.
+ for required in DB_URL DB_USERNAME DB_PASSWORD; do
+ if ! grep -q "^${required}=." .env; then
+ echo "❌ 필수 시크릿이 비어 있습니다: ${required}"
+ exit 1
+ fi
+ done
+
+ # private 이미지 pull 위해 EC2 도 Docker Hub 로그인
+ echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
+
+ docker compose pull
+ docker compose config --quiet
+ docker compose up -d
+ # SHA 태그라 이전 이미지가 dangling 이 되지 않는다 → 컨테이너가 안 쓰는 이미지를 모두 정리
+ docker image prune -af
+
+ # 기동 확인 (최대 120초 대기). health 는 DB 연결까지 확인한다.
+ for i in $(seq 1 24); do
+ if curl -sf http://localhost:8080/actuator/health > /dev/null; then
+ echo "✅ 배포 성공: 앱이 응답합니다."
+ exit 0
+ fi
+ echo "기동 대기 중... ($i/24)"
+ sleep 5
+ done
+ echo "❌ 앱이 120초 내 응답하지 않음. 로그 확인:"
+ docker compose logs --tail=100
+ exit 1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..c7c0a33
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,48 @@
+name: CI
+
+# develop / main 으로의 PR 시 테스트 + 빌드 검증만 수행 (배포 X)
+on:
+ pull_request:
+ branches: [develop, main]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ # @SpringBootTest 가 실제 DB 연결을 요구하므로 운영과 같은 MySQL 8.4 를 띄운다
+ services:
+ mysql:
+ image: mysql:8.4
+ env:
+ MYSQL_ROOT_PASSWORD: test
+ MYSQL_DATABASE: itssum
+ ports:
+ - 3306:3306
+ # -h 127.0.0.1 로 TCP 확인 → 초기화용 임시 서버(소켓 전용)가 아닌 실제 서버가 뜬 뒤에 통과한다
+ options: >-
+ --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -ptest"
+ --health-interval=5s
+ --health-timeout=5s
+ --health-retries=20
+
+ env:
+ DB_URL: jdbc:mysql://127.0.0.1:3306/itssum?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
+ DB_USERNAME: root
+ DB_PASSWORD: test
+
+ steps:
+ - name: 소스 체크아웃
+ uses: actions/checkout@v5
+
+ - name: JDK 21 설정
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '21'
+ cache: gradle
+
+ - name: gradlew 실행 권한
+ run: chmod +x gradlew
+
+ - name: 빌드 & 테스트
+ run: ./gradlew clean build
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..2e8affd
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,25 @@
+# ===== Build stage =====
+FROM eclipse-temurin:21-jdk AS builder
+WORKDIR /app
+
+# 빌드 스크립트 먼저 복사 → 의존성 레이어 캐싱 (소스만 바뀔 땐 재다운로드 안 함)
+COPY gradlew .
+COPY gradle gradle
+COPY build.gradle settings.gradle ./
+RUN chmod +x gradlew && ./gradlew dependencies --no-daemon > /dev/null 2>&1 || true
+
+# 소스 복사 후 실행 가능한 jar 빌드 (테스트는 CI에서 이미 통과한 상태)
+COPY src src
+RUN ./gradlew clean bootJar --no-daemon
+
+# ===== Runtime stage =====
+FROM eclipse-temurin:21-jre
+WORKDIR /app
+
+# plain jar를 껐으므로 build/libs 에는 실행 가능한 jar 하나만 존재
+COPY --from=builder /app/build/libs/*.jar app.jar
+
+EXPOSE 8080
+
+# 컨테이너에 할당된 메모리에 맞춰 힙 자동 조정 (t3.micro 등 소형 인스턴스 대비)
+ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
diff --git a/README.md b/README.md
index 39d5997..0cfea67 100644
--- a/README.md
+++ b/README.md
@@ -33,11 +33,12 @@
|---|---|
| Language | Java 21 |
| Framework | Spring Boot 4.1.1, Spring MVC |
-| DB | MySQL 8.0, Spring Data JPA (Hibernate) |
+| DB | MySQL 8.4, Spring Data JPA (Hibernate) |
| 인증 | Spring Security (JWT 적용 예정) |
| 실시간 | Spring WebSocket + STOMP |
| 기타 | Validation, Java Mail Sender, Lombok |
| Build | Gradle (Groovy) |
+| 배포 | GitHub Actions, Docker, AWS EC2 · RDS |
| 예정 | AWS S3 (이미지), FCM (푸시), springdoc (Swagger) |
@@ -129,7 +130,7 @@ src/main/java/com/itssum
## 로컬 실행
-MySQL 8.0에 `itssum` 스키마를 먼저 만들어 두고, DB 접속 정보는 환경변수로 넘깁니다. 접속 정보는 커밋하지 않습니다.
+MySQL 8.4에 `itssum` 스키마를 먼저 만들어 두고, DB 접속 정보는 환경변수로 넘깁니다. 접속 정보는 커밋하지 않습니다.
```bash
export DB_URL="jdbc:mysql://localhost:3306/itssum?serverTimezone=Asia/Seoul"
@@ -143,6 +144,24 @@ IntelliJ에서 띄울 때는 Run Configuration의 Environment variables에 같
+## 배포
+
+수동 배포는 하지 않습니다. `main`에 머지되면 GitHub Actions가 EC2에 배포합니다.
+
+| 워크플로우 | 실행 시점 | 하는 일 |
+|---|---|---|
+| CI (`.github/workflows/ci.yml`) | `develop`, `main` 대상 PR | MySQL 8.4 컨테이너를 띄워 빌드·테스트 |
+| CD (`.github/workflows/cd.yml`) | `main` push, Actions 탭 수동 실행 | 이미지 빌드 → Docker Hub push → EC2에서 컨테이너 교체 → `/actuator/health` 확인 |
+
+- 인프라는 EC2(Ubuntu 24.04) 한 대와 RDS MySQL 8.4입니다. EC2의 호스트 Nginx가 80 → `127.0.0.1:8080`으로 프록시하고, 초기 세팅 스크립트는 `docs/infra/ec2-user-data.sh`에 있습니다.
+- 비밀값은 GitHub Secrets에만 둡니다. CD가 배포할 때마다 EC2의 `.env`를 새로 만들기 때문에 EC2에서 `.env`를 직접 고치면 다음 배포에서 덮어써집니다.
+- 환경변수를 새로 추가할 때는 GitHub Secrets 등록과 `cd.yml`의 `.env` 블록 수정을 같이 해야 합니다.
+- 이미지 태그는 커밋 SHA입니다. 롤백은 Actions에서 이전 CD 실행을 Re-run 하면 됩니다.
+
+필요한 GitHub Secrets: `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN`, `EC2_HOST`, `EC2_USER`, `EC2_SSH_KEY`, `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`
+
+
+
## API 명세
API 명세는 노션에서 관리하고 있습니다. springdoc을 붙이면 Swagger 주소를 여기에 추가할 예정입니다.
diff --git a/build.gradle b/build.gradle
index fa0ac4d..b450505 100644
--- a/build.gradle
+++ b/build.gradle
@@ -18,6 +18,7 @@ repositories {
}
dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-security'
@@ -41,3 +42,8 @@ dependencies {
tasks.named('test') {
useJUnitPlatform()
}
+
+// plain jar를 끈다 → build/libs 에 실행 가능한 jar 하나만 남아 Dockerfile 이 그대로 복사한다
+tasks.named('jar') {
+ enabled = false
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..63d49b1
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,22 @@
+# EC2에서 앱 컨테이너를 기동하는 구성.
+# 실제 값(이미지 태그, DB 접속정보)은 같은 폴더의 .env 를 CD 워크플로우가 생성해 주입한다.
+services:
+ app:
+ image: ${DOCKER_IMAGE} # 예: /itssum-be:<커밋 SHA> (.env 에서 주입)
+ container_name: itssum-be
+ ports:
+ # 외부 트래픽은 호스트 Nginx(80)만 받고, 앱은 Nginx를 통해서만 접근 → 127.0.0.1 바인딩으로 8080 직접노출 차단
+ - "127.0.0.1:8080:8080"
+ env_file:
+ - .env # DB_* 주입
+ environment:
+ SPRING_PROFILES_ACTIVE: prod
+ # 로그 시각을 KST로 맞춘다 (DB 저장 기준은 application.yml 의 hibernate time_zone)
+ TZ: Asia/Seoul
+ # 로그 파일이 디스크를 채우지 않도록 10MB x 5개로 순환
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "5"
+ restart: unless-stopped
diff --git a/docs/infra/ec2-user-data.sh b/docs/infra/ec2-user-data.sh
new file mode 100644
index 0000000..3ae9695
--- /dev/null
+++ b/docs/infra/ec2-user-data.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+# EC2(Ubuntu 24.04) 최초 부팅 시 실행하는 사용자 데이터. Docker, Nginx(80 -> 8080), 2GB 스왑, KST 시간대를 세팅한다.
+# 이미 만든 인스턴스에는 한 번만 sudo bash 로 실행한다 (두 번 돌리면 스왑 단계에서 실패).
+# 콘솔 사용자 데이터에 파일로 올리면 한글 주석이 깨져 보이지만 주석이라 실행에는 영향 없다.
+set -euxo pipefail
+export DEBIAN_FRONTEND=noninteractive
+
+timedatectl set-timezone Asia/Seoul
+
+# 스왑 2GB — Spring Boot 기동 시 메모리 여유
+fallocate -l 2G /swapfile
+chmod 600 /swapfile
+mkswap /swapfile
+swapon /swapfile
+echo '/swapfile none swap sw 0 0' >> /etc/fstab
+
+# Docker + compose 플러그인. ubuntu 사용자를 docker 그룹에 넣어 CD가 sudo 없이 docker 를 쓴다.
+curl -fsSL https://get.docker.com | sh
+usermod -aG docker ubuntu
+
+# Nginx: 80 -> 127.0.0.1:8080 (STOMP WebSocket 업그레이드 포함)
+apt-get update -y
+apt-get install -y nginx
+cat > /etc/nginx/sites-available/itssum <<'EOF'
+map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+}
+
+server {
+ listen 80;
+ server_name _;
+
+ location / {
+ proxy_pass http://127.0.0.1:8080;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 300s;
+ }
+}
+EOF
+ln -sf /etc/nginx/sites-available/itssum /etc/nginx/sites-enabled/itssum
+rm -f /etc/nginx/sites-enabled/default
+nginx -t
+systemctl restart nginx
+
+# CD가 docker-compose.yml과 .env를 올릴 폴더
+mkdir -p /home/ubuntu/itssum-app
+chown ubuntu:ubuntu /home/ubuntu/itssum-app
diff --git a/src/main/java/com/itssum/global/config/SecurityConfig.java b/src/main/java/com/itssum/global/config/SecurityConfig.java
index f989bf2..df37e4b 100644
--- a/src/main/java/com/itssum/global/config/SecurityConfig.java
+++ b/src/main/java/com/itssum/global/config/SecurityConfig.java
@@ -20,8 +20,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- // TODO: JWT 적용 시 인증이 필요한 경로를 제한한다. 지금은 전체 허용.
- .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
+ .authorizeHttpRequests(auth -> auth
+ // CD 배포 헬스체크. JWT 적용 후에도 인증 없이 열어 둔다.
+ .requestMatchers("/actuator/health").permitAll()
+ // TODO: JWT 적용 시 인증이 필요한 경로를 제한한다. 지금은 전체 허용.
+ .anyRequest().permitAll());
return http.build();
}
}
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
new file mode 100644
index 0000000..38f8872
--- /dev/null
+++ b/src/main/resources/application-prod.yml
@@ -0,0 +1,5 @@
+# 운영(EC2) 전용 설정. CD가 SPRING_PROFILES_ACTIVE=prod 로 띄운다.
+# 공통 값은 application.yml, 비밀값(DB_* 등)은 CD가 만든 .env 로 주입된다.
+server:
+ # 호스트 Nginx 뒤에서 X-Forwarded-* 헤더로 원래 요청의 IP·프로토콜을 인식한다
+ forward-headers-strategy: native
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index d9f087a..fd08b2e 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -17,3 +17,10 @@ spring:
hibernate:
jdbc:
time_zone: Asia/Seoul
+
+management:
+ endpoints:
+ web:
+ exposure:
+ # CD 배포 확인용. health 외 actuator 엔드포인트는 노출하지 않는다.
+ include: health