보안관제자동화 에이전트 회고 17주차 - triage 워커 병목 개선

아키텍처11분English

English version: Security Operations Automation Agent Retro, Week 17 - Fixing the Triage Worker Bottleneck

배경

개발계에서 triage가 밀리는 병목이 생겼다.
보안 alert를 모아 incident로 묶고, LLM으로 incident마다 true positive(TP)인지 false positive(FP)인지 가르는 시스템이다.
이 과정을 triage, 그 결과를 verdict라고 부른다.

incident는 서로 관련된 alert를 묶은 단위다. triage는 incident가 실제 위협인지 가려 대응 순서를 정하는 일이고, verdict는 그 결과(TP 또는 FP)다.

alert alert 묶기 incident로 triage 대기 줄 번호 작은 순 triage 워커 배치마다 N건 LLM 2회 호출 verdict 테이블 후속 처리 결과 저장
triage 워커가 incident를 몇 건씩 묶어(배치) 가져가 LLM에 보낸다. 색으로 표시한 곳이 이 글에서 다루는 부분이다.

고객사 하나를 새로 연결하자, 연결 전까지 보류해 둔 alert가 재처리되며 incident 수천 건이 한꺼번에 생겼다.
이 글에서는 이렇게 생긴 incident를 보류분이라고 부른다.
그 뒤로 다른 고객사의 새 incident가 triage를 받지 못하고 쌓였다.

확인해 보니 막힌 곳이 세 군데였다.
큐와 스레드를 고친 뒤에야 세 번째가 드러났다.

1 triage 대기 줄 · 큐 하나 … 새 incident 보류분 2 스케줄러 스레드 1개 triage 워커 (몇 분) alert 묶기 수집 … 그 외 14개 3 triage 배치 내부 17초 17초 17초 LLM 응답을 기다리며 한 건씩 보류분이 앞 번호를 차지해 새 incident가 뒤에서 기다림 가상 스레드 옵션 때문에 작업 17개가 스레드 하나 공유 배치 크기를 늘려도 분당 약 3건 그대로
막힌 곳 세 군데. 작업사항 1~3에서 차례대로 다룬다.

1번은 큐를 기본 큐와 보류분 큐로 분리했고,
2번은 공용 스레드 8개와 triage 워커 전용 스레드로 나눴고,
3번은 배치 안에서 여러 건을 동시에 처리하는 최소한의 병렬 처리만 먼저 적용했다.
이번 작업의 목표는 처리량을 끌어올리는 것보다 큐와 스레드 구조를 바로잡는 것이었다.
처리량 개선은 다음 작업에서 본격적으로 다룬다.

작업사항 - 1

큐 분리

triage 워커는 verdict가 없는 incident를 번호가 작은 순서로 몇 건씩 한 번에 배치로 처리한다.
오래된 것부터 처리해야 옛 incident가 계속 밀리지 않으니, 평소에는 맞는 규칙이다.
그런데 보류분 수천 건이 한꺼번에 생기면서 앞 번호를 차지했고, 새 incident는 그 뒤에서 기다려야 했다.
처리량은 분당 약 3건이라, 이 속도로는 보류분을 다 처리하는 데 하루 반이 넘게 걸렸다.

AS-IS … triage 워커 새 incident 보류분 수천 건이 앞 번호를 차지 앞에서부터 가져감 TO-BE 기본 큐 · 새 incident 밸브 동시 수·멈춤 기존 워커 보류분 큐 · 보류분 밸브 동시 수·멈춤 새 워커 verdict 테이블 하나를 같이 씀
바뀐 점: 보류분을 별도 큐로 분리.

incident마다 어느 큐에 배정됐는지 적는 컬럼을 하나 두고, 기존 워커는 기본 큐만, 새 워커는 보류분 큐만 가져가게 했다.
두 워커 모두 결과를 같은 verdict 테이블에 쓴다.
triage 뒤에 이어지는 후속 처리가 모두 verdict 테이블을 기준으로 돌기 때문에, 갈라지는 것은 누가 가져가느냐 하나뿐이다.
큐마다 밸브를 달아 동시 처리 수와 멈춤을 화면에서 바꾸게 했다.
기본 큐를 멈추면 실시간 triage가 멈추므로, 확인 창을 한 번 거친다.

triage에 실패한 incident의 재시도는 큐로 떼지 않았다.
재시도는 따로 도는 워커가 아니라, 각 워커가 배치마다 자기 큐의 실패 건을 먼저 집는 목록이다.
재시도를 세 번째 큐로 옮기면 어느 큐에서 왔는지가 사라져, 보류분의 실패가 실시간 triage를 다시 막는다.

작업사항 - 2

스케줄러 스레드 분리

새 워커를 배포하고 분 단위로 triage 건수를 세어 보니, 새 워커가 triage하는 동안에는 기존 워커가 0건이었고 반대도 마찬가지였다.
lock은 큐마다 따로 두었으니, 스레드 문제일 거라는 생각이 들었다.
확인해 보니 예약 작업 17개가 스레드 하나를 나눠 쓰고 있었다.
triage 배치가 약 5분 동안 그 스레드를 잡으면, 60초마다 돌아야 할 alert 묶기 작업이 150~172초 만에 돌았다.
늘어난 간격이 끝나는 시각은 triage 배치나 수집이 끝난 시각과 초 단위로 맞았다.

처음에는 스레드 수 설정(spring.task.scheduling.pool.size)의 기본값이 1이라서라고 생각했다.
반만 맞았다.
가상 스레드 옵션(spring.threads.virtual.enabled=true)을 켜면 Spring Boot는 스레드 풀 대신 SimpleAsyncTaskScheduler를 만든다.
이 스케줄러는 일정을 거는 스레드가 하나이고, fixedDelay 작업은 그 스레드에서 차례로 실행한다.
스레드 수 설정은 이 스케줄러에 적용되지 않아서, 값을 올려도 아무 변화가 없었다.

AS-IS 스레드 1개를 예약 작업 17개가 공유 0분 1분 2분 3분 4분 5분 6분 triage 배치 약 5분 동안 스레드를 잡음 alert 묶기 제때 못 돌고 triage 배치가 끝난 뒤 몰아서 돎 TO-BE 공용 스레드 8개 + triage 워커 전용 스레드 triage 배치 전용 스레드에서 돎 alert 묶기 60초마다 제때 돎
바뀐 점: triage 배치는 전용 스레드로, 나머지 예약 작업은 공용 스레드 8개로 분리.

예약 작업용 스레드 풀을 코드로 직접 정의해 공용 스레드 8개를 두었다.
개수는 운영 서버 사양을 보고 정했다.
스케줄러 빈이 하나 있으면 Spring Boot는 기본 스케줄러를 만들지 않으므로, @Scheduled 작업이 모두 이 풀로 온다.

@Bean
fun taskScheduler(): ThreadPoolTaskScheduler =
    ThreadPoolTaskScheduler().apply {
        poolSize = 8
        setThreadNamePrefix("scheduled-")
    }

triage 워커는 이 풀에 두지 않았다.
triage 배치는 몇 분씩 걸려 공용 스레드를 오래 차지하기 때문에, 워커마다 전용 단일 스레드 스케줄러를 만들어 직접 일정을 건다.

이 전용 스케줄러를 빈으로 등록하면 Spring이 그것을 공용 스케줄러로 착각하므로, 빈으로 등록하지 않았다.

작업사항 - 3

배치 내 병렬 처리

본격적인 처리량 개선은 다음 작업에서 다루고, 이번에는 큐와 스레드를 나눈 뒤에도 남은 병목을 풀 만큼만 최소한으로 적용했다.

스레드를 나눈 뒤에도 보류분 큐는 분당 약 2.6건이었다.
한 큐는 lock 하나로 한 번에 한 배치만 돌고, 배치 안에서는 여전히 한 건씩 triage했기 때문이다.
triage 한 건은 LLM을 두 번(verdict, 근거) 부르고 약 17초가 걸린다.
그중 실제 연산은 1초 남짓이고, 나머지는 LLM 응답을 기다리는 시간이다.
한 건씩 처리하면 1분에 끝낼 수 있는 건수는 한 건에 걸리는 시간으로 정해진다.
한 건에 17초 안팎이면 분당 3건 남짓이 한계라서, 배치 크기를 5건에서 10건으로 올려도 처리량은 그대로였다.

가장 쉬운 방법은 스레드 수를 늘려 triage를 무작정 동시에 돌리는 것이다.
하지만 LLM 서버는 동시 호출 수와 분당 호출 수에 한도가 있고, 한도를 넘는 요청은 429(Too Many Requests)로 거절한다.
스레드 수만큼 동시 호출이 늘어나면 한도에 걸려 triage가 한꺼번에 실패할 수 있다.
그래서 배치는 그대로 두고, 배치 안에서 큐마다 정해 둔 수만큼만 동시에 돌리게 했다.

AS-IS 0초 30초 60초 90초 120초 150초 한 건씩 17초 오래 걸림 40초 17초 17초 폴링 간격 60초 TO-BE 동시 2건 17초 17초 17초 17초 17초 17초 오래 걸림 40초 17초 17초 17초 폴링 간격 5초 배치 데드라인 90초
바뀐 점: 배치 안에서 두 건씩 동시에 처리하고, 폴링 간격을 60초에서 5초로 줄임.
  • 배치 크기는 그대로 두고, 동시 처리 수만큼 triage를 함께 돌린다. 한 건이 끝나면 바로 다음 건을 시작해서, 오래 걸리는 건 하나가 나머지를 묶지 않는다.
  • 한 건이 60초를 넘기면 끊고 재시도 대기로 보낸다.
  • 배치가 90초를 넘기면 새 건을 시작하지 않고, 진행 중인 건만 마친다. 시작하지 못한 건은 실패가 아니라 다음 배치로 넘어간다.
  • 시작값은 기본 큐 3건, 보류분 큐 2건이다. 밸브가 조절하는 값도 배치 크기에서 동시 처리 수로 바뀌었다.

폴링 간격(polling interval)도 같이 바꿔야 했다.
다음 배치는 앞 배치가 끝나고 60초 뒤에 시작한다(fixedDelay).
폴링 간격을 그대로 두고 3건씩 동시에 돌리면 배치 20초에 간격 60초라서, 6분에 약 13건으로 순차 처리와 별 차이가 없다.
그래서 큐에 남은 건이 있으면 폴링 간격을 5초로 줄이고, 큐가 비었을 때만 60초를 두게 했다.
이렇게 하면 같은 6분에 약 43건, 분당 약 7건이다.

작업사항 - 4

슬롯 방식을 쓰지 않은 이유

배치를 아예 없애고, 처리 중인 triage를 늘 N건으로 채워 두는 방식도 검토했다.
워커가 동시에 처리할 슬롯을 N개 갖고, 슬롯 하나가 비면 바로 큐에서 다음 건을 집어 넣는다.
배치 경계가 없으니 오래 걸리는 건이 있어도 그 슬롯 하나만 붙잡히고, 나머지 슬롯은 계속 돈다.

채택 배치 내 병렬 처리 동시 2건 17초 17초 17초 오래 걸림 60초 17초 17초 17초 17초 17초 17초 배치 끝 빈 시간 다음 배치 검토 슬롯 방식 슬롯 2개 17초 17초 17초 17초 17초 17초 오래 걸림 60초 17초 17초 빈 시간 없음 대신 처리 중 표시·만료·이어받기 필요
배치 내 병렬 처리는 배치 끝에서 빈 시간이 생기고, 슬롯 방식은 빈 시간이 없는 대신 장치가 더 필요하다.

처리량만 보면 슬롯 방식이 조금 낫다.
배치 내 병렬 처리는 배치 끝에서 오래 걸리는 건 하나를 기다리는 동안 나머지 슬롯이 놀고, 그 시간은 건별 시간 제한(60초)까지 늘어날 수 있다.

대신 슬롯 방식은 바꿔야 할 것이 훨씬 많다.

배치 내 병렬 처리 (채택)슬롯 방식
처리량동시 수에 비례, 배치 끝에서 조금 손해동시 수에 비례, 손해 거의 없음
오래 걸리는 건배치 끝에서 나머지가 기다림그 슬롯 하나만 차지
큐에서 집는 방식지금 그대로 (큐마다 lock 하나)incident마다 처리 중 표시 + FOR UPDATE SKIP LOCKED
여러 파드lock을 잡은 파드 하나만 돈다파드마다 슬롯, 처리 중 표시로 중복 방지
파드가 죽으면verdict가 없는 건은 다음 배치가 다시 집는다만료 시각이 지난 뒤 다른 파드가 이어받기
긴급 정지, 배치 기록그대로배치 단위 판단을 시간 창 기준으로 다시 설계

슬롯 방식은 배치가 없으니, 연속 실패를 세어 긴급 정지하는 판단이나 “이번 배치 전부 실패” 같은 판단을 모두 시간 창 기준으로 다시 짜야 한다.
얻는 것은 배치 끝의 빈 시간뿐인데, 큐에서 집는 방식부터 장애 처리까지 손대야 한다.

가장 큰 이유는 일정이었다. (인력충원이라도 해주던가..)
개발 기한은 정해져 있는데 고칠 곳이 너무 많아서, 기한 안에 바꾸기에는 리스크가 컸다.
그래서 큐마다 lock 하나인 지금 구조를 유지하고 배치 안에서만 동시에 돌렸다.
오래 걸리는 건 때문에 배치가 자주 시간 제한까지 늘어지는 것이 측정으로 보이면 그때 다시 검토할 예정이다.

배치를 겹쳐 돌리는 방식도 뺐다.
앞 배치에서 처리 중인 incident는 아직 verdict가 없어서 다음 배치가 또 집고, 겹친 배치 수만큼 동시 호출이 불어나 한도를 넘길 수 있다.

결과

분당 triage 건수많을수록 좋음 개선 전 · 한 건씩 2.6건 개선 후 · 동시 2건 5.5건 약 2.1배 보류분 5,956건을 다 처리하는 시간짧을수록 좋음 개선 전 · 한 건씩 약 38시간 개선 후 · 동시 2건 약 18시간 절반 이하
보류분 큐 기준. 동시 4건으로 배치 하나를 잰 값은 분당 10.5건이었다.

비교는 대기가 5천 건 넘게 쌓인 보류분 큐에서, 배치 크기(10건)를 바꾸지 않고 배포 앞뒤 구간으로 했다.

지표전후
보류분 10분당 triage 건수21~30건49~63건 (평균 약 55)
보류분 5,956건 소진 (처리량에서 환산)약 38시간약 18시간
LLM 호출 1회 소요 p50 / p9015.8초 / 19.4초16.6초 / 21.1초
분당 LLM 호출6.0회12.2회
한도 초과(429)00
수집 간격 상위 5%100~102초69초
수집 간격 90초 초과47~50번24~27번
수집 간격 120초 초과28~29번22~26번
예약 작업 스레드1개를 17개 작업이 공유공용 8 + triage 전용 2

처리량 개선은 남은 개발 기간에 다음 작업으로 진행할 예정이다.

참고