Kotlin으로 테이블당 10만건 데이터 뽀개기(테이블만 50개)

궁금하면 500원·2025년 3월 7일

미생의 개발 이야기

목록 보기
32/69

스프링과 코틀린으로 10만 데이터 성능 최적화 처리하기

안녕하세요!
오늘은 Spring Boot와 Kotlin을 사용해 대용량 데이터를 효율적으로 처리하는 방법에 대해 알아보겠습니다.
각 테이블당 10만 개의 데이터를 처리하면서 3,300개를 넘어갈 때 속도가 급격히 저하되는 문제를 해결한 경험을 공유합니다.

문제 상황

프로젝트를 시작할 때 테스트 데이터로 10만 개의 다수의 테이블들을 생성하는 초기화 코드를 작성했습니다.
그런데 로그를 확인해보니 다음과 같은 성능 문제가 발생했습니다

특히 약 3,300개 이상의 데이터를 처리할 때부터 속도가 현저히 느려지는 현상이 발생했습니다. 이는 36분이 넘는 초기화 시간을 초래했으며, 실제 운영 환경에서는 받아들이기 힘든 성능이었습니다.

함수형 프로그래밍과 코틀린

이 문제를 해결하기 위해 코틀린의 함수형 프로그래밍 특성을 적극 활용했습니다.
함수형 프로그래밍이란 무엇일까요?

함수형 프로그래밍이란?

함수형 프로그래밍은 프로그램을 순수 함수의 조합으로 구성하는 패러다임입니다.
몇 가지 핵심 특징은 다음과 같습니다.

  • 불변성(Immutability): 데이터가 한 번 생성되면 변경되지 않음
  • 순수 함수(Pure Functions): 동일한 입력에 대해 항상 동일한 출력을 반환
  • 고차 함수(Higher-Order Functions): 함수를 매개변수로 전달하거나 결과로 반환 가능
  • 지연 평가(Lazy Evaluation): 결과가 필요할 때만 계산을 수행

코틀린은 이러한 함수형 프로그래밍 기능을 자연스럽게 지원하며, 특히 map, filter, fold 등의 함수형 연산과 코루틴을 통한 비동기 처리를 결합하여 효율적인 코드를 작성할 수 있습니다.

성능 최적화 전략

1. 배치 처리 최적화

기존 코드는 각 항목을 개별적으로 저장했지만, 새로운 접근 방식에서는 데이터를 배치로 그룹화하여 처리했습니다.

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
}

2. 병렬 처리 적용

코틀린 코루틴을 활용하여 여러 배치를 병렬로 처리함으로써 성능을 대폭 향상시켰습니다.

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()
    }
}

3. 캐싱 전략 최적화

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)
            }
        }
    }
}

4. 코루틴 디스패처 최적화

작업 유형에 따라 최적화된 디스패처를 사용함으로써 스레드 활용을 개선했습니다.

private val redisCacheDispatcher = Dispatchers.IO.limitedParallelism(8)
private val customDispatcher = Dispatchers.IO.limitedParallelism(PARALLEL_BATCHES)

5. 5. 메모리 캐싱 활용

자주 접근하는 데이터를 메모리에 캐싱하여 반복적인 데이터베이스 조회를 줄였습니다.

// 멤버 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개 이후 발생하던 성능 저하 현상이 완전히 해소되었고, 데이터 처리 속도가 일관되게 유지되는 결과를 얻었습니다.

함수형 프로그래밍 적용 포인트

이번 최적화에서 사용한 함수형 프로그래밍의 핵심 적용 포인트는 다음과 같습니다.

  1. 불변 데이터 구조 활용: Result 타입을 활용한 에러 처리와 데이터 변환
private inline fun <T> Result<Result<T>>.flatten(): Result<T> =
    fold(
        onSuccess = { it },
        onFailure = { Result.failure(it) }
    )
  1. 함수 합성(Function Composition): 여러 변환 단계를 체이닝으로 연결
validateRequest(request)
    .mapCatching { req -> ... }
    .map { post -> postToResponse(post) }
    .onFailure { e -> ... }
  1. 고차 함수 사용: 함수를 매개변수로 전달하는 패턴
suspend fun <T> getCachedOrCompute(
    key: String,
    ttl: Duration,
    compute: suspend () -> T
): Result<T>
  1. Sequence와 Flow를 활용한 지연 평가
postRepository.findAll()
    .asSequence()
    .map { post -> postToResponse(post) }
    .forEach { emit(it) }

결론

대용량 데이터 처리 시 성능 이슈는 필연적으로 발생하지만, 코틀린의 함수형 프로그래밍 기법과 다양한 최적화 전략을 활용하면 이러한 문제를 효과적으로 해결할 수 있습니다.

특히 배치 처리, 병렬 처리, 효율적인 캐싱, 그리고 코루틴을 조합한 접근 방식은 대용량 데이터 처리에 있어 큰 성능 향상을 가져왔습니다.

이 글이 비슷한 성능 문제로 고민하는 개발자들에게 도움이 되길 바랍니다.

코틀린과 스프링을 함께 사용하여 함수형 프로그래밍의 장점을 활용한다면, 더 효율적이고 안정적인 애플리케이션을 구축할 수 있을 것입니다.

다음 글에서는 이러한 최적화 전략을 실제 프로덕션 환경에 적용할 때의 모니터링 방법과 성능 지표 분석에 대해 알아보겠습니다.

감사합니다!

profile
그냥 코딩할래요 재미있어요

0개의 댓글