[프로젝트] 무빙 - 304 Not Modified 추적하기

hogu__giriboy·2026년 8월 14일

스프린트

목록 보기
11/13
post-thumbnail

관리자 페이지의 회원 검색 기능을 확인하던 중 네트워크 탭에서 304 Not Modified 응답을 발견했다.
처음에는 검색 API에 문제가 있거나 백엔드에서 잘못된 상태 코드를 반환한다고 생각했다. 하지만 코드를 확인해 보니 Controller에서는 명확하게 200 OK를 반환하고 있었고,

프로젝트 전체를 검색해도 res.status(304)를 사용하는 코드는 없었다.


1. 304 Not Modified 란?

304 Not Modified는 서버 오류가 아니다.

브라우저가 이전에 받은 리소스를 캐시에 보관하고 있을 때, 서버에 기존 데이터가 여전히 유효한지 확인하는 과정에서 사용되는 HTTP 상태 코드다.

서버는 응답 본문을 다시 보내는 대신 304 Not Modified만 반환한다.
브라우저는 자신이 보관하고 있던 응답 본문을 재사용한다.

따라서 304 Not Modified는 조회에 실패했다는 의미가 아니라, 이전 응답과 달라진 내용이 없다는 의미다.


2. 실제 요청 헤더 확인

네트워크 탭에서 실제 회원 목록 조회 API를 확인해 보니
요청 헤더에는 If-None-Match가 포함되어 있었고,
응답 헤더에는 동일한 값의 ETag가 있었다.

ETag는 서버가 응답 내용의 버전을 식별하기 위해 생성하는 값이다.

브라우저는 이전 응답에서 받은 ETag를 저장해 두었다가 같은 리소스를 다시 요청할 때 If-None-Match 헤더에 담아 서버로 전송한다.

서버가 새로 만든 응답의 ETag와 브라우저가 보낸 If-None-Match가 같다면 응답 내용이 변경되지 않았다는 의미다.
따라서 서버는 같은 JSON 본문을 다시 전송하지 않고 304 Not Modified를 반환한다.


3. 기간을 설정하면 정상적으로 반환되는 200 OK

조사 당시 기본 조건으로 회원 목록을 조회하면 304 Not Modified가 발생했지만, 기간 필터를 적용하면 200 OK가 반환됐다.

처음에는 기간 필터에 따라 서버의 응답 처리 방식이 달라지는 것으로 생각했다.
하지만 기간을 설정하면 startDateendDate가 query parameter에 추가되면서 요청 URL 자체가 달라진다.

# 기본 조회
GET /api/admin/members?userType=CUSTOMER&page=1&pageSize=10

# 기간 설정 조회
GET /api/admin/members?userType=CUSTOMER&page=1&pageSize=10&startDate=2026-08-01&endDate=2026-08-14

브라우저 캐시는 요청 URL별로 구분된다.
따라서 해당 기간 조건을 처음 요청한 경우에는 브라우저에 저장된 ETag가 없어 If-None-Match 헤더도 전송되지 않는다.

비교할 이전 ETag가 없으므로 서버는 JSON 응답 본문과 함께 200 OK를 반환한다.

기본 조회
→ 이전에 같은 URL을 요청한 기록이 있음
→ If-None-Match 전송
→ ETag가 같으면 304 Not Modified

기간 설정 후 최초 조회
→ query parameter가 추가되어 URL이 달라짐
→ 비교할 기존 ETag가 없음
→ 200 OK와 JSON 본문 반환

기간 필터 자체가 캐시를 비활성화한 것은 아니다. 같은 기간 조건을 다시 요청하고 조회 결과가 이전과 동일하다면 기간 조회에서도 304 Not Modified가 발생할 수 있다.

실제로 최초 조회 이후, 같은 결과를 가져오는 같은 기간 조회 시 304 Not Modified를 확인할 수 있었다.

4. Controller200인데 최종 응답은 304

회원 목록 Controller에서는 다음과 같이 명확하게 200 OK를 지정하고 있었다.

/** 관리자 회원 목록 조회 */
export const getAdminMemberList = async (
  _req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const query = getValidated<AdminMemberListQuery>(res, 'query');

    const data = await adminMemberService.getAdminMemberList(query);

    res.status(200).json({ data });
  } catch (error) {
    next(error);
  }
};

그런데도 브라우저에서는 304 Not Modified가 나타났다.
원인은 Express의 기본 ETag 처리에 있었다.
현재 프로젝트에서는 Express를 생성한 뒤 ETag 기능을 별도로 비활성화하지 않았다.

Express는 기본적으로 res.json()이나 res.send()로 전송하는 응답 본문을 기반으로 weak ETag를 생성한다.

W/는 weak validator를 의미한다.
이는 해당 ETag가 강한 바이트 단위 동일성 검증이 아닌 HTTP 캐시 재검증에 사용되는 값이라는 표시다.
응답을 보내는 시점에 Express는 요청이 fresh한지 검사한다.

요청의 If-None-Match
          ↕
새 응답의 ETag

두 값이 일치하면 Express는 Controller에서 지정한 200 OK를 최종적으로 304 Not Modified로 변경하고 응답 본문을 제거한다.

Controller가 응답 객체를 구성한 후 Express가 실제 응답을 전송하는 과정에서 캐시 유효성을 검사하기 때문에,
Controller에서 지정한 200 OK와 브라우저가 받은 304 Not Modified은 모순이 아니다.


5. 304가 발생하면 DB 조회도 생략될까?

304 Not Modefied의 원인을 조사하면서 서버 로직과 DB 조회도 생략되는 것인지 의문이 들었다.

하지만 현재 구조에서는 그렇지 않다.

Express가 ETag를 비교하려면 이번 요청에 반환할 JSON 응답이 필요하다.
따라서 먼저 Middleware, Controller, Service, Repository를 거쳐 DB를 조회하고 응답 본문을 생성한다.

그다음 생성한 응답의 ETag를 브라우저가 보낸 If-None-Match와 비교한다.

GET /api/admin/members
→ 인증 Middleware
→ Query 검증 Middleware
→ Controller
→ Service
→ Repository
→ DB 조회
→ JSON 응답 생성
→ ETag 생성
→ If-None-Match와 비교
→ 동일하면 304 Not Modified 반환

따라서 현재 구조에서 304 Not Modified가 줄여주는 것은 주로 네트워크 응답 본문의 전송량이다.

줄어드는 것
- JSON 응답 본문 전송량

그대로 실행되는 것
- 인증 Middleware
- Query 검증
- Controller
- Service
- Repository
- DB 조회

DB 조회까지 줄이고 싶다면 Redis와 같은 서버 캐시나 별도의 캐시 계층이 필요하다.


6. 304를 수정해야 할까?

이번 304 Not Modified는 정상적인 HTTP 캐시 재검증 결과이므로 반드시 수정해야 하는 문제는 아니다.

브라우저는 304 응답을 받으면 이전에 저장한 JSON 본문을 재사용한다.
따라서 화면에는 정상적으로 회원 목록이 표시된다.

다만 관리자 API 응답을 브라우저에 저장하지 못하게 해야 한다면 다음과 같은 정책을 검토할 수 있다.

특정 응답의 저장 방지

Cache-Control: no-store`

Express ETag 비활성화

app.disable('etag');

하지만 app.disable('etag')는 전체 API 응답에 영향을 줄 수 있다.
단순히 네트워크 탭에서 304가 보인다는 이유만으로 전역 ETag를 비활성화하는 것은 적절하지 않다.

먼저 다음 사항을 확인해야 한다.

  • 실제 화면에 오래된 데이터가 표시되는가?
  • 304 Not Modified 때문에 기능 오류가 발생하는가?
  • 관리자 목록 응답을 브라우저에 저장해도 되는가?
  • 줄이려는 비용이 네트워크 전송량인가, DB 조회 비용인가?

이번 경우에는 화면 오류가 발생한 것이 아니었기 때문에 별도의 코드 수정은 하지 않았다.


7. 결론

이번에 발생한 304 Not Modified는 검색 API의 오류도 아니었고 Controller가 직접 반환한 응답도 아니었다.

브라우저가 이전 응답의 ETagIf-None-Match에 담아 보냈고,
Express가 새로 생성한 응답의 ETag와 비교한 결과 내용이 동일했기 때문에 최종 상태를 304 Not Modified로 변경한 것이다.

브라우저가 이전 ETag 저장
→ 동일한 URL 재요청
→ If-None-Match로 기존 ETag 전달
→ 서버가 DB 조회 후 JSON 응답 생성
→ 새로운 ETag 생성
→ 두 값이 동일하면 304 Not Modified
→ 브라우저가 이전 응답 본문 재사용

이번 문제를 통해 Controller에서 지정한 상태 코드가 항상 최종 네트워크 응답 상태가 되는 것은 아니라는 점을 알게 되었다.

또한 304 Not Modified는 서버 실행과 DB 조회를 생략해 주는 서버 캐시가 아니라,
동일한 응답 본문의 재전송을 줄이는 HTTP 캐시 재검증이라는 점도 확인할 수 있었다.

0개의 댓글