문제 상황
실시간 주문 알림 기능을 구현하면서 SSE(Server-Sent Events) 기술을 사용했는데, SSE 연결이 지속되지 않고 첫 연결 후 바로 끊어지는 문제가 발생했었다.
원인 분석
SSE는 연결이 지속되어야 하는데, Content-Length 헤더가 설정되면 연결이 유지되지 않는 문제가 있었다.
원인을 요약하면 다음과 같다.
- ContentCachingResponseWrapper#copyBodyToResponse 호출 시 Content-Length가 설정되고 flush가 실행됨
- Http11Processor#prepareResponse() 실행
- 길이가 존재하므로 chunked 헤더가 할당되지 않음
- 클라이언트는 chunked 헤더가 없어서 이벤트로 인식하지 않고 연결을 종료
RFC7230에 따르면 발신자는 Transfer-Encoding 헤더 필드가 포함되어 있다면 Content-Legnth 헤더를 동시에 사용하면 안 된다고 써있다.
- Transfer-Encoding 헤더가 있는 경우, Content-Length 헤더는 무시되어야 합니다.
- 두 헤더가 동시에 존재하는 경우, 이는 잘못된 요청 또는 응답으로 간주될 수 있습니다.
문제의 근본 원인
로깅을 위해 사용한 ContentCachingResponseWrapper가 문제의 원인이였다.
해당 래퍼는 로그를 남기기 위해(재사용 하기 위해) response를 캐싱하는데, copyBodyToResponse 메서드에서 Content-Length를 설정하면서 문제가 발생하였다.
protected void copyBodyToResponse(boolean complete) throws IOException {
// ... (코드 생략)
if (rawResponse.getHeader(HttpHeaders.TRANSFER_ENCODING) == null) {
rawResponse.setContentLength(complete ? this.content.size() : this.contentLength);
}
// ... (코드 생략)
}
Spring PR 도전
이 문제를 해결하기 위해 Spring Framework에 첫 PR을 제출하였다. Content-Type이 text/event-stream인 경우 Content-Length를 설정하지 않도록 수정하는 PR이였다.
PR 링크: https://github.com/spring-projects/spring-framework/pull/33285
하지만 안타깝게도 PR은 거절되었다.
메인테이너는 래핑 수행 전에 스트리밍 타입인지 먼저 검사하라고 조언해줬다.
해결 방법
결국 스트리밍 요청 시 로깅 필터를 거치지 않도록 수정하여 문제를 해결했다.
이를 통해 RFC7230에 명시된 것처럼, Transfer-Encoding 헤더와 Content-Length 헤더를 동시에 사용하지 않아야 한다는 원칙을 따르게 되었다.
후기
비록 Spring 컨트리뷰터의 꿈은 이루지 못했지만, 이 경험을 통해 많은 것을 배웠다.
- 디버깅 과정에서 Spring의 내부 동작을 깊이 이해
- 스트리밍 타입에서 Content-Length 설정의 문제점을 인식
- Spring 공식 문서를 더 열심히 읽게 되는 계기
이번 경험을 통해 Spring에 대한 이해도를 높이고, 스프링 컨트리뷰터가 되고 싶다는 욕심이 생겼다.
꼭 꿈을 이루고 싶다.
'개발 이야기' 카테고리의 다른 글
효율적인 개발과 유지보수를 위한 문서화의 힘 (0) | 2024.08.11 |
---|---|
Redis로 장바구니 구현하기: 추상화의 편리함과 함정 (0) | 2024.08.10 |
실시간 주문 알림 도입기(서버 to 클라이언트) (0) | 2024.07.27 |
Redis Cluster 환경에서 'DEL Collection'은 왜 안 될까? (0) | 2024.07.20 |
비즈니스 로직 분산 (0) | 2024.07.13 |