본문 바로가기

트러블 슈팅

[트러블 슈팅] SSE(Server-Send-Events)

문제 현상

프론트엔드에서 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();

}