SSE | 실시간 결제 알림 시스템 개발기 (3) - SSE와 LoggingFilter 충돌 해결기

들어가며
이전 글에서는 로드밸런싱 환경에서의 문제를 Redis Pub/Sub으로 해결한 과정을 다뤘습니다. 기술적으로는 완성된 것처럼 보였지만, 실제 배포 전 테스트 환경에서 예상치 못한 문제가 발생했습니다.
SSE 연결은 성공하는데, 거래 알림이 제대로 전달되지 않거나 연결이 비정상적으로 종료되는 현상이었습니다. 로그를 확인해보니 사내에서 공통으로 사용하는 LoggingFilter가 원인이었습니다.
이번 글에서는 SSE와 LoggingFilter의 충돌 원인, 해결 과정, 그리고 그 과정에서의 기술적 고민을 공유합니다.
문제 발견
증상
테스트 환경에 배포 후 다음과 같은 문제들이 발생했습니다:
1. SSE 연결은 성공하지만 메시지가 전달되지 않음
2. 연결이 예상보다 빨리 종료됨
3. 간헐적으로 500 에러 발생
원인 분석
문제의 원인은 사내 공통 라이브러리의 LoggingFilter였습니다.
일반적인 HTTP 요청-응답 사이클은 이렇습니다:
Request → Filter → Controller → Response → Filter → Client
↑_____ 여기서 로깅 _____↑
LoggingFilter는 요청과 응답을 가로채서 로그를 남기는데, 이 과정에서:
- Request body를 읽어서 로깅
- Response body를 읽어서 로깅
- 연결 종료
그런데 SSE는 다릅니다:
Request → Filter → Controller → [Connection Held Open]
↓
[Streaming...]
↓
[Event 1, 2, 3...]
↓
[Still Open...]
SSE는 연결을 계속 유지하면서 스트리밍 방식으로 데이터를 전송합니다. 하지만 LoggingFilter는:
- Response를 즉시 읽으려고 시도 → SSE는 아직 데이터를 보내지 않았음
- Connection을 빨리 종료 → SSE 스트림이 끊김
- Buffering 시도 → 메모리 문제 발생 가능
해결 방안 검토
사내 공통 라이브러리는 여러 프로젝트에서 사용 중이므로 직접 수정할 수 없었습니다. 그래서 우회 방법을 찾아야 했습니다.
시도 1: URL 패턴 제외 (실패)
처음에는 Filter 등록 시 URL 패턴으로 제외하려고 했습니다.
@Bean
public FilterRegistrationBean<LoggingFilter> loggingFilter() {
FilterRegistrationBean<LoggingFilter> registration =
new FilterRegistrationBean<>();
registration.setFilter(loggingFilter);
registration.addUrlPatterns("/*");
// SSE 경로 제외 시도
registration.addInitParameter("excludePattern", "/api/notifications/subscribe/*");
return registration;
}
문제점: 사내 라이브러리의 LoggingFilter가 이미 @Component로 자동 등록되어 있었고, 제외 패턴 기능을 지원하지 않았습니다.
시도 2: Filter Order 조정 (부분적 성공)
SSE 요청만 먼저 처리하는 Filter를 추가했습니다.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SseBypassFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) {
String uri = request.getRequestURI();
if (uri.contains("/subscribe")) {
request.setAttribute("SKIP_LOGGING", true);
}
filterChain.doFilter(request, response);
}
}
문제점: LoggingFilter가 SKIP_LOGGING 플래그를 체크하지 않았습니다. 사내 라이브러리를 수정할 수 없으므로 이 방법도 실패했습니다.
최종 해결: @Primary로 Bean 재정의
결국 @Primary 어노테이션을 사용하여 LoggingFilter를 재정의하는 방식으로 해결했습니다.
구현
LoggingFilter 래핑
@Configuration
public class FilterConfig {
@Primary
@Bean
public OncePerRequestFilter customLoggingFilter(
@Qualifier("loggingFilter") OncePerRequestFilter originalFilter) {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// SSE 요청인지 확인
if (isSseRequest(request)) {
// SSE는 원본 필터를 거치지 않고 바로 통과
filterChain.doFilter(request, response);
return;
}
// 일반 요청은 원본 로깅 필터 실행
originalFilter.doFilter(request, response, filterChain);
}
private boolean isSseRequest(HttpServletRequest request) {
String uri = request.getRequestURI();
String accept = request.getHeader("Accept");
// URL 패턴으로 확인
if (uri.contains("/api/notifications/subscribe")) {
return true;
}
// Accept 헤더로 확인
if (accept != null && accept.contains("text/event-stream")) {
return true;
}
return false;
}
};
}
}
동작 원리
@Primary 어노테이션은 같은 타입의 Bean이 여러 개 있을 때 우선순위를 지정합니다.
[Before]
Application → LoggingFilter (사내 라이브러리) → Controller
[After]
Application → CustomLoggingFilter (@Primary) → Controller
↓
SSE 요청?
↙ ↘
Yes No
↓ ↓
바로 통과 원본 Filter 실행
이 방식의 장점:
- 사내 라이브러리 코드 수정 불필요
- 기존 로깅 기능 유지 (일반 요청은 그대로)
- SSE만 선택적으로 우회 가능
더 나은 대안은 없었을까?
프로젝트를 완료한 후, 더 나은 해결 방법은 없었는지 고민해봤습니다.
사내 라이브러리 팀에 개선 요청
가장 근본적인 해결책입니다.
// 사내 라이브러리에 추가 요청할 기능
@Component
public class LoggingFilter extends OncePerRequestFilter {
@Value("${logging.filter.exclude-patterns:}")
private String[] excludePatterns;
@Override
protected void doFilterInternal(...) {
if (shouldSkip(request)) {
filterChain.doFilter(request, response);
return;
}
// 로깅 로직...
}
private boolean shouldSkip(HttpServletRequest request) {
String uri = request.getRequestURI();
for (String pattern : excludePatterns) {
if (uri.matches(pattern)) {
return true;
}
}
return false;
}
}
그러면 설정 파일에서 간단하게 제외할 수 있습니다:
logging:
filter:
exclude-patterns:
- "/api/notifications/subscribe/.*"
- "/api/streaming/.*"
장점:
- 모든 프로젝트에서 활용 가능
- 깔끔한 설정 기반 제어
- 유지보수성 향상
단점:
- 라이브러리 팀의 승인 및 배포 시간 필요
- 다른 팀의 일정에 의존
실무에서의 트레이드오프
우리가 선택한 이유
결국 @Primary 방식을 선택한 이유는:
- 시간 제약: 프로젝트 일정이 촉박했음
- 최소 영향: 기존 시스템에 영향 없이 빠르게 해결
- 실용성: 완벽하지 않지만 요구사항은 충족
기술 부채 관리
하지만 이 방식은 기술 부채를 남깁니다:
문제점:
- 사내 라이브러리 업데이트 시 동작 보장 안 됨
- 다른 개발자가 원본 Filter가 동작한다고 착각 가능
- 암묵적인 오버라이드로 디버깅 어려움
대응 방안:
코드 리뷰와 문서에 명확히 기록하고, 백로그에 개선 작업을 등록했습니다.
배운 점
1. 레거시 시스템과의 통합
모든 프로젝트가 그린필드는 아닙니다. 기존 시스템, 공통 라이브러리와의 충돌은 실무에서 흔한 일입니다.
완벽한 해결책을 고집하기보다, 현실적인 제약 속에서 최선의 선택을 하는 것이 중요합니다.
2. 문서화의 중요성
임시 해결책일수록 명확한 문서화가 필수입니다:
- 왜 이렇게 구현했는지
- 어떤 제약사항이 있었는지
- 향후 개선 방향은 무엇인지
6개월 후 다른 개발자(혹은 미래의 나)가 코드를 볼 때를 대비해야 합니다.
3. 기술 부채 관리
기술 부채는 나쁜 것이 아닙니다. 중요한 것은:
- 부채를 인지하고 있는가
- 부채를 관리하고 있는가
- 적절한 시점에 상환 계획이 있는가
우리는 백로그에 개선 작업을 등록하고, 사내 라이브러리 팀에 feature request를 제출했습니다.
마치며
실시간 결제 알림 시스템을 구축하면서 많은 것을 배웠습니다:
- SSE vs WebSocket: 요구사항에 맞는 기술 선택의 중요성
- 분산 환경: 단일 서버와는 다른 고민과 해결책
- 레거시 통합: 현실적 제약 속에서의 의사결정
- 기술 부채: 인지하고 관리하는 자세
완벽한 시스템은 없습니다. 중요한 것은 비즈니스 요구사항을 만족시키면서, 기술적 품질과 현실적 제약 사이의 균형을 찾는 것입니다.
이 시리즈가 실시간 통신 시스템을 구축하거나, 분산 환경에서의 문제를 해결하는 분들에게 도움이 되길 바랍니다.
후속 개선 계획
프로젝트는 완료되었지만, 개선할 부분들이 남아있습니다:
- SSE 재연결 처리: Last-Event-ID를 활용한 메시지 재전송
- Circuit Breaker 패턴: Redis 장애 시 자동 복구
- 사내 라이브러리 개선: 공통 LoggingFilter에 exclude 기능 추가
- 성능 최적화: 대규모 동시 접속 시 메모리 사용량 개선
기술 부채를 인지하고 있으며, 우선순위에 따라 하나씩 개선해 나갈 예정입니다.