실패 화면 표준이 예상 못 한 예외를 error.js로 잡기로 했다. 화면은 지켜진다. 그런데 터졌다는 사실은 아무 데도 안 간다.

방문자   「문제가 생겼습니다」 를 본다
개발자   아무것도 모른다

모르면 고칠 수도 없다. 방문자가 전화해서 「화면이 하얘졌어요」라고 말해 주지 않는 한, 그 버그는 계속 그 자리에 있다.


먼저: 프론트에는 서버 로그에 해당하는 것이 없다

백엔드는 터지면 흔적이 남는다. 프론트는 안 남는다.

백엔드   /var/arimom/logs 에 굴러가며 쌓인다        →  볼 수 있다
프론트   방문자 브라우저 콘솔에만 남는다             →  탭 닫으면 사라진다

이 비대칭이 이 표준이 푸는 문제다. 그리고 백엔드 쪽도 「남는다」와 「본다」는 다르다 — 파일에 쌓여도 아무도 안 뒤지면 모르는 것은 같다.


원칙 1. 프론트에서 터진 예외는 백엔드로 보낸다

보낼 곳이 셋이었다.

안 A — Sentry 같은 수집 서비스. 스택 트레이스·소스맵(빌드하며 압축·변환된 코드를 원래 작성한 코드의 파일·줄 번호로 되돌려 보여주는 변환표다)·묶어 보기·알림까지 한 번에 준다. 버린 이유는 지금 필요한 것보다 크기 때문이다 — 새 의존성과 비용이 붙고, 어디까지 실어 보낼지 개인정보 검토가 따로 필요하다.

안 B — 이미 깔린 GA4(구글이 만든 웹사이트 방문 통계 도구, Google Analytics 4)로 보낸다. 새로 만들 것이 없다. lib/track.js가 이미 있고 측정 ID가 없으면 조용히 넘어가는 방어까지 되어 있다. 버린 이유는 스택 트레이스를 못 담기 때문이다. GA4는 이벤트 파라미터 길이가 짧아 「어느 줄에서 터졌는지」가 안 들어간다. 그리고 값을 보려면 콘솔에서 맞춤 측정기준을 사람이 한 번 등록해야 한다 — 코드로 끝나지 않는 규칙은 안 지켜진 채로 굳는다.

골랐다 — 백엔드에 보낸다. 안 A가 비싸서 못 하는 것과 안 B가 짧아서 못 하는 것을 둘 다 피한다. 전문을 그대로 받고, 우리가 무엇을 저장할지 고른다.

새로 만들 것이 적다는 것이 결정적이었다. 걸림돌로 꼽았던 둘이 이미 전례가 있다.

공개 엔드포인트   /api/v1/inquiries · /api/v1/reviews 가 이미 permitAll
속도 제한         InquirySearchRateLimiter(99줄) · ReviewViewCountLimiter
보내는 코드       lib/api.js 의 api.post 한 줄

여기서 permitAll은 그 주소를 로그인 없이도 누구나 들어올 수 있게 열어 둔다는 뜻이다(permitAll 참고).

대가는 백엔드가 죽어서 난 에러는 보고가 못 나간다는 것이다. 다만 그때는 백엔드 로그에 이미 흔적이 있다.


원칙 2. 프론트 예외와 백엔드 500은 같은 자리에서 다룬다

여기서 500은 HTTP 상태 코드 중 “서버 쪽 잘못”을 뜻하는 번호다.

둘은 다른 곳에서 터지지만 묻는 것이 같다 — 「예상 못 한 것이 터졌다」.

백엔드에는 그것들이 모이는 자리가 이미 하나 있다.

@ExceptionHandler(Exception.class)   // ApiExceptionHandler

@ExceptionHandler는(@RestControllerAdvice 참고) 특정 예외가 발생했을 때 그 처리를 맡을 메서드를 표시해 두는 애너테이션이다. 여기서는 Exception.class를 지정해 어떤 예외든 다 받는 마지막 그물 역할을 한다.

예외 처리 표준이 「웹 요청의 예외 로그는 이곳 한 곳에서만 남긴다」로 정해 둔 자리다. 프론트에서 올라온 예외도 거기로 합류시킨다.

나누면 알림 형식이 둘이 되고, 「같은 에러인가」를 재는 기준도 둘이 된다. 한 채널에 두 모양이 뜨면 읽는 쪽이 매번 어느 쪽인지부터 가려야 한다.

같은 자리에서 받되 ASSERTION_FAILED는 알림에 표시한다. ASSERTION_FAILED는 어서션이 깨졌을 때, 즉 “절대 이 분기에 들어올 리 없다”고 가정해 둔 자리에 실제로 들어왔을 때 붙는 에러 코드다. 도달 불가 분기에 들어간 것은 우리 코드의 버그라 다른 500과 대응이 다르다 — 서버 장애를 의심할 자리가 아니라 그 분기를 고칠 자리다. 코드가 같으면 채널에서 구별이 안 되고, 구별이 안 되면 묻힌다. 근거는 예외 처리 표준 원칙 5에 있다.


원칙 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줄

DB가 5분 멈추면 그동안 들어온 요청마다 500이 나서, 같은 에러가 300번 쌓여 디스코드에 300줄이 찍힌다는 예시다.

그러면 그 채널을 아무도 안 보게 된다. 이 표준이 고치려던 「아무도 모른다」로 정확히 되돌아간다.

「같은 에러」는 예외 종류와 터진 자리로 잰다. 메시지로 재면 안 된다 — 메시지에는 값이 섞여 들어와(id=123) 같은 버그가 매번 다른 에러로 잡힌다.

창 길이는 표준에 못 박지 않고 설정으로 뺀다. 그리고 그 값이 어떤 값인지 밝혀 둔다.

시작값 10분. 재본 값이 아니다. 켜 보고 고친다.

잴 수가 없어서다 — 에러를 수집하지 않고 있으니 빈도를 모르고, 알려면 먼저 켜야 한다. 닭과 달걀이라 첫 값은 지어낼 수밖에 없다. 에이전트 운영 표준의 「쪼갤지 묻는 계약 개수」가 같은 자리였고 같은 방식으로 적었다.

설정으로 빼는 것이 핵심이다. 표준에 숫자를 박으면 시끄러운 날 고치려고 배포를 해야 한다.


원칙 6. 알림에는 값을 싣지 않고 링크를 준다

디스코드 채널은 통제된 곳이 아니다. 그 채널을 보는 사람 전부가 읽고, 지나간 것도 남는다.

터진 자리에는 값이 붙어 다닌다.

프론트   입력하던 값 · 주소의 ?no=123 · 화면에 뜬 이름
백엔드   요청 본문 · 스택 트레이스 안의 값

여기서 ?no=123은 쿼리 파라미터로, 주소 뒤에 물음표를 붙여 조건값을 실어 보내는 문법이다. 요청 본문은 요청과 함께 보내는 실제 데이터(JSON 등)이고, 스택 트레이스는 에러가 어디서 났는지 호출 순서대로 찍어 보여주는 기록이다.

그래서 알림은 「무엇이 어디서 터졌나」까지만 싣고, 자세한 것은 링크로 넘긴다.

[에러] 후기 상세에서 NullPointerException · 이 창에서 3번째
       https://admin.arimom.com/errors/8412

링크를 누르면 로그인한 사람만 자세한 것을 본다. 값은 접근 통제가 있는 곳에 두고, 통제 없는 곳에는 포인터만 둔다.

값을 아예 안 남기는 안은 버렸다. 그러면 알림은 안전해지지만 재현할 수가 없어 고치지 못한다. 문제는 값이 존재하는 것이 아니라 값이 통제 없는 곳에 있는 것이다.

이 저장소가 이미 같은 규칙을 쓰고 있다 — 로그인 코드를 디스코드로 보내면서 그 코드를 우리 로그에 적지 않는다. 보는 사람이 다른 두 곳에 같은 값을 두지 않는 것이다.


원칙 7. 관리자 화면에 에러 목록과 서버 로그 뷰어를 둔다

원칙 6이 링크를 주기로 했으므로 그 링크가 닿을 화면이 있어야 한다. 둘을 만든다.

에러 목록 — 우리가 모은 것(프론트 예외 + 백엔드 500)을 저장해 보여준다. 저장하면 검색과 집계가 그냥 되고, 「이 창에서 몇 번째인지」도 거기서 나온다. 무엇을 담을지 우리가 고를 수 있다는 것이 파일 로그와 갈리는 자리다 — 파일은 이미 적힌 뒤라 못 고른다.

서버 로그 뷰어 — 로그 파일을 화면에서 본다. 에러 목록이 담지 않은 앞뒤 맥락이 거기 있다.

뷰어는 표면이 커서 조건을 함께 못 박는다.

  • 파일 이름을 받지 않는다. 정해진 디렉토리의 목록에서 고르게 한다. 이름을 받으면 경로 조작(path traversal — ../../etc/passwd처럼 상위 디렉토리로 거슬러 올라가는 이름을 넣어 원래 보면 안 되는 다른 파일을 읽어내는 공격)이 열린다.
  • 전체를 읽지 않는다. 꼬리부터 정해진 줄 수만 읽는다. 굴러가며 쌓이는 파일이라 통째로 읽으면 메모리가 터진다.
  • SUPER_ADMIN만 연다. 로그에는 온갖 값이 섞여 있고 — 그게 로그의 성질이다 — 관리자라고 다 볼 것은 아니다.

뷰어를 안 만드는 안도 검토했다. 에러 목록만 있으면 51번이 푸는 문제는 닫히고, 파일을 웹으로 여는 위험이 사라진다. 버리지는 않았지만 기본으로 삼지 않았다 — 서버에 붙을 수 있는 사람은 이미 파일을 볼 수 있어서다. 다만 붙을 수 없는 자리에서 봐야 하는 상황이 실제로 있고, 그때 없으면 아무것도 못 한다. 위 세 조건으로 표면을 좁혀 만든다.


판단 기준 정리

질문 답 결론
프론트 예외를 어디로 백엔드 Sentry 는 비싸고 GA4 는 스택 트레이스를 못 담는다
백엔드 500 과 따로 다루나 같은 자리 묻는 것이 같다. 나누면 알림 형식이 둘이 된다
저장만 하면 되나 안 된다 아무도 안 열면 「모른다」가 그대로다
어디로 알리나 디스코드 이미 채널 넷이 돈다
알림이 실패하면 하던 일은 성공시킨다 이미 끝난 요청에 대한 보고다
채널을 합치나 전용 채널 잦은 것이 드물고 급한 것을 묻는다
같은 에러가 쏟아지면 창당 한 번 「같은」은 예외 종류 + 터진 자리로 잰다
창 길이 설정값. 시작 10분 재본 값이 아니다. 켜 보고 고친다
알림에 값을 싣나 안 싣는다. 링크를 준다 채널은 통제된 곳이 아니다
서버 로그 뷰어 만든다 파일 이름 금지 · 꼬리만 · SUPER_ADMIN

이 표준을 정하기까지

어제 실패 화면 표준을 쓰면서 내가 연 빈틈이었다. 「예상 못 한 것은 잡아서 화면을 지키되, 그것이 버그라는 사실이 드러나야 한다」고 적어 놓고 어떻게 드러나는지는 안 정했다. 그 자리를 장부에 열어 두고 하루 뒤에 왔다.

안을 세우기 전에 이미 깔린 것을 먼저 셌다. 이 저장소가 반복해서 헛돈 자리라 그렇게 한다. 세 번 다 「이미 있다」가 나왔다 — GA4와 이벤트 함수가 있었고, 공개 엔드포인트와 속도 제한에 전례가 있었고, 디스코드 채널이 이미 넷 돌고 있었다. 그래서 이 표준이 새로 만드는 것은 거의 없고 대부분 있는 것을 어떻게 쓸지를 정한다.

GA4 로 가려다 돌아섰다. 새로 만들 게 0이라 처음엔 그쪽이 맞아 보였는데, 두 가지가 걸렸다 — 파라미터가 짧아 스택 트레이스가 안 들어가고, 값을 보려면 사람이 콘솔에서 맞춤 측정기준을 등록해야 한다. 뒤엣것이 결정적이었다. 코드로 끝나지 않는 규칙은 안 지켜진 채로 굳는다는 것을 이 저장소가 여러 번 겪었다.

「저장만으로는 모자라다」를 붙잡은 것이 방향을 바꿨다. 백엔드로 보내 로그에 남기면 끝인 줄 알았는데, 아무도 로그를 안 뒤지면 「모른다」가 그대로였다. 그래서 알림이 규칙에 들어왔고, 알림이 들어오자 폭주와 개인정보가 따라 들어왔다.

개인정보는 「무엇을 싣나」가 아니라 「어디에 두나」의 문제였다. 처음엔 알림에서 값을 빼는 쪽으로 갔는데, 그러면 재현을 못 해서 고칠 수가 없다. 값을 없애는 게 아니라 통제된 곳으로 옮기고 알림에는 링크만 주는 것이 답이었다. 그 순간 관리자 화면이 규칙의 일부가 됐다.

서버 로그 뷰어는 내가 반대했다가 접었다. 파일을 웹으로 여는 표면이 크고 51번이 푸는 문제는 에러 목록만으로 닫힌다고 봤다. 다만 서버에 붙을 수 없는 자리에서 봐야 하는 상황이 실제로 있고, 그때 없으면 아무것도 못 한다. 반대를 접는 대신 조건 셋을 붙였다 — 파일 이름을 안 받고, 꼬리만 읽고, SUPER_ADMIN만 연다.


정리

  • 프론트에서 터진 예외는 백엔드로 보낸다. Sentry 는 비싸고 GA4 는 스택 트레이스를 못 담는다.
  • 백엔드 500 과 같은 자리에서 다룬다. 묻는 것이 같고, 나누면 알림 형식이 둘이 된다.
  • 저장만 하면 「아무도 모른다」가 그대로다. 디스코드로 즉시 알린다.
  • 에러 알림은 전용 채널을 판다. 잦은 것이 드물고 급한 것을 묻는다.
  • 같은 에러는 창 하나에 한 번. 「같은」은 예외 종류 + 터진 자리로 재고, 창 길이는 설정으로 뺀다 — 재본 값이 아니다.
  • 알림에는 값을 싣지 않고 링크를 준다. 채널은 통제된 곳이 아니다.
  • 관리자 화면에 에러 목록과 서버 로그 뷰어를 둔다. 뷰어는 파일 이름을 안 받고, 꼬리만 읽고, SUPER_ADMIN만 연다.

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

댓글남기기