Skip to main content

Command Palette

Search for a command to run...

카프카 입문 시리즈 8편: Kafka Connect 동작 원리

Updated
13 min read

이 글은 Apache Kafka 입문 시리즈의 여덟 번째 글입니다. 1~6편에서 카프카 자체의 구성 요소와 메시지 흐름을 살펴봤고, 7편에서는 카프카 위에서 데이터를 가공하는 Streams API를 다뤘습니다. 이번 편에서는 카프카와 외부 시스템(DB, 검색엔진, 객체 스토리지 등) 사이를 잇는 통합 프레임워크인 Kafka Connect를 들여다봅니다. Connect를 처음 다뤄보지만 단순한 사용법이 아니라 내부 구조까지 한 번 정리해두고 싶은 분을 대상으로 합니다.

왜 Kafka Connect가 필요한가?

카프카로 외부 시스템 데이터를 옮기는 일은 매우 자주 일어납니다. MySQL의 변경사항을 카프카로 흘려보내거나, 카프카의 이벤트를 Elasticsearch에 색인하거나, S3에 아카이브하는 식입니다.

이런 파이프라인을 직접 작성한다고 가정해 봅시다. Producer/Consumer API를 호출하는 코드는 어렵지 않습니다. 그런데 막상 운영에 올리면 본질이 아닌 일이 차곡차곡 쌓이기 시작합니다.

  • 어디까지 읽었는지 오프셋 관리 (소스 시스템의 binlog 위치, 파일의 line 번호 등)
  • 워커가 죽었을 때 자동 재시작과 작업 재분배
  • 설정을 바꾸면 무중단으로 재로딩
  • 메시지 포맷을 JSON, Avro, Protobuf 사이에서 변환
  • 메시지에 공통 변환 로직(필드 마스킹, 타임스탬프 추가) 일괄 적용
  • 운영자가 REST API로 상태 점검·재시작

이 모든 걸 매번 손으로 짜다 보면, 결국 외부 시스템마다 비슷한 코드를 변형해서 다시 만드는 상황이 됩니다.

Kafka Connect는 이 반복을 없애기 위해 만들어진 프레임워크입니다. 외부 시스템과의 연동 코드를 Connector 플러그인 형태로 모듈화하고, 그 외의 모든 운영 관심사(분산 실행, 장애 복구, 오프셋 관리, 변환, REST API)는 프레임워크가 책임집니다.

flowchart LR
    subgraph Sources["External Sources"]
        DB[(MySQL)]
        FS[/files/]
    end

    subgraph Connect["Kafka Connect Cluster"]
        SC[Source Connector]
        SK[Sink Connector]
    end

    subgraph Kafka["Kafka Cluster"]
        T1[topic A]
        T2[topic B]
    end

    subgraph Sinks["External Sinks"]
        ES[(Elasticsearch)]
        S3[(S3)]
    end

    DB --> SC --> T1
    FS --> SC --> T2
    T1 --> SK --> ES
    T2 --> SK --> S3

핵심 아이디어는 단순합니다. 외부 → 카프카로 보내는 쪽이 Source Connector, 카프카 → 외부로 내보내는 쪽이 Sink Connector이고, 두 방향 모두 같은 Connect 프레임워크 위에서 돕니다.


세 가지 추상화: Connector, Task, Worker

Connect를 처음 보면 비슷한 단어들이 한꺼번에 나와 헷갈립니다. Connector, Task, Worker는 책임이 분명히 다른 세 계층입니다.

Connector — 일을 어떻게 쪼갤지 결정한다

Connector연동 자체에 대한 정의와 설정 관리를 담당합니다. 직접 데이터를 옮기지는 않습니다. Connector가 하는 일은 두 가지입니다.

  1. 사용자가 넘긴 설정을 받아 검증한다
  2. 그 일을 몇 개의 Task로 쪼개고, 각 Task에 어떤 설정을 줄지 결정한다

예를 들어 JDBC Source Connector에 tasks.max=4topics=t1,t2,t3,t4,t5를 주면, Connector는 5개의 토픽을 4개의 Task에 분배해서 "Task 1은 t1, t2를 담당", "Task 2는 t3을 담당", "..."처럼 분할 계획을 만듭니다.

Task — 실제로 데이터를 옮긴다

Task는 Connector가 만든 분할 계획에 따라 실제로 레코드를 읽고 쓰는 단위입니다. Source Task라면 외부 시스템에서 poll()을 호출해 레코드 목록을 반환하고, Sink Task라면 카프카에서 받은 레코드를 외부 시스템에 put() 합니다.

Task는 병렬화의 단위이기도 합니다. Connector 1개에 Task 4개가 있다면, 워커 4대로 흩어져서 동시에 일을 처리할 수 있습니다.

Worker — Task를 실행하는 JVM 프로세스

WorkerTask를 실행하는 JVM 프로세스입니다. 워커는 자기에게 할당된 Task들을 별도 스레드로 돌리고, 죽은 워커가 생기면 그 워커가 돌리던 Task를 다른 워커로 옮깁니다.

워커와 Task의 관계는 카프카 컨슈머 그룹과 비슷합니다. 컨슈머 그룹에서 컨슈머 인스턴스가 파티션을 나눠 갖듯, Connect 클러스터에서 워커가 Task를 나눠 갖습니다.

flowchart TB
    subgraph Connectors["Connector definitions"]
        C1[mysql-source]
        C2[es-sink]
    end

    subgraph Tasks["Tasks (logical units of work)"]
        T1[mysql-source-0]
        T2[mysql-source-1]
        T3[mysql-source-2]
        T4[es-sink-0]
        T5[es-sink-1]
    end

    subgraph Workers["Workers (JVM processes)"]
        W1[Worker A]
        W2[Worker B]
    end

    C1 --> T1
    C1 --> T2
    C1 --> T3
    C2 --> T4
    C2 --> T5

    T1 --> W1
    T3 --> W1
    T4 --> W1
    T2 --> W2
    T5 --> W2

요약하면 이렇습니다.

  • Connector: 일의 정의와 분할 계획. 메모리 위의 가벼운 객체
  • Task: 실제 입출력을 수행하는 단위. 보통 백그라운드 스레드 1개
  • Worker: Task가 실행되는 JVM 프로세스

Source vs Sink, 같은 프레임워크의 두 방향

Source와 Sink는 데이터 흐름의 방향만 다릅니다. 프레임워크 입장에서는 둘 다 "어디서 레코드를 받아 어디로 보낸다"는 동일한 추상화의 양 끝일 뿐입니다.

Source Connector — 외부에서 카프카로

Source Task는 프레임워크가 주기적으로 호출하는 poll() 메서드를 구현합니다.

public abstract class SourceTask {
    public abstract List<SourceRecord> poll() throws InterruptedException;
}

SourceRecord에는 페이로드 외에 소스 파티션과 소스 오프셋이 함께 들어갑니다. 예를 들어 파일을 읽는 커넥터라면 소스 파티션은 파일 경로, 소스 오프셋은 line 번호가 됩니다.

이 (소스 파티션, 소스 오프셋) 쌍이 핵심입니다. 워커가 죽었다가 다시 살아났을 때, 프레임워크가 마지막으로 처리한 오프셋을 알아내 그 다음부터 재개할 수 있게 해주는 것이 바로 이 정보입니다.

Sink Connector — 카프카에서 외부로

Sink Task는 카프카에서 받은 레코드 배치를 외부 시스템에 쓰는 put()을 구현합니다.

public abstract class SinkTask {
    public abstract void put(Collection<SinkRecord> records);
    public void flush(Map<TopicPartition, OffsetAndMetadata> offsets) { }
}

Sink Task는 내부적으로 카프카 컨슈머를 들고 있습니다. 그래서 Sink Connector를 띄우면 카프카 클러스터에 connect-<connector-name>이라는 이름의 컨슈머 그룹이 자동으로 생깁니다. Sink Task는 일반 컨슈머와 똑같이 파티션을 나눠 가지고, 메시지를 받아 외부 시스템에 씁니다.

어디까지 처리했는가의 두 얼굴

흥미로운 비대칭이 있습니다.

  • Source: "어디까지 읽었는가"는 외부 시스템 기준 위치(binlog GTID, 파일 line 번호 등)이며, 이를 카프카의 내부 토픽에 저장합니다
  • Sink: "어디까지 썼는가"는 카프카 토픽의 오프셋이며, 이를 카프카의 컨슈머 그룹 오프셋 메커니즘에 저장합니다

같은 프레임워크 안에서 방향이 다르기 때문에 오프셋 저장소도 다릅니다.


Standalone vs Distributed 모드

Connect는 두 가지 실행 모드를 지원합니다. 이름은 비슷하지만 분산성, 장애 복구, 설정 저장 방식이 모두 다릅니다.

Standalone — 단일 프로세스, 로컬 파일

Standalone 모드는 워커가 1대뿐인 모드입니다. 설정 파일을 명령줄에 직접 넘겨서 실행합니다.

bin/connect-standalone.sh \
  config/connect-standalone.properties \
  config/source-connector.properties

특징은 다음과 같습니다.

  • 설정과 오프셋이 로컬 파일에 저장 (offset.storage.file.filename 프로퍼티)
  • 워커가 죽으면 거기서 끝, 다른 워커로 옮겨가지 않음
  • REST API는 살아있지만 분산 동작이 없으므로 활용도는 제한적

장비 1대에서 로그를 모으는 작은 시나리오나 로컬 개발 환경에 적합합니다.

Distributed — 클러스터로 동작, 카프카 토픽이 저장소

Distributed 모드는 여러 워커가 같은 그룹에 묶여 클러스터를 이룹니다. 워커는 다음과 같이 시작합니다.

bin/connect-distributed.sh config/connect-distributed.properties

설정은 명령줄이 아니라 REST API로 등록합니다.

curl -X POST http://connect-1:8083/connectors \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-source",
    "config": {
      "connector.class": "io.example.MySource",
      "tasks.max": "4",
      "topic": "events"
    }
  }'

핵심은 상태가 모두 카프카에 저장된다는 점입니다. 그래서 워커를 여러 대 띄우면 자동으로 일을 분담하고, 워커가 죽으면 그 워커의 Task가 자동으로 다른 워커로 재배치됩니다.

flowchart TB
    subgraph Workers["Connect Workers (group.id=connect-cluster-1)"]
        W1[Worker 1]
        W2[Worker 2]
        W3[Worker 3]
    end

    subgraph Internal["Internal Topics in Kafka"]
        CT[config.storage.topic]
        OT[offset.storage.topic]
        ST[status.storage.topic]
    end

    W1 <--> CT
    W2 <--> CT
    W3 <--> CT
    W1 <--> OT
    W2 <--> OT
    W3 <--> OT
    W1 <--> ST
    W2 <--> ST
    W3 <--> ST

운영 환경에서는 항상 Distributed 모드를 씁니다. 같은 group.id를 공유하는 워커들이 한 클러스터를 이루며, 다른 클러스터를 운영하려면 group.id와 세 개의 내부 토픽을 모두 다르게 지정해야 합니다.


Distributed 모드의 세 내부 토픽

Distributed 모드에서 워커들이 공유하는 상태는 카프카 토픽 세 개에 저장됩니다. 이 세 토픽은 모두 log compaction으로 동작하며, 각각의 역할이 명확히 다릅니다.

config.storage.topic — 커넥터 설정

REST API로 등록한 Connector 설정이 그대로 이 토픽에 들어갑니다. 워커가 재시작해도 카프카의 이 토픽을 읽어 클러스터의 모든 Connector를 복원합니다.

이 토픽은 반드시 단일 파티션이어야 합니다. 설정 변경 이벤트의 전역 순서를 보장해야 하기 때문입니다. 복제 인수(config.storage.replication.factor)는 운영 환경에서 보통 3으로 둡니다.

offset.storage.topic — Source Connector 오프셋

Source Connector의 (소스 파티션, 소스 오프셋) 매핑을 저장합니다. 예를 들어 JDBC Source Connector라면 {"table": "users"} 같은 소스 파티션에 대해 {"incrementing": 12345} 같은 오프셋을 기록합니다.

이 토픽은 다중 파티션이 가능합니다(offset.storage.partitions, 기본 25). 같은 키(소스 파티션)에 대한 최신 오프셋만 유지하면 되므로 compaction과 잘 맞습니다.

주의: 이 토픽은 Source Connector 전용입니다. Sink Connector의 진행도는 위에서 설명했듯 일반 컨슈머 그룹 오프셋(__consumer_offsets)에 저장됩니다.

status.storage.topic — 커넥터/태스크 상태

각 Connector와 Task의 현재 상태(RUNNING, PAUSED, FAILED, 에러 트레이스 등)가 저장됩니다. REST API로 GET /connectors/{name}/status를 호출하면 워커가 이 토픽에서 답을 합쳐 돌려줍니다.

상태는 자주 바뀌는 작은 데이터이므로 다중 파티션(status.storage.partitions, 기본 5)이 기본입니다.

구성 요약

토픽 저장 내용 권장 파티션 수 형식
config.storage.topic Connector 설정 1 (필수) compacted
offset.storage.topic Source 오프셋 25 (기본) compacted
status.storage.topic Connector/Task 상태 5 (기본) compacted

세 토픽 이름은 같은 클러스터 안의 모든 워커에서 동일해야 하며, 다른 Connect 클러스터를 운영하려면 이름을 모두 다르게 해야 합니다.


Converter와 Single Message Transform 체인

Source Task가 만든 SourceRecord에는 사람이 만든 객체(POJO, Schema, Struct)가 들어 있습니다. 이걸 카프카 토픽에 넣으려면 결국 바이트 배열로 직렬화해야 합니다. 이 직렬화 단계의 정책을 결정하는 것이 Converter, 그 직전에 끼어드는 변환 단계가 Single Message Transform(SMT) 입니다.

Converter — 직렬화/역직렬화 경계

Connector 설정에는 key.convertervalue.converter를 지정합니다. 자주 쓰는 Converter는 다음과 같습니다.

  • org.apache.kafka.connect.json.JsonConverter
  • org.apache.kafka.connect.storage.StringConverter
  • io.confluent.connect.avro.AvroConverter (Confluent 플러그인)
  • org.apache.kafka.connect.converters.ByteArrayConverter

JsonConverter에는 *.converter.schemas.enable이라는 잘 잊히는 옵션이 있습니다. 기본값 true이면 스키마와 페이로드를 함께 감싼 {"schema": ..., "payload": ...} 형태가 토픽에 들어갑니다. 단순 JSON을 원했다면 의외의 결과가 됩니다. 단순 JSON 흐름을 만들려면 양쪽 모두 false로 둬야 합니다.

SMT — 한 메시지 단위의 가벼운 변환

SMT는 KIP-66으로 도입된 기능으로, 한 레코드 안에서만 끝나는 변환을 위한 훅입니다. 외부 상태를 모으거나 윈도우 집계를 하지는 않습니다(그건 Streams의 영역입니다).

  • 필드 이름 바꾸기 (ReplaceField)
  • 메시지 키 추출 (ValueToKey)
  • 타임스탬프를 토픽 라우팅에 사용 (TimestampRouter)
  • PII 마스킹 (MaskField)

여러 SMT를 체인으로 묶을 수 있습니다.

transforms=insertTs,routeByDate
transforms.insertTs.type=org.apache.kafka.connect.transforms.InsertField$Value
transforms.insertTs.timestamp.field=ts
transforms.routeByDate.type=org.apache.kafka.connect.transforms.TimestampRouter
transforms.routeByDate.topic.format=\({topic}-\){timestamp}
transforms.routeByDate.timestamp.format=yyyyMMdd

transforms 키에 적은 순서대로 insertTs → routeByDate가 차례로 적용됩니다.

Source/Sink에서 Converter와 SMT의 적용 순서

방향에 따라 SMT와 Converter의 순서가 다릅니다.

flowchart LR
    subgraph Source["Source flow"]
        ST[Source Task] --> SMTs[SMT chain]
        SMTs --> CV[Converter serialize]
        CV --> KT[Kafka topic]
    end
    subgraph Sink["Sink flow"]
        KT2[Kafka topic] --> CV2[Converter deserialize]
        CV2 --> SMTs2[SMT chain]
        SMTs2 --> SK[Sink Task]
    end

Source는 Task → SMT → Converter → 토픽 순서로 흐르고, Sink는 토픽 → Converter → SMT → Task 순서로 흐릅니다. 둘 다 SMT는 카프카 와이어 포맷이 아닌 Connect의 내부 표현(Struct/Schema) 위에서 동작한다는 점이 핵심입니다.


Rebalancing — 워커 변동에 어떻게 반응하나

워커가 들어오거나 나가면 Task를 누구에게 줄지 다시 정해야 합니다. 이 절차를 rebalancing이라 부르고, Connect는 KIP-415의 Incremental Cooperative Rebalancing을 씁니다(Apache Kafka 2.3에서 도입).

이전 방식인 eager rebalancing은 모든 워커가 일제히 자기 Task를 멈추고, 새 분배가 끝나면 다시 시작하는 stop-the-world 방식이었습니다. 짧은 워커 재시작이나 롤링 업그레이드 때마다 전체 클러스터가 출렁였습니다.

Cooperative rebalancing은 다릅니다.

  • 한 라운드에서는 재배치가 필요한 Task만 멈추고 나머지는 그대로 일을 계속합니다
  • 멈춘 Task는 다음 라운드에서 새 워커에 할당됩니다
  • 이동이 없는 Task는 한 번도 멈추지 않습니다
flowchart LR
    subgraph Eager["Eager (old)"]
        E1["1) all tasks stop"] --> E2["2) reassign"] --> E3["3) all tasks start"]
    end
    subgraph Cooperative["Cooperative (KIP-415)"]
        C1["1) only moving tasks stop"] --> C2["2) reassign just those"] --> C3["3) others never paused"]
    end

운영 관점에서는 워커를 한 대씩 차례로 재시작하는 롤링 업그레이드가 거의 무중단에 가까워집니다.


Source Exactly-Once (Kafka 3.3+)

원래 Connect는 Source Connector에 대해 at-least-once만 보장했습니다. Source Task가 레코드를 카프카에 쓰고 오프셋을 저장하기 직전에 죽으면, 재시작 후 같은 레코드를 다시 보낼 수 있었습니다.

Apache Kafka 3.3에서 KIP-618로 Source Connector의 exactly-once 지원이 들어왔습니다. 핵심 아이디어는 카프카 트랜잭션의 활용입니다.

어떻게 동작하나

Source Task의 레코드 전송소스 오프셋 기록을 하나의 트랜잭션으로 묶습니다. 두 작업 모두 같은 transactional producer가 수행하고, commitTransaction()을 호출해야만 양쪽이 함께 가시화됩니다.

flowchart LR
    ST[Source Task] -->|beginTxn| P[Transactional Producer]
    P -->|send records| TT[Topic A]
    P -->|send offset| OT[offset.storage.topic]
    P -->|commitTxn| Result[Both visible together]

Task가 commit 전에 죽으면 트랜잭션은 abort되고, 새로 시작한 Task는 마지막으로 commit된 오프셋부터 재개합니다. 같은 레코드가 두 번 카프카에 저장되는 일은 발생하지 않습니다.

Producer fencing

추가로 KIP-618은 이전 세대 Task의 Producer를 fence하는 메커니즘을 갖춥니다. 워커 A에 있던 Task가 워커 B로 옮겨갈 때, 옛 Task의 Producer가 살아남아 뒤늦게 commit하는 상황을 막아야 합니다.

해결책은 transactional ID에 epoch를 부여하고, 새 Task가 더 높은 epoch로 트랜잭션을 시작하는 것입니다. 카프카 브로커는 더 낮은 epoch의 Producer가 send나 commit을 시도하면 ProducerFenced 예외로 거부합니다.

활성화 조건과 한계

exactly.once.source.support=enabled

이 옵션은 워커 레벨에서 켜야 하며, 한번 켜면 클러스터 안의 모든 Source Connector가 EOS로 동작합니다. 일부만 EOS로 돌리는 부분 적용은 지원하지 않습니다.

또한 다음 조건이 필요합니다.

  • Distributed 모드 전용입니다. Standalone에서는 동작하지 않습니다
  • Connector 자체가 SourceConnector.exactlyOnceSupport()를 구현해 EOS 지원 의사를 명시해야 합니다
  • 같은 source partition을 두 Task가 동시에 읽지 않아야 합니다

Sink Connector의 EOS는 KIP-618 범위가 아닙니다. Sink는 외부 시스템마다 멱등성을 어떻게 만들지가 다르기 때문에, 보통 외부 시스템 측 upsert나 idempotent write로 해결합니다.


REST API — 운영의 진입점

Distributed 모드에서 클러스터에 명령을 내리는 유일한 통로는 REST API입니다. 자주 쓰는 엔드포인트 몇 가지만 정리합니다.

# 클러스터 정보
GET  /

# Connector 등록/목록
POST /connectors
GET  /connectors

# 단일 Connector 제어
GET  /connectors/{name}
GET  /connectors/{name}/status
PUT  /connectors/{name}/config
DELETE /connectors/{name}

# Task 단위 제어
GET  /connectors/{name}/tasks
POST /connectors/{name}/tasks/{taskId}/restart

# 일시정지/재개
PUT  /connectors/{name}/pause
PUT  /connectors/{name}/resume

# Connect 클러스터에 설치된 플러그인 목록
GET  /connector-plugins

# 오프셋 조회·수정 (KIP-875, Apache Kafka 3.5+)
GET  /connectors/{name}/offsets
PATCH /connectors/{name}/offsets
DELETE /connectors/{name}/offsets

마지막 그룹은 KIP-875로 들어온 first-class offsets API입니다. 이전에는 Source 오프셋을 수정하려면 offset.storage.topic에 직접 메시지를 넣어야 했는데, 이제는 REST 한 번이면 됩니다. 운영 중인 Connector를 일시정지한 뒤 오프셋을 되감거나 비우는 작업이 안전해졌습니다.


실전에서 부딪히는 함정 다섯 가지

1. 같은 클러스터에 다른 group.id의 워커가 섞임

group.id만 다르고 세 내부 토픽 이름이 같으면 두 클러스터가 같은 토픽을 두고 싸웁니다. 새 클러스터를 띄울 때는 group.idconfig.storage.topic, offset.storage.topic, status.storage.topic모두 새로 지어야 합니다.

2. JsonConverter의 schemas.enable 기본값

기본값 true이면 메시지가 {"schema": ..., "payload": ...} 구조가 됩니다. 다운스트림 컨슈머가 단순 JSON을 가정했다면 키 이름과 형태가 달라 혼란스럽습니다. 단순 JSON으로 쓰려면 양쪽 모두 명시적으로 false로 두어야 합니다.

3. config.storage.topic의 파티션 수

이 토픽은 단일 파티션이어야 합니다. 멀티 파티션으로 만들면 설정 변경 이벤트의 전역 순서가 깨질 수 있고, 워커가 시작 시 클러스터 상태를 잘못 복원할 수 있습니다. 토픽을 자동 생성에 맡기지 말고 미리 의도한 설정으로 만들어 두세요.

4. tasks.max만 늘리면 병렬도가 올라간다는 오해

tasks.max는 상한일 뿐, 실제 Task 수는 Connector가 결정합니다. JDBC Source가 테이블 1개만 읽고 있다면 tasks.max=10이라도 실제 Task는 1개만 만들어집니다. 병렬도를 더 올리려면 분할 가능한 입력을 늘려야 합니다(테이블 수, 파일 수, 파티션 수 등).

5. Sink Task 컨슈머 그룹의 lag

Sink Connector를 띄우면 connect-<connector-name> 컨슈머 그룹이 생기고, 일반 컨슈머와 똑같이 컨슈머 lag이 잡힙니다. 외부 시스템의 응답이 느려져 lag이 쌓이는 상황은 일반 컨슈머와 동일하게 모니터링하면 됩니다. 이때 Connector를 죽였다 살려도 컨슈머 그룹 오프셋은 카프카에 남아 있어 처리가 이어집니다.


정리

이번 편에서는 Kafka Connect를 다음 골격으로 정리했습니다.

  • 세 계층의 추상화: Connector(분할 계획) → Task(실제 입출력) → Worker(JVM 프로세스)
  • 두 방향: Source는 외부 → 카프카, Sink는 카프카 → 외부, 둘 다 동일한 프레임워크 위에서 동작
  • Distributed 모드의 상태 저장소: config.storage.topic, offset.storage.topic, status.storage.topic
  • 메시지 가공의 두 단계: Converter는 직렬화 경계, SMT는 한 메시지 단위의 가벼운 변환 체인
  • 운영 친화 기능: KIP-415의 cooperative rebalancing으로 무중단에 가까운 재배치, KIP-618의 source exactly-once, KIP-875의 first-class offsets API

같은 외부 시스템 연동을 매번 손으로 짜는 대신, Connector 플러그인을 골라 끼우고 REST API로 운영하는 모델이 어떻게 가능한지가 핵심입니다. 다음 편에서는 카프카 클러스터 안쪽으로 다시 들어가 컨트롤 플레인의 변화(ZooKeeper에서 KRaft로의 전환)를 살펴보겠습니다.

참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ

카프카 입문 시리즈 8편: Kafka Connect 동작 원리