WebSocket을 기반으로 채팅 기능을 구현하던 중, 다음과 같은 문제가 발생했다:
⚠️수신자가 disconnect 상태일 경우, 메시지가 전송되지 않거나 유실되는 상황
이는 단순한 장애가 아니라, 사용자 입장에서 "보냈는데 안 온 메시지"가 발생한다는 점에서 신뢰도에 치명적인 설계 결함이었다.
또한 일반적인 메시징 서비스에서 상대방이 오프라인이더라도 메시지는 전송 가능하고, 이후 접속 시 확인 가능한 구조가 보편적인 UX라는 점에서 반드시 해결이 필요한 문제였다.
WebSocket은 본질적으로 지속 연결 기반의 양방향 스트리밍이지만, 이 연결은 클라이언트가 페이지를 종료하거나 네트워크가 불안정하면 쉽게 끊길 수 있다.
하지만 우리는 다음과 같은 전제 조건을 필요로 했다:
메시지는 수신자가 오프라인이라도 발신자 입장에서 전송 실패가 발생하지 않아야 한다.
수신자가 추후 재접속하면 해당 메시지를 확인할 수 있어야 한다.
즉, 연결 상태와 관계없이 일관된 메시지 처리 보장이 필요했다.
이를 해결하기 위해 가상의 연결 상태를 정의했다.
즉, 실제 WebSocket 연결 여부가 아니라, “유저가 얼마 전까지 연결되어 있었는지”를 기반으로 논리적 연결 상태를 판단하는 구조다.
✅ 핵심 구현 방식은 다음과 같다:
- 하트비트(heartbeat): 프론트엔드에서 일정 주기로 신호를 보내고, 백엔드에서는 이를 수신 시간 기준으로 저장한다.
- Map 자료구조를 사용해 userId → lastHeartbeatTime 형태로 구성.
덮어쓰기 방식으로 항상 최신 상태만 유지하여, 메모리 효율성도 확보했다.- Scheduled Task를 통해 일정 주기마다 하트비트 수신 여부를 확인하고, 30초 이상 신호가 없으면 오프라인으로 간주했다.
그러나 문제는 여기서 끝나지 않았다.
이 구조는 HTTP 기반의 요청에서는 유효했지만, STOMP(WebSocket) 계층에서는 유저의 상태를 알 수 없다는 프로토콜 간 상태 단절 문제가 있었다.
HTTP는 비연결 기반으로 매 요청마다 상태 확인이 가능
STOMP는 지속 연결 기반으로 초기 핸드셰이크 이후에는 상태를 전파받지 못함
따라서 HTTP 계층에서 추적 중인 가상 연결 상태를 STOMP 계층에 공유할 수 있어야 했다.
HttpHandshakeInterceptor
HTTP 세션 정보를 WebSocket 세션에 넘겨주는 구조.
하지만 disconnect된 유저는 세션 자체가 존재하지 않기 때문에 무의미했다.
WebSocketSession
STOMP 명령 중 DISCONNECT를 직접 핸들링하려 했지만,
WebSocketSession은 해당 시점의 유저 상태나 식별 정보 접근이 어려워 제한적이었다.
Redis
- HTTP와 STOMP 모두 접근 가능한 인메모리 저장소
- 세션 정보가 아닌, 직접 정의한 유저 상태값(key-value)로 구성
- 빠른 응답성과 비동기 동작으로 자원 낭비 최소화
- Docker 환경에서 volumes 설정으로 재시작 시에도 복원 가능

테스트코드도 정상동작하는 걸 볼 수 있다.
수신자가 오프라인이더라도 메시지는 정상적으로 저장되고, 이후 접속 시 해당 메시지를 지연 수신할 수 있는 구조가 완성되었다.
Redis 기반 구조 덕분에 다중 서버 간 세션 공유, 수평 확장, 마이크로서비스 전환 등 확장성 기반도 자연스럽게 확보되었다.
이 경험을 통해 느낀 건, 단순히 작동하는 코드를 작성하는 것이 아니라, 동작 방식의 배경과 설계 이유를 설명할 수 있어야 한다는 것이었다.
실제로도 이번 구조는 단 한 줄의 오류 메시지도 없이, 설계의 허점에서 비롯된 기능 결함이었고,
그것을 해결하기 위해 프로토콜 차이를 공부하고, 상태 공유를 위한 구조를 비교하고, 적절한 기술 조합을 설계하는 일이 필요했다.
이런 설계 경험 하나하나가 개발자의 실력을 만드는 것임을 절감했다.