“메시지를 보냈는데 왜 안 오지?”
채팅 기능은 데이팅 앱에서 가장 중요한 접점입니다. 매칭 알고리즘이 아무리 정교해도, 두 사람이 실제로 대화를 나누는 순간이 없으면 의미가 없습니다. 그리고 채팅에서 가장 치명적인 경험은 “내가 보낸 메시지가 상대방한테 도착했나?” 하는 불확실성입니다.
썸블링의 채팅 시스템을 설계하면서 우리가 풀어야 할 문제는 단순히 “메시지를 전달하는 것”이 아니었습니다. 메시지가 즉시 화면에 보여야 하고, 상대방이 입력 중인지 알아야 하고, 내가 자리를 비운 사이 온 메시지가 몇 개인지 뱃지로 보여야 하고, 앱을 다시 열었을 때 이전 대화를 자연스럽게 이어가야 합니다. 이 모든 것을 안정적으로 만드는 것이 목표였습니다.
왜 순수 WebSocket이 아닌 STOMP인가
WebSocket은 양방향 통신 채널을 열어주지만, 그 위에서 무엇을 어떻게 주고받을지는 전혀 정해주지 않습니다. 순수 WebSocket으로 채팅을 구현하면 다음 문제들을 직접 해결해야 합니다.
메시지 라우팅. A가 보낸 메시지를 같은 채팅방에 있는 B에게만 전달해야 합니다. 서버가 연결된 모든 WebSocket 세션을 관리하고, 채팅방 ID 기준으로 수신자를 찾아 메시지를 분배해야 합니다. 직접 구현하면 복잡하고 버그가 생기기 쉽습니다.
재연결 처리. 모바일 환경에서는 네트워크가 자주 끊깁니다. 지하철을 탈 때, 와이파이에서 LTE로 전환될 때, 화면이 꺼질 때마다 WebSocket 연결이 끊길 수 있습니다. 재연결 로직을 클라이언트와 서버 양쪽에서 직접 구현해야 합니다.
인증 통합. 연결 수립 시점과 메시지 전송 시점 모두에서 JWT 토큰을 검증해야 합니다.
STOMP(Simple Text Oriented Messaging Protocol)는 이 문제들을 프로토콜 레벨에서 해결합니다. 구독(SUBSCRIBE)/발행(SEND)/수신(MESSAGE)의 명확한 프레임 구조가 있고, Spring WebSocket이 STOMP를 네이티브로 지원해 라우팅과 세션 관리를 프레임워크에 위임할 수 있습니다.
SockJS 폴백도 중요한 이유였습니다. 일부 기업 네트워크나 구형 환경에서는 WebSocket이 차단됩니다. SockJS는 WebSocket이 안 되면 Long-polling 등 대안으로 자동 폴백합니다. 사용자 입장에서는 동일한 경험입니다.
채팅 메시지 흐름
사용자가 메시지를 보낼 때 어떤 일이 일어나는지 단계별로 살펴봅니다.
sequenceDiagram
participant A as 사용자 A
participant C as Client (Next.js)
participant S as Spring STOMP
participant DB as PostgreSQL
participant R as Redis
participant B as 사용자 B
A->>C: 메시지 입력 · 전송
C->>C: 낙관적 업데이트 (즉시 화면 표시)
C->>S: STOMP SEND /app/chat/{roomUuid}/send
S->>S: JWT 검증 · 채팅방 권한 확인
S->>DB: 메시지 저장 (INSERT)
S->>R: INCR chat:unread:{roomId}:{userId}
S-->>C: /user/{A}/queue/messages (발신 확인)
S-->>B: /user/{B}/queue/messages (수신)
B-->>B: 화면에 메시지 표시
1. 클라이언트 → 서버 (STOMP SEND)
SEND
destination:/app/chat/{roomUuid}/send
content-type:application/json
{"content": "안녕하세요!", "type": "TEXT"}
클라이언트는 채팅방 UUID를 포함한 destination으로 메시지를 발행합니다. UUID를 쓰는 이유는 내부 DB ID(Long)를 외부에 노출하지 않기 위해서입니다. 채팅방 ID가 순차적 숫자라면 chat/1, chat/2처럼 다른 사람의 채팅방에 접근을 시도할 수 있습니다.
2. 서버 처리
Spring의 @MessageMapping("/chat/{roomUuid}/send") 핸들러가 메시지를 받습니다. 이때 세 가지 검증을 수행합니다: JWT 토큰 유효성, 해당 채팅방 존재 여부, 발신자가 해당 채팅방의 참여자인지. 검증을 통과하면 메시지를 DB에 저장하고 수신자에게 전달합니다.
3. 서버 → 클라이언트 (개인 큐)
MESSAGE
destination:/user/{userId}/queue/messages
{"messageId": 445568, "roomUuid": "abc-123-def", "content": "안녕하세요!", ...}
Spring STOMP의 /user/{userId}/queue/ 패턴은 특정 사용자에게만 메시지를 전달합니다. 채팅방의 모든 참여자를 순회하며 각자의 개인 큐에 메시지를 넣습니다.
Redis: 실시간 상태 관리의 핵심
메시지 자체는 PostgreSQL에 영구 저장합니다. 하지만 실시간성이 필요한 휘발성 상태들은 Redis가 훨씬 적합합니다.
presence:{memberId} — 온라인 상태
SET presence:123456 "1706123456789" EX 300
TTL 5분의 Redis 키로 온라인 상태를 관리합니다. WebSocket 연결이 수립되면 이 키를 설정하고, 연결이 끊기면 삭제합니다. 추가로 30초마다 클라이언트가 heartbeat를 보내 TTL을 갱신합니다.
TTL 방식의 장점은 서버가 비정상 종료되어도 5분 후 자동으로 오프라인 상태가 됩니다. 명시적인 정리 로직 없이도 상태가 자연스럽게 소멸합니다.
채팅방에서 상대방 온라인 상태를 표시할 때는 단순히 이 키의 존재 여부를 확인합니다.
boolean isOnline = Boolean.TRUE.equals(redisTemplate.hasKey("presence:" + memberId));
chat:typing:{roomId}:{memberId} — 타이핑 인디케이터
SET chat:typing:112233:123456 "1" EX 5
TTL 5초짜리 키입니다. 사용자가 키보드를 누르는 순간 이 키를 설정합니다. 5초 안에 다시 키를 누르지 않으면 자동으로 만료됩니다. “입력 중…” 표시가 자연스럽게 사라집니다.
클라이언트는 키 입력 이벤트에 debounce(2초)를 적용해 STOMP 메시지를 서버로 보냅니다. 매 키 입력마다 보내면 트래픽 낭비입니다.
// 프론트엔드: 타이핑 이벤트 발송
const debouncedTyping = useMemo(
() => debounce((roomId: string) => {
sendTyping(roomId, true);
}, 500),
[]
);
chat:unread:{roomId}:{memberId} — 읽지 않은 메시지 수
INCR chat:unread:112233:789012
메시지가 도착할 때마다 수신자의 unread 카운터를 원자적으로 증가시킵니다. Redis의 INCR은 원자 연산이므로 동시에 여러 메시지가 도착해도 카운터가 정확합니다.
채팅방에 입장하면(읽음 처리) 카운터를 삭제합니다. BottomNav의 채팅 탭 뱃지는 모든 채팅방의 unread 합산값을 표시합니다.
chat:last-seen:{memberId} — 마지막 접속 시각
SET chat:last-seen:123456 "1706123456789" EX 604800
TTL 7일. 상대방이 오프라인일 때 “마지막 접속 3시간 전”처럼 표시하기 위한 데이터입니다. WebSocket 연결이 끊길 때 현재 타임스탬프를 저장합니다.
낙관적 업데이트: 체감 속도의 비밀
사용자가 전송 버튼을 눌렀을 때 서버 응답을 기다렸다가 메시지를 표시하면 어떻게 될까요? 네트워크 상황에 따라 100ms~500ms의 지연이 생깁니다. 채팅에서 이 지연은 매우 불편합니다.
썸블링은 **낙관적 업데이트(Optimistic Update)**를 사용합니다. 전송 버튼을 누르는 순간 임시 메시지 객체를 생성해 화면에 즉시 표시합니다. 동시에 서버로 메시지를 전송합니다. 서버 응답이 오면 임시 메시지를 실제 메시지로 교체합니다.
const sendMessage = (roomId: string, content: string) => {
// 1. 임시 메시지를 즉시 화면에 표시
const optimisticMessage: ChatMessage = {
messageId: Date.now(), // 임시 ID (음수나 timestamp로 실제 ID와 구분)
content,
senderId: currentUserId,
status: 'SENDING',
createdAt: new Date().toISOString(),
};
addMessage(roomId, optimisticMessage);
// 2. STOMP로 서버에 전송
stompClient.publish({
destination: `/app/chat/${roomId}/send`,
body: JSON.stringify({ content, type: 'TEXT' }),
});
};
서버에서 에러가 반환되면 임시 메시지의 status를 FAILED로 변경하고 재시도 버튼을 표시합니다. 사용자는 전송 실패를 인지하고 다시 시도할 수 있습니다.
REST API 폴백
WebSocket은 안정적이지만, 100% 신뢰할 수는 없습니다. 연결 수립에 실패하거나 불안정한 네트워크 환경에서는 메시지 전송 자체가 불가능해집니다.
썸블링은 WebSocket 전송 실패 시 REST API로 폴백합니다.
const sendMessage = async (roomId: string, content: string) => {
if (isStompConnected()) {
// WebSocket으로 전송
stompClient.publish({ ... });
} else {
// REST API 폴백
await api.post(`/api/v1/chat/rooms/${roomId}/messages`, { content });
}
};
REST 폴백으로 보낸 메시지는 폴링을 통해 화면을 갱신합니다. 실시간성은 약간 떨어지지만 메시지 유실은 없습니다. 폴백 상황임을 UI로 명확히 표시해 사용자가 연결 상태를 인식할 수 있도록 합니다.
커서 기반 페이지네이션: offset의 함정
채팅 히스토리를 불러올 때 offset 기반 페이지네이션을 쓰면 안 됩니다.
LIMIT 20 OFFSET 40으로 3페이지를 불러오는 사이에 새 메시지 5개가 도착했다고 가정합니다. 다음 페이지를 요청하면 실제로는 4페이지가 아닌 4.25페이지 어딘가부터 시작합니다. 메시지 중복이 생깁니다.
커서 기반 페이지네이션은 마지막으로 본 메시지 ID를 기준점으로 씁니다.
SELECT * FROM chat_messages
WHERE chat_room_id = :roomId
AND id < :cursor -- cursor = 현재 화면에서 가장 오래된 메시지 ID
ORDER BY id DESC
LIMIT 20
새 메시지가 아무리 도착해도 cursor 이전의 메시지 집합은 변하지 않습니다. 페이지 경계가 항상 안정적입니다. 무한 스크롤 구현에서 이것이 핵심입니다.
수평 확장의 문제
현재 아키텍처의 한계는 WebSocket 서버가 단일 인스턴스일 때 잘 작동한다는 점입니다. 서버를 2대로 늘리면 문제가 생깁니다. A 사용자는 서버1에, B 사용자는 서버2에 연결되어 있을 때, A가 보낸 메시지를 서버1이 서버2의 B에게 전달할 방법이 없습니다.
flowchart TD
subgraph Current["현재: 인메모리 브로커"]
A1[사용자 A] --> S1[서버 1]
B1[사용자 B] --> S1
S1 -->|SimpleBroker| S1
end
subgraph Future["확장: 외부 메시지 브로커"]
A2[사용자 A] --> S2[서버 1]
B2[사용자 B] --> S3[서버 2]
S2 <-->|StompBrokerRelay| MQ[RabbitMQ]
S3 <-->|StompBrokerRelay| MQ
end
이 문제의 해법은 외부 메시지 브로커입니다. 각 서버 인스턴스가 RabbitMQ나 Redis Pub/Sub를 통해 메시지를 공유하면 어느 서버에 연결되어 있어도 메시지를 받을 수 있습니다.
Spring STOMP는 외부 메시지 브로커 연동을 위한 StompBrokerRelayMessageHandler를 지원합니다. 현재는 인메모리 브로커를 쓰고 있지만, 트래픽이 증가하면 RabbitMQ나 ActiveMQ로 전환할 계획입니다.
// 현재: 인메모리 브로커
registry.enableSimpleBroker("/user", "/topic");
// 확장 시: 외부 브로커 (RabbitMQ)
registry.enableStompBrokerRelay("/user", "/topic")
.setRelayHost("rabbitmq-host")
.setRelayPort(61613);
코드 변경이 최소화됩니다. 인프라 레벨의 변경으로 수평 확장이 가능한 구조를 미리 준비해두는 것이 핵심입니다.
현재 시스템의 지표
직접 측정한 현재 지표들입니다.
- 메시지 전달 지연 (WS): p50 45ms, p99 180ms
- 타이핑 인디케이터 지연: p50 30ms
- Redis 명령 응답 시간: p99 2ms 미만
- WebSocket 재연결 성공률: 98.7% (5초 이내)
- REST 폴백 비율: 전체 메시지의 약 0.3%
수치보다 중요한 것은 사용자 경험입니다. “내가 보낸 메시지가 즉시 화면에 보이고, 상대방이 읽었는지 알 수 있으며, 앱을 다시 열어도 대화 흐름이 자연스럽게 이어진다.” 이것이 목표이고, 낙관적 업데이트와 Redis 기반 상태 관리가 이 경험을 만드는 핵심 요소입니다.
채팅은 기능이 아니라 경험입니다. 기술이 뒤에서 조용히 작동할 때, 사용자는 대화에 집중할 수 있습니다.
GitHub 계정으로 로그인하여 댓글을 남길 수 있습니다. 허위사실, 욕설, 사칭 등의 댓글은 통보 없이 삭제될 수 있습니다.