
안녕하세요!
오늘은 Spring Boot와 Kotlin을 사용해 대용량 데이터를 효율적으로 처리하는 방법에 대해 알아보겠습니다.
각 테이블당 10만 개의 데이터를 처리하면서 3,300개를 넘어갈 때 속도가 급격히 저하되는 문제를 해결한 경험을 공유합니다.
프로젝트를 시작할 때 테스트 데이터로 10만 개의 다수의 테이블들을 생성하는 초기화 코드를 작성했습니다.
그런데 로그를 확인해보니 다음과 같은 성능 문제가 발생했습니다

특히 약 3,300개 이상의 데이터를 처리할 때부터 속도가 현저히 느려지는 현상이 발생했습니다. 이는 36분이 넘는 초기화 시간을 초래했으며, 실제 운영 환경에서는 받아들이기 힘든 성능이었습니다.
이 문제를 해결하기 위해 코틀린의 함수형 프로그래밍 특성을 적극 활용했습니다.
함수형 프로그래밍이란 무엇일까요?
함수형 프로그래밍은 프로그램을 순수 함수의 조합으로 구성하는 패러다임입니다.
몇 가지 핵심 특징은 다음과 같습니다.
코틀린은 이러한 함수형 프로그래밍 기능을 자연스럽게 지원하며, 특히 map, filter, fold 등의 함수형 연산과 코루틴을 통한 비동기 처리를 결합하여 효율적인 코드를 작성할 수 있습니다.
기존 코드는 각 항목을 개별적으로 저장했지만, 새로운 접근 방식에서는 데이터를 배치로 그룹화하여 처리했습니다.
private fun generateMemberBatches(): List<List<MemberRequest>> {
val batchCount = (MEMBER_COUNT + BATCH_SIZE - 1) / BATCH_SIZE
val batches = mutableListOf<List<MemberRequest>>()
for (batchIndex in 0 until batchCount) {
val start = batchIndex * BATCH_SIZE + 1
val end = minOf(start + BATCH_SIZE - 1, MEMBER_COUNT)
batches.add((start..end).map { i ->
MemberRequest("user$i", "pass%02d".format(i))
})
}
return batches
}
코틀린 코루틴을 활용하여 여러 배치를 병렬로 처리함으로써 성능을 대폭 향상시켰습니다.
coroutineScope {
memberBatches.chunked(PARALLEL_BATCHES).forEach { batchChunk ->
val tasks = batchChunk.map { batch ->
async(customDispatcher) {
executeInNewTransaction {
(memberService as? MemberServiceImpl)?.registerBatch(batch)
}
logger.info("프로세스 배치: {} 멤버", batch.size)
}
}
tasks.awaitAll()
}
}
Redis 캐싱을 효과적으로 활용하되, 초기화 과정에서는 캐싱을 비활성화하여 오버헤드를 줄였습니다
// 초기화 중에는 캐싱 비활성화
val prevCachingState = cacheService.enableCaching
cacheService.enableCaching = false
try {
// 초기화 작업 수행
} finally {
// 캐싱 상태 복원
cacheService.enableCaching = prevCachingState
}
또한 벌크 캐싱 메서드를 추가하여 여러 항목을 한 번에 캐시에 저장할 수 있도록 했습니다.
suspend fun <K, V> putBulkInCache(items: Map<K, V>, keyPrefix: String, ttl: Duration): Result<Unit> {
if (!enableCaching || items.isEmpty()) return Result.success(Unit)
return runCatching {
withContext(redisCacheDispatcher) {
val operations = redisTemplate.opsForValue()
items.forEach { (key, value) ->
operations.set("$keyPrefix:$key", value as Any, ttl)
}
}
}
}
작업 유형에 따라 최적화된 디스패처를 사용함으로써 스레드 활용을 개선했습니다.
private val redisCacheDispatcher = Dispatchers.IO.limitedParallelism(8)
private val customDispatcher = Dispatchers.IO.limitedParallelism(PARALLEL_BATCHES)
자주 접근하는 데이터를 메모리에 캐싱하여 반복적인 데이터베이스 조회를 줄였습니다.
// 멤버 ID 캐시 (초기화 과정에서 사용)
private val memberCache = ConcurrentHashMap<Long, Member>()
// 캐시된 멤버 정보 사용
val author = memberCache.computeIfAbsent(authorId) {
memberService.findMemberEntityById(it).getOrThrow()
}
| 지표 | 최적화 전 | 최적화 후 | 향상률 |
|---|---|---|---|
| 총 초기화 시간 | 2184초 (36.4분) | 869초 (14.48분) | 약 60.2% 감소 |
| 게시글 생성 시간 | 1340초 (22.3분) | 243초 (4.05분) | 약 81.9% 감소 |
| 메모리 사용량 | 높음 (350.77MB) | 개선됨 (76.77MB) | 약 78.1% 감소 |
특히 3,300개 이후 발생하던 성능 저하 현상이 완전히 해소되었고, 데이터 처리 속도가 일관되게 유지되는 결과를 얻었습니다.
이번 최적화에서 사용한 함수형 프로그래밍의 핵심 적용 포인트는 다음과 같습니다.
private inline fun <T> Result<Result<T>>.flatten(): Result<T> =
fold(
onSuccess = { it },
onFailure = { Result.failure(it) }
)
validateRequest(request)
.mapCatching { req -> ... }
.map { post -> postToResponse(post) }
.onFailure { e -> ... }
suspend fun <T> getCachedOrCompute(
key: String,
ttl: Duration,
compute: suspend () -> T
): Result<T>
postRepository.findAll()
.asSequence()
.map { post -> postToResponse(post) }
.forEach { emit(it) }
대용량 데이터 처리 시 성능 이슈는 필연적으로 발생하지만, 코틀린의 함수형 프로그래밍 기법과 다양한 최적화 전략을 활용하면 이러한 문제를 효과적으로 해결할 수 있습니다.
특히 배치 처리, 병렬 처리, 효율적인 캐싱, 그리고 코루틴을 조합한 접근 방식은 대용량 데이터 처리에 있어 큰 성능 향상을 가져왔습니다.
이 글이 비슷한 성능 문제로 고민하는 개발자들에게 도움이 되길 바랍니다.
코틀린과 스프링을 함께 사용하여 함수형 프로그래밍의 장점을 활용한다면, 더 효율적이고 안정적인 애플리케이션을 구축할 수 있을 것입니다.
다음 글에서는 이러한 최적화 전략을 실제 프로덕션 환경에 적용할 때의 모니터링 방법과 성능 지표 분석에 대해 알아보겠습니다.
감사합니다!