4. 컨슈머(Consumer)의 모든 것: 메시지 소비 방식과 컨슈머 그룹

컨슈머와 컨슈머 그룹의 역할

카프카 컨슈머(Consumer)는 토픽에 저장된 메시지를 가져와서(consume) 소비하는 클라이언트 애플리케이션입니다. 하지만 카프카의 진정한 힘은 개별 컨슈머가 아닌, 여러 컨슈머가 협력하는 컨슈머 그룹(Consumer Group)에서 나옵니다.

토픽: 주문

파티션 0
파티션 1
파티션 2

컨슈머 그룹 A (배송팀)

컨슈머 A-1
파티션 0 파티션 1
컨슈머 A-2
파티션 2

컨슈머 그룹 B (알림팀)

컨슈머 B-1
파티션 0 파티션 1 파티션 2

그룹 A에서는 2개의 컨슈머가 3개의 파티션을 나누어 처리하고, 그룹 B에서는 1개의 컨슈머가 모든 파티션을 처리합니다.

컨슈머 그룹의 마법, 리밸런싱(Rebalancing)

리밸런싱은 컨슈머 그룹 내의 파티션 소유권을 동적으로 재조정하는 과정입니다. 그룹에 새로운 컨슈머가 참여하거나 기존 컨슈머가 떠날 때 발생하며, 이를 통해 카프카는 높은 가용성과 확장성을 유지합니다.

Before (컨슈머 2개)

컨슈머 1
파티션 0 파티션 1
컨슈머 2
파티션 2 파티션 3

컨슈머 3 추가

After (컨슈머 3개)

컨슈머 1
파티션 0 파티션 1
컨슈머 2
파티션 2
컨슈머 3
파티션 3

하지만 리밸런싱은 공짜가 아닙니다. 리밸런싱이 진행되는 동안에는 그룹 내 모든 컨슈머가 메시지 처리를 일시적으로 중단합니다. 따라서 불필요하게 잦은 리밸런싱은 시스템 전체의 성능 저하를 초래할 수 있습니다.

실무 트러블슈팅: 시스템을 마비시키는 '리밸런싱 스톰'

문제 상황: 서비스 트래픽이 몰리는 특정 시간에 카프카 메시지 처리가 몇 분간 멈추는 현상이 반복적으로 발생합니다. 로그를 확인하니 "Rebalancing" 관련 메시지가 계속해서 나타납니다.

원인 분석: 컨슈머가 poll()로 메시지를 가져온 후, 다음 poll()을 호출하기까지의 시간이 max.poll.interval.ms(기본 5분)를 초과했기 때문입니다. 즉, 메시지 처리 로직이 너무 오래 걸린 것입니다. 이 경우 브로커는 해당 컨슈머가 응답이 없다고 판단(Timeout)하여 그룹에서 내쫓고 리밸런싱을 시작합니다. 잠시 후 처리를 마친 컨슈머가 다시 그룹에 참여하려 하면서 또 리밸런싱이 발생하는 악순환, 즉 '리밸런싱 스톰'이 발생한 것입니다.

컨슈머의 작업 시간 흐름

poll()
메시지 처리 (DB 저장 등)
| <------------------- 이 전체 시간이 `max.poll.interval.ms` 이내여야 함 -------------------> |

핵심 파라미터와 해결 방안:

해결책: 컨슈머의 비즈니스 로직 평균 처리 시간을 측정하고, max.poll.interval.ms를 그보다 충분히 여유 있게 설정합니다. 동시에, 한 번에 너무 많은 메시지를 가져와 처리 시간이 길어지지 않도록 max.poll.records 값을 줄이는 것도 좋은 방법입니다.

"어디까지 읽었지?" - 오프셋 커밋(Offset Commit)

컨슈머는 자신이 그룹 내에서 특정 파티션의 메시지를 어디까지 처리했는지 그 위치(오프셋)를 카프카에 기록해야 합니다. 이를 '오프셋 커밋'이라고 합니다. 이 커밋 정보 덕분에 컨슈머에 장애가 발생했다가 복구되어도, 마지막으로 처리했던 위치부터 이어서 작업을 재개할 수 있습니다.

1. 자동 커밋 (enable.auto.commit=true)

가장 간편한 방식이지만, 데이터 처리 여부를 보장하지 않기 때문에 심각한 데이터 유실이나 중복을 유발할 수 있습니다. 이 때문에 중요한 데이터를 다루는 실무 환경에서는 절대 사용해서는 안 되는 옵션으로 간주됩니다. auto.commit.interval.ms(기본 5초)마다 컨슈머가 poll()로 가져간 메시지 묶음의 가장 마지막 오프셋을 처리 여부와 상관없이 자동으로 커밋합니다. 가장 간편한 방식이지만, 데이터 처리 여부를 보장하지 않기 때문에 심각한 데이터 유실이나 중복을 유발할 수 있습니다. 이 때문에 중요한 데이터를 다루는 실무 환경에서는 절대 사용해서는 안 되는 옵션으로 간주됩니다. auto.commit.interval.ms(기본 5초)마다 컨슈머가 poll()로 가져간 메시지 묶음의 가장 마지막 오프셋을 **처리 여부와 상관없이** 자동으로 커밋합니다.

자동 커밋의 위험성

시나리오 1: 데이터 유실 (가장 위험)

  1. 마지막 커밋된 오프셋이 90입니다.
  2. 컨슈머가 poll()91~100번 메시지 10개를 가져옵니다.
  3. 메시지를 처리하는 도중, 자동 커밋 시간(5초)이 되어 라이브러리가 "100번까지 가져갔으니 다음은 101번 차례"라고 판단하고 오프셋 101을 커밋합니다.
  4. 그 직후, 애플리케이션이 95번 메시지를 처리하다가 장애로 종료됩니다.
  5. 다시 시작된 컨슈머는 커밋된 오프셋 101부터 메시지를 가져옵니다.
  6. 결과: 실제로 처리되지 않은 95번부터 100번까지의 메시지는 영원히 유실됩니다.

시나리오 2: 데이터 중복

  1. 마지막 커밋된 오프셋이 90입니다.
  2. 컨슈머가 poll()91~100번 메시지를 가져와서, DB 저장까지 모두 성공적으로 처리했습니다.
  3. 하지만 자동 커밋 시간(5초)이 되기 직전에 애플리케이션이 장애로 종료됩니다.
  4. 다시 시작된 컨슈머는 마지막 커밋 지점인 90번 다음부터 메시지를 가져옵니다.
  5. 결과: 이미 처리 완료된 91번부터 100번까지의 메시지가 다시 한번 처리됩니다.

2. 수동 커밋 (enable.auto.commit=false)

개발자가 코드에서 명시적으로 커밋 시점을 제어하는 방식으로, 데이터 처리의 정합성을 보장하기 위한 필수적인 선택입니다. 가장 일반적인 패턴은 "선(先)처리, 후(後)커밋" 입니다.

실무 Best Practice: 최소 한 번 처리(At-Least-Once) 보장하기
데이터의 유실을 절대 허용하지 않는 대부분의 비즈니스 환경에서는 '최소 한 번 처리'를 보장하는 것이 중요합니다. 이는 수동 동기 커밋으로 간단하게 구현할 수 있습니다. 아래 그림처럼, 모든 비즈니스 로직을 성공적으로 마친 후에만 커밋을 수행하는 것입니다.

자동 커밋 (위험)

1. poll()
2. 자동 커밋 발생
3. 비즈니스 로직 처리

(처리 중 장애 발생 시 데이터 유실)

수동 커밋 (안전)

1. poll()
2. 비즈니스 로직 처리
3. commitSync()

(처리 중 장애가 나도 커밋 안됨)


while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
    for (ConsumerRecord<String, String> record : records) {
        // 1. 메시지를 가져와서
        System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());

        // 2. 가장 중요한 비즈니스 로직을 먼저 처리합니다. (e.g., DB에 저장)
        processRecord(record);
    }
    try {
        // 3. 모든 로직이 성공적으로 끝난 후에, 마지막으로 오프셋을 커밋합니다.
        consumer.commitSync();
    } catch (CommitFailedException e) {
        // 커밋 실패 시 예외 처리 (e.g., 로깅, 재시도)
        log.error("Failed to commit offsets", e);
    }
}
        

위와 같이 구성하면, processRecord() 실행 중 장애가 발생해도 오프셋이 커밋되지 않았으므로, 다음 컨슈머가 동일한 레코드를 다시 가져와 안전하게 처리할 수 있습니다.

주요 컨슈머 설정 파라미터

파라미터 설명 기본값
group.id 컨슈머가 속한 컨슈머 그룹의 ID. 필수 설정입니다. -
auto.offset.reset 커밋된 오프셋이 없거나, 기존 오프셋이 유효하지 않을 때 어디서부터 읽을지 결정합니다. earliest(가장 처음부터), latest(가장 마지막부터). latest
enable.auto.commit 오프셋 자동 커밋 여부를 결정합니다. true
fetch.min.bytes 컨슈머가 브로커로부터 데이터를 요청할 때 받을 최소 데이터 크기. 이 값에 도달할 때까지 브로커는 대기합니다. 1
fetch.max.wait.ms fetch.min.bytes에 데이터가 차지 않아도, 이 시간이 지나면 브로커는 데이터를 반환합니다. 500
max.poll.records poll() 한 번에 반환되는 최대 레코드 수. 500