LLM을 활용한 리뷰 분석 시스템 구축기 1부:네트워크 지연 환경에서의 안정적인 배치 처리

JH.KIM·2025년 8월 14일
post-thumbnail

1부:네트워크 지연 환경에서의 안정적인 배치 처리 구현

대량의 리뷰 데이터를 LLM으로 분석하는 시스템을 구축하게 되었습니다. 구현하면서 마주친 여러 문제점들과 해결 과정을 공유합니다.

시리즈 구성
1부: 네트워크 지연 환경에서의 효율적 배치 처리 (현재 글)
2부: LangChain을 통한 LLM 연동과 토큰·응답 속도 최적화 (다음 글)

🔍 문제 상황

초기 구조는 단순했습니다. 데이터 수집 서버에서 가져온 리뷰 데이터를 배치로 처리해서 LLM API를 호출하고, 분석 결과를 저장하는 것이었습니다.

데이터 수집 서버 → 배치 작업 → LLM API 호출 → 결과 저장

실제로 운영해보니 다음과 같은 문제들이 발생했습니다:

  • LLM API 응답 시간이 평균 3-5초로 긴 편이었음
  • 대량 데이터 처리 시 서버 프로세스가 장시간 점유됨
  • 중간에 실패하면 처음부터 다시 처리해야 하는 구조

특히 수천 건의 리뷰를 처리할 때 전체 작업이 완료되기까지 예상보다 훨씬 오랜 시간이 걸렸고, 서버 리소스 사용량이 불안정했습니다.

🏗️ 시스템 분리

메인 서비스에 영향을 주지 않기 위해 LLM 처리를 별도 서버로 분리했습니다.

데이터 수집 서버 → 서비스 API 서버 → AWS SQS → LLM 전담 서버

분리한 이유는 다음과 같습니다:

장애 격리
LLM API의 지연이나 실패가 메인 서비스 응답성에 영향을 주지 않도록 설계할 수 있습니다.

리소스 최적화
기존 SpringBoot/Kotlin 기반 서비스는 그대로 유지하면서, LLM 처리만 별도로 분리함으로써 FastAPI/LangChain과 같은 Python 생태계의 강점을 활용할 수 있습니다.

확장성
LLM 처리 부하에 따라 해당 서버만 독립적으로 스케일링할 수 있습니다.

💾 대량 데이터 저장 최적화

서비스 API는 기존에 JPA를 사용하고 있었습니다. 수집된 리뷰 데이터를 DB에 저장하는 과정에서 대량 데이터 처리에 한계가 있었습니다.

JPA만으로는 대량 데이터 처리에 한계가 있었습니다. Hibernate의 배치 설정을 사용할 수도 있지만, 다음과 같은 제약을 고려할 수 있습니다:

  • 영속성 컨텍스트 관리로 인한 메모리 오버헤드
  • DB 특화 기능 사용의 제약
  • 복잡한 최적화 로직으로 인한 예측 어려움

이러한 한계를 해결하기 위해 대량 데이터 처리 부분에는 JdbcTemplate을 도입할 수 있습니다.

구현 예시

@Repository
class ReviewBulkRepository(private val namedParameterJdbcTemplate: NamedParameterJdbcTemplate) {
    
    fun bulkInsert(reviews: List<Review>) {
        val sql = """
            INSERT INTO reviews (id, content, platform, created_at)
            VALUES (:id, :content, :platform, :createdAt)
            ON DUPLICATE KEY UPDATE 
                content = VALUES(content),
                updated_at = NOW()
        """.trimIndent()
        
        val parameterList = reviews.map { review ->
            mapOf(
                "id" to review.id,
                "content" to review.content,
                "platform" to review.platform,
                "createdAt" to review.createdAt
            )
        }
        
        namedParameterJdbcTemplate.batchUpdate(sql, parameterList.toTypedArray())
    }
}

JdbcTemplate 도입으로 다음과 같은 개선 효과를 얻을 수 있습니다:

  • 직접적인 SQL 제어로 성능 최적화 가능
  • 메모리 효율성 개선 효과
  • 파라미터 이름 기반 바인딩으로 코드 가독성 향상
  • MySQL의 ON DUPLICATE KEY UPDATE 기능을 활용한 중복 처리
  • rewriteBatchedStatements=true 설정으로 네트워크 라운드트립 최소화

참고: 파라미터 바인딩 시 NamedParameterJdbcTemplate을 사용하면 인덱스 기반 바인딩보다 코드 가독성과 유지보수성을 높일 수 있습니다.

⚡ 동시성 제어

LLM API 응답 시간(평균 3-5초)을 고려하면 순차 처리 방식으로는 대량 데이터 처리에 너무 많은 시간이 소요됩니다.

직렬 처리 vs 병렬 처리 비교 (1,000건 기준)

처리 방식건당 처리 시간총 소요 시간시간당 처리량
직렬 처리 (1개)약 3.5초약 58분1,030건/시간
병렬 처리 (4개)약 3.5초약 15분4,120건/시간

건당 처리 시간 구성

  • LLM API 응답: 평균 3초
  • DB 트랜잭션 및 후처리: 약 0.5초
  • 총 3.5초/건

병렬 처리로 약 4배의 처리량 향상을 기대할 수 있습니다. 이제 적정 동시성 수준을 결정해야 했습니다.

서버 스펙

  • CPU: 2vCPU
  • 메모리: 4GB
  • 네트워크: 외부 LLM API 의존

부하 테스트 결과

동시 요청 수CPU 사용률메모리 사용률처리 성공률비고
2개60%70%높음안정적인 수준
4개85%80%높음허용 가능한 범위
6개95%90%낮음불안정한 상태
8개100%95%매우 낮음리소스 부족

테스트 결과를 바탕으로 동시 처리 4개를 최적값으로 설정할 수 있었습니다.

1차 구현 컨셉
리소스 제약을 고려해 병렬 처리를 4개로 제한하며 일괄 처리하는 방식으로 접근했습니다. 커서 기반 페이지네이션으로 데이터를 조회하고, 동시 처리 개수(4개)에 맞춰 청크 단위로 나누어 병렬 처리하는 방식으로 설계했습니다. 전체 데이터 처리가 완료될 때까지 재귀적으로 실행되도록 구현할 수 있습니다.

구현 예시

@Service
class ReviewAnalysisService {
    companion object {
        private const val MAX_CONCURRENT_REQUESTS = 4
        private const val BATCH_SIZE = 10
        private const val PAGE_LIMIT = MAX_CONCURRENT_REQUESTS * BATCH_SIZE
    }
    
    suspend fun processReviews() {
        var cursor: String? = null
        
        do {
            val reviews = reviewRepository.findByCursor(cursor, PAGE_LIMIT)
            
            if (reviews.isNotEmpty()) {
                // 4개 청크로 나누어 병렬 처리
                reviews.chunked(BATCH_SIZE).map { chunk ->
                    async { processChunk(chunk) }
                }.awaitAll()
                
                cursor = reviews.last().id
            }
        } while (reviews.size == PAGE_LIMIT)
    }
    
    private suspend fun processChunk(chunk: List<Review>) {
        chunk.forEach { review ->
            llmAnalysisService.analyzeReview(review)
        }
    }
}

개선된 부분

  • 서버 리소스 사용량을 예측 가능한 범위 내로 제한할 수 있음
  • 동시 처리 제어로 서버 과부하를 방지할 수 있음
  • 커서 기반 페이지네이션으로 메모리 효율적 처리 가능

남은 한계

  • 전체 데이터 처리 완료까지 서버 프로세스가 장시간 점유됨
  • 단일 서버에서만 처리되어 수평 확장에 제약
  • 중간에 서버 장애 발생 시 처리 중인 모든 데이터 재처리 필요
  • 전체 진행 상황 추적 및 모니터링의 어려움

📬 AWS SQS 기반 비동기 처리

이러한 한계를 해결하기 위해 메시지 큐 기반 비동기 처리를 도입할 수 있습니다.

FIFO SQS 선택 이유

  • 메시지 순서 보장 (리뷰 시간순 처리 요구사항)
  • MessageDeduplicationId를 통한 중복 메시지 방지
  • 서울 리전 기준 초당 2,400개 처리량 지원

메시지 발행 예시

@Component
class ReviewAnalysisPublisher(private val sqsClient: SqsClient) {
    
    fun publishReviewBatch(reviews: List<Review>) {
        reviews.chunked(4).forEach { batch ->
            val message = ReviewBatchMessage(
                batchId = UUID.randomUUID().toString(),
                reviews = batch,
                timestamp = LocalDateTime.now()
            )
            
            sqsClient.sendMessage(
                SendMessageRequest.builder()
                    .queueUrl(QUEUE_URL)
                    .messageBody(objectMapper.writeValueAsString(message))
                    .messageGroupId("review-analysis")
                    .messageDeduplicationId(message.batchId)
                    .build()
            )
        }
    }
}

메시지 소비 예시

@SqsListener("review-analysis-queue.fifo")
suspend fun processReviewBatch(message: ReviewBatchMessage) {
    if (isAlreadyProcessed(message.batchId)) return
    
    message.reviews.map { review ->
        async { llmAnalysisService.analyzeReview(review) }
    }.awaitAll()
    
    markAsProcessed(message.batchId)
}

📦 SQS 페이로드 최적화

AWS SQS는 메시지 크기를 256KB로 제한합니다. 대량의 리뷰 데이터를 배치로 처리할 때 이 제한을 쉽게 초과할 수 있습니다.

현재 배치 크기(4건)로는 문제없지만, 처리량 향상을 위해 배치 크기를 늘리거나 컨텐츠 자체의 크기가 커질 경우 제한에 걸릴 수 있습니다.

Redis 기반 페이로드 분리

메시지 큐에는 참조 키만 담고, 실제 데이터는 Redis와 같은 캐시 저장소에 저장하는 방식으로 해결할 수 있습니다.

장점

메시지 크기 최적화

  • SQS 메시지: ~100B (참조 정보만)
  • 실제 데이터: Redis에서 관리
  • 256KB 제한에서 자유로운 대용량 배치 처리 가능

성능 향상

  • Redis는 메모리 기반으로 빠른 읽기/쓰기 성능
  • SQS 네트워크 트래픽 최소화
  • 대용량 데이터 전송 부담 감소

유연성 확보

  • 배치 크기를 동적으로 조정 가능
  • 다양한 크기의 리뷰 데이터 처리 가능
  • 메시지 구조 변경 없이 데이터 스키마 확장 가능

고려사항

Redis 장애 대응

  • DB에서 재조회하는 fallback 로직 필요

TTL 관리

  • 처리 예상 시간보다 충분히 긴 TTL 설정
  • DLQ 재처리를 고려한 TTL 연장 메커니즘
  • 메모리 사용량 모니터링

⏱️ Visibility Timeout 관리

SQS 사용 시 중요하게 고려할 부분은 Visibility Timeout 설정입니다.

Visibility Timeout의 역할
SQS에서 메시지를 가져온 후 처리가 완료되기 전까지는 다른 컨슈머가 해당 메시지를 볼 수 없습니다. 이 시간이 Visibility Timeout입니다.

타임아웃 설정에는 트레이드오프가 있습니다:

  • 너무 짧게 설정한 경우: LLM API 응답 지연을 고려하지 않으면, 처리 중인 메시지가 다시 큐에 나타나 중복 처리되는 문제가 발생할 수 있습니다.
  • 너무 길게 설정한 경우: 처리 중 서버 장애나 예외 발생 시, 해당 메시지가 타임아웃 시간만큼 다른 컨슈머에게 재할당되지 않아 전체 처리 지연이 발생할 수 있습니다.

동적 타임아웃 연장 컨셉
LLM 처리 시간이 가변적이므로 고정된 타임아웃으로는 한계가 있습니다. 따라서 메시지 처리 중에 주기적으로 타임아웃을 연장하는 Heartbeat 방식을 구현할 수 있습니다. 처리가 완료되면 메시지를 삭제하고, 실패하면 SQS가 자동으로 재시도하도록 설정할 수 있습니다.

권장 설정값

  • 초기 Visibility Timeout: 60~120초 (평균 처리 시간의 2배)
  • Heartbeat 주기: 20~30초 (타임아웃의 1/3 ~ 1/4)
  • 연장 시간: 60~120초 (다음 Heartbeat까지 충분한 여유)
  • 최대 연장 횟수: 10~20회 (최대 처리 시간 제한)

동적 타임아웃 연장 구현 예시

@SqsListener("review-analysis-queue.fifo")
suspend fun processMessage(
    message: ReviewBatchMessage,
    @Header("ReceiptHandle") receiptHandle: String
) {
    val heartbeatJob = startHeartbeat(receiptHandle)
    
    try {
        if (isAlreadyProcessed(message.batchId)) return
        
        processReviewAnalysis(message)
        sqsClient.deleteMessage(receiptHandle)
        
    } catch (e: Exception) {
        log.error("Message processing failed", e)
        throw e
    } finally {
        heartbeatJob.cancel()
        heartbeat.join()
    }
}

private fun startHeartbeat(receiptHandle: String): Job {
    return CoroutineScope(Dispatchers.IO).launch {
        while (isActive) {
            delay(30000) // 30초마다 연장
            try {
                sqsClient.changeMessageVisibility(receiptHandle, 120) // 2분으로 연장
            } catch (e: Exception) {
                log.warn("Failed to extend visibility timeout", e)
                break
            }
        }
    }
}

참고: Heartbeat 주기는 전체 타임아웃의 1/3 ~ 1/4 정도로 설정할 수 있습니다. 타임아웃이 120초라면 → 30초 간격으로 갱신하는 구성을 적용할 수 있습니다.

DLQ 설정
안정적인 운영을 위해서는 Dead Letter Queue 설정을 고려할 수 있습니다. 처리 실패가 반복되는 메시지의 무한 재시도를 방지할 수 있습니다. 적절한 재시도 횟수를 설정하여 DLQ로 이동하도록 구성할 수 있습니다.

✅ 개선 결과

서버 자원 안정성

  • CPU 사용률을 일정 수준으로 유지할 수 있음
  • 메모리 사용량을 예측 가능한 범위로 관리할 수 있음
  • 네트워크 대역폭을 효율적으로 사용할 수 있음

배치 작업 효율성

  • 리소스 제약에 맞는 동시성 제어 가능
  • 개별 배치 실패가 전체 시스템에 미치는 영향 최소화
  • 데이터량 증가에 대한 확장성 확보
  • 실시간 처리 상태 모니터링 가능

운영 관리

  • 장애 발생 시 영향 범위를 제한할 수 있음
  • DLQ를 통한 실패 메시지 관리 가능
  • 처리 서버의 수평 확장 가능

💡 구현 시 고려사항

구현 과정에서 고려할 수 있는 점들입니다:

  1. 리소스 한계 고려: 서버 스펙에 맞는 동시성 설정을 통해 시스템 안정성을 확보할 수 있습니다.
  2. DLQ 모니터링: 실패 메시지 누적을 방지하기 위해 정기적인 확인을 수행할 수 있습니다.
  3. 멱등성 보장: 중복 처리가 발생하더라도 시스템에 부작용이 없도록 설계할 수 있습니다.

📚 다음 글에서는

LangChain을 활용한 LLM 연동 최적화에 대해 다룰 예정입니다:

  • LangChain을 통한 효율적인 LLM 처리
  • 프롬프트 엔지니어링을 통한 토큰 사용량 최적화
  • 추론 강도 조절을 통한 응답 속도 최적화

기술 스택

  • SpringBoot 3.x, Kotlin
  • AWS SQS (FIFO), MySQL
  • JdbcTemplate, Kotlin Coroutines
profile
일하며 겪은 문제를 나눠요

0개의 댓글