dev notes

4개월째 반복된 ECS CPU spike — JSON_EXTRACT full scan 해결기

2026-04-1713 min read
공유

증상#

운영 중인 multi-tenant SaaS에서 특정 고객사(약 1,000명 규모)가 bulk 작업을 실행할 때마다 ECS Fargate의 CPU가 98%까지 치솟았습니다. 매주 반복, 4개월째 지속. 임시 조치로 task spec을 2배로 올려둔 상태였습니다 (vCPU 2048→4096, Memory 8096→10240).

이 서비스는 수 년간 운영되어 온 레거시 시스템이고, 원래 설계자는 이미 없는 상태에서 인수받은 코드였습니다. 아키텍처 전체를 파악하지 못한 채로 디버깅을 시작해야 했습니다.

잘못된 출발: 다른 서비스부터 고침#

증상은 서비스 A의 CPU spike였습니다. 그런데 bulk 작업 트리거 시점에 서비스 B의 API가 61초 timeout을 찍고 있었습니다. "B가 느려서 A에 부하를 주는 거겠지"라는 가설로 시작했습니다.

서비스 B의 레거시 코드를 열어보니 실제로 문제가 있었습니다. 트리 구조를 순회하면서 연관 데이터를 생성하는 로직에 O(N²) 탐색이 여러 군데 있었습니다.

트리 노드 탐색: O(N) stream → O(1) HashMap#

java
// Before: 매번 전체 노드를 순회
public FooNode findNode(Long id) {
    return nodes.stream()
        .filter(node -> node.getId().equals(id))
        .findFirst()
        .orElseThrow();
}

트리 빌드 시점에 index를 만들어두면 됩니다.

java
private Map<Long, FooNode> nodeIndex;
 
private void initNodes() {
    nodes = Lists.newArrayList();
    nodeIndex = Maps.newHashMap();
    rootNode.traverse(node -> {
        nodes.add(node);
        nodeIndex.put(node.getId(), node);
        return null;
    });
}
 
public FooNode findNode(Long id) {
    FooNode node = this.nodeIndex.get(id);
    if (node == null) {
        throw new IllegalArgumentException("id: " + id);
    }
    return node;
}

서비스 특성상 트리 구조를 순회해야 하는 곳이 여러 군데 있었는데, 다른 트리에서도 같은 패턴이 있었습니다. 1,000명 규모의 데이터에서 상위 노드 탐색이 반복되면 호출 횟수가 급격히 늘어납니다.

java
private Map<Long, List<BarNode>> barIndex;
 
private void initBarNodes() {
    allNodes = Lists.newArrayList();
    barIndex = new HashMap<>();
    this.rootNode.traverse(node -> {
        allNodes.add(node);
        if (node instanceof BarNode bn) {
            barIndex
                .computeIfAbsent(bn.getRefId(), k -> new ArrayList<>())
                .add(bn);
        }
        return null;
    });
}

결과: 61.6초 → 25.9초. 배포했습니다. 그런데 서비스 A의 CPU를 다시 보니 여전히 100%를 넘기고 있었습니다. 서비스 B의 개선은 그 자체로 유효하지만, 서비스 A의 CPU spike와는 관련이 없었습니다.

방향 전환: 서비스 A 직접 조사#

APM trace를 확인하니, CPU spike 시간대에 가장 많이 호출되는 엔드포인트는 bulk 작업 API가 아니라 목록 조회 API였습니다. 일일 2,055회 호출. 사용자들이 화면에 진입할 때마다 치는 엔드포인트였습니다.

JSON_EXTRACT가 WHERE 절에 있었다#

이 서비스의 레거시 설계에서는 일부 속성을 JSON 컬럼에 저장하고 있었습니다. 해당 API의 핵심 쿼리를 EXPLAIN 해보니 문제가 바로 보였습니다.

sql
EXPLAIN SELECT a.*
FROM foo_item a
WHERE JSON_EXTRACT(a.metadata, '$.targetId') = 12345;
+------+-------+-------+---------+-------------+
| type | key   | rows  | filtered| Extra       |
+------+-------+-------+---------+-------------+
| ALL  | NULL  | 39508 | 100.00  | Using where |
+------+-------+-------+---------+-------------+

39,508 rows full table scan. JSON_EXTRACT는 non-sargable expression이라 인덱스를 탈 수 없습니다. 이 쿼리가 하나의 API 호출에서 여러 번 실행되고, 그 API가 하루 2,000번 이상 호출됩니다.

레거시 코드에서 JSON 컬럼을 도입한 시점에는 데이터가 적어서 full scan이 문제되지 않았을 겁니다. 데이터가 쌓이면서 성능이 서서히 악화된 전형적인 패턴입니다.

시도 1: N+1 → Batch 전환 (실패)#

가장 먼저 시도한 건 N+1 쿼리를 배치로 묶는 것이었습니다. targetId 목록을 미리 모아서 IN clause로 한 번에 조회하면 쿼리 횟수가 줄어드니까 당연히 빨라질 거라고 생각했습니다.

k6로 부하 테스트를 돌렸습니다.

# Before (N+1, 15 VU, 2min)
avg=1,044ms  p95=1,940ms

# After (Batch IN clause)
avg=1,450ms  p95=2,890ms

39% 느려졌습니다. concurrent load 환경에서 IN clause + JSON_EXTRACT는 full scan 시간이 IN 절 크기에 비례해서 늘어나고, 그 동안 DB connection을 잡고 있습니다. 요청이 동시에 들어오면 connection pool이 빠르게 고갈됩니다.

Vlad Mihalcea의 connection pool sizing 분석에서 설명하는 Little's Law(L = λ × W)가 정확히 이 상황입니다. 개별 쿼리의 hold time(W)이 늘어나면, 같은 throughput(λ)을 유지하기 위해 필요한 동시 연결 수(L)가 비례해서 증가합니다. pool이 이를 감당 못하면 대기열이 생기고 전체 latency가 올라갑니다.

개별 쿼리가 full scan인 상황에서 batch로 묶으면 IN 절이 커지는 만큼 full scan 시간도 늘어나고, 그 동안 connection을 잡고 있습니다. 롤백했습니다.

시도 2: Generated Column + Index (성공)#

JSON_EXTRACT에 직접 인덱스를 걸 수 없으니, MariaDB의 Generated Column 기능으로 JSON 필드 값을 별도 컬럼으로 추출합니다.

sql
ALTER TABLE foo_item
  ADD COLUMN target_id BIGINT
  AS (JSON_EXTRACT(metadata, '$.targetId')) PERSISTENT;
 
CREATE INDEX idx_target_id ON foo_item (target_id);

PERSISTENT로 지정하면 INSERT/UPDATE 시점에 값이 계산되어 디스크에 저장됩니다. 일반 컬럼과 동일하게 인덱스를 탈 수 있습니다.

sql
EXPLAIN SELECT * FROM foo_item WHERE target_id = 12345;
+------+---------------+------+---------+-------+
| type | key           | rows | filtered| Extra |
+------+---------------+------+---------+-------+
| ref  | idx_target_id | 1    | 100.00  | NULL  |
+------+---------------+------+---------+-------+

39,508 rows → 1 row.

multi-tenant DDL 적용#

이 서비스는 테넌트마다 별도 스키마를 사용합니다 (약 300개 스키마). DDL을 한 번에 적용해야 합니다.

처음에는 mysql < apply.sql로 실행했는데, 특정 테넌트에 해당 테이블이 없어서 스크립트가 중간에 멈췄습니다. --force는 에러를 무시하고 진행하기 때문에 의도하지 않은 DDL이 실행될 위험이 있어서 사용하지 않았습니다.

information_schema를 조회해서 테이블과 컬럼 존재 여부를 확인한 후 실행하는 방식으로 변경했습니다.

sql
SET @schema_name = 'tenant_001';
SET @table_exists = (
    SELECT COUNT(*) FROM information_schema.tables
    WHERE table_schema = @schema_name
    AND table_name = 'foo_item'
);
 
SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.columns
    WHERE table_schema = @schema_name
    AND table_name = 'foo_item'
    AND column_name = 'target_id'
);
 
SET @sql = IF(@table_exists > 0 AND @col_exists = 0,
    CONCAT('ALTER TABLE `', @schema_name, '`.foo_item ',
           'ADD COLUMN target_id BIGINT ',
           'AS (JSON_EXTRACT(metadata, ''$.targetId'')) PERSISTENT'),
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

롤백은 동일한 패턴으로 DROP COLUMN. additive change라 기존 쿼리와 INSERT에 영향이 없고, 롤백도 안전합니다.

코드 변경: JSON_EXTRACT → Generated Column 직접 참조#

DDL만으로는 부족합니다. 기존 JPQL이 여전히 JSON_EXTRACT function call을 사용하면 MariaDB 옵티마이저가 Generated Column과 매칭하지 않습니다. 쿼리를 명시적으로 변경해야 합니다.

java
// Before
@Query("SELECT a FROM FooItem a " +
       "WHERE FUNCTION('JSON_EXTRACT', a.metadata, '$.targetId') = :targetId")
 
// After
@Query("SELECT a FROM FooItem a " +
       "WHERE a.targetId = :targetId")

엔티티에도 매핑을 추가합니다.

java
@Column(name = "target_id", insertable = false, updatable = false)
private Long targetId;

insertable = false, updatable = false가 핵심입니다. Generated Column은 DB가 자동으로 계산하므로 JPA가 INSERT/UPDATE에 포함시키면 안 됩니다.

결과#

# Before (JSON_EXTRACT full scan)
avg=1,044ms  p95=1,940ms  max=4,290ms

# After (Generated Column + Index)
avg=889ms    p95=1,650ms  max=3,380ms

개발 환경 기준 약 15% 개선. prod에서는 데이터 볼륨과 동시 접속자 규모가 다르기 때문에 30-50% 수준의 개선이 예상됩니다. full scan → index lookup이므로 데이터가 많을수록 차이가 커집니다.

발견했지만 해결하지 못한 것들#

Feign Client 커넥션 리밋 미설정#

서비스 간 통신에 OpenFeign을 사용하고 있었는데, maxConnectionsmaxConnectionsPerRoute가 기본값(200/50) 그대로였습니다. 레거시 설정을 그대로 가져온 결과입니다.

yaml
# 설정되어 있지 않음 — 기본값 사용 중
feign:
  httpclient:
    max-connections: 200
    max-connections-per-route: 50

k6 테스트에서 서비스 A의 CPU가 감소하자 서비스 B의 CPU가 61%→100%까지 올라갔습니다. 병목이 이동한 것입니다. Feign 레벨에서 concurrent request를 제한하지 않으면 downstream 서비스에 부하가 그대로 전달됩니다.

서킷브레이커: Hystrix → Resilience4j 마이그레이션 필요#

프로젝트에서 사용 중인 Hystrix는 2018년에 maintenance mode로 전환된 후 업데이트되지 않고 있습니다. Netflix는 Resilience4j로의 마이그레이션을 권장하고 있습니다. bulkhead, rate limiter, retry 등의 패턴이 Resilience4j에서 더 세밀하게 제어 가능합니다. 레거시 의존성이 그대로 남아있는 전형적인 케이스입니다.

ECS Auto Scaling 미설정#

해당 서비스에 Auto Scaling policy가 설정되어 있지 않았습니다. CPU가 98%까지 올라가도 task가 추가되지 않습니다. target tracking policy로 CPU 70% 기준 스케일아웃을 걸면 코드 변경 없이 peak을 흡수할 수 있습니다.

돌아보며#

타임라인을 정리하면 이렇습니다.

작업소요 시간서비스 A CPU 영향
서비스 B O(N²) → HashMap~3시간없음
N+1 → Batch 전환 + k6 + 롤백~1.5시간39% 악화
Generated Column DDL + EXPLAIN~10분39,508 → 1 row

결과적으로 EXPLAIN 한 방이 가장 빨리 답을 줬습니다. Brendan Gregg의 USE Method대로 Utilization → Saturation → Errors 순서로 서비스 A부터 봤으면 이 테이블에서 첫 행이 사라졌을 겁니다.

batch 전환이 오히려 느려진 건 Julia Evans가 말하는 "Inspect, don't squash" 케이스였습니다. 원인을 모르는 상태에서 패턴을 적용하면 상황을 더 꼬이게 만듭니다. Uber의 M3 최적화에서도 비슷한 경험을 공유하고 있습니다 — 앱 코드를 1주일 뒤졌는데 원인은 Go runtime이었다는 이야기.

레거시 코드를 열면 고칠 곳이 눈에 들어옵니다. O(N²) 탐색, deprecated 라이브러리, 미설정된 커넥션 리밋. 그게 다 실제 문제이긴 한데, 지금 추적 중인 증상과 관련이 있는지는 별개입니다.

Connected Notes