공뷰/Spring

[Spring] 야구보구의 실시간 기능, 배포 후 발생한 SSE 문제 정리(nginx 설정)

nourzoo 2025. 10. 5. 19:49

 

 

야구보구 홈 화면에서는 오늘 인증된 구장별 팬 현황을 실시간으로 보여주는 기능을 구현하고 싶었다.

이를 위해 여러 방식을 고민했고, 그중 SSE를 선택했다.

 

SSE를 도입하게 된 이유와 구현 과정은 아래 글에서 자세히 정리해 두었다.

이 글에서는 SSE를 실제로 배포하는 과정에서 발생했던 문제점과 해결 과정을 중심으로 다룬다.

 

[Spring] 야구보구 홈 화면 SSE(Server-Sent Events)로 실시간 팬 현황 갱신하기

왜 SSE를 선택했는가야구보구 홈 화면에서는 오늘 인증된 구장별 팬 현황을 실시간으로 보여주고 싶었다.현재 홈 화면은 다음과 같다. 이를 위해 몇 가지 방법을 고민했었다. Polling클라이언트가

nourzoo.tistory.com

 

🔧 dev 서버에서의 테스트

SSE는 아래와 같은 방식으로 정상적으로 동작했다.

curl -v --http1.1 -N \
  -H "Accept: text/event-stream" \
  -H "Authorization: Bearer <access token>" \
  "http://13.124.13.216/api/event-stream"

 

요청이 성공하면 서버는 일정 주기로 keep-alive 이벤트를 전송하고,

경기 인증 이벤트가 발생할 때마다 실시간으로 메시지를 푸시했다.

 

즉, dev 환경에서는 모든 SSE 흐름이 잘 작동하고 있었다.

문제가 없다고 판단했기에, 바로 main으로 배포했다.

문제는 main 서버에 배포한 뒤부터였다.

 

🚨 prod 환경에서의 문제

배포 직후 prod 서버로 동일한 curl 요청을 보내자 응답이 오지 않았다.

처음에는 Cloudflare가 SSE를 막는 건가? 라고 생각했지만, 원인은 nginx 설정이었다.

 

SSE가 잘 동작하지 않았던 원인은 nginx가 업스트림 서버(백엔드)와 통신할 때 기본적으로 HTTP/1.0을 사용하기 때문이었다.

HTTP/1.0은 연결을 요청마다 끊어버리는 방식이라, 서버가 지속적으로 데이터를 흘려보내야 하는 SSE 방식과 맞지 않는다. 그래서 브라우저 쪽에서는 연결이 주기적으로 끊기거나, 이벤트가 한꺼번에 몰려서 도착하거나, 서버 로그에 ClientAbortException이나 Broken pipe 같은 예외가 뜨는 문제가 발생했다.

 

SSE는 “끊기지 않는 HTTP 스트림”을 유지해야 하므로, 업스트림 서버와의 통신도 반드시 HTTP/1.1로 유지되어야 한다.

그래서 nginx 설정파일에 다음 한 줄을 추가했다.

proxy_http_version 1.1;

 

이 설정을 적용하면, nginx가 백엔드 서버와 HTTP/1.1로 통신하면서 지속 연결(keep-alive)과 chunked transfer을 모두 지원하게 된다.

즉, Nginx가 중간에서 데이터를 버퍼링하거나 응답을 조기 종료하지 않고, 서버가 보낸 이벤트를 클라이언트에 실시간으로 흘려보낼 수 있게 된다.

 

정리하자면, 이전에는 nginx가 업스트림에 HTTP/1.0으로 요청을 보내면서 SSE 스트림이 중간에 잘렸고,

proxy_http_version 1.1;을 적용한 뒤에는 SSE의 본래 특성인 지속 연결과 실시간 이벤트 전송이 안정적으로 보장되었다.

 

결국 문제의 핵심은 클라우드나 CDN이 아니라, nginx의 기본 HTTP 버전 설정이었다.

SSE를 사용할 때는 반드시 이 옵션을 명시해주는 것이 좋다.

 

⚠️ 두 번째 문제 – 500 에러 발생

이후 또 다른 문제가 발생했다.

SSE 요청 중 클라이언트에서 500 Server Error가 발생한 것이다.

 

🧩 에러 처리 구조

현재 SSE 연결을 관리하는 코드는 다음과 같다.

    public SseEmitter add(final SseEmitter sseEmitter) {
        String id = UUID.randomUUID().toString();
        sseEmitterMap.put(id, sseEmitter);

        Runnable cleanup = () -> sseEmitterMap.remove(id);

        sseEmitter.onTimeout(() -> {
            try {
                log.info("SSE time out");
                sseEmitter.send(SseEmitter.event()
                        .name("timeout")
                        .data("server-timeout")
                        .reconnectTime(3000));
            } catch (IOException e) {
                throw new RuntimeException(e);
            } finally {
                cleanup.run();
            }
        });

        sseEmitter.onCompletion(() -> {
            log.info("SSE completed: emitter={}", sseEmitter.hashCode());
            cleanup.run();
        });
        sseEmitter.onError(t -> {
            if (isClientDisconnect(t)) {
                log.warn("SSE client disconnect: emitterId={}, cause={}", id, rootMessage(t));
            } else if (isAlreadyCompleted(t)) {
                log.debug("SSE already completed: emitterId={}, cause={}", id, rootMessage(t));
            } else {
                log.error("SSE send failed: emitterId={}, cause={}", id, rootMessage(t));
            }
            cleanup.run();
        });

        return sseEmitter;
    }

 

 

1. onTimeout 처리

로그로 "SSE time out"을 남기고, 클라이언트에게 "timeout" 이벤트를 전송해 재연결을 유도한다.이후 finally 블록에서 cleanup을 수행해 emitter를 제거한다. 이때 send()IOException이 발생하면 RuntimeException으로 감싸 다시 던진다. 연결이 일정 시간 이상 유지되면 타임아웃 콜백이 실행된다.

 

2. onCompletion 처리

예외는 발생하지 않으며, 종료 후 cleanup을 수행한다. 스트림이 정상적으로 종료되면 "SSE completed" 로그를 남긴다.

 

3. onError 처리

전송 도중 예외가 발생하면 onError 콜백이 실행된다. 예외 원인에 따라 세 가지로 분기한다.

  • 클라이언트 단절(isClientDisconnect):
    "SSE client disconnect" 경고 로그를 남긴다. 사용자가 페이지를 닫거나 네트워크가 끊긴 경우다.
  • 이미 완료된 응답(isAlreadyCompleted):
    "SSE already completed" 디버그 로그를 남긴다. 응답이 이미 커밋된 후 send를 시도한 경우다.
  • 기타 전송 실패:
    위 두 경우를 제외한 모든 오류에 대해 "SSE send failed" 에러 로그를 남긴다.

모든 경우에서 cleanup을 수행해 emitter를 제거한다.

 

 

⚠️ 두 번째 문제 – 500 Server Error가 발생했던 이유

앞서 설명한 것처럼 SseEmitter에서는 onTimeout, onError, onCompletion 등 모든 경우에서 cleanup을 수행해 emitter를 제거하고 있다.

따라서 정상적인 예외가 발생하더라도, 컨트롤러 단에서는 이를 잡아 4xx 형태의 응답으로 내려줘야 했다.

그런데 실제 서비스에서는 500 Server Error가 발생했다.

 

문제를 재현하기 위해 시나리오를 정리해보았다.

현재 야구보구 앱의 SSE 동작 방식은 다음과 같다.

 

  • 사용자가 앱을 완전히 종료하거나(백그라운드 포함), 직접 앱을 닫으면 SSE 연결을 종료한다.
  • 그 외의 상황 — 예를 들어 네트워크 지연이나 타임아웃 — 에서는 클라이언트 측에서 자동 재시도 로직이 동작한다. 연결이 다시 복구될 때까지 재요청을 보낸다.

이제 문제 상황을 살펴보자.

우리 앱의 Access Token 만료 시간은 15분이다.

즉, 15분이 지나면 만료된 토큰으로 요청을 보내게 되고, 이때는 정상적으로 401 Unauthorized가 발생해야 한다.

 

그런데 SSE의 특성상, 연결이 한 번 성립되면 그 연결은 계속 유지된다.

따라서 사용자가 처음 SSE를 구독할 때만 Access Token이 헤더에 포함되어 전송되고, 연결이 계속 유지되는 동안에는 재요청을 보내지 않아야 한다.

문제는 15분이 지난 뒤, 네트워크 일시 장애나 타임아웃으로 인해 연결이 잠시 끊기면 클라이언트는 자동으로 재연결을 시도하는데, 이때 사용하는 토큰은 이미 만료된 토큰이다.

 

이 상황이라면 서버는 당연히 401 응답을 내려야 한다.

그런데 실제로는 401이 아니라 500 Server Error가 발생했다.

즉, 예외 자체는 예상한 시나리오에 의해 발생했지만,

서버가 이 예외를 올바르게 직렬화하지 못하고 내부 오류로 처리한 것이다.

 

그 이유는 SSE의 응답 구조 때문이었다.

SSE 컨트롤러는 일반 API처럼 ResponseEntity를 반환하지 않고 SseEmitter를 통해 직접 스트림을 내려보낸다.

@RequiredArgsConstructor
@RequestMapping("/api/event-stream")
@RestController
public class StreamController {

    private final SseEmitterService sseEmitterService;

    @RequireRole
    @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter getEventStream(HttpServletResponse response) {
        // 캐싱 방지
        response.setHeader("Cache-Control", "no-cache");
        // 연결 유지
        response.setHeader("Connection", "keep-alive");
        // nginx에서 버퍼링 방지
        response.setHeader("X-Accel-Buffering", "no");

        return sseEmitterService.add();
    }
}

 

그렇기 때문에 GlobalExceptionHandler에서 작성한 @ExceptionHandler 로직이 JSON 바디로 변환하는 과정에서 이미 커밋된 SSE 응답 스트림에 접근하려다가 500 Internal Server Error가 발생한 것이다.

@ExceptionHandler(value = UnAuthorizedException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ExceptionResponse handleUnAuthorizedException(final UnAuthorizedException e) {
    log.info("[UnAuthorizedException]- {}", e.getMessage());

    return new ExceptionResponse(e.getMessage());
}
@ExceptionHandler(UnAuthorizedException.class)
public ResponseEntity<?> handleUnAuthorizedException(
        final UnAuthorizedException e,
        final HttpServletRequest request
) {
    final String accept = request.getHeader(org.springframework.http.HttpHeaders.ACCEPT);
    final boolean isSse = accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE);

    log.warn("[UnAuthorizedException] {}", e.getMessage());

    if (isSse) {
        // ⚠️ SSE는 JSON 바디를 쓰면 협상 충돌(406/500) 위험 → 상태코드만
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

    // 일반 요청: JSON 바디 반환
    ExceptionResponse body = new ExceptionResponse(e.getMessage());
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
            .contentType(MediaType.APPLICATION_JSON)
            .body(body);
}

 

아래와 같이 코드를 수정했더니 401에러로 잘 반환되는 것을 알 수 있었다.

“응답이 커밋됐다”는 게 무슨 말이냐?

HTTP 응답은 크게 두 단계이다.

  1. 준비 단계: 서버가 헤더(Content-Type, Status Code, 쿠키 등)와 바디를 아직 메모리에 쥐고 있는 상태. 아직 네트워크로 안 나감. 이땐 마음대로 헤더/바디를 바꿀 수 있다.
  2. 커밋 단계: 서버가 클라이언트에게 첫 바이트라도 내보낸 순간. 이 시점부터는 “응답이 확정됐다(committed)”라고 함.
    • 헤더는 이미 전송됐기 때문에 다시 못 바꿈.
    • 바디도 이미 흘러가기 시작해서 포맷을 뒤집을 수 없음.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

: ping
  • 위처럼 200 OK와 Content-Type: text/event-stream 헤더, 그리고 바디 일부(: ping)가 나갔다면 응답은 커밋된 상태이다.
  • 이 상태에서 “아, 사실은 401 Unauthorized였네!” 하고 JSON 바디를 쓰려고 하면, 이미 text/event-stream이 나갔으니 불가능 → 스프링은 “response already committed” 예외를 던지고 커넥션을 닫아버린다.

👉🏽 그래서 커밋 후 다시 내보내면 오류난다.