오늘은 실무에서 월 배치 작업으로 로그성 데이터를 백업하는 작업에 대한 포스트이다.
상반기에 배포한 알림 시스템 파트에서 로그의 성격을 지닌 알림 내역 데이터는 서비스 기획 정책 상 최근 1개월 데이터만을 보여준다.
무수히 많이 생성되는 데이터 중 정책에 따라 1개월치만 남겨두고, 이전 데이터들은 csv 파일로 만들어 파일로써 만들고 이를 백업해두는 작업을 하려 한다.
이 작업으로 달성하고자 하는 목표는 데이터 경량화이다.
테이블이 보유한 데이터의 크기가 커질수록 조회해야 하는 데이터의 범위도 커지기에 그 범위를 줄이는 작업을 진행하려 한다.

전반적인 흐름은 위의 그림과 같다.
우선 스케쥴러 서버에 매월 1일 자정에 실행하도록 설정했다.
정책 : 1개월 내 데이터만 노출
예: 2024-09-01 00:00:00 에 스케줄러가 돌면, 2024-08-01 ~ 2024-08-31 까지의 데이터를 조회해서 파일로 만들고 S3에 업로드한다.
테이블 데이터를 그대로 import 하기 용이하도록 csv 파일로 만들었다. 여기에 더해 파일 업로드 속도 개선 목적으로 해당 파일을 zip 하는 과정도 추가했다.
DB 외에 별도의 저장소로는 S3를 사용하고 있다. S3로 파일을 업로드해서 월간 단위로 데이터를 관리한다.
Springboot 3.2.2
Kotlin
JDK 21
MySQL
아래 csv 파일 생성과 파일 압축을 위해 필요한 라이브러리 의존성을 추가한다.
build.gradle.kts
...
implementation ("org.apache.commons:commons-csv:1.10.0")
implementation ("org.apache.commons:commons-compress:1.27.1")
...
BackUpController.kt
RestController
@RequestMapping("/api/alarm-backup")
class BackUpController(
private val s3Util: S3Util,
private val csvUtil: CsvUtil,
private val slackUtil: SlackUtil,
private val getAlarmMonthsAgoUseCase: GetAlarmMonthsAgoUseCase,
private val deleteAlarmMonthsAgoUseCase: DeleteAlarmMonthsAgoUseCase,
private val saveAlarmBackUpLogsUseCase: SaveAlarmBackUpLogsUseCase
) {
// 매월 1일 동작
@Scheduled(cron = "0 0 0 1 * ?")
@Transactional
fun backupCsv(date: LocalDate? = null) {
try {
val alarmsMapByLdt = this.getAlarmMonthsAgoUseCase.getAlarmMonthsAgo(date) // 1개월 전 데이터 조회
val header = NoticeHistoryBackUpDto::class.java.declaredFields.map { it.name } // DTO에 정의된 변수명을 header로 사용하고자 field명을 리스트로 변환
alarmsMapByLdt?.let { it ->
val csvFileName = "alarm-backup-${it.date}.csv"
val zipFileName = "alarm-backup-${it.date}.zip"
try {
val data: List<List<String>> = extractCsvData(it, header) // csv 파일에 데이터로 넣기 위해 DTO에서 가져온 데이터 추출
val file = makeCsvFile(csvFileName, header, data, zipFileName) // csv 파일 만들기
this.s3Util.uploadFile(file, S3Directory.ALARM, zipFileName) // S3에 파일 업로드
} catch (e: Exception) {
createLogAndSendSlackMsgWhenFail(it.date, e, "Failed to create CSV file")
return@let
} finally { // 업로드가 성공한 이후에 삭제 대상 데이터를 실제로 DB에서 삭제 처리
deleteDumpFile(csvFileName, zipFileName)
val result = this.deleteAlarmMonthsAgoUseCase.deleteByIds(it.data.map { it.id }) // 삭제 대상이 모두 삭제되었는지 확인하는 작업
if (result != 0L) throw Exception("Failed to delete alarm data. remain count : $result")
}
}
} catch (e: Exception) {
createLogAndSendSlackMsgWhenFail(date ?: LocalDate.now(), e, "Failed to backup CSV")
}
}
// S3 업로드 이후 파일(.csv, .zip) 모두 삭제
private fun deleteDumpFile(csvFileName: String, zipFileName: String) {
Files.deleteIfExists(Paths.get(csvFileName))
Files.deleteIfExists(Paths.get(zipFileName))
}
// csv 파일ㄹ을 만든 후, 해당 파일을 zip으로 만들어 반환
private fun makeCsvFile(
csvFileName: String,
header: List<String>,
data: List<List<String>>,
zipFileName: String
): File {
this.csvUtil.writeCsv(csvFileName, header, data)
this.csvUtil.zipFile(csvFileName, zipFileName)
val file = File(zipFileName)
return file
}
// csv 파일로 만들 데이터 추출
private fun extractCsvData(
it: NoticeHistoryPortDto.Companion.Backup,
header: List<String>
): List<List<String>> {
val data: List<List<String>> = it.data.map { alarm ->
header.map { field ->
try {
val value =
alarm::class.java.getDeclaredField(field).apply { isAccessible = true }.get(alarm)
value?.toString() ?: ""
} catch (e: NoSuchFieldException) {
""
}
}
}
return data
}
// 백업 과정에 대한 로그 생성(실패시에만), 슬랙 노티
private fun createLogAndSendSlackMsgWhenFail(
date: LocalDate,
e: Exception,
msg: String
) {
logger().error("$msg : $date => ", e)
this.saveAlarmBackUpLogsUseCase.save(
AlarmBackUpLogsDto(
clazz = this::class.java.name,
exitMessage = e.message ?: e.stackTraceToString(),
param = date.toString()
)
)
slackUtil.sendMessageToSirenChannel(
"$msg : $date => ${e.message}",
"AlarmBackupController.backupCsv"
)
}
}
CsvUtil.kt
@Component
class CsvUtil {
fun writeCsv(fileName: String, headers: List<String>, data: List<List<String?>>) {
FileOutputStream(fileName).use { fos ->
OutputStreamWriter(fos, StandardCharsets.UTF_8).use { osw ->
CSVPrinter(
osw,
CSVFormat.DEFAULT.builder().setHeader(*headers.toTypedArray()).build()
).use { csvPrinter ->
data.forEach { row ->
csvPrinter.printRecord(row)
}
csvPrinter.flush()
}
}
}
}
fun zipFile(sourceFileName: String, zipFileName: String) {
FileOutputStream(zipFileName).use { fos ->
ZipArchiveOutputStream(fos).use { zos ->
zos.putArchiveEntry(ZipArchiveEntry(sourceFileName))
FileInputStream(sourceFileName).use { fis ->
fis.copyTo(zos)
}
zos.closeArchiveEntry()
}
}
}
}
굵직하게 사용된 로직은 위에 보여지는 것이 전부이다. 코드마다 주석을 달아두었으니, 이해에 큰 어려움은 없을 거라 생각한다.
지금까지는 별도의 백업을 하지 않고, RDB에 모두 보유하고 있었다. 굳이 백업을 하지 않았던 이유는,
1. RDS에서 지원하는 스냅샷이 있어 혹시 모를 상황에 대비할 수는 있다.
2. 백업으로 빼고 삭제할만한 성격의 데이터가 없었다.
이다.
이번에 알림 시스템을 개편하면서, 알림 내역의 경우 로그성의 성격도 갖고 있는 데이터로 유저에게 1개월만 보여줄 것이기에 수백만 데이터를 굳이 RDB 계속 보관하면서 용량을 차지할 이유가 없다고 판단했다.
백업 과정에서 스케쥴러가 정상적으로 수행되고 있는지 아닌지를 개발자가 직접 들여다보지 않아도 되게 하고 싶어, 실패의 경우에만 슬랙 노티를 하게끔 걸어두었다. 또한, 슬랙 노티에 실패할 경우를 대비해 로그성으로 별도의 테이블에 저장하게 해두었다.
백업 프로세스를 스케쥴러로 작업해본 것은 처음이라, 개발 설계 당시 미흡했던 부분이 많았다.!
개발팀 내에 PR 리뷰 문화로 사수님으로부터 파일 압축이나, 트랜잭션 범위의 크기 제한, 해당 작업의 성공여부 기록 등의 피드백을 받으며 더 탄탄해진 플로우가 탄생했다.