2024.01월부터 웹소켓 기반의 타이핑 게임을 주제로 프로젝트를 진행하게 되었습니다. 프로젝트를 진행하면서 가장 먼저 구현하고자 했던 부분이 실시간으로 생성되고, 삭제되고, 누군가 입장하고, 퇴장하는 게임방을 실시간으로 사용자들에게 보여주는 기능이었고, 이를 어떻게 보여줄지 고민하게 되었습니다.
1. WebSocket
가장 먼저 떠올랐던 방법은 웹소켓입니다. 웹소켓은 실시간 양방향 데이터 전송을 가능하게 하는 통신 프로토콜로 일반적인 HTTP 프로토콜과는 달리 지속적인 연결을 유지하고 서버와 클라이언트 간의 데이터 전송이 빠르게 이루어지기 때문에 실시간 데이터를 받아야 하는 서비스에 어울린다고 생각하게 되었습니다.
2. SSE (Server-Sent Events)
두번째로 떠오른 방법은 SSE입니다. SSE는 서버에서 클라이언트로의 단방향 통신을 지원하는 기술로 보통 실시간 데이터 전송이나 서버에서 발생하는 특정 이벤트에 클라이언트가 즉시 반응해야 하는 경우 SSE 방식을 사용해 데이터를 서버에서 클라이언트로 실시간으로 전송합니다.
구현하려던 기능은 단순히 서버에서 클라이언트로 특정 이벤트(생성, 삭제, 입장, 퇴장 등)가 발생했을 때 실시간 게임방 목록 리스트를 전달하려는 것이 목적이었기 때문에 웹소켓과 같이 양방향 통신이 아닌 SSE와 같은 서버에서 클라이언트로의 단방향 통신이 더욱 어울릴 것 같다고 생각했으며 구현 또한 웹소켓에 비해 단순한 SSE를 사용하기로 결정을 했습니다.
SSE (Server-Sent Events)
그럼 이제 간단하게 SSE에 대해 좀 정리해볼게요. Server-Sent Events라는 말답게 서버에서 클라이언트로 이벤트 발생시 실시간으로 데이터를 전송하는데 사용되는 기술로써 SSE는 클라이언트가 데이터를 주기적으로 요청할 필요가 없고, 서버에서 데이터를 전송할 수 있습니다. HTTP 프로토콜을 기반으로 하며, 실시간 업데이트나 이벤트 알림에 적합합니다.
1. SSE의 특징
- 서버에서 발생하는 이벤트나 데이터 변경 사항을 클라이언트에게 실시간으로 전달할 수 있습니다.
- SSE는 HTTP 프로토콜을 사용하여 서버에서 클라이언트로의 단방향 통신을 지원합니다.
- SSE 연결이 끊어졌을 때 자동으로 재연결을 시도하지만, 클라이언트가 연결을 끊어도 서버에서 감지하기 어렵습니다
2. SSE 통신 흐름
(1) 클라이언트에서 서버의 이벤트 구독을 위한 요청을 보냅니다.
- 이벤트 미디어 타입은 text/event-stream 입니다.
- Cache-Control : no-cache 실시간 이벤트의 경우 캐싱이 필요하지 않아요 ! (오히려 데이터 불일치 발생 가능성이 생김)
GET /connect HTTP/1.1
Accept: text/event-stream
Cache-Control: no-cache
(2) 서버는 클라이언트의 요청에 대한 응답을 보냅니다.
- 응답의 미디어 타입은 text/event-stream 입니다.
- Transfer-Encoding은 HTTP 헤더 중 하나로, 클라이언트와 서버 간 데이터 전송 방식을 결정하는 역할을 합니다. 일반적으로 chuncked 방식을 사용하게 됩니다.
HTTP/1.1 200
Content-Type: text/event-stream;charset=UTF-8
Transfer-Encoding: chunked
(3) 서버에서 클라이언트로의 이벤트 전달
- 클라이언트에서 구독을 하고 나면, 서버는 해당 클라이언트에게 비동기적으로 데이터를 전송할 수 있고 데이터는 UTF-8로 인코딩 된 텍스트 데이터만 가능합니다.
SpringFramework에서 SSE 적용해보기
Spring framework 4.2부터 SSE 통신을 지원하는 SseEmitter API를 제공하기 때문에 이를 사용해 SSE 구독 요청에 대한 응답을 할 수 있습니다.
@Tag(name = "SSE", description = "SSE(Server-Side-Event) API 입니다.")
@RestController
@RequiredArgsConstructor
public class SseController {
private static final Long EMITTER_EXPIRATION_TIME = 60 * 1000L;
private final SseEmitters sseEmitters;
private final SseService sseService;
@Operation(summary = "SSE: 게임방 목록 받아오기")
@GetMapping(value = "/api/v1/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect() {
// Emitter 객체 생성 & 5분 시간 설정
SseEmitter emitter = new SseEmitter(EMITTER_EXPIRATION_TIME);
sseEmitters.addEmitter(emitter);
// 2번째 인자로 게임방 리스트 넘겨주면 된다.
List<GameRoomGetResponse> gameRooms = sseService.getRooms();
sseEmitters.connect(emitter, gameRooms);
// 타임아웃 발생시 콜백 등록
emitter.onTimeout(emitter::complete);
// 에러 발생시 처리
emitter.onError(throwable -> emitter.complete());
// 타임아웃 발생시 브라우저에 재요청 연결 보내는데, 이때 새로운 객체 다시 생성하므로 기존의 Emitter 객체 리스트에서 삭제
emitter.onCompletion(() -> sseEmitters.remove(emitter));
return emitter;
}
}
생성자를 통해 만료 시간을 따로 설정하여 SseEmitter객체를 생성하고, 해당 객체는 이벤트가 발생했을 때 클라이언트로 이벤트를 전송하기 위해 사용되기 때문에 서버에서 따로 저장 및 관리를 해주어야 합니다.
Emitter 생성후 만료시간까지 데이터를 보내지 않으면, 재연결 요청시 503 Service Unavailable 에러가 발생할 수 있기 때문에 처음 SSE 연결 시 더미 이벤트를 전송하는 것이 좋다고 합니다.
public void connect(SseEmitter emitter, Object roomInfo) {
try {
emitter.send(SseEmitter.event()
.name("connect")
.data(roomInfo));
} catch (IOException e) {
handleEmitterError(emitter);
}
}
public void updateGameRoom(Object roomInfo) {
log.warn(">>>>>> SSE Emitter Change GameRoom");
for (SseEmitter emitter : emitters) {
CompletableFuture.runAsync(() -> {
try {
emitter.send(SseEmitter.event()
.name("changeGameRoom")
.data(roomInfo));
} catch (IOException e) {
handleEmitterError(emitter);
}
}, executorService);
}
}
이벤트 이름을 설정해주면, 클라이언트에서 설정한 이름으로 이벤트를 받을 수 있습니다.
또한 시간이 만료되었을 때는 onTimeout()이나, 작업이 만료되었을 때 onCompletion() 같은 콜백을 등록하여 Emitter 객체를 삭제 및 관리할 수 있습니다.
emitter.onTimeout(emitter::complete);
// 에러 발생시 처리
emitter.onError(throwable -> emitter.complete());
// 타임아웃 발생시 브라우저에 재요청 연결 보내는데, 이때 새로운 객체 다시 생성하므로 기존의 Emitter 객체 리스트에서 삭제
emitter.onCompletion(() -> sseEmitters.remove(emitter));
주의할 점
1. SseEmitter는 기본적으로 다중 스레드 환경에서 작동하고, 다중 스레드를 사용할 때 동시성 문제가 발생하지 않게 하기 위해 CopyOnWriteArrayList나 ConcurrentHashMap 등의 Thread-Safe한 자료구조를 사용하는 것이 좋습니다.
2. SSE 통신을 하는 동안 HTTP Connection이 계속 열려있기 때문에 만약 SSE 연결 응답 API에서 JPA를 사용하고 open-in-view 속성을 true로 설정했다면, HTTP Connection이 열려있는 동안 DB Connection도 같이 열려있게 되어 Connection 고갈 문제가 발생할 수 있습니다. 즉 DB Connection Pool에서 최대 10개의 Connection을 사용할 수 있다면, 10명의 클라이언트가 SSE 연결 요청을 하는 순간 DB 커넥션도 고갈되게 되기 때문에 이런 경우 open-in-view 설정을 반드시 false로 설정해야 합니다.
<< 참고 자료 >>
Spring에서 Server-Sent-Events 구현하기
…
tecoble.techcourse.co.kr
Spring으로 보는 SSE(Server-Sent Events)
SSE 통신의 개념과 Spring Framework에서의 사용법
pandamun.github.io