데이터 Backfill을 위한 자원 격리 전략

데이터 Backfill을 위한 자원 격리 전략

1. 서론: 대용량 데이터 재전송(Backfill) 아키텍처의 당면 과제

현대의 분산 시스템 아키텍처에서 데이터 파이프라인의 안정성은 서비스의 생존과 직결됩니다. 특히 사물인터넷(IoT), 금융, 혹은 실시간 모니터링 시스템에서 발생하는 시계열(TimeSeries) 데이터는 초당 수만 건 이상의 높은 스루풋(Throughput)을 요구하는 경우가 많습니다. 이러한 환경에서 시스템 장애, 네트워크 단절, 혹은 다운스트림(Downstream) 데이터베이스의 일시적 마비로 인해 실패한 데이터를 누락 없이 다시 채워 넣는 재전송(Backfill) 프로세스는 백엔드 엔지니어에게 매우 까다로운 숙제입니다.

많은 개발자가 범하는 실수 중 하나는 실패한 데이터를 단순히 루프를 돌며 다시 쏘아 보내거나, 실시간 트래픽이 흐르는 공용 메시지 큐에 그대로 밀어 넣는 것입니다. 그러나 대규모 운영 환경에서 이러한 접근은 실시간 정상 서비스까지 마비시키는 연쇄 장애(Cascading Failure)를 유발합니다.

본 기술 문서에서는 초고속 메시징 인프라인 NATS와 Java의 고성능 논블로킹 자료구조인 ConcurrentLinkedQueue, 그리고 스프링의 커스텀 비동기 스레드 풀(@Async)을 유기적으로 결합하여, 실시간 트래픽을 완벽히 보호하면서도 수백만 건의 누적 시계열 데이터를 안정적이고 효율적으로 재전송한 시스템 고도화 경험과 그 최적화 전략을 상세히 기술합니다.

2. 기존 아키텍처의 한계점과 기술적 페인 포인트(Pain Point)

고도화 이전의 초기 시스템은 하나의 메시징 파이프라인 내에서 실시간 트래픽과 재전송 트래픽을 동시에 처리하는 단순한 구조였습니다. 이 구조는 소규모 테스트 환경에서는 결함을 드러내지 않았으나, 운영 환경에서 대규모 백필 작업이 트리거되는 순간 다음과 같은 세 가지 치명적인 병목 현상을 야기했습니다.

2.1. 자원 고갈(Resource Exhaustion)로 인한 실시간 서비스 마비

실시간 사용자 요청과 백필 요청이 공용 HTTP 스레드 풀과 단일 메시지 큐 자원을 공유했습니다. 이로 인해 수백만 건의 백필 데이터가 유입되면, 재전송 워커 스레드가 전체 CPU와 메모리, 그리고 스레드 자원을 독점하게 되었습니다. 결과적으로 실시간으로 인입되어야 할 핵심 요청이 스레드를 할당받지 못해 무한정 대기하다가 Connection Timeout으로 터지는 대참사가 발생했습니다.

image1.png

그림 1. 실시간 요청과 백필 요청이 동일한 자원을 공유할 때 발생하는 자원 고갈 문제

2.2. 시간 기반(Time-based) 스케줄링의 부하 스파이크

큐에 쌓인 백필 데이터를 다운스트림 데이터베이스(PostgreSQL)에 적재할 때, 초기에는 "10초마다 스케줄러를 돌려 큐를 완전히 비운다" 같은 시간 중심의 로직을 사용했습니다. 이는 데이터 유입량이 불규칙할 때 예측 불가능성을 키웁니다. 특정 10초 사이에 데이터가 폭발적으로 쌓이면, 다음 스케줄링 타임에 수만 건의 쿼리가 한꺼번에 데이터베이스로 투하되면서 커넥션 풀이 마비되고 DB CPU 사용률이 100%를 찍는 '트래픽 스파이크(Spike)' 현상이 빈번했습니다.

2.3. 스레드 블로킹(Thread Blocking)으로 인한 리소스 낭비

백필 데이터 재전송이 모두 완료된 시점을 확인하고 최종 통계 집계(Aggregation) 프로세스를 트리거하는 과정에서, "모든 데이터가 처리될 때까지 잠시 대기한다"는 명목으로 Thread.sleep()이나 CountDownLatch 같은 동기식 블로킹 매커니즘을 사용했습니다. 이는 대기 상태의 스레드가 메모리를 계속 붙잡고 있게 만들며, 잦은 컨텍스트 스위칭(Context Switching)을 유발하여 전반적인 시스템 자원 효율성을 극도로 저하시켰습니다.

3. 격리와 최적화를 위한 4가지 아키텍처 고도화 전략

위의 세 가지 한계를 극복하기 위해, 인프라 레이어부터 애플리케이션의 자료구조, 그리고 스레드 모델에 이르기까지 전반적인 구조를 완전히 분리하는 Bulkhead 패턴(격벽 격리 아키텍처)을 도입했습니다.

3.1. 인프라 및 자료구조 레이어 격리: NATS와 ConcurrentLinkedQueue

가장 먼저 진행한 것은 트래픽의 철저한 라우팅 분리입니다. 실시간 데이터 토픽과 백필 전용 토픽을 인프라(NATS) 레벨에서 분리하고, Java 애플리케이션 내부에는 백필 데이터만을 안전하게 수용하기 위한 독립된 메모리 버퍼 큐를 @Bean으로 등록했습니다.

@Configuration
public class QueueConfig {

    @Bean
    public Queue<TimeSeriesVo> backfillTimeSeriesVoQueue() {
        // 다중 스레드 환경에서 Lock-Free 고성능을 보장하는 논블로킹 큐 선택
        return new ConcurrentLinkedQueue<>();
    }
}

여기서 핵심은 ConcurrentLinkedQueue의 선택입니다. 일반적인 LinkedBlockingQueue나 ArrayBlockingQueue는 생산자(Producer)와 소비자(Consumer) 스레드가 큐에 접근할 때 락(Lock)을 획득해야 하므로 동시성 경쟁이 심할 때 대기 병목이 생깁니다. 반면, ConcurrentLinkedQueue는 락을 사용하지 않고 CPU 레벨의 원자적 연산인 CAS(Compare-And-Swap) 알고리즘 기반으로 작동합니다. 네트워크 응답 속도가 무지막지하게 빠른 NATS 컨슈머가 초당 수만 건의 데이터를 밀어 넣어도, 워커 스레드와 충돌 없이 논블로킹(Non-blocking)으로 안전하게 데이터를 메모리에 받아낼 수 있는 완충지대 역할을 수행합니다.

image2.png

그림 2. 다수의 NATS 컨슈머 스레드가 CAS 알고리즘으로 락 없이 큐에 데이터를 적재하는 방식

3.2. 스레드 레이어 격리: @Async("backfillTimeSeriesPool")

NATS와 메모리 큐를 통해 데이터를 안전하게 받았더라도, 이를 꺼내어 실제 비즈니스 로직을 수행하고 DB에 적재하는 워커들이 웹 서버의 메인 스레드 풀을 공유한다면 앞서 언급한 자원 고갈 문제는 해결되지 않습니다.

따라서 스프링의 @Async 매커니즘을 활용하여, 백필 작업만을 전담하는 독립된 커스텀 Thread Pool을 구축했습니다.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "backfillTimeSeriesPool")
    public Executor backfillTimeSeriesPool() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);          // 기본 유지할 스레드 개수
        executor.setMaxPoolSize(10);          // 트래픽 폭발 시 최대 확장 스레드 개수
        executor.setQueueCapacity(500);       // Task 대기 큐 용량
        executor.setThreadNamePrefix("backfill-task-");
        executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

이렇게 격리된 스레드 풀을 선언하고, 백필을 처리하는 비동기 워커 컴포넌트에 주입합니다.

@Component
public class BackfillWorker {

    private final Queue<TimeSeriesVo> backfillTimeSeriesVoQueue;
    private final TimeSeriesRepository timeSeriesRepository;

    public BackfillWorker(
            Queue<TimeSeriesVo> backfillTimeSeriesVoQueue,
            TimeSeriesRepository timeSeriesRepository) {
        this.backfillTimeSeriesVoQueue = backfillTimeSeriesVoQueue;
        this.timeSeriesRepository = timeSeriesRepository;
    }

    @Async("backfillTimeSeriesPool")
    public void processBackfill() {
        // 공용 HTTP 스레드를 전혀 오염시키지 않고, 백필 전용 스레드(backfill-task-) 내에서만 안전하게 루프 구동
        List<TimeSeriesVo> chunkList = new ArrayList<>();
        while (!backfillTimeSeriesVoQueue.isEmpty()) {
            TimeSeriesVo vo = backfillTimeSeriesVoQueue.poll();
            if (vo != null) {
                chunkList.add(vo);
                // 3.3 섹션에서 서술할 크기 기반 청크 처리 로직이 여기에 위치함
                if (chunkList.size() >= 1000) {
                    timeSeriesRepository.saveAll(chunkList);
                    chunkList.clear();
                }
            }
        }
        if (!chunkList.isEmpty()) {
            timeSeriesRepository.saveAll(chunkList);
        }
    }
}

이 설계를 통해 백필 작업이 수 시간 동안 무겁게 백그라운드에서 돌아가더라도, 유저의 실시간 요청을 처리하는 http-nio- 스레드들은 완전히 격리되어 0.1초 미만의 고성능 응답 속도를 평온하게 유지할 수 있게 되었습니다.

3.3. 백엔드 흐름 제어(Flow Control): 크기 기반(Size-based) 청크 추출

시간 기반 스케줄러의 무차별적인 DB 투하 문제를 해결하기 위해, 시스템의 한계 용량 내에서 최적의 속도를 유지하도록 Size-based Extraction(크기 기반 추출) 방식으로 패러다임을 바꿨습니다.

스레드가 큐에서 데이터를 인출할 때, 시간의 흐름에 의존하지 않고 오직 데이터의 개수(Chunk Size)만을 기준으로 삼습니다. 데이터베이스(PostgreSQL)가 인덱스 갱신 및 커넥션 부하 측면에서 가장 안정적으로 받아들일 수 있는 최적의 벌크 단위를 산정하고, 큐에 데이터가 차오르는 대로 지정된 크기씩 쪼개어 지속적으로 DB에 밀어 넣습니다(poll()).

이 흐름 제어를 통해 백필 데이터가 수십만 건 이상 급격히 유입되더라도, 데이터베이스가 일시에 마비되는 최악의 상황을 방지하고 시스템 임계치 내에서 부하의 상한선을 제어하며 점진적으로 벌크 인서트(Bulk Insert)를 완수할 수 있는 구조적 안정성을 확보했습니다.

image3.png

그림 3. 시간 기반 스케줄링은 부하 스파이크를 유발하지만, 크기 기반 청크 추출은 일정한 처리량을 유지한다

3.4. 비동기 리소스 최적화: 제어 플래그와 isEmpty() 논블로킹 완료 검증

백필 데이터가 완벽히 재전송된 후, 시스템은 최종 데이터 정합성을 맞추기 위해 집계(Aggregation) 연산을 수행해야 합니다. 기존의 스레드 블로킹 방식을 걷어내고 완벽한 비동기 흐름을 완성하기 위해 Control Flag(제어 플래그)와 큐 상태 검증을 결합했습니다.

NATS 프로토콜을 통해 데이터 전송의 끝을 알리는 백필 완료 신호(End-of-Stream Flag)가 컨슈머에 도달하면, 시스템은 상태 플래그를 COMPLETING으로 전환합니다. 이후 워커 스레드는 잠들거나 대기하지 않고, 논블로킹으로 메인 큐의 상태를 체크합니다.

public void checkAndTriggerAggregation() {
    // 스레드를 재우는 오버헤드 없이, 락 프리로 큐의 공백 상태를 체크
    if (isEndOfStreamSignalReceived && backfillTimeSeriesVoQueue.isEmpty()) {
        // 대기 시간 0초 만에 즉시 최종 집계 프로세스 비동기 트리거
        aggregationService.triggerFinalAggregation();
    }
}

backfillTimeSeriesVoQueue.isEmpty()가 true를 반환하는 그 즉시, 어떠한 CPU 지연이나 스레드 유휴 상태 없이 최종 집계 로직이 트리거됩니다.

또한, 분산 비동기 환경 특성상 네트워크 지연으로 인해 '백필 데이터 요청'과 '성공 응답 수신'의 순서가 뒤바뀌는 일부 레이스 컨디션(Race Condition)을 방어하기 위해, 특정 정상 응답 이벤트를 수신하면 이를 메모리 내 화이트리스트(Whitelist)에 먼저 등록하고 교차 검증하는 로직을 추가하여 데이터 유실 가능성을 축소시켰습니다.

4. 결론: 인프라와 소프트웨어의 조화가 주는 교훈

본 고도화 프로젝트를 통해 얻은 가장 값진 교훈은 "아무리 빠른 고성능 분산 인프라(NATS)를 도입하더라도, 이를 받아내는 백엔드 애플리케이션 내부의 자료구조와 스레드 모델, 흐름 제어 전략이 부실하면 병목은 장소를 옮겨 결국 다시 발생한다"는 점입니다.

인프라 레벨에서의 토픽 분리, 스프링 설정을 통한 @Async 커스텀 스레드 풀 격리, ConcurrentLinkedQueue를 통한 락 프리(Lock-Free) 버퍼링, 그리고 크기 기반 청크 제어라는 사슬이 유기적으로 맞물렸을 때 비로소 진정한 의미의 대용량 데이터 파이프라인이 완성되었습니다.

시스템의 리소스 소비를 극적으로 낮추면서도 대규모 재전송 환경에서 데이터 무결성을 완벽하게 보장한 이번 아키텍처 개선 경험은, 한정된 컴퓨팅 자원 속에서 트레이드 오프(Trade-off)를 조율하고 어떤 극한의 트래픽 상황에서도 흔들리지 않는 견고한 시스템 안정성을 확보하는 발판이 되었습니다.

Pancake Maker

Site footer