문제 현상
프론트엔드에서 SSE(Server-Sent Events) 연결을 시도하면 연결은 성공하지만, 즉시 연결이 끊어지는 현상이 발생
원인 분석
SSE는 HTTP의 Transfer-Encoding: chunked 방식을 사용해 지속적인 연결을 유지하는 것이 핵심
그러나 서버 로그 필터에서 사용하는 ContentCachingResponseWrapper가 copyBodyToResponse() 호출 시 자동으로 Content-Length 헤더를 추가
이로 인해 Transfer-Encoding과 Content-Length가 동시에 존재하게 되어 브라우저는 응답이 끝났다고 판단하고 연결을 끊음
※ 알아야 할 개념 : HTTP 응답의 데이터 전송 방식
서버가 클라이언트에게 응답을 줄 때 2가지 방식 중 하나를 선택
1. Content-Length 방식
응답의 전체 길이를 미리 계산해서 알려줌
브라우저는 응답 결과의 길이를 알게 되어 해당 길이만큼 오면 연결을 끊음
HTTP/1.1 200 OK
Content-Length: 123
1. 서버 : 123바이트 보낼께~
2. 브라우저 : ㅇㅋ
3. (서버가 데이터 보내는 중)
4. (브라우저는 123바이트 받고 연결을 종료)
Content-Length 방식은 일반적으로 HTML, JSON 응답 등에서 사용
2. Transfer-Encoding : chunked 방식
데이터를 여러 개의 덩어리(Chunk)로 쪼개서 순차적으로 전송
전체 길이를 모를 때도 계속 전송이 가능
마지막에 0\r\n\r\n (길이가 0인 청크로) 로 끝을 알려줌
HTTP/1.1 200 OK
Transfer-Encoding: chunked
7\r\n
Hello, \r\n
6\r\n
world!\r\n
0\r\n
\r\n
1. 서버 : 일단 계속 보낼께~
2. 브라우저 : ㅇㅋ
3. (서버가 데이터를 계속 보내는 중)
3.1 (0\r\n\r\n 을 보내서 종료)
4. (브라우저는 연결을 끊음)
문제 해결
팀 프로젝트를 진행하면서 Request, Response 원본을 보고 싶어 Log 전용 Filter를 만들어 로그를 보고 있으며 그 과정에서 ContentCachingResponseWrapper 를 사용하고 있었습니다.
ContentCachingResponseWrapper 를 사용한 이유
1. HttpServlet의 한계
HttpServletRequest와 HttpServletResponse의 InputStream/OutputStream은 한 번만 읽고 쓸 수 있음
ContentCachingRequestWrapper와 ContentCachingResponseWrapper는 이를 복사본을 만들기 위해 wrapping해서 다회 접근 가능하게 해 줌
2. 상속
ContentCachingResponseWrapper가 HttpServletResponseWrapper를 상속하고 있고, 그 자체가 HttpServletResponse 인터페이스를 구현한 구조입니다.
res.copyBodyToResponse()를 호출했을 때 Content-Length가 자동으로 추가가 되게 됩니다.
위에 설명이 있듯이 Transfer-Encoding : chunked와 Content-Length가 충돌이 발생하여 연결이 되자마자 바로 끊기는 형상이 발생한 것입니다.
코드 수정
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
// SSE 요청인지 확인
String acceptHeader = httpRequest.getHeader("Accept");
boolean isSse = acceptHeader != null && acceptHeader.contains("text/event-stream");
if (isSse) {
// SSE는 래핑하지 않고 그대로 진행
filterChain.doFilter(httpRequest, httpResponse);
return;
}
// 일반 요청은 래핑해서 로그 처리
ContentCachingRequestWrapper req = new ContentCachingRequestWrapper(httpRequest);
ContentCachingResponseWrapper res = new ContentCachingResponseWrapper(httpResponse);
// 다음 필터 혹은 서블릿으로 요청 전달
filterChain.doFilter(req,res);
// 요청 로그
logRequest(req);
// 응답 로그
logResponse(res);
// 응답 본문 클라이언트에 전달
res.copyBodyToResponse();
}