프레즌스는 사용자가 지금 온라인, 자리 비움, 오프라인 중 어느 상태인지를 연결과 하트비트로 최신으로 유지하는 실시간 신호입니다. 초록 점은 겉보기엔 단순한 UI지만, 그 아래에는 진짜 어려운 분산 시스템 문제가 있습니다. 서버는 부재를 추론해야 하고(디바이스는 자신의 종료를 좀처럼 알리지 않습니다), 그 추론을 지켜보는 모든 사람에게 연락처 목록 규모로 브로드캐스트해야 합니다. 이 패턴은 현대 채팅보다 훨씬 전에 XMPP의 presence stanza로 표준화되었고, 그 기본 원리는 지금까지 변하지 않았습니다.
핵심 요약
| 질문 | 답변 |
|---|---|
| 상태 | 온라인 · 자리 비움/유휴 · 오프라인, 그리고 사용자가 직접 설정하는 상태(방해 금지) |
| 메커니즘 | 하트비트 쓰기 + TTL 만료, 연결 이벤트는 빠른 경로 |
| 수치 | 하트비트 약 30초 · 오프라인 타임아웃은 간격의 2–3배 · 깜빡임 유예 30–60초 |
| 짝 | 마지막 접속, 휘발성 점 뒤에 있는 영속 타임스탬프 |
| 어려운 부분 | 팬아웃: 관찰자 N명 × 상태 변화 M회, 그리고 성공할수록 곱해짐 |
하트비트 계약
패턴 전체가 하나의 교환에 들어갑니다:
client 30초마다: save { status: "online", lastActiveAt: now }
server 60초마다: 스윕 — lastActiveAt이 90초 경과? → status: "offline"
(TTL = 하트비트 간격의 2–3배)
watcher status = "online" → 초록 점
status = "offline" → "마지막 접속 12:41" ← lastActiveAt, 영속되는 부분
같은 계약을 SDK 코드로 표현하면 하트비트는 나가고 구독은 들어옵니다:
// JavaScript / Node.js — Back4app JS SDK
// Presence: heartbeat your own status, subscribe to your contacts'
const heartbeat = () =>
myPresence.save({ status: 'online', lastActiveAt: new Date() });
await heartbeat();
setInterval(heartbeat, 30_000); // server sweeps offline at 2–3× this
const contacts = new Parse.Query('Presence');
contacts.containedIn('user', myContactIds);
const sub = await contacts.subscribe();
sub.on('update', (p) => setStatus(p.get('user'), p.get('status'))); // Flutter / Dart — Back4app Flutter SDK
// Presence: heartbeat your own status, subscribe to your contacts'
Future<void> heartbeat() async {
myPresence
..set('status', 'online')
..set('lastActiveAt', DateTime.now());
await myPresence.save();
}
Timer.periodic(const Duration(seconds: 30), (_) => heartbeat());
final contacts = QueryBuilder<ParseObject>(ParseObject('Presence'))
..whereContainedIn('user', myContactIds);
final sub = await LiveQuery().client.subscribe(contacts);
sub.on(LiveQueryEvent.update, (p) => setStatus(p.get('user'), p.get('status'))); // iOS / Swift — Back4app Swift SDK
// Presence: heartbeat your own status, subscribe to your contacts'
func heartbeat() async throws {
myPresence.status = "online"
myPresence.lastActiveAt = Date()
_ = try await myPresence.save()
}
// Fire every 30 s — the server sweeps offline at 2–3× this
let contacts = Presence.query(containedIn(key: "user", array: myContactIds))
let sub = try await contacts.subscribe()
sub.handleEvent { _, e in
if case .updated(let p) = e { setStatus(p.user, p.status) }
} // Android / Kotlin — Back4app Android SDK
// Presence: heartbeat your own status, subscribe to your contacts'
fun heartbeat() {
myPresence.put("status", "online")
myPresence.put("lastActiveAt", Date())
myPresence.saveInBackground()
}
timer.scheduleAtFixedRate(0, 30_000) { heartbeat() } // sweep at 2–3× this
val contacts = ParseQuery.getQuery<ParseObject>("Presence")
contacts.whereContainedIn("user", myContactIds)
val sub = ParseLiveQueryClient.Factory.getClient().subscribe(contacts)
sub.handleEvent(SubscriptionHandling.Event.UPDATE) { _, p ->
setStatus(p.getString("user"), p.getString("status"))
} 연결 이벤트 vs. 하트비트 TTL
두 감지 모델을 공정하게 비교합니다. 상위 검색 결과 중 이를 하나의 표로 정리한 페이지는 없습니다:
| 연결 수명 주기 | 하트비트 + TTL | |
|---|---|---|
| 온라인 신호 | 소켓 열림 | 시간 창 안의 새 하트비트 |
| 오프라인 신호 | close 이벤트 / close 프레임 | TTL 만료, 하트비트 간격 2–3회 동안 하트비트 없음 |
| 감지 속도 | 정상 종료 시 즉시 | 지연에 상한이 있음(타임아웃까지) |
| 조용한 장애 | 놓침: 전원이 꺼지면 close 프레임이 없음 | 잡아냄: 침묵 자체가 신호 |
| 서버 상태 | 연결별 추적 | TTL 저장소에 대한 무상태 쓰기 |
| 실패 양상 | 좀비 “온라인” 사용자 | 하트비트 누락 시 잠깐의 잘못된 “오프라인” |
WebSocket 프로토콜 자체가 이 교훈을 보여 줍니다. TCP가 사라진 상대를 알려 주지 않기 때문에 ping/pong 제어 프레임을 제공하며, close 코드 체계에서 1006을 “abnormal closure — no close frame received”, 즉 좀비 사례에 예약해 두었습니다. 그래서 프로덕션 프레즌스는 두 모델을 겹쳐 씁니다. 즉각적인 전환에는 연결 이벤트를, 이벤트가 놓친 것을 잡는 진실로는 하트비트 만료를 사용합니다.
상태 머신
완성도 높은 구현을 구분 짓는 두 가지 개선점이 있습니다. 자리 비움은 클라이언트가 감지합니다. 서버는 백그라운드로 간 탭을 볼 수 없지만 브라우저는 볼 수 있습니다. Page Visibility API가 숨겨진 탭을 표시하고, 입력 리스너가 유휴 상태를 표시하며, 탭을 닫을 때의 최선 노력(best-effort) 오프라인 신호는 Beacon API에 실어 보냅니다. 오프라인에는 디바운스를 적용합니다. 유예 상태가 존재하는 이유는 모바일 연결이 깜빡이기 때문입니다. 터널과 엘리베이터마다 “오프라인”을 발행하면 모든 관찰자의 연락처 목록이 덩달아 깜빡입니다. 알림을 늦추고 재연결 시 취소하면 깜빡임은 잠잠해집니다. 프레즌스를 정식화한 프로토콜에서 빌려 올 만한 구분이 하나 더 있습니다. 계산된 프레즌스(연결 기준 온라인)와 사용자가 설정한 상태(방해 금지)를 구분하는 것이며, 병합에서는 항상 후자가 이깁니다.
설계상 휘발성
프레즌스는 기록이 아니라 현실의 캐시입니다. 늦게 도착한 상태는 없는 것보다 나쁩니다. 어제의 “온라인”은 오늘의 거짓말입니다. 이 원칙이 구현 선택을 결정합니다. 상태는 영속 테이블이 아니라 TTL이 있는 빠른 저장소에 두고, 오프라인 전달을 위해 큐에 쌓지 않으며, 백프레셔 상황에서 가장 먼저 버리는 데이터입니다. 영속성이 필요한 것은 lastActiveAt뿐입니다. 입력 중 표시(typing indicator)는 이 원칙의 극단입니다. 하나의 대화로 범위가 한정된 마이크로 프레즌스로, 몇 초 만에 만료되고, 보내는 쪽에서 디바운스되며, 재시도하지 않고 버려집니다. “Ada is typing…”이 지금 도착할 수 없다면 영영 도착하지 않는 것이 맞습니다.
팬아웃 문제
프레즌스의 확장 한계는 저장이 아니라 곱셈입니다. 예를 들어 봅니다. 동시 사용자 100,000명이 각각 연락처 50명에게 보이고, 각자 시간당 20회라는 적당한 횟수로 상태가 바뀐다면 100,000 × 50 × 20 = 시간당 1억 건의 프레즌스 이벤트를 전달해야 합니다. 사용자당 작은 행 하나만 저장하는 기능에서 말입니다. 조절 수단을 당기는 순서는 다음과 같습니다. 좁게 구독합니다. 전체 연락처 그래프가 아니라 화면에 있는 사용자(열린 대화 목록)만 지켜봅니다. 롱테일은 푸시하지 않고 가져옵니다. 마지막 접속은 스트리밍하지 말고 프로필을 열 때 조회합니다. 그룹에서는 브로드캐스트를 제한합니다. 구성원이 수백 명을 넘으면 명단 전체가 아니라 상호작용할 때 프레즌스를 보여 줍니다. 전환을 배칭합니다. 깜빡이는 사용자 한 명이 이벤트 30건이 아니라 디바운스된 이벤트 1건의 비용만 들게 합니다. 팬아웃 자체는 표준 pub/sub 메커니즘을 타고 갑니다. 프레즌스는 그 패턴에서 최대 한 번(at-most-once) 전달의 극단적인 사례이며, 오래된 이벤트를 버리는 것이 오히려 기능입니다.
멀티 디바이스와 프라이버시
사용자 한 명, 디바이스 세 대, 점 하나. 사용자별 프레즌스는 디바이스별 프레즌스를 병합한 결과입니다. 각 디바이스의 하트비트를 따로 추적하고, 어느 한 디바이스라도 온라인이면 사용자는 온라인입니다. 병합 정책은 가장 활성인 상태 우선입니다. 휴대폰의 온라인이 데스크톱의 유휴를 이기고, 어느 디바이스든 방해 금지가 설정되면 나머지를 덮어씁니다. 마지막 접속은 가장 최근의 디바이스를 기준으로 합니다. 프라이버시는 설계의 나머지 절반이며, 사용자가 가장 많이 묻는 부분인데도 엔지니어링 블로그들이 건너뛰는 절반입니다. 공개 범위 규칙(전체 / 연락처 / 비공개), 상호성(내 상태를 숨기면 다른 사람의 상태도 볼 수 없음), 대략적인 마지막 접속(타임스탬프 대신 “최근”) 등이 여기에 해당합니다. 이 규칙은 데이터 계층의 권한에서 강제해야 합니다. 프레즌스는 행동 데이터이며, 유출된 “새벽 3시에 온라인”은 실제 정보 노출입니다.
일반적인 사용 사례
- 채팅과 메시징: 대표적인 초록 점, 마지막 접속, 입력 중 표시.
- 협업 도구: 문서에 누가 있는지, 커서 프레즌스, “지금 활동 중” 사이드바.
- 고객 지원과 마켓플레이스: 상담원 가용성 기반 라우팅, “판매자 온라인” 같은 신뢰 신호.
- 멀티플레이어와 소셜: 로비, 친구 목록, 친구와 합류하기 기능.
- 업무 도구: 라우팅과 상태 대시보드로 이어지는 가용성 상태.
프레즌스를 어떻게 구축해야 할까? 결정 매트릭스
| 기능 | 메커니즘 |
|---|---|
| 연락처의 초록 점 | 하트비트 + TTL 저장소, 보이는 사용자에 대한 라이브 쿼리 구독 |
| 열린 채팅에서의 즉각적인 전환 | 소켓 위의 빠른 경로로서의 연결 이벤트 |
| 마지막 접속 | 하트비트마다 타임스탬프 기록, 필요할 때 조회 |
| 자리 비움 감지 | 클라이언트 측: visibility + 유휴 리스너, 하트비트에 담아 보고 |
| 입력 중 표시 | 대화별 마이크로 이벤트, 수 초 단위 TTL, 영속화하지 않음 |
| 구성원 5,000명 그룹의 프레즌스 | 브로드캐스트하지 않음, 상호작용 시 표시하고 명단은 제한 |
한계와 트레이드오프
- 프레즌스는 확률적입니다. 하트비트 사이의 점은 추측입니다. 정직한 시스템은 즉각적인 진실을 가장하기보다 상한이 있는 데이터 지연(bounded staleness)을 받아들입니다.
- 최신성은 쓰기 비용을 치릅니다. 하트비트 간격을 절반으로 줄이면 모든 온라인 사용자에 걸쳐 쓰기 부하가 두 배가 됩니다. 감지 속도는 처리량을 대가로 얻는 것입니다.
- 팬아웃은 성공과 함께 커집니다. 이 기능은 사용자 1,000명일 때는 저렴하지만 1,000만 명일 때는 아키텍처 프로젝트가 됩니다. 성장에 떠밀리기 전에 구독 범위를 설계해야 합니다.
- 깜빡임은 피할 수 없습니다. 모바일 네트워크에서는 재연결 반복이 반드시 생깁니다. 디바운스가 없으면 프레즌스는 네트워크 잡음을 UI 잡음으로 증폭합니다.
- 감시와 닮은 기능입니다. 온라인 패턴은 수면, 업무, 습관을 드러냅니다. 공개 범위 제어와 권한 계층에서의 강제는 개선 사항이 아니라 필수 요건입니다.
Back4app에서의 프레즌스
Back4app은 관리형 데이터베이스, 자동 생성되는 REST 및 GraphQL API, 인증, 파일 스토리지, Cloud Code 서버리스 함수를 결합한 오픈소스 Backend as a Service(BaaS) 플랫폼입니다. 위의 구성 요소는 일대일로 대응합니다. Presence 클래스가 status와 lastActiveAt을 보관하고, 하트비트는 주기적인 저장(코드 탭 참고)입니다. 관찰자는 현재 화면에 있는 연락처에 대한 Live Query 구독을 유지하며, 플랫폼이 관리하는 WebSocket 인프라를 통해 푸시된 업데이트를 받습니다. 예약된 Cloud Code 작업이 TTL 스윕을 맡아 오래된 행을 오프라인으로 바꾸고 디바운스를 실행하며, 클래스 수준 권한과 ACL이 공개 범위 규칙을 원래 있어야 할 자리인 데이터 계층에서 구현합니다. 따로 운영할 맞춤 구성 요소는 없습니다. 프레즌스는 백엔드가 이미 운영하는 인프라 위에서의 데이터 모델링 작업이 됩니다.
자주 묻는 질문
프레즌스 시스템이란 무엇입니까?
각 사용자가 현재 온라인, 자리 비움, 오프라인 중 어느 상태인지 추적하고, 그 변화를 볼 권한이 있는 사용자에게 실시간으로 브로드캐스트하는 서비스입니다. 내부적으로는 지속 연결이나 주기적인 하트비트가 빠른 인메모리 저장소에 기록하고, 상태 변화는 pub/sub으로 팬아웃됩니다.
"마지막 접속"은 어떻게 동작합니까?
서버는 하트비트와 연결 종료가 있을 때마다 타임스탬프를 기록합니다. 온라인인 동안에는 실시간 상태를 제공하고, 오프라인이 되면 마지막으로 기록된 타임스탬프가 대신 답합니다. 본질적으로 휘발성인 시스템에서 유일하게 영속되는 부분입니다. 상태는 만료되지만 마지막 접속은 남습니다.
프레즌스에는 어떤 하트비트 간격을 사용해야 합니까?
프로덕션의 일반적인 기본값은 30초이며, 오프라인 타임아웃은 간격의 2–3배(60–90초)로 둡니다. 간격을 줄이면 감지 지연은 줄지만 쓰기 부하가 온라인 사용자 수에 비례해 늘어납니다. 또한 간격은 인프라의 유휴 타임아웃보다 짧아야 합니다. 그렇지 않으면 프록시가 조용한 연결을 먼저 끊어 버립니다.
연결 기반 감지와 하트비트 기반 감지 중 어느 쪽이 더 낫습니까?
둘 다 필요하며, 각각 다른 장애를 잡습니다. 연결 이벤트(열림/닫힘)는 즉각적인 전환을 주지만 조용한 종료를 놓칩니다. 전원이 꺼진 디바이스는 close 프레임을 보내지 않아 좀비 연결이 남습니다. 하트비트 만료는 감지 지연을 대가로 그 오래된 상태의 지속 시간에 상한을 둡니다. 프로덕션 시스템은 연결 이벤트를 빠른 경로로, 하트비트 TTL을 진실의 원천으로 사용합니다.
온라인 상태가 깜빡이는 것은 어떻게 막습니까?
오프라인 전환에 디바운스를 적용합니다. 연결이 끊기면 유예 타이머(보통 30–60초)를 시작하고, 사용자가 재연결하면 취소하며, 타이머가 만료될 때만 "오프라인"을 발행합니다. 엘리베이터나 지하철 터널 안의 사용자는 끊임없이 재연결합니다. 유예 기간이 없으면 그 사용자가 나타나는 모든 연락처 목록이 함께 깜빡입니다.
프레즌스는 수백만 사용자로 어떻게 확장됩니까?
팬아웃 규모를 감안해 설계해야 확장됩니다. 모든 상태 변화는 모든 관찰자에게 도달해야 하므로 연락처 N명 × 전환 M회는 금세 폭발합니다. 조절 수단은 상태를 위한 인메모리 TTL 저장소, 화면에 보이는 사용자만 구독하기(전체 연락처가 아닌 열린 채팅 목록), 마지막 접속은 푸시하지 않고 필요할 때 가져오기, 대규모 그룹에서의 상태 브로드캐스트 제한입니다.
멀티 디바이스 프레즌스는 어떻게 동작합니까?
디바이스별로 연결이나 하트비트를 추적하고 병합합니다. 어느 한 디바이스라도 온라인이면 사용자는 온라인이고, 마지막 디바이스가 조용해져야 오프라인이 됩니다. 일반적인 병합 정책은 가장 활성인 상태 우선입니다. 휴대폰의 온라인이 데스크톱의 유휴를 이기며, 마지막 접속은 가장 최근의 디바이스에서 가져옵니다.
사용자가 자신의 온라인 상태를 숨길 수 있습니까?
숨길 수 있어야 합니다. 공개 범위는 나중에 덧붙이는 것이 아니라 제품 기능입니다. 전체 공개, 연락처만, 비공개 같은 규칙에 보통 상호성(내 상태를 숨기면 다른 사람의 상태도 볼 수 없음)이 함께 붙습니다. 프레즌스 데이터는 행동 데이터이므로 누가 누구를 볼 수 있는지는 UI가 아니라 권한 모델에서 범위를 정해야 합니다.