모니터링 에이전트의 CPU 사용량을 낮춘 방법
📘 SyncMon
파일 읽기 로직의 6가지 비효율을 제거하며 얻은 것
운영 중인 모니터링 시스템에서 CPU 문제는 대개 두 가지 중 하나로 귀결됩니다.
하나는 처리량이 실제로 늘어난 경우이고, 다른 하나는 같은 일을 비효율적으로 반복하고 있는 경우입니다.
이번 이슈는 후자에 가까웠습니다.
SyncMon 환경에서 OGG 관련 지표를 수집하던 PerformanceRate, DmlCount 계열 서비스는 시간이 갈수록 CPU 사용량이 비정상적으로 높아졌습니다. 특히 에이전트 수가 증가하고, 각 프로세스의 rpt 파일이 지속적으로 갱신되는 구간에서 CPU가 예상보다 가파르게 상승했습니다.
처음에는 단순히 “파일 감시 대상이 많아졌기 때문”이라고 볼 수도 있었습니다. 하지만 실제 변경 사항을 따라가 보니, 문제는 파일 수 자체보다 파일을 다루는 방식에 있었습니다.
이 글은 그 과정에서 확인한 비효율과, 왜 단순 튜닝이 아니라 파일 수집 구조 자체를 다시 정리해야 했는지를 정리한 기록입니다.
1. 배경: 이 문제는 왜 운영에서 바로 체감됐는가
구조는 단순했습니다.
OGG가 rpt 파일에 지속적으로 상태를 기록하고, 에이전트는 이 파일을 읽어 필요한 지표를 추출한 뒤 서버로 전달합니다.
문제는 이 구조가 “파일이 계속 바뀐다”는 전제 위에 있다는 점입니다.
즉, 읽기 로직은 단발성 배치가 아니라, 장시간 살아 있으면서 같은 파일을 반복적으로 감시하고 일부만 읽어야 하는 tailing 성격의 작업이었습니다.
이런 작업에서 CPU가 높아진다는 것은 대개 파일 파싱이 무거워서가 아니라, 아래 둘 중 하나입니다.
- 바뀌지 않은 파일에 대해서도 계속 불필요한 작업을 한다
- 바뀐 파일을 읽더라도 이미 처리한 영역까지 반복해서 읽는다
이번 케이스는 두 가지가 동시에 있었습니다.
결과적으로 이 서비스는 “새로운 데이터만 가볍게 읽는 구조”가 아니라, 변경 감지, 파일 오픈, 포인터 관리, 스케줄링, 수명 관리가 서로 어긋난 채 누적 비용을 키우는 구조가 되어 있었습니다.
2. 증상: CPU는 왜 생각보다 더 가파르게 올랐는가
이 문제의 특징은 단순히 프로세스 수에 비례해서 CPU가 늘어난 것이 아니라, 운영 시간이 길어질수록 비효율이 더 커지는 방향으로 나타났다는 점입니다.
특히 rpt 파일이 계속 append 되는 환경에서는 파일 크기가 점점 커집니다.
이 상황에서 파일 포인터를 잘못 다루거나, 매번 파일을 새로 열거나, 변경이 없는데도 루프를 계속 진입하면 누적 비용은 선형이 아니라 훨씬 더 불리한 방식으로 쌓입니다.
즉, 당시 문제는 CPU를 많이 쓰는 “한 번의 무거운 작업”이 있었던 게 아니라,
가벼워야 할 반복 작업이 계속 불필요하게 비싸게 수행되고 있었던 것이 본질이었습니다.
3. 실제 원인: 파일 읽기 구조에 숨어 있던 6가지 비효율
이번 diff에서 의미 있었던 건, 눈에 띄는 로직 하나를 최적화한 것이 아니라 작은 비효율 여러 개가 한 방향으로 CPU를 밀어 올리고 있었다는 점입니다.
3.1 RandomAccessFile을 호출 때마다 새로 열고 닫고 있었다
가장 먼저 드러난 문제는 파일 핸들 재사용이 되지 않는다는 점이었습니다.
기존에는 프로세스별로 RandomAccessFile이 이미 열려 있더라도, 호출 시점마다 기존 객체를 닫고 다시 열었습니다.
하지만 tailing 성격의 작업에서는 좋지 않습니다. 파일이 바뀌지 않아도, 수집 주기마다 파일 핸들을 새로 만들고 OS 자원을 다시 할당받는 비용이 들어가기 때문입니다.
개선 이후에는 파일 객체가 없을 때만 새로 열고, 이미 있으면 그대로 재사용하도록 바꿨습니다.
이 변화는 단순한 객체 재사용 이상의 의미가 있습니다.
장시간 반복 작업에서는 “매번 새로 여는 방식”보다 “상태를 유지하면서 읽는 방식”이 훨씬 자연스럽고, 파일 tailing 구조와도 맞습니다.
3.2 파일에 새 내용이 없어도 읽기 루프에 진입하고 있었다
두 번째 문제는 더 직접적이었습니다.
파일에 실제 변경이 없더라도, 마지막으로 읽은 위치 이후를 다시 읽기 위해 루프에 진입하고 있었습니다.
개선 전에는 seek(lastFilePointer) 이후 곧바로 읽기 로직이 돌았습니다.
즉, 읽을 데이터가 없다는 사실을 확인하기 전에 읽기 준비 비용부터 쓰고 있었던 것입니다.
개선 후에는 파일 길이와 마지막 포인터를 먼저 비교해서, 새 내용이 없으면 즉시 반환하도록 바꿨습니다.
이 변경의 핵심은 단순한 조건문 추가가 아닙니다.
읽기 로직 진입 자체를 줄였다는 점이 중요합니다.
운영성 코드에서 CPU를 아끼는 가장 좋은 방법은 “더 빨리 읽는 것”보다 “아예 읽지 않아도 되는 경우를 빨리 탈출하는 것”입니다.
이 변경은 정확히 그 방향의 수정이었습니다.
3.3 ENTRY_MODIFY마다 파일 포인터를 초기화하고 있었다
이번 이슈에서 가장 치명적이었던 부분 중 하나는 파일 포인터 관리였습니다.
기존에는 파일 생성뿐 아니라 수정 이벤트가 들어올 때도 initFileInfo(processName)를 호출해 포인터를 초기화하고 있었습니다.
문제는 rpt 파일의 ENTRY_MODIFY는 예외적인 이벤트가 아니라 로그가 append 될 때마다 매우 자주 발생하는 정상 이벤트라는 점입니다.
즉, 파일에 내용이 추가될 때마다 포인터를 0이나 초기 상태로 되돌리는 구조였고, 그 결과 이미 읽은 파일도 다시 처음부터 읽게 됐습니다.
rpt 파일이 커질수록 이 비용은 더 심각해집니다.
운영 초반에는 티가 안 나더라도, 시간이 지나 파일 크기가 커지면 매 수집 주기마다 과거 데이터를 반복해서 훑는 구조가 됩니다.
개선 후에는 파일이 진짜 새로 만들어졌을 때만 초기화하도록 바꿨습니다.
이건 단순한 이벤트 분기 수정이 아닙니다.
파일 감시 시스템에서는 CREATE, MODIFY, ROTATE, REPLACE가 각각 의미가 다르고, 이 의미를 잘못 해석하면 읽기 비용이 눈덩이처럼 불어납니다.
이번 수정은 이벤트 의미와 포인터 생명주기를 맞춘 작업이었다고 보는 게 맞습니다.
3.4 WatchService는 이벤트를 받았지만, 처리 루프는 여전히 공격적으로 돌고 있었다
기존 코드의 watchService.take() 자체는 블로킹 호출이기 때문에 CPU를 바로 높이지는 않습니다.
문제는 이벤트를 처리한 뒤였습니다.
변경이 많이 발생하는 구간에서는 이벤트 처리 후 곧바로 다시 루프에 진입하고, 그 다음 이벤트를 계속 소비하는 흐름이 매우 촘촘하게 이어졌습니다. 중간에 루프를 완충하는 장치가 없었습니다.
개선 후에는 poll(timeout) 방식으로 바꾸고, 이벤트가 없을 때와 처리 후 모두 일정한 간격을 두도록 조정했습니다.
여기서 중요한 것은 sleep 자체가 아니라, 이벤트 감지와 실제 처리 속도를 분리했다는 점입니다.
운영 코드에서는 “이벤트가 들어오는 즉시 무조건 가장 빨리 처리하는 것”이 항상 정답이 아닙니다.
특히 파일이 짧은 주기로 연속 수정되는 환경에서는, 감시 루프가 지나치게 민감하면 오히려 CPU만 더 많이 쓰고 실질적인 운영 가치는 크지 않을 수 있습니다.
즉, 이번 수정은 감시 정확도를 해치지 않는 선에서 불필요하게 예민한 소비 루프를 완화한 것에 가깝습니다.
3.5 API 호출이 없어도 수집 스케줄러가 계속 살아 있었다
이 문제는 CPU 최적화에서 자주 놓치는 종류입니다.
읽기 로직이 효율적이더라도, 애초에 더 이상 필요 없는 작업이 살아 있으면 그 자체가 낭비입니다.
기존 구조에서는 isRunning이 true인 한 스케줄러가 계속 살아 있었고, 해당 API가 더 이상 호출되지 않거나 에이전트가 사실상 비활성 상태가 되어도 백그라운드 수집 작업이 남아 있었습니다.
개선 후에는 마지막 API 호출 시점을 기록하고, 일정 시간 이상 호출이 없으면 스스로 종료하도록 바꿨습니다.
또는 더 짧은 주기를 요구하는 수집기는 1분 기준으로 종료했습니다.
이 변화는 단순히 불필요한 스레드를 줄이는 정도가 아닙니다.
운영 시스템에서 백그라운드 작업은 “잘 돌게 만드는 것”만큼이나 언제 멈춰야 하는지가 중요합니다.
즉, 이 수정은 수집기의 성능을 개선한 것이기도 하지만, 동시에 수집기의 생명주기를 요청 기반으로 맞춘 작업이기도 했습니다.
3.6 scheduleAtFixedRate가 작업 중첩 가능성을 만들고 있었다
마지막으로 실행 정책도 문제였습니다.
기존에는 scheduleAtFixedRate(...)를 사용하고 있었는데, 이 방식은 이전 작업이 오래 걸려도 다음 실행 시점을 기준으로 작업을 계속 예약합니다.
즉, 읽기 비용이 커지는 상황에서는 작업이 겹치거나, 최소한 스케줄링 압박이 누적될 수 있습니다.
개선 후에는 scheduleWithFixedDelay(...)로 변경했습니다.
이 방식은 이전 작업이 끝난 뒤 일정 시간 간격을 두고 다음 작업을 시작합니다.
즉, 실행 주기를 엄격하게 맞추는 대신, 작업 중첩을 막고 시스템이 감당 가능한 속도로 흐르게 만듭니다.
모니터링 수집기에서는 이 선택이 꽤 중요합니다.
이론상 더 자주 돌 수 있다고 해서 항상 좋은 게 아니라, 수집 주기보다 중요한 것은 이전 수집이 끝나기도 전에 다음 수집이 밀려들지 않게 하는 것이기 때문입니다.
이번 수정은 스케줄링 방식을 바꾼 것이 아니라, 사실상 작업 압력을 제어하는 정책을 바꾼 것에 가깝습니다.
4. 이번 개선의 핵심은 “빠르게 읽기”보다 “불필요하게 읽지 않기”였다
이 6가지 변경을 관통하는 공통점은 명확합니다.
- 파일을 다시 열지 않아도 되는 경우는 다시 열지 않고
- 새 데이터가 없으면 읽지 않고
- 이미 읽은 데이터는 다시 읽지 않고
- 이벤트가 많다고 해서 무조건 더 촘촘하게 반응하지 않고
- 더 이상 필요 없는 작업은 종료하고
- 이전 실행이 끝나지 않았으면 다음 실행을 밀어 넣지 않는다
즉, 이번 개선은 CPU를 줄이기 위한 미세 최적화라기보다,
수집기를 “항상 뭔가 하고 있는 구조”에서 “필 있는 구조”에서 “필요할 때만 일하는 구조”로 바꾼 작업이었습니다.
운영 환경에서 CPU를 안정화할 때 중요한 건 보통 알고리즘 개선보다 이런 종류의 정리입니다.
특히 tailing, polling, watch, scheduler 같은 반복성 높은 구성요소는 작은 비효율이 오래 누적되기 때문에, 개별 로직보다 생명주기와 진입 조건을 먼저 정리하는 것이 더 큰 효과를 내는 경우가 많습니다.
5. 개선 이후 기대할 수 있었던 변화
이런 종류의 개선은 대개 특정 함수 하나가 빨라졌다고 끝나지 않습니다.
의미 있는 변화는 보통 아래 세 가지에서 나옵니다.
첫째, 파일 핸들 생성/해제와 불필요한 읽기 루프가 줄어들면서 CPU 피크가 낮아집니다.
둘째, rpt 파일이 커질수록 더 불리해지던 구조가 완화되면서 운영 시간에 따른 성능 악화가 줄어듭니다.
셋째, 사용하지 않는 수집기가 정리되면서 에이전트 수 증가 시 백그라운드 부하가 덜 누적됩니다.
6. 이 이슈에서 얻은 교훈
이번 작업을 정리하면서 다시 느낀 점은,
운영성 코드는 대개 “무거운 연산”보다 “가벼운 낭비의 반복”에서 더 자주 무너진다는 것입니다.
특히 파일을 감시하고, 일부만 읽고, 일정 주기로 스케줄링하는 구조에서는 다음 네 가지가 매우 중요합니다.
- 상태를 재사용할 수 있으면 재사용할 것
- 변경이 없으면 최대한 이른 시점에 탈출할 것
- 이벤트의 의미를 정확히 구분할 것
- 작업은 시작보다 종료 조건이 더 중요할 수 있다는 점을 잊지 말 것
이번 CPU 이슈는 거창한 병렬 처리 문제도 아니었고, 비싼 연산 하나 때문도 아니었습니다.
오히려 파일 tailing과 스케줄링이라는 일상적인 코드가 운영 규모를 만나면서 구조적 비용으로 드러난 사례에 더 가까웠습니다.
결국 해결도 복잡한 최적화가 아니라,
이미 읽은 것을 다시 읽지 않고, 필요 없는 작업은 돌지 않게 하고, 실행이 겹치지 않게 만드는 기본 원칙을 운영 코드에 다시 적용하는 과정이었습니다.
7. 정리
이번 개선은 PerformanceRate, DmlCount 수집 로직의 CPU를 줄이기 위한 단순 튜닝이 아니었습니다.
본질적으로는 파일 기반 수집기의 읽기 방식, 이벤트 처리 방식, 스케줄링 방식, 생명주기를 운영 가능한 구조로 다시 정리한 작업이었습니다.
문제의 시작은 사소해 보였습니다.
- 파일을 매번 다시 열고
- 새 내용이 없어도 읽고
- 수정 이벤트마다 포인터를 초기화하고
- 감시 루프는 과하게 민감했고
- 필요 없는 스케줄러도 살아 있었고
- 실행 주기는 작업 완료와 무관하게 계속 흘러갔습니다
이 각각은 단독으로 보면 작은 비효율처럼 보일 수 있습니다.
하지만 반복 작업에서는 이런 작은 실수들이 가장 먼저 CPU를 잡아먹습니다.
이번 작업의 핵심은 한 줄짜리 최적화가 아니라,
수집기를 “계속 일하는 구조”에서 “필요한 만큼만 일하는 구조”로 바꾼 것이었습니다.
운영 시스템은 결국 오래 버텨야 합니다.
그래서 성능 개선도 “한 번 빠르게”보다 오래 돌려도 덜 무너지는 구조를 만드는 쪽이 더 중요합니다. 이번 변경은 바로 그 방향의 수정이었습니다.