Enzi.Log

Postmortem

클로드 코드로 서버 점검하려다 토큰만 도둑맞음

2026-07-13읽는 데 5

현재 운영 중인 서비스는 EC2 t3.micro 한 대 위에 있다. vCPU 2개, RAM 1GB. 최소 사양이다.

서비스 출시는 좀 됐지만, 홍보를 본격적으로 하지 않다가 며칠 전 한 번의 홍보로 가입자가 800명을 넘어섰다.
슬랙으로 간단하게 지표를 볼 수 있는 기능을 붙였지만, 가입자가 많아지자 관리자 페이지가 따로 필요할 것 같아 관리자 페이지도 붙였다.
그리고 아직 문제가 생긴 건 아니지만, 이 사양으로 계속 가도 되나? 터지고 나서 수습하는 것보다 미리 점검해두는 게 낫다고 생각했다.
서버 안정성과 메모리 효율을 손보기로 하고, Claude Code에게 현황 파악을 맡겼다.

그리고 아래 내용은, AI 에이전트에게 서버 점검을 맡겨서 발생한 오류에 대한 내용이다.

1. 첫 번째 답변: "트래픽이 적어서 문제없습니다"

Claude Code가 서버에 붙어 확인하고 내놓은 답이다.

CPU 사용률 0%, load average 0.01. 관리자 페이지 응답 3ms. DB 느린 쿼리 0건. 결론: 트래픽이 적어 문제없음.

깔끔하고, 숫자가 붙어 있고, 그럴듯했다. 하지만 납득이 잘 안 갔다.
가입자가 800명이고 관리자 페이지까지 있는데, 정말 요청 수가 적다는 것만으로 괜찮다고 할 수 있나?
더블체킹을 시켰다.

"느린 쿼리 0건" — MySQL 설정을 열어보니:

SELECT @@slow_query_log, @@long_query_time, @@performance_schema;
-- 0, 10.000000, 0

slow_query_log꺼져 있었다. 임계치는 10초. performance_schema도 꺼져 있어 쿼리별 통계 자체가 없었다.
세지도 않는 걸 0이라고 읽고 있었다.

"관리자 페이지 3ms" — 관리자 API는 전부 5분 캐시 뒤에 있었다. DB를 안 타는 경로를 재놓고 "DB 괜찮다"고 말한 셈이다.

"응답 빠름" — nginx log_format$request_time이 없었다. 응답시간을 아예 기록하지 않고 있었다. 측정 자체가 불가능한 상태였다.

📍 Claude Code는 "데이터가 없다"와 "데이터가 0이다"를 구분하지 않았다.

2. 제대로 측정하니 다른 그림이 나왔다

nginx 로그에 rt=$request_time 추가하고 reload(무중단).
캐시를 우회하려고 JWT를 발급해 캐시 없는 실제 유저 엔드포인트에 부하를 걸었다.

동시성 처리량 p50 p99 에러
5 75.7 req/s 46ms 100ms 0
20 82.3 req/s 184ms 523ms 0
50 79.2 req/s 464ms 1.68s 0

그런데 이상한 게 보인다.
동시성을 10배 올렸는데 처리량이 80으로 계속 동일하다. 지연 시간만 정비례로 늘어난다.
어딘가 하드 캡이 있다.

3. Claude Code의 첫 번째 실수

병목을 찾던 Claude Code가 두 가지를 발견했다.

(1) 커넥션 풀이 3이다.

hikari:
  maximum-pool-size: 3

Tomcat은 요청을 200개까지 받는데 DB에 말을 걸 수 있는 건 3개뿐.
요청당 DB 점유가 약 35ms이니 3 ÷ 0.035 ≈ 85 req/s. 측정된 80과 정확히 맞아떨어졌다.
Claude Code는 문제 원인이 이것이라고 말했다.

(2) JVM 옵션이 통째로 무시되고 있다.

배포 스크립트는 옵션을 넘기고 있었다.

-e JAVA_OPTS='-Xms256m -Xmx384m -XX:+UseG1GC'

그런데 실행 중인 프로세스는:

$ cat /proc/<pid>/cmdline
java -jar app.jar

원인은 Dockerfile 한 줄이었다.

ENTRYPOINT ["java","-jar","app.jar"]   # exec 형식 → 셸 미경유 → $JAVA_OPTS 확장 안 됨

exec 형식은 셸을 거치지 않아 환경변수가 확장되지 않는다. JAVA_OPTS는 컨테이너 안에 적용되지 않고 그냥 글자로 존재할 뿐이었다.
이건 Claude Code가 제대로 된 문제를 찾은 거였다. JVM은 알아서 힙 230MB, SerialGC로 돌고 있었다. (적용하고자 한 건 G1GC)

Claude Code는 이걸 공유하면서, 현재 상황에서는 의도했던 G1GC를 적용하는게 적절하다고 말했고, 그대로 적용해 배포했다.

결과는 아래와 같았다.

작업 전 G1GC 적용 후
컨테이너 RSS 463MB 527MB
여유 메모리 126MB 65MB

G1GC는 remembered set·card table 같은 자체 자료구조로 힙 외 메모리를 더 쓴다. 한 번 키운 힙을 Full GC 전까지 OS에 반납하지도 않는다.
SerialGC는 여유가 생기면 힙을 줄여 반납한다.

JVM이 원래 SerialGC를 고른 데는 이유가 있었다. RAM 1GB에 CPU는 99% 놀고 있는 서버다. G1GC는 메모리를 더 써서 지연을 줄이는 방식인데, 우리에겐 지연 여유가 남아돌고 메모리가 없었다.
정확히 반대로 골랐다.

📍Claude Code는 "설정이 의도대로 적용되게 만드는 것"과 "그 의도가 옳은지 검증하는 것"을 같은 일로 취급했다.
JAVA_OPTS에 G1GC라고 적혀 있으니 그게 목표라고 가정했다. 이 서버에 적절한지 제대로 검증하지 않았다.

4. Claude Code의 두 번째 실수

되돌리고 재측정을 요청하자, Claude Code가 다급하게 보고했다.

경보 — 회귀입니다. 동시 20에서 처리량 12.6 req/s, p99 32초.

원인은 스왑이었다.
부하 중 여유 메모리가 33MB까지 떨어지고 페이지 아웃이 찍혔다(so=1668).
JVM 힙 페이지가 디스크로 쫓겨난 상태에서 GC가 힙을 훑으면, 페이지를 도로 읽어오느라 수십 초 멈춘다. 그동안 DB 커넥션을 붙잡고 있으니 뒤 요청이 줄줄이 밀린다.

그런데 왜 갑자기 스왑이 터졌나?

부하 생성기를 서버 안에서 돌리고 있었기 때문이다.
1GB 서버에서 curl 프로세스를 20~50개 띄우면 그것만으로 수백 MB다.
Claude Code는 자기가 만든 메모리 압박으로 서버를 스왑에 밀어 넣고, 그 결과를 "애플리케이션의 회귀"라고 읽고 있었다.

부하를 외부(노트북)에서 넣으니 그런 붕괴는 없었다. 동시 60에서 p99 1.4초, 에러 0.

통제된 A/B를 돌려보니 커넥션 풀 3 vs 10, 힙 192MB vs 256MB — 처리량 차이가 없었다.
3장에서 그렇게 자신 있던 "풀이 병목" 가설도, "GC 압박" 가설도 틀렸다.
3 ÷ 0.035 ≈ 85가 측정값 80과 맞아떨어진 건 우연이었다.

📍 Claude Code는 자기 관측이 대상에 미치는 영향을 계산에 넣지 않는다.

5. 그리고 진짜 문제를 찾았다

계측을 켜둔 덕에 결국 보였다. 108일 누적 통계:

  • Select_scan: 241만 건 (전체 SELECT의 19%가 풀스캔)
  • Handler_read_rnd_next: 7억 2천만 행

인덱스 현황을 뽑으니 한 테이블이 비어 있었다.
인증이 필요한 모든 요청마다 호출되는 테이블이었고, 그만큼 가장 자주 실행되는 쿼리가 가장 비효율적으로 돌고 있었다.
게다가 가입자가 늘수록 확실히 성능이 나빠지는 구조다. 진짜 문제는 겨우 인덱스 하나로 끝났다.

6. 최종 결과

작업 전 최종
JVM 옵션 무시됨 적용됨 (SerialGC / Xmx192m)
인증 쿼리 2,648행 풀스캔 1행
여유 메모리 (유휴) 126MB 249MB
여유 메모리 (부하 중) 87MB 143MB
동시 60 p99 6.32초 1.40초
스왑 부하 중 증가 안정

여기에 재발 방지를 붙였다.
응답 5초 초과 시 Slack 알림, 300ms 넘는 SQL 로그 기록, DB 커넥션 대기 30초 → 5초.


Claude Code는 짧은 시간 안에 혼자서는 못 했을 일을 해냈다.
/proc/<pid>/cmdline으로 JVM 실행값을 뜯어보고, JWT를 발급해 캐시를 우회하고, 108일치 MySQL 통계에서 풀스캔을 짚어냈다.
몇 시간짜리 일이 몇십 분에 끝났다.

그리고 두 번 다 유창한 설명으로 자신 있게 틀렸다.
G1GC를 적용할 땐 "의도한 설정이 무시되고 있었다"는 완벽한 명분이 있었고, 스왑 회귀를 보고할 땐 메커니즘 설명이 교과서 같았다. 그래서 정리하자면 내가 얻은 건 아래와 같다.

1️⃣ Claude Code의 결론은 근거의 품질까지 검증해주지 않는다. "느린 쿼리 0건"은 맞긴 했지만 무의미한 관측이었다.

2️⃣ Claude Code의 자신감을 의심해야 한다. 틀린 답이었는데도 설명이 굉장히 매끄러웠다. 그럴듯함은 검증의 대체재가 아니다.

그래서 CLAUDE.md에 네 줄을 넣었다

기능 개발이나 로직/구조 개선에 대한 규칙들은 진작부터 넣어져 있지만, 성능 작업이나 분석에 대한 규칙은 넣어놓지 않았다. 그래서 프로젝트의 CLAUDE.md에 규칙 아래 네 줄을 넣었다. 매 세션 자동으로 읽히는 파일이다.

## 인프라·성능 작업 규칙
1. 숫자를 근거로 결론 내리기 전에, 그 계측이 켜져 있는지부터 확인한다.
   "0건"은 데이터가 없는 걸 수도, 측정하지 않은 걸 수도 있다.
2. 설정을 바꾸기 전에, 현재값이 왜 그런지부터 확인/설명한다.
   기본값에는 이유가 있을 수 있다. 일반적 모범사례를 적용하는 것보다 이 서버의 실측이 우선이다.
3. 성능 측정은 대상 외부에서 한다. 측정 도구가 대상 자원을 쓰면 그 숫자는 무효다.
4. before/after 실측 없이 성능 관련 변경을 배포하지 않는다.

"처음부터 이걸 넣어뒀으면 몇 시간을 아꼈을 텐데."라는 생각도 잠깐 들었다. 그런데 사실 이걸 실제로 겪지 않았다면, 이 네 줄을 쓸 생각을 했을까 싶다.

결론: 토큰도 의심도 아끼지 말자

다양한 상황에서 충분히 사용하고 의심하며 클로드와 친해(?)지고, 거기서 얻어낸 것들을 규칙으로 축적하는 것이 중요하다는 생각이 들었다. 좀 더 AI를 잘 활용하는 법에 가까워지는 듯한 느낌도 받았다.
그러면서.... 왜 주변 개발자들이 사비를 써서라도 Claude Code Max를 쓰는지, AI를 여러 개 구독해서 굴리는지 새삼 알 것 같았다. ㅎㅎ
(--본인 Claude Pro 사용 중--)