티스토리 뷰

개요

  • Server-Sent Events(이하 SSE)는 HTTP 스트리밍을 통해 서버에서 클라이언트로 단방향의 Push Notification을 전송할 수 있는 HTML5 표준 기술이다. 이번 글에서는 Spring Boot에서 SSE를 이용한 단방향 스트리밍 통신 방법을 실제 운영 프로덕션 레벨 관점에서 설명하고자 한다.

특징

  • 전통적인 웹 애플리케이션이라면 클라이언트의 요청 단건에 대해 서버가 응답하는 방식이지만 SSE를 이용하면 별도의 복잡한 기술이 필요없이 HTTP 프로토콜을 기반으로 서버에서 클라이언트로 Real-Time Push Notification을 전송할 수 있다. 클라이언트의 요청에 의해 한 번 연결이 맺어지면 서버가 원하는 시점에 클라이언트에게 원하는 메시지를 전송할 수 있다. 이러한 특징 덕분에 최소의 오버헤드로 모니터링 시스템의 그래프 갱신, 채팅 및 메신저 등의 비지니스에 광범위하게 적용할 수 있다.
  • HTTP/1.1 프로토콜 사용시 브라우저에서 1개 도메인에 대해 생성할 수 있는 EventSteam의 최대 개수는 6개로 제한된다. (HTTP/2 프로토콜 사용시에는 브라우저와 서버간의 조율로 최대 100개까지 유지가 가능하다.) [관련 링크]
  • 이벤트 데이터는 UTF-8 인코딩된 문자열만 지원한다. 서버 사이드에서 이벤트 데이터를 담은 객체를 JSON으로 마샬링하여 전송하는 것이 가장 일반적이고 무난하다.
  • 현재 Internet Explorer을 제외한 모든 브라우저에서 지원한다. JavaScript에서는 EventSource를 이용하여 연결 생성 및 전송된 이벤트에 대한 제어가 가능하다.
  • Spring Framework4.2(2015년)부터 SseEmitter 클래스를 제공하여 서버 사이드에서의 SSE 통신 구현이 가능해졌다.

설계 시나리오

  • 클라이언트의 요청으로 생성된 EventStream에 접근 가능한 SseEmitter 객체를 생성 즉시 각 인스턴스의 로컬 캐시에 저장한다. (로컬 캐시에 저장하는 이유는 SseEmitter가 개별 인스턴스의 물리적인 HTTP 연결에 관계된 객체이기 때문이다.) 추가로 타임아웃과 캐시의 만료기간을 동일하게 설정하여 유효하지 않은 객체가 불필요한 메모리를 차지하는 것을 예방한다. (로컬 캐시는 멀티 쓰레드 환경에서 동기화가 보장되며 스프링 빈 전역 범위에서 접근이 가능하여 편리하다.)
  • 서버에서 클라이언트에게 전송하는 이벤트 데이터는 모든 인스턴스에서 접근 가능한 분산 캐시에 저장한다. 각 인스턴스에서는 전파된 캐시 생성 이벤트를 인지한 후, 자신의 로컬 캐시에 대상 클라이언트에 해당하는 SseEmitter 객체가 존재할 경우 이벤트를 전송한다. (분산 캐시를 통해 n개 인스턴스 문제를 쉽게 해소할 수 있다.)
  • 서버에서 SseEmitter 객체 생성 시점에 클라이언트로부터 획득한 Last-Event-ID 헤더 값을 확인하여 분산 캐시에서 클라이언트가 미수신한 이벤트 데이터를 조회하여 클라이언트에게 전송한다. (분산 캐시를 통해 유실될뻔한 이벤트 데이터 문제를 해소할 수 있다.)

build.gradle.kts 종속성 추가

  • Spring Boot 기반 프로젝트에서 SSE를 구현하기 위해 필요한 추가적인 아티팩트는 없다. 다만, 예제를 구현하기 위해 앞서 설명한 분산 캐시 라이브러리를 아래와 같이 추가했다.
dependencies {
    implementation("org.infinispan:infinispan-core:12.0.1.Final")
    implementation("org.infinispan:infinispan-commons:12.0.1.Final")
    implementation("org.infinispan:infinispan-marshaller-protostuff:12.0.1.Final")
    implementation("org.jboss.marshalling:jboss-marshalling-osgi:2.0.10.Final")
}
  • 운영 환경에서 서버는 고가용성을 위해 n개 인스턴스로 구성된 클러스터로 구동되는데, 각 인스턴스가 접근할 수 있는 공통된 데이터를 다루는 분산 컴퓨팅 솔루션이 필수적으로 요구된다. 예제에서 사용한 JBoss Infinispan은 업계에서 가장 인지도가 높은 오픈 소스 분산 캐시 라이브러리이다. (자세한 개념 및 사용법은 본 블로그의 이 글을 참고한다.)

분산 캐시 설정

  • EventStream은 클라이언트의 요청에 의해 n개가 생성될 수 있다. 생성된 EventStream을 서버에서 온전히 관리하려면 일반적인 Map 구현체보다는 동시성과 만료 기능이 잘 고려되어 있는 로컬 캐시를 사용하는 편이 낫다. 아래는 생성된 EventStream을 로컬 캐시에 관리하기 위한 스프링 빈을 생성하는 예이다.
package com.jsonobject.example

import org.infinispan.Cache
import org.infinispan.configuration.cache.CacheMode
import org.infinispan.configuration.cache.ConfigurationBuilder
import org.infinispan.configuration.global.GlobalConfigurationBuilder
import org.infinispan.configuration.parsing.ConfigurationBuilderHolder
import org.infinispan.manager.DefaultCacheManager
import org.infinispan.manager.EmbeddedCacheManager
import org.infinispan.marshaller.protostuff.ProtostuffMarshaller
import org.infinispan.transaction.LockingMode
import org.infinispan.transaction.TransactionMode
import org.infinispan.util.concurrent.IsolationLevel
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.env.Environment
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.util.concurrent.TimeUnit


@Configuration
class CacheConfig(
    val environment: Environment
) {
    // 캐시를 총괄하는 매지저 빈 생성
    @Bean("cacheManager")
    fun cacheManager(): EmbeddedCacheManager {

        val global = GlobalConfigurationBuilder()
            .transport().defaultTransport()
            .clusterName("foobar-api-${environment.getProperty("SPRING_PROFILES_ACTIVE") ?: "local"}")
            .defaultCacheName("default-cache")

        global.serialization().marshaller(ProtostuffMarshaller())

        return DefaultCacheManager(ConfigurationBuilderHolder(Thread.currentThread().contextClassLoader, global), true)
    }

    // 로컬 캐시 빈 생성
    @Bean("sseEmitterCache")
    fun sseEmitterCache(@Qualifier("cacheManager") cacheManager: EmbeddedCacheManager): Cache<String, SseEmitter> {

        val config = ConfigurationBuilder().apply {
            // 로컬 캐시의 만료 시간을 설정
            // 아래 설명할 EventStream의 만료 시간과 동일하게 설정할 것
            this.expiration().lifespan(10, TimeUnit.MINUTES)
            this.clustering().cacheMode(CacheMode.LOCAL)
            this.locking()
                .isolationLevel(IsolationLevel.READ_COMMITTED)
                .useLockStriping(false)
                .lockAcquisitionTimeout(10, TimeUnit.SECONDS)
            this.transaction()
                .lockingMode(LockingMode.OPTIMISTIC)
                .transactionMode(TransactionMode.NON_TRANSACTIONAL)
        }
        cacheManager.defineConfiguration("sse-emitter-cache", config.build())

        return cacheManager.getCache("sse-emitter-cache")
    }
    
    // 전송할 Event 데이터를 저장할 분신 캐시 빈 생성
    @Bean("sseEventCache")
    fun sseEventCache(@Qualifier("cacheManager") cacheManager: EmbeddedCacheManager): Cache<String, String> {

        val config = ConfigurationBuilder().apply {
            this.expiration().lifespan(1, TimeUnit.MINUTES)
            this.clustering().cacheMode(CacheMode.REPL_ASYNC)
            this.locking()
                .isolationLevel(IsolationLevel.READ_COMMITTED)
                .useLockStriping(false)
                .lockAcquisitionTimeout(10, TimeUnit.SECONDS)
            this.transaction()
                .lockingMode(LockingMode.OPTIMISTIC)
                .transactionMode(TransactionMode.NON_TRANSACTIONAL)
        }
        cacheManager.defineConfiguration("sse-event-cache", config.build())

        return cacheManager.getCache("sse-event-cache")
    }
}
  • EventStream의 만료 시간을 너무 길게 설정하는 것은 서버 입장에서 좋은 방법이 아니다. 운영 레벨에서 긴 수명을 가진 EventStream이 차지하는 커넥션과 쓰레드는 잠재적인 퍼포먼스 저하 요소가 될 수 있다. 또한, 서버 앞단의 로드 밸런서도 최대 연결 시간 설정에 제한이 있기 때문에 비지니스 로직을 고려하여 적절한 만료 시간을 정해야 한다. 이번 예제에서는 10분으로 설정했다.
  • 아래 설명할 내용이지만, 서버에서 연결을 만료해도 클라이언트의 브라우저 레벨에서 자동으로 EventStream의 새로운 생성을 요청하기 때문에 큰 흐름에서 EventStream은 유지된다고 생각해도 된다.

서버 코드 작성

  • EventStream의 생성은 최초 클라이언트 요청으로 발생한다. EventStream이 생성되면 서버는 원하는 시점에 n개의 EventStreamEvent 데이터를 전송할 수 있다. 아래는 EventStream의 생성과 Event의 전송 방법을 컨트롤러에 작성한 예이다. (편의상 HTTP 메서드는 GET으로 통일하였다.)
package com.jsonobject.example

import com.devskiller.friendly_id.FriendlyId
import org.infinispan.Cache
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter

@RestController
class SseController(
    // 생성된 EventStream 목록을 저장하기 위한 로컬 캐시
    val sseEmitterCache: Cache<String, SseEmitter>,
    // 전송된 Event 목록을 임시 저장하기 위한 분산 캐시
    val sseEventCache: Cache<String, String>
) {
    // 클라이언트의 요청으로 특정 userId에 대한 EventStream 생성
    @GetMapping("/create-event-stream-by-user-id")
    fun createEventStream(
        @RequestParam userId: String,
        // 클라이언트가 마지막으로 수신한 Last-Event-ID 획득
        @RequestHeader("Last-Event-ID", required = false, defaultValue = "") lastEventId: String
    ): SseEmitter {
    
        // 리버스 프록시에서의 오동작을 방지
        response.addHeader("X-Accel-Buffering", "no")

        // EventStream 생성 후 10분 경과시 제거
        // 클라이언트는 연결 종료 인지 후 EventStream 자동 재생성 요청
        val sseEmitter = SseEmitter(10 * 60 * 1000)

        // 로컬 캐시에 생성된 EventStream 저장, 동일한 타임아웃 생성
        sseEmitterCache["${userId}_${System.currentTimeMillis()}"] = sseEmitter

        // 첫 생성시 더미 Event 전송
        // 503 Service Unavailable 오류 응답 예방
        val event = SseEmitter
            .event()
            // 각 Event를 식별할 ID 문자열을 입력
            // Last-Event-ID로 사용되므로 현재 시간을 적용
            .id("${userId}_${System.currentTimeMillis()}")
            // 클라이언트와 사전에 약속된 Type 문자열을 입력
            .name("sse")
            // 실제 클라이언트에 전송할 Data 문자열을 입력
            // 문자열이 아닌 객체를 저장할 경우 전송 즉시 EventStream 연결이 종료됨에 유의
            .data("EventStream Created. [userId=$userId]")
        sseEmitter.send(event)
            
        // 클라이언트가 미수신한 Event 목록이 존재할 경우 전송하여 Event 유실을 예방
        if (lastEventId.isNotBlank()) {
            sseEventCache.filterKeys { it.startsWith("${userId}_") && it > lastEventId }.forEach {
                val unsentEvent = SseEmitter
                    .event()
                    .id(it.key)
                    .name("sse")
                    .data(it.value)
                sseEmitter.send(unsentEvent)
            }
        }

        return sseEmitter
    }

    // 특정 userId로 생성된 모든 EventStream에 Event 전송
    @GetMapping("/create-event-by-user-id")
    fun createEvent(@RequestParam userId: String): String {
    
        val eventId = "${userId}_${System.currentTimeMillis()}"
        val eventData = "Event Pushed. [userId=$userId]"
        // 분산 캐시에 Event 저장
        sseEventCache[eventId] = eventData

        // 로컬 캐시에서 특정 userId에 해당하는 Map<String, SseEmitter> 객체를 획득
        sseEmitterCache.filterKeys { it.startsWith("${userId}_") }.forEach {
            it.value?.let { sseEmitter ->
                val event = SseEmitter
                    .event()
                    // 클라이언트와 사전에 약속된 Type 문자열을 입력
                    .name("sse")
                    .id(eventId)
                    // 실제 클라이언트에 전송할 Data 문자열을 입력
                    .data(eventData)
                try {
                    // 클라이언트에 보낼 데이터를 담은 Event 전송
                    sseEmitter.send(event)
                } catch (ex: IOException) {
                    // 로컬 캐시에서 연결 종료된 SseEmitter 제거
                    sseEmitterCache.remove(it.key)
                } catch (ex: java.lang.IllegalStateException) {
                    // 로컬 캐시에서 기간 만료된 SseEmitter 제거
                    sseEmitterCache.remove(it.key)
                }
            }
        }

        return "OK"
    }
}
  • Event 전송시 발생할 수 있는 예외는 아래 트러블슈팅에 정리했다. 대부분의 경우 EventStream이 유효하지 않은 경우이므로 해당 객체를 로컬 캐시에서 제거하는 등의 적절한 조치가 필요하다.

클라이언트 코드 작성

  • 아래는 클라이언트에서 서버에 EventStream 생성을 요청하고 Event 데이터를 수신하는 예이다.
<!DOCTYPE html>
<html lang="ko">
   <head>
      <meta charset="utf-8">
   </head>
   <body>
      <script>
          const eventSource = new EventSource('http://localhost:8080/create-event-stream-by-user-id?userId=foobar')
          console.log(eventSource)
          eventSource.addEventListener("sse", function(event) {
               console.log(event.data)
          })
      </script>
   </body>
</html>
  • 클라이언트의 요청으로 생성된 EventStream은 서버에서 연결을 종료(서버에서의 EventStream 타임아웃 만료, 서버 재시작 등)하지 않는 한 계속 유지된다. 만약, 언급한 이유로 서버로부터 연결이 종료된다면 브라우저 레벨에서 자동으로 새로운 EventStream의 생성을 시도하며, 서버가 정상적인 상태라면 새롭게 생성된 EventStream은 다시 유효하게 작동하게 된다. (다만, 클라이언트에서 연결 종료 인지 후 재생성 요청 과정에서 서버가 전송한 Event는 유실된다. 해결책은 아래 트러블슈팅에 정리했다.)

SSE 통신 작동 확인

  • 아래는 실제로 SSE 통신이 작동하는지 확인하기 위해 패킷을 관찰한 예이다. 클라이언트의 EventStream 생성 요청에 대해 서버로부터 응답이 온 것을 캡쳐한 것이다.
0000   18 00 00 00 60 09 44 dd 01 f8 06 80 00 00 00 00   ....`.D.........
0010   00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00   ................
0020   00 00 00 00 00 00 00 00 00 00 00 01 43 8d 1f 90   ............C...
0030   9d fe 24 95 d2 3e 6c 9c 50 18 27 f6 ce 4a 00 00   ..$..>l.P.'..J..
0040   47 45 54 20 2f 63 72 65 61 74 65 2d 65 76 65 6e   GET /create-even
0050   74 2d 73 74 72 65 61 6d 2d 62 79 2d 75 73 65 72   t-stream-by-user
0060   2d 69 64 3f 75 73 65 72 49 64 3d 66 6f 6f 62 61   -id?userId=fooba
0070   72 20 48 54 54 50 2f 31 2e 31 0d 0a 48 6f 73 74   r HTTP/1.1..Host
0080   3a 20 6c 6f 63 61 6c 68 6f 73 74 3a 38 30 38 30   : localhost:8080
0090   0d 0a 43 6f 6e 6e 65 63 74 69 6f 6e 3a 20 6b 65   ..Connection: ke
00a0   65 70 2d 61 6c 69 76 65 0d 0a 41 63 63 65 70 74   ep-alive..Accept
00b0   3a 20 74 65 78 74 2f 65 76 65 6e 74 2d 73 74 72   : text/event-str
00c0   65 61 6d 0d 0a 43 61 63 68 65 2d 43 6f 6e 74 72   eam..Cache-Contr
00d0   6f 6c 3a 20 6e 6f 2d 63 61 63 68 65 0d 0a 55 73   ol: no-cache..Us
00e0   65 72 2d 41 67 65 6e 74 3a 20 4d 6f 7a 69 6c 6c   er-Agent: Mozill
00f0   61 2f 35 2e 30 20 28 57 69 6e 64 6f 77 73 20 4e   a/5.0 (Windows N
0100   54 20 31 30 2e 30 3b 20 57 69 6e 36 34 3b 20 78   T 10.0; Win64; x
0110   36 34 29 20 41 70 70 6c 65 57 65 62 4b 69 74 2f   64) AppleWebKit/
0120   35 33 37 2e 33 36 20 28 4b 48 54 4d 4c 2c 20 6c   537.36 (KHTML, l
0130   69 6b 65 20 47 65 63 6b 6f 29 20 43 68 72 6f 6d   ike Gecko) Chrom
0140   65 2f 38 36 2e 30 2e 34 32 34 30 2e 31 39 38 20   e/86.0.4240.198 
0150   53 61 66 61 72 69 2f 35 33 37 2e 33 36 0d 0a 4f   Safari/537.36..O
0160   72 69 67 69 6e 3a 20 6e 75 6c 6c 0d 0a 53 65 63   rigin: null..Sec
0170   2d 46 65 74 63 68 2d 53 69 74 65 3a 20 63 72 6f   -Fetch-Site: cro
0180   73 73 2d 73 69 74 65 0d 0a 53 65 63 2d 46 65 74   ss-site..Sec-Fet
0190   63 68 2d 4d 6f 64 65 3a 20 63 6f 72 73 0d 0a 53   ch-Mode: cors..S
01a0   65 63 2d 46 65 74 63 68 2d 44 65 73 74 3a 20 65   ec-Fetch-Dest: e
01b0   6d 70 74 79 0d 0a 41 63 63 65 70 74 2d 45 6e 63   mpty..Accept-Enc
01c0   6f 64 69 6e 67 3a 20 67 7a 69 70 2c 20 64 65 66   oding: gzip, def
01d0   6c 61 74 65 2c 20 62 72 0d 0a 41 63 63 65 70 74   late, br..Accept
01e0   2d 4c 61 6e 67 75 61 67 65 3a 20 6b 6f 2d 4b 52   -Language: ko-KR
01f0   2c 6b 6f 3b 71 3d 30 2e 39 2c 6a 61 3b 71 3d 30   ,ko;q=0.9,ja;q=0
0200   2e 38 2c 65 6e 2d 55 53 3b 71 3d 30 2e 37 2c 65   .8,en-US;q=0.7,e
0210   6e 3b 71 3d 30 2e 36 2c 66 72 3b 71 3d 30 2e 35   n;q=0.6,fr;q=0.5
0220   0d 0a 0d 0a
  • 아래는 EventStream 생성 후, 서버에서 Event를 전송한 것을 캡쳐한 것이다.
0000   18 00 00 00 60 01 ff 25 01 53 06 80 00 00 00 00   ....`..%.S......
0010   00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00   ................
0020   00 00 00 00 00 00 00 00 00 00 00 01 1f 90 43 8d   ..............C.
0030   d2 3e 6c 9c 9d fe 26 79 50 18 27 f6 cd 73 00 00   .>l...&yP.'..s..
0040   48 54 54 50 2f 31 2e 31 20 32 30 30 20 0d 0a 56   HTTP/1.1 200 ..V
0050   61 72 79 3a 20 4f 72 69 67 69 6e 0d 0a 56 61 72   ary: Origin..Var
0060   79 3a 20 41 63 63 65 73 73 2d 43 6f 6e 74 72 6f   y: Access-Contro
0070   6c 2d 52 65 71 75 65 73 74 2d 4d 65 74 68 6f 64   l-Request-Method
0080   0d 0a 56 61 72 79 3a 20 41 63 63 65 73 73 2d 43   ..Vary: Access-C
0090   6f 6e 74 72 6f 6c 2d 52 65 71 75 65 73 74 2d 48   ontrol-Request-H
00a0   65 61 64 65 72 73 0d 0a 41 63 63 65 73 73 2d 43   eaders..Access-C
00b0   6f 6e 74 72 6f 6c 2d 41 6c 6c 6f 77 2d 4f 72 69   ontrol-Allow-Ori
00c0   67 69 6e 3a 20 2a 0d 0a 43 6f 6e 74 65 6e 74 2d   gin: *..Content-
00d0   54 79 70 65 3a 20 74 65 78 74 2f 65 76 65 6e 74   Type: text/event
00e0   2d 73 74 72 65 61 6d 0d 0a 54 72 61 6e 73 66 65   -stream..Transfe
00f0   72 2d 45 6e 63 6f 64 69 6e 67 3a 20 63 68 75 6e   r-Encoding: chun
0100   6b 65 64 0d 0a 44 61 74 65 3a 20 46 72 69 2c 20   ked..Date: Fri, 
0110   30 35 20 4d 61 72 20 32 30 32 31 20 30 37 3a 33   05 Mar 2021 07:3
0120   38 3a 34 35 20 47 4d 54 0d 0a 4b 65 65 70 2d 41   8:45 GMT..Keep-A
0130   6c 69 76 65 3a 20 74 69 6d 65 6f 75 74 3d 36 30   live: timeout=60
0140   0d 0a 43 6f 6e 6e 65 63 74 69 6f 6e 3a 20 6b 65   ..Connection: ke
0150   65 70 2d 61 6c 69 76 65 0d 0a 0d 0a 31 64 0d 0a   ep-alive....1d..
0160   69 64 3a 66 6f 6f 62 61 72 5f 31 36 31 34 39 32   id:foobar_161492
0170   39 39 32 35 35 36 31 0a 64 61 74 61 3a 0d 0a      9925561.data:..

0000   18 00 00 00 60 01 ff 25 00 3e 06 80 00 00 00 00   ....`..%.>......
0010   00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00   ................
0020   00 00 00 00 00 00 00 00 00 00 00 01 1f 90 43 8d   ..............C.
0030   d2 3e 6d db 9d fe 26 79 50 18 27 f6 25 e6 00 00   .>m...&yP.'.%...
0040   32 34 0d 0a 45 76 65 6e 74 53 74 72 65 61 6d 20   24..EventStream 
0050   43 72 65 61 74 65 64 2e 20 5b 75 73 65 72 49 64   Created. [userId
0060   3d 66 6f 6f 62 61 72 5d 0d 0a                     =foobar]..

트러블슈팅: 503 Service Unavailable

  • 클라이언트에서 EventSteam 생성 후 서버에서 만료 시간이 경과되었을 경우 자동으로 재생성 요청을 하지 않고 503 Service Unavailable 응답과 함께 연결이 종료되는 경우가 있다. 원인은 최초 생성 후 만료까지 서버에서 단 1개의 Event도 전송하지 않았을 경우 발생한다. 해결책은 앞서 설명한 예제에서와 같이 SseEmitter 객체를 생성하고 응답하는 과정에서 Event도 함께 전송시키면 된다.

트러블슈팅: java.io.IOException

  • 서버에서 SseEmitter 객체의 send() 메서드 실행시 아래 예외가 발생하는 경우가 있다. 원인은 클라이언트에서 브라우저 새로 고침 또는 브라우저 종료 등의 행위로 EventStream의 연결이 종료된 것이다. 특별히 문제될 것은 없는 정상적인 상황이며 로컬 캐시에서 해당 SseEmitter 객체를 제거해주는 조치를 취하면 된다.
org.apache.catalina.connector.ClientAbortException: java.io.IOException: Connection reset by peer

트러블슈팅: java.lang.IllegalStateException

  • 서버에서 SseEmitter 객체의 send() 메서드 실행시 아래 예외가 발생하는 경우가 있다. 원인은 기간 만료 등의 이유로 EventStream의 연결이 종료된 것이다. 특별히 문제될 것은 없는 정상적인 상황이며 로컬 캐시에서 해당 SseEmitter 객체를 제거해주는 조치를 취하면 된다.
java.lang.IllegalStateException: ResponseBodyEmitter has already completed

트러블슈팅: org.springframework…AsyncRequestTimeoutException

  • 서버에서 SseEmitter 객체가 지정한 타임아웃이 도래하여 만료되면 아래 예외가 발생한다. 특별히 문제될 것은 없는 정상적인 상황으로 무시하면 된다.
org.springframework.web.context.request.async.AsyncRequestTimeoutException: Async request timed out

트러블슈팅: EventSteam 재생성시 서버 전송 데이터 유실

  • 특정 EventStream이 어떤 이유로 서버에서 만료되어 클라이언트에서 자동으로 재생성을 요청할 경우, 일반적으로 수초가 소요된다. 만약, 클라이언트에서 EventSource 객체 생성을 완료하기 전에 서버에서 Event를 전송할 경우 해당 데이터는 클라이언트에 도착하지 못하고 유실될 수 있다. (인터넷 상의 대다수의 관련 예제가 이 부분을 간과하고 있다.)
  • 해당 이슈에 대한 안전 장치로 클라이언트는 재생성 요청시 요청 헤더에 Last-Event-ID를 추가하여 마지막으로 수신한 데이터가 무엇인지 서버에 알리도록 스펙이 설계되어 있다. 본 예제에서도 앞서 이 부분을 다루었다. [관련 링크]

트러블슈팅: Event 전송 즉시 EventSteam 연결 종료

  • SseEventBuilder 객체의 .data() 메서드를 이용하여 전송할 이벤트 데이터를 저장할 때 원본 객체를 문자열로 마샬링하지 않고 그대로 저장할 경우 EventSteam 연결이 즉시 종료되는 현상이 확인되었다. 반드시 JSON 등의 온전한 문자열의 형태로 마샬링해야 정상적으로 작동한다.

참고 도서

참고 글

댓글
  • 프로필사진 감사합니다 글 잘읽었습니다. 트러블슈팅: java.io.IOException부분에서 질문이 있습니다
    브라우저 새로고침으로 연결이 끊긴다면 로그인페이지에서 등록된 이벤트소스는
    클라이언트가 다른 페이지로 이동시 사용할 수 없게 되는건가요?
    이런 사용자가 등록 후 다른 페이지로 이동하는 등의 로직에서는 web socket을 이용하는것이 적합할까요?
    양질의 글 작성해주셔서 감사합니다
    2022.05.06 20:07
  • 프로필사진 BlogIcon 지단로보트 감사합니다 님, 브라우저 연결 종료는 신경 쓰지 않아도 됩니다. 이 것이 SSE의 강점인데 어떤 이유로 브라우저에서 연결이 종료되면 브라우저 네이티브 레벨에서 자동으로 재연결을 수행하고, 재연결 과정에서 미처 수신 못한 메시지까지 수신이 가능합니다. 위에 정리한 내용은 서버 입장에서는 연결이 종료된 SseEmitter 객체는 더이상 필요 없으므로 로컬 캐시에서 삭제하면 된다는 의미였습니다. 2022.05.10 13:18 신고
댓글쓰기 폼