코드는 다 있는데 알림이 안 온다
앞선 리버스 프록시 두 편이 트래픽 앞단 얘기였다면, 이번엔 인수받은 사내 모니터링 콘솔에 알림을 붙이다가 겪은 일이다. 결론부터: 알림은 켜졌고, 그 과정에서 2주 전 내 커밋이 심어둔 지뢰를 발견해 제거했다.
(사내 인프라 특성상 서버·도메인·사람 이름·수치 일부는 일반화했다. 시스템 이름은 “콘솔”로 부른다.)
배경 — 인수받은 콘솔
콘솔은 사내 서버들의 CPU/메모리/디스크를 수집해 대시보드로 보여주는 Java 8 서블릿 앱이다. 특징이 몇 가지 있다.
- SVN 커밋 = 즉시 운영 배포. 서버 쪽 훅이 바뀐
.java를 컴파일해서 돌아가는 Tomcat에 밀어넣는다. 스테이징은 없다. - 나는 서버 접근 권한이 없다. 할 수 있는 건 SVN 커밋과 관리자 웹 화면뿐.
- 자체 프레임워크, 자체 마이그레이션 도구, 자체 스케줄러. 문서는 있지만 3천 줄짜리 XML 한 파일에 SQL이 다 들어있는 식.
이런 시스템을 인수받고 첫 과내가 “알림을 Slack으로 받게 해달라”였다.
코드를 읽었더니 — 이미 다 있었다
먼저 “알림 기능을 만들어야 하나?”부터 확인했다. 코드를 읽어보니:
Slack.java— Incoming Webhook으로 POST, 3회 재시도AlertNotifier.java— 임계치 초과(FIRING) / 해소(RESOLVED) 단계별 발송, 실패하면 DB 컬럼(notified_at)을 비워둬서 다음 사이클에 재시도- 60초마다 도는 평가 배치가 규칙을 읽어 위 둘을 호출
전부 있었다. 없는 건 딱 하나, 서버에 webhook 주소가 설정돼 있지 않았던 것. 주소가 없으니 Slack.post()는 매번 조용히 false를 돌려주고, “못 보냈다”는 표시만 몇 주째 쌓이고 있었다.
여기서 첫 번째 교훈: “기능을 만들어달라”는 요청의 절반은 이미 있는 기능을 켜는 일이다. 코드를 먼저 읽자.
켜기 전에 로컬에서 4번 울려보기
서버 권한이 없고 스테이징도 없으니, 켜기 전에 내 PC에 전체 스택(JDK 8 + Tomcat 9 + MariaDB)을 올렸다. 그리고 실제 알림 파이프라인을 네 가지 시나리오로 돌렸다.
| 시나리오 | 만든 방법 | 결과 |
|---|---|---|
| 🔴 FIRING | 가짜 호스트에 5초마다 CPU 99% 샘플 주입 + 규칙 cpu > 1 |
감지 → 0.5초 뒤 Slack 도착 |
| ✅ RESOLVED | 규칙 임계값을 200으로 | 다음 사이클에 도착 |
| 🔴 DOWN | 샘플 주입 중단 → 2분 미수신 | 도착 |
| ✅ RECOVERED | 주입 재개 | 도착 |
여기서 작은 함정 하나. 처음엔 샘플을 한 번만 넣고 60초 배치를 기다렸는데 아무 일도 안 일어났다. 규칙의 “유지 시간”이 0초면 평가기는 최근 15초 창만 보는데, 60초 배치가 돌 때쯤엔 샘플이 이미 창 밖이었던 것이다. 수집기처럼 계속 넣어야 한다. 이 함정은 나중에 운영에서 그대로 다시 만난다(5장).
켜는 방법을 두고 — “그냥 커밋하면 안 돼요?”
주소를 어디에 넣을지가 문제였다. 이 앱은 시크릿을 System property > 환경변수 > 코드 기본값 순서로 읽는다. 선택지는:
- 코드 기본값에 박아서 커밋 — 된다. 커밋하면 배포되니까. 하지만 시크릿이 SVN 히스토리에 영원히 남고, 프로젝트 문서에 “시크릿은 SVN에 넣지 않는다”가 명시돼 있고, 두 달 전 똑같은 짓을 했다가 롤백한 전례가 있었다(파트4 예정).
- 서버 환경변수 — 정석. 서버 권한 있는 분께 한 줄 부탁.
- 관리 화면에서 넣게 코드 수정 — 오너가 서버 권한이 없는 구조에선 사실 이게 맞는 설계지만, 문서 규칙과 충돌해 합의 필요.
2번으로 요청했더니 배포 담당자가 “설정 파일(jnut.properties)에 넣고 알려달라”고 답했다. 그 파일은 SVN 안에 있어서 결국 1번과 비슷하게 시크릿이 저장소에 들어가는 셈이었는데, 배포 담당의 지시 + webhook은 노출돼도 “그 채널에 글쓰기”만 되고 재발급으로 즉시 무효화되는 낮은 등급 시크릿이라 감수하기로 했다. 다만 이 판단은 문서에 남겼다. 나중에 누가 “왜 여기 있지?” 할 테니까.
(참고: 키 이름이 환경변수 방식과 설정파일 방식이 다르다 — SMON_SLACK_WEBHOOK_URL vs smon.slack.webhookUrl. 이걸 틀리면 조용히 안 읽힌다. 로컬에서 설정파일 방식으로 한 번 더 울려보고 커밋했다.)
재시작 직후 — 알림 폭탄
재시작하자마자 Slack이 울리기 시작했다. DOWN, RECOVERED, DOWN, RECOVERED… 수백 건.
원인은 1장의 그 “못 보냈다는 표시”이다. 코드는 실패한 알림을 다음 사이클에 최대 100건씩 재시도하도록 설계돼 있고, 몇 주치 적체가 그대로 방출된 것이다. 설계상 정상이고, 사이클당 50개 이벤트(FIRING+RESOLVED 100메시지)씩 오래된 것부터 나간다.
예상은 했는데 준비를 안 했다. “쌓인 게 쏟아질 수 있다”까진 알았지만 재시작 전에 건수를 확인하거나, 오래된 것들에 미리 “보낸 것”으로 표시하는 SQL을 같이 부탁했어야 했다. 한 줄이면 됐다:
UPDATE alert_events
SET firing_notified_at = COALESCE(firing_notified_at, NOW(3)),
resolved_notified_at = CASE WHEN state='resolved' THEN COALESCE(resolved_notified_at, NOW(3)) ELSE resolved_notified_at END
WHERE fired_at < NOW(3) - INTERVAL 1 HOUR;
결국 채널 음소거 걸고 밤새 흘려보냈다. 다음날 아침엔 조용했고요.
운영 검증 — 0초의 함정, 다시
다음날 관리자 화면에서 테스트 규칙을 만들었다. CPU > 1, 유지 시간 0초. 아무 일도 안 일어났다. 이벤트 자체가 안 생김.
2장의 그 함정이다. 운영 수집기는 15초보다 뜸하게 보내니, “0초 = 최근 15초 창”은 운영에선 거의 항상 비어 있는 창이다. 유지 시간을 120초로 바꾸니 바로 🔴 FIRING 이 도착했다.
이건 사용법 문제라기보다 설계 허점이다. 화면이 “0초”를 허용하는데 그 규칙은 운영에서 영영 안 울린다. 최소 창을 수집 주기 기반으로 잡거나, 화면에 경고를 띄우는 게 맞다. 개선 항목으로 남겼다.
그리고 하나 더 — “규칙을 삭제하면 RESOLVED가 오겠지” 했는데 안 온다. 삭제 SQL이 이벤트를 해소 처리하면서 “해소 알림도 이미 보냈음”으로 같이 표시하더라. 관리자가 직접 끈 건 알리지 않는 설계였다. 나는 함수 이름만 보고 “온다”고 말했다가 SQL을 읽고 정정했다. 이름 말고 SQL을 읽자.
그런데 로컬에서 배치가 전부 멈췄다
시간을 2장로 되돌려서. 로컬 스택을 올렸을 때 사실 첫 반응은 “배치가 하나도 안 돈다”였다. 로그엔:
batch execution requires a compatible application schema (reason=DRIFT)
이 콘솔은 앱을 기동할 때마다 “DB 스키마가 패키지된 카탈로그와 정확히 같은가”를 검사하고, 다르면(DRIFT) 배치를 전부 거부한다. 검사 항목 중엔 “대시보드 위젯 설정 행 전체의 SHA-256이 이 값이어야 한다”는 핀이 있었고, 그게 어긋나 있었다.
누가 어긋나게 했나? 2주 전의 저였다.
내가 심은 지뢰
2주 전에 “CPU/메모리/디스크가 85%를 넘으면 대시보드 카드를 깜빡이게 해달라”는 과제로 위젯 설정 원본(매니페스트)에 displayThreshold 필드를 추가해 커밋했다. 테스트도 통과했고… 라고 생각했는데, 사실 관련 유닛테스트를 안 돌렸다. 돌렸으면 바로 알았을 것이다 — 그 테스트가 정확히 “매니페스트를 바꿨으면 검증기의 해시 핀도 같이 올려라”를 검사하니까.
결과는 두 가지였다.
- 운영에선 깜빡임이 켜진 적이 없었다. 매니페스트는 “원본”이고, DB 복사본은 부트스트랩 잡이 돌 때만 갱신된다. 그 잡은 8월에 한 번 돌고 삭제된 뒤였다. 아무도 눈치 못 챈 건 그동안 85%를 넘은 서버가 없었기 때문.
- 새로 설치하거나 리셋하면 배치가 전면 정지하는 지뢰가 저장소에 심겼다. 운영은 (복사본이 옛것 그대로라) 검사를 통과하고 있었을 뿐.
기능이 안 켜진 것보다 2번이 무섭다. 언젠가 누가 새 서버를 세우는 날 터지는 지뢰니까.
제거 — 선임의 패턴을 그대로
이 콘솔의 마이그레이션 도구는 Flyway와 비슷하되 더 엄격하다. 마이그레이션마다 SQL과 검증기 SQL이 쌍으로 있고, 검증기는 “적용된 상태”를 증명해야 하며, 후속 마이그레이션이 이전 검증기를 “대체(supersede)”할 수 있다. 8월에 선임이 같은 상황(위젯 v3→v4)을 처리한 커밋이 있어서, 그 패턴을 그대로 따랐다.
- 새 마이그레이션(rank 11): DDL 없음. 부트스트랩 잡을 다시 큐에 넣는
INSERT한 문장 — 운영 복사본을 원본으로 갱신하라는 지시서. - 새 검증기: 이전 검증기 복사 + 위젯 해시를 새 값으로. 카탈로그에 “rank 9를 대체한다”고 등록.
- 그리고 동기화해야 할 곳이 여섯 군데: 카탈로그, 신규 설치용 스냅샷 SQL, 배치 스케줄러의 DB측 게이트(
이력이 정확히 N개일 때만 실행), SQL을 Java 상수로 임베딩한 파일(배포기가.java만 배포하니까), 스냅샷 해시 핀, 유닛테스트 4개.
파일 16개. 로직 변경은 0줄. 전부 “설정 추가 + 숫자 맞추기” 인데, 한 군데라도 빠지면 어딘가의 검사가 실패한다.
운영을 내 PC에 복제하기
커밋하면 바로 배포되는 환경이라, 커밋 전에 운영과 똑같은 DB 상태를 로컬에 만들어 순서대로 돌려봤다.
- 옛 스냅샷으로 DB 생성 → 옛 빌드로 Tomcat 기동 → 부트스트랩이 v4 콘텐츠 설치
- 위젯에서
displayThreshold조각을 제거하고 부트스트랩 잡을 삭제 → 운영과 동일한 상태. 옛 카탈로그로status→compatible=true(지금 운영과 같음) - 새 빌드 배포 →
status→ rank 11 =apply, drift 없음 ← 운영이 배포 직후 보게 될 화면 apply→ 즉시compatible=true, 잡 큐잉 → 앱이 알아서 부트스트랩 → 위젯 3개에 필드 반영 → 검증 11개 전부 통과
3번 단계에서 또 하나의 실수가 잡혔다. 처음엔 rank 11이 apply가 아니라 baseline(“이미 적용된 걸로 기록만”)으로 나왔다. 옛 위젯 상태인데 새 검증기가 통과한다? 파일을 다시 보니 — 해시를 설명 주석에서만 바꾸고, 실제로 비교하는 리터럴 한 줄은 옛 값 그대로였다. 유닛테스트는 주석 마커만 검사해서 통과했고요. 리터럴을 고치고, 테스트가 리터럴도 검사하도록 보강했다.
이 시뮬레이션이 없었으면 어떻게 됐을까 커밋 → 운영에서 rank 11이 baseline으로 기록 → 부트스트랩은 큐잉되지 않음 → 깜빡임은 여전히 안 켜지고, 저장소는 “고쳤다”고 믿는 상태. 지뢰가 한 층 더 깊어졌을 것이다.
커밋, 그리고 회색 버튼
커밋(r187)하고 관리자 화면의 “스키마 마이그레이션”에서 Apply 하면 끝… 인데 Apply 버튼이 회색이었다. 확인 문구(APPLY 1 ONLINE MIGRATIONS TO <db> <해시12자리>)를 정확히 붙여넣어도 회색.
이 화면은 최근 5분 이내 로그인이 아니면 Apply를 거부하고, 한 번 거부되면 문구를 맞춰도 버튼이 계속 비활성이다. 로그아웃 → 로그인 → Plan부터 다시 → 파란 버튼 → Apply → “스키마 마이그레이션과 독립 검증을 완료했다”.
마지막으로 테스트 규칙 하나를 만들어 🔴 FIRING이 오는지 봤다. 왔다. 이건 두 가지를 동시에 증명한다 — 배치가 살아있다는 것, 그리고 스케줄러의 XML 게이트(이력 = 11)까지 재시작 없이 배포됐다는 것. 후자는 이 배포 파이프라인이 “XML도 자동 반영하는가”라는 문서의 오래된 미확인 항목이었는데, 이걸로 답이 나왔다.
마치며
이틀 동안 한 일을 한 줄로 줄이면 “설정 한 줄 넣고, 파일 16개짜리 마이그레이션 하나 추가”이다. 하지만 그 사이에 있었던 것들:
- 코드를 먼저 읽어서 만들 필요가 없는 기능을 만들지 않았고
- 로컬 전체 스택으로 켜기 전에 4번 울려봤고
- 그 로컬이 내 지뢰를 밟아줬고
- 운영을 복제한 시뮬레이션이 두 번째 실수를 잡아줬고
- 실수 두 개를 모두 테스트가 다음번엔 잡도록 남겼다
교훈 요약
- “기능을 만들어달라”는 요청 → 이미 있는지 코드부터. 없는 건 대개 설정 한 줄.
- 스테이징이 없으면 내가 스테이징을 만든다. 커밋=배포인 환경에선 더더욱.
- 매니페스트/스키마 같은 “원본”을 바꿀 땐 그걸 고정하는 검증기·테스트·게이트가 어디 있는지 먼저 찾는다. 테스트는 돌린다.
- 시뮬레이션은 “될 것 같은” 걸 확인하는 게 아니라 “안 되는 걸 찾는” 도구다. 두 번 살았다.
- 함수 이름 말고 SQL을 읽는다. 주석 말고 리터럴을 본다.
- 재시작 전에 “그동안 못 한 일이 쌓여 있진 않나”를 묻는다. 재시도 큐는 성실하다.
다음 글은 시간을 거슬러 올라가 두 달 전 이야기 — “급하니까 시크릿을 코드에 박았다가 4일 만에 되돌린” Keycloak realm 사건이다. 이번 글의 3장에서 “전례가 있다”고 한 바로 그 일이고, 이 시스템의 “배포기는 .java만 옮긴다”는 전제를 얻은 사건이기도 하다.