Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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/
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
*.sh text eol=lf
99 changes: 99 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

<br>
Expand Down Expand Up @@ -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"
Expand All @@ -143,6 +144,24 @@ IntelliJ에서 띄울 때는 Run Configuration의 Environment variables에 같

<br>

## 배포

수동 배포는 하지 않습니다. `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`

<br>

## API 명세

API 명세는 노션에서 관리하고 있습니다. springdoc을 붙이면 Swagger 주소를 여기에 추가할 예정입니다.
Expand Down
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -41,3 +42,8 @@ dependencies {
tasks.named('test') {
useJUnitPlatform()
}

// plain jar를 끈다 → build/libs 에 실행 가능한 jar 하나만 남아 Dockerfile 이 그대로 복사한다
tasks.named('jar') {
enabled = false
}
22 changes: 22 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# EC2에서 앱 컨테이너를 기동하는 구성.
# 실제 값(이미지 태그, DB 접속정보)은 같은 폴더의 .env 를 CD 워크플로우가 생성해 주입한다.
services:
app:
image: ${DOCKER_IMAGE} # 예: <dockerhub-id>/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
54 changes: 54 additions & 0 deletions docs/infra/ec2-user-data.sh
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions src/main/java/com/itssum/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
5 changes: 5 additions & 0 deletions src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 운영(EC2) 전용 설정. CD가 SPRING_PROFILES_ACTIVE=prod 로 띄운다.
# 공통 값은 application.yml, 비밀값(DB_* 등)은 CD가 만든 .env 로 주입된다.
server:
# 호스트 Nginx 뒤에서 X-Forwarded-* 헤더로 원래 요청의 IP·프로토콜을 인식한다
forward-headers-strategy: native
7 changes: 7 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@ spring:
hibernate:
jdbc:
time_zone: Asia/Seoul

management:
endpoints:
web:
exposure:
# CD 배포 확인용. health 외 actuator 엔드포인트는 노출하지 않는다.
include: health
Loading