본문 바로가기

Project/기록

10분마다 치는 spike 평탄화 작업기(feat. ACU 45% 절감)

Redis에 쌓인 조회수를 주기적으로 RDB에 동기화하는 작업을 진행중이었습니다.

최근 서비스가 성장중이며 DAU가 평균 500명대에서 최고 800명 수준으로 뛰어올랐고, 전체 Request 발생량 역시 평소 3~5만 건에서 피크 기준 15만 건까지 급증했습니다.

물론 이 정도의 절대적인 트래픽 규모나 데이터량이 당장 시스템을 다운되게 만들 만큼의 엄청난 양은 아니었습니다.

하지만 모니터링 대시보드에는 눈에 거슬리는 현상이 발생하기 시작했습니다. 이 단순해 보이는 동기화 배치가 돌 때마다 DB의 ACU가 4~5 부근으로 주기적인 스파이크를 치며 '톱날' 모양의 그래프를 그리고 있었습니다.

이 글에서는 비록 시스템을 당장 셧다운 시킬 규모는 아닐지라도, 잘못 짠 쿼리 패턴이 어떻게 DB 엔진과 Node.js 런타임에 불균형한 오버헤드(Overhead)를 유발하는지 CS 관점에서 해부해 보고, 이를 애플리케이션 레벨의 스케줄링과 SQL 구조 최적화로 풀어내어 인프라 비용을 45% 절감한 과정을 공유합니다.


10분마다 찾아오는 스파이크

10분 주기로 스파이크 치는 그래프

모니터링 대시보드의 증상은 명확했습니다. 정확히 10분 주기마다 Redis에 축적된 조회수 데이터를 RDBMS로 밀어 넣을 때마다 DB Reader와 Writer 인스턴스의 ACU와 CPU가 4~5까지 상승했다가 떨어지기를 반복했습니다.

장애 수준의 부하는 아니었지만, 단순한 동기화 작업이 소모하는 리소스 치고는 명백히 비효율적인 패턴입니다.  원인은 데이터를 RDBMS로 밀어 넣는 과정에서 발생한 'Monolithic Query(단일 쿼리)' 묶음 방식에 있었습니다.

1-1 . 데이터 병합

기존 로직은 Redis의 scan 명령어로 모든 데이터를 애플리케이션 메모리에 한꺼번에 적재한 뒤, 아래와 같은 UPDATE ... CASE WHEN 구문을 사용하여 조회수 Row를 단 하나의 트랜잭션으로 처리하려 했습니다.

UPDATE "Content"
SET
"viewCount" = CASE "id"
    WHEN 1 THEN 100
    WHEN 2 THEN 150
    ...
    ELSE "viewCount"
END
WHERE id IN (1, 2, ... -- ${ids});

"DB 커넥션을 여러 번 맺는 건 비용이 크니까, 쿼리 한 방에 태워 보내자!"라는 의도였을 것입니다. 하지만, 이것은 DB와 Node.js 양쪽의 아키텍처를 전혀 고려하지 않은 치명적인 안티 패턴이었습니다.

1-2. DB 계층의 문제: "실행"보다 "해석"이 힘들다

보통 DB가 느리다고 하면 디스크 I/O 병목을 떠올리지만, 이 스파이크의 진짜 원인은 구문 분석(Lexical Analysis)과 옵티마이저 오버헤드에 있었습니다. RDBMS는 텍스트를 실행하기 전 아래와 같은 순서를 동작시키는데요.

Update on "Content"  (cost=105.23..845.12 rows=1000)
  ->  Index Scan using "Content_pkey" on "Content"  (actual time=2.102..12.315 rows=5000 loops=1)
        Index Cond: (id = ANY ('{1,2,...,5000}'::integer[]))

Planning Time: 485.621 ms
Execution Time: 19.105 ms
구문 분석 -> AST(추상 구문 트리) 생성 -> 실행 계획 수립

AST 생성 비용 및 CPU 사이클 낭비입니다. 수천 개의 WHEN 조건이 포함된 긴 텍스트 쿼리가 들어오면, DB 엔진은 이를 문법 트리에 매핑하기 위해 불필요한 Heap 메모리를 할당하고 CPU 사이클을 낭비합니다.

옵티마이저의 한계입니다. WHERE IN 절에 다수의 조건이 들어가면 옵티마이저는 비용을 계산하는 데 부하를 겪습니다. 데이터 자체를 쓰는 시간보다, 쿼리를 해석하는 데 DB의 CPU가 돌며 ACU 스파이크를 유발한 것입니다

즉, DB는 디스크에 데이터를 쓰기도 전에 Massive한 쿼리를 '해석'하느라 이미 뻗어버리게 되는 상황이 발생할 것입니다.

1-3. Node.js 계층의 문제: 이벤트 루프 블로킹

do {
    const result = await this.redisAdapter.getCommonService().scan({
      cursor,
      match: REDIS_API_KEYS('*').CONTENT_VIEW_COUNT,
      count: this.BATCH_SIZE,
    })
    cursor = result.cursor

    parsedKey = parsedKey.concat(
      result.keys.map((key) => {
        const match = key.match(/content:(\d+):view/)
        return match ? match[1] : ''
      }),
    )

    keys = keys.concat(result.keys)
  } while (cursor !== 0)

해당 쿼리는 비동기 논블로킹(Non-blocking) 기반의 Node.js 서버와도 어울리지 않았습니다.

Node는 싱글 스레드입니다. 다수의 키-값을 순회하며 긴 SQL 문자열을 조합하는 CPU-Bound 작업이 진행되며, 메인 스레드가 블로킹 됩니다. 그렇게 다른 유저들의 API 요청이나, I/O 콜백이 제때 처리되지 못하고 Latency가 발생했습니다.

위 두가지는 한번의 쿼리로 효율을 높이겠다는 착각이 불러온 문제점들이었습니다.

해결 전략: 나누고, 양보하고, 효율화하고

해결책은 Node.js의 이벤트 루프와 DB의 파서가 가볍게 소화할 수 있는 수준으로 작업을 나누고, 각각이 가장 잘하는 방식으로 데이터를 다루게 제어하는 것입니다.

1-1. Chunk 단위 실행 및 유량 제어(Throttling)

모든 데이터를 한 번에 문자열로 조합하는 방식을 버리고, BATCH_SIZE를 설정해 Chunk 단위로 나누어 스캔하도록 수정했습니다. 핵심은 setTimeout을 단순한 대기 목적으로 쓴 것이 아니라, 이벤트 루프의 틱(Tick)을 의도적으로 분리하여 스케줄링했다는 점입니다.

do {
  // 1. 적정 크기로 분할 조회 (Chunking)
  const result = await this.redisAdapter.getCommonService().scan({
    cursor,
    match: REDIS_API_KEYS('*').CONTENT_VIEW_COUNT,
    count: this.BATCH_SIZE, 
  })
  
  // ... (중략) ...

  if (keys.length > 0) {
    await this.processBatch(keys)
    
    // 2. 이벤트 루프 제어 (Throttling)
    // 200ms 대기하여 다른 I/O 작업이 처리될 시간을 확보
    await new Promise((res) => setTimeout(res, 200))
  }
} while (cursor !== 0)

이 전략은 메인 스레드의 제어권을 태스크 큐(Task Queue)로 유량제어(Throttling)함으로써, 동기화 배치가 도는 와중에도 타 유저의 API 요청이나 I/O 작업에 우선순위를 보장합니다. 해당 방식으로 Event Loop Starvation을 해소 할 수 있을 것입니다.

1-2. SQL 최적화: 절차적 구문을 집합적 관계로 (DB 레벨)

이제 DB 쪽의 파싱 병목을 해결할 차례입니다. 기존의 UPDATE ... CASE WHEN은 if-else 로직을 억지로 SQL에 끼워 넣은 절차적 접근이었습니다. 이를 RDBMS의 본질인 집합(Set) 연산에 맞게 VALUES 구문을 사용한 쿼리로 리팩토링했습니다. 수천 개의 조건문을 해석하라고 던져주는 대신, 업데이트할 데이터 자체를 하나의 '관계(Relation, 가상 테이블)'로 취급하도록 VALUES 구문을 사용하여 쿼리를 리팩토링했습니다.

UPDATE "Content" AS c
SET "viewCount" = v.count
FROM (
    VALUES 
    (1, 100), 
    (2, 150), 
    ... 
) AS v(id, count) -- 가상 테이블화
WHERE c.id = v.id;

이 작은 문법의 차이가 가져오는 CS적 이점은 엄청납니다.

기존 방식에서는 DB 파서가 끝도 없는 분기(Branch)를 가진 AST를 생성하느라 CPU 오버헤드가 발생했습니다. 하지만 VALUES 구문을 사용하면 데이터베이스는 괄호 안의 데이터를 일시적인 인메모리 가상 테이블(Derived Table)로 인식합니다.

복잡한 구문 분석 과정이 통째로 생략되고, 타겟 테이블과 가상 테이블 간의 표준 조인(Join) 연산으로 해석됩니다. 쿼리 파싱에 낭비되던 CPU 자원을 보존하고 실행 엔진 쪽으로 부하를 넘길 수 있었습니다.


성과 검증 및 데이터 기반 분석

변경된 로직을 배포한 후, 10분마다 톱니바퀴처럼 튀던 스파이크가 완전히 사라졌습니다.

가장 중요한 엔지니어링 성과는 '부하의 평탄화(Workload Flattening)'를 달성했다는 점입니다. EKS의 HPA나 Aurora Serverless 같은 클라우드 환경은 메트릭 변화를 감지하고 스케일링하기까지 물리적 시간(Warm-up)이 필요합니다. 이전처럼 10분마다 ACU가 4~5로 급상승하는 패턴에서는 인프라가 제때 반응하기 까다롭습니다.(물론 4에 지정해놓지 않겠지만, 성장중이었기에 이런 내용을 작성했습니다.)

부하를 평탄화함으로써 인프라가 튀는 구간 없이 통제된 범위 내에서 리소스를 유연하게 소비하는 환경이 완성되었습니다.

1-1. 정량적 성과 및 인프라 임팩트

최적화 결과, 인프라 비용 절감과 시스템 안정성 측면에서 압도적인 성과를 거두었습니다.

톱니바퀴 제거!

  • Reader ACU(Aurora Capacity Unit): 약 26% 감소
  • Writer ACU(Aurora Capacity Unit): 약 45% 감소


결론

"네트워크 왕복(I/O)을 줄이기 위해 쿼리를 합치자"는 최적화가, 실제로는 DB 엔진의 파서와 Node.js의 메인 스레드에 불필요한 오버헤드를 유발하는 안티 패턴이 될 수 있음을 확인했습니다.

향후 아키텍처 개선을 해야함은 분명합니다. 현재의 최적화로 리소스 효율은 확보했으나, 주기적인 업데이트 작업이 원본 테이블에 지속된다는 것은 현재 테이블 스키마의 확장성에 한계는 있다 로 생각 됩니다. 향후 트래픽이 더 증가한다면 원본 테이블에 직접 업데이트를 치는 대신, 조회수 전용 통계 테이블을 분리하거나 반정규화(Denormalization)를 도입하는 방향으로 아키텍처를 진화시켜 나가야합니다.