✨ 오늘은 내가 최근에 겪은 문제이고, 그것을 해결해서 포스팅을 끄적여본다...
내 주요 업무는 데이터 배치를 실행하고, 그걸 관리하고, 데이터를 수집하고 DB를 최적화하는 것이다.. (사실 1인 개발자는 다 해야 돼.) 근데 말이야, 최근에 S3 버킷에서 스트리밍으로 데이터 가져오는 게 자꾸 오류가 난다? 그것도 진짜 다양하게!
- SSL 인증 세션 만료 문제 🔒
- 데이터 길이 안 맞을 때 예외 처리 못하는 문제 📏
- 접속 불안정 문제 🌐
이 세 가지 문제 때문에 정말 머리가 아팠다.. 예외 처리를 만들고 또 만들고, 계속해서 수정했지만... 문제는 끝없이 났고. 정말 절망적이었다. (무려 월요일부터 금요일까지 계속 예외처리만 구성했다.) 최소 이만큼의 예외처리를 만들었고, 더 많은,,, 예외처리 과정이 있다.
@Bean
@JobScope
fun updateJob(
@Value("#{jobParameters[insertS3FileKey]}") insertS3FileKey: String?,
customSkipPolicy: CustomSkipPolicy,
skipLoggingListener: SkipLoggingListener
) = batch {
step("updateStep") {
chunk(100, transactionManager) {
reader(s3FlatFileReaderTest(insertS3FileKey))
processor(updateProcessor())
writer(resultWriter())
faultTolerant {
retry<SocketException>() // 네트워크 관련 재시도
retry<SSLException>() // SSL 관련 재시도
retry<IOException>() // IO 관련 재시도
retry<SdkClientException>()
retryLimit(5)
backOffPolicy(ExponentialBackOffPolicy().apply {
initialInterval = 2000L
multiplier = 2.0
maxInterval = 30000L
})
skip(NonSkippableReadException::class) // NonSkippableReadException 발생 시 스킵
skipPolicy(customSkipPolicy) // 커스텀 스킵 정책
noSkip<FileNotFoundException>() // 파일이 없는 경우는 스킵하지 않음
listener(skipLoggingListener as SkipListener<items, itemList>)
}
}
}
}
@Component
class CustomSkipPolicy : SkipPolicy {
override fun shouldSkip(throwable: Throwable, skipCount: Long): Boolean {
return when (throwable) {
// is NonSkippableReadException -> true // Non-skippable exception이지만, 스킵 처리
is SocketException -> true
is SSLException -> true
is IOException -> true
else -> false
}
}
}
그러다 문득, "그렇다면 S3 버킷을 버리고 로컬로 다운로드 받는 방법을 선택하면 어떨까?" (다행히 이번 작업은 100MB 이하의 파일들이기에 가능한 생각이었다.)
자, 이제 두 방법의 차이점을 살펴보면서, 왜 로컬 다운로드 방식을 선택했는지 알아보도록 하자.
🪡 S3에서 직접 처리하기
이 방식은 S3 버킷에서 파일을 직접 스트리밍해서 읽고 처리하는 방식이다.
🐣 장점
1. 메모리 효율성 : 전체 파일을 다운로드하지 않고 스트리밍하기 때문에, 메모리 사용량이 적다.
2. 실시간 처리 : 데이터를 즉시 처리할 수 있기 때문에 실시간성이 중요한 경우 유용하다.
3. 저장 공간 절약 : 로컬 디스크에 파일을 저장할 필요가 없기 때문에 저장 공간을 절약할 수 있다.
🐥단점
1. 네트워크 의존성 : 지속적인 네트워크 연결이 필요하며, 연결 문제 시 처리가 중단된다.
2. 에러 처리의 복잡성 : 네트워크 관련 예외 처리가 필요하다
3. 성능 변동 : 네트워크 상태에 따라 처리 성능이 크게 변동될 수 있다.
@Configuration
class S3DirectProcessingJobConfig(
private val batch: BatchDsl,
private val amazonS3: AmazonS3
) {
@Bean
fun s3DirectProcessingJob(s3DirectProcessingStep: Step) = batch {
job("s3DirectProcessingJob") {
step(s3DirectProcessingStep)
}
}
@Bean
fun s3DirectProcessingStep(
@Value("#{jobParameters[s3FilePath]}") s3FilePath: String
) = batch {
step("s3DirectProcessingStep") {
chunk<String, String>(10) {
reader(s3ItemReader(s3FilePath))
processor { item: String -> item.toUpperCase() }
writer { items: List<String> -> items.forEach(::println) }
}
}
}
private fun s3ItemReader(s3FilePath: String): ItemReader<String> {
val s3Object = amazonS3.getObject(GetObjectRequest("my-bucket", s3FilePath))
val reader = BufferedReader(InputStreamReader(s3Object.objectContent))
return ItemReader {
val line = reader.readLine()
if (line != null) line else null
}
}
}
🧶 Local Download 후 처리하는 방식
이 방식은 처음 배치를 시작할 때, S3 버킷에서 로컬로 다운로드 한 후에 그 파일을 기반으로 처리하는 것이다.
🐣 장점
1. 안정성 : 네트워크 문제의 영향을 덜 받는다
2. 단순성 : 로컬 파일 처리는 일반적으로 더 간단하고, 예외에 대해 예측이 가능하다.
3. 성능 : 대용량 파일의 경우, 한번 다운로드 후 처리하는 것이 더 빠를 수 있다.
4. 에러 복구 용이 : 처리 중 오류 발생 시에, 이어서 처리하기가 쉽다.
5. 네트워크 부하 감소 : 파일을 한 번만 다운로드하기 때문에, 지속적인 네트워크 사용을 줄일 수 있다.
🐥 단점
1. 디스크 공간 : 로컬에 충분한 저장 공간이 필요하다.
2. 초기 지연 : 파일 다운로드로 인한 초기 지연이 발생한다.
3. 동시성 제한 : 여러 서버에서 동시에 처리하기 어려울 수 있다.
4. 보안 : 민감한 데이터가 로컬에 저장될 수 있기 때문에 추가적인 보안 조치가 필요할 수 있다.
@Configuration
class LocalProcessingJobConfig(
private val batch: BatchDsl,
private val amazonS3: AmazonS3
) {
@Bean
fun localProcessingJob(localProcessingStep: Step) = batch {
job("localProcessingJob") {
step(localProcessingStep)
}
}
@Bean
fun localProcessingStep(
@Value("#{jobParameters[s3FilePath]}") s3FilePath: String
) = batch {
step("localProcessingStep") {
chunk<String, String>(10) {
reader(localItemReader(downloadFromS3(s3FilePath)))
processor { item: String -> item.toUpperCase() }
writer { items: List<String> -> items.forEach(::println) }
}
}
}
private fun downloadFromS3(s3FilePath: String): String {
val localFilePath = "/tmp/${UUID.randomUUID()}.txt"
val s3Object = amazonS3.getObject(GetObjectRequest("my-bucket", s3FilePath))
FileOutputStream(localFilePath).use { outputStream ->
s3Object.objectContent.use { inputStream ->
inputStream.copyTo(outputStream)
}
}
return localFilePath
}
private fun localItemReader(filePath: String): ItemReader<String> {
val reader = BufferedReader(FileReader(filePath))
return ItemReader {
val line = reader.readLine()
if (line != null) line else null
}
}
}
🏹 왜 나는 로컬 다운로드 방식을 선택했을까?
프로젝트에서 로컬 다운로드 방식을 선택한 이유는 다음과 같다.
- 파일 크기와 처리 시간: 처리해야 할 S3 파일의 크기가 매우 컸다.. 직접 처리 방식을 사용했을 때, 네트워크 지연으로 인해 전체 처리 시간이 예상보다 훨씬 길어졌다. 로컬 다운로드 후 처리하는 방식으로 변경한 결과, 초기에 다운로드 시간이 소요되지만 전체 처리 시간이 크게 단축되는 결과를 얻었다. (보통 청크단위 100에 1분 20초 소요에서 40초로 크게 줄어들었다.)
- 네트워크 안정성 문제: S3에 직접 접근하는 과정에서 간헐적인 네트워크 문제가 발생했다. 이로 인해 처리가 중단되거나 예외가 발생하는 경우가 많았다. 로컬 다운로드 방식을 사용함으로써 네트워크 문제의 영향을 최소화할 수 있었다.
- 에러 처리와 재시도 로직: 직접 처리 방식에서는 네트워크 관련 예외 처리가 복잡했다. 특히 NonSkippableReadException과 같은 예외를 처리하기 어려웠고, 실제로 스킵이 불가능한 부분이 생각보다 많았다. 로컬 파일을 사용함으로써 이러한 복잡한 예외 처리 로직을 단순화할 수 있었다.
- SSL 인증 문제: S3 접근 시 SSL 토큰 만료 문제가 간헐적으로 발생했다. 이는 장시간 실행되는 배치 작업에서 특히 문제가 되었는데, 로컬 다운로드 방식을 사용하면 이러한 인증 관련 문제를 파일 다운로드 단계에서만 처리하면 되므로, 전체 프로세스의 안정성이 향상되었다.
- 리소스 활용: 현재 충분한 로컬 디스크 공간을 가지고 있었지만, 네트워크 대역폭은 제한적이었다(아직 작은 서비스이기에 인스턴스의 크기 자차게 작다). 로컬 다운로드 방식을 사용함으로써 네트워크 리소스 사용을 최적화하고, 가용한 디스크 공간을 효율적으로 활용할 수 있었다. 물론, 비용 문제도 크게 절감이 가능하다!
- 백그라운드 처리와 모니터링: 로컬에 파일을 다운로드함으로써, 파일 다운로드 진행 상황을 쉽게 모니터링하고 로깅할 수 있다. 이는 전체 프로세스의 진행 상황을 추적하고 문제를 진단하는 데 큰 도움이 되었다.
- 확장성과 유연성: 로컬 파일을 사용함으로써, 필요한 경우 다른 처리 도구나 스크립트를 쉽게 적용할 수 있었다. 이는 프로세스의 유연성을 크게 향상시키는 결과를 보였다.
구현 시 주의사항
로컬 다운로드 방식을 선택했지만, 다음과 같은 점들을 고려하여 구현했다.
- 보안: 다운로드된 파일에 대한 적절한 접근 권한 설정과 암호화를 적용했다. 물론, 현재의 파일은 보안이 문제가 되는 것이 아니지만 차후에 다른 방법으로 사용할 때에는 문제가 생길 수도 있기에, 암호화를 적용하여 타인이 접근할 수 없도록 했다.
- 클린업: 처리가 완료된 후 로컬 파일을 안전하게 삭제하는 로직을 구현했다. 이것으로 인해 배치 작업이 종료된 후엔 파일은 삭제되어 불필요한 리소스를 가지고 있는 상황을 방지했다.
- 디스크 공간 관리: 다운로드 전 충분한 디스크 공간이 있는지 확인하는 로직을 구현했다. 예를 들어 내가 10mb 파일을 저장할 경우, 디스크는 최소 20mb의 공간을 가지고 있어야 한다.
- 병렬 처리: 여러 파일을 동시에 처리해야 하는 경우, 적절한 동시성 제어를 구현했다.
결론
S3 직접 처리 방식과 로컬 다운로드 후 처리 방식은 각각의 장단점이 있다.. 프로젝트의 특성, 데이터의 크기, 네트워크 환경, 보안 요구사항 등을 종합적으로 고려하여 적절한 방식을 선택해야 하는 건 당연한 이야기다.
내 경우에는 대용량 파일 처리, 네트워크 안정성 문제, 복잡한 에러 처리 등의 이유로 로컬 다운로드 방식이 더 적합했다. 이 방식을 통해 전체적인 처리 성능과 안정성을 크게 향상시킬 수 있었다. 특히, 퇴근하고 모니터링이 불가능한 상황이기 때문에 에러에 즉각 대응이 불가능한 상태에서 매우 좋은 판단이었다.
하지만 이것은 모든 상황에 적합한 것은 아니다. 실시간으로 처리가 필요하거나 저장 공간이 부족할 경우, 또는 데이터 보안이 극도로 중요한 회원 정보 등등등등등 일 경우, 배치의 단위가 너무 클 경우는 실시간 스트리밍 방식을 사용하는 것이 옳다.
'JAVA&KOTLIN' 카테고리의 다른 글
[Spring Batch&Kotlin] WebFlux 를 쓰는 이유? 그리고 발견된 문제 (0) | 2024.08.05 |
---|---|
VO? DTO? 둘의 차이가 뭔데 (0) | 2024.07.31 |
[Spring Boot & kotlin] bean과 JobScope (0) | 2024.07.26 |