“PK로 게시물을 조회했는데 데이터가 없어요. 404 줘야겠죠?” 라고 묻는 주니어 개발자에게 “네, 당연하죠”라고 답했던 과거의 나에게 사과부터 하고 시작하겠습니다. 표준만 보면 그게 정답인데, 운영 서버에 모니터링 툴 하나 붙여 보면 알게 됩니다. 그 “당연한 404”가 매일 새벽 3시에 알림으로 울려대고, Sentry 이슈 목록 상위권을 평정하고, 핀포인트(Pinpoint) 대시보드를 빨갛게 물들이는 주범이 된다는 사실을요.
이 글은 “GET /posts/{id}로 조회했는데 없는 게시물이면 어떻게 응답해야 하나?”라는 단순해 보이는 질문을 운영 관점까지 끌고 가서 끝장을 보는 글입니다. HTTP 표준이 뭐라고 말하는지, 그리고 그 표준을 따랐을 때 왜 모니터링 시스템이 비명을 지르는지, 마지막으로 실무에서 어떻게 균형을 잡는지까지 다룹니다.
결론부터: 404가 맞다, 그런데 404를 “에러”로 취급하지 마라
스포일러를 먼저 던지자면 이렇습니다.
- HTTP 표준상 PK 기반 단건 조회에서 리소스가 없으면 응답 코드는 404가 맞습니다.
- 다만 “404 응답”과 “시스템 에러”는 다른 개념입니다. 서버는 정상 동작했고, 그저 클라이언트가 존재하지 않는 자원을 요청했을 뿐입니다.
- 따라서 핀포인트, Sentry, Datadog 같은 모니터링 툴에서는 4xx를 “에러 알림 대상”에서 분리해야 합니다.
이제 왜 이런 결론이 나오는지 차근차근 풀어보겠습니다.
HTTP 표준이 말하는 404의 정의
RFC 9110(이전엔 RFC 7231에서 정의됐던 그 친구)을 보면 404 Not Found에 대해 이렇게 정의합니다. 원문은 “The origin server did not find a current representation for the target resource or is not willing to disclose that one exists”라고 되어 있는데, 우리말로 풀면 “요청한 리소스의 현재 표현을 못 찾았거나, 존재 여부를 알려주고 싶지 않을 때” 쓰는 코드입니다.
여기서 핵심은 단어 두 개입니다. “target resource”와 “current representation.” PK로 단건을 요청한다는 건 명백하게 특정 리소스를 가리키는 행위입니다. /posts/12345는 “12345번 게시물이라는 리소스”를 가리키는 URI이고, 그 리소스가 없으면 404가 정확한 답입니다.
여기서 흔히 혼동하는 게 “빈 리스트”와 “단건 조회 실패”의 차이입니다.
| 요청 종류 | 예시 데이터 | 없을 때 응답 |
| 컬렉션 조회 (필터 결과 없음) | GET /posts?author=spock | 200 OK + 빈 배열 [] |
| 단건 조회 (PK로 지정) | GET /posts/12345 | 404 Not Found |
| 컬렉션 자체가 없음 | GET /not-a-collection | 404 Not Found |
컬렉션은 “비어 있을 수 있는 상자”이고, 단건은 “있거나 없거나 둘 중 하나인 물건”입니다. 상자는 비어 있어도 상자가 존재하지만, 12345번 게시물은 존재하지 않으면 그냥 존재하지 않습니다. 그래서 컬렉션이 비면 200, 단건이 없으면 404입니다.
그럼 200으로 통일하면 안 될까?
가끔 “그냥 200 주고 body에 { "exists": false } 같은 걸 넣으면 안 되나요?”라는 제안이 나옵니다. 솔직히 동작은 합니다. 하지만 다음과 같은 문제가 생깁니다.
1. 캐시 정책이 꼬인다
HTTP 캐시(브라우저, CDN, 리버스 프록시 등)는 상태 코드 기반으로 동작합니다. 404는 RFC상 캐시 가능한 응답이라 CDN이 “이건 없는 거구나” 하고 짧게 캐싱해줄 수 있습니다. 그런데 “200 + body에 없다고 표시” 방식은 CDN 입장에서는 “정상 응답”이라 데이터가 생긴 뒤에도 “없음” 응답을 계속 내려주는 사고가 납니다.
2. 클라이언트 코드가 지옥이 된다
// 표준 방식
if (response.status === 404) {
showNotFoundPage();
}
// 200 통일 방식
if (response.status === 200) {
const data = await response.json();
if (data === null || data.exists === false || Object.keys(data).length === 0) {
showNotFoundPage();
}
}
코드만 봐도 어느 쪽이 미래의 나를 덜 괴롭힐지 답이 나옵니다.
3. API 명세 도구와 안 맞는다
OpenAPI(Swagger), GraphQL의 REST 변환 도구, 자동 생성된 SDK들은 전부 HTTP 상태 코드 기반으로 동작합니다. 404를 200으로 바꾸는 순간 이 생태계 전체와 어긋나기 시작합니다.
진짜 문제: 404가 “에러 알림”으로 들어오는 현상
여기까지 읽으면 “알았어요, 404 줄게요. 근데 그래서 알림은요?”라는 외침이 들립니다. 이게 진짜 문제입니다.
운영 환경에서 흔히 보이는 풍경은 이렇습니다.
- 검색 엔진 크롤러가 옛날 URL을 계속 긁어와서 404 발생
- 클라이언트 앱이 캐시된 이전 게시물 ID로 요청해서 404 발생
- 누군가 SNS에 공유했던 링크가 게시물 삭제 후에도 살아 있어서 404 발생
- 봇이나 취약점 스캐너가 /posts/1, /posts/2, /posts/3을 무차별 요청해서 404 발생
이게 다 “정상적인 시스템 동작”인데, 모니터링 툴 설정을 잘못 잡으면 전부 “에러”로 잡힙니다. APM 도구의 핵심 가르침 하나는 “애플리케이션이 정상적으로 작동하고 있다는 신호인 검증, 인증, 404 오류는 버그가 아니다”라는 점입니다. 사용자가 이메일 칸에 asdf를 입력했다고 해서 그게 고쳐야 할 버그가 아닌 것과 같은 맥락입니다.
핀포인트, Sentry, Datadog에서 404 노이즈 줄이는 법
본격적으로 해결책을 보겠습니다.
1. 서버단에서 의도된 404와 진짜 에러를 분리
가장 먼저 할 일은 코드 레벨에서 404를 “예외”가 아니라 “정상 흐름의 분기”로 처리하는 것입니다. Spring Boot 기준으로 보면, 흔히 이런 식으로 짭니다.
// 안 좋은 예: ResourceNotFoundException이 무조건 에러로 잡힘
@GetMapping("/posts/{id}")
public PostResponse getPost(@PathVariable Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Post not found: " + id));
return PostResponse.from(post);
}
이 코드의 문제는 ResourceNotFoundException이 스택 트레이스를 남기면서 모니터링 툴에 “예외 발생”으로 잡힌다는 점입니다. 핀포인트에서는 이게 빨간 점이 됩니다. 좀 더 깔끔한 방식은 이렇습니다.
@GetMapping("/posts/{id}")
public ResponseEntity<PostResponse> getPost(@PathVariable Long id) {
return postRepository.findById(id)
.map(post -> ResponseEntity.ok(PostResponse.from(post)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
또는 GlobalExceptionHandler에서 ResourceNotFoundException을 WARN 레벨로 로깅하고, APM 트랜잭션에서 “에러”로 마킹하지 않도록 처리합니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException e, HttpServletRequest request) {
// ERROR가 아닌 INFO/DEBUG 레벨로 기록
log.info("Resource not found: {} {}", request.getMethod(), request.getRequestURI());
// APM에 에러로 기록되지 않도록 처리 (Pinpoint, Datadog 등)
// 예: 트랜잭션 태그에 expected=true 추가
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("POST_NOT_FOUND", "요청한 게시물을 찾을 수 없습니다."));
}
}
2. APM 도구의 에러 마킹 규칙 조정
핀포인트의 경우 pinpoint.config나 에이전트 설정에서 특정 HTTP 상태 코드를 “에러로 간주하지 않음”으로 설정할 수 있습니다. 보통 profiler.http.status.code.errors 같은 설정으로 “에러로 잡을 상태 코드 범위”를 지정하는데, 기본값을 4xx 전체에서 5xx만으로 좁히면 노이즈가 확 줄어듭니다.
Datadog의 경우 Error Tracking이 자동으로 에러를 이슈로 그루핑하는데, 이때 error.type, error.message, 스택 트레이스 프레임을 기반으로 핑거프린트를 만듭니다. 의도된 404가 예외로 던져지지 않게만 처리해도 Error Tracking에서 자동으로 빠집니다. 그래도 들어온다면 Inclusion/Exclusion 룰을 설정해서 특정 서비스나 에러 타입을 제외할 수 있습니다.
Sentry는 더 간단합니다. beforeSend 훅이나 ignoreErrors에서 특정 예외 클래스를 명시적으로 제외하면 됩니다.
Sentry.init({
dsn: 'your-dsn',
ignoreErrors: [
'ResourceNotFoundException',
'EntityNotFoundException',
],
beforeSend(event, hint) {
const error = hint.originalException;
if (error?.statusCode === 404) {
return null; // 이벤트 전송 안 함
}
return event;
},
});
3. 메트릭으로는 추적하되 알림은 끄기
여기서 중요한 분리가 있습니다. “404를 기록조차 하지 말자”는 게 아닙니다. 404는 여전히 의미 있는 지표입니다. 예를 들어 평소 시간당 200건 발생하던 404가 갑자기 2만 건이 되면, 그건 봇 공격이거나 마이그레이션 사고이거나 누가 사이트맵을 잘못 올린 신호일 수 있습니다.
그래서 권장 패턴은 이렇습니다.
| 신호 종류 | 처리 방식 |
| 개별 404 이벤트 | 알림 없음, 로그 INFO 레벨 |
| 404 발생량(rate) | 메트릭으로 수집, 임계치 초과 시에만 알림 |
| 5xx 에러 | 즉시 알림, 스택 트레이스 캡처 |
| 특정 패턴의 404 (예: 인증된 사용자가 자기 자원에 404) | 별도 추적, 비즈니스 알림 |
Datadog에서는 이걸 Error Tracking 대신 Metrics + Monitor 조합으로 구성하는 게 일반적이고, 핀포인트는 별도 사용자 정의 알람으로 분리합니다.
4. 클라이언트도 “예상된 404”라는 시그널을 보내기
좀 더 고급 기법으로, API 클라이언트가 “이 요청은 404가 나올 수 있다는 걸 알고 있다”는 힌트를 헤더로 보내는 방법도 있습니다.
GET /posts/12345 HTTP/1.1
X-Expected-Status: 200,404
서버 미들웨어가 이 헤더를 보고 응답 상태가 X-Expected-Status에 포함되면 APM에 에러로 기록하지 않는 거죠. 이건 표준은 아니지만 사내 시스템에서 꽤 유용하게 쓰입니다.
그래도 200으로 주고 싶다면 (예외 케이스)
표준은 404가 맞지만, 모든 규칙엔 예외가 있습니다. 다음 케이스에서는 200 + 빈 응답을 고려할 만합니다.
- 매우 높은 트래픽의 캐시 우선 시스템에서 404 캐싱 정책이 복잡해질 때
- GraphQL 스타일로 단일 엔드포인트에서 여러 리소스를 동시에 조회하는 경우
- 내부 마이크로서비스 간 통신에서 “없음”이 정상 비즈니스 흐름인 경우(예: 캐시 조회용 서비스)
핵심은 “팀과 클라이언트가 합의했고, 일관되게 적용한다”입니다. 어떤 엔드포인트는 404, 어떤 엔드포인트는 200을 주는 일관성 없는 API가 가장 나쁩니다.
정리: 의사결정 트리
상황이 헷갈릴 때 쓸 수 있는 간단한 의사결정 가이드입니다.
- PK 또는 고유 식별자로 단건 조회를 했는데 자원이 없다 → 404
- 컬렉션을 필터링했는데 결과가 없다 → 200 + 빈 배열
- URL 패턴 자체가 존재하지 않는다 → 404
- 권한이 없어서 못 보여주는 거다 → 403 또는 404 (보안 정책에 따라)
- 자원이 영구적으로 삭제됐고 그걸 클라이언트가 알아야 한다 → 410 Gone
그리고 무엇보다 중요한 원칙은, “HTTP 상태 코드를 결정하는 것”과 “모니터링 시스템에 무엇을 알릴지 결정하는 것”은 별개의 문제라는 점입니다. 404는 정확한 응답일 수 있지만, 그게 새벽 3시에 당신을 깨워야 할 신호는 아닙니다. 표준은 표준대로 지키고, 알림은 알림대로 똑똑하게 설정하는 것. 그게 운영하는 사람의 정신 건강을 지키는 길입니다.
오늘부터 핀포인트와 Sentry에서 4xx 분리부터 시작해보시죠. 알림 개수가 90% 줄어드는 마법을 경험하게 될 겁니다.
참고 자료
- RFC 9110 HTTP Semantics, 404 Not Found 정의: https://www.rfc-editor.org/rfc/rfc9110.html
- API Handyman, Empty list - HTTP status code 200 vs 204 vs 404: https://apihandyman.io/empty-lists-http-status-code-200-vs-204-vs-404/
- Swiftmade, Sentry Best Practices - What to Report and What to Ignore: https://swiftmade.co/blog/2026-01-05-sentry-error-reporting-best-practices/
- Datadog Docs, Error Tracking for Backend Services: https://docs.datadoghq.com/tracing/error_tracking/
- Datadog Docs, Error Tracking Monitor: https://docs.datadoghq.com/monitors/types/error_tracking/
'IT' 카테고리의 다른 글
| 2026년 5월 19일 주요 IT 뉴스 TOP 5: 구글 I/O부터 HBM 전쟁까지 (4) | 2026.05.19 |
|---|---|
| 2026년 5월 18일, 지금 IT 업계에서 가장 뜨거운 뉴스 5선 총정리 (4) | 2026.05.18 |
| 맥북 Dock 뜨는 속도 빠르게 하기 (2) | 2026.05.14 |
| 미국 정부가 AI를 출시 전 검열한다, CAISI-구글-MS-xAI 협약의 진짜 의미 (1) | 2026.05.12 |
| 애플도 결국 백기 들었다, iOS 27에 제미나이·클로드 탑재 검토 전말 (1) | 2026.05.12 |