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
5 changes: 5 additions & 0 deletions .github/workflows/cd-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/cd-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ version: "3.8"

services:
app:
image: renzzle/github-action:latest
ports:
- "9001:9001"
env_file:
- .env
dns:
- 8.8.8.8
- 1.1.1.1
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD}
networks:
Expand All @@ -19,6 +23,7 @@ services:
db:
container_name: mysql
image: mysql:8.0
command: --default-authentication-plugin=mysql_native_password
volumes:
- ./mysql-data:/var/lib/mysql
ports:
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/renzzle/backend/BackendApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,7 @@
public class TestController {

private final TestService testService;
private final BackupScheduler backupScheduler;

@Operation(summary = "Server response test")
@GetMapping("/hello/{name}")
Expand Down Expand Up @@ -108,4 +110,11 @@ public ApiResponse<Boolean> deleteAllTestData() {
return ApiUtils.success(true);
}

@Operation(summary = "DB 백업 강제 실행", description = "로컬 테스트용: 즉시 DB 백업을 수행합니다.")
@GetMapping("/backup") // [3] 임시 엔드포인트 생성
public String manualBackup() {
backupScheduler.backupDatabase(); // [4] 메서드 직접 호출
return "백업 로직이 실행되었습니다. 서버 로그를 확인하세요.";
}

}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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;
}
}
8 changes: 8 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down