들어가며
최근 TRPG 게임인 DungeonTalk 프로젝트를 진행하며 메시징 시스템을 구축했다.
Redis Pub/Sub과 WebSocket을 활용한 실시간 채팅 시스템인데, 이 과정에서
“만약 사용자가 폭발적으로 늘어나면 어떻게 할 수 있을까?”라는 고민이 생겼다.
참고링크
•
DungeonTalk Repository:
•
DungeonTalk 팀 위키:
DungeonTalk의 선택
현재 DungeonTalk은 Redis Pub/Sub + WebSocket 을 사용하고 있다.
그 이유는 다음과 같다:
•
실시간 게임 특성상 낮은 지연시간 중요
•
빠른 시간에 MVP를 출시하는 것이 목표였음
•
간단한 구조 구성을 통하여 유지보수의 안정성 유지
하지만 다음과 같은 상황인 경우 Kafka 도입이 꼭 필요하다.
•
동시 접속자 10,000명 이상
•
게임 로그 분석 시스템 구축
•
실시간 이상 탐지 시스템 도입
•
멀티 리전 확장
WebSocket vs Pub/Sub vs Kafka
WebSocket - 실시간 양방향 통신의 기본
DungeonTalk에선 WebSocket STOMP 프로토콜을 사용한다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub")
.setHeartbeatValue(new long[]{30_000, 30_000});
registry.setApplicationDestinationPrefixes("/pub");
}
}
Java
복사
WebSocket의 특징
•
클라이언트
서버 간 실시간 양방향 통신
•
HTTP 연결을 업그레이드하여 지속적인 연결 유지
•
낮은 지연시간(Low Latency)
•
단일 서버에서만 동작 → 확장성 제약
Redis Pub/Sub - 간단한 메시지 브로커
DungeonTalk의 멀티 서버 환경에서 메시지를 동기화하기 위해
Redis Pub/Sub 을 사용한다.
@Component
public class RedisPublisher {
private final RedisTemplate<String, Object> redisTemplate;
// 채팅방에 메시지 발행
public void publish(String roomId, String message) {
redisTemplate.convertAndSend("chatroom." + roomId, message);
}
// AI 채팅에 메시지 발행
public void publishAiChat(String aiGameRoomId, String message) {
redisTemplate.convertAndSend("aichat." + aiGameRoomId, message);
}
}
Java
복사
@Service
public class RedisSubscriber implements MessageListener {
private final SimpMessageSendingOperations messagingTemplate;
public void onMessage(Message message, byte[] pattern) {
String payload = new String(message.getBody());
String topic = new String(message.getChannel());
// chatroom.{roomId} 채널에서 메시지 수신
String roomId = topic.substring("chatroom.".length());
// WebSocket을 통해 클라이언트에 전달
messagingTemplate.convertAndSend("/sub/chat/room/" + roomId, payload);
}
}
Java
복사
Redis Pub/Sub 특징
•
메시지를 모든 구독자에게 브로드캐스트
•
메시지 저장 안 됨 (Fire and Forget)
•
구독자가 없으면 메시지 유실
•
간단한 구조, 빠른 속도
•
메시지 보장 없음 (메시지 손실 가능)
Kafka - 대용량 트래픽의 해결사
Kafka의 핵심 철학: “메시지는 영속적으로 저장되고, 소비자는 자신의 속도로 읽는다”
// Kafka Producer 예시
@Service
public class KafkaMessageProducer {
private final KafkaTemplate<String, GameMessage> kafkaTemplate;
public void sendGameMessage(String roomId, GameMessage message) {
// 메시지를 Kafka 토픽으로 전송
kafkaTemplate.send("game-messages", roomId, message)
.whenComplete((result, ex) -> {
if (ex == null) {
log.info("메시지 전송 성공: offset={}",
result.getRecordMetadata().offset());
} else {
log.error("메시지 전송 실패", ex);
}
});
}
}
Java
복사
// Kafka Consumer 예시
@Service
public class KafkaMessageConsumer {
@KafkaListener(topics = "game-messages", groupId = "dungeontalk-group")
public void consumeGameMessage(
@Payload GameMessage message,
@Header(KafkaHeaders.OFFSET) Long offset,
@Header(KafkaHeaders.PARTITION) int partition
) {
log.info("메시지 수신: partition={}, offset={}", partition, offset);
// 메시지 처리 로직
processGameMessage(message);
}
}
Java
복사
Kafka 특징
•
메시지를 디스크에 영속적으로 저장 (기본 7일)
•
메시지 순서 보장 (파티션 내에서)
•
재처리 가능 (offset 조정으로 과거 메시지 재소비)
•
대용량 처리 (초당 수백만 메시지)
•
수평 확장 (파티션 추가로 무한 확장)
Kafka vs RabbitMQ - 메시징 시스템 비교
RabbitMQ - 전통적인 메시지 큐
// RabbitMQ 메시지 전송
@Service
public class RabbitMQProducer {
private final RabbitTemplate rabbitTemplate;
public void sendToQueue(String queueName, String message) {
// 큐에 메시지 전송 (큐가 비면 메시지 제거됨)
rabbitTemplate.convertAndSend(queueName, message);
}
}
Java
복사
RabbitMQ의 특징
•
메시지 큐 방식: 메시지를 소비하면 큐에서 제거
•
라우팅 기능 강력: Exchange를 통한 복잡한 라우팅
•
우선순위 큐 지원: 중요한 메시지 먼저 처리
•
즉시 전달 보장: push 방식으로 즉시 전달
•
메모리 기반: 빠르지만 대용량에는 부적합
Kafka - 로그 기반 스트리밍 플랫폼
// Kafka는 메시지를 삭제하지 않고 계속 보관
@Service
public class KafkaStreamProcessor {
@KafkaListener(topics = "user-actions")
public void processUserAction(UserAction action) {
// 이 메시지는 소비되어도 Kafka에 남아있음
// 다른 Consumer Group이 동일한 메시지를 다시 읽을 수 있음
log.info("사용자 행동 처리: {}", action);
}
}
Java
복사
Kafka의 특징
•
로그 기반: 메시지를 append-only 로그로 저장
•
메시지 영속성: 소비해도 메시지 유지
•
다중 소비자 그룹: 같은 메시지를 여러 그룹이 독립적으로 소비
•
Pull 방식: 소비자가 자신의 속도로 메시지 가져옴
•
대용량 특화: 수평 확장에 최적화
비교표
특징 | Redis Pub/Sub | RabbitMQ | Kafka |
메시지 저장 | |||
메시지 보장 | |||
재처리 가능 | |||
처리량 | ~100K msg/s | ~10K msg/s | ~1M msg/s |
확장성 | 제한적 | 중간 | 우수 |
복잡도 | 낮음 | 중간 | 높음 |
사용 사례 | 실시간 알림 | 작업 큐 | 이벤트 스트리밍 |
Kafka 핵심 개념 정리
1. Producer - 메시지를 생성하는 주체
@Configuration
public class KafkaProducerConfig {
@Bean
public ProducerFactory<String, GameEvent> producerFactory() {
Map<String, Object> config = new HashMap<>();
// Kafka 브로커 주소
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
// Key/Value 직렬화 설정
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
StringSerializer.class);
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
JsonSerializer.class);
// 성능 튜닝 옵션
config.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384); // 배치 크기
config.put(ProducerConfig.LINGER_MS_CONFIG, 10); // 배치 대기시간
config.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy"); // 압축
// 메시지 전송 보장 수준
config.put(ProducerConfig.ACKS_CONFIG, "all"); // 모든 replica 확인
return new DefaultKafkaProducerFactory<>(config);
}
}
Java
복사
Producer의 역할
•
메시지를 Kafka 토픽에 발행
•
파티션 선택 (키 기반 또는 라운드로빈)
•
배치 처리로 성능 최적화
2. Consumer - 메시지를 소비하는 주체
@Configuration
public class KafkaConsumerConfig {
@Bean
public ConsumerFactory<String, GameEvent> consumerFactory() {
Map<String, Object> config = new HashMap<>();
config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
// Consumer Group ID (중요!)
config.put(ConsumerConfig.GROUP_ID_CONFIG, "dungeontalk-game-processor");
// Offset 자동 커밋 설정
config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
// 처음 시작 시 어디서부터 읽을지
config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class);
config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
JsonDeserializer.class);
return new DefaultKafkaConsumerFactory<>(config);
}
}
Java
복사
Consumer Group의 기능 사례
// Consumer Group 1: 실시간 게임 처리
@KafkaListener(topics = "game-events", groupId = "game-processor")
public void processGameEvent(GameEvent event) {
log.info("실시간 게임 처리: {}", event);
}
// Consumer Group 2: 통계 분석 (같은 메시지를 독립적으로 소비)
@KafkaListener(topics = "game-events", groupId = "analytics")
public void analyzeGameEvent(GameEvent event) {
log.info("통계 분석: {}", event);
}
// Consumer Group 3: 알림 발송 (같은 메시지를 또 독립적으로 소비)
@KafkaListener(topics = "game-events", groupId = "notification")
public void sendNotification(GameEvent event) {
log.info("알림 발송: {}", event);
}
Java
복사
3. Topic & Partition - 메시지의 저장소
Topic: game-events (파티션 3개)
Partition 0: [msg1] [msg4] [msg7] ...
Partition 1: [msg2] [msg5] [msg8] ...
Partition 2: [msg3] [msg6] [msg9] ...
Markdown
복사
파티션의 역할
•
메시지를 병렬로 처리
•
같은 키를 가진 메시지는 같은 파티션으로 (순서 보장)
•
파티션 수만큼 Consumer를 병렬 실행 가능
// 게임방 ID를 키로 사용하여 같은 방의 메시지는 순서 보장
public void sendGameMessage(String roomId, GameMessage message) {
// roomId를 키로 사용 → 같은 roomId는 같은 파티션으로
kafkaTemplate.send("game-messages", roomId, message);
}
Java
복사
4. Offset - 메시지의 위치
Partition 0의 메시지 로그:
[0] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10]
↑
현재 Consumer Offset: 4
Consumer는 offset 5부터 읽기 시작
Markdown
복사
수동 Offset 커밋
@KafkaListener(topics = "game-events")
public void consume(ConsumerRecord<String, GameEvent> record,
Acknowledgment ack) {
try {
// 메시지 처리
processGameEvent(record.value());
// 처리 성공 시 offset 커밋
ack.acknowledge();
} catch (Exception e) {
// 처리 실패 시 커밋 안함 → 재시도
log.error("메시지 처리 실패, 재시도 예정", e);
}
}
Java
복사
DungeonTalk에 Kafka를 적용한다면?
현재 구조 (Redis Pub/Sub + WebSocket)
Client → WebSocket → Spring Boot → Redis Pub/Sub → Spring Boot → WebSocket → Client
↓
PostgreSQL
Markdown
복사
장점
•
간단한 구조
•
낮은 지연시간
•
실시간성 우수
단점
•
메시지 유실 가능
•
재처리 불가능
•
대용량 트래픽에 취약
•
확장성 제한
Kafka 적용 구조
Client → WebSocket → Spring Boot → Kafka → Consumer Group 1 (실시간 전송)
↓
Consumer Group 2 (DB 저장)
↓
Consumer Group 3 (분석)
Markdown
복사
// 1. 메시지 수신 시 Kafka로 발행
@MessageMapping("/aichat/send")
public void sendMessage(AiGameMessageSendRequest request) {
// Kafka에 메시지 발행
GameMessage message = GameMessage.builder()
.roomId(request.getRoomId())
.content(request.getContent())
.timestamp(Instant.now())
.build();
kafkaProducer.sendGameMessage(message);
}
// 2. Consumer Group 1: 실시간 WebSocket 전송
@KafkaListener(topics = "game-messages", groupId = "websocket-sender")
public void sendToWebSocket(GameMessage message) {
messagingTemplate.convertAndSend(
"/sub/aichat/room/" + message.getRoomId(),
message
);
}
// 3. Consumer Group 2: DB 저장 (비동기)
@KafkaListener(topics = "game-messages", groupId = "db-writer")
public void saveToDatabase(GameMessage message) {
aiGameMessageRepository.save(message.toEntity());
}
// 4. Consumer Group 3: 실시간 분석
@KafkaListener(topics = "game-messages", groupId = "analytics")
public void analyzeMessage(GameMessage message) {
// 사용자 활동 분석, 이상 탐지 등
analyticsService.analyze(message);
}
Java
복사
Kafka 적용 후 얻게 되는 장점
1.
메시지 보장
// 메시지 전송 실패 시 자동 재시도
@Retryable(
value = { KafkaException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public void sendWithRetry(GameMessage message) {
kafkaTemplate.send("game-messages", message);
}
Java
복사
2.
재처리 가능
// 특정 시점부터 메시지 재처리
@KafkaListener(topics = "game-messages")
public void reprocessMessages(ConsumerRecord<String, GameMessage> record) {
// offset을 조정하여 과거 메시지 재처리 가능
log.info("재처리 offset: {}", record.offset());
}
Java
복사
3.
부하 분산
Partition 0 → Consumer 1
Partition 1 → Consumer 2
Partition 2 → Consumer 3
Markdown
복사
4.
장애 복구
// Consumer가 장애로 중단되어도, 재시작 시 마지막 offset부터 계속 처리
@KafkaListener(topics = "game-messages")
public void consume(GameMessage message) {
// 처리 중 서버가 재시작되어도
// 마지막 커밋된 offset 이후부터 자동으로 계속 처리
}
Java
복사
대용량 트래픽 처리 전략
1. 파티션 전략
// 사용자 수에 따라 파티션 수 결정
// 동시 접속자 10,000명 예상 → 파티션 30개 (약 333명/파티션)
@Configuration
public class KafkaTopicConfig {
@Bean
public NewTopic gameMessagesTopic() {
return TopicBuilder.builder()
.name("game-messages")
.partitions(30) // 파티션 30개
.replicas(3) // 복제본 3개 (장애 대비)
.config(TopicConfig.RETENTION_MS_CONFIG, "604800000") // 7일 보관
.build();
}
}
Java
복사
2. 배치 처리
// 메시지를 모아서 한 번에 처리 (처리량 향상)
@KafkaListener(
topics = "game-messages",
containerFactory = "batchContainerFactory"
)
public void consumeBatch(List<GameMessage> messages) {
// 100개 메시지를 한 번에 처리
log.info("배치 처리: {}개 메시지", messages.size());
// Bulk Insert로 성능 향상
aiGameMessageRepository.saveAll(
messages.stream()
.map(GameMessage::toEntity)
.collect(Collectors.toList())
);
}
Java
복사
3. 압축
@Bean
public ProducerFactory<String, GameMessage> producerFactory() {
Map<String, Object> config = new HashMap<>();
// Snappy 압축 (빠른 속도, 적당한 압축률)
config.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy");
// 또는 GZIP (느린 속도, 높은 압축률)
// config.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "gzip");
return new DefaultKafkaProducerFactory<>(config);
}
Java
복사
4. 병렬 Consumer
# application.yml
spring:
kafka:
consumer:
concurrency: 10 # 동시에 10개 Consumer 실행
YAML
복사
@KafkaListener(
topics = "game-messages",
groupId = "game-processor",
concurrency = "10" // 10개의 Consumer 인스턴스가 병렬 처리
)
public void consume(GameMessage message) {
processGameMessage(message);
}
Java
복사
결론
Redis Pub/Sub을 선택해야 할 때
•
실시간성이 가장 중요할 때 (10ms 이하)
•
메시지 유실이 허용될 때
•
간단한 브로드캐스트가 필요할 때
•
동시 접속자가 적을 때 (< 1,000명)
Kafka를 선택해야 할 때
•
메시지 유실이 절대 안 될 때
•
대용량 트래픽 처리가 필요할 때(> 10,000 msg/s)
•
메시지 재처리가 필요할 때
•
여러 시스템이 같은 메시지를 소비할 때
•
이벤트 소싱 패턴을 적용할 때

