SW 개발/Spring
Spring Boot, SSE(Server-Sent Events)로 단방향 스트리밍 통신 구현하기
지단로보트
2021. 3. 3. 15:04
개요
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 Framework는 4.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개의 EventStream에 Event 데이터를 전송할 수 있다. 아래는 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 등의 온전한 문자열의 형태로 마샬링해야 정상적으로 작동한다.
참고 도서
참고 글
- Understanding Server-Sent Events
- Server-Sent Events in Spring
- MDN Web Docs - Using server-sent events
- Stack Overflow - EventSource permanent auto reconnection
- Stack Overflow - Some of Server Sent events lost while EventSource reconnecting
- Stack Overflow - Server sent event EventStream does not trigger “onmessage” but Chrome Debug shows data in “EventStream” tab