404 silent failure 추적 — "발행 됐는데 안 됐다" 사고 잡은 사연

3 min read · 653 words

활용 팁 / 블로그 운영 / 디버깅 스토리
약 1,700자

webapp 의 발행 버튼을 누르면 토스트는 "발행 완료" 라고 뜨는데 라이브에 글이 안 올라가는 경우가 있었습니다. Blogger Discord 알림 0건. 로그도 깨끗. 사장님은 어디서 무엇이 실패했는지 모른 채 같은 발행을 두 번 세 번 시도하게 됐습니다. 우리는 이걸 글로벌 exception handler 와 system_log 모듈로 잡았습니다. 추적과 해결 패턴을 그대로 풀어 둡니다.

사고 발생

서브 페이지 발행 후 라이브 fetch — 글 없음. 같은 발행을 다시 시도 — UI 토스트 "발행 완료". 라이브 다시 fetch — 여전히 없음.

webapp 로그 tail → 마지막 라인이 "INFO writer.js POST /api/blogger/publish-post" 였습니다. 그 다음에 아무 라인 없음. exception 도 없음. trace 도 없음. 발행 endpoint 가 "조용히" 실패한 케이스.

추적 1단계 — endpoint 매칭

브라우저 DevTools 의 Network 탭에서 직접 확인. 요청 method = GET, status = 404. method 가 GET 인데 endpoint 는 POST 전용. UI 가 잘못된 method 로 호출.

writer.js 의 코드 라인:


fetch('/api/blogger/publish-post', {
 // method 안 박힘 → 기본 GET
 body: JSON.stringify(payload)
});

method: 'POST' 박지 X → fetch 가 기본 GET 으로 보냄 → FastAPI 가 405 method-not-allowed 반환 → 그게 어디서 404 로 매핑됐는지 분석해보니 starlette 의 default 응답.

근본 원인 = JavaScript 의 작은 오타 한 글자. 그러나 webapp 어디에도 이걸 알려주는 곳이 없었습니다.

해결 — 글로벌 exception handler

작은 fix (writer.js 의 method: 'POST' 한 줄 추가) 외에 우리는 webapp 전체에 안전망을 박았습니다.


from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException

@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
 """404 / 405 / 모든 HTTP exception 자동 캐치 + 로그."""
 from webapp.seo.system_log import record_error
 record_error(
 component=f"endpoint:{request.url.path}",
 message=f"HTTP {exc.status_code}: {exc.detail}",
 severity="warning",
 context={"method": request.method, "status": exc.status_code},
 )
 return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})

@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
 """Uncaught exception 전부 캐치 + trace 저장."""
 import traceback
 from webapp.seo.system_log import record_error
 record_error(
 component=f"endpoint:{request.url.path}",
 message=f"{type(exc).__name__}: {exc}",
 severity="error",
 context={"method": request.method, "trace": traceback.format_exc()[:1000]},
 )
 return JSONResponse(
 status_code=500,
 content={"ok": False, "error": "Internal server error", "detail": str(exc)[:200]},
 )

이 두 핸들러를 app.py 에 박는 순간 — webapp 의 모든 endpoint 의 모든 exception 이 자동으로 system_log 에 기록됩니다. trace 첫 1000자 포함. Discord 알림으로도 자동 발사 (threshold 도달 시).

추가 — error burst 자동 알림

같은 endpoint 가 짧은 시간에 여러 번 실패하면 Discord 로 알림.


# webapp/seo/system_log.py 안
def record_error(component: str, message: str, **kwargs):
 # 로컬 JSON 에 누적
 log_to_state(component, message, **kwargs)
 # 임계값 (5분 안 같은 component 3건+) 도달 시 burst 알림
 if recent_count(component, minutes=5) >= 3:
 notify_discord_burst(component, recent_messages(component))

발행 1건 fail = 일반 로그. 같은 endpoint 가 5분에 3번 fail = Discord 알림. 사장님이 사고를 발견하기 전에 시스템이 먼저 알려줌.

효과

  • silent failure 도입 전 평균 월 4건 (사장님이 라이브 보고 발견)
  • 도입 후 0건 (사고는 여전히 발생 — 단 시스템이 5분 안에 알림)
  • 평균 사고 발견 시간: 6시간 → 5분
  • 디버깅 시간: trace + endpoint + method 모두 로그에 박혀있어서 30초 안에 원인 식별

따라하실 분께

FastAPI / Flask / Express 어느 백엔드든 같은 패턴:

  1. 전역 exception handler 한 개 박아서 모든 4xx / 5xx 응답을 로그에 박음
  2. 같은 component 가 짧은 시간에 반복 fail = Discord/Slack 알림
  3. 로그 파일 1개 (예: system_log_state.json) 에 누적 → /api/system/recent-errors endpoint 로 사장님 dashboard 에서 즉시 조회

이 패턴이 도입되기 전엔 사장님이 "왜 안 되지" 라며 같은 발행을 3번씩 시도하는 일이 있었습니다. 도입 후엔 사고가 발생해도 5분 안에 원인이 보입니다.

요약: 발행 fail 의 가장 무서운 케이스는 silent failure 입니다. 백엔드에 global exception handler 한 함수 박아두면 silent failure 자체가 사라집니다. 코드 30 줄이면 충분합니다.

Category Coverage Notice

This article follows our label-specific editorial criteria. Details:

ToolSignal Pro Editorial

ToolSignal Pro는 AI·IT·소프트웨어 트렌드를 다루는 종합 IT 인사이트 매거진입니다.

이전 글 다음 글