이 글에서는 Kafka 기반의 대규모 채팅 서비스를 직접 설계하고 구현하며 마주친 문제와, 이를 어떻게 해결했는지를 기록합니다.
인프라 아키텍처부터 코드 레벨의 구조 개선, 서비스의 생명주기 까지 순차적으로 회고합니다.
1. 서비스 구성을 위해 정해야 할 것
각 노드간 통신을 어떻게 해야할지?
단일 노드 사용 시 성능 한계와 노드 다운에 대한 안정성 문제가 있어, 멀티 노드로 구성하겠습니다.
그럼 멀티 노드간에 사용자에 대한 통신 문제를 아래 '짱구' 와 '철수' 예제로 보겠습니다.

(Node는 서버 또는 Pod를 의미하며, client는 현재 연결된 connection을 의미합니다.)
위와 같이 Node를 구성 할 경우 '짱구'와 '철수'는 각기 다른 Node를 통신하기에 메시지 송수신이 불가능합니다.
짱구와 철수가 통신을 가능하도록 하려면 Node1과 Node2 사이의 통신이 필요하며,
통신 방법으로 TCP, HTTP, Redis, RDBMS, Kafka 등의 통신 방식을 사용해볼 수 있습니다.
이중에서 Kafka를 선택하였습니다.
TCP, HTTP, Redis, RDBMS, Kafka 중 Kafka를 선택한 이유
이 중 RDBMS는 insert 메시지 된 데이터를 가져오는 message polling을 직접 구현해야하므로 제외하겠습니다.
Redis는 pub/sub 구조를 쓸 수 있으나, 일관성이 Kafka보다는 낮기때문에 제외하겠습니다.
그리고 TCP, HTTP 방식은 통신에 적합하지만, Node 교체와 Auto Scale-out 을 고려했을 때 연결 설정에 대한 복잡성이 있으므로 이 또한 제외하겠습니다.
통신과 안정성, 그리고 Auto Scale-out 을 고려하며, 각 노드별로 Group을 분리하면 Broadcast 처리가 가능하기에 적합한 인프라라고 생각되어 Kafka를 선택하겠습니다.
(만약 개별 group으로 인한 Kafka 부하가 너무 커진다고 한다면 DB sharding을 포함한 RDBMS outbox 처리도 고려해볼 수 있을 것 같습니다.)

이렇게 구성하고 짱구 가 메시지를 보내면 Kafka를 통해서 Node 2에 메시지가 전달되고 철수에게 메시지를 보낼 수 있습니다.
구조를 키워보면 다음과 같습니다.

사용자와는 어떻게 통신해야 하는지?
노드 1을 가져와보겠습니다.

여기서의 HTTP 통신은 server 의 push를 구현해야 하고 불필요한 통신 작업(3-way/4-way handshake 등)이 중간중간 포함되므로,
TCP 통신이 적합하다고 생각되어 TCP통신을 선택하였습니다.
그러나 ServerSocket 과 같은 Blocking I/O 방식을 사용한다고 했을 때 아래와 같은 기본 리소스가 필요합니다.
- n명의 클라이언트 메시지 수신 : n개의 thread 리소스
- 클라이언트에게 메시지 발송 : 최소 1개의 thread 리소스
이론적으로 1만 명의 클라이언트와 통신 한다고 했을 때 "10,001 * 1MB 의 스택/힙 메모리 + 컨텍스트 스위칭 비용" 이 들어가므로
대규모 채팅 서비스에는 적합하지 않습니다.
그렇기에 Non-Blocking I/O인 NIO를 사용하여 클라이언트와의 통신을 구축하도록 합니다.
NIO를 간략히 설명하자면 요청에 대해 os 이벤트를 활용하여 이벤트가 있을때만 쓰레드가 작동하도록 할 수 있는 비동기 통신 방식입니다.
참고 자료 : https://youseong.tistory.com/99
NIO(New I/O)와 Webflux 비동기 프로그래밍
Java의 New Input/Output을 사용해보며 대규모 처리에서 중요한 개념이라고 생각되어 이를 소개합니다. 1. NIO는 어떤것이고 왜 사용하는가NIO가 등장하기 이전에 사용된던 방식은 Blocking I/O입니다.글에
youseong.tistory.com
2. Broadcaster(Kafka) 구성
Kafka를 이용해서 BroadCasting하는 방법
kafka의 Consumer는 Group 과 같이 사용되며, Group과 topic 내부 partition을 조합하여 분산 처리가 가능합니다.
여기서는 Group 의 식별자를 모두 다르게 설정하여, 모든 요청이 모든 Node로 전달하는 Broadcast 구조를 만듭니다.
클라이언트가 Node1로 메시지를 전송한 것을 예시로 들어보겠습니다.

Node1에서 Kafka로 메시지가 전송되었습니다.

Kafka로 전송된 메시지는 다시 Node1, Node2, Node3으로 전송되게됩니다.
그럼 Node2 와 Node3 에서는 클라이언트에게 메시지를 전송하게 되고,
Node1 에서는 메시지가 중복으로 처리되는 문제가 발생하게됩니다.
각 Node는 producer와 consumer 역할을 병행하기에 '내가 보낸 메시지가 나한테 들어오면서' 중복 처리 문제가 발생합니다.
중복 메시지를 어떻게 해결하는지?
여기서 선택한 방법은 Node 마다 Runtime 시점에 식별자를 부여하여 메시지를 Filtering 하도록 합니다.
Auto Scale-out을 고려하여 환경설정 값이 아닌 값 중 중복없는 random한 값을 사용하기 위해 UUID.random()를 사용합니다.
이제 Node에서 Kafka로 메시지를 보낼 때 UUID를 포함하여 전송합니다.
그리고 Kafka에서 Node로 메시지가 들어오면 식별자(ID)를 확인하여,
식별자가 Node 식별자와 동일하다면 해당 메시지를 사용하지 않고 버립니다.
3. 내부적으로 메시지를 처리하는 방식
서비스 복잡도
Kafka를 이용한 BroadCasting 전략까지 구현을 하면 꽤나 복잡한 구조가 만들어집니다.
아래와 같은 요청이 있고 각각 처리를 한다고 하겠습니다.
- 채팅방 입장 요청
- 채팅방 퇴장 요청
- 사용자 메시지 발송 요청
- 시스템 메시지 발송 요청
- ERROR 또는 Noti 발송 요청
- Kafka에서 들어온 사용자 메시지 발송 요청
- 기타 custom 처리 요청
이러한 처리를 각 컴포넌트에서 처리한다면 아래와 같이 복잡한 형태를 띄게 됩니다.

신규 개발의 경우 충분히 구현 가능한 수준은 맞습니다.
그러나 서비스를 지속하면서 추가 개발 및 예외처리를 진행할 경우 복잡성은 크게 늘어나게 되는 구조입니다.
그렇기에 Command 디자인 패턴을 적용하여 Event-Driven 아키텍처로 만들어보겠습니다.

이렇게 각각 Command(Subscriber)를 구성하여 Publisher에 등록해놓고 요청이 들어올 때 처리하는 방식으로 코드를 구성합니다.
public class ChatEventPublisher {
private static final Logger log = LoggerFactory.getLogger(ChatEventPublisher.class);
private final Map<EventType, MessageSubscriber> eventHandlerMap = new ConcurrentHashMap<>();
public void registerSubscriber(EventType eventType, MessageSubscriber handler) {
if (eventType == null || handler == null) {
throw new IllegalArgumentException("Message type and handler cannot be null");
}
if (eventHandlerMap.containsKey(eventType)) {
log.info("Event handler switched for message type: {}", eventType);
}
eventHandlerMap.put(eventType, handler);
}
public void publish(Message message) {
log.info("Publishing message: {}", message);
EventType eventType = message.eventType();
if (eventType == null) throw new IllegalArgumentException("Message type cannot be null");
MessageSubscriber messageSubscriber = eventHandlerMap.get(eventType);
if (messageSubscriber == null)
throw new IllegalStateException("No handler registered for message type: " + eventType);
messageSubscriber.subscribe(message);
}
}
어떻게 처리할지만 Command로 작성해놓으면 기존 코드를 수정하지 않고도 새로운 기능을 쉽게 추가하거나,
명령의 실행을 취소/재실행하는 기능을 구현할 수 있습니다.
또한, 요청을 비동기 큐에 저장하거나 로깅하여 추후 처리를 용이하게 할 수 있습니다.
4. Resource LifeCycle 관리
클라이언트가 중복으로 입장하면 어떤 문제가 발생하지?
만약 클라이언트가 수만명까지 늘어나고 os 이벤트 없이 connection이 종료된 경우 다시 채팅방을 입장할 경우 중복이 발생할 수 있고,
서버는 이에 따라서 리소스 부족이 발생할 수 있습니다.
이러한 경우를 예상하여 다음과 같이 ping을 이용해 connection의 상태를 관리하도록 합니다.
/**
* Removes all participants whose socket connections are no longer open.
*/
public void sweepParticipants() {
participants.removeIf(user -> {
try {
user.getSocketChannel().write(ByteBuffer.wrap("ping".getBytes()));
return false;
} catch (IOException e) {
log.warn("Failed to check if user socket channel is open: {}", user, e);
return true; // 예외 발생 시 해당 유저 제거
}
});
}
ping은 ScheduledThread로 구동하며 특정 주기마다 사용자에게 요청을 보냅니다.
사용자에게 데이터를 보내서 예외가 발생하는지, 또는 사용자에게서 read를 했을 때 EOF signal인 -1 이 리턴되는지를 사용할 수 있습니다.
5. 채팅 기록 저장 및 불러오기
사용자가 채팅방에 입장할 때 기존 채팅 내역을 보여준다거나, 과거 기록을 확인하는 경우가 있습니다.
단순 보관을 위해서는 MySQL 또는 PostgreSQL 을 쓸 수 있습니다.
조회가 많은 특수한 경우라면 시간기반 DB를 사용하거나, RDBMS + Elasticsearch를 병행 사용하여 조회 속도를 높일 수 있습니다.

위와 같은 구조로 Kafka의 후처리 작업을 제외하면서 RDMBS 저장 기능을 만들 수 있습니다.
그리고 클라이언트 Application의 local storage에 데이터를 저장하는 방식도 가능하며,
이는 KakaoTalk 서비스에서 적용하는 방식입니다.
만약 local storage를 사용한다고 하면, connection 이 끊어지기 전 시점의 데이터는 local로,
끊어진 이후 현재까지는 rdbms로 불러와 병합하여 사용자에게 표출하면 될 것 같습니다.
또한, DB에 저장하는 방식으로는 "채팅방" 이라는 확실히 격리성을 띄는 구조를 가지고있기에 DB 샤딩을 적용하기에도 적합하고 월별 파티셔닝 을 같이 적용한다면 꽤 효율적인 저장소를 구축할 수 있습니다.
6. 서비스 무중단 배포
서비스가 Stateless 한 서비스라고 한다면 공유가 필요한 데이터(Session 등)를 메모리 기반 DB에 올려놓고,
L4 또는 L7 로드벨런서의 포워딩을 막기만 해서 Connection을 다른 Node로 이동시킬 수 있습니다.
그러나 Node가 Connection을 유지하고있는 현재 구조에서는 L4, L7 만을 이용해서는 Connection 이동이 불가합니다.
Connection의 이동 과정은 Server에서 할 수 없고, Client Application의 역할이 됩니다.
그렇기에 Application은 서버가 다운될 경우 이를 캐치하여 재연결을 수립하도록 만들어놓는 개발이 사전에 필요하며,
이를 구현하기 위해 server로 ping을 보낸다거나 일정시간 ping이 오지 않으면 끊어진 것으로 간주하는 방법이 있습니다.

이렇게 구성하면 Connection이 끊긴 경우 새로운 Node를 찾아 Connection을 다시 수립할 수 있습니다.
클라이언트가 한쪽으로 몰릴 경우
Connection은 정상 연결 될테지만, 서버를 순차 재기동하게되면 사용자가 한쪽 Node로 몰릴 가능성이 존재합니다.
아래와 같은 상황이 그러한 예 입니다.
- Node1 다운/기동 : Node1 클라이언트가 Node2 로 이동
- Node2 다운/기동 : Node2 클라이언트가 Node3 으로 이동
- Node3 다운/기동 : Node3 클라이언트가 Node1 로 이동
이런 배포 과정을 거치게 되면 모든 사용자는 Node1 에만 connection을 맺고 있게됩니다.
이를 방지하기 위해서는 로드 밸런싱 설정으로 Least Connection(connection이 가장 적은 서버로 연결) 을 사용하게 되면,
Connection이 한쪽으로 몰리는 현상을 방지할 수 있습니다.
클라이언트가 한쪽에만 없을 경우
Least Connection을 이용해서 재연결을 잘 분산 시켰습니다.
그러나 마지막으로 기동한 서버의 경우 재연결을 수행하려는 클라이언트가 없기에 0명의 클라이언트로 서버가 운용되게 됩니다.
이는 리소스의 낭비이고, 신규 Connection이 없다고 한다면 서버 한대는 아무것도 하지 않게됩니다.
이를 방지하기 위해 서비스 기동 후 추가작업을 해주어야 합니다.
Server가 판단하여 적정한 수치의 클라이언트를 이동시키는 요청을 클라이언트의 Application으로 전송하는 방식으로 구현해 볼 수 있을 것 같습니다.
처리 방법에 대해서는 Kafka KRaft 모드의 Leader Election 알고리즘을 일부 차용할 것 같습니다.

7. 마무리
블로그 글 작성을 통해 단순히 채팅 기능을 구현하는 것을 넘어, 대규모 시스템에서의 노드 간 통신, 메시지 중복 처리, 무중단 배포, 리소스 관리, 메시지 저장 전략까지 고민하며 실질적인 문제 해결을 담아내었습니다.
특히 Kafka를 사용한 메시지 브로드캐스팅 구조와, Command 패턴을 적용한 내부 메시지 처리 방식은 실제 실무에서도 확장성과 유지보수성을 높이는 데 큰 도움이 되는 설계 방식이라 판단됩니다.
실시간 처리를 요구하는 시스템일수록 작은 선택 하나가 전체 성능에 큰 영향을 주는 만큼, 앞으로도 이러한 구조적 고민을 통해 더 나은 아키텍처를 만들어가고자 합니다.
'Architecture' 카테고리의 다른 글
| AI와 Chaining으로 데이터 없는 검색 서비스 구현 (실무 활용) (0) | 2025.12.06 |
|---|---|
| 선착순 이벤트 아키텍처 구성 (2) | 2025.05.30 |
| Java 에서의 데이터 입출력(I/O), Byte Stream (0) | 2025.05.20 |
| CDN 이란? 글로벌 서비스 캐싱 전략 (0) | 2025.04.19 |
| Spring Security 없이 OAuth2 클라이언트 직접 구현 (0) | 2025.04.13 |