본문 바로가기

Monew

S3 버킷에 데이터를 '백업'하는 로직

 - 날짜 단위로 백업하기

 - 백업 저장소는 AWS S3를 활용하기

 - 백업 작업은 배치로 수행하기

 

DTO

package com.codeit.monew.article.backUp;

import com.codeit.monew.article.entity.Article;

import java.time.LocalDateTime;

public record ArticleBackupDto(
        String source,
        String sourceUrl,
        String articleTitle,
        LocalDateTime articlePublishDate,
        String articleSummary,
        long articleCommentCount,
        long articleViewCount,
        boolean deleted,
        String interestId // 복구할 때 필요함
) {
    public static ArticleBackupDto from(Article article) {
        // Interest가 Lazy해도 ID접근을 프록시로 가능하게끔
        String interestIdStr = (article.getInterest() != null && article.getInterest().getId() != null)
                ? article.getInterest().getId().toString()
                : null;

        return new ArticleBackupDto(
                article.getSource(),
                article.getSourceUrl(),
                article.getArticleTitle(),
                article.getArticlePublishDate(),
                article.getArticleSummary(),
                article.getArticleCommentCount(),
                article.getArticleViewCount(),
                article.isDeleted(),
                interestIdStr
        );
    }
}

 

 

AWS

 

S3Config

package com.codeit.monew.article.backUp.aws;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

@Configuration
public class S3Config {

    @Value("${aws.s3.region}")
    private String region;

    @Bean
    public S3Client s3Client() {
        return S3Client.builder().region(Region.AP_NORTHEAST_2).build();
    }
}

 

 

BackupUtility

package com.codeit.monew.article.backUp.aws;

import java.time.LocalDate;

public class BackupUtility {
    // S3에 백업파일을 저장할 때 파일 경로(키)를 만들어주는 유틸리티
    private BackupUtility() {}

    public static String keyFor(LocalDate date) {
        String y = "%04d".formatted(date.getYear());
        String m = "%02d".formatted(date.getMonthValue());
        String d = "%02d".formatted(date.getDayOfMonth());
        return "backups/articles/%s/%s/%s/articles-%s-%s-%s.jsonl.gz"
                .formatted(y,m,d,y,m,d); // backups/articles/2025/09/16/articles-2025-09-16.jsonl.gz
    }

    // 위 파일명을 임시 업로드용 파일명으로 바꾸는 메서드
    // 임시 키(.part)로 먼저 업로드하고, 업로드 성공하면 최종 키(.json.gz)로 복사 후 임시파일 삭제
    public static String tempKeyFor(LocalDate date, String uuid) {
        return keyFor(date).replace(".jsonl.gz", "." + uuid + ".part");
    }
}

 

 

Repository

package com.codeit.monew.article.backUp.repository;

import com.codeit.monew.article.entity.Article;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

public interface ArticleBackupRepository extends JpaRepository<Article, UUID> {
    @Query("""
        select art from Article as art
        where art.articlePublishDate >= :startDate and art.articlePublishDate < :endDate

    """)
    Page<Article> findAllInRange(
            @Param("startDate")LocalDateTime startDate,
            @Param("endDate")LocalDateTime endDate,
            Pageable pageable
            );

    @Query("""
        select art.sourceUrl from Article as art
        where art.articlePublishDate >= :startDate and art.articlePublishDate < :endDate
    """)
    List<String> findAllSourceUrlsInRange(
            @Param("startDate") LocalDateTime startDate,
            @Param("endDate") LocalDateTime endDate
    );
}

 

 

 

Service

package com.codeit.monew.article.backUp.service;

import com.codeit.monew.article.backUp.dto.ArticleBackupDto;
import com.codeit.monew.article.backUp.aws.BackupUtility;
import com.codeit.monew.article.backUp.repository.ArticleBackupRepository;
import com.codeit.monew.article.entity.Article;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.zip.GZIPOutputStream;

@Service
@RequiredArgsConstructor
@Slf4j
public class ArticleBackupService {

    private static final int PAGE_SIZE = 1000;

    private final ArticleBackupRepository articleBackupRepository;
    private final S3Client s3;

    @Value("${app.backup.bucket}")
    String bucket;

    private final ObjectMapper objectMapper = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);


    // 날짜에 따라 백업하는 메서드
    public void articleBackup(LocalDate date) {
        LocalDateTime start = date.atStartOfDay();
        LocalDateTime end = date.plusDays(1).atStartOfDay();

        String backupKey = BackupUtility.keyFor(date);
        String tempKey = BackupUtility.tempKeyFor(date, UUID.randomUUID().toString()); // UUID String

        Path tmp = null;
        try {
            tmp = Files.createTempFile("articleBackup",".jsonl.gz");
            System.out.println("tmp 경로 : " + tmp.toAbsolutePath());
            try (
                    OutputStream outOs = Files.newOutputStream(tmp, StandardOpenOption.WRITE);
                    GZIPOutputStream gzipOs = new GZIPOutputStream(outOs);
                    OutputStreamWriter outOsWriter = new OutputStreamWriter(gzipOs, StandardCharsets.UTF_8)
            )
            {
                Pageable pageable = PageRequest.of(0, PAGE_SIZE, Sort.by("id").ascending()); // import 주의
                Page<Article> page;
                long total = 0;

                do {
                    page = articleBackupRepository.findAllInRange(start, end, pageable);
                    for(Article article : page.getContent()) {
                        ArticleBackupDto articleDto = ArticleBackupDto.from(article);
                        outOsWriter.write(objectMapper.writeValueAsString(articleDto));
                        outOsWriter.write('\n');
                        total++;
                    }
                    outOsWriter.flush();
                    pageable = page.nextPageable();
                } while(!page.isLast());

                log.info("백업 작성 종료됨, date={}, count={}, file={}", date, total, tmp);

            } // 안쪽 try

            // S3 임시 키 업로드하기
            long size = Files.size(tmp);
            PutObjectRequest putObject = PutObjectRequest.builder()
                    .bucket(bucket)
                    .key(tempKey)
                    .contentType("application/x-ndjson")
                    .contentEncoding("gzip")
                    .build();
            try(InputStream input = Files.newInputStream(tmp)) { // s3에 올리는 로직
                s3.putObject(putObject, RequestBody.fromInputStream(input,size));
            }

            s3.copyObject(c -> c
                    .sourceBucket(bucket).sourceKey(tempKey)
                    .destinationBucket(bucket).destinationKey(backupKey));
            s3.deleteObject(b -> b.bucket(bucket).key(tempKey));

            log.info("백업 업로드됨, s3://{}/{}", bucket, backupKey);

        } catch(Exception e) {
          log.error("백업 실패함, date = {}", date, e);
          throw new RuntimeException(e);
        } finally {
            if(tmp != null) {
                try {
                    Files.deleteIfExists(tmp);
                } catch (IOException ioException) {

                }
            }
        } // 바깥쪽 try
    }
}

 

 

 

Scheduler

package com.codeit.monew.article.backUp.scheduler;

import com.codeit.monew.article.backUp.service.ArticleBackupService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.ZoneId;


@Component
@RequiredArgsConstructor
@Slf4j
public class BackupScheduler {
    private final ArticleBackupService backupService;

    // 초, 분, 시, 일, 월, 요일
    // 매일 0시, 전날의 데이터를 백업함
    @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
    public void backupArticle() {
        LocalDate target = LocalDate.now(ZoneId.of("Asia/Seoul")).minusDays(1);
        log.info("뉴스기사 백업 시작, {}", target);
        backupService.articleBackup(target);
    }
}

 

 

의존성

    implementation 'software.amazon.awssdk:s3:2.31.7' // 아마존 S3 버킷 백업/복구

    implementation 'com.fasterxml.jackson.core:jackson-databind' // Jackson
    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' // Jackson

    implementation 'org.springframework.boot:spring-boot-starter-logging' // 로깅

 

'Monew' 카테고리의 다른 글

CD(지속적 배포) 작성  (0) 2025.09.19
S3에 백업한 뉴스 기사를 '복구'하는 기능  (0) 2025.09.19
CI(지속적 통합) 작성  (0) 2025.09.12
build.gradle  (0) 2025.09.04
Jacoco를 활용하기 (테스트 커버리지)  (0) 2025.09.04