Pulsar 클라이언트

Pulsar 클라이언트 (Pulsar Clients): 프로듀서·컨슈머·리더

Pulsar는 애플리케이션이 쓸 클라이언트 API를 제공해요. Java, C++, Go, Python, Node.js, C# 바인딩이 있고, 이 API가 클라이언트와 브로커 사이의 통신 프로토콜을 캡슐화해서 단순하고 직관적인 인터페이스를 드러내줘요. 클라이언트 라이브러리는 브로커로의 투명한 재연결·연결 폴백, 확인될 때까지의 메시지 큐잉, 백오프가 있는 연결 재시도 같은 기능을 기본 지원해요.

출처: Pulsar Clients - Apache Pulsar 공식 문서

본문

클라이언트 설정 단계 (Client setup phase)

프로듀서/컨슈머를 만들기 전에 클라이언트 라이브러리는 두 단계의 설정을 시작해요.

  1. 클라이언트가 브로커에 HTTP lookup 요청을 보내 토픽의 소유자를 알아내요. 요청을 받은 활성 브로커는 (캐시된) Zookeeper 메타데이터를 보고 누가 그 토픽을 서빙하는지 확인하고, 아무도 서빙하지 않으면 가장 부하가 적은 브로커에 할당을 시도해요.
  2. 브로커 주소를 얻으면 TCP 연결을 만들거나(풀의 기존 연결을 재사용) 인증해요. 이 연결 안에서 클라이언트와 브로커는 커스텀 프로토콜로 이진 명령을 주고받고, 클라이언트는 프로듀서/컨슈머 생성 명령을 보내요. 브로커는 인가(authorization) 정책을 검증한 뒤 처리해요.

TCP 연결이 끊어지면 클라이언트는 이 설정 단계를 즉시 다시 시작하고, 지수 백오프(exponential backoff)로 성공할 때까지 프로듀서/컨슈머 재연결을 계속 시도해요.

프로듀서 (Producer)

프로듀서는 토픽에 붙어서 브로커에 메시지를 발행하는 프로세스예요. 모든 프로듀서는 이름을 가지는데, 모든 Pulsar 클러스터에서 유일해야 해요. 이름을 지정하지 않으면 Pulsar가 전역 고유 이름을 자동 생성해요. 명시적으로 지정하면 브로커가 같은 이름의 프로듀서 하나만 같은 토픽에서 발행하도록 강제하고, 같은 이름으로 두 번째 프로듀서를 만들려 하면 실패해요.

명시적 이름은 메시지 중복 제거(deduplication)를 쓸 때 필요한데, Pulsar가 프로듀서 이름과 sequence ID를 함께 써서 중복 메시지를 식별하기 때문이에요. 메트릭과 admin stats에 프로듀서 이름이 나타나므로 디버깅·모니터링에도 유용해요.

전송 모드(Send mode) — 프로듀서가 메시지를 동기/비동기로 보낼지 결정해요.

모드 설명
Sync send 프로듀서가 메시지를 보낼 때마다 브로커의 확인을 기다려요. 확인을 못 받으면 전송을 실패로 처리해요.
Async send 프로듀서가 메시지를 블로킹 큐에 넣고 즉시 반환해요. 클라이언트 라이브러리가 백그라운드에서 브로커로 보내요. 큐가 가득 차면 전달된 인자에 따라 API 호출 시 블로킹되거나 즉시 실패해요.

접근 모드(Access mode) — 프로듀서가 토픽에 대해 가지는 권한을 결정해요.

접근 모드 설명
Shared 여러 프로듀서가 토픽에 발행할 수 있어요. 기본값이에요.
Exclusive 한 프로듀서만 토픽에 발행할 수 있어요. 이미 연결된 프로듀서가 있으면 다른 프로듀서는 즉시 오류를 받아요. 기존 프로듀서가 브로커와 네트워크 파티션을 겪으면 퇴출되고 새 프로듀서가 다음 exclusive 프로듀서로 선택돼요.
ExclusiveWithFencing 한 프로듀서만 발행할 수 있어요. 이미 연결된 프로듀서가 있으면 즉시 제거·무효화해요.
WaitForExclusive 이미 연결된 프로듀서가 있으면 생성이 타임아웃되지 않고 Exclusive를 얻을 때까지 대기해요. 성공한 프로듀서가 리더로 취급돼요. 애플리케이션에서 리더 선출(leader election) 스킴을 구현하고 싶다면 이 접근 모드를 쓸 수 있어요.

Exclusive 또는 WaitForExclusive 접근 모드로 프로듀서를 성공적으로 만들면 그 인스턴스는 그 토픽의 유일한 writer가 된다는 보장을 받아요. 다른 프로듀서는 즉시 오류를 받거나 Exclusive를 얻을 때까지 기다려야 해요.

컨슈머 (Consumer)

컨슈머는 구독(subscription)을 통해 토픽에 붙어 메시지를 받는 프로세스예요. 컨슈머는 브로커에 flow permit 요청을 보내 메시지를 받아요. 컨슈머 쪽에는 브로커가 푸시한 메시지를 받는 큐가 있고, 크기는 receiverQueueSize 파라미터로 설정하며 기본값은 1000이에요. consumer.receive()를 호출할 때마다 버퍼에서 메시지 하나가 빠져나와요.

수신 모드(Receive mode) — 메시지를 동기/비동기로 받을지 결정해요.

모드 설명
Sync receive 메시지가 생길 때까지 블로킹돼요.
Async receive 즉시 future 값을 반환하고, 새 메시지가 생기면 그 future가 완성돼요. 예를 들어 Java에서는 CompletableFuture를 써요.

리스너(Listener) — 클라이언트 라이브러리는 컨슈머용 리스너 구현을 제공해요. 예를 들어 Java 클라이언트는 MessageListener 인터페이스를 제공하고, 새 메시지를 받을 때마다 received 메서드가 호출돼요.

컨슈머 인터페이스 vs 리더 인터페이스

"표준" 컨슈머는 토픽을 리슨하고 메시지를 처리한 뒤 확인(acknowledgment)을 보내요. 새 구독이 만들어지면 기본적으로 토픽의 끝에 위치하고, 그 구독의 컨슈머는 이후에 생기는 첫 메시지부터 읽어요. 기존 구독에 컨슈머가 연결되면 그 구독에서 미확인(un-acked)된 가장 오래된 메시지부터 읽어요. 요약하면 컨슈머 인터페이스에서는 구독 커서를 Pulsar가 메시지 확인에 맞춰 자동으로 관리해요.

반면 리더 인터페이스(reader interface) 는 애플리케이션이 커서를 수동으로 관리하게 해줘요. 리더로 토픽에 연결할 때는 어느 메시지부터 읽을지 지정해야 해요. 토픽의 earliest(가장 오래된) 메시지, latest(가장 최신) 메시지, 또는 그 사이의 특정 메시지(이때는 메시지 ID를 명시적으로 제공)로 시작할 수 있어요.

리더 인터페이스는 스트림 처리 시스템에 effectively-once 처리 의미론을 제공하는 like use case에 유용해요. 그런 시스템은 토픽을 특정 메시지로 "되감기(rewind)"해서 읽을 수 있어야 하는데, 리더 인터페이스가 그런 수동 위치 지정을 위한 저수준 추상화를 제공해요.

리더는 내부적으로 랜덤 이름의 exclusive·비영구(non-durable) 구독을 쓰는 컨슈머로 구현돼요. 리더는 비영구적이라 토픽 데이터가 삭제되는 것을 막지 않으므로, 데이터 보존(data retention)을 설정하는 것이 강력하게 권장돼요. 보존이 충분히 설정되지 않으면 리더가 아직 읽지 않은 메시지가 삭제돼 메시지를 건너뛰게 될 수 있어요.

TableView

TableView 인터페이스는 컴팩션된 토픽 데이터의 키-값 맵 뷰를 지속적으로 갱신해주는 캡슐화된 접근 패턴이에요. 키가 없는 메시지는 무시돼요. TableView로 토픽의 모든 메시지 업데이트를 가져와 각 키의 최신 값을 가진 맵을 만들고, 이를 로컬 데이터 캐시로 쓸 수 있어요. 리스너를 등록하면 맵을 스캔하고 새 메시지가 도착할 때 알림을 받아 이벤트 기반 애플리케이션·메시지 모니터링 같은 use case를 다룰 수 있어요.

TableView는 파티션당 Reader 인스턴스 하나를 쓰고 기본적으로 컴팩션된 뷰부터 읽어요. 그래서 토픽·네임스페이스에 자동 컴팩션을 설정해 두는 것이 권장돼요. 컴팩션이 자주 될수록 TableView를 재구성할 때 replay할 데이터가 적어져 시작 시간이 짧아져요.

트랜잭션 (Transactions)

Pulsar 클라이언트는 여러 토픽·파티션에 걸친 원자적 연산을 가능하게 하는 트랜잭션을 지원해요. 트랜잭션은 exactly-once 의미론을 제공하고, 트랜잭션 안의 연산이 전부 성공하거나 전부 실패하도록 보장해요. 트랜잭션으로 다음을 할 수 있어요.

  • 원자적 메시지 생산: 트랜잭션 경계 안에서 여러 토픽에 원자적으로 메시지를 생산해요.
  • 원자적 메시지 확인: 트랜잭션 경계 안에서 메시지를 확인해, 트랜잭션이 성공할 때만 처리된 메시지가 커밋되도록 보장해요.
  • 크로스 토픽 연산: 하나의 원자적 트랜잭션으로 여러 토픽을 spanning하는 연산을 수행해요.

트랜잭션 흐름은 세 단계예요. 트랜잭션 생성(타임아웃 설정) → 트랜잭션 컨텍스트 안에서 메시지 전송·확인 → 커밋(commit) 또는 어보트(abort). 커밋하면 모든 연산이 영구화되고, 어보트하면 전부 롤백돼요.

Transaction txn = client.newTransaction()
    .withTransactionTimeout(1, TimeUnit.MINUTES)
    .build().get();
try {
    producer.newMessage(txn).value("message-1").send();
    producer.newMessage(txn).value("message-2").send();
    consumer.acknowledgeAsync(messageId, txn); // 트랜잭션 안에서 메시지 확인
    txn.commit().get();
} catch (Exception e) {
    txn.abort().get(); // 오류 시 트랜잭션 어보트
}

트랜잭션은 exactly-once 처리 파이프라인을 만들거나, 여러 Pulsar 토픽 간의 데이터 일관성을 보장하거나, 복잡한 이벤트 처리 패턴을 구현할 때 특히 유용해요.

더 알아보기

  • 메시지·토픽·구독이 무엇인지는 Messaging 문서에서 다뤄요.
  • 프로듀서가 발행할 데이터의 형태를 합의하는 방법은 스키마 이해하기 문서를 보면 돼요.
  • Pulsar가 어떤 구조로 돌아가는지 살펴보려면 개요 문서부터 시작해요.