[Node.js-09] Server-Sent Events

Comely·2025년 3월 12일

Node.js

목록 보기
9/14

Server-Sent Events (SSE) 기본 개념

SSE란?

  • 단방향 실시간 통신: 서버 → 클라이언트로만 데이터 전송
  • HTTP 연결 유지: 1회 요청 후 연결을 끊지 않고 계속 응답
  • WebSocket 대안: 단순한 실시간 데이터 전송에 적합

일반 HTTP vs SSE

구분일반 HTTPServer-Sent Events
연결 방식요청 → 응답 → 종료요청 → 응답 지속
데이터 전송1회성지속적
사용 사례일반 웹페이지실시간 알림, 피드

HTTP Headers 이해

Headers란?

서버와 클라이언트 간 주고받는 부가 정보

Request Headers (클라이언트 → 서버)

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

Response Headers (서버 → 클라이언트)

Content-Type: text/html
Set-Cookie: sessionId=abc123
Cache-Control: no-cache
Connection: keep-alive

Headers 확인 방법

  1. Chrome 개발자도구Network 탭
  2. 페이지 새로고침
  3. 요청 클릭 → Headers 탭 확인

SSE 서버 구현

기본 SSE 서버

app.get('/stream/list', (요청, 응답) => {
  // SSE 헤더 설정
  응답.writeHead(200, {
    "Connection": "keep-alive",        // 연결 유지
    "Content-Type": "text/event-stream", // SSE 타입
    "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>
  // SSE 연결 생성
  let eventSource = new EventSource('/stream/list')
  
  // 메시지 수신 처리
  eventSource.addEventListener('msg', function(e) {
    console.log('받은 데이터:', e.data)
  })
  
  // 연결 에러 처리
  eventSource.onerror = function(e) {
    console.log('SSE 연결 에러:', e)
  }
  
  // 연결 종료
  // eventSource.close()
</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)
})

조건부 감시

// Insert만 감시
let 조건 = [
  { $match: { operationType: 'insert' } }
]

const changeStream = db.collection('post').watch(조건)

changeStream.on('change', (result) => {
  console.log('새 문서 추가:', result.fullDocument)
})

주요 연산 타입

operationType설명사용 예시
insert문서 생성새 글 등록
update문서 수정글 수정
delete문서 삭제글 삭제
replace문서 교체전체 내용 변경

특정 필드 조건

// name이 123인 문서만 감시
let 조건 = [
  { $match: { 'fullDocument.name': 123 } }
]

// 제목에 '공지'가 포함된 글만 감시
let 조건 = [
  { 
    $match: { 
      operationType: 'insert',
      'fullDocument.title': { $regex: '공지' }
    } 
  }
]

실시간 게시물 알림 구현

서버: 새 게시물 감지 및 전송

app.get('/stream/post', (요청, 응답) => {
  // SSE 헤더 설정
  응답.writeHead(200, {
    "Connection": "keep-alive",
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
  })

  // Change Stream 설정
  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()
  })
})

클라이언트: 실시간 게시물 표시

<!-- list.ejs -->
<div class="white-bg">
  <!-- 기존 게시물들 -->
</div>

<script>
  let eventSource = new EventSource('/stream/post')
  
  eventSource.addEventListener('newPost', function(e) {
    console.log('새 게시물 수신:', e.data)
    
    // JSON 파싱
    let 새게시물 = JSON.parse(e.data)
    
    // HTML에 추가
    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')
  
  // Change Stream 초기화
  changeStream = db.collection('post').watch([
    { $match: { operationType: 'insert' } }
  ])
  
  server.listen(process.env.PORT, () => {
    console.log('서버 실행중')
  })
}).catch((err) => {
  console.log(err)
})

// API에서 기존 Change Stream 활용
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', (요청, 응답) => {
  // 타임아웃 설정 (30분)
  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()
  
  // 30초마다 통계 업데이트
  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 EventsWebSocket
통신 방향단방향 (서버→클라이언트)양방향
프로토콜HTTPWebSocket
자동 재연결지원수동 구현
구현 복잡도간단복잡
사용 사례알림, 피드, 실시간 데이터채팅, 게임, 협업 도구
브라우저 지원우수우수
방화벽 친화적매우 좋음보통

실전 체크리스트

기본 구현

  • SSE 서버 헤더 설정
  • 클라이언트 EventSource 연결
  • Change Stream 설정
  • 데이터 형식 정의

고급 기능

  • 조건부 데이터 감시
  • 사용자별 맞춤 알림
  • 실시간 통계 전송
  • 메시지 큐잉

성능 최적화

  • Change Stream 연결 관리
  • 메모리 누수 방지
  • 연결 수 제한
  • 데이터 압축

에러 처리

  • 서버 오류 처리
  • 클라이언트 재연결
  • 타임아웃 설정
  • 로깅 시스템
profile
App, Web Developer

0개의 댓글