54.1 서버리스에선 왜 웹소켓 유지가 어려워요?
지금까지는 서버 한 대를 계속 켜두고 그 메모리에 접속자 명단이며 방 상태를 담아뒀어요. 그런데 요즘 많이 쓰는 서버리스(serverless, 서버를 직접 굴리지 않고 요청이 올 때만 함수가 잠깐 깨어나는 방식)로 옮기면 이 전제가 통째로 무너져요.
서버리스 함수는 요청이 오면 깨어나서 잠깐 일하고, 몇 초 뒤엔 사라져요. 상태를 어디 붙들어둘 상주 프로세스가 없는 거죠. 게다가 다음 요청은 완전히 다른 인스턴스로 갈 수도 있어서, 방금 그 연결을 기억하지도 못해요. 반면 웹소켓(WebSocket)은 연결을 몇 시간이고 계속 열어둬야 하잖아요. 잠깐 깼다 사라지는 함수 위에 몇 시간짜리 연결을 얹으려니 애초에 안 맞는 거예요. 저도 처음 서버리스로 채팅을 옮기다 "연결이 자꾸 툭툭 끊겨요" 소리를 듣고 한참 헤맸어요.
54.2 그럼 실시간을 아예 못 하나요?
다행히 방법이 있어요. 크게 두 갈래예요.
하나는 상태를 붙들어주는 특별한 객체를 쓰는 거예요. 대표가 클라우드플레어의 Durable Objects(듀러블 오브젝트, 상태를 오래 유지하는 객체)예요. 서버리스인데도 "이 방만큼은 늘 같은 한 곳"이 담당하도록 묶어줘요. 다른 하나는 아예 연결 관리를 전문 업체에 맡기는 거예요. Pusher(푸셔), Ably(에이블리), Supabase Realtime(수파베이스 리얼타임) 같은 관리형 실시간 서비스죠. 내 함수는 "이런 일이 생겼다"만 던지고, 수많은 연결을 붙들고 있는 골치 아픈 일은 그쪽이 대신 해줘요. 여기선 둘 다 맛보고, 언제 뭘 고를지까지 정리할게요.
54.3 Durable Objects는 뭘 해결해줘요?
Durable Objects의 핵심은 "하나의 방 = 하나의 인스턴스"예요. 채팅방 id로 객체를 부르면, 전 세계 어디서 요청이 와도 클라우드플레어가 항상 같은 객체로 연결해줘요. 그 객체는 메모리 상태를 유지하고, 웹소켓 연결도 그 안에서 직접 붙들어요. 우리가 서버 한 대에서 하던 걸, 방 단위로 쪼갠 작은 서버가 대신 해준다고 보면 돼요.
상황: 한 채팅방을 담당하는 Durable Object. 연결을 붙들고 받은 걸 되뿌려요.
export class ChatRoom {
constructor(state) {
this.sessions = []; // 이 방의 연결들
}
async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
this.sessions.push(server);
server.addEventListener('message', (e) => {
for (const s of this.sessions) s.send(e.data);
});
return new Response(null, { status: 101, webSocket: client });
}
}
WebSocketPair로 연결 한 쌍을 만들어 한쪽(server)은 객체가 쥐고, 다른 쪽(client)은 브라우저에 돌려줘요. status 101(프로토콜 전환, 일반 HTTP를 웹소켓으로 바꾼다는 신호)로 응답하면 연결이 성립돼요. 이제 이 객체는 this.sessions에 연결을 쌓아두고, 누가 메시지를 보내면 방 안 모두에게 되뿌려요. 서버 한 대에서 하던 브로드캐스트랑 모양이 똑같죠? 다른 점은 이게 방마다 따로 살아 움직인다는 거예요.
한 가지 더 좋은 점은, Durable Objects엔 영속 저장소(storage, 껐다 켜도 남는 저장 공간)가 딸려 있다는 거예요. 그래서 방 안 최근 메시지나 정원 정보 같은 걸 객체 안에 그대로 보관했다가, 객체가 잠들었다 깨어나도 이어서 쓸 수 있어요. 상태와 저장을 한 몸으로 둘 수 있는 게 이 방식의 큰 매력이에요.
54.4 관리형 서비스에 맡기면 뭐가 편해요?
Durable Objects도 결국 내가 코드를 짜서 연결을 관리해요. 그마저도 남에게 넘기고 싶다면 관리형 서비스가 답이에요. 여기선 Pusher로 보여줄게요. 그림이 확 단순해져요. 내 서버는 "이 채널에 이런 일 생겼어"라고 이벤트 하나만 던지고 바로 할 일 끝, 나머지는 Pusher가 알아서 모든 접속자에게 뿌려요.
상황: 서버(서버리스 함수)에서 새 메시지 사건을 던져요.
// 서버 쪽
await pusher.trigger('room-42', 'new-message', {
user: '이혼음료',
text: '다들 접속했어요?'
});
연결을 하나도 안 붙들고 trigger 한 번으로 끝나니, 잠깐 깼다 사라지는 서버리스 함수와 궁합이 딱 맞아요. 브라우저 쪽은 채널을 구독(subscribe, 특정 채널의 소식을 받겠다고 신청)만 하면 돼요.
// 브라우저 쪽
const channel = pusher.subscribe('room-42');
channel.bind('new-message', (data) => {
addMessage(data.user, data.text);
});
웹소켓을 직접 열고, 하트비트로 살아 있나 챙기고, 재연결을 짜는 그 모든 걸 서비스가 대신 해줘요. 대신 연결 수와 메시지 수만큼 요금을 내죠. 편함을 돈으로 사는 셈이에요.
54.5 둘 중 뭘 골라야 하죠?
정답은 없고 상황이에요. 제가 쓰는 기준을 풀어볼게요.
관리형 서비스는 "빨리 붙이고 싶다", "연결 관리에 신경 쓰기 싫다"일 때 좋아요. 몇 줄로 실시간이 붙으니까요. 단점은 요금이 규모 따라 커지고, 그 회사에 묶인다(lock-in, 나중에 갈아타기 어려움)는 거예요. 반대로 Durable Objects는 "메시지에 내 로직을 끼우고 싶다", "상태를 내가 쥐고 싶다", "비용을 더 눌러야 한다"일 때예요. 대신 내가 짜고 관리할 게 많아요. 접속자 검증, 방 정원 제한, 메시지 저장 같은 걸 연결 흐름 한가운데서 주무르고 싶으면 Durable Objects, 그냥 소식만 빠르게 뿌리면 되면 관리형. 이 정도로 갈라도 대부분 답이 나와요.
예를 들어 게임 로비처럼 방마다 규칙이 복잡하고 접속자를 실시간으로 검증해야 하면 Durable Objects가 편하고, 공지 브로드캐스트나 간단한 알림처럼 뿌리기만 하면 되는 기능은 관리형으로 하루면 붙여요. 실제로 저는 한 서비스 안에서도 둘을 섞어 써요. 무거운 협업 편집 방은 Durable Objects로, 가벼운 시스템 알림은 관리형으로요. 기능마다 성격이 다르니 굳이 하나로 통일할 이유도 없어요.
54.6 오늘 내용을 정리해볼게요
서버리스에서 실시간이 왜 까다롭고, 어떻게 푸는지 훑었어요.
핵심은 서버리스 함수는 잠깐 깼다 사라져서 웹소켓처럼 오래 열어두는 연결을 붙들 곳이 없다는 거예요. 그래서 Durable Objects로 "방마다 늘 같은 한 곳"을 만들어 상태와 연결을 붙들거나, Pusher, Ably, Supabase Realtime 같은 관리형에 연결 관리를 통째로 맡겨요. 전자는 제어가 세고 비용이 낮은 대신 손이 많이 가고, 후자는 손은 편한데 규모가 커지면 요금이 부담이에요.
둘 중 뭘 고르든 한 가지는 꼭 기억하세요. 서버 한 대 시절의 "메모리에 다 담아두면 되지"라는 감각을 버려야 한다는 거예요. 서버리스에선 상태를 어디에 어떻게 붙들지가 곧 설계의 절반이에요. 작은 채팅방 하나를 Durable Objects로도, Pusher로도 만들어보면 두 세계의 차이가 손끝으로 느껴질 거예요.