Backend
home
🍽️

Kafka 제대로 이해하기

Github 링크
날짜
2025/10/01

들어가며

최근 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
메시지 저장
❌ 저장 안함
✅ 메모리/디스크
✅ 디스크 (영속)
메시지 보장
❌ 없음
✅ ACK 기반
✅ Offset 기반
재처리 가능
❌ 불가능
❌ 소비 후 삭제
✅ 가능
처리량
~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)
•
메시지 재처리가 필요할 때
•
여러 시스템이 같은 메시지를 소비할 때
•
이벤트 소싱 패턴을 적용할 때