diff --git a/.github/workflows/Backend-CD.yml b/.github/workflows/Backend-CD.yml index 093d466..c133841 100644 --- a/.github/workflows/Backend-CD.yml +++ b/.github/workflows/Backend-CD.yml @@ -21,6 +21,7 @@ env: CONTAINER_1_NAME: relife_1 # 슬롯1(고정 이름) CONTAINER_2_NAME: relife_2 # 슬롯2(고정 이름) CONTAINER_PORT: 8080 # 컨테이너 내부 포트(스프링부트) + HEALTH_CHECK_PORT: 8090 # 헬스체크용 포트(임시, 추후 필요 시) EC2_INSTANCE_TAG_NAME: relife-ec2-1 # 배포 대상 EC2 Name 태그 DOCKER_NETWORK: common # 도커 네트워크 BACKEND_DIR: back # Dockerfile 위치 @@ -87,15 +88,73 @@ jobs: prerelease: false # --------------------------------------------------------- - # 2) 도커 이미지 빌드/푸시 (캐시 최대 활용) + # 2) 커밋 해시 값으로부터 PR 당시 Artifact의 run_id 추출 + # --------------------------------------------------------- + findCorrespondingCIrun: + runs-on: ubuntu-latest + outputs: + run_id: ${{ steps.find_run.outputs.run_id }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # .git 히스토리 가져오기 + + - name: 연관있는 PR 및 CI workflow 실행 찾기 + id: find_run + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # 1. 현재 push를 유발한 커밋(merge commit) SHA 가져오기 + MERGE_COMMIT_SHA=${{ github.sha }} + echo "Merge commit SHA: $MERGE_COMMIT_SHA" + + # 2. 커밋 SHA를 이용해 병합된 PR 번호 찾기 + PR_NUMBER=$(gh pr list --search "$MERGE_COMMIT_SHA" --state merged --json number --jq '.[0].number') + if [ -z "$PR_NUMBER" ]; then + echo "⚠️ Could not find a merged PR for this commit" + exit 1 + fi + echo "✅ Found PR number: $PR_NUMBER" + + # 3. PR의 마지막 커밋(head SHA) 알아내기 + PR_HEAD_SHA=$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid') + echo "PR head SHA: $PR_HEAD_SHA" + + # 4. PR의 마지막 커밋과 CI 워크플로우 파일 이름을 이용해 성공한 CI 실행(run)의 ID 찾기 + # CI 워크플로우 파일명과 일치 + RUN_ID=$(gh run list --workflow="Backend-CI.yml" --commit="$PR_HEAD_SHA" --status=success --json databaseId --jq '.[0].databaseId') + if [ -z "$RUN_ID" ]; then + echo "⚠️ Could not find a successful CI run for this PR." + exit 1 + fi + echo "✅ Found CI run ID: $RUN_ID" + + # 5. 찾은 RUN_ID를 output으로 내보내기 + echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT + + # --------------------------------------------------------- + # 3) 도커 이미지 빌드/푸시 # --------------------------------------------------------- buildImageAndPush: name: 도커 이미지 빌드와 푸시 - needs: createTagAndRelease + needs: [createTagAndRelease, findCorrespondingCIrun] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Create dist directory + working-directory: back + run: mkdir -p dist + + - name: CI 상에서 나온 Artifact 다운로드 + uses: dawidd6/action-download-artifact@v6 + with: + # 앞 단계에서 찾은 run_id 사용 + workflow: Backend-CI.yml + run_id: ${{ needs.findCorrespondingCIrun.outputs.run_id }} + name: relife-backend-jar + path: '${{ env.BACKEND_DIR }}/dist' # BACKEND_DIR 기준 + - name: Docker Buildx 설치 uses: docker/setup-buildx-action@v3 @@ -126,7 +185,7 @@ jobs: ghcr.io/${{ env.OWNER_LC }}/${{ env.IMAGE_REPOSITORY }}:latest # --------------------------------------------------------- - # 3) Blue/Green 무중단 배포 (EC2 + NPM 스위치) + # 4) Blue/Green 무중단 배포 (EC2 + NPM 스위치) # --------------------------------------------------------- deploy: name: Blue/Green 무중단 배포 @@ -187,6 +246,7 @@ jobs: SLOT1="${{ env.CONTAINER_1_NAME }}" SLOT2="${{ env.CONTAINER_2_NAME }}" PORT_IN="${{ env.CONTAINER_PORT }}" + HEALTH_PORT="${{ env.HEALTH_CHECK_PORT }}" NET="${{ env.DOCKER_NETWORK }}" ENV_FILE="/tmp/relife.env" @@ -268,7 +328,7 @@ jobs: "${IMAGE}" # --------------------------------------------------------- - # 5) 헬스체크 (/actuator/health 200 OK까지 대기) + # 5) 헬스체크 (/actuator/health/readiness 200 OK까지 대기) # --------------------------------------------------------- echo "⏱ health-check: ${GREEN}" TIMEOUT=120 @@ -277,7 +337,7 @@ jobs: sleep 8 # 초기 부팅 여유 while (( ELAPSED < TIMEOUT )); do - CODE=$(docker exec "${GREEN}" curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT_IN}/actuator/health" || echo 000) + CODE=$(docker exec "${GREEN}" curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${HEALTH_PORT}/actuator/health/readiness" || echo 000) if [[ "${CODE}" == "200" ]]; then echo "✅ ${GREEN} is healthy" break diff --git a/.github/workflows/Backend-CI.yml b/.github/workflows/Backend-CI.yml index 74fb047..670640a 100644 --- a/.github/workflows/Backend-CI.yml +++ b/.github/workflows/Backend-CI.yml @@ -63,7 +63,17 @@ jobs: with: distribution: "graalvm" java-version: "21" - cache: "gradle" + + # Gradle 캐시 설정 + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- # Gradle 실행 권한 부여 - name: Grant execute permission for gradlew @@ -87,4 +97,18 @@ jobs: uses: mikepenz/action-junit-report@v5 if: always() with: - report_paths: "**/build/test-results/test/TEST-*.xml" \ No newline at end of file + report_paths: "**/build/test-results/test/TEST-*.xml" + + # 빌드 및 테스트가 완료된 JAR 파일을 별도 디렉토리에 복사 + - name: Copy JAR file to dist directory + run: | + mkdir -p dist + cp $(ls build/libs/*.jar | grep -v plain | head -n 1) dist/app.jar + + # JAR 파일을 아티팩트로 업로드 (CD 파이프라인에서 사용) + - name: Upload artifact + uses: actions/upload-artifact@v4 + if: success() + with: + name: relife-backend-jar + path: back/dist/app.jar \ No newline at end of file diff --git a/back/.dockerignore b/back/.dockerignore index f3cc755..1ef7663 100644 --- a/back/.dockerignore +++ b/back/.dockerignore @@ -9,7 +9,8 @@ build/ .gradle/ # Files -.env.example +.env.local +.env.production .gitignore .dockerignore diff --git a/back/.env.local b/back/.env.local new file mode 100644 index 0000000..f42a997 --- /dev/null +++ b/back/.env.local @@ -0,0 +1,11 @@ +AWS_REGION=SHOULD_BE_SET_IF_YOU_USE_AWS_DEPENDENCIES +AWS_ACCESS_KEY_ID=SHOULD_BE_SET_IF_YOU_USE_AWS_DEPENDENCIES +AWS_SECRET_ACCESS_KEY=SHOULD_BE_SET_IF_YOU_USE_AWS_DEPENDENCIES +AWS_CLOUD_FRONT_DOMAIN=SHOULD_BE_SET_IF_YOU_USE_AWS_DEPENDENCIES +AWS_S3_BUCKET_NAME=SHOULD_BE_SET_IF_YOU_USE_AWS_DEPENDENCIES +PROD_BASE_DOMAIN=localhost +GOOGLE_CLIENT_ID=MUST_BE_SET_AT_LEAST +GOOGLE_CLIENT_SECRET=MUST_BE_SET_AT_LEAST +GITHUB_CLIENT_ID=MUST_BE_SET_AT_LEAST +GITHUB_CLIENT_SECRET=MUST_BE_SET_AT_LEAST +GEMINI_API_KEY=MUST_BE_SET_AT_LEAST \ No newline at end of file diff --git a/back/.env.production b/back/.env.production new file mode 100644 index 0000000..601ca89 --- /dev/null +++ b/back/.env.production @@ -0,0 +1,14 @@ +AWS_REGION= +AWS_CLOUD_FRONT_DOMAIN= +AWS_S3_BUCKET_NAME= +AWS_RDS_ENDPOINT= +AWS_RDS_PORT= +AWS_RDS_DB_NAME= +AWS_RDS_USERNAME= +PASSWORD_1= +PROD_BASE_DOMAIN= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GEMINI_API_KEY= \ No newline at end of file diff --git a/back/Dockerfile b/back/Dockerfile index 3d1cff9..16df1d6 100644 --- a/back/Dockerfile +++ b/back/Dockerfile @@ -1,42 +1,22 @@ -##################################################### -# 마지막 수정: 250924 +############################################## +# 마지막 수정: 250929 # 작성자: gooraeng # # CD 과정에서 사용될 Dockerfile 입니다. -# 멀티 스테이지 적용 (빌드 스테이지, 실행 스테이지) -##################################################### - -######### 빌드 스테이지 시작 ######### -FROM gradle:jdk-21-and-23-graal-jammy AS builder - -# 작업 디렉토리 설정 -WORKDIR /app - -# Gradle build, Setting 파일 복사 -COPY build.gradle.kts settings.gradle.kts gradlew ./ -COPY gradle gradle - -# Gradlew에 실행권한 부여 -RUN chmod +x gradlew - -# 종속성 설치 -RUN ./gradlew dependencies --no-daemon - -# 소스 코드 복사 -COPY src src - -# 애플리케이션 빌드 (CI 통과 이후 테스트 불필요) -RUN ./gradlew build -x test --no-daemon --build-cache -######### 빌드 스테이지 끝 ######### - +# 싱글 스테이지 적용 (실행 스테이지) +############################################## ######### 실행 스테이지 시작 ######### -FROM container-registry.oracle.com/graalvm/jdk:21 +FROM eclipse-temurin:21-jre # 작업 디렉토리 설정 WORKDIR /app +RUN apt-get update && \ + apt-get install -y curl && \ + rm -rf /var/lib/apt/lists/* + # 첫 번째(빌드) 스테이지에서 빌드된 JAR 파일 복사 -COPY --from=builder /app/build/libs/*.jar app.jar +COPY dist/app.jar app.jar # JVM 메모리 설정 # - XX:MaxRAMPercentage=N diff --git a/back/build.gradle.kts b/back/build.gradle.kts index ea63acd..a15b578 100644 --- a/back/build.gradle.kts +++ b/back/build.gradle.kts @@ -31,6 +31,20 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-oauth2-client") // OAuth2 Client 추가 + // Redis + implementation("org.springframework.boot:spring-boot-starter-data-redis") + + // Session + implementation("org.springframework.session:spring-session-data-redis") + + // Health Check + implementation("org.springframework.boot:spring-boot-starter-actuator") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // Swagger implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9") @@ -38,14 +52,18 @@ dependencies { implementation("io.jsonwebtoken:jjwt-api:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") + + // Lombok compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + + // Database runtimeOnly("com.h2database:h2") runtimeOnly("org.postgresql:postgresql") - annotationProcessor("org.projectlombok:lombok") - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.security:spring-security-test") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // Migration + implementation("org.flywaydb:flyway-core:11.11.2") + runtimeOnly("org.flywaydb:flyway-database-postgresql:11.11.2") // QueryDSL implementation("io.github.openfeign.querydsl:querydsl-jpa:7.0") @@ -62,4 +80,3 @@ dependencies { tasks.withType { useJUnitPlatform() } - diff --git a/back/src/main/java/com/back/BackApplication.java b/back/src/main/java/com/back/BackApplication.java index a15f5d5..1ac181e 100644 --- a/back/src/main/java/com/back/BackApplication.java +++ b/back/src/main/java/com/back/BackApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableJpaAuditing @SpringBootApplication +@EnableCaching public class BackApplication { public static void main(String[] args) { diff --git a/back/src/main/java/com/back/global/security/SecurityConfig.java b/back/src/main/java/com/back/global/security/SecurityConfig.java index 95367dc..cbb7240 100644 --- a/back/src/main/java/com/back/global/security/SecurityConfig.java +++ b/back/src/main/java/com/back/global/security/SecurityConfig.java @@ -52,6 +52,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/users-auth/**", "/oauth2/**", "/login/oauth2/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") + .requestMatchers("/actuator/**").permitAll() .anyRequest().authenticated() ) .logout(logout -> logout diff --git a/back/src/main/resources/application-prod.yml b/back/src/main/resources/application-prod.yml new file mode 100644 index 0000000..ff43f51 --- /dev/null +++ b/back/src/main/resources/application-prod.yml @@ -0,0 +1,78 @@ +spring: + # AWS RDS(PostgreSQL) 관련 설정 + # EC2에 역할 부여를 하더라도 해당 값은 반드시 들어가야 합니다. + autoconfigure: + exclude: [] + datasource: + url: jdbc:postgresql://${AWS_RDS_ENDPOINT}:${AWS_RDS_PORT}/${AWS_RDS_DB_NAME}?sslmode=require + username: ${AWS_RDS_USERNAME} + password: ${PASSWORD_1} + driver-class-name: org.postgresql.Driver + flyway: + enabled: true + locations: classpath:db/migration + baseline-version: 0 + data: + redis: + host: redis_1 + password: ${PASSWORD_1} + jpa: + hibernate: + ddl-auto: validate # 운영 환경에서는 validate 또는 none 권장 + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: false + highlight_sql: false + use_sql_comments: false + # https://bugoverdose.github.io/docs/database-connection-pool-sizing/ + hikari: + maximum-pool-size: 7 # RDS 최대 커넥션 풀 core * (base_conn - 1) + effective_spindle + idle-timeout: 600000 # 커넥션이 유휴 상태로 유지되는 최대 시간 (10분) + max-lifetime: 1800000 # 커넥션의 최대 수명 (30분) + connection-timeout: 30000 # 커넥션을 얻기 위해 대기하는 최대 시간 (30초) + leak-detection-threshold: 60000 # 커넥션 누수 감지 임계값 (60초) + profiles: + active: prod + +logging: + level: + org.hibernate.orm.jdbc.bind: INFO + org.hibernate.orm.jdbc.extract: INFO + org.springframework.transaction.interceptor: INFO + org.springframework.security.oauth2: INFO + org.springframework.security.web: INFO + com.back: INFO + +management: + endpoints: + web: + exposure: + include: "health,info" + endpoint: + health: + probes: + enabled: true + show-details: never + # 헬스 체크 포트 (외부 공개 X) + server: + port: 8090 + +server: + servlet: + session: + cookie: + secure: true # 운영 true(HTTPS) + same-site: strict + domain: ${custom.site.baseDomain} + +springdoc: + api-docs: + enabled: false + +custom: + site: + baseDomain: "${custom.prod.baseDomain}" + frontUrl: "${custom.prod.frontUrl}" + backUrl: "${custom.prod.backUrl}" \ No newline at end of file diff --git a/back/src/main/resources/application.yml b/back/src/main/resources/application.yml index 672a264..356e0b0 100644 --- a/back/src/main/resources/application.yml +++ b/back/src/main/resources/application.yml @@ -1,6 +1,14 @@ spring: application: name: back + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration + config: + import: optional:file:.env[.properties] + flyway: + enabled: false profiles: active: test output: @@ -19,19 +27,20 @@ spring: format_sql: true highlight_sql: true default_batch_fetch_size: 100 + open-in-view: false security: # 여기에 추가 oauth2: client: registration: google: - client-id: your-google-client-id - client-secret: your-google-client-secret + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} scope: - email - profile github: - client-id: your-github-client-id - client-secret: your-github-client-secret + client-id: ${GITHUB_CLIENT_ID} + client-secret: ${GITHUB_CLIENT_SECRET} scope: - user:email - read:user @@ -85,4 +94,20 @@ server: name: JSESSIONID http-only: true secure: false # 로컬 false, 운영 true(HTTPS) - same-site: Lax \ No newline at end of file + same-site: Lax + domain: ${custom.site.baseDomain} + forward-headers-strategy: native + +custom: + dev: + baseDomain: localhost + frontUrl: "http://${custom.dev.baseDomain}:3000" + backUrl: "http://${custom.dev.baseDomain}:${server.port}" + prod: + baseDomain: ${PROD_BASE_DOMAIN} + frontUrl: "https://www.${custom.prod.baseDomain}" + backUrl: "https://api.${custom.prod.baseDomain}" + site: + baseDomain: ${custom.dev.baseDomain} + frontUrl: ${custom.dev.frontUrl} + backUrl: ${custom.dev.backUrl} \ No newline at end of file diff --git a/back/src/main/resources/db/README.md b/back/src/main/resources/db/README.md new file mode 100644 index 0000000..996826b --- /dev/null +++ b/back/src/main/resources/db/README.md @@ -0,0 +1,64 @@ +# Flyway Migration Guide + +## 파일 명명 규칙 + +``` +- resources/db/migration 에서 작업 + +V{version}__{description}.sql +``` + +- **V**: 버전 마이그레이션 (필수) +- **version**: 숫자 (예: 1, 2, 1.1, 2023.01.01) +- **__**: 언더스코어 2개로 구분 +- **description**: 영문 설명 (snake_case) + +### 예시 +``` +V1__init_schema.sql +V2__add_user_table.sql +V3__add_post_indexes.sql +V3.1__fix_user_email_constraint.sql +``` + +## 마이그레이션 작성 예시 + +### V1__init_schema.sql +```sql +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + nickname VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_users_email ON users(email); +``` + +### V2__add_posts.sql +```sql +CREATE TABLE posts ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + title VARCHAR(200) NOT NULL, + content TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +``` + +## 주의사항 + +1. **절대 수정 금지**: 이미 적용된 마이그레이션 파일은 수정하지 말 것 +2. **롤백 없음**: Flyway는 자동 롤백을 지원하지 않음 (수동으로 새 마이그레이션 작성) +3. **순서 보장**: 버전 번호 순서대로 실행됨 +4. **Production 필수**: `application-prod.yml`에서 `flyway.enabled: true` + +## 현재 설정 + +- **Location**: `classpath:db/migration` +- **Baseline**: `0` +- **개발환경**: Flyway 비활성화 (H2 자동 스키마) +- **운영환경**: Flyway 활성화 (PostgreSQL)