diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index 92a4786d..577e45df 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -33,6 +33,11 @@ jobs: GMAIL_ADDRESS: ${{ secrets.GMAIL_ADDRESS }} GMAIL_PASSWORD: ${{ secrets.GMAIL_PASSWORD }} JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} + + # 백업 DB 환경변수 + BACKUP_DB_URL: ${{ secrets.BACKUP_DB_URL}} + BACKUP_DB_USER: ${{ secrets.BACKUP_DB_USER }} + BACKUP_DB_PASSWORD: ${{ secrets.BACKUP_DB_PASSWORD }} run: ./gradlew build -x test --stacktrace - name: Set up Docker environment @@ -95,6 +100,9 @@ jobs: echo "GMAIL_PASSWORD=${{ secrets.GMAIL_PASSWORD }}" >> .env echo "GMAIL_ADDRESS=\"${{ secrets.GMAIL_ADDRESS }}\"" >> .env echo "JWT_SECRET_KEY=\"${{ secrets.JWT_SECRET_KEY }}\"" >> .env + echo "BACKUP_DB_URL=${{ secrets.BACKUP_DB_URL }}" >> .env + echo "BACKUP_DB_USER=\"${{ secrets.BACKUP_DB_USER }}\"" >> .env + echo "BACKUP_DB_PASSWORD=\"${{ secrets.BACKUP_DB_PASSWORD }}\"" >> .env - name: Copy .env file to EC2 uses: appleboy/scp-action@master diff --git a/.github/workflows/cd-test.yml b/.github/workflows/cd-test.yml index 760e1d3b..1468bd43 100644 --- a/.github/workflows/cd-test.yml +++ b/.github/workflows/cd-test.yml @@ -52,6 +52,12 @@ jobs: GMAIL_ADDRESS: ${{ secrets.GMAIL_ADDRESS }} GMAIL_PASSWORD: ${{ secrets.GMAIL_PASSWORD }} JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} + + # 백업 DB 환경변수 + BACKUP_DB_URL: ${{ secrets.BACKUP_DB_URL}} + BACKUP_DB_USER: ${{ secrets.BACKUP_DB_USER }} + BACKUP_DB_PASSWORD: ${{ secrets.BACKUP_DB_PASSWORD }} + run: ./gradlew build -x test --stacktrace - name: Login to Docker Hub diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0701ffd2..4eaec7e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: Java CI with Gradle on: pull_request: - branches: [ "main"] + branches: [ "main", "develop" ] permissions: write-all @@ -32,6 +32,11 @@ jobs: echo "GMAIL_PASSWORD=${{ secrets.GMAIL_PASSWORD }}" >> .env echo "GMAIL_ADDRESS=\"${{ secrets.GMAIL_ADDRESS }}\"" >> .env echo "JWT_SECRET_KEY=\"${{ secrets.JWT_SECRET_KEY }}\"" >> .env + + # 백업 DB 환경변수 + echo "BACKUP_DB_URL=\"${{ secrets.BACKUP_DB_URL }}\"" >> .env + echo "BACKUP_DB_USER=${{ secrets.BACKUP_DB_USER }}" >> .env + echo "BACKUP_DB_PASSWORD=${{ secrets.BACKUP_DB_PASSWORD }}" >> .env - name: Load environment variables from .env run: cat .env >> $GITHUB_ENV diff --git a/Dockerfile b/Dockerfile index f48d0a78..b4736600 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ # Use an official OpenJDK runtime as a parent image -FROM eclipse-temurin:17-jdk-alpine +FROM eclipse-temurin:17-jdk +# mysqldump 명령어를 사용 +RUN apt-get update && apt-get install -y default-mysql-client && rm -rf /var/lib/apt/lists/* # Set the working directory in the container WORKDIR /app diff --git a/docker-compose.yml b/docker-compose.yml index 322abfcf..e32b7bb3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,9 @@ services: - "9001:9001" env_file: - .env + dns: + - 8.8.8.8 + - 1.1.1.1 environment: - REDIS_PASSWORD=${REDIS_PASSWORD} networks: @@ -19,6 +22,7 @@ services: db: container_name: mysql image: mysql:8.0 + command: --default-authentication-plugin=mysql_native_password volumes: - ./mysql-data:/var/lib/mysql ports: diff --git a/src/main/java/com/renzzle/backend/BackendApplication.java b/src/main/java/com/renzzle/backend/BackendApplication.java index 1b456fad..0f329b35 100644 --- a/src/main/java/com/renzzle/backend/BackendApplication.java +++ b/src/main/java/com/renzzle/backend/BackendApplication.java @@ -3,7 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @EnableAsync @SpringBootApplication public class BackendApplication { diff --git a/src/main/java/com/renzzle/backend/domain/test/api/TestController.java b/src/main/java/com/renzzle/backend/domain/test/api/TestController.java index 4483fc0f..9d546df7 100644 --- a/src/main/java/com/renzzle/backend/domain/test/api/TestController.java +++ b/src/main/java/com/renzzle/backend/domain/test/api/TestController.java @@ -8,6 +8,7 @@ import com.renzzle.backend.domain.test.domain.TestEntity; import com.renzzle.backend.domain.test.service.TestService; import com.renzzle.backend.global.common.response.ApiResponse; +import com.renzzle.backend.global.scheduler.BackupScheduler; import com.renzzle.backend.global.util.ApiUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -25,6 +26,7 @@ public class TestController { private final TestService testService; + private final BackupScheduler backupScheduler; @Operation(summary = "Server response test") @GetMapping("/hello/{name}") @@ -108,4 +110,11 @@ public ApiResponse deleteAllTestData() { return ApiUtils.success(true); } + @Operation(summary = "DB 백업 강제 실행", description = "로컬 테스트용: 즉시 DB 백업을 수행합니다.") + @GetMapping("/backup") // [3] 임시 엔드포인트 생성 + public String manualBackup() { + backupScheduler.backupDatabase(); // [4] 메서드 직접 호출 + return "백업 로직이 실행되었습니다. 서버 로그를 확인하세요."; + } + } diff --git a/src/main/java/com/renzzle/backend/global/config/DataSourceConfig.java b/src/main/java/com/renzzle/backend/global/config/DataSourceConfig.java new file mode 100644 index 00000000..d2cc1d46 --- /dev/null +++ b/src/main/java/com/renzzle/backend/global/config/DataSourceConfig.java @@ -0,0 +1,46 @@ +package com.renzzle.backend.global.config; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.core.JdbcTemplate; + +import javax.sql.DataSource; + +public class DataSourceConfig { + + // --- 1. 메인 DB 설정 (기존 JPA가 사용할 DB) --- + @Primary + @Bean + @ConfigurationProperties("spring.datasource") + public DataSourceProperties mainDataSourceProperties() { + return new DataSourceProperties(); + } + + @Primary + @Bean + public DataSource mainDataSource() { + return mainDataSourceProperties().initializeDataSourceBuilder().build(); + } + + // --- 2. 백업 DB 설정 (새로 추가한 Aiven DB) --- + @Bean + @ConfigurationProperties("backup.datasource") + public DataSourceProperties backupDataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean(name = "backupDataSource") + public DataSource backupDataSource() { + return backupDataSourceProperties().initializeDataSourceBuilder().build(); + } + + // 백업 DB에 쿼리를 날리기 위한 JdbcTemplate 등록 + @Bean(name = "backupJdbcTemplate") + public JdbcTemplate backupJdbcTemplate(@Qualifier("backupDataSource") DataSource dataSource) { + return new JdbcTemplate(dataSource); + } +} diff --git a/src/main/java/com/renzzle/backend/global/scheduler/BackupScheduler.java b/src/main/java/com/renzzle/backend/global/scheduler/BackupScheduler.java new file mode 100644 index 00000000..f1136b04 --- /dev/null +++ b/src/main/java/com/renzzle/backend/global/scheduler/BackupScheduler.java @@ -0,0 +1,116 @@ +package com.renzzle.backend.global.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BackupScheduler { + + // application.yml에서 환경변수를 가져옵니다. + @Value("${spring.datasource.url}") + private String mainDbUrl; // 예: jdbc:mysql://host:3306/db + @Value("${spring.datasource.username}") + private String mainDbUser; + @Value("${spring.datasource.password}") + private String mainDbPassword; + + // 백업 DB (Aiven) 정보 - 환경변수로 따로 관리하는 것을 추천합니다. + + @Value("${backup.datasource.url}") + private String backupDbUrl; // 전체 JDBC URL을 받아옵니다. + @Value("${backup.datasource.username}") + private String backupUser; + @Value("${backup.datasource.password}") + private String backupPassword; + + @Scheduled(cron = "0 0 4 * * *") + public void backupDatabase() { + log.info("🚀 [Backup Start] 전체 데이터베이스 백업을 시작합니다..."); + + try { + // 1. 메인 DB 호스트 파싱 + String mainHost = parseHost(mainDbUrl); + String mainDbName = parseDbName(mainDbUrl); + + // 2. 백업 DB (Aiven) 정보 파싱 (URL에서 추출) + String backupHost = parseHost(backupDbUrl); + String backupPort = parsePort(backupDbUrl); + String backupDbName = parseDbName(backupDbUrl); + + // 2. 셸 명령어 작성 (mysqldump -> mysql) + // ProcessBuilder의 환경변수 맵을 활용해 안전하게 주입 + String command = String.format( + // [Source: 로컬 Docker MySQL] + // 1. --no-tablespaces : 아까 겪으신 'Access denied' 권한 에러 해결 + // 2. --set-gtid-purged=OFF : DB 간 이동 시 ID 충돌 방지 + // 3. --ssl-mode=DISABLED : 로컬 도커는 SSL 설정이 없으므로 DISABLED가 맞음 + "mysqldump -h %s -u %s -p$MAIN_PWD --single-transaction --skip-lock-tables --routines --triggers --no-tablespaces --set-gtid-purged=OFF --ssl-mode=DISABLED %s | " + + + // 4. --ssl-mode=REQUIRED : Aiven은 보안상 SSL 필수 + "mysql -h %s -P %s -u %s -p$BACKUP_PWD --ssl-mode=REQUIRED %s", + + + mainHost, mainDbUser, mainDbName, + backupHost, backupPort, backupUser, backupDbName + ); + + // 3. 프로세스 실행 + ProcessBuilder pb = new ProcessBuilder("/bin/sh", "-c", command); + Map env = pb.environment(); + env.put("MAIN_PWD", mainDbPassword); + env.put("BACKUP_PWD", backupPassword); + + Process process = pb.start(); + + // 로그 출력 + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.error("Backup Process Log: {}", line); + } + } + + int exitCode = process.waitFor(); + if (exitCode == 0) { + log.info("✅ [Backup Process Finished] 백업 성공!"); + } else { + log.error("❌ [Backup Failed] 종료 코드: {}", exitCode); + } + + } catch (Exception e) { + log.error("❌ [Backup Error] 백업 중 예외 발생", e); + } + } + + private String parseHost(String url) { + String cleanUrl = url.replace("jdbc:mysql://", ""); // host:port/dbName... + return cleanUrl.substring(0, cleanUrl.indexOf("/")).split(":")[0]; + } + + private String parsePort(String url) { + String cleanUrl = url.replace("jdbc:mysql://", ""); + String hostAndPort = cleanUrl.substring(0, cleanUrl.indexOf("/")); + if (hostAndPort.contains(":")) { + return hostAndPort.split(":")[1]; + } + return "3306"; // 포트 없으면 기본값 + } + + private String parseDbName(String url) { + String cleanUrl = url.replace("jdbc:mysql://", ""); + String dbAndParams = cleanUrl.substring(cleanUrl.indexOf("/") + 1); + if (dbAndParams.contains("?")) { + return dbAndParams.split("\\?")[0]; // 파라미터 제거 + } + return dbAndParams; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0b600104..a2bdebc1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,6 +56,14 @@ rank: session: ttl: 3600 # 초 단위, 운영 기본값 +backup: + datasource: + # Aiven용 JDBC URL (SSL 옵션 포함) + url: ${BACKUP_DB_URL} + username: ${BACKUP_DB_USER} + password: ${BACKUP_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + springdoc: packages-to-scan: com.renzzle.backend default-consumes-media-type: application/json;charset=UTF-8