Spring Boot 1분 단위 API 폴링 아키텍처

2026. 03. 22 작성 · 트러블슈팅

1. 서비스의 핵심 요구사항: 1분의 벽을 넘어라

로스트아크 경매장 알림 서비스 '벨로아'의 백엔드는 매우 명확하면서도 까다로운 요구사항을 가지고 있습니다. 바로 수백, 수천 명의 사용자가 등록한 키워드(아이템명, 각인, 품질 등)를 기반으로 게임사의 Open API를 주기적으로 호출하고, 새로운 매물이 등장하면 1분 이내에 푸시 알림을 발송해야 한다는 것입니다. 이를 위해 Spring Boot의 @Scheduled 어노테이션을 활용하여 1분 주기의 폴링(Polling) 아키텍처를 설계하게 되었습니다.

2. 초기 설계의 한계와 병목 현상 (Bottleneck)

초기에는 단순하게 1분마다 동작하는 스케줄러를 만들고, 루프를 돌며 API를 호출한 뒤 파싱하여 DB에 INSERT 하는 순차적(Synchronous) 방식으로 구현했습니다. 그러나 사용자가 등록하는 알림 조건이 많아질수록 단일 스레드(Single Thread) 기반의 스케줄러는 이전 작업이 끝나기도 전에 다음 1분 주기가 도래하는 Task Overlap(작업 겹침) 현상을 발생시켰습니다. API 응답 대기 시간과 DB I/O 시간이 합쳐져 스레드가 블로킹(Blocking)되었고, 결과적으로 알림 지연과 서버 메모리 부족 현상이 나타났습니다.

3. 비동기 처리(Asynchronous)와 스레드 풀(Thread Pool) 튜닝

이 문제를 해결하기 위해 가장 먼저 병렬 처리를 도입했습니다. Spring의 @Async 어노테이션을 활용하여 API 호출 및 파싱 로직을 비동기 워커(Worker) 스레드에 위임했습니다. 스케줄러 스레드는 작업만 할당하고 바로 다음 로직을 이어가도록 분리한 것입니다.

단순히 @Async만 붙이는 것으로는 부족했습니다. 트래픽 스파이크 시 무한정 스레드가 생성되어 OOM(Out of Memory)이 발생하는 것을 막기 위해, ThreadPoolTaskExecutor를 직접 빈(Bean)으로 등록하여 Core Pool Size, Max Pool Size, Queue Capacity를 서버의 가용 리소스(현재 OCI 2GB RAM 환경)에 맞게 세밀하게 튜닝했습니다. 큐가 가득 찼을 때의 정책(RejectedExecutionHandler)은 데이터를 유실하지 않기 위해 CallerRunsPolicy로 설정하여 메인 스케줄러가 잠시 백프레셔(Backpressure)를 받도록 유도했습니다.

4. MariaDB Bulk Insert를 통한 I/O 최적화

API 호출 속도는 비동기 처리로 해결했지만, 수집된 대량의 데이터를 MariaDB에 저장하는 과정에서 다시 병목이 발생했습니다. JPA의 기본 saveAll() 메서드는 내부적으로 엔티티 건수만큼 단건 INSERT 쿼리를 날리기 때문에 네트워크 I/O 비용이 매우 컸습니다.

이를 해결하기 위해 JPA의 한계를 우회하여 Spring JDBC의 JdbcTemplate.batchUpdate()를 도입했습니다. 수십 개의 INSERT 쿼리를 하나의 쿼리로 묶어서 DB에 날리는 Bulk Insert(다중 행 삽입) 방식으로 변경한 결과, DB 저장에 소요되는 시간이 기존 대비 약 80% 이상 단축되었습니다. 부가적으로 검색 속도 향상을 위해 아이템 이름과 카테고리 컬럼에 복합 인덱스(Composite Index)를 구성하여 필터링 매칭 속도도 함께 끌어올렸습니다.

5. 요약 및 성과

스레드 풀을 활용한 비동기 병렬 처리와 데이터베이스 Bulk Insert 최적화를 통해, 단일 서버 환경에서도 타임아웃 없이 안정적으로 1분 단위 API 폴링과 푸시 발송을 처리할 수 있게 되었습니다. 제한된 인프라 자원 내에서 소프트웨어 아키텍처와 DB 최적화만으로 시스템의 처리량(Throughput)을 극대화한 의미 있는 트러블슈팅 경험이었습니다.