카테고리 없음

[유레카 / 백엔드] TIL - 19 (Kafka)

coding-quokka101 2026. 1. 20. 23:34

실전 카프카 개발부터 운영까지 ❘ 고승범 - 교보문고


📌 들어가며

오늘의 학습 목표:
실시간 데이터 스트리밍의 핵심, Apache Kafka를 완벽하게 이해하고 실무에서 바로 활용할 수 있도록 정리하기

안녕하세요! 오늘은 백엔드 개발자라면 꼭 알아야 하는 Apache Kafka에 대해 깊이 있게 학습했습니다. 사실 처음엔 "메시지 큐? 그냥 RabbitMQ랑 비슷한 거 아냐?"라고 생각했는데, 수업을 듣고 나니 Kafka가 단순한 메시지 큐가 아니라 이벤트 스트리밍 플랫폼이라는 걸 확실히 이해하게 되었어요! 💪

특히 실무에서 대용량 트래픽을 처리하거나, 마이크로서비스 간 비동기 통신이 필요할 때 Kafka가 왜 필수인지 체감했습니다. 넷플릭스, 우버, 링크드인 같은 대기업들이 왜 Kafka를 사용하는지 이해가 가더라고요!


🎯 Today I Learned

✅ Apache Kafka란 무엇인가?
✅ Kafka vs 전통적인 메시지 큐 (RabbitMQ, ActiveMQ)
✅ Kafka의 핵심 구성 요소 (Broker, Topic, Partition, Offset)
✅ Producer와 Consumer의 동작 원리
✅ Consumer Group과 리밸런싱
✅ 파티션 전략과 순서 보장
✅ Kafka의 내결함성 (Replication, ISR)
✅ 실무 적용 사례와 Best Practice

🤔 Apache Kafka, 왜 필요할까?

[아파치 카프카] Kafka란? (개념, 특징, 장단점, 면접 대비)

전통적인 시스템의 한계

오늘날의 서비스들은 엄청난 양의 데이터를 실시간으로 처리해야 해요:

클릭 스트림 데이터: 수백만 사용자의 실시간 활동 추적
로그 데이터: 서버에서 쏟아지는 수많은 로그
IoT 센서 데이터: 초당 수천 개의 센서 데이터
금융 거래 데이터: 실시간 주문 처리

기존의 데이터베이스나 메시지 큐로는 이런 대용량 스트리밍 데이터를 감당하기 어려웠어요.

Kafka의 탄생 배경

LinkedIn에서 2011년에 개발했는데, 당시 LinkedIn의 문제는 이거였어요:

문제: 각 시스템이 각자 다른 방식으로 통신
→ 통신 복잡도가 N x N으로 증가
→ 하나의 시스템 변경이 모든 시스템에 영향

해결: Kafka를 중앙 메시징 허브로!
→ 모든 시스템이 Kafka와만 통신
→ 복잡도가 N으로 감소

📊 Apache Kafka란 무엇인가?

Kafka의 정의

Apache Kafka는 실시간으로 기록 스트림을 게시, 구독, 저장 및 처리할 수 있는 분산형 데이터 스트리밍 플랫폼입니다.

쉽게 말하면:

Kafka = 초대용량 실시간 데이터를 처리하는 파이프라인

A 시스템에서 → Kafka → B, C, D... 모든 시스템으로
동시에 데이터를 전달할 수 있어요!

Kafka vs 전통적인 메시지 큐

 

구분 RabbitMQ (메시지 큐) Kafka (이벤트 스트리밍)

데이터 보존 소비되면 삭제 설정한 기간 동안 영구 보존
처리 방식 메시지 전달 후 삭제 이벤트 로그로 저장
처리량 수천 msg/sec 수백만 msg/sec
재처리 어려움 쉬움 (Offset 이동)
확장성 제한적 무한 확장 가능
사용 사례 작업 큐, 간단한 메시징 대용량 로그, 실시간 분석

핵심 차이:

메시지 브로커와 다르게 이벤트 브로커는 이벤트 소싱 방식이다. 카프카는 메시지가 사용되어도 사라지지 않는다.


🏗️ Kafka의 핵심 구성 요소

https://images.ctfassets.net/gt6dp23g0g38/130jkNPNKOm2I6QizpNtu7/a90e9d3e66da6f98208fe2882f824f5b/Kafka_Internals_026.png

1️⃣ Kafka Cluster & Broker

Broker: 하나의 Kafka 서버

Kafka Cluster
├── Broker 1 (server:9092)
├── Broker 2 (server:9093)
└── Broker 3 (server:9094)

역할:
- 프로듀서로부터 메시지 수신
- 오프셋 지정
- 디스크에 저장
- 컨슈머에게 전달

하나의 카프카 서버를 브로커(Broker)라고 한다. 브로커는 프로듀서로부터 메시지를 수신하고 오프셋을 지정한 후 해당 메시지를 디스크에 저장한다.

2️⃣ Topic - 데이터의 분류

Topic: 데이터가 저장되는 논리적 카테고리

예시:
Topic: user-events
├── 로그인 이벤트
├── 로그아웃 이벤트
└── 프로필 수정 이벤트

Topic: order-events
├── 주문 생성 이벤트
├── 결제 완료 이벤트
└── 배송 시작 이벤트

토픽(Topic)은 데이터의 주제를 나타내며, 이름으로 분리된 로그입니다. 메시지를 보낼 때는 특정 토픽을 지정합니다.

3️⃣ Partition - 확장성의 핵심

Partition: 토픽을 여러 조각으로 나눈 것

Topic: user-events (3개 파티션)

Partition 0: [msg1, msg4, msg7, msg10...]
Partition 1: [msg2, msg5, msg8, msg11...]
Partition 2: [msg3, msg6, msg9, msg12...]

왜 나눌까?
✅ 병렬 처리 가능
✅ 데이터 분산 저장
✅ 높은 처리량

파티션(Partition)은 토픽은 하나 이상의 파티션으로 나누어질 수 있으며, 각 파티션은 순서가 있는 연속된 메시지의 로그입니다. 파티션은 병렬 처리를 지원하고, 데이터의 분산 및 복제를 관리합니다.

4️⃣ Offset - 메시지의 위치

Offset: 파티션 내 메시지의 고유한 순번

// 예시
Partition 0:
Offset 0: {"userId": 1, "event": "login"}
Offset 1: {"userId": 2, "event": "logout"}
Offset 2: {"userId": 3, "event": "login"}
...

Offset이 중요한 이유:
- 어디까지 읽었는지 추적
- 원하는 시점으로 되돌리기 가능
- 정확히 한 번 처리 보장

오프셋(Offset)은 특정 파티션 내의 레코드 위치를 식별하는 값입니다.


📤 Producer - 데이터를 보내는 쪽

Producer의 역할

프로듀서(Producer)는 데이터를 토픽에 보내는 역할을 하며, 메시지를 생성하고 특정 토픽으로 보냅니다.

Producer 코드 예시

// Producer 설정
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

// Producer 생성
KafkaProducer<String, String> producer = new KafkaProducer<>(props);

// 메시지 전송
ProducerRecord<String, String> record = 
    new ProducerRecord<>("user-events", "user123", "login");

producer.send(record, (metadata, exception) -> {
    if (exception == null) {
        System.out.println("전송 성공: " + metadata.offset());
    } else {
        System.err.println("전송 실패: " + exception.getMessage());
    }
});

producer.close();

Partitioning 전략

메시지가 어느 파티션으로 갈지 결정하는 방법:

1) Key 기반 파티셔닝

// 같은 키는 항상 같은 파티션으로!
ProducerRecord<String, String> record1 = 
    new ProducerRecord<>("orders", "user123", "{order1}");  // → Partition 0

ProducerRecord<String, String> record2 = 
    new ProducerRecord<>("orders", "user123", "{order2}");  // → Partition 0

ProducerRecord<String, String> record3 = 
    new ProducerRecord<>("orders", "user456", "{order3}");  // → Partition 1

결과: user123의 모든 주문은 순서대로 처리됨!

2) Round-Robin (키 없을 때)

// 키가 없으면 라운드 로빈
ProducerRecord<String, String> record = 
    new ProducerRecord<>("logs", null, "log message");

결과: 파티션 0 → 1 → 2 → 0 → 1 → 2 ...

📥 Consumer - 데이터를 읽는 쪽

Consumer의 역할

컨슈머(Consumer)는 토픽에서 데이터를 읽는 역할을 하며, 특정 토픽의 메시지를 가져와서(poll) 처리합니다.

Consumer 코드 예시

// Consumer 설정
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "user-event-processor");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");

// Consumer 생성
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("user-events"));

// 메시지 읽기
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    
    for (ConsumerRecord<String, String> record : records) {
        System.out.printf("Offset: %d, Key: %s, Value: %s%n", 
            record.offset(), record.key(), record.value());
        
        // 비즈니스 로직 처리
        processUserEvent(record.value());
    }
}

Consumer Group - 확장성의 비밀

Consumer Group: 같은 목적으로 데이터를 읽는 Consumer들의 집합

Topic: user-events (3 Partitions)

Consumer Group: analytics-team
├── Consumer 1 → Partition 0
├── Consumer 2 → Partition 1
└── Consumer 3 → Partition 2

Consumer Group: notification-team
├── Consumer 4 → Partition 0, 1, 2

핵심:
1. 같은 그룹 내에서 하나의 파티션은 하나의 컨슈머만!
2. 다른 그룹은 독립적으로 읽음

중요한 규칙:

하나의 토픽 내의 파티션 개수보다 더 많은 수의 컨슈머를 추가하는 것은 의미가 없다.

// ✅ 좋은 예
Partition 4개 : Consumer 4개 = 최대 성능!

// ❌ 나쁜 예
Partition 4개 : Consumer 5개 = 1개는 놀아요!

// ⚠️ 주의할 예
Partition 4개 : Consumer 2개 = 처리 느림

🔄 Rebalancing - 동적 할당

Rebalancing이란?

한 컨슈머로부터 다른 컨슈머로 파티션 소유권이 이전되는 것을 리밸런싱(Rebalancing)이라고 한다.

Rebalancing이 발생하는 경우

초기 상태:
Consumer 1 → Partition 0, 1
Consumer 2 → Partition 2, 3

🆕 Consumer 3 추가!

리밸런싱 후:
Consumer 1 → Partition 0
Consumer 2 → Partition 1
Consumer 3 → Partition 2, 3

✅ 부하가 재분배되었어요!

리밸런싱 발생 조건:

  • 새로운 컨슈머 추가
  • 기존 컨슈머 종료/오류
  • 파티션 개수 변경

🛡️ Kafka의 내결함성 (Fault Tolerance)

Replication - 데이터 복제

Topic: orders (3 Partitions, Replication Factor: 3)

Broker 1 (Leader):
├── Partition 0 (Leader) ⭐
├── Partition 1 (Follower)
└── Partition 2 (Follower)

Broker 2:
├── Partition 0 (Follower)
├── Partition 1 (Leader) ⭐
└── Partition 2 (Follower)

Broker 3:
├── Partition 0 (Follower)
├── Partition 1 (Follower)
└── Partition 2 (Leader) ⭐

장점:
- Broker 1개 죽어도 데이터 안전!
- Leader가 죽으면 Follower가 Leader로 승격

Leader와 Follower:

producer가 메세지를 쓰고, consumer가 메세지를 읽는 건 오로지 leader가 전적으로 역할을 담당한다. 나머지 follower들은 leader와 싱크를 항상 맞춘다.

ISR (In-Sync Replicas)

ISR = Leader와 동기화된 Follower 목록

Partition 0:
- Leader: Broker 1 ✅
- ISR: [Broker 1, Broker 2, Broker 3]

만약 Broker 3이 느려지면:
- Leader: Broker 1 ✅
- ISR: [Broker 1, Broker 2]
- Out-of-sync: [Broker 3] ⚠️


💻 실무 활용 사례

1️⃣ 실시간 로그 수집

// 로그 Producer
public class LogProducer {
    private final KafkaProducer<String, String> producer;
    
    public void sendLog(String logLevel, String message) {
        String logJson = String.format(
            "{\"level\": \"%s\", \"message\": \"%s\", \"timestamp\": %d}",
            logLevel, message, System.currentTimeMillis()
        );
        
        ProducerRecord<String, String> record = 
            new ProducerRecord<>("application-logs", logLevel, logJson);
        
        producer.send(record);
    }
}

// 로그 Consumer (ElasticSearch에 저장)
public class LogConsumer {
    public void processLogs() {
        while (true) {
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
            
            for (ConsumerRecord<String, String> record : records) {
                // ElasticSearch에 저장
                elasticSearchClient.index("logs", record.value());
            }
        }
    }
}

2️⃣ 이벤트 기반 마이크로서비스

// 주문 서비스 → Kafka
@Service
public class OrderService {
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
    
    public void createOrder(Order order) {
        // 주문 저장
        orderRepository.save(order);
        
        // Kafka에 이벤트 발행
        String orderEvent = objectMapper.writeValueAsString(order);
        kafkaTemplate.send("order-created", order.getUserId(), orderEvent);
    }
}

// 알림 서비스 ← Kafka
@Service
public class NotificationService {
    @KafkaListener(topics = "order-created", groupId = "notification-service")
    public void handleOrderCreated(String orderEvent) {
        Order order = objectMapper.readValue(orderEvent, Order.class);
        
        // 이메일 발송
        emailService.sendOrderConfirmation(order.getUserEmail());
        
        // 푸시 알림
        pushService.sendNotification(order.getUserId(), "주문이 접수되었습니다!");
    }
}

// 재고 서비스 ← Kafka
@Service
public class InventoryService {
    @KafkaListener(topics = "order-created", groupId = "inventory-service")
    public void handleOrderCreated(String orderEvent) {
        Order order = objectMapper.readValue(orderEvent, Order.class);
        
        // 재고 차감
        inventoryRepository.decreaseStock(order.getProductId(), order.getQuantity());
    }
}

3️⃣ CDC (Change Data Capture)

// 데이터베이스 변경 이벤트 캡처
@Service
public class UserService {
    @Transactional
    public void updateUser(User user) {
        // DB 업데이트
        userRepository.save(user);
        
        // Kafka에 변경 이벤트 발행
        UserChangeEvent event = new UserChangeEvent(
            user.getId(),
            "UPDATE",
            objectMapper.writeValueAsString(user)
        );
        
        kafkaTemplate.send("user-changes", user.getId().toString(), event);
    }
}

// 검색 엔진 동기화
@Service
public class SearchSyncService {
    @KafkaListener(topics = "user-changes", groupId = "search-sync")
    public void syncToElasticSearch(UserChangeEvent event) {
        if (event.getType().equals("UPDATE")) {
            User user = objectMapper.readValue(event.getData(), User.class);
            elasticSearchClient.update("users", user.getId(), user);
        }
    }
}

📊 Kafka 설정 Best Practices

https://docs.datadoghq.com/ko/tracing/guide/monitor-kafka-queues/

Producer 설정

Properties props = new Properties();

// 필수 설정
props.put("bootstrap.servers", "kafka1:9092,kafka2:9092,kafka3:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

// 성능 최적화
props.put("acks", "all");  // 모든 복제본이 받을 때까지 대기
props.put("retries", 3);   // 실패 시 재시도 횟수
props.put("batch.size", 16384);  // 배치 크기 (바이트)
props.put("linger.ms", 10);      // 배치 대기 시간 (밀리초)
props.put("compression.type", "snappy");  // 압축 타입

// 멱등성 보장
props.put("enable.idempotence", "true");  // 중복 방지

Consumer 설정

Properties props = new Properties();

// 필수 설정
props.put("bootstrap.servers", "kafka1:9092,kafka2:9092,kafka3:9092");
props.put("group.id", "my-consumer-group");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");

// Offset 관리
props.put("enable.auto.commit", "false");  // 수동 커밋
props.put("auto.offset.reset", "earliest");  // 처음부터 읽기

// 성능 최적화
props.put("fetch.min.bytes", 1024);  // 최소 페치 크기
props.put("max.poll.records", 500);  // 한 번에 가져올 레코드 수

💡 오늘 나는 무엇을 배웠는가

1. Kafka는 메시지 큐가 아니다

처음에는 "Kafka = RabbitMQ 같은 메시지 큐"라고 생각했는데, 완전히 다른 개념이더라고요!

메시지 큐 (RabbitMQ):
✉️ 편지를 전달하면 사라짐
→ 일회성 작업에 적합

이벤트 스트리밍 (Kafka):
📚 모든 이벤트를 기록하고 보관
→ 재처리, 분석, 감사에 적합

2. 파티션 개수 = 성능의 핵심

파티션 1개:
- Consumer 1개만 처리
- 처리량: 100 msg/sec

파티션 10개:
- Consumer 10개 병렬 처리
- 처리량: 1,000 msg/sec

파티션 100개:
- Consumer 100개 병렬 처리
- 처리량: 10,000 msg/sec

파티션을 잘 설계하면 성능이 100배 차이!

3. Offset이 정말 중요하다

Offset 덕분에:
✅ 정확히 한 번만 처리 (Exactly Once)
✅ 에러 발생 시 재처리
✅ 과거 데이터 다시 읽기
✅ 여러 Consumer Group 독립 처리

이게 없었으면 Kafka는 그냥 메시지 큐!

😊 좋았던 점 & 😅 아쉬웠던 점

좋았던 점

👍 실무 중심 수업

단순히 "Kafka는 이렇다"가 아니라 "실제로 이렇게 쓴다"를 배워서 좋았어요. 특히 우아한형제들 사례가 인상 깊었습니다!

👍 직접 실습

Docker로 Kafka Cluster 띄우고, Producer/Consumer 코드 직접 작성해보니 개념이 확실히 잡혔어요.

👍 성능 차이 체감

파티션 1개 vs 10개로 실습했을 때 처리 속도 차이가 명확하게 보여서 놀라웠어요!

아쉬웠던 점

😢 Zookeeper 개념

Kafka 3.0부터는 Zookeeper 없이도 된다고 하는데, 아직 많은 회사가 Zookeeper를 쓰고 있어서 헷갈려요.

😢 실습 시간 부족

Kafka Streams, Kafka Connect 등 고급 기능도 배우고 싶었는데 시간이 부족했어요.

😢 운영 관련 내용

실제 프로덕션 환경에서 Kafka를 어떻게 모니터링하고 관리하는지도 알고 싶었습니다.


🤔 어려웠던 점과 해결

문제 1: Consumer Group 개념

 

"같은 그룹 내에서 한 파티션은 한 컨슈머"라는 게 처음엔 이해가 안 됐어요.

해결: 직접 그림 그려보니 이해됨!

파티션 3개, 컨슈머 2개:
Consumer 1: 파티션 0, 1
Consumer 2: 파티션 2

파티션 3개, 컨슈머 4개:
Consumer 1: 파티션 0
Consumer 2: 파티션 1
Consumer 3: 파티션 2
Consumer 4: 놀아요! ❌

 

문제 2: Offset Commit 시점

언제 커밋해야 하는지 헷갈렸어요.

해결: 처리 완료 후 수동 커밋이 가장 안전!

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    
    for (ConsumerRecord<String, String> record : records) {
        try {
            // 처리
            processRecord(record);
            
            // ✅ 처리 성공 후 커밋
            consumer.commitSync();
        } catch (Exception e) {
            // ❌ 실패하면 커밋 안 함 → 재처리
            log.error("처리 실패", e);
        }
    }
}

 

문제 3: 파티션 키 선택

어떤 키를 사용해야 순서가 보장되는지 몰랐어요.

해결: 순서가 중요한 단위로 키 설정!

// ✅ 사용자별 순서 보장
record = new ProducerRecord<>("orders", userId, orderData);

// ✅ 상품별 순서 보장
record = new ProducerRecord<>("inventory", productId, inventoryData);

// ❌ 순서 보장 안 됨
record = new ProducerRecord<>("logs", null, logData);

🎓 나만의 학습 팁

1. Docker로 로컬 환경 구축

# docker-compose.yml
version: '3'
services:
  kafka:
    image: confluentinc/cp-kafka:latest
    ports:
      - "9092:9092"
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      
  zookeeper:
    image: confluentinc/cp-zookeeper:latest
    ports:
      - "2181:2181"
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181

2. 개념을 그림으로 정리

복잡한 개념은 직접 그림으로 그려서 Notion에 정리했어요. 특히 Partition, Offset, Consumer Group 관계를 그림으로 그리니까 이해가 쉬웠습니다!

3. 실습 프로젝트 아이디어

프로젝트 1: 실시간 주문 시스템
- Producer: 주문 생성
- Consumer 1: 알림 발송
- Consumer 2: 재고 차감
- Consumer 3: 로그 저장

프로젝트 2: 로그 수집 시스템
- Producer: 애플리케이션 로그
- Consumer: ElasticSearch 저장

프로젝트 3: 이벤트 소싱
- 모든 상태 변경을 이벤트로 저장
- 언제든 과거 상태 재현 가능

🚀 다음 학습 목표

📌 단기 목표 (이번 주)
   ✓ Kafka Streams로 실시간 데이터 처리 구현
   ✓ Kafka Connect로 DB 연동 실습
   ✓ 개인 프로젝트에 Kafka 적용해보기

📌 중기 목표 (이번 달)
   ✓ Schema Registry로 데이터 스키마 관리
   ✓ Kafka 모니터링 도구 (Kafka Manager) 사용
   ✓ 실제 운영 환경 설정 Best Practice 학습

📌 장기 목표 (부트캠프 기간)
   ✓ 포트폴리오에 Kafka 활용 프로젝트 추가
   ✓ 대용량 트래픽 처리 경험 쌓기
   ✓ 이벤트 기반 마이크로서비스 아키텍처 구축

🎬 마치며

오늘 Kafka를 배우기 전에는 "메시지 큐 하나 더 배우는 건가?"라고 생각했는데, 전혀 다른 차원의 기술이더라고요!

Kafka는 단순한 메시지 전달이 아니라, 데이터의 흐름 자체를 관리하는 플랫폼이라는 걸 깨달았습니다.

"메시지는 사라지지만, 이벤트는 기록된다"

특히 실무에서 마이크로서비스 간 통신, 실시간 로그 처리, 대용량 데이터 파이프라인 등에서 Kafka가 왜 필수인지 이해하게 되었어요.

앞으로 개인 프로젝트에서도 Kafka를 적용해서, "대용량 트래픽을 Kafka로 처리해본 경험"을 쌓아야겠습니다! 💪

여러분도 Kafka, 한번 도전해보세요. 분명 백엔드 개발자로서 한 단계 성장하는 계기가 될 거예요! 🚀


📚 참고 자료

공식 문서

기술 블로그

개발 가이드