실패 화면 표준이 예상 못 한 예외를 error.js로 잡기로 했다. 화면은 지켜진다. 그런데 터졌다는 사실은 아무 데도 안 간다.
방문자 「문제가 생겼습니다」 를 본다
개발자 아무것도 모른다
모르면 고칠 수도 없다. 방문자가 전화해서 「화면이 하얘졌어요」라고 말해 주지 않는 한, 그 버그는 계속 그 자리에 있다.
먼저: 프론트에는 서버 로그에 해당하는 것이 없다
백엔드는 터지면 흔적이 남는다. 프론트는 안 남는다.
백엔드 /var/arimom/logs 에 굴러가며 쌓인다 → 볼 수 있다
프론트 방문자 브라우저 콘솔에만 남는다 → 탭 닫으면 사라진다
이 비대칭이 이 표준이 푸는 문제다. 그리고 백엔드 쪽도 「남는다」와 「본다」는 다르다 — 파일에 쌓여도 아무도 안 뒤지면 모르는 것은 같다.
원칙 1. 프론트에서 터진 예외는 백엔드로 보낸다
보낼 곳이 셋이었다.
안 A — Sentry 같은 수집 서비스. 스택 트레이스·소스맵·묶어 보기·알림까지 한 번에 준다. 버린 이유는 지금 필요한 것보다 크기 때문이다 — 새 의존성과 비용이 붙고, 어디까지 실어 보낼지 개인정보 검토가 따로 필요하다.
안 B — 이미 깔린 GA4로 보낸다. 새로 만들 것이 없다. lib/track.js가 이미 있고 측정 ID가 없으면 조용히 넘어가는 방어까지 되어 있다. 버린 이유는 스택 트레이스를 못 담기 때문이다. GA4는 이벤트 파라미터 길이가 짧아 「어느 줄에서 터졌는지」가 안 들어간다. 그리고 값을 보려면 콘솔에서 맞춤 측정기준을 사람이 한 번 등록해야 한다 — 코드로 끝나지 않는 규칙은 안 지켜진 채로 굳는다.
골랐다 — 백엔드에 보낸다. 안 A가 비싸서 못 하는 것과 안 B가 짧아서 못 하는 것을 둘 다 피한다. 전문을 그대로 받고, 우리가 무엇을 저장할지 고른다.
새로 만들 것이 적다는 것이 결정적이었다. 걸림돌로 꼽았던 둘이 이미 전례가 있다.
공개 엔드포인트 /api/v1/inquiries · /api/v1/reviews 가 이미 permitAll
속도 제한 InquirySearchRateLimiter(99줄) · ReviewViewCountLimiter
보내는 코드 lib/api.js 의 api.post 한 줄
대가는 백엔드가 죽어서 난 에러는 보고가 못 나간다는 것이다. 다만 그때는 백엔드 로그에 이미 흔적이 있다.
원칙 2. 프론트 예외와 백엔드 500은 같은 자리에서 다룬다
둘은 다른 곳에서 터지지만 묻는 것이 같다 — 「예상 못 한 것이 터졌다」.
백엔드에는 그것들이 모이는 자리가 이미 하나 있다.
@ExceptionHandler(Exception.class) // ApiExceptionHandler
예외 처리 표준이 「웹 요청의 예외 로그는 이곳 한 곳에서만 남긴다」로 정해 둔 자리다. 프론트에서 올라온 예외도 거기로 합류시킨다.
나누면 알림 형식이 둘이 되고, 「같은 에러인가」를 재는 기준도 둘이 된다. 한 채널에 두 모양이 뜨면 읽는 쪽이 매번 어느 쪽인지부터 가려야 한다.
원칙 3. 터지면 디스코드로 즉시 알린다
저장만 하면 「아무도 모른다」가 그대로 남는다. 로그 파일에 쌓아 둔 것과 다를 것이 없다 — 누군가 열어 봐야 보인다.
디스코드는 이미 이 저장소가 쓰는 통로다. 채널이 넷 돌고 있다.
배포 GitHub Actions
생존 app.health.alert.discord-webhook-url
상담 app.consultation.alert.discord-webhook-url
로그인 두 번째 인증 코드
그래서 규칙 둘을 그대로 물려받는다.
- 알림이 못 나가도 하던 일은 성공시킨다. 상담·생존이 그렇게 하고 있다. 로그인 코드만 예외인데, 그건 못 받으면 로그인이 성립하지 않아서다. 에러 보고는 이미 끝난 요청에 대한 것이라 실패시킬 이유가 없다.
- 웹훅 주소를 로그·예외 메시지에 남기지 않는다. 디스코드 웹훅은 토큰이 경로에 있어(
/api/webhooks/<id>/<token>) 그대로 샌다.
원칙 4. 에러 알림은 전용 채널을 판다
기존 규칙은 「보는 사람이 다르면 채널을 나눈다」였다. 에러를 볼 사람은 생존 알림을 보는 사람과 같으므로, 그 규칙만으로는 합쳐도 된다.
합치면 이렇게 된다.
[에러] 후기 화면에서 터짐
[에러] 문의 화면에서 터짐
[생존] 서버가 죽었다 ← 여기 묻힌다
[에러] 지도 화면에서 터짐
서버가 죽은 것은 당장 뛰어가야 하는 일인데 자잘한 에러 사이에 끼면 못 본다.
그래서 가르는 축을 하나 더한다 — 빈도와 급한 정도. 보는 사람이 같아도 드물고 급한 것과 잦고 안 급한 것은 채널을 나눈다. 기존 규칙을 뒤집는 것이 아니라 옆에 붙이는 것이다.
원칙 5. 같은 에러는 창 하나에 한 번만 알린다
DB가 5분 끊겼다 → 500이 300번 난다 → 디스코드에 300줄
그러면 그 채널을 아무도 안 보게 된다. 이 표준이 고치려던 「아무도 모른다」로 정확히 되돌아간다.
「같은 에러」는 예외 종류와 터진 자리로 잰다. 메시지로 재면 안 된다 — 메시지에는 값이 섞여 들어와(id=123) 같은 버그가 매번 다른 에러로 잡힌다.
창 길이는 표준에 못 박지 않고 설정으로 뺀다. 그리고 그 값이 어떤 값인지 밝혀 둔다.
시작값 10분. 재본 값이 아니다. 켜 보고 고친다.
잴 수가 없어서다 — 에러를 수집하지 않고 있으니 빈도를 모르고, 알려면 먼저 켜야 한다. 닭과 달걀이라 첫 값은 지어낼 수밖에 없다. 에이전트 운영 표준의 「쪼갤지 묻는 계약 개수」가 같은 자리였고 같은 방식으로 적었다.
설정으로 빼는 것이 핵심이다. 표준에 숫자를 박으면 시끄러운 날 고치려고 배포를 해야 한다.
원칙 6. 알림에는 값을 싣지 않고 링크를 준다
디스코드 채널은 통제된 곳이 아니다. 그 채널을 보는 사람 전부가 읽고, 지나간 것도 남는다.
터진 자리에는 값이 붙어 다닌다.
프론트 입력하던 값 · 주소의 ?no=123 · 화면에 뜬 이름
백엔드 요청 본문 · 스택 트레이스 안의 값
그래서 알림은 「무엇이 어디서 터졌나」까지만 싣고, 자세한 것은 링크로 넘긴다.
[에러] 후기 상세에서 NullPointerException · 이 창에서 3번째
https://admin.arimom.com/errors/8412
링크를 누르면 로그인한 사람만 자세한 것을 본다. 값은 접근 통제가 있는 곳에 두고, 통제 없는 곳에는 포인터만 둔다.
값을 아예 안 남기는 안은 버렸다. 그러면 알림은 안전해지지만 재현할 수가 없어 고치지 못한다. 문제는 값이 존재하는 것이 아니라 값이 통제 없는 곳에 있는 것이다.
이 저장소가 이미 같은 규칙을 쓰고 있다 — 로그인 코드를 디스코드로 보내면서 그 코드를 우리 로그에 적지 않는다. 보는 사람이 다른 두 곳에 같은 값을 두지 않는 것이다.
원칙 7. 관리자 화면에 에러 목록과 서버 로그 뷰어를 둔다
원칙 6이 링크를 주기로 했으므로 그 링크가 닿을 화면이 있어야 한다. 둘을 만든다.
에러 목록 — 우리가 모은 것(프론트 예외 + 백엔드 500)을 저장해 보여준다. 저장하면 검색과 집계가 그냥 되고, 「이 창에서 몇 번째인지」도 거기서 나온다. 무엇을 담을지 우리가 고를 수 있다는 것이 파일 로그와 갈리는 자리다 — 파일은 이미 적힌 뒤라 못 고른다.
서버 로그 뷰어 — 로그 파일을 화면에서 본다. 에러 목록이 담지 않은 앞뒤 맥락이 거기 있다.
뷰어는 표면이 커서 조건을 함께 못 박는다.
- 파일 이름을 받지 않는다. 정해진 디렉토리의 목록에서 고르게 한다. 이름을 받으면 경로 조작이 열린다.
- 전체를 읽지 않는다. 꼬리부터 정해진 줄 수만 읽는다. 굴러가며 쌓이는 파일이라 통째로 읽으면 메모리가 터진다.
SUPER_ADMIN만 연다. 로그에는 온갖 값이 섞여 있고 — 그게 로그의 성질이다 — 관리자라고 다 볼 것은 아니다.
뷰어를 안 만드는 안도 검토했다. 에러 목록만 있으면 51번이 푸는 문제는 닫히고, 파일을 웹으로 여는 위험이 사라진다. 버리지는 않았지만 기본으로 삼지 않았다 — 서버에 붙을 수 있는 사람은 이미 파일을 볼 수 있어서다. 다만 붙을 수 없는 자리에서 봐야 하는 상황이 실제로 있고, 그때 없으면 아무것도 못 한다. 위 세 조건으로 표면을 좁혀 만든다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 프론트 예외를 어디로 | 백엔드 | Sentry 는 비싸고 GA4 는 스택 트레이스를 못 담는다 |
| 백엔드 500 과 따로 다루나 | 같은 자리 | 묻는 것이 같다. 나누면 알림 형식이 둘이 된다 |
| 저장만 하면 되나 | 안 된다 | 아무도 안 열면 「모른다」가 그대로다 |
| 어디로 알리나 | 디스코드 | 이미 채널 넷이 돈다 |
| 알림이 실패하면 | 하던 일은 성공시킨다 | 이미 끝난 요청에 대한 보고다 |
| 채널을 합치나 | 전용 채널 | 잦은 것이 드물고 급한 것을 묻는다 |
| 같은 에러가 쏟아지면 | 창당 한 번 | 「같은」은 예외 종류 + 터진 자리로 잰다 |
| 창 길이 | 설정값. 시작 10분 | 재본 값이 아니다. 켜 보고 고친다 |
| 알림에 값을 싣나 | 안 싣는다. 링크를 준다 | 채널은 통제된 곳이 아니다 |
| 서버 로그 뷰어 | 만든다 | 파일 이름 금지 · 꼬리만 · SUPER_ADMIN |
이 표준을 정하기까지
어제 실패 화면 표준을 쓰면서 내가 연 빈틈이었다. 「예상 못 한 것은 잡아서 화면을 지키되, 그것이 버그라는 사실이 드러나야 한다」고 적어 놓고 어떻게 드러나는지는 안 정했다. 그 자리를 장부에 열어 두고 하루 뒤에 왔다.
안을 세우기 전에 이미 깔린 것을 먼저 셌다. 이 저장소가 반복해서 헛돈 자리라 그렇게 한다. 세 번 다 「이미 있다」가 나왔다 — GA4와 이벤트 함수가 있었고, 공개 엔드포인트와 속도 제한에 전례가 있었고, 디스코드 채널이 이미 넷 돌고 있었다. 그래서 이 표준이 새로 만드는 것은 거의 없고 대부분 있는 것을 어떻게 쓸지를 정한다.
GA4 로 가려다 돌아섰다. 새로 만들 게 0이라 처음엔 그쪽이 맞아 보였는데, 두 가지가 걸렸다 — 파라미터가 짧아 스택 트레이스가 안 들어가고, 값을 보려면 사람이 콘솔에서 맞춤 측정기준을 등록해야 한다. 뒤엣것이 결정적이었다. 코드로 끝나지 않는 규칙은 안 지켜진 채로 굳는다는 것을 이 저장소가 여러 번 겪었다.
「저장만으로는 모자라다」를 붙잡은 것이 방향을 바꿨다. 백엔드로 보내 로그에 남기면 끝인 줄 알았는데, 아무도 로그를 안 뒤지면 「모른다」가 그대로였다. 그래서 알림이 규칙에 들어왔고, 알림이 들어오자 폭주와 개인정보가 따라 들어왔다.
개인정보는 「무엇을 싣나」가 아니라 「어디에 두나」의 문제였다. 처음엔 알림에서 값을 빼는 쪽으로 갔는데, 그러면 재현을 못 해서 고칠 수가 없다. 값을 없애는 게 아니라 통제된 곳으로 옮기고 알림에는 링크만 주는 것이 답이었다. 그 순간 관리자 화면이 규칙의 일부가 됐다.
서버 로그 뷰어는 내가 반대했다가 접었다. 파일을 웹으로 여는 표면이 크고 51번이 푸는 문제는 에러 목록만으로 닫힌다고 봤다. 다만 서버에 붙을 수 없는 자리에서 봐야 하는 상황이 실제로 있고, 그때 없으면 아무것도 못 한다. 반대를 접는 대신 조건 셋을 붙였다 — 파일 이름을 안 받고, 꼬리만 읽고, SUPER_ADMIN만 연다.
정리
- 프론트에서 터진 예외는 백엔드로 보낸다. Sentry 는 비싸고 GA4 는 스택 트레이스를 못 담는다.
- 백엔드 500 과 같은 자리에서 다룬다. 묻는 것이 같고, 나누면 알림 형식이 둘이 된다.
- 저장만 하면 「아무도 모른다」가 그대로다. 디스코드로 즉시 알린다.
- 에러 알림은 전용 채널을 판다. 잦은 것이 드물고 급한 것을 묻는다.
- 같은 에러는 창 하나에 한 번. 「같은」은 예외 종류 + 터진 자리로 재고, 창 길이는 설정으로 뺀다 — 재본 값이 아니다.
- 알림에는 값을 싣지 않고 링크를 준다. 채널은 통제된 곳이 아니다.
- 관리자 화면에 에러 목록과 서버 로그 뷰어를 둔다. 뷰어는 파일 이름을 안 받고, 꼬리만 읽고,
SUPER_ADMIN만 연다.
AI 코드 어시스턴트에 바로 적용하기
Claude Code · Codex — .claude/skills/failure-alert-standard/SKILL.md · .agents/skills/failure-alert-standard/SKILL.md
두 파일은 바이트까지 같아야 한다. .agents 쪽은 tools/agents-skills.rb 가 뽑는 생성물이다.
---
name: failure-alert-standard
description: 터진 것을 사람이 어떻게 아는지 정한다. 프론트 예외를 백엔드로 보내는 이유, 백엔드 500 과 합치는 자리, 디스코드 알림과 전용 채널, 같은 에러를 묶는 창, 알림에 값을 안 싣고 링크를 주는 이유, 관리자 화면의 에러 목록과 서버 로그 뷰어를 담는다. 에러 보고를 붙일 때, 알림을 추가할 때, 로그 화면을 만들 때 적용한다.
---
# 실패 알림 표준
**화면을 지키는 것과 사람이 아는 것은 다르다.** 실패 화면 표준이 `error.js` 로 화면을 지켰지만, 터졌다는 사실은 아무 데도 안 간다.
```
백엔드 파일에 쌓인다 → 볼 수 있다 (다만 안 뒤지면 모른다)
프론트 브라우저 콘솔뿐 → 탭 닫으면 사라진다
```
## 프론트 예외는 백엔드로 보낸다
- **Sentry 같은 수집 서비스를 붙이지 않는다.** 지금 필요한 것보다 크다 — 새 의존성·비용·개인정보 검토가 붙는다.
- **GA4 로 보내지 않는다.** 이벤트 파라미터가 짧아 **스택 트레이스를 못 담고**, 값을 보려면 사람이 콘솔에서 맞춤 측정기준을 등록해야 한다. **코드로 끝나지 않는 규칙은 안 지켜진 채로 굳는다.**
- 공개 엔드포인트와 속도 제한은 **전례를 본뜬다** — 이미 `permitAll` 인 경로가 있고 `InquirySearchRateLimiter` 가 있다.
- **백엔드가 죽어서 난 에러는 보고가 못 나간다.** 그때는 백엔드 로그에 이미 흔적이 있다.
## 백엔드 500 과 같은 자리에서 다룬다
- **묻는 것이 같다** — 「예상 못 한 것이 터졌다」.
- 백엔드에는 그 자리가 이미 하나다 — `@ExceptionHandler(Exception.class)`. **프론트에서 올라온 예외도 거기로 합류시킨다.**
- 나누면 알림 형식이 둘이 되고 「같은 에러인가」를 재는 기준도 둘이 된다. **한 채널에 두 모양이 뜨면 읽는 쪽이 매번 어느 쪽인지부터 가려야 한다.**
## 터지면 즉시 알린다
- **저장만 하면 「아무도 모른다」가 그대로다.** 로그 파일에 쌓아 둔 것과 다를 것이 없다.
- **알림이 못 나가도 하던 일은 성공시킨다.** 에러 보고는 이미 끝난 요청에 대한 것이라 실패시킬 이유가 없다.
- **웹훅 주소를 로그·예외 메시지에 남기지 않는다.** 디스코드 웹훅은 토큰이 경로에 있어 그대로 샌다.
## 에러 알림은 전용 채널을 판다
- 보는 사람이 같아도 **드물고 급한 것**과 **잦고 안 급한 것**은 채널을 나눈다. 생존 알림(서버가 죽었다)이 에러 스무 줄 사이에 끼면 못 본다.
- 기존 규칙(**보는 사람이 다르면 나눈다**)을 뒤집는 것이 아니라 **가르는 축을 하나 더하는 것**이다.
## 같은 에러는 창 하나에 한 번만 알린다
- **「같은 에러」는 예외 종류와 터진 자리로 잰다.** **메시지로 재지 않는다** — 메시지에는 값이 섞여 들어와(`id=123`) 같은 버그가 매번 다른 에러로 잡힌다.
- **창 길이는 코드에 박지 않고 설정으로 뺀다.** 박으면 시끄러운 날 고치려고 배포를 해야 한다.
- 시작값 10분. **재본 값이 아니다 — 에러를 수집하지 않고 있어 빈도를 모른다. 켜 보고 고친다.**
## 알림에는 값을 싣지 않고 링크를 준다
```
[에러] 후기 상세에서 NullPointerException · 이 창에서 3번째
https://admin.example.com/errors/8412
```
- **디스코드 채널은 통제된 곳이 아니다.** 보는 사람 전부가 읽고 지나간 것도 남는다.
- **싣는다** — 예외 종류 · 터진 자리 · 시각 · 이 창에서 몇 번째인지.
- **안 싣는다** — 입력값 · 주소의 쿼리스트링 · 요청 본문 · 스택 트레이스 전문.
- **값을 아예 안 남기지는 않는다.** 그러면 재현을 못 해 고치지 못한다. 문제는 값이 있는 것이 아니라 **값이 통제 없는 곳에 있는 것**이다.
## 관리자 화면에 둘을 둔다
**에러 목록** — 모은 것(프론트 예외 + 백엔드 500)을 저장해 보여준다. 검색·집계와 「몇 번째인지」가 거기서 나온다. **무엇을 담을지 우리가 고를 수 있다는 것**이 파일 로그와 갈리는 자리다.
**서버 로그 뷰어** — 파일을 화면에서 본다. 표면이 커서 조건이 붙는다.
- **파일 이름을 받지 않는다.** 정해진 디렉토리의 목록에서 고르게 한다 — 이름을 받으면 경로 조작이 열린다.
- **전체를 읽지 않는다.** 꼬리부터 정해진 줄 수만 읽는다.
- **`SUPER_ADMIN` 만 연다.** 로그에는 온갖 값이 섞여 있다 — 그게 로그의 성질이다.
## 하지 않는 것
- **프론트 예외를 잡고 아무 데도 안 보내지 않는다.**
- **GA4 나 외부 수집 서비스로 보내지 않는다.**
- **저장만 하고 알림을 안 보내지 않는다.**
- **에러 알림을 생존·상담 채널에 섞지 않는다.**
- **같은 에러를 날 때마다 보내지 않는다.** 채널이 죽으면 아무도 안 본다.
- **에러 메시지로 「같은 에러」를 재지 않는다.**
- **창 길이를 코드에 박지 않는다.**
- **알림에 입력값·쿼리스트링·요청 본문을 싣지 않는다.**
- **로그 뷰어에 파일 이름을 받지 않고, 전체를 읽지 않는다.**
GitHub Copilot — .github/instructions/failure-alert-standard.instructions.md
---
description: 터진 것을 사람이 어떻게 아는가
applyTo: "**"
---
# 실패 알림
- **화면을 지키는 것과 사람이 아는 것은 다르다.** 프론트는 브라우저 콘솔뿐이라 탭을 닫으면 사라지고, 백엔드는 파일에 쌓여도 안 뒤지면 모른다.
- **프론트에서 터진 예외는 백엔드로 보낸다.** **Sentry 를 붙이지 않는다**(지금 필요한 것보다 크다). **GA4 로 보내지 않는다** — 이벤트 파라미터가 짧아 **스택 트레이스를 못 담고**, 값을 보려면 사람이 콘솔에서 맞춤 측정기준을 등록해야 한다. **코드로 끝나지 않는 규칙은 안 지켜진 채로 굳는다.** 공개 엔드포인트와 속도 제한은 기존 전례를 본뜬다.
- **백엔드 500 과 같은 자리에서 다룬다** — `@ExceptionHandler(Exception.class)` 로 합류시킨다. 묻는 것이 같고, 나누면 알림 형식과 「같은 에러인가」의 기준이 둘이 된다.
- **저장만 하지 않는다. 터지면 즉시 디스코드로 알린다.** 저장만 하면 「아무도 모른다」가 그대로다. **알림이 못 나가도 하던 일은 성공시킨다** — 이미 끝난 요청에 대한 보고다. **웹훅 주소를 로그·예외 메시지에 남기지 않는다**(토큰이 경로에 있다).
- **에러 알림은 전용 채널을 판다.** 보는 사람이 같아도 **드물고 급한 것**(서버가 죽었다)과 **잦고 안 급한 것**은 나눈다 — 잦은 쪽이 드문 쪽을 묻는다.
- **같은 에러는 창 하나에 한 번만 알린다.** 「같은」은 **예외 종류 + 터진 자리**로 잰다 — **메시지로 재지 않는다**(값이 섞여 같은 버그가 매번 달라진다). **창 길이는 코드에 박지 않고 설정으로 뺀다.** 시작값 10분은 **재본 값이 아니다** — 켜 보고 고친다.
- **알림에는 값을 싣지 않고 링크를 준다.** 싣는 것은 예외 종류·터진 자리·시각·이 창에서 몇 번째인지까지다. **입력값·쿼리스트링·요청 본문·스택 트레이스 전문은 안 싣는다.** 디스코드 채널은 통제된 곳이 아니다. **값을 아예 안 남기지는 않는다** — 그러면 재현을 못 한다. 문제는 값이 있는 것이 아니라 **통제 없는 곳에 있는 것**이다.
- **관리자 화면에 에러 목록과 서버 로그 뷰어를 둔다.** 뷰어는 **파일 이름을 받지 않고**(경로 조작), **전체를 읽지 않고**(꼬리부터 정해진 줄 수만), **`SUPER_ADMIN` 만 연다**(로그에는 온갖 값이 섞여 있다).
자신만의 철학을 만들어가는 중입니다.
댓글남기기