[IoT] MQTT -> Kafka를 이용한 데이터 파이프라인 구축하기 #3
IoT 데이터 파이프라인 구축 시리즈
Kafka Connect 커스텀 이미지 빌드
MQTT Source Connector 플러그인을 포함한 Kafka Connect 이미지를 먼저 준비합니다.
1
2
3
4
5
6
7
8
9
10
11
# Dockerfile
FROM confluentinc/cp-kafka-connect:7.7.4
ARG CAMEL_PLUGIN_PATH="/home/appuser/camel-kafka-connectors"
ARG CAMEL_REPOSITORY_BASE_URL="https://repo.maven.apache.org/maven2/org/apache/camel/kafkaconnector"
ARG CAMEL_PACKAGE_VERSION="4.11.0"
RUN mkdir -p "${CAMEL_PLUGIN_PATH}" \
&& wget -qO- "${CAMEL_REPOSITORY_BASE_URL}/camel-mqtt-source-kafka-connector/${CAMEL_PACKAGE_VERSION}/camel-mqtt-source-kafka-connector-${CAMEL_PACKAGE_VERSION}-package.tar.gz" | tar -C "${CAMEL_PLUGIN_PATH}" -xz
ENV CONNECT_PLUGIN_PATH="/usr/share/java,${CAMEL_PLUGIN_PATH}"
이미지 빌드
1
docker build -t custom-kafka-connect:7.7.4 .
Kafka 브로커 클러스터 기본 구성 확인
docker-compose.yml에서 브로커 3대, 내부/외부 리스너, 복제 설정을 확인합니다.
이 글은 ZooKeeper 기반 구성(cp-kafka 7.7)입니다. Kafka 4.0부터는 ZooKeeper 지원이 제거되어 KRaft 모드만 쓸 수 있으니, 새로 구성한다면 KRaft를 검토해주세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# docker-compose.yml (Kafka broker 핵심)
kafka1:
image: confluentinc/cp-kafka:7.7.4
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:${ZOOKEEPER_PORT}
KAFKA_LISTENERS: INTERNAL://0.0.0.0:${KAFKA_INTERNAL_PORT},EXTERNAL://0.0.0.0:${KAFKA_EXTERNAL_PORT}
KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:${KAFKA_INTERNAL_PORT},EXTERNAL://${KAFKA1_ADVERTISED_HOST}:${KAFKA1_HOST_PORT}
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: ${KAFKA_REPLICATION_FACTOR}
kafka2:
image: confluentinc/cp-kafka:7.7.4
environment:
KAFKA_BROKER_ID: 2
KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka2:${KAFKA_INTERNAL_PORT},EXTERNAL://${KAFKA2_ADVERTISED_HOST}:${KAFKA2_HOST_PORT}
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: ${KAFKA_REPLICATION_FACTOR}
# (나머지 리스너 설정은 kafka1과 동일)
kafka3:
image: confluentinc/cp-kafka:7.7.4
environment:
KAFKA_BROKER_ID: 3
KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka3:${KAFKA_INTERNAL_PORT},EXTERNAL://${KAFKA3_ADVERTISED_HOST}:${KAFKA3_HOST_PORT}
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: ${KAFKA_REPLICATION_FACTOR}
# (나머지 리스너 설정은 kafka1과 동일)
브로커 최적화 포인트
INTERNAL/EXTERNAL: 리스너 분리로 클러스터 내부 통신과 외부 접근을 명확히 분리KAFKA_INTER_BROKER_LISTENER_NAME=INTERNAL: 브로커 간 트래픽 안정화KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 멀티 브로커 수에 맞춰 설정해 장애 복구성 강화
이후 명령어는
KAFKA_INTERNAL_PORT=9092기준입니다. 컨테이너 안에서는 INTERNAL 리스너(kafka1:9092)로, 호스트에서는 EXTERNAL 리스너로 접속합니다.
토픽 선생성(권장) 및 클러스터 기동
topic-creator 컨테이너를 통해 vehicle-data 토픽을 사전 생성합니다.
토픽이 없는 상태에서 커넥터가 먼저 메시지를 쓰면, 브로커가 토픽을 기본값으로 자동 생성합니다. 기본값(
num.partitions,default.replication.factor)을 따로 지정하지 않았다면 파티션 1개 · 복제 1개 토픽이 만들어져, 브로커 3대를 두고도 분산과 복제가 되지 않습니다. 한 번 만들어진 토픽의 복제 계수는 쉽게 바꿀 수 없으니 꼭 토픽을 미리 생성해주세요.
Docker Compose 를 이용한 토픽 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# docker-compose.yml (topic-creator)
topic-creator:
image: confluentinc/cp-kafka:7.7.4
depends_on:
kafka1:
condition: service_healthy
kafka2:
condition: service_healthy
kafka3:
condition: service_healthy
command:
- bash
- -c
- |
echo "Waiting for Kafka brokers to be ready..."
cub kafka-ready -b kafka1:${KAFKA_INTERNAL_PORT} 3 30
echo "Creating vehicle-data topic..."
kafka-topics --create --if-not-exists --topic vehicle-data --bootstrap-server kafka1:${KAFKA_INTERNAL_PORT} --partitions ${KAFKA_REPLICATION_PARTITIONS} --replication-factor ${KAFKA_REPLICATION_FACTOR}
echo "Topic creation completed."
수동 토픽 생성
1
2
3
4
5
6
7
8
9
10
11
kafka-topics --create \
--topic 토픽이름 \
--bootstrap-server Kafka 브로커 접속 주소 \
--partitions 3 \
--replication-factor 3
docker exec -it kafka1 kafka-topics --create \
--bootstrap-server kafka1:9092,kafka2:9092,kafka3:9092 \
--replication-factor 3 \
--partitions 3 \
--topic vehicle-data
클러스터 실행
1
2
3
# 로컬 예시
docker compose -p kafka-cluster --env-file kafka.local.env down --volumes --remove-orphans
docker compose -p kafka-cluster --env-file kafka.local.env up -d
Kafka Connect 분산 모드 구성
Connect 노드를 3대로 구성하고, 동일한 CONNECT_GROUP_ID로 분산 실행합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# docker-compose.yml (Kafka Connect 핵심)
kafka-connect1:
image: custom-kafka-connect:7.7.4
environment:
CONNECT_BOOTSTRAP_SERVERS: "kafka1:${KAFKA_INTERNAL_PORT},kafka2:${KAFKA_INTERNAL_PORT},kafka3:${KAFKA_INTERNAL_PORT}"
CONNECT_REST_ADVERTISED_HOST_NAME: "kafka-connect1"
CONNECT_REST_PORT: ${KAFKA_CONNECT_INTERNAL_PORT}
CONNECT_GROUP_ID: "vehicle-connect-group"
CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR: ${KAFKA_REPLICATION_FACTOR}
CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR: ${KAFKA_REPLICATION_FACTOR}
CONNECT_STATUS_STORAGE_REPLICATION_FACTOR: ${KAFKA_REPLICATION_FACTOR}
CONNECT_KEY_CONVERTER: "org.apache.kafka.connect.storage.StringConverter"
CONNECT_VALUE_CONVERTER: "org.apache.kafka.connect.storage.StringConverter"
CONNECT_VALUE_CONVERTER_SCHEMAS_ENABLE: "false"
volumes:
- ./mqtt-connector.json:/etc/kafka-connect/mqtt-connector.json
- ./kafka-init:/kafka-init
여기서 분산 모드의 이점은 처리량이 아니라 장애 대비입니다.
MQTT 소스 커넥터는 EMQX에 구독자 하나로 붙기 때문에 일을 나눌 수 없고, task는 1개만 돕니다. Connect 노드 1대가 죽으면 같은 그룹의 다른 노드가 그 task를 이어받습니다.
MQTT Source Connector 설정
EMQX(MQTT 브로커)에서 구독한 메시지를 Kafka vehicle-data 토픽으로 보냅니다.
아래 파일은
curl로 그대로 보낼 파일이라 주석을 넣지 않았습니다. JSON은//주석을 허용하지 않아서, 주석이 있으면 등록 요청이 실패합니다. 각 항목 설명은 아래 표를 참고해주세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
{
"name": "mqtt-source-connector",
"config": {
"connector.class": "CONNECTOR CLASS",
"tasks.max": "1",
"topics": "vehicle-data", // Kafka 브로커에 메시지가 기록될 대상 토픽명
"camel.kamelet.mqtt-source.brokerUrl": "tcp://localhost:1883", // MQTT 브로커 접속 URL (프로토콜 + 호스트 + 포트)
"camel.kamelet.mqtt-source.clientId": "mqtt-source", // MQTT 클라이언트 ID. 브로커에서 고유하게 식별하는 ID
"camel.kamelet.mqtt-source.topic": "/mqtt/test/#", // MQTT 브로커에서 구독할 토픽 (와일드카드 # 지원)
"camel.kamelet.mqtt-source.username": null, // MQTT 브로커 접속에 필요한 사용자명 (없으면 null)
"camel.kamelet.mqtt-source.password": null, // MQTT 브로커 접속에 필요한 비밀번호 (없으면 null)
"camel.kamelet.mqtt-source.qos": "1", // MQTT QoS 레벨 (0: At most once, 1: At least once, 2: Exactly once)
"camel.kamelet.mqtt-source.clean.session.enabled": true, // true면 연결 재시작 시 이전 세션 상태를 유지하지 않음
"camel.kamelet.mqtt-source.connect.timeout.seconds": 30, // MQTT 브로커 연결 시도 타임아웃(초)
"camel.kamelet.mqtt-source.keepalive.interval.seconds": 60, // MQTT keep-alive 간격(초)
"camel.kamelet.max.retry.time.ms": 30000, // 재시도 최대 대기 시간(ms)
"camel.aggregation.size": 10, // 메시지를 모아서 처리할 개수(배치 크기)
"camel.aggregation.timeout": 500, // 메시지 집계 대기 시간(ms)
"camel.beans.aggregate": null, // 커스텀 Aggregation 전략 Bean 지정 가능
"camel.error.handler": "default", // 오류 처리 전략 (default, deadletter 등)
"camel.error.handler.max.redeliveries": 0, // 에러 발생 시 재시도 최대 횟수
"camel.error.handler.redelivery.delay": 1000, // 재시도 간 대기 시간(ms)
"camel.idempotency.enabled": false, // 중복 메시지 방지를 위한 멱등성 활성화 여부
"camel.idempotency.expression.header": null, // 멱등성 키를 추출할 Kafka 메시지 헤더
"camel.idempotency.expression.type": "body", // 멱등성 키를 메시지 본문에서 추출할지 여부
"camel.idempotency.repository.type": "memory", // 멱등성 저장소 타입 (memory / kafka)
"camel.idempotency.memory.dimension": 100, // memory 저장소 크기 제한
"camel.idempotency.kafka.bootstrap.servers": "localhost:9092", // Kafka 멱등성 저장소를 사용할 경우 연결할 Kafka 클러스터 주소
"camel.idempotency.kafka.topic": "kafka_idempotent_repository", // 멱등성 저장소용 Kafka 토픽명
"camel.idempotency.kafka.max.cache.size": 1000, // Kafka 멱등성 저장소 캐시 최대 크기
"camel.idempotency.kafka.poll.duration.ms": 100, // Kafka 멱등성 저장소 poll 주기(ms)
"camel.map.headers": true, // 메시지 헤더를 매핑할지 여부
"camel.map.properties": true, // 메시지 프로퍼티를 매핑할지 여부
"camel.remove.headers.pattern": "", // 제거할 헤더 패턴 지정
"camel.source.camelMessageHeaderKey": null, // Kafka 메시지 키를 Camel 메시지 헤더에서 추출
"camel.source.component": null, // 사용할 Camel 컴포넌트 이름 (예: mqtt5)
"camel.source.contentLogLevel": "OFF", // 메시지 콘텐츠 로그 레벨 (OFF, INFO, DEBUG)
"camel.source.marshal": null, // 메시지를 직렬화할 포맷 지정 (예: json, avro)
"camel.source.unmarshal": null, // 메시지를 역직렬화할 포맷 지정 (예: json, avro)
"camel.source.maxBatchPollSize": 1000, // Kafka Connect가 한 번에 poll할 최대 메시지 개수
"camel.source.maxNotCommittedRecords": 1024, // 커밋되지 않은 최대 레코드 수 제한
"camel.source.maxPollDuration": 1000, // poll 대기 시간(ms)
"camel.source.pollingConsumerBlockTimeout": 0, // polling 시 block timeout (0이면 무제한 대기)
"camel.source.pollingConsumerBlockWhenFull": true, // 큐가 가득 찼을 때 block 여부
"camel.source.pollingConsumerQueueSize": 1000, // polling consumer 내부 큐 크기
"camel.source.url": null, // Camel source용 URL (특정 컴포넌트 기반일 때만 사용)
"key.converter": "org.apache.kafka.connect.storage.StringConverter", // Kafka 메시지 key 직렬화 방식
"key.converter.schemas.enable": false, // key에 스키마 포함 여부
"value.converter": "org.apache.kafka.connect.json.JsonConverter", // Kafka 메시지 value 직렬화 방식
"value.converter.schemas.enable": false // value에 스키마 포함 여부
}
}
| 항목 | 설명 |
|---|---|
connector.class |
Camel MQTT Source 커넥터 클래스 |
tasks.max |
반드시 1 (아래 설명 참고) |
topics |
Kafka 브로커에 메시지가 기록될 대상 토픽명 |
brokerUrl |
MQTT 브로커 접속 URL (프로토콜 + 호스트 + 포트) |
clientId |
EMQX가 이 커넥터를 식별하는 ID. 지속 세션을 쓰려면 고정값이어야 함 |
topic |
EMQX에서 구독할 MQTT 토픽 (와일드카드 # 지원) |
qos |
1 = At least once. 유실 대신 중복을 허용하고 중복은 Consumer에서 처리 |
clean.session.enabled |
false = 지속 세션. 커넥터가 재시작되는 동안의 메시지를 EMQX가 보관 |
connect.timeout.seconds / keepalive.interval.seconds |
연결 시도 타임아웃 / keep-alive 간격(초) |
max.retry.time.ms |
연결 재시도 최대 대기 시간(ms) |
key.converter / value.converter |
Kafka에 기록할 key / value 직렬화 방식 |
Camel 커넥터에는 이 밖에도 aggregation, idempotency 관련 옵션이 많지만, 기본값으로 두어도 동작합니다. 위 항목만 상황에 맞게 조정하면 충분합니다.
실무 팁:
brokerUrl에localhost를 쓰면 안 됩니다. 커넥터는 Connect 컨테이너 안에서 돌기 때문에localhost는 Connect 컨테이너 자신을 가리킵니다. 같은 Docker 네트워크라면nginx-mqtt같은 서비스 이름을, 아니라면 호스트 주소를 넣어주세요.clean.session.enabled는false로 둡니다.true면 커넥터가 재시작되거나 재배포되는 동안 발행된 메시지는 받을 세션이 없어 그대로 사라집니다. #2의 지속 세션 조건(고정 Client ID + QoS 1 이상)이 커넥터에도 똑같이 적용됩니다.tasks.max를 늘리면 메시지가 중복됩니다. task마다 같은 MQTT 토픽을 따로 구독하므로 같은 메시지가 task 수만큼 Kafka에 기록됩니다. Client ID가 고정이라면 task끼리 서로의 연결을 끊어 버리기도 합니다.- MQTT 토픽은 발행하는 쪽과 정확히 맞춰야 합니다. 발행 토픽을 바꾸고 커넥터
topic을 그대로 두면 에러 없이 조용히 수집이 멈춥니다. 토픽을 바꿀 때는 양쪽을 함께 바꿔주세요. value.converter가JsonConverter이면 MQTT 본문(byte 배열)이 Base64 문자열로 기록됩니다. Consumer에서 이를 어떻게 처리하는지는 #4에서 다룹니다.
커넥터 등록
POST /connectors는 처음 한 번만 성공하고, 같은 이름의 커넥터가 있으면 409 Conflict로 실패합니다.
설정을 바꿀 때마다 삭제 후 재등록하게 되므로, PUT /connectors/{name}/config를 쓰는 편이 좋습니다. 없으면 만들고 있으면 설정을 바꾸기 때문에, 몇 번을 실행해도 결과가 같습니다.
PUT은 name을 경로로 받고 본문에는 config 객체만 보냅니다. 아래 예시는 jq로 config 부분만 꺼내 보냅니다(jq가 없다면 config 내용만 담은 파일을 따로 두고 --data @파일명으로 보내도 됩니다).
1
2
3
4
5
6
7
# 최초 등록 (한 번만)
curl -X POST -H "Content-Type: application/json" http://localhost:28083/connectors --data @mqtt-connector.json
# 등록 + 변경 (권장 — 여러 번 실행해도 안전)
jq .config mqtt-connector.json | \
curl -X PUT -H "Content-Type: application/json" \
http://localhost:28083/connectors/mqtt-source-connector/config --data @-
동작 검증 (토픽/메시지/분산 처리)
브로커와 커넥터가 정상 연동되면, 토픽/메시지 적재 상태를 순서대로 검증합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 토픽 목록/상세 확인
docker exec -it kafka1 kafka-topics --list --bootstrap-server kafka1:9092
docker exec -it kafka1 kafka-topics --describe --topic vehicle-data --bootstrap-server kafka1:9092
# 메시지 소비 확인(파티션 출력)
docker exec -it kafka1 kafka-console-consumer --bootstrap-server kafka1:9092 --topic vehicle-data --property print.partition=true --from-beginning
# 파티션 오프셋 확인(분산 적재 체크)
docker exec -it kafka1 kafka-get-offsets --bootstrap-server kafka1:9092 --topic vehicle-data
# 커넥터 상태 확인
curl http://localhost:28083/connectors/mqtt-source-connector/status
## 커넥터와 실패한 task 재시작 (task 번호는 0부터 시작)
curl -X POST "http://localhost:28083/connectors/mqtt-source-connector/restart?includeTasks=true&onlyFailed=true"
## 커넥터 삭제
curl -X DELETE http://localhost:28083/connectors/mqtt-source-connector
task를 하나만 재시작하려면
POST /connectors/{name}/tasks/{taskId}/restart를 씁니다.tasks.max=1이면 task는0번 하나뿐이라tasks/1은 404가 납니다.
운영 시 체크리스트
- EMQX 접속 주소와 Kafka 외부 리스너 주소가 서로 통신 가능한 네트워크인지 확인
- 토픽 복제 계수(
replication-factor)와 브로커 수 불일치 여부 점검 - Connect 내부 토픽과 업무 토픽 모두 보존 정책/파티션 수를 운영 목적에 맞게 사전 설계
- 커넥터를 재시작하는 동안 발행한 메시지가 Kafka에 들어오는지(지속 세션) 확인
- 장애 테스트(브로커 1대/Connect 1대 다운)로 복구 시간과 데이터 연속성 검증
댓글남기기