온라인 강의용 실시간 리액션 시스템: Cloud Run에서 SSE 비용을 90% 줄이기
수업 중에 학생 반응을 알고 싶다
온라인 수업을 하다 보면 제일 답답한 게 학생들이 이해하고 있는지 모른다는 점이다. 오프라인에서는 고개를 끄덕이거나 멍한 표정을 보면 알 수 있는데, 화면 공유만 하고 있으면 반응이 전혀 안 보인다.
"여기까지 이해됐나요?" — 침묵. "질문 있으면 채팅 남겨주세요" — 역시 침묵.
그래서 만들었다. 학생이 버튼 하나 누르면 강사 화면에 이모지가 떠오르는 리액션 시스템.
전체 구조
크롬 확장 프로그램 2개와 백엔드 API로 되어 있다.
학생 (Sender) 백엔드 (Cloud Run) 강사 (Receiver) ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ Chrome 확장 │ POST /api │ Spring Boot │ SSE │ Chrome 확장 │ │ │──────────────→│ │──────────→│ │ │ [👍] [🤔] [🆘] │ reactions/ │ ReactionSse │ reactions/ │ 모든 탭에 │ │ [✋] [☕] │ {session} │ Service │ {session}/ │ 이모지 표시 │ │ │ │ │ subscribe │ │ └──────────────┘ └──────────────────┘ └──────────────────┘ Manifest V3 ConcurrentHashMap Manifest V3 permissions: <sessionId, permissions: storage Set<SseEmitter>> storage, tabs, scripting, alarms
Sender(학생용)는 단순하다. 세션 ID를 설정하고 이모지 버튼을 누르면 POST 요청 하나 날리는 게 전부. 크롬 storage 권한만 있으면 된다.
Receiver(강사용)는 좀 복잡하다. SSE로 백엔드에 상시 연결해 두고, 리액션이 오면 열려 있는 모든 탭에 이모지를 띄워야 한다. Service Worker로 SSE 연결을 유지하고, content script를 동적 주입해서 어떤 페이지에서든 이모지가 나타난다.
5개의 교육용 리액션
일반 스트리밍처럼 "하트", "좋아요"만 있는 게 아니라, 수업에 맞는 신호를 넣었다.
┌─────────┬──────────────┬───────────────────────────┐ │ 이모지 │ 라벨 │ 의미 │ ├─────────┼──────────────┼───────────────────────────┤ │ 👍 │ 네/좋아요 │ 이해했다, 동의한다 │ │ 🤔 │ 이해 안돼요 │ 설명이 좀 더 필요하다 │ │ 🆘 │ 모르겠어요 │ 완전히 놓쳤다 │ │ ✋ │ 기다려주세요 │ 아직 따라하는 중이다 │ │ ☕ │ 쉬어가요 │ 쉬는 시간이 필요하다 │ └─────────┴──────────────┴───────────────────────────┘
🤔가 계속 올라오면 "이 부분 다시 설명해야겠다", ✋가 많으면 "잠깐 기다리자", ☕가 슬슬 보이면 "10분 쉬고 오시죠" 하는 식으로 쓰고 있다.
이모지가 화면에 뜨는 방식
리액션이 도착하면 강사가 보고 있는 모든 탭에 이모지가 나타난다. 코딩하면서 IDE를 보고 있어도, 슬라이드를 보고 있어도.
화면 아래쪽에서 위로 떠오르는 이모지 ┌──────────────────────────────────────┐ │ │ │ 👍 │ │ ↑ 🤔 │ │ │ ↑ │ │ ☕ │ │ │ │ ↑ │ ✋ │ │ │ │ │ ↑ │ │ │───────│────│──────────│─────────│─────│ │ 하단에서 생성 → 위로 떠오르며 사라짐 │ └──────────────────────────────────────┘
각 이모지는 랜덤 위치(10%~90%), 랜덤 크기(60~100px), 랜덤 글로우 색상으로 떠오른다. 살짝 좌우로 흔들리면서 올라가다가 화면 상단 80% 지점에서 투명하게 사라진다. 이름을 설정한 학생이면 이모지 아래에 작은 이름표도 붙는다.
pointer-events: none과 z-index: 2147483647로 어떤 페이지 위에서든 이모지가 떠 있되, 클릭이나 스크롤을 방해하지 않는다.
백엔드: DB 없이 메모리만으로
리액션 데이터를 DB에 저장할 이유가 없다. "학생이 👍 눌렀다"는 정보는 강사 화면에 한 번 나타나고 사라지면 끝이니까.
ReactionSseService ┌─────────────────────────────────────────────┐ │ ConcurrentHashMap<String, Set<SseEmitter>> │ │ │ │ "frontend-17" → [emitter1, emitter2] │ │ "likelion19" → [emitter3] │ │ │ │ broadcast(sessionId, event) │ │ → sessionEmitters.forEach(e -> e.send()) │ └─────────────────────────────────────────────┘
세션별로 SseEmitter Set을 관리한다. 같은 세션에 여러 명이 구독할 수 있다(나와 아내가 각자 PC에서 수업하니까 실제로 그렇다). 리액션이 오면 해당 세션의 모든 emitter에 브로드캐스트.
인증도 없다. 세션 ID만 알면 누구나 리액션을 보내고 받을 수 있다. 수업 시간에만 쓰는 도구이고, 세션 ID를 모르면 접근할 수 없으니 이 정도면 된다.
Cloud Run에서 SSE의 비용 문제
SSE(Server-Sent Events)는 HTTP 연결을 계속 열어두는 기술인데, Cloud Run에서 이걸 쓰면 생각보다 비용이 많이 나온다.
Cloud Run의 과금 모델이 이렇다.
cpu-throttling: true (기본값) ───────────────────────────── 요청이 처리되는 동안만 CPU 할당 & 과금 요청이 없으면 CPU 스로틀링 → 과금 거의 0 일반 API 요청: 빠르게 끝남 [요청 처리 30ms] → 과금 30ms SSE 연결: 연결이 열려 있는 내내 "요청 처리 중" [========= 300초간 과금 =========]→ 타임아웃 → 재연결 [========= 300초간 과금 =========]→ 타임아웃 → 재연결 ... 무한 반복
Cloud Run의 기본 요청 타임아웃은 300초다. SSE 연결은 이 300초 내내 "요청 처리 중"으로 간주된다. 아무 데이터도 주고받지 않아도 300초 내내 CPU 요금이 나간다.
24시간 켜 두면 얼마?
SSE 1개 연결 (24시간): 300초/연결 × (3600 ÷ 300) = 12회/시간 → 24 × 12 × 300 = 86,400 vCPU-초/일 Cloud Run vCPU-초 단가: $0.00002400 → 86,400 × $0.000024 ≈ $2.07/일 SSE 2개(강사 2명): $4.14/일 → $124/월
하루 $4, 한 달 $124. 리액션 하나 안 보내도, SSE 연결을 유지하는 것만으로 이만큼 나간다.
해결: 수업 시간에만 연결하기
실제로 SSE가 필요한 건 수업 시간뿐이다.
수업 스케줄 (평일만): 오전: 09:00 ~ 12:00 (3시간) 오후: 13:00 ~ 16:00 (3시간) ───────────────────── 하루 6시간, 주 30시간
24시간 중 6시간. 주말 제외하면 168시간 중 30시간. 전체 시간의 약 18%만 쓴다.
이걸 크롬 확장 프로그램의 Receiver 쪽에서 제어한다.
┌─────────────────────────────────────────────┐ │ background.js (Service Worker) │ │ │ │ isClassTime() │ │ KST 기준: │ │ - 평일(월~금)만 │ │ - 09:00~12:00 또는 13:00~16:00 │ │ │ │ Keep-Alive 알람 (24초마다): │ │ ├─ 수동 연결? → 시간 무관하게 재연결 │ │ ├─ 휴가중? → SSE 해제 │ │ ├─ 수업시간? → 끊어졌으면 재연결 │ │ └─ 그 외? → SSE 해제 │ │ │ │ 확장 시작 시: │ │ autoConnect + sessionId 있으면 │ │ → 휴가 아니고 수업시간이면 자동 연결 │ └─────────────────────────────────────────────┘
수동 연결 vs 자동 연결
처음에는 모든 연결에 시간 제한을 걸었다. 그랬더니 퇴근 후 테스트도 못 하고, 보충 수업 같은 예외 상황에도 쓸 수 없었다. 그래서 수동 Connect 버튼은 시간 제한 없이 두고, 자동 연결만 시간으로 제한했다.
수동 연결 자동 연결 시간 제한 없음 평일 9-12, 13-16 Keep-Alive 무조건 재연결 시간 밖이면 해제 휴가 모드 영향 없음 연결 차단
자동은 엄격하게, 수동은 유연하게. 이렇게 분리하니까 예외를 허용하면서도 기본 비용은 줄일 수 있었다.
Service Worker의 특이한 점
크롬 확장 프로그램의 Service Worker는 비활성 상태가 되면 종료된다. 30초 정도 아무 이벤트가 없으면 크롬이 메모리에서 내려 버린다. SSE 연결도 같이 사라지고 let 변수도 전부 초기화된다.
이걸 막기 위해 24초마다 Chrome Alarm을 울린다.
chrome.alarms.create('keep-alive', { periodInMinutes: 0.4 }); // 24초
알람이 울리면 Service Worker가 깨어나면서 SSE 연결이 살아 있는지 확인하고, 끊어졌으면 시간 조건에 따라 재연결하거나 정리한다.
isManualConnect 같은 상태 변수는 Service Worker가 죽으면 날아간다. 하지만 chrome.storage.sync에 저장한 sessionId, autoConnect, vacation 같은 설정은 살아남는다. Service Worker가 재시작되면 이 설정을 읽어서 자동 연결 여부를 판단한다.
휴가 모드
방학이나 공휴일에는 수업이 없으니 SSE 연결도 필요 없다. isClassTime()으로 평일만 거르긴 하지만, 평일에 개인 휴가를 쓸 수도 있으니 별도 토글을 만들었다.
팝업에서 "휴가중" 토글을 켜면 chrome.storage.sync에 저장되고, 다음부터 자동 연결이 차단된다. Keep-Alive 알람에서도 휴가 상태를 확인해서, 진행 중인 SSE도 끊는다.
비용 절감 효과
시간 제한 적용 후 비용이 이렇게 바뀐다.
변경 전 (24시간 상시 연결): SSE 2개 × 86,400 vCPU-초 = $4.14/일 → $124/월 변경 후 (평일 6시간만): SSE 2개 × 21,600 vCPU-초 = $1.04/일 × 22일(평일) = $22.8/월 절감: $124 → $22.8 (약 82% 절감)
휴가 기간까지 고려하면 실제 절감률은 90%에 가깝다. 수업이 없는 달이면 비용은 거의 0.
SSE 대신 WebSocket을 쓰면?
WebSocket도 같은 문제가 있다. Cloud Run에서 연결을 유지하면, 연결 시간 동안 과금된다. SSE든 WebSocket이든 long-lived connection은 Cloud Run과 궁합이 안 맞는다.
24시간 상시 연결이 정말 필요하다면 Compute Engine(VM)을 쓰는 게 맞다. e2-micro 기준 월 ~$30 고정비로 연결 수 제한 없이 유지할 수 있다. 하지만 이 리액션 시스템처럼 하루 6시간만 쓰는 경우라면, Cloud Run에서 클라이언트 쪽으로 연결 시간을 제한하는 게 낫다. 안 쓰는 시간에 비용이 0이 되니까.
마무리
리액션 시스템 자체는 단순하다. POST로 이모지 보내고, SSE로 받아서 화면에 띄우는 것뿐. 백엔드 코드도 100줄이 안 된다.
다만 "단순한 기능을 클라우드에 올렸을 때 비용이 어떻게 나오는가"는 별개 문제였다. SSE가 CPU 과금을 유발한다는 걸 알기 전까지는, 리액션 하나 안 보내도 인스턴스가 24시간 돌아가고 있었을 거다. 비용 제어를 서버가 아니라 클라이언트(크롬 확장)에서 했다는 점이 좀 특이한데, 백엔드는 항상 열어두되 "언제 연결할지"를 Receiver가 결정하는 구조라 이게 자연스러웠다.

댓글
댓글을 작성하려면 이 필요합니다.