Server-Sent Events (SSE) 기본 개념
SSE란?
- 단방향 실시간 통신: 서버 → 클라이언트로만 데이터 전송
- HTTP 연결 유지: 1회 요청 후 연결을 끊지 않고 계속 응답
- WebSocket 대안: 단순한 실시간 데이터 전송에 적합
일반 HTTP vs SSE
| 구분 | 일반 HTTP | Server-Sent Events |
|---|
| 연결 방식 | 요청 → 응답 → 종료 | 요청 → 응답 지속 |
| 데이터 전송 | 1회성 | 지속적 |
| 사용 사례 | 일반 웹페이지 | 실시간 알림, 피드 |
서버와 클라이언트 간 주고받는 부가 정보
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
Accept-Language: ko-KR,ko;q=0.9,en;q=0.8
Cookie: sessionId=abc123
Content-Type: application/json
Content-Type: text/html
Set-Cookie: sessionId=abc123
Cache-Control: no-cache
Connection: keep-alive
- Chrome 개발자도구 → Network 탭
- 페이지 새로고침
- 요청 클릭 → Headers 탭 확인
SSE 서버 구현
기본 SSE 서버
app.get('/stream/list', (요청, 응답) => {
응답.writeHead(200, {
"Connection": "keep-alive",
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
});
응답.write('event: msg\n');
응답.write('data: 안녕하세요\n\n');
});
SSE 데이터 형식
event: 이벤트명
data: 전송할데이터
event: notification
data: {"message": "새 글이 등록되었습니다", "count": 5}
중요 규칙:
event: 와 data: 왼쪽에 띄어쓰기 금지
- 각 줄 끝에
\n 추가
- 데이터 끝에
\n\n (빈 줄)로 구분
SSE 클라이언트 구현
기본 클라이언트
<script>
let eventSource = new EventSource('/stream/list')
eventSource.addEventListener('msg', function(e) {
console.log('받은 데이터:', e.data)
})
eventSource.onerror = function(e) {
console.log('SSE 연결 에러:', e)
}
</script>
JSON 데이터 처리
eventSource.addEventListener('notification', function(e) {
let data = JSON.parse(e.data)
console.log('메시지:', data.message)
console.log('개수:', data.count)
})
MongoDB Change Stream
Change Stream이란?
- 실시간 DB 모니터링: 컬렉션의 변경사항을 실시간 감지
- 이벤트 기반: Insert, Update, Delete 감지 가능
- 조건부 감시: 특정 조건의 변경사항만 감지
기본 사용법
const changeStream = db.collection('post').watch()
changeStream.on('change', (result) => {
console.log('DB 변경 감지:', result)
})
조건부 감시
let 조건 = [
{ $match: { operationType: 'insert' } }
]
const changeStream = db.collection('post').watch(조건)
changeStream.on('change', (result) => {
console.log('새 문서 추가:', result.fullDocument)
})
주요 연산 타입
| operationType | 설명 | 사용 예시 |
|---|
insert | 문서 생성 | 새 글 등록 |
update | 문서 수정 | 글 수정 |
delete | 문서 삭제 | 글 삭제 |
replace | 문서 교체 | 전체 내용 변경 |
특정 필드 조건
let 조건 = [
{ $match: { 'fullDocument.name': 123 } }
]
let 조건 = [
{
$match: {
operationType: 'insert',
'fullDocument.title': { $regex: '공지' }
}
}
]
실시간 게시물 알림 구현
서버: 새 게시물 감지 및 전송
app.get('/stream/post', (요청, 응답) => {
응답.writeHead(200, {
"Connection": "keep-alive",
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
})
const 조건 = [
{ $match: { operationType: 'insert' } }
]
let changeStream = db.collection('post').watch(조건)
changeStream.on('change', (result) => {
console.log('새 게시물 등록됨')
응답.write('event: newPost\n')
응답.write(`data: ${JSON.stringify(result.fullDocument)}\n\n`)
})
요청.on('close', () => {
changeStream.close()
})
})
클라이언트: 실시간 게시물 표시
<div class="white-bg">
</div>
<script>
let eventSource = new EventSource('/stream/post')
eventSource.addEventListener('newPost', function(e) {
console.log('새 게시물 수신:', e.data)
let 새게시물 = JSON.parse(e.data)
let newPostHTML = `
<div class="list-box new-post">
<h4>${새게시물.title}</h4>
<p>${새게시물.content}</p>
<small>방금 전</small>
</div>
`
document.querySelector('.white-bg')
.insertAdjacentHTML('afterbegin', newPostHTML)
setTimeout(() => {
document.querySelector('.new-post').classList.remove('new-post')
}, 3000)
})
window.addEventListener('beforeunload', () => {
eventSource.close()
})
</script>
CSS 애니메이션 추가
.new-post {
animation: slideIn 0.5s ease-out;
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
성능 최적화
1. Change Stream 연결 관리
let changeStream
connectDB.then((client) => {
console.log('DB연결성공')
db = client.db('forum')
changeStream = db.collection('post').watch([
{ $match: { operationType: 'insert' } }
])
server.listen(process.env.PORT, () => {
console.log('서버 실행중')
})
}).catch((err) => {
console.log(err)
})
app.get('/stream/post', (요청, 응답) => {
응답.writeHead(200, {
"Connection": "keep-alive",
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
})
const listener = (result) => {
응답.write('event: newPost\n')
응답.write(`data: ${JSON.stringify(result.fullDocument)}\n\n`)
}
changeStream.on('change', listener)
요청.on('close', () => {
changeStream.off('change', listener)
})
})
2. 메모리 누수 방지
app.get('/stream/post', (요청, 응답) => {
const timeout = setTimeout(() => {
응답.end()
}, 30 * 60 * 1000)
요청.on('close', () => {
clearTimeout(timeout)
changeStream.off('change', listener)
})
})
고급 활용 예시
1. 사용자별 알림
app.get('/stream/notifications/:userId', (요청, 응답) => {
응답.writeHead(200, {
"Connection": "keep-alive",
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
})
const userId = 요청.params.userId
const 조건 = [
{
$match: {
operationType: 'insert',
'fullDocument.targetUser': userId
}
}
]
let changeStream = db.collection('notifications').watch(조건)
changeStream.on('change', (result) => {
응답.write('event: notification\n')
응답.write(`data: ${JSON.stringify(result.fullDocument)}\n\n`)
})
})
2. 실시간 댓글 알림
app.get('/stream/comments/:postId', (요청, 응답) => {
응답.writeHead(200, {
"Connection": "keep-alive",
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
})
const postId = 요청.params.postId
const 조건 = [
{
$match: {
operationType: 'insert',
'fullDocument.parentId': new ObjectId(postId)
}
}
]
let changeStream = db.collection('comments').watch(조건)
changeStream.on('change', (result) => {
응답.write('event: newComment\n')
응답.write(`data: ${JSON.stringify(result.fullDocument)}\n\n`)
})
})
3. 실시간 통계
app.get('/stream/stats', (요청, 응답) => {
응답.writeHead(200, {
"Connection": "keep-alive",
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
})
const sendStats = async () => {
let stats = {
totalPosts: await db.collection('post').countDocuments(),
totalUsers: await db.collection('users').countDocuments(),
timestamp: new Date()
}
응답.write('event: stats\n')
응답.write(`data: ${JSON.stringify(stats)}\n\n`)
}
sendStats()
const interval = setInterval(sendStats, 30000)
요청.on('close', () => {
clearInterval(interval)
})
})
에러 처리 및 디버깅
서버 에러 처리
app.get('/stream/post', (요청, 응답) => {
try {
응답.writeHead(200, {
"Connection": "keep-alive",
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
})
const changeStream = db.collection('post').watch([
{ $match: { operationType: 'insert' } }
])
changeStream.on('change', (result) => {
try {
응답.write('event: newPost\n')
응답.write(`data: ${JSON.stringify(result.fullDocument)}\n\n`)
} catch (writeError) {
console.error('SSE 데이터 전송 오류:', writeError)
}
})
changeStream.on('error', (error) => {
console.error('Change Stream 오류:', error)
응답.end()
})
} catch (error) {
console.error('SSE 초기화 오류:', error)
응답.status(500).end()
}
})
클라이언트 에러 처리
let eventSource = new EventSource('/stream/post')
eventSource.onopen = function(e) {
console.log('SSE 연결 성공')
}
eventSource.onerror = function(e) {
console.error('SSE 연결 오류:', e)
if (e.readyState === EventSource.CLOSED) {
console.log('SSE 연결이 닫혔습니다. 재연결 시도중...')
}
}
function reconnectSSE() {
eventSource.close()
setTimeout(() => {
eventSource = new EventSource('/stream/post')
}, 5000)
}
SSE vs WebSocket 비교
| 특징 | Server-Sent Events | WebSocket |
|---|
| 통신 방향 | 단방향 (서버→클라이언트) | 양방향 |
| 프로토콜 | HTTP | WebSocket |
| 자동 재연결 | 지원 | 수동 구현 |
| 구현 복잡도 | 간단 | 복잡 |
| 사용 사례 | 알림, 피드, 실시간 데이터 | 채팅, 게임, 협업 도구 |
| 브라우저 지원 | 우수 | 우수 |
| 방화벽 친화적 | 매우 좋음 | 보통 |
실전 체크리스트
기본 구현
고급 기능
성능 최적화
에러 처리