프로젝트를 개발하면서 떼 놓을 수 없는 것 중 하나는 바로 알림입니다.
서비스를 이용하는 사용자가 지속적으로, 자주 서비스에 접근하게 만드는 것이 좋기 때문이죠.
고객을 유치할 수 있는 방법 중 가장 손쉽게 접근할 수 있는 것이 바로 알람입니다.
웹에서 알림을 보내려면 Web Push Notification을 이용하거나, 앱이면 인앱(In-app) 알림이나 Firebase를 이용한 푸시 알림 등 그 선택지가 더 다양하기도 합니다.
그러나 한 프로젝트에서 저는 이 반대의 경우를 마주하게 됩니다.
사용자가 데이터를 보내고, 서버(+AI모델)에서 이 데이터를 처리한 후 처리한 데이터를 화면에 보여줘야 하는데
이런 상황을 마주하게 됩니다.
프론트에서 할 수 있는 건, 하염없이 기다리며 일정 시간마다 ( 혹시 일 끝났어? ) 하고 물어보는 방법밖에 없다는 거죠.
이건 양쪽에 여러모로 좋지 않은 영향을 끼치게 됩니다.
프론트에서도 불필요한 연락을 계속 보내야 하고, 서버 측에서도 같은 응답만 반복하는 무한 굴레에 빠지게 됩니다.
이 무한 굴레를 끝내기 위한 방법은 간단합니다.
서버가 작업이 끝나면 클라이언트에게 알려주면 되는 것이죠!
SSE란 무엇인가
SSE(Server-Sent Event)는 서버에서 클라이언트로 단방향 실시간 이벤트를 전송하는 웹 기술입니다.
이를 통해 서버는 업데이트나 알림 등의 사항을 클라이언트에게 전달할 수 있습니다.
SSE는 단방향 통신이기 때문에 서버에서 클라이언트로만 데이터를 전송할 수 있습니다.
클라이언트는 HTTP 프로토콜을 통해 연결을 설정하고, 서버는 HTTP연결을 유지한 상태에서 데이터를 전송합니다.
조금 더 자세한 원리는 아래에서 살펴보기로 하고, 이 녀석이 나오기 전에는 어떤 방법으로 클라이언트와 서버가 소통을 했는지, 그리고 지금은 이 SSE 말고 다른 방식이 있는지 한 번 찾아봅시다.
SSE의 이전에는 뭐가 있었을까
SSE가 나오기 전에는, 가장 첫번째에서 잠깐 이야기가 나왔던 Polling 방식을 사용했습니다.
Q. Polling이라뇨? 전 그런 단어를 본 적이 없는데요? 🤨
흠. 정확한 이름을 알려주지 않았네요.
서버가 (나 작업 완료 됐어!) 라는 응답을 보내주기 전까지 클라이언트에서 하염없이 (작업 끝났어?)를 물어보는 방식이 바로 Polling입니다.
조금 더 전문적으로 말해보자면..
일정 시간마다 클라이언트가 서버로 요청을 보내 데이터 갱신이 있는지 확인하고, 갱신이 있으면 응답을 받는 방식이 Polling입니다.
쉽게 구현할 수 있지만 계속 요청을 하고, 응답을 받아야 하는 과정에서 리소스 낭비가 발생합니다.
그러나 요청하는데 부담이 적고, 요청주기가 길어도 괜찮을만큼 실시간성이 중요하지 않다면. 혹은 데이터 갱신이 특정 주기를 가지고 이루어진다면 SSE 대신 Polling을 사용하는 것도 나쁜 선택지가 아닙니다.
Polling vs Long Polling vs Web socket vs SSE
polling은 뭔지 알았는데, Long Polling이 뭐냐 물으신다면 대답해 드리는 게 인지상정.
Long Polling은 Polling과 비슷하지만 서버가 응답을 바로 보내지 않고 새로운 데이터가 생길 때까지 연결을 유지하는 방식입니다.
클라이언트는 요청을 보내고, 서버는 데이터가 준비될 때까지 응답을 지연시킵니다.
polling보다 불필요한 요청이 줄어 서버 부하가 감소하고, 실시간성이 개선된다는 장점이 있지만
여전히 요청을 반복적으로 보내야 하는 데다 요청이 많아지면 동시 연결 수가 제한되는 서버에서 부담이 커지게 된다는 장점이 있죠.
이것들과 비슷한 것중 가장 유명한 녀석, Web Socket도 다양한 장점이 있습니다.
아까 SSE는 단방향 통신이기 때문에 서버 → 클라이언트로 밖에 연결을 보내지 못한다는 점 기억하시나요?
Web Socket은 SSE와 다르게 양방향 통신을 지원하여 서버 ↔ 클라이언트 간의 지속적인 연결을 유지하며 데이터를 받을 수 있습니다.
그렇기 때문에 클라이언트와 서버가 자유롭게 메시지를 주고받을 수 있다는 것이 큰 장점입니다.
실시간성이 최강이고, 요청을 계속해서 보내지 않아도 되기 때문에 부하가 적죠.
SSE와의 차이점은 통신 방향과, HTTP 프로토콜에서 동작하는 SSE가 좀 더 구현이 쉽다는 차이점이 있습니다.
각기 장단점이 다른 만큼 Web socket은 게임, 채팅, 스트리밍 서비스에 가장 많이 쓰이고, SSE는 서버에서의 일방적 데이터 전송이 필요한 업데이트나, 실시간 알림 서비스 등에 적합합니다.
방식 | 실시간성 | 네트워크 부하 | 구현 난이도 | 양방향 통신 |
Polling | 낮음 | 높음 | 쉬움 | ❌ |
Long Polling | 중간 | 중간 | 보통 | ❌ |
WebSocket | 매우 높음 | 낮음 | 어려움 | ✔️ |
SSE | 높음 | 낮음 | 쉬움 | ❌ (서버→클라이언트) |
SSE의 실행과정
SSE는 단방향 통신답게 서버에서 일방적으로 데이터를 푸시하는 형태입니다.
- 클라이언트가 SSE 연결 생성
- EventSource 객체를 사용해 서버와 연결
- HTTP 요청을 보내며 SSE 스트림을 받을 준비가 되었음을 알림
- 서버가 연결을 수락하고 스트림 유지
- 서버는 HTTP응답의 Content-Type을 text/event-stream으로 설정
- 새로운 이벤트 발생 시 데이터를 클라이언트로 전송
- 클라이언트가 데이터 수신 및 처리
- EventSource는 자동으로 서버에서 받은 데이터 처리
- 받은 데이터를 기반으로 UI 업데이트
- 연결이 유지되며 계속 데이터 전달
- 일반 HTTP요청과 달리 응답이 끝나지 않고 열린 상태 유지
- 새로운 데이터 발생 시 서버에서 즉시 클라이언트로 전송
- 연결이 끊어지면 자동 재연결
- 네트워크 문제 등으로 연결이 끊어져도 자동으로 다시 연결 시도
❓ 만약 서버가 강제로 연결을 닫아버리면?
서버에서 응답을 닫아버리면 SSE 연결이 종료 됩니다.
서버가 연결을 종료하지 않는다면 계속 유지되지만, 서버에서 res.end() 또는 Connection: close를 설정하면 연결이 닫힙니다.
❓ 반대로 클라이언트에서 강제로 연결을 닫아버린다면?
서버는 클라이언트가 종료했다는 것을 인지하지 못하고 여전히 데이터를 전송하는 문제가 발생할 수 있습니다.
이 경우, 일정 시간 후 서버가 연결이 끊긴 것을 감지하고 클라이언트 세션을 정리해야 할 수도 있습니다.
SSE 구현방법
제가 작성했던 코드를 한 번 뜯어보면서 살펴볼 시간입니다. (〃⌒▽⌒〃)ゝ하핫
그냥 어떤 흐름으로 코드가 작성되었는지 훑어본다는 느낌으로 코드를 살펴보겠습니다.
제가 SSE를 사용했던 프로젝트에 대해 간단히 말씀드리자면,
강의를 녹음하여 녹음 파일을 서버에 보내면, AI 모델이 해당 음성 파일에서 어조, 세기, 속도 등을 파악하여 강의의 하이라이트 부분을 파악합니다. 분석이 완료된 이후 음성 파일에 대한 STT와 하이라이트 된 부분 등에 대한 정보를 화면에 표시하는 작업이 필요했습니다.
이때 음성 분석이 완료되고, 실시간으로 서버에서 데이터를 받아 화면에 띄우는 작업을 구현하기 위해 SSE를 사용했습니다.
- 클라이언트
const eventSource = new EventSource(
`${import.meta.env.VITE_API_URL}voice/sse?note_id=${note_id}`
); // notd_id를 키값으로 들고다님 반드시 필요!
console.log("SSE 연결 시도 중...");
// STT 완료 이벤트 처리
eventSource.addEventListener("stt_complete", (event) => {
eventSource.close();
Toast.fire({
icon: "success",
title: "STT분석이 완료되었어요",
});
setSTTStatus("done");
fetchData();
});
// 일반 메시지 처리
eventSource.onmessage = (event) => {
console.log("메시지 수신: ", event.data);
};
// 오류 처리
eventSource.onerror = (event) => {
Toast.fire({
icon: "error",
title: "STT분석에 실패했어요",
});
eventSource.close(); // 연결 종료
};
// 컴포넌트 언마운트 시 SSE 연결 닫기
return () => {
setIsLoading(false);
eventSource.close();
};
코드가 좀 길어 보이지만 괜찮습니다.
핵심 내용은 별 거 없으니까요. 필요 없는 코드는 좀 지워서 올릴까 생각했지만 SSE의 상태를 좀 잘 보여준다 생각해 그대로 나둬보았습니다.
제일 먼저
const eventSource = new EventSource(
`${import.meta.env.VITE_API_URL}voice/sse?note_id=${note_id}`
);
해당 코드를 사용해 서버와 스트리밍을 연결하는 작업을 진행합니다.
연결이 완료된 후
eventSource.addEventListener("stt_complete", (event) => {
eventSource.close();
Toast.fire({
icon: "success",
title: "STT분석이 완료되었어요",
});
setSTTStatus("done");
fetchData();
});
stt_complete라는 이벤트가 오기를 기다리다가, 분석이 완료되었다는 데이터를 받으면 이후 작업을 수행합니다.
eventSource.onmessage = (event) => {
console.log("메시지 수신: ", event.data);
};
해당 코드는 서버에서 전송하는 기본 메시지 이벤트(onmessage)를 처리하는 콜백 함수입니다.
stt_complete 외의 다른 이벤트를 받을 때 유용하게 쓰이지만, 저는 이벤트가 단 한 가지밖에 없어 따로 처리를 하지는 않았습니다.🙄
eventSource.onerror = (event) => {
Toast.fire({
icon: "error",
title: "STT분석에 실패했어요",
});
eventSource.close(); // 연결 종료
};
하지만 stt를 진행하다 혹시나 오류가 발생했는데, 처리를 해주지 않는다면 클라이언트는 하염없이 UI가 업데이트되기를 기다리겠지요.
이런 상황을 막기 위해 error이벤트가 발생했을 때 토스트 메시지를 띄워 사용자가 문제를 인식하도록 구현했습니다.
아쉬운 부분
return () => {
setIsLoading(false);
eventSource.close();
};
마지막으로 언마운트시 SSE를 종료하는 코드입니다.
열어둔다면 새로운 이벤트를 계속 받을 수 있지만, 해당 서비스에서는 1회 이상으로 작업이 일어나지 않기 때문에 이벤트를 받고 바로 종료가 되도록 설정했습니다.
그러나 아까 실행과정 하단에 적어뒀던 것처럼,
만약 클라이언트 측에서 SSE연결을 닫아버린다면 서버는 이를 알지 못하고 데이터를 계속해서 보내는 상황이 발생할 수 있습니다.
해당 서비스에서는 1회 연결 후 종료기 때문에 별다른 문제가 발생하지는 않았지만,
eventSource.onerror = function () {
console.log("서버가 SSE 연결을 종료했습니다.");
};
이와 같이 연결 종료는 서버가 진행하고,
클라이언트 측에서는 연결 종료를 감지하면 그때 종료하는 형태로 갔으면 조금 더 좋지 않았을까 하는 생각이 듭니다.🥺
- 서버
자, 항상 보여드리기 난감한 백엔드 서버 코드입니다.
이 코드는 제가 아닌 백엔드 팀원이 고생해 준 코드라는 것을 명시하며 짧게 살펴보겠습니다.
@GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamSTT(@RequestParam("note_id") long noteId) {
if (emitters.get(noteId) == null) {
SseEmitter emitter = new SseEmitter(30 * 60000L);
emitters.put(noteId, emitter);
emitter.onCompletion(() -> emitters.remove(emitter));
emitter.onTimeout(() -> emitters.remove(emitter));
try {
emitter.send(SseEmitter.event().name("")
);
} catch (IOException e) {
emitter.completeWithError(e);
}
return emitter;
} else {
SseEmitter emitter = emitters.get(noteId);
return emitter;
}
}
이 코드는 클라이언트가 SSE연결을 요청하면 SSE연결을 생성하는 코드입니다.
SseEmitter 객체를 생성하고, note_id를 키로 emitters Map에 저장합니다.
만약 연결이 끊기거나 타임아웃되면 emitters에서 제거하고, SseEmitter.event().name("")을 전송하여 연결이 정상적으로 열렸음을 알리는 역할을 합니다.
if (emitters.get(sttResultRequest.getId()) != null) {
SseEmitter emitter = emitters.get(sttResultRequest.getId());
try {
emitter.send(
SseEmitter
.event()
.name("stt_complete") // 이벤트 이름 설정
.data("STT 정보 수신 완료") // 전송할 데이터
);
emitter.complete(); // SSE 연결 종료
} catch (IOException e) {
emitter.completeWithError(e);
} finally {
// emitters에서 해당 SSE 연결 제거
emitters.remove(sttResultRequest.getId());
emitter = null; // 참조 제거
}
}
이후에 STT 분석이 완료되면 emitters.get(sttResultRequest.getId())를 통해 SSE연결이 되어있는지 확인하고,
stt_complete라는 이벤트를 클라이언트에게 전송하고, SSE 연결을 종료합니다.
회고
오늘도 이렇게 프로젝트 회고를 통해 SSE에 대해 좀 더 알아보았습니다.
SSE에 대해 알고 있다고 생각했지만 글을 정리하며 보니 생각보다 잘 모르고 있었던 것도 있었고,
코드를 살펴보며 리팩토링이 필요한 부분도 찾아보게 되었네요.
특히 해당 프로젝트는 거진 2주..라는 짧은 시간 내에 완성되었던 프로젝트인 만큼
스파게티 코드도 많았고.. 지금 보면 다소 웃긴 로직으로 쓰인 코드도 많을 거라는 생각이 듭니다..ㅋㅋ
기회가 된다면 꼭 리팩토링을 해보고 싶다는 마음을 한편에 품고... 마무리하겠습니다.
출처
'Frontend' 카테고리의 다른 글
[디자인 패턴] MVC / MVP / MVVM 패턴 (0) | 2025.04.16 |
---|---|
주소창에 google.com 입력시 일어나는 일 (0) | 2025.04.10 |
Tanstack Query와 Next.js fetch. 뭘 사용해야 할까? (0) | 2025.04.02 |
프론트엔드를 위한 Presigned URL 업로드 (1) | 2025.02.04 |