SW Maestro · Kafka 101

Kafka 입문

회사/프로젝트에서 이벤트 스트리밍을 어디에 쓰고, 어떤 코드와 구조로 연결하는지 15분 안에 이해하기

K
Event Streaming Backbone 동기 호출 사이에 이벤트 로그를 두는 백엔드 패턴
Async비동기 처리
Log남겨두고 읽기
Scale분산 처리

동기 API 호출만 이어 붙이면 장애와 지연이 그대로 전파됩니다

Web / App POST /orders 사용자 요청 Order API 주문 저장 뒤 서비스 직접 호출 Payment API 느리면 주문도 대기 Notification 문자/메일 발송 Analytics 로그/지표 반영 문제 느린 서비스가 앞 서비스를 막음 장애 전파 호출 관계 증가 재처리 어려움 동기 체인: 새 기능이 붙을수록 API가 서로 기다리는 구조가 됨
Kafka는 이 문제를 “서비스가 서로 직접 기다리는 구조”에서 “이벤트를 남기고 각자 읽는 구조”로 바꿉니다.

Kafka는 이벤트를 안전하게 쌓아두고 여러 서비스가 각자 읽게 하는 분산 로그입니다

Event

“주문이 생성됐다”, “결제가 끝났다”처럼 이미 발생한 사실

Log

이벤트를 순서대로 저장하고, 읽은 위치를 Consumer가 관리

Streaming

이벤트가 계속 들어오고, 여러 서비스가 거의 실시간으로 처리

큐처럼 “한 번 가져가면 사라지는 상자”라기보다, 여러 팀이 같은 기록을 자기 목적에 맞게 읽는 이벤트 장부에 가깝습니다.

전체 흐름: 주문 API는 이벤트를 발행하고, 뒤쪽 서비스들이 독립적으로 처리합니다

CLIENT Web / App POST /orders Gateway auth / route COMMAND SIDE Order Service 주문 검증 / 저장 Order DB transaction commit Producer event publish KAFKA CLUSTER Topic: order.created key = orderId, value = JSON P0 101 104 107 P1 102 105 108 P2 103 106 109 Offset Consumer Group별 읽은 위치 CONSUMERS Payment 결제 승인 Notification 메일 / 푸시 Analytics 지표 / 색인 주문 API는 이벤트만 발행 각 서비스는 자기 속도로 독립 처리 Kafka가 서비스 사이 직접 의존을 끊고, 이벤트 로그를 중심에 둠

Kafka 회의에서 가장 많이 듣는 단어

용어 역할 실무 감각
Producer 이벤트를 Kafka에 쓰는 애플리케이션 Order Service가 order.created를 발행
Topic 같은 종류의 이벤트가 모이는 이름 payment.completed, user.signed_up
Partition Topic 안에서 병렬 처리되는 로그 줄 같은 key는 같은 Partition으로 가서 순서가 유지됨
Broker Kafka 서버 한 대 여러 Broker가 Cluster를 구성하고 데이터를 복제
Consumer Group 같은 목적의 Consumer 묶음 알림 Consumer 3대가 이벤트를 나눠 처리

실무에서는 먼저 “이벤트 메시지 모양”을 약속합니다

이벤트 이름 Topic 이름은 보통 과거형 사건으로 정합니다. 예: order.created
Key 순서가 중요한 기준입니다. 주문 이벤트라면 보통 orderId가 key 후보입니다.
Payload Consumer가 필요한 최소 데이터만 넣고, 큰 상세 조회는 API/DB로 분리합니다.
{
  "eventId": "evt-20260513-0001",
  "eventType": "order.created",
  "occurredAt": "2026-05-13T20:45:00+09:00",
  "orderId": "ORD-10043",
  "userId": "USR-7781",
  "amount": 59000,
  "currency": "KRW",
  "items": [
    { "sku": "BOOK-KAFKA-01", "quantity": 1 }
  ]
}

Producer: 주문 저장 후 Kafka에 이벤트를 발행합니다

핵심 흐름 주문을 DB에 저장한 뒤, 같은 주문 ID를 key로 이벤트를 보냅니다.
key = orderId 같은 주문의 변경 이벤트가 같은 Partition에 들어가 순서가 유지됩니다.
주의 실제 서비스에서는 DB 저장과 이벤트 발행 사이 실패를 막기 위해 Outbox 패턴을 자주 검토합니다.
// Spring Boot + Spring Kafka 예시
@Service
public class OrderService {
  private final OrderRepository orderRepository;
  private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;

  public OrderResponse createOrder(CreateOrderRequest request) {
    Order order = orderRepository.save(Order.from(request));

    OrderCreatedEvent event = new OrderCreatedEvent(
        UUID.randomUUID().toString(),
        order.getId(),
        order.getUserId(),
        order.getAmount(),
        order.getCreatedAt()
    );

    kafkaTemplate.send(
        "order.created",
        order.getId(),   // partition key
        event
    );

    return OrderResponse.from(order);
  }
}

Consumer: 이벤트를 읽고 자기 책임만 처리합니다

groupId 같은 group 안에서는 이벤트를 나눠 읽고, 다른 group은 같은 이벤트를 독립적으로 읽습니다.
중복 처리 대비 Kafka Consumer는 재시도 때문에 같은 이벤트를 다시 받을 수 있다고 생각해야 합니다.
실무 포인트 결제/알림/분석은 각각 별도 Consumer Group으로 두면 서로의 장애가 덜 전파됩니다.
@Component
public class NotificationConsumer {
  private final ProcessedEventRepository processedEvents;
  private final NotificationService notificationService;

  @KafkaListener(
      topics = "order.created",
      groupId = "notification-service"
  )
  public void handle(OrderCreatedEvent event) {
    if (processedEvents.existsById(event.eventId())) {
      return; // duplicate message guard
    }

    notificationService.sendOrderCreatedMessage(
        event.userId(),
        event.orderId(),
        event.amount()
    );

    processedEvents.save(event.eventId());
  }
}

Consumer Group은 “같은 일을 나눠 하는 팀”입니다

Topic: order.created 3 partitions P0 101 104 107 P1 102 105 108 P2 103 106 109 GROUP A: notification-service notification-1 notification-2 notification-3 분담 GROUP B: analytics-service analytics-1 같은 Topic 독립 구독 다른 Consumer Group은 같은 이벤트를 별도로 읽음 Group마다 offset이 따로 있으므로 알림 장애가 분석 처리 위치를 망치지 않음
Kafka를 처음 볼 때 가장 중요한 감각은 “Topic은 하나인데, Consumer Group마다 읽은 위치가 따로 있다”는 점입니다.

Kafka가 필요한 상황과 과한 상황을 구분해야 합니다

Kafka가 잘 맞는 경우

  • 주문, 결제, 알림, 분석처럼 여러 서비스가 같은 사건을 각자 처리한다.
  • 트래픽이 몰려도 이벤트를 쌓아두고 Consumer가 자기 속도로 처리해야 한다.
  • 로그, 검색 색인, 추천 피처, 감사 이력처럼 이벤트 기록이 중요하다.
  • 나중에 새 Consumer를 붙여 기존 이벤트 흐름을 재사용할 가능성이 높다.

Kafka가 과한 경우

  • 요청 즉시 결과를 받아야 하는 단순 조회 API다.
  • 서비스가 하나뿐이고 이벤트를 읽을 다른 주체가 없다.
  • 운영 모니터링, 재처리, 메시지 중복 처리까지 감당할 준비가 없다.
  • 단순 백그라운드 작업 큐 하나면 충분한 작은 프로젝트다.

처음 설계 회의에 들어가면 이 네 가지를 물어보면 됩니다

1. 이벤트 이름

order.created처럼 사건을 과거형으로 정의했는가?

2. 메시지 필드

Consumer가 필요한 최소 데이터와 버전 관리 기준이 있는가?

3. Partition Key

순서를 지켜야 하는 기준이 주문 ID인지 사용자 ID인지 정했는가?

4. 실패 처리

중복, 재시도, DLQ, 알림 실패를 어떻게 다룰지 정했는가?

실행하면 이벤트가 Topic에 쌓이고 Consumer가 가져가 처리합니다

Producer Code

Order order = repo.save(req);
var event = OrderCreatedEvent.of(order);
kafka.send(
  "order.created",
  order.id(),
  event
);
@KafkaListener("order.created")
void handle(OrderCreatedEvent e) {
  notify.send(e.userId());
  commitOffset(e);
}
Run 버튼을 누르면 Producer 코드부터 실행됩니다.
Producer Order Service
주문 저장 후 이벤트 발행
Topic: order.created Partition 1 · append-only log
offset 0 · empty
offset 1 · empty
offset 2 · empty
Consumer notification-service
poll → process → commit
waiting for poll()
evt-001
ORD-10043
consumer offset: 0

실제로는 이 파일 순서대로 작성하면 됩니다

build.gradle Spring Kafka 의존성을 추가합니다. 애플리케이션이 Kafka Producer/Consumer API를 사용할 수 있게 됩니다.
application.yml broker 주소, serializer/deserializer, consumer group, offset commit 방식을 설정합니다.
OrderCreatedEvent.java Topic에 실어 보낼 메시지 계약입니다. Producer와 Consumer가 같은 모양을 공유합니다.
OrderEventProducer.java 주문 생성 이벤트를 order.created Topic으로 발행합니다.
OrderService.java 주문 저장 후 Producer를 호출합니다. 비즈니스 코드와 Kafka 발행 지점이 만나는 곳입니다.
NotificationConsumer.java Topic에서 이벤트를 읽고 알림을 보낸 뒤 offset을 commit합니다.

1. 의존성과 Kafka 연결 설정

1plugins { id 'java' }
2plugins { id 'org.springframework.boot' version '3.3.0' }
3
4dependencies {
5  implementation 'org.springframework.boot:spring-boot-starter-web'
6  implementation 'org.springframework.kafka:spring-kafka'
7  implementation 'org.springframework.boot:spring-boot-starter-validation'
8  testImplementation 'org.springframework.kafka:spring-kafka-test'
9}
10
11# application.yml
12spring.kafka.bootstrap-servers: localhost:9092
13spring.kafka.producer.key-serializer: org.apache.kafka.common.serialization.StringSerializer
14spring.kafka.producer.value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
15spring.kafka.consumer.group-id: notification-service
16spring.kafka.consumer.auto-offset-reset: earliest
17spring.kafka.consumer.key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
18spring.kafka.consumer.value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
19spring.kafka.consumer.properties.spring.json.trusted.packages: com.example.kafka
20spring.kafka.listener.ack-mode: manual
  • 6번 Spring Boot에서 KafkaTemplate과 @KafkaListener를 쓰기 위한 핵심 의존성입니다.
  • 12번 로컬 개발이면 보통 Docker Kafka의 9092 포트를 바라봅니다.
  • 14, 18번 Java 객체를 JSON으로 보내고, Consumer에서 다시 객체로 받기 위한 설정입니다.
  • 19번 JsonDeserializer가 역직렬화해도 되는 패키지를 제한합니다.
  • 20번 Consumer가 처리를 끝낸 뒤 직접 offset commit을 호출하게 만듭니다.

2. Topic에 실어 보낼 Event DTO를 먼저 정합니다

1package com.example.kafka.order;
2
3import java.time.Instant;
4import java.util.UUID;
5
6public record OrderCreatedEvent(
7    String eventId,
8    String orderId,
9    String userId,
10    long amount,
11    Instant occurredAt
12) {
13  public static OrderCreatedEvent from(Order order) {
14    return new OrderCreatedEvent(
15        UUID.randomUUID().toString(),
16        order.id(),
17        order.userId(),
18        order.amount(),
19        Instant.now()
20    );
21  }
22}
23
24public final class KafkaTopics {
25  public static final String ORDER_CREATED = "order.created";
26  private KafkaTopics() {}
27}
  • 6번 record를 쓰면 JSON으로 보낼 필드를 명확히 고정할 수 있습니다.
  • 7번 eventId는 중복 처리 방지에 씁니다. Consumer가 같은 eventId를 이미 처리했는지 확인할 수 있습니다.
  • 8번 orderId는 Kafka message key로 쓰기 좋습니다. 같은 주문 이벤트를 같은 partition으로 보내기 위함입니다.
  • 14번 도메인 객체 Order에서 이벤트 객체로 변환합니다. DB Entity를 그대로 메시지로 보내지 않습니다.
  • 25번 Topic 이름은 상수화해서 Producer와 Consumer가 같은 문자열을 공유하게 합니다.

3. Producer는 Topic, key, event를 명확히 넣어 보냅니다

1@Slf4j
2@Component
3@RequiredArgsConstructor
4public class OrderEventProducer {
5  private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
6
7  public void publishOrderCreated(Order order) {
8    OrderCreatedEvent event = OrderCreatedEvent.from(order);
9    String topic = KafkaTopics.ORDER_CREATED;
10    String key = event.orderId();
11
12    kafkaTemplate.send(topic, key, event)
13        .whenComplete((result, ex) -> {
14          if (ex != null) {
15            log.error("publish failed. topic={}, key={}", topic, key, ex);
16            return;
17          }
18          var meta = result.getRecordMetadata();
19          log.info("published. partition={}, offset={}", meta.partition(), meta.offset());
20        });
21  }
22}
  • 5번 key는 String, value는 OrderCreatedEvent인 KafkaTemplate을 주입받습니다.
  • 8번 비즈니스 객체를 이벤트 메시지로 변환합니다.
  • 10번 key를 orderId로 잡으면 같은 주문 관련 이벤트가 같은 partition에 들어갈 가능성이 높아집니다.
  • 12번 send(topic, key, value) 형태로 보냅니다. 성공/실패는 callback에서 확인합니다.
  • 18-19번 운영 로그에는 partition과 offset을 남기면 추적이 쉬워집니다.

4. Service는 “DB 저장 후 이벤트 발행” 순서로 연결합니다

1package com.example.kafka.order;
2
3import lombok.RequiredArgsConstructor;
4import org.springframework.stereotype.Service;
5import org.springframework.transaction.annotation.Transactional;
6
7@Service
8@RequiredArgsConstructor
9public class OrderService {
10  private final OrderRepository orderRepository;
11  private final OrderEventProducer orderEventProducer;
12
13  @Transactional
14  public OrderResponse createOrder(CreateOrderRequest request) {
15    Order order = Order.create(
16        request.userId(),
17        request.items(),
18        request.amount()
19    );
20
21    Order savedOrder = orderRepository.save(order);
22    orderEventProducer.publishOrderCreated(savedOrder);
23
24    return OrderResponse.from(savedOrder);
25  }
26}
  • 13번 주문 저장은 트랜잭션 안에서 처리합니다.
  • 21번 DB에 주문이 먼저 남아야 Consumer가 나중에 주문 상세를 조회할 수 있습니다.
  • 22번 저장된 주문 기준으로 이벤트를 발행합니다.
  • 중요 아주 엄격하게 하려면 21번과 22번 사이 실패를 막기 위해 Outbox 패턴을 씁니다. 입문 자료에서는 연결 지점을 먼저 이해하면 됩니다.

5. Consumer는 처리 성공 후에만 offset을 commit합니다

1@Slf4j
2@Component
3@RequiredArgsConstructor
4public class NotificationConsumer {
5  private final ProcessedEventRepository processedEventRepository;
6  private final NotificationService notificationService;
7
8  @KafkaListener(topics = KafkaTopics.ORDER_CREATED, groupId = "notification-service")
9  public void handle(OrderCreatedEvent event, Acknowledgment ack) {
10    log.info("consume. eventId={}, orderId={}", event.eventId(), event.orderId());
11
12    if (processedEventRepository.existsByEventId(event.eventId())) {
13      ack.acknowledge();
14      return;
15    }
16
17    notificationService.sendOrderCreatedMessage(event.userId(), event.orderId(), event.amount());
18    processedEventRepository.save(event.eventId());
19    ack.acknowledge();
20  }
21}
  • 8번 이 Consumer는 order.created Topic을 notification-service group으로 읽습니다.
  • 9번 Acknowledgment는 수동 offset commit 핸들입니다.
  • 12-15번 같은 이벤트가 재전달될 수 있으므로 eventId로 중복 처리를 막습니다.
  • 17번 Consumer는 자기 책임인 “알림 발송”만 처리합니다.
  • 19번 성공적으로 처리한 뒤 commit합니다. 실패하면 commit하지 않아 재처리될 수 있습니다.

6. Consumer 처리부는 중복 방지 → 비즈니스 처리 → commit 순서입니다

1public void handle(OrderCreatedEvent event, Acknowledgment ack) {
2  log.info("consume. eventId={}, orderId={}",
3      event.eventId(), event.orderId());
4
5  if (processedEventRepository.existsByEventId(event.eventId())) {
6    ack.acknowledge();
7    return;
8  }
9
10  notificationService.sendOrderCreatedMessage(
11      event.userId(),
12      event.orderId(),
13      event.amount()
14  );
15
16  processedEventRepository.save(event.eventId());
17  ack.acknowledge();
18}
  • 5번 Consumer는 같은 메시지를 두 번 받을 수 있다고 가정합니다. 그래서 eventId로 이미 처리했는지 먼저 봅니다.
  • 6번 이미 처리한 이벤트라면 비즈니스 로직을 다시 실행하지 않고 offset만 commit합니다.
  • 10-14번 실제 담당 업무인 알림 발송만 수행합니다. 결제나 분석 로직을 여기 넣지 않습니다.
  • 16번 처리 완료 eventId를 저장해 다음 중복 수신을 막습니다.
  • 17번 모든 처리가 끝난 뒤 commit합니다. 이 줄 전에 예외가 나면 commit하지 않아 재처리 여지가 남습니다.