머릿말
졸업 작품 프로젝트를 진행하던 중 다른 이용자가 댓글을 작성하거나 좋아요를 누르거나 매칭 요청을 보낼 경우 해당 사항을 실시간으로 알려주는 기능을 구현해야 했다. 이에 따라 실시간 알림 기능이 필요해졌고 통신 방식을 SSE로 정하게 됐다.
SSE가 필요한 이유는?
HTTP 프로토콜의 주요 특징은 비연결성이다.
따라서 위와 같은 경우, Server가 전송하고 싶어도 해당 Client와 지속적으로 연결이 되어있지 않기 때문에 보낼 수 없는 상황이 발생하게 된다.
이를 해결하는 방식으로는 Polling, Long Polling, Websocket, SSE 총 4가지가 존재한다.
Polling
Client가 주기적으로 Server로 요청을 보내는 방식이다.
일정 시간마다 Client가 Server로 요청을 보내 데이터 갱신이 있는지 확인하고, 갱신이 되면 응답을 받는 방식이다.
구현은 단순하지만, 계속 요청을 해야한다는 점에서 리소스 낭비가 발생한다.
구현이 단순하다는 장점이 있기 때문에, 요청하는데 부담이 크지 않고, 실시간성이 중요하지 않고, 또 데이터 갱신이 특정 주기를 갖는다면 해당 방법이 적합할 수 있다.
Long Polling
유지 시간을 조금 더 길게 갖는다는 점에서 Polling과 차이점이 존재한다.
요청을 보내고 서버에서 변경이 일어날 때까지 대기하는 방법이다.
긴 Connection을 갖도록 Request를 보내고, 이 지속 시간 동안 이벤트가 발생하면 그 이벤트에 대한 값을 유지되고 있는 Connection을 통해서 보내면 된다.
Connection이 연결되는 동안은 이벤트 발생을 실시간으로 감지할 수 있어진다. 그리고 지속적으로 요청을 계속 보내지 않기 때문에 Polling 방식보다는 부담이 덜하다.
하지만 유지 시간을 짧게 갖는다면 Polling 방식과 차이점이 없고, 지속적으로 연결되어 있기 때문에 다수의 클라이언트에게 동시에 이벤트가 발생하면 순간적 부담이 급증한다.
Long Polling 방식은 실시간 전달이 중요한데 상태가 빈번하게 갱신되진 않을 때 적합하다.
WebSocket
웹소켓은 HTTP와 같은 프로토콜의 일종으로 양방향 통신을 실현하기 위한 구조이다.
최초 접속은 일반 HTTP 요청을 이용한 handshaking으로 이뤄지는데, HTTP와 같이 연결 후 끊어버리는 것이 아니라 계속적으로 Connection을 지속하므로 연결에 드는 불필요한 비용을 제거할 수 있다.
또한 웹소켓을 활용하면, Http Header를 최초 접속시에만 보내고 더이상 보내지않으므로 리소스면에서 이득을 볼 수 있다. 그리고 웹소켓 포트에 접속해있는 모든 클라이언트에게 이벤트 방식으로 응답할 수 있다.
Server-Sent-Events
SSE는 웹소켓과 달리, Server에서 Client로 단방향으로 실시간 이벤트를 전송하는 웹기술이다.
SSE는 단방향 통신 방식으로 서버에서 클라이언트로 데이터를 전송한다. 이를 통해 서버에서 발생하는 업데이트나 알림 등을 실시간으로 클라이언트에게 전달할 수 있다.
클라이언트는 HTTP 프로토콜을 통해 SSE 연결을 설정하고, 서버는 HTTP 응답을 유지한 상태에서 데이터를 전송한다.
SSE는 재연결 기능을 제공하기 때문에 연결이 끊어졌을 때도 자동으로 다시 연결한다. 이는 기존의 Pollling 방식이랑 비교했을 때 효율적이며, 서버와 클라이언트 간의 불필요한 통신을 최소화한다.
SSE는 이벤트 스트림 형태로 데이터를 전송하며, 클라이언트는 이벤트를 수신하여 처리할 수 있다.
Websocket VS SSE
SSE와 웹소켓의 가장 큰 차이점은 데이터의 흐름이다.
SSE는 서버에서 클라이언트로 데이터를 전송하는 단방향 통신 방식이다. 반면 웹소켓은 양방향 통신을 지원하여 서버와 클라이언트가 양방향으로 데이터를 주고받을 수 있다.
때문에 SSE는 주로 서버에서 클라이언트로 일방적인 데이터 전송이 필요한 주가 업데이트나, 실시간 알림 메시지에 적합하고 웹소켓은 양방향 통신이 필요한 실시간 채팅 등에 사용된다.
SSE는 웹기술이기 때문에 HTTP 프로토콜 위에서 동작한다. 또한, 기존의 HTTP 연결을 유지한 상태에서 재연결이나 추가 설정 없이 서버로부터 지속적인 데이터 스트림을 받을 수 있다.
반면, 웹소켓은 독립적인 프로토콜을 사용하고 HTTP와는 별도의 연결을 만들어 데이터를 주고받는다.
간단한 SSE 구현 방법
Client
useEffect(()=> {
const eventSource = new EventSource("http://localhost:8080/subscribe/1")
eventSource.onopen = async () => {
await console.log("sse opened!")
}
eventSource.addEventListener('like', (event) => {
console.log("like")
const data = JSON.parse(event.data);
console.log(data)
});
eventSource.onerror = async (e) => {
await console.log(e)
}
return () => {
eventSource.close()
}
},[])
Server
@RestController
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
@GetMapping(value = "/subscribe/{user_id}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@PathVariable(value = "user_id") Long userId) {
return notificationService.subscribe(userId);
}
}
@Service
@RequiredArgsConstructor
public class NotificationService {
private final MemberRepository memberRepository;
private final EmitterRepository emitterRepository;
private static final Logger logger = LoggerFactory.getLogger(NotificationService.class);
private static final Long DEFAULT_TIMEOUT = 600L * 1000 * 60;
public SseEmitter subscribe(Long userId) {
SseEmitter emitter = createEmitter(userId);
sendToClient(userId, "EventStream Created. [userId="+ userId + "]", "sse 접속 성공");
return emitter;
}
public <T> void customNotify(Long userId, T data, String comment, String type) {
try{
sendToClient(userId, data, comment, type);
logger.info("Successfully sent notification");
}
catch (Exception e){
logger.error("Failed to send notification");
}
}
public void notify(Long userId, Object data, String comment) {
sendToClient(userId, data, comment);
}
private void sendToClient(Long userId, Object data, String comment) {
SseEmitter emitter = emitterRepository.get(userId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event()
.id(String.valueOf(userId))
.name("sse")
.data(data)
.comment(comment));
} catch (IOException e) {
emitterRepository.deleteById(userId);
emitter.completeWithError(e);
}
}
}
private <T> void sendToClient(Long userId, T data, String comment, String type) {
SseEmitter emitter = emitterRepository.get(userId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event()
.id(String.valueOf(userId))
.name(type)
.data(data)
.comment(comment));
} catch (IOException e) {
emitterRepository.deleteById(userId);
emitter.completeWithError(e);
}
}
}
private SseEmitter createEmitter(Long userId) {
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
emitterRepository.save(userId, emitter);
emitter.onCompletion(() -> emitterRepository.deleteById(userId));
emitter.onTimeout(() -> emitterRepository.deleteById(userId));
return emitter;
}
private Member validUser(Long userId) {
return memberRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("멤버 ID " + userId + "에 해당하는 유저를 찾을 수 없습니다."));
}
}
전체적인 흐름
- 클라이언트에서 SSE 연결 요청을 보낸다.
- 서버에서 클라이언트와 매핑되는 SSE 통신 객체 (SseEmitter)를 만든다.
- 생성자 파라미터로 만료시간을 설정할 수 있다.
- ⇒ 만료 시간을 너무 길게 설정하면 서버에서 불필요한 connection을 관리해주어야 하기 때문에 오버헤 드가 발생하고, 너무 짧으면 재연결 요청이 잦아진다. 따라서, 적절한 시간을 설정해줘야한다.
- 서버에서 이벤트가 발생하면 해당 객체를 통해 클라이언트로 데이터를 전송한다.
- RequestParam으로 전달받은 아티클과 연결된 Emitter들을 모두 가져온다.
- (댓글 관련 기능이라 가정했을 때) 가져온 Emitter들에게 댓글을 전송한다.
Reference
https://velog.io/@alswn9938/SSE란
https://yeo-computerclass.tistory.com/480#SSE%-A%--Server-Sent%--Events