실시간 모니터링 대시보드를 폴링 대신 SSE로 만들었다
2초마다 API를 부르던 서버 모니터링 화면을 SSE로 바꿨다. Express 스트림과 EventSource 코드, 그리고 Apache 프록시 버퍼링 때문에 이벤트가 몰아서 오던 문제를 flushpackets로 푼 과정이다.
#SSE #실시간 #React #백엔드
서버 상태(CPU, 메모리, 디스크)를 보는 모니터링 대시보드를 만들었다. 실시간이니까 데이터가 계속 갱신돼야 하는데, 처음엔 그냥 2초마다 API를 다시 부르는 폴링으로 했다. 되긴 됐는데, 네트워크 탭을 열어보니 같은 요청이 2초마다 끝없이 왕복하고 있었다. 화면은 멀쩡한데 뭔가 찜찜했다. 그래서 SSE(Server-Sent Events) 로 바꿨다. 이 글에서는 왜 폴링을 버렸고 SSE로 어떻게 옮겼는지, 그리고 로컬에서는 잘 되던 게 서버에 올리니 왜 안 왔는지까지 정리해본다. 폴링이 왜 아쉬웠나 폴링은 클라이언트가 2초마다 서버에 "데이터 줘" 하고 계속 물어보는 방식이다. 단순해서 좋은데, 한 번 켜두면 계속 떠 있는 화면에는 잘 안 맞았다. 데이터가 안 바뀌었어도 일단 요청을 보내니까 낭비고, 요청 간격만큼 항상 지연이 있어서 실시간이라기엔 살짝 늦다. 게다가 매번 새 HTTP 요청이라 헤더 같은 오버헤드가 요청마다 반복된다. 대시보드 탭 하나를 하루 종일 켜두면 요청이 4만 번이 넘는다. 그중 대부분은 이전 응답이랑 똑같은 내용이다. SSE는 서버가 먼저 보낸다 SSE는 방향이 반대다. 클라이언트가 한 번 연결을 열어두면, 서버가 갱신될 때마다 알아서 데이터를 밀어 넣는다. 클라이언트는 계속 물어볼 필요 없이 가만히 받기만 하면 된다. 웹소켓보다 가볍고, 서버에서 클라이언트로 가는 한 방향이면 SSE로 충분하다. 모니터링은 딱 그 경우다. 브라우저가 서버한테 보낼 건 없고 받기만 하면 되니까. 백엔드는 응답 헤더를 text/event-stream으로 열어두고, 연결을 끊지 않은 채로 주기적으로 데이터를 write한다. // db/metrics.js router.get('/stream', (req, res) = { // 토큰 검증 (EventSource는 헤더를 못 실어서 쿼리로 받았다. 이 부분은 뒤에서 다시 나온다) try { jwt.verify(req.query.token, JWT_SECRET); } catch { return res.status(401).end(); } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }); res.flushHeaders(); async function send() { const data = await collectMetrics(); res.write(`data: ${JSON.stringify(data)}\n\n`); } send(); // 연결되자마자 한 번 const interval = setInterval(send, 2000); // 이후 2초마다 req.on('close', () = clearInterval(interval)); // 끊기면 정리 }); 형식은 단순하다. data: 뒤에 내용을 쓰고 빈 줄 하나로 끝내면 브라우저가 그걸 메시지 하나로 받는다. JSON을 그대로 넣어도 된다. 여기서 꼭 챙겨야 하는 게 req.on('close')다. 클라이언트가 탭을 닫으면 setInterval을 멈춰줘야 한다. 안 그러면 끊긴 연결에 계속 데이터를 쏘는 타이머가 쌓인다. 대시보드를 열었다 닫는 사람이 나 하나라도, 하루에 열 번 열면 타이머가 열 개다. 이거 빼먹으면 서버 메모리가 슬금슬금 샌다. 프론트는 EventSource 하나면 끝난다 브라우저에는 SSE를 받는 EventSource 객체가 내장돼 있다. 따로 라이브러리도 필요 없다. 연결하고, onmessage로 받고, 언마운트될 때 close 하면 끝이다. // hooks/useSSE.ts (처음 만든 버전) export function useSSE(path, onData) { useEffect(() = { const token = localStorage.getItem('monitor_token'); const es = new EventSource(`${path}?token=${token}`); es.onmessage = (e) = onData(JSON.parse(e.data)); es.onerror = () = { /* 끊김 처리 */ }; return () = es.close(); // 정리 }, [path]); } 이렇게 훅으로 감싸두니까 대시보드 컴포넌트에서는 useSSE만 부르면 데이터가 알아서 흘러들어온다. 폴링 때 있던 setInterval, 취소 처리, 중복 요청 방지 코드가 다 사라져서 오히려 깔끔해졌다. 덤으로 EventSource는 연결이 끊기면 알아서 다시 붙는다. 그 부분도 내가 짤 필요가 없었다. 로컬에서는 됐는데 서버에서는 안 왔다 다 만들고 서버에 올렸더니 이벤트가 안 왔다. 정확히는 한참 있다가 몰아서 왔다. 로컬에서는 2초마다 착착 오던 게 서버에서는 뚝뚝 끊겨서 한 번에 여러 개가 도착했다. Node 코드만 계속 들여다봤는데 문제는 그 앞단에 있었다. 내 서버는 전부 Apache 리버스 프록시 뒤에서 도는데, Apache가 프록시하면서 응답을 버퍼에 모았다가 한 번에 내보내고 있었다. SSE는 조금씩 계속 흘려보내는 게 전부인데, 프록시 입장에서는 그게 그냥 덜 찬 버퍼였던 거다. # monitor-ssl.conf ProxyPass / http://localhost:5020/ flushpackets=on flushpackets=on 한 마디 붙이니 바로 해결됐다. 백엔드에서 받은 패킷을 모으지 말고 바로 내보내라는 옵션이다. 찾는 데는 두 시간쯤 걸렸다. 로컬에는 Apache가 없으니 재현이 안 됐고, 서버 코드는 로컬이랑 똑같으니 코드가 문제일 리 없었는데도 코드만 봤다. 지금 이 서버에서 이 옵션이 붙어 있는 곳을 세어보면 설정 파일 세 개다. 모니터링은 스트림 경로 세 개에만 따로 붙였고, 블로그와 커플앱은 ProxyPass 전체에 붙어 있다. 실시간으로 뭘 흘려보내는 서비스가 딱 그만큼이라는 뜻이다. 새로 SSE를 붙이는 서비스가 생기면 코드보다 이 설정부터 확인하게 됐다. 그 뒤에 바뀐 것: 토큰을 URL에 싣지 않는다 위 코드에는 한 가지 찜찜한 부분이 있다. EventSource는 요청 헤더를 붙일 수 없어서 JWT를 쿼리스트링으로 넘겼다. 그런데 쿼리스트링은 URL의 일부라 Apache 접근 로그에 그대로 남는다. 로그를 찾아보니 실제로 토큰 원문이 찍힌 줄이 있었다. 이 이야기는 모니터링 서버 100일 회고에 따로 적어뒀다. 그래서 지금은 연결 직전에 30초짜리 1회용 티켓을 받아서 그걸 넘긴다. 서버 메모리에만 있는 무작위 문자열이라 로그에 남아도 다시 쓸 수 없다. // hooks/useSSE.ts (지금 버전) const connect = async () = { const ticket = (await getStreamTicket()).data.ticket; // POST /api/auth/stream-ticket es = new EventSource(`${path}?ticket=${encodeURIComponent(ticket)}`); es.onmessage = (e) = onDataRef.current(JSON.parse(e.data)); es.onerror = () = { // 티켓은 1회용이라 브라우저의 자동 재연결은 401로 끝난다. 새 티켓으로 직접 다시 붙는다 es?.close(); if (!disposed) retry = setTimeout(connect, 3000); }; }; 대신 EventSource가 공짜로 해주던 자동 재연결은 못 쓰게 됐다. 같은 티켓으로 다시 붙으면 401이 나니까 끊길 때마다 새 티켓을 받아서 직접 다시 연결한다. 편하자고 쓴 자동 재연결이 보안을 조이니 오히려 걸림돌이 된 셈이다. 서버에서 브라우저로 흘려보내기만 하면 되는 화면이라면 웹소켓까지 갈 것 없이 SSE를 먼저 써보길 추천한다. 코드는 폴링보다 짧고, 대신 프록시 버퍼링과 인증값을 어디에 실을지 이 두 가지는 처음부터 같이 생각해두는 게 좋다.