카프카 도입 설계 - 100-hours-a-week/5-yeosa-wiki GitHub Wiki

1. Kafka 도입 배경

a. 기존 Http 통신 기반 부하테스트 결과 높은 지연 시간과 유실 위험 존재

  • 3개의 시나리오로 테스트 진행

    • [시나리오 1] max VUs : 30, 30s ramp-up / 2min plateau / 30s ramp-down
    • [시나리오 2] max VUs : 20, 30s ramp-up / 3min plateau / 30s ramp-down
    • [시나리오 3] max VUs : 50, 30s ramp-up / 1min plateau / 30s ramp-down
  • 테스트 결과

    항목(request duration) 시나리오 1 시나리오 2 시나리오 3
    avg 2s 3s 2s
    max 30s 30s 23s
    p95 14s 28s 4s
    p99 29s 30s 5s
    iteration time 3s 4s 3s
    • p99기준 30초 이상의 duration을 보임(setTimeOut이 30초로 설정되어있어 30초로 기록됨)

    • 부하가 지속적으로 가해질수록 불안정한 duration 분포

    • 앨범 분석 실패 또한 11% ~ 36%로, 특히 시나리오 2에서 높은 실패율 기록

b. AI 서버 요청에 대한 유실 방지 및 복구 가능성 확보

  • HTTP 기반 단방향 요청의 경우 서버 다운/응답 유실 시 복구 불가능
  • Kafka를 통해 메시지 영속화하고 재처리/재전송 가능
항목 Kafka Redis Stream RabbitMQ
메시지 순서 보장 파티션 내 순서 보장 ID 기반 순서 보장 채널 기반 순서 보장
재시도 / 실패 복구 offset 기반 재처리 가능 XPENDING/XACK으로 처리 메시지 ACK 기반 재전송
운영 복잡도 높음 (Zookeeper, 설정 필요) * 3.x버전 사용 시 Kraft 사용으로 Zookeeper 불필요 * springboot 3.4 기준으로 호환되는 kafka version은 3.8.x~3.9.x 낮음 (기존 Redis 사용 시 간단) 중간(상태 기반 운영 필요)
모니터링 도구 풍부함 (Kafka UI, Grafana 등) 제한적 (XINFO 등 직접 구현) 존재(RabbitMQ Management UI)
적합한 메시지 크기 수 MB 이상 가능 수 KB ~ 수십 KB 적절 수 KB ~ 수십 KB 적절
파티션 기반 분산 자동 분산 없음 없음
병렬 소비 확장 컨슈머 수 제한 거의 없음 그룹 수 늘어나면 병목 가능 제한적(채널별로 고정 처리 구조)
처리량 증가 시 브로커/파티션 확장으로 해결 키 분할 등 수작업 필요 수직 확장 중심
  • 3대의 브로커 구성으로 고가용성 확보
  • kafka 3.8.x사용할거기 때문에 kraft 사용(zookeeper 제거)
  • 브로커/컨트롤러 3개 하나의 클러스터로 구성
  • 모든 토픽은 replication factor 3으로 설정 ⇒ 3대의 브로커에 데이터 복제되게 처리
  • min.insync.replica=2로 설정하여 최소 두 대의 ISR가 동기화 되어야 메시지 commit 처리하고 write 허용
  • 리더 컨트롤러 1개 + 나머지 컨트롤러 2개 (KRaft quorum 용도)
  • 파티션 개수가 병렬성을 결정: ai 서버 워커 프로세스 2개
  • 병렬성을 위해 1개의 브로커에 토픽당 2개의 파티션 필요
    • 3개 브로커 * 브로커당 2개 파티션 ⇒ 토픽당 총 6개 파티션 구성
요청 토픽 요청 토픽 DLQ (관리주체 : AI서버) 응답 토픽 응답 토픽 DLQ (관리주체 : BE서버)
album.ai.embedding.request album.ai.embedding.request.dlq album.ai.embedding.response album.ai.embedding.response.dlq
album.ai.quality.request album.ai.quality.request.dlq album.ai.quality.response album.ai.quality.response.dlq
album.ai.duplicate.request album.ai.duplicate.request.dlq album.ai.duplicate.response album.ai.duplicate.response.dlq
album.ai.category.request album.ai.category.request.dlq album.ai.category.response album.ai.category.response.dlq
album.ai.aesthetic.request album.ai.aesthetic.request.dlq album.ai.aesthetic.response album.ai.aesthetic.response.dlq
album.ai.people.request album.ai.people.request.dlq album.ai.people.response album.ai.people.response.dlq
album.ai.style.request album.ai.style.request.dlq album.ai.style.response album.ai.style.response.dlq

각 토픽별 메시지 구조 : https://docs.google.com/spreadsheets/d/1spP9P88Rj7WwetS3SQaXliyv_WitiE8YSmn95qWlQ6A/edit?gid=1878554884#gid=1878554884

2. Producer 구조

a. Backend

  • 토픽 발행 구조 : 기존 api 방식 요청과 동일하게 다중 토픽으로 분리 전송

  • producer 구현 방식 : Spring Kafka(KafkaTemplate)

  • key 설정 방식 : userId기준으로 파티셔닝

    • 한 유저에 대해 동시에 병렬 작업 진행되지 않아 데이터 충돌 방지 가능(예시 : 카테고리 분석과 점수산정은 동기적으로 이루어져야 함)
    • 향후 특정 유저 대상 반복적인 에러 발생시 브로커 로그 분석 용이
  • 상태 관리 전용 테이블

    CREATE TABLE ai_task_status (
        task_id     CHAR(64) PRIMARY KEY, // step+ULID 적용(ex) embedding_01xs....)
        user_id     BIGINT NOT NULL,
        album_id    BIGINT NOT NULL,
        step        ENUM('EMBEDDING', 'QUALITY', 'DUPLICATE', 'CATEGORY', 'AESTHETIC', 'PEOPLE') NOT NULL,
        status      ENUM('PENDING', 'IN_PROGRESS', 'SUCCESS', 'FAILED', 'RETRY', 'TIMEOUT') NOT NULL,
        retry_count TINYINT DEFAULT 0,
        error_msg   TEXT,
        created_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at  DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
    );
    
    • 각 토픽에 맞는 step을 기록하고, 실패 시 토픽으로 재전송(최대 3회, 카운트는 retry_count에 기록)
    • 3회 재시도 실패 시 DLQ로 전송, 전송과 동시에 디스코드 알림

b. AI

  • 토픽 발행 구조: 기존 api 방식 응답과 동일하게 다중 토픽으로 분리 전송
  • producer 구현 방식: aiokafka.AIOKafkaProducer
  • key 설정 방식
    • Kafka 브로커에서 메시지를 소비할 때 유저 ID 정보를 얻고, 해당 정보를 응답 메시지를 발행할 때 재사용
    • 유저 ID 정보를 key로 활용함으로써, 특정 유저에게서 발생한 장애 확인을 위해 하나의 브로커만 확인하면 됨

3. Consumer 구조

a. Backend

  • Consumer : 1(be-group)
  • 메시지 처리 방식
    1. 메시지 처리는 배치 처리
      • 배치처리와 단건처리는 trade-off 관계
        • 배치처리는 처리량 증가하는 대신 처리속도 빠름
        • 단건처리는 처리량이 감소하는 대신 처리속도 느림
      • 부하테스트 결과 백엔드 서버가 HTTP 통신으로 발생하는 에러(SetTimeOut 등) 외 처리량 초과 이슈는 없음
      • 배치처리를 하되 최대 배치 사이즈와 최대 대기 시간은 실험을 통해 튜닝할 예정
    2. AI producer로부터 전달받은 메시지를 db에 저장
    3. 저장까지 성공한다면 상태 관리 테이블에 해당 task id의 status 수정(IN_PROGRESSSUCCESS)
  • 재처리 & 에러핸들링
    • Retry : consume 실패 시 3초 간격으로 최대 3회 재시도
    • DLQ : 3회 모두 실패 시 DLQ로 전송, 디스코드 알림
    • DLQ로 전송된 메시지는 별도 DLQ cousumer(id : be-dlq-group)에서 관리하며, 10분에 한번씩 원래 토픽으로 재전송
    • 3회 이상 실패 시 폐기

b. AI

  • Consumer: 1 (ai-group)
  • 메시지 처리 및 트랜잭션 흐름
    1. Kafka Consumer가 메시지를 poll하여 배치로 가져옴
      • 단건 처리 vs 배치 처리 기존에는 단건 처리로 진행하려 함 이유: 배치 처리 시, 배치에 속하는 메시지 1건 실패 시, 배치에 포함된 전체 메시지 롤백 수정 이유:
      1. ai 서버에서 처리 실패하는 상황은 거의 요청이 밀리는 상황에서 timeout이므로 실패가 거의 발생하지 않을 것으로 예상
      2. 배치 처리 시, 메시지 헤더가 동일한 메시지들 효율적 압축 가능(중복되는 패턴 효율적 처리됨)
    2. 각 메시지에 대해 비즈니스 로직 처리 (예: AI 모델 inference) 수행
    3. 처리 결과를 Kafka Producer를 통해 응답 토픽에 발행
    4. 응답 발행이 완료되면 해당 메시지의 오프셋을 커밋하여 Kafka에 반영
    5. 이 전체 과정을 Kafka의 트랜잭션 API를 이용하여 하나의 트랜잭션으로 처리
      • begin_transaction()send()commit_transaction()
    6. 트랜잭션 중 하나라도 실패하면 전체 롤백 (abort_transaction)
  • 메시지 처리 방식: 단건 처리
    • 배치 처리의 경우, 하나의 메시지라도 실패하면 전체 롤백되므로, 단건 처리
  • 병렬 처리
    • AI 서버는 2개의 워커 프로세스로 구성되며, 각 프로세스는 Kafka Consumer로 동작
    • 두 컨슈머는 동일한 컨슈머 그룹 (ai-group)에 속하며, Kafka 파티션 2개에 각기 하나씩 연결되어 병렬 처리 수행
    • 파티션-프로세스 1:1 매핑 구조로 메시지 병렬 소비
  • 재시도 및 DLQ 전략
    • 트랜잭션 실패 시:
      • 동일 메시지에 대해 최대 3회 재시도 (3초 간격)
        • 동일 메시지인지는 TTL이 있는 인메모리 캐시로 확인
      • 3회 실패 시, 해당 메시지를 DLQ(Dead Letter Queue) 토픽으로 전송
      • DLQ 전송 시, 디스코드 알림(Webhook) 전송
  • DLQ 재처리:
    • 별도 DLQ Consumer가 존재
      • 메인 컨슈머는 실시간 처리에 집중, DLQ 컨슈머는 주기적 실행하며 부하가 덜함
      • Gunicorn worker를 DLQ 컨슈머로 혼용 시, 리소스 낭비
    • 10분 간격으로 DLQ 메시지를 **원래 요청 토픽(request-topic)**으로 재전송
    • retry-count 헤더를 기반으로 3회 이상 실패한 메시지는 폐기, 폐기된 메시지는 로그 저장

4. Backend-AI Kafka 아키텍쳐

a. 멀티 프로세스, 브로커, 컨슈머 아키텍쳐

카프카

*FastAPI 서버가 프로듀서, Spring 서버가 컨슈머인 응답 브로커 아키텍쳐 로직 동일하여 생략

프로듀서

  • 처음에 부트스트랩 브로커로부터 카프카 메타 데이터 수신
  • 메시지 키를 해싱하여 파티션 결정
  • 메타데이터 기반으로 해당 파티션의 리더가 있는 브로커 확인
  • 메시지 발행

컨슈머

  • 처음에 부트스트랩 브로커로부터 카프카 메타 데이터 수신
  • group coordinator가 컨슈머 그룹 내 컨슈머들에 파티션 분배
  • 컨슈머는 해당 파티션의 리더가 있는 브로커에서 읽기 진행
  • 컨슈머는 파티션에 쌓인 메시지 배치 읽기
  • 내부 로직 처리는 하단 [AI 서버 트랜젝션 처리 아키텍쳐] 참고

b. 백엔드 서버 트랜젝션 처리 아키텍쳐

프로듀서

  • 프로듀서가 각각 해당되는 처리단계의 request topic에 메시지 전송
  • 메시지 전송 시 키값은 유저 아이디, 페이로드는 명세서 참고

컨슈머

  • 해당되는 처리단계의 response topic에서 메시지 pull
  • db에 저장 후 해당 task-id의 status를 SUCCESS로 변경
  • 만약 실패한다면 3회 retry
  • retry 실패 시 DLQ producer로 전송, 이시점에 디스코드 웹훅으로 백엔드 담당자(gray)에게 알림
  • DLQ producer에 의해 topic에 매핑되는 dlq response topic으로 전송
  • dlq 전용 컨슈머가 해당 토픽에서 메시지를 pull, 그 후 dlq reproducer에 의해 원래 토픽으로 다시 전송하여 재처리 유도
  • 만약 DLQ 처리도 3회 이상 실패한다면 메시지 소멸 조치

c. AI 서버 트랜젝션 처리 아키텍쳐

카프카1

메인 로직 처리

  1. 요청 브로커로부터 메시지 소비 (오토커밋 해제) - 컨슈머
  2. 트랜잭션 시작
    1. 내부 로직 처리
    2. 응답 브로커에 발행 - 프로듀서
      • 키는 1번에서 소비한 키(유저 id)를 이용
    3. 수동 오프셋 커밋

3.a. 성공 (다음 메시지 소비)

3.b. 도중 실패 → 트랜잭션 롤업

  1. 인메모리 캐시에 taskid를 키로 retry_count 업데이트
  2. retry_count 3인 메시지는 DLQ로 발행 & 디스코드 웹훅 알림

DLQ 처리

  1. 별도 프로세스에서 10분 간격으로 DLQ 소비
  2. 요청 브로커로 재발행
  • 만약 DLQ 처리도 3회 이상 실패한다면 메시지 소멸 조치

5. HA 보장 방안

  • 브로커 3개 → 파티션 리더 장애 시 자동 failover
  • KRaft 모드: 컨트롤러 노드 장애 시 자동 전환
  • 파티션 복제(replication) 설정으로 데이터 손실 없이 안정성 확보
  • acks 옵션: all
    • acks = 0: 프로듀서가 응답 기다리지 않음
    • acks=1: 리더 파티션에 작성 완료되었음을 응답 받음
    • acks = all: 팔로워 파티션들에 복제되었음을 응답 받음
    • ISR(In-Sync Replica)를 통해 EOS(Exactly Once Semantic) 만족
  • 모든 브로커를 부트스트랩 브로커로 설정 bootstrap.servers
    • 부트스트랩 브로커 하나가 다운되더라도 카프카 클라이언트가 다른 부트스트랩 브로커로부터 메타데이터를 받아올 수 있음