6. An HTTP-to-Kafka Ingest Server

HTTP로 받아 Kafka에 넣는 수집 서버

  • Haram Lee
  • 2026-10-08
  • studies / Topics / Go

받은 이벤트를 검증만 하고 Kafka topic에 넣는, “받아서 넣기만 하는” 서버를 생각해 보자. 이 글은 앞 글들의 개념을 모아 그런 서버의 뼈대를 짜 보고, 설계에서 정해야 할 것들을 정리한다.

6.1 Why “Just Ingest”

브라우저는 페이지를 떠날 때(visibilitychange, pagehide) 수집할 이벤트를 navigator.sendBeacon으로 보내곤 한다. 이런 요청은 응답을 기다리지 않고, 짧은 시간 안에 수없이 들어온다. 그래서 수집 서버는 다음을 지킨다.

  • 응답은 빨리. 검증만 하고 바로 202 Accepted를 돌려준다. 무거운 가공은 하지 않는다.
  • 가공은 뒤에서. Kafka에 넣은 뒤의 집계, 조인, 웨어하우스 적재는 Flink 같은 뒤쪽 시스템이 맡는다. 수집 서버가 웨어하우스에 직접 쓰면 웨어하우스가 느려질 때 수집까지 같이 느려진다.
  • Kafka가 완충 장치다. 뒤쪽이 멈춰도 Kafka 보존 기간 안에서는 데이터가 남는다.

6.2 The Overall Flow

flowchart TD
  client["Client (sendBeacon)"] -->|"POST /v1/events (JSON)"| handler["HTTP Handler"]
  handler -->|"invalid"| r400["400"]
  handler -->|"valid"| buffer["Buffered Channel"]
  buffer -->|"full"| r503["503"]
  buffer --> batcher["Batch Goroutine<br/>(500 events or 1s)"]
  batcher --> producer["Kafka Producer"]
  producer --> topic["topic: events"]

6.3 Skeleton Code

아래는 구조를 보여 주기 위한 뼈대다. 실제 Kafka 클라이언트 라이브러리는 대부분 자체 배치와 비동기 전송을 하므로, 직접 channel로 배치를 짤지 라이브러리에 맡길지는 선택이다. 연습할 때는 직접 짜 보는 쪽이 개념을 익히기 좋다.

먼저 이벤트와 검증, 그리고 Kafka 쪽 의존성을 정의한다. Kafka 클라이언트는 2편에서 본 것처럼 쓰는 쪽에서 작은 인터페이스로 감싼다.

go
const topic = "events"

type Event struct {
    Name  string            `json:"name"`
    Page  string            `json:"page"`
    TsMs  int64             `json:"ts_ms"`
    Attrs map[string]string `json:"attrs,omitempty"`
}

var (
    ErrMissingName      = errors.New("missing name")
    ErrInvalidTimestamp = errors.New("invalid timestamp")
)

func (e Event) validate() error {
    if e.Name == "" {
        return ErrMissingName
    }
    if e.TsMs <= 0 {
        return ErrInvalidTimestamp
    }
    return nil
}

// 쓰는 쪽에서 정의하는 작은 인터페이스. 실제 Kafka 클라이언트를 감싸 구현한다.
type Producer interface {
    Produce(ctx context.Context, topic string, values [][]byte) error
    Close() error
}

핸들러는 검증하고 channel에 넣기만 한다. channel이 가득 차면 기다리지 않고 503을 준다(4편의 select와 default).

go
func handleEvents(events chan<- Event) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        r.Body = http.MaxBytesReader(w, r.Body, 64<<10) // 본문 크기 제한 64KB
        var e Event
        if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
            http.Error(w, "bad json", http.StatusBadRequest)
            return
        }
        if err := e.validate(); err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        select {
        case events <- e:
            w.WriteHeader(http.StatusAccepted)
        default:
            http.Error(w, "busy", http.StatusServiceUnavailable)
        }
    }
}

handleEvents가 의존성(events)을 받아 핸들러 함수를 돌려주는 모양이 클로저로 의존성을 주입하는 방식이다. 이렇게 하면 전역 변수 없이도 테스트에서 다른 channel을 넣을 수 있다.

배치 goroutine은 4편의 select 루프와 같다. 한 가지 다른 점은 ctx.Done() 대신 channel이 닫히는 것을 종료 신호로 쓴다는 것이다. ctx.Done()으로 끝내면 그 순간 channel에 남아 있던 이벤트를 버리게 된다. channel이 닫힐 때까지 받으면 남은 것을 모두 꺼낸 뒤에 끝난다.

go
func runBatcher(events <-chan Event, p Producer) {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    batch := make([][]byte, 0, 500)
    flush := func() {
        if len(batch) == 0 {
            return
        }
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        if err := p.Produce(ctx, topic, batch); err != nil {
            log.Printf("produce %d events: %v", len(batch), err)
        }
        batch = batch[:0]
    }

    for {
        select {
        case e, ok := <-events:
            if !ok { // channel이 닫혔다 = 서버 종료. 남은 것을 보내고 끝낸다
                flush()
                return
            }
            b, err := json.Marshal(e)
            if err != nil {
                log.Printf("marshal event: %v", err)
                continue
            }
            batch = append(batch, b)
            if len(batch) >= 500 {
                flush()
            }
        case <-ticker.C:
            flush()
        }
    }
}

main은 5편의 종료 순서를 그대로 따른다.

go
func main() {
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
    defer stop()

    producer := newProducer() // 쓰는 Kafka 라이브러리에 따라 다르다
    events := make(chan Event, 10_000)

    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        runBatcher(events, producer)
    }()

    mux := http.NewServeMux()
    mux.HandleFunc("POST /v1/events", handleEvents(events)) // Go 1.22부터 메서드 패턴 지원
    srv := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
    }

    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("listen: %v", err)
        }
    }()
    <-ctx.Done()

    sctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    if err := srv.Shutdown(sctx); err != nil {
        log.Printf("shutdown: %v", err)
    }
    close(events) // 핸들러가 모두 끝난 뒤에 닫아야 닫힌 channel에 보내는 panic이 없다
    wg.Wait()
    producer.Close()
}

newProducer는 고른 Kafka 라이브러리로 Producer 인터페이스를 구현해 돌려주는 함수다. 이것만 채우면 위 코드는 그대로 컴파일된다.

6.4 Kafka Client Options

Go의 Kafka 클라이언트는 여럿이다.

라이브러리특징
github.com/twmb/franz-go순수 Go, 기능이 가장 넓고 활발하다. 배치, 멱등 프로듀서, 트랜잭션을 지원한다
github.com/confluentinc/confluent-kafka-goC 라이브러리 librdkafka를 감싼다. cgo가 필요해 빌드가 복잡해진다
github.com/IBM/sarama오래된 순수 Go 클라이언트다. 예전 코드에서 많이 보인다
github.com/segmentio/kafka-goAPI가 단순한 순수 Go 클라이언트다

로컬 연습용 Kafka는 공식 이미지로 띄울 수 있다. docker run -d -p 9092:9092 apache/kafka를 실행하면 단일 노드 KRaft 브로커가 localhost:9092에 뜬다.

6.5 Design Decisions

뼈대를 짜면 실제 서비스에서 결정해야 할 것들이 보인다.

  • 전달 보장: 서버가 202를 준 뒤 Kafka 전송 전에 죽으면 channel에 있던 이벤트는 사라진다. 이 이벤트에서 일부 유실을 허용할 수 있는가? 허용할 수 없다면 요청 안에서 동기로 Kafka 응답(ack)까지 기다려야 하고, 응답이 느려진다.
  • Kafka acks 설정: acks=all이면 안전하지만 느리고, acks=1이면 빠르지만 리더 장애 때 유실될 수 있다.
  • 파티션 키: 키 없이 보내면 고르게 퍼진다. 페이지나 사용자 기준 순서가 필요하면 키를 준다. 뒤에서 Flink가 키 기준으로 집계한다면 키 선택이 Flink 상태 분포에 영향을 준다.
  • 스키마: JSON을 그대로 넣을지, Protobuf나 Avro로 바꿔 넣을지 정해야 한다. 다른 이벤트 스키마 체계와 맞출지도 함께 정한다.
  • 배압: 버퍼가 차면 503을 줄지, 기다릴지 정해야 한다. sendBeacon은 응답을 보지 않으므로 503을 줘도 재전송되지 않는다.
  • 관측성: 받은 수, 거절한 수, Kafka 전송 지연과 실패 수를 메트릭으로 낸다. Prometheus 클라이언트(prometheus/client_golang)를 쓰는 것이 흔하다.

6.6 Exercises

  1. 위 뼈대의 newProducer를 franz-go로 채워 로컬 Kafka에 넣는다. kcat -C -b localhost:9092 -t events로 들어간 것을 확인한다.
  2. hey나 vegeta로 초당 수천 건을 보내 보고, 버퍼 크기와 배치 크기에 따라 503 비율이 어떻게 바뀌는지 본다.
  3. 부하 중에 Ctrl+C를 누른다. 그다음 202를 준 수와 Kafka 오프셋을 비교해, 받은 이벤트가 모두 Kafka에 들어갔는지 확인한다.
  4. 핸들러 테스트를 httptest로 쓴다. Kafka는 Producer 인터페이스에 가짜 구현을 넣는다.
Discussion