[아키텍처] 채팅 시스템 설계
채팅은 실시간 통신 설계의 기본기가 모두 담긴 주제다. 단방향 통신에서 양방향 통신으로, 서버 1대에서 여러 대로, 일대일에서 대규모 그룹으로 규모를 키우며 어떤 문제가 새로 생기고 어떻게 푸는지 단계별로 정리한다. 통신 방식(poll·SSE·web socket)부터 시작해, 서버 대수를 늘릴 때 드러나는 연결 지역성 문제, 그룹 채팅의 팬아웃 증폭까지 다룬다.
기본 채팅 시스템
채팅의 목적
채팅은 사용자 간의 메시지를 전달하기 위한 시스템이다. 단순 메일처럼 메시지를 보내고, 일정 시간 뒤에 회신받는 정도가 아니라 짧은 문장을 빠르게 전달하는 것이 목적이다.
poll
양 사용자 간 메시지를 전달하기 위해 poll 방식을 선택할 수 있다. 사용자에게 메시지를 보내기 위해선 다음의 흐름으로 진행한다.
- 사용자 A는 서버로 메시지 전송 HTTP 요청을 보낸다.
- 서버는 이 요청을 전달받아 데이터베이스에 사용자의 메시지를 저장하고 종료한다.
- 사용자 B는 사용자 A가 메시지를 전송했음을 알 수 없으므로 지속적으로 사용자 A의 메시지가 왔는지 요청한다.
- 사용자 A의 메시지가 데이터베이스에 존재하면 서버는 사용자 A의 메시지를 응답한다.
sequenceDiagram participant A as 사용자 A participant S as 서버 participant DB as DB participant B as 사용자 B A->>S: 메시지 전송 (HTTP) S->>DB: 메시지 저장 S-->>A: 200 OK loop 주기적 polling B->>S: 새 메시지 있나요? (HTTP) S->>DB: 조회 DB-->>S: 결과 S-->>B: 메시지 또는 빈 응답 end
와 같은 과정으로 양 사용자는 메시지를 주고받을 수 있다. 가장 단순하게 채팅을 구현하자면 이런 방식으로 수행할 수 있으나 여러 문제점이 존재한다.
- polling의 주기는 어떻게 되는가
- 지속적으로 발생하는 조회 요청은 서버, DB의 리소스를 사용하는데, 자원 낭비가 발생하진 않는가
- 10명, 100명 … N명이 되었을 때 polling 요청을 감당하기 위해 서버를 스케일 아웃해야하는데, 불필요한 리소스 할당은 아닌가
즉, HTTP로 stateless하게 주고받는 요청은 단방향으로만 흐르기 때문에 반대쪽 클라이언트에서 메시지가 도착했음을 알기 위한 방법이 필요하다.
SSE
그럼 메시지를 보내고, 서버 측에서 상대방 클라이언트에게 메시지가 왔음을 알리는 방법도 있지 않을까? 이 방법이 SSE(Server Sent Event) 이다. SSE의 흐름은 다음과 같다.
- 클라이언트에서 서버로 Accept: text/event-stream 헤더를 보내고, 서버는 Content-Type: text/event-stream으로 응답한다.
- 서버와 클라이언트는 끊기지 않는 TCP 연결을 맺게 되고, 서버에서 이벤트가 발생하면 클라이언트에게 지속해서 메시지를 전달할 수 있다.
sequenceDiagram participant C as 클라이언트 participant S as 서버 C->>S: GET (Accept: text/event-stream) S-->>C: 200 (Content-Type: text/event-stream) Note over C,S: 하나의 연결을 계속 유지 S-->>C: event: message S-->>C: event: message S-->>C: event: message
SSE는 단방향으로 클라이언트에게 지속적으로 이벤트를 푸시해줄 수 있으며, HTTP 프로토콜이라는 면에서 기술적 변경 없이 실시간 처리를 구현할 수 있다.
다만, 서버와 클라이언트 간 지속적으로 커넥션을 유지해야하기 때문에 멀티 인스턴스 상황에서의 session 관리, 커넥션 관리 등을 고려해야 한다.
web socket
웹 소켓(WebSocket)은 앞선 단방향 통신들과 달리 하나의 연결 위에서 클라이언트와 서버가 서로 자유롭게 메시지를 주고받는 양방향 프로토콜이다. SSE가 서버에서 클라이언트로 이벤트를 밀어주는 것까지만 담당했다면, 웹 소켓은 같은 연결로 클라이언트도 서버에 즉시 메시지를 보낼 수 있다. 채팅처럼 양쪽이 번갈아 메시지를 던지는 상황에 가장 자연스럽게 맞는다.
웹 소켓 연결은 HTTP에서 시작한다. 흐름은 다음과 같다.
- 클라이언트가 일반 HTTP GET 요청에
Upgrade: websocket,Connection: Upgrade,Sec-WebSocket-Key헤더를 실어 보낸다. 이 단계를 핸드셰이크(handshake)라 한다. - 서버가 요청을 수락하면
101 Switching Protocols로 응답하고, 이 시점부터 같은 TCP 연결이 HTTP가 아닌 웹 소켓 프레임(frame)을 주고받는 통로로 전환된다. - 이후 클라이언트와 서버는
ws://(평문) 또는wss://(TLS) 위에서 프레임 단위로 텍스트와 바이너리를 양방향으로 교환한다.
sequenceDiagram participant C as 클라이언트 participant S as 서버 C->>S: GET (Upgrade: websocket) S-->>C: 101 Switching Protocols Note over C,S: 같은 TCP 연결이 웹 소켓 프레임 통로로 전환 C->>S: 프레임 (텍스트/바이너리) S-->>C: 프레임 (텍스트/바이너리) C->>S: 프레임 S-->>C: 프레임
한 번 연결을 맺으면 요청마다 HTTP 헤더를 다시 붙이지 않는다. 프레임은 헤더가 매우 가벼워서, poll이 매 요청마다 수백 바이트의 헤더를 반복 전송하던 낭비가 사라진다. 사용자 A가 보낸 메시지는 서버를 거쳐 사용자 B의 커넥션으로 바로 전송되고, B의 응답도 같은 커넥션으로 되돌아온다.
SSE와 web socket의 차이
SSE와 웹 소켓은 둘 다 연결을 유지한 채 서버가 클라이언트에게 실시간으로 데이터를 밀어줄 수 있다는 점에서 비슷하다. 그러나 통신 방향과 프로토콜 성격이 달라 적합한 상황이 갈린다.
| 항목 | SSE | web socket |
|---|---|---|
| 통신 방향 | 서버에서 클라이언트로 단방향 | 클라이언트와 서버 양방향 |
| 프로토콜 | HTTP 그대로 (text/event-stream) | HTTP 핸드셰이크 후 웹 소켓 프레임(ws/wss) |
| 연결 수립 | 일반 GET 요청 + Accept: text/event-stream | Upgrade 헤더로 101 Switching Protocols 전환 |
| 데이터 형식 | UTF-8 텍스트 이벤트만 | 텍스트와 바이너리 프레임 모두 |
| 클라이언트 → 서버 전송 | 별도 HTTP 요청을 새로 열어야 함 | 이미 열린 같은 연결로 즉시 전송 |
| 재연결 | 브라우저 EventSource 가 자동 재연결하고 Last-Event-ID 로 유실 구간 복구 | 표준 자동 재연결이 없어 애플리케이션이 직접 구현 |
| 인프라 친화성 | 일반 HTTP라 프록시·방화벽 통과가 쉬움 | Upgrade 를 막는 중간 장비가 있으면 연결이 실패할 수 있음 |
채팅 관점에서 둘의 결정적 차이는 클라이언트에서 서버로 보내는 경로다. SSE는 받는 쪽만 스트림으로 열려 있으므로, 사용자가 메시지를 보낼 때는 별도의 HTTP 요청을 해야한다. 즉 SSE 기반 채팅은 수신은 SSE, 발신은 일반 HTTP 요청이라는 두 경로가 공존한다. 반면 웹 소켓은 발신과 수신이 하나의 연결에 얹히므로 구조가 단순해지고, 매 발신마다 연결을 새로 세우는 비용도 없다.
그래서 서버가 알림을 흘려보내기만 하면 되는 실시간 대시보드, 주가와 피드 갱신 같은 단방향 시나리오에는 SSE가 가볍다. 반대로 양쪽이 대등하게 메시지를 주고받고 지연에 민감한 채팅, 협업, 게임 같은 상황에는 웹 소켓이 적합하다. 다만 웹 소켓도 SSE와 마찬가지로 연결을 계속 유지하므로, 멀티 인스턴스 환경에서의 세션의 연결 관리와 재연결 처리는 설계에서 풀어내야 한다.
일대일 채팅 시스템 (서버 1대)
웹 소켓으로 넘어오면 서버는 상태를 가진다. HTTP 요청처럼 왔다 가는 것이 아니라, 접속한 사용자마다 열린 연결을 서버가 계속 붙잡고 있어야 한다. 그래서 서버는 접속자마다 그 사용자의 세션을 메모리에 보관한다. 보통 userId를 키로, 웹 소켓 세션을 맵으로 갖고 있다.
메시지 전달은 이 맵을 조회하고, 전달된다. 사용자 A가 사용자 B에게 보낸 프레임이 서버에 도착하면, 서버는 맵에서 B의 세션을 찾아 그 세션으로 프레임을 전송한다. 동시에 메시지를 DB에 저장해, B가 접속 중이 아니거나 나중에 히스토리를 볼 때를 대비한다.
@Component
class ChatWebSocketHandler(
private val messageStore: MessageStore,
) : TextWebSocketHandler() {
// userId 를 키로 열린 세션을 보관. 단일 서버의 메모리에만 존재한다.
private val sessions = ConcurrentHashMap<Long, WebSocketSession>()
override fun afterConnectionEstablished(session: WebSocketSession) {
sessions[session.userId()] = session
}
override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
val chat = message.toChatMessage()
messageStore.save(chat) // 저장
sessions[chat.receiverId] // 수신자 세션 조회
?.sendMessage(TextMessage(chat.toJson())) // 프레임 전달
}
override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
sessions.remove(session.userId())
}
}구조는 단순하지만 한계가 뚜렷하다. 모든 연결이 한 프로세스의 메모리에 있으므로, 접속자 수가 곧 그 서버의 메모리와 파일 디스크립터 한계에 부딪힌다. 서버가 죽으면 모든 연결이 함께 끊기는 단일 장애점(single point of failure)이기도 하다.
일대일 채팅 시스템 (서버 2대)
서버를 2대로 늘리면 로드 밸런서가 접속을 분산한다. 사용자 A는 서버 1에, 사용자 B는 서버 2에 붙을 수 있다. 이때 A가 B에게 보낸 메시지는 서버 1에 도착하지만, 서버 1의 세션 맵에는 B가 없다. B의 연결은 서버 2의 메모리에 있기 때문이다. 이것이 연결 지역성(connection locality) 문제다. 인메모리 맵은 자기 서버에 붙은 사용자만 알 뿐, 다른 서버의 연결은 보지 못한다.
해결의 핵심은 글로벌로 서버 간 메시지를 전파할 저장소를 두는 것이다. 가장 단순한 방법은 Redis Pub/Sub 같은 발행-구독 채널이다. 모든 서버가 공통 채널을 구독하고, 메시지를 받은 서버는 그 채널에 발행한다. 각 서버는 채널로 흘러온 메시지의 수신자가 자기 메모리에 있으면 전달하고, 없으면 무시한다.
@Component
class ChatRelay(
private val redisTemplate: StringRedisTemplate,
private val localSessions: LocalSessionRegistry, // 이 서버에 붙은 세션
) : MessageListener {
// A 가 보낸 메시지를 일단 공통 채널에 publish 한다.
fun relay(chat: ChatMessage) {
redisTemplate.convertAndSend("chat", chat.toJson())
}
// 모든 서버가 구독. 수신자가 내 메모리에 있을 때만 전달한다.
override fun onMessage(message: Message, pattern: ByteArray?) {
val chat = message.toChatMessage()
localSessions.find(chat.receiverId)?.sendMessage(chat)
}
}특정 사용자를 항상 같은 서버로 보내는 sticky 라우팅도 떠올릴 수 있다. 그러나 대화 상대가 매번 달라지는 채팅에서는 두 사용자를 같은 서버에 고정하기 어렵다.
일대일 채팅 시스템 (서버 10대)
Pub/Sub 브로드캐스트는 서버가 몇 대 안 될 때는 잘 동작하지만, 서버가 10대로 늘면 낭비가 발생한다. 한 서버가 채널에 발행하면 fan-out이기 때문에 10대 전부가 그 메시지를 받는다. 그러나 수신자의 연결을 실제로 처리할 곳은 1대뿐이고, 나머지 9대는 버린다. 서버가 N대면 메시지 1건마다 N번 전달되는 셈이라, 규모가 커질수록 브로드캐스트 비용이 선형으로 늘어난다.
해결책은 수신자가 어느 서버에 붙어 있는지 아는 것이다. 세션 레지스트리(session registry)를 Redis에 두고 userId를 serverId로 매핑한다. 사용자가 접속하면 자기 serverId를 레지스트리에 기록하고, 끊기면 지운다. A가 B에게 보낼 때 발신 서버는 레지스트리에서 B의 serverId를 조회한 뒤, 그 서버에만 지향 전달(directed routing)한다.
@Component
class DirectedChatRelay(
private val redisTemplate: StringRedisTemplate,
private val messageStore: MessageStore,
@Value("\${server.id}") private val serverId: String,
) {
// 접속 시 어느 서버에 붙었는지 기록 (TTL 로 stale 엔트리 방어)
fun register(userId: Long) {
redisTemplate.opsForValue()
.set("session:$userId", serverId, Duration.ofSeconds(30))
}
fun relay(chat: ChatMessage) {
val targetServer = redisTemplate.opsForValue().get("session:${chat.receiverId}")
if (targetServer == null) {
messageStore.save(chat) // 오프라인이면 저장만
return
}
redisTemplate.convertAndSend("server:$targetServer", chat.toJson()) // 그 서버에만
}
}서버가 비정상 종료하면 레지스트리에 stale 엔트리가 남는다. 그래서 TTL과 주기적 헬스체크로 만료시킨다. 여기까지 오면 연결을 받는 계층과 메시지를 라우팅하는 계층을 분리하는 구조가 자연스러워진다. 접속을 받는 게이트웨이 서버군과, 메시지를 어디로 보낼지 판단하는 라우팅 계층으로 역할이 갈린다.
그룹 채팅 시스템 (3명)
일대일이 그룹으로 확장되면 방(room) 개념을 사용할 수 있다. 메시지는 특정 개인이 아니라 그 방의 멤버 전원에게 전달된다. 이렇게 하나의 메시지를 여러 수신자에게 복제해 보내는 것을 팬아웃(fan-out)이라 한다.
3명짜리 방은 팬아웃이 작다. A가 방에 메시지를 보내면 서버는 방 멤버십(room → [A, B, C])을 조회하고, 발신자 A를 뺀 B와 C에게 전달한다. 멤버가 서로 다른 서버에 붙어 있으면 앞서 본 세션 레지스트리로 각자의 서버를 찾아 지향 전달한다. 전달 규모가 작아 일대일과 크게 다르지 않다.
방 단위로 메시지를 저장해 히스토리와 읽음 처리를 지원한다. 메시지 순서는 방 단위로만 보장하면 충분하다. 같은 방의 메시지가 뒤섞이지 않도록 방 식별자를 기준으로 순서를 맞춘다.
그룹 채팅 시스템 (1000명)
멤버가 1000명이 되면 팬아웃 대상이 많아진다. 메시지 1건이 곧바로 1000건의 전달로 늘어난다. 이것을 팬아웃 증폭(fan-out amplification)이라 한다. 초당 여러 명이 동시에 말하면 전달량은 다시 그만큼 곱해진다.
첫째, 연결 분산이다. 1000명은 여러 서버에 흩어져 붙어 있다. 발신 서버가 1000명 각자에게 직접 프레임을 밀면 서버 간 트래픽이 폭발한다. 대신 서버 단위 팬아웃을 쓴다. 발신 서버는 방 채널에 딱 1번만 발행하고, 그 방의 멤버를 가진 각 서버가 자기 로컬 멤버에게만 분배한다. 서버 간 전달은 서버 수만큼으로 억제되고, 실제 1000건의 전달은 각 서버 안에서 로컬로 처리된다.
둘째, 쓰기 팬아웃과 읽기 팬아웃의 선택이다.
| 방식 | 전달 시점 | 장점 | 한계 |
|---|---|---|---|
| 쓰기 팬아웃(fan-out on write) | 발신 즉시 모든 연결로 | 낮은 지연 | 대형 방에서 전달량 폭증 |
| 읽기 팬아웃(fan-out on read) | 클라이언트 조회 시 | 발신 부하 완화 | 실시간성 약화, 조회 비용 증가 |
일반 채팅은 쓰기 팬아웃으로 실시간성을 확보하고, 수만 명 규모의 방송형 채널은 읽기 팬아웃으로 발신 부하를 던다.
셋째, 핫 룸(hot room) 문제다. 초대형 방 하나가 특정 채널과 서버에 부하를 몰아준다. 서버 단위 팬아웃, 전달 배치, 그리고 클라이언트가 못 따라올 때 전송 속도를 늦추는 백프레셔(backpressure)로 완화한다. 저장 쪽은 방 메시지를 저장하고 멤버별 읽음 커서를 따로 관리해, 각자 어디까지 읽었는지 추적한다.
주의사항과 베스트프랙티스
- 연결 생존 확인: 끊긴 연결을 붙잡고 있으면 메모리가 샌다. 핑퐁(ping/pong) 하트비트로 죽은 연결을 감지해 정리한다.
- 재연결: 웹 소켓은 표준 자동 재연결이 없다. 클라이언트가 지수 백오프로 재연결하고, 재연결 후 놓친 메시지를 마지막 수신 지점 기준으로 다시 받아야 한다.
- 메시지 중복과 순서: 재전송이나 재연결로 같은 메시지가 두 번 올 수 있다. 메시지에 고유 ID를 부여해 멱등하게 처리하고, 방 단위 순서를 보장한다.
- 오프라인 전달: 수신자가 접속 중이 아니면 실시간 전달은 실패한다. DB에 저장해 두었다가 재접속 시 동기화하거나, 푸시 알림으로 우회한다.
- 세션 레지스트리 정합성: 비정상 종료가 남긴 stale 엔트리는 TTL과 헬스체크로 만료시킨다. 레지스트리가 틀리면 메시지가 엉뚱한 서버로 간다.
- 팬아웃 억제: 대형 방은 서버 단위 팬아웃으로 서버 간 트래픽을 줄이고, 필요하면 읽기 팬아웃으로 전환한다.
- 백프레셔: 느린 클라이언트가 전체를 느리게 만들지 않도록 전송 버퍼 한계를 두고, 초과하면 연결을 끊거나 메시지를 건너뛴다.
규모 단계별로 정리하면 다음과 같다.
| 단계 | 핵심 문제 | 해결 |
|---|---|---|
| 서버 1대 | 단일 장애점, 연결 수 한계 | 인메모리 세션 맵 (가장 단순) |
| 서버 2대 | 연결 지역성 | Redis Pub/Sub 브로드캐스트 |
| 서버 10대 | 브로드캐스트 낭비 | 세션 레지스트리 + 지향 전달 |
| 그룹 3명 | 팬아웃 도입 | 룸 멤버십 기반 복제 전달 |
| 그룹 1000명 | 팬아웃 증폭, 핫 룸 | 서버 단위 팬아웃, 읽기 팬아웃, 백프레셔 |