실패 화면 표준이 예상 못 한 예외를 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` 만 연다**(로그에는 온갖 값이 섞여 있다).

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기