최근 기술 블로그 RSS를 스크래핑하고 AI로 내용을 요약하는 'Morning Commit' 프로젝트의 파이프라인을 개선했습니다. 구현 과정에서 마주친 성능 병목 지점과 이를 Kotlin Coroutines로 해결한 과정을 정리했습니다.
1. 문제 상황: 순차 처리의 한계
기존 방식은 RSS 피드에서 추출한 포스트들을 하나씩 순서대로 처리했습니다.
- 프로세스: HTML 스크래핑 → AI 요약(LLM API 호출) → 데이터 가공 및 저장
- 소요 시간: 포스트 당 평균 약 10초
포스트 하나당 10초는 짧아 보일 수 있지만, 배치 작업의 특성상 누적 시간은 무시할 수 없습니다. 한 번에 처리해야 할 포스트가 30개만 되어도 5분이 소요되며, 연동하는 블로그 소스가 늘어날수록 전체 작업 완료 시간은 선형적으로 증가하는 구조였습니다.
기존 코드 (Sequential)
Kotlin
feed.entries
.filter { /* 날짜 필터링 */ }
.mapNotNull { entry ->
// 포스트 하나당 약 10초 대기 발생
val fullContent = htmlScraper.scrapeContent(link)
val analysisResult = summaryService.analyze(fullContent)
// ... Post 객체 생성
}
2. 해결책: Kotlin Coroutines와 Semaphore
스크래핑과 AI API 호출은 대표적인 I/O Bound 작업입니다. CPU 자원을 소모하기보다는 네트워크 응답을 기다리는 시간이 대부분이므로, 이를 병렬로 처리하면 전체 대기 시간을 획기적으로 줄일 수 있습니다.
다만, 무분별한 병렬 처리는 AI API의 Rate Limit(호출 제한)을 초과하거나 시스템에 과부하를 줄 수 있습니다. 이를 방지하기 위해 Semaphore를 사용하여 동시에 실행되는 작업의 개수를 제한했습니다.
구현 핵심
- runBlocking & Dispatchers.IO: Spring Batch의 동기적인 흐름을 유지하면서 코루틴을 실행하기 위해 runBlocking을 사용했으며, I/O 작업에 최적화된 스레드 풀을 활용했습니다.
- async & awaitAll: 각 포스트 처리 로직을 비동기로 실행하고, 모든 결과가 준비되었을 때 한꺼번에 수집하도록 구현했습니다.
- Semaphore(5): 동시에 실행되는 코루틴 개수를 5개로 제한하여 안정적인 API 호출 환경을 구축했습니다.
3. 변경된 코드 (Parallel)
Kotlin
// 동시 처리 제한을 위한 세마포어 설정 (동시성 5)
val semaphore = Semaphore(5)
runBlocking(Dispatchers.IO) {
filteredEntries.map { entry ->
async {
// 세마포어 허가를 획득한 코루틴만 내부 로직 수행
semaphore.withPermit {
try {
val link = entry.link ?: return@withPermit null
// 비동기 스레드 차단 없이 병렬 수행
val fullContent = htmlScraper.scrapeContent(link)
val analysisResult = summaryService.analyze(fullContent)
if (analysisResult == null || analysisResult.isPromotional) return@withPermit null
Post(
// ... 객체 생성 로직
)
} catch (e: Exception) {
log.error("Failed to process entry: ${entry.title}", e)
null
}
}
}
}.awaitAll().filterNotNull() // 전체 결과 수집 및 유효 데이터 필터링
}
4. 결과 및 결론
병렬 처리를 도입한 후, 배치 작업의 전체 소요 시간이 크게 단축되었습니다.
- 성능 향상: 10개의 포스트를 처리할 때 기존에는 100초가 걸렸으나, 동시성 5를 적용한 후에는 약 20~25초 수준으로 완료되었습니다.
- 안정성: Semaphore를 통해 동시 실행 수를 제어함으로써, API 서버로부터 'Too Many Requests' 에러를 받지 않고 안정적으로 데이터를 수집할 수 있었습니다.
단순히 "빠르게" 만드는 것보다 시스템의 제약 사항을 고려하여 "효율적이고 안전하게" 설계하는 것이 실무적인 병렬 처리의 핵심임을 배울 수 있었습니다.
'ETC' 카테고리의 다른 글
| [Spring Boot/Kotlin] Redis 캐싱: PageImpl 직렬화 에러와 ClassCastException 완벽 해결 가이드 (0) | 2026.02.17 |
|---|---|
| OpenFeign부터 Spring AI, 그리고 Kotlin 최적화까지: 타입 안전한 AI 서비스 구축기 (0) | 2026.02.17 |
| 만들면서 배우는 클린 아키텍처 (2) | 2025.04.22 |