Spring - SEV 장애 대응

🛠️ 개발 환경

  • Java: 21
  • Spring Boot: 3.4.4
  • DB: RDS + MariaDB

💬 상황 설명

최근 합류한 회사는 개발 조직이 최근에 만들어진 곳이었다. 빠르게 서비스를 만들다 보니 인프라에 부족한 부분이 많았다. 그래서 가장 먼저 손을 댄 일은 에러 알림을 슬랙으로 보내는 것이었다. 그 동안 아무도 몰랐던 에러들이 하나씩 수면 위로 올라왔고, 하나씩 정상화를 진행했다.

 

알림 체계를 갖추고 얼마 지나지 않아 순식간에 엄청 많은 에러 메세지가 쏟아졌다. 사이트를 확인해보니 동작은 하지만 정말 느렸고, 로그를 확인해보니 커넥션 풀 고갈이 문제였다. 하지만 실제 원인을 분석해본 결과 커넥션 풀 고갈은 표면적인 문제였다.

  1. 웹 훅이 사용하는 커넥션 풀을 지정하지 않았다.
  2. 순식간에 많은 웹 훅이 쏟아지면서 커넥션 풀을 모두 사용했다.
  3. 실사용자의 요청 마저도 실패하게 되었다.

어드민 페이지에서도 비슷한 에러가 발생했다.

  1. 커넥션 풀이 고갈된 상황에서 무거운 집계 요청이 왔다.
  2. 커넥션을 어떻게든 하나 잡아서 집계 쿼리를 DB에 날렸다.
  3. 서버 및 DB에는 Timeout 설정이 없었다.
  4. 어드민은 데이터 로딩이 느리다보니 새로고침을 했다.
  5. 그 때마다 무거운 집계 쿼리가 DB에 쌓이게 된다.

즉, 타임아웃이 없어 무거운 집계 쿼리가 끊기지 않고 계속 쌓였고, 그만큼 CPU 부하가 누적되며 결국 99%까지 치솟은 것이다.

✅ 해결 과정

1. SEV-2 응급 조치

이 상황은 핵심 기능에 대한 부분 장애였고, 최대 30분 내에 수정을 해야 했다. 지금 당장 할 수 있는 것은 두 가지였다. 길게 잡혀 있던 폭주 쿼리를 강제로 끊고, 더 이상 쌓이지 않도록 타임아웃을 걸고 HikariCP 풀을 2배로 늘리는 것이다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
    readonly:
      hikari:
        data-source-properties:
          socketTimeout: 60000
mybatis:
  configuration:
    default-statement-timeout: 15

이렇게 Green에 배포해 스위칭하고, 기존에 DB에서 길게 커넥션을 물고 있던 집계 쿼리를 강제로 Kill 했다. 그 결과 Connection is not available 에러는 잦아들었고, 사이트도 정상적으로 동작함을 확인했다.

 

다만 풀을 늘린 것은 급한 불을 끄는 임시 조치일 뿐 근본 해결이 아니었다. CPU가 포화된 상황에서 풀을 늘리는 건 폭주 쿼리를 더 많이 동시에 받아주는 꼴이라, 오히려 독이 될 수도 있다. 진짜 해결은 그 뒤에 이어졌다.

참고로 이 응급 조치 중 타임아웃 도입은, 사고 직후 포스트모템을 정리한 뒤 별도 PR로 반영한 재발 방지책이다. 당장의 불은 폭주 쿼리 Kill과 풀 확대로 껐고, 타임아웃은 "다시는 폭주가 쌓이지 않게" 만드는 방어선이었다.

2. 쿼리 최적화

프로젝트 내 존재하는 모든 조회 쿼리에 대해 최적화를 진행했다. 대부분의 테이블에 100만 ~ 500만 건 이상의 데이터가 존재하였고, JOIN 남발, 부족한 인덱싱 등 여러 문제가 많음을 발견했다.

 

그 중 가장 무거웠던 집계 쿼리를 우선적으로 손봤다. 이 쿼리는 상태별 집계값을 뽑기 위해 같은 테이블을 상태마다 다시 스캔하는 파생 테이블 4개를 만들고, 그걸 또 메인 테이블에 LEFT JOIN하고 있었다. 옵티마이저는 같은 테이블이라고 해서 한 번만 읽어 주지 않는다. 즉, 같은 테이블을 4번 풀스캔하고, 집계도 4번, 조인도 4번 수행한다.

LEFT JOIN (SELECT ..., COUNT(v.id) AS total_submitted ...) submitted_sum ON ...
LEFT JOIN (... WHERE v.status = 'APPROVED' ...) approved_sum  ON ...
LEFT JOIN (... WHERE v.status = 'PENDING'  ...) pending_sum   ON ...
LEFT JOIN (... WHERE v.status = 'DENIED'   ...) denied_sum    ON ...

이 쿼리를 CASE WHEN을 사용한 조건부 집계로 바꿔, 한 번만 스캔하면서 상태별 카운트를 모두 뽑도록 했다.

LEFT JOIN (
    SELECT ...,
        COUNT(v.id) AS total_submitted,
        COUNT(CASE WHEN v.status = 'APPROVED' THEN 1 END) AS total_approved,
        COUNT(CASE WHEN v.status = 'PENDING'  THEN 1 END) AS total_pending,
        COUNT(CASE WHEN v.status = 'DENIED'   THEN 1 END) AS total_denied
    ...
) ...

결과적으로 같은 테이블 스캔을 4회에서 1회로 줄였고, 결과값은 완전히 동일했다. 이 외에도 여러 조회 쿼리를 같은 관점으로 최적화했다.

3. 레플리카 도입

테이블마다 수백만 건에 JOIN도 많아 조회가 무겁고, 실제로 이번 사고도 읽기 부하가 CPU를 올린 경우였다. 읽기 트래픽을 원본에서 떼어내 원본은 쓰기에 집중시키면, 무거운 조회가 전체 서비스를 흔드는 일을 구조적으로 줄일 수 있다고 판단했다.

 

그렇게 읽기 복제본(replica) 인스턴스를 도입했다. 본 DB는 Master로 CRUD가 모두 가능하지만, 복제본인 Slave는 Read만 가능한 실시간 사본이다. Master는 가장 작은 축인 db.t3.small을 쓰고 있지만, Slave는 조회가 훨씬 많고 무겁기 때문에 한 단계 위인 db.t3.medium을 사용했다.

 

스프링에서는 AbstractRoutingDataSource를 상속해 "읽기 전용 트랜잭션이면 복제본, 아니면 원본" 이라는 규칙 하나만 구현하면 된다.

public class ReplicaRoutingDataSource extends AbstractRoutingDataSource {

    static final String WRITE = "WRITE"; // 원본(Master)
    static final String READ  = "READ"; // 복제본(Slave)

    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager
            .isCurrentTransactionReadOnly() ? READ : WRITE;
    }
}

이렇게 해두면 조회 메서드에 @Transactional(readOnly = true)만 붙여도 그 안의 SELECT가 자동으로 Slave로 흘러간다.

 

단, 여기엔 주의할 점이 하나 있다. 이 라우터만 등록하면 라우팅이 동작하지 않는다. 스프링은 트랜잭션을 시작할 때 보통 커넥션부터 먼저 잡고 그 다음에 readOnly 플래그를 세팅하는데, 위 라우터는 "커넥션을 잡는 순간"에 원본/복제본을 결정한다. 그래서 아직 readOnly 표시가 안 된 상태로 판단해 항상 원본으로 붙어버린다.

 

이를 해결하려면 데이터소스를 LazyConnectionDataSourceProxy로 한 번 감싸야 한다. 이 프록시는 커넥션 획득을 실제 쿼리가 나가는 순간까지 미루기 때문에, 그 사이에 readOnly 플래그가 이미 세팅되어 올바르게 복제본으로 라우팅된다.

@Configuration
public class DataSourceConfig {

    @Bean
    public DataSource routingDataSource(
        @Qualifier("dataSource") DataSource write,
        @Qualifier("replicaDataSource") DataSource read
    ) {
        ReplicaRoutingDataSource routing = new ReplicaRoutingDataSource();
        routing.setTargetDataSources(Map.of(
            ReplicaRoutingDataSource.WRITE, write,
            ReplicaRoutingDataSource.READ,  read));
        routing.setDefaultTargetDataSource(write);
        return routing;
    }

    @Primary
    @Bean
    public DataSource primaryRoutingDataSource(
        @Qualifier("routingDataSource") DataSource routing
    ) {
        return new LazyConnectionDataSourceProxy(routing);
    }
}

그렇게 기존 메서드들 중 내부에서 쓰기가 일어나지 않는 일반 조회 메서드를 모두 Slave에서 조회하도록 전환했다.

🤔 회고

Keep

정말 큰 사고였음에도 빠르게 문제를 파악하고 순차적으로 배포하며 에러를 해결했다. 또한 CloudWatch, Replica 모두 미루지 않고 빠르게 도입한 것은 잘한 일이라고 생각한다. 이번 이슈 이후로 데이터 로딩까지 5s 이상 걸리던 것들이 대부분 50% 이상 개선되었고, 1s 내로 줄어든 페이지도 생겼다.

Problem

Replica를 미루지 않고 도입한 것은 좋았지만, 67개 서비스 중 일부 서비스의 2개 메서드에서 내부적으로 Update가 일어나는 부분을 놓쳐 에러가 발생했었다. 읽기 전용 트랜잭션이 복제본으로 라우팅되는데, 그 안에서 쓰기를 시도하니 복제본이 거부한 것이다. 아직 테이블 구조나 서비스 플로우에 완벽하게 익숙하지 않기에 발생한 실수로 본다.

Try

자주 사용되는 메서드들은 서비스 코드 베이스를 더 깊게 들여다봐야 할 것 같다. 특히 readOnly 트랜잭션 안에서 쓰기가 숨어 있는 경로를 사전에 걸러내는 감사 습관이 필요할 것 같다.