실시간 채팅과 Kafka 후처리를 분리한 이유

궁금하면 500원·2026년 7월 2일

미생의 개발 이야기

목록 보기
90/92

채팅은 살아 있고 Kafka만 죽었을 때

채팅 서비스에서 가장 치명적인 장애는 사용자가 보낸 메시지가 사라지거나, 상대방에게 전달되지 않는 상황입니다.

그런데 Kafka, Milvus 같은 후처리 인프라가 죽었다고 해서 사용자 채팅까지 같이 멈춰야 할까요?

Ko_Chat에서는 이 문제를 해결하기 위해 실시간 메시지 전달 경로비동기 후처리 경로를 분리했습니다.

실시간 채팅은 WebSocket과 Redis Pub/Sub을 통해 처리하고, 감사 로그·검색 인덱싱·첨부파일 후처리·Milvus 벡터 등록 같은 작업은 Kafka와 Outbox를 통해 뒤로 미뤘습니다.

README 트러블슈팅에 적힌 PENDING 누적, consumer lag 증가, Milvus 인덱싱 실패 같은 증상들은 단순한 버그 목록이 아닙니다.

오히려 “장애가 발생했을 때 어디에서 멈추도록 설계했는가”를 보여주는 운영 지점입니다.

이 글은 Ko_Chat에서 Outbox와 Kafka를 어떻게 분리했고, 장애가 났을 때 운영자가 어디를 봐야 하는지 정리한 설계 기록입니다.


1. 채팅 한 건 뒤에는 생각보다 많은 일이 있습니다

사용자가 메시지 하나를 보내면 단순히 DB에 insert 한 번 하고 끝나는 것처럼 보입니다.
하지만 실제 서비스에서는 그 뒤에 여러 작업이 따라붙습니다.

  • 메시지 저장
  • WebSocket을 통한 실시간 전달
  • Redis Pub/Sub을 통한 다중 인스턴스 브로드캐스트
  • 읽지 않은 메시지 수 증가
  • 감사 로그 기록
  • 첨부파일 후처리
  • 검색 인덱싱
  • Milvus 벡터 인덱싱

문제는 이 모든 작업을 하나의 트랜잭션이나 하나의 실행 경로에 몰아넣었을 때 발생합니다.

예를 들어 Milvus가 죽었는데 메시지 전송까지 실패한다면, 사용자는 납득하기 어렵습니다.
사용자 입장에서는 “파일 검색 인덱싱이 안 됐다”가 아니라 “채팅이 안 된다”로 느껴지기 때문입니다.

그래서 Ko_Chat에서는 경로를 나눴습니다.

핵심 UX는 최대한 짧고 안정적인 경로에 둡니다.
후처리는 실패해도 다시 처리할 수 있는 경로에 둡니다.

즉, 채팅은 살아 있어야 하고 Kafka 장애는 Kafka 쪽에서 멈춰야 합니다.


2. 무엇을 반드시 살릴 것인가?

이번 설계에서 가장 먼저 정한 기준은 “무엇이 죽으면 안 되는가”였습니다.

채팅 서비스에서 가장 중요한 것은 메시지 저장과 실시간 전달입니다.
반면 Kafka 발행, Milvus 인덱싱, 감사 로그, 검색 색인 같은 작업은 중요하지만 즉시 성공해야 하는 작업은 아닙니다.

그래서 기준을 다음처럼 나눴습니다.

구분처리 방식장애 시 영향
메시지 저장DB 트랜잭션실패하면 사용자 요청 실패
실시간 전달afterCommit 이후 WebSocket + RedisKafka와 무관하게 동작
Kafka 이벤트 발행Outbox Relay실패 시 PENDING 또는 FAILED로 남김
Milvus 인덱싱Kafka consumer 후처리실패해도 채팅은 유지
운영 복구Admin API / DLQ / Requeue수동 또는 재처리 가능

핵심은 단순합니다.

사용자 요청 경로는 짧게 유지하고, 실패 가능한 외부 인프라는 뒤로 밀어냅니다.


3. Outbox를 선택한 이유

가장 흔한 방식은 메시지를 저장한 직후 바로 Kafka로 이벤트를 발행하는 것입니다.

messageRepository.save(message)
kafkaTemplate.send(...)

겉으로 보기에는 단순합니다.
하지만 이 방식은 DB 저장과 Kafka 발행 사이에 불일치가 생길 수 있습니다.

예를 들어 DB 저장은 성공했는데 Kafka 발행이 실패하면 어떻게 해야 할까? 고민이되었습니다.
반대로 Kafka 발행은 성공했는데 DB 트랜잭션이 롤백되면 consumer는 존재하지 않는 메시지를 처리할 수 있습니다.

이 문제를 줄이기 위해 Ko_Chat에서는 Outbox 패턴을 사용했습니다.

메시지 저장 트랜잭션 안에서 outbox_events 테이블에 이벤트를 같이 기록합니다.
그 후 별도의 Relay가 PENDING 이벤트를 읽어 Kafka로 발행합니다.

항목선택
선택한 방식DB 트랜잭션 안에 Outbox 이벤트 저장
대안저장 직후 Kafka 직접 발행
얻은 것DB 저장과 이벤트 발행 사이의 유실 가능성 감소
감수한 것Relay, 재시도, PENDING 적체 관리 필요

Outbox는 Kafka 장애를 없애는 기술이 아닙니다.
대신 Kafka가 죽었을 때 이벤트를 잃지 않고, 어디에 쌓였는지 볼 수 있게 만드는 장치입니다.

장애를 숨기는 것이 아니라, 장애를 상태로 남기는 방식입니다.


4. 실시간 전달은 Kafka와 분리했습니다

Ko_Chat의 메시지 전달은 DB 커밋 이후 실행됩니다.

fun scheduleDispatch(saved: SavedChatMessage) {
    runAfterCommit {
        publishMessage(saved)          // WebSocket + Redis
        indexAttachmentIfKafkaDisabled(saved)
        chatUnreadCountService.incrementUnreadForRoomMembers(...)
    }
}

여기서 중요한 점은 afterCommit입니다.

메시지가 DB에 정상 저장된 뒤에만 WebSocket과 Redis로 브로드캐스트합니다.
즉, 저장되지 않은 메시지를 사용자에게 먼저 보여주는 일을 피합니다.

또 하나 중요한 점은 Kafka가 이 경로에 없다는 것입니다.

Kafka가 죽어도 메시지 저장과 WebSocket 전달은 유지됩니다.
README 트러블슈팅에서 “다른 탭에 메시지가 보이지 않는다”는 증상이 있다면 Kafka가 아니라 Redis Pub/Sub 경로를 먼저 봐야 합니다.

예를 들어 다음 항목을 확인합니다.

  • Redis 컨테이너가 살아 있는지
  • spring.data.redis.port와 실제 Redis 포트가 일치하는지
  • 로컬 포트 9379가 정상적으로 열려 있는지
  • WebSocket 연결이 정상적으로 유지되는지

이렇게 장애 지점을 분리해야 운영 중에 엉뚱한 곳을 뒤지지 않습니다.
Kafka 문제가 아닌데 Kafka만 계속 보면 시간만 녹습니다. 장애 대응에서 시간 낭비는 진짜 비싼 실수입니다.


5. Outbox Relay는 실패를 기록합니다

Outbox Relay는 PENDING 상태의 이벤트를 주기적으로 조회한 뒤 Kafka로 발행합니다.

@Scheduled(fixedDelayString = "\${app.kafka.outbox-relay-interval-ms:3000}")
fun relayPendingEvents() {
    pendingEvents.forEach { outboxEvent ->
        try {
            kafkaTemplate
                .send(outboxEvent.topic, outboxEvent.partitionKey, outboxEvent.payload)
                .get()

            outboxEvent.status = OutboxEventStatus.PUBLISHED
        } catch (ex: Exception) {
            outboxEvent.retryCount += 1

            if (outboxEvent.retryCount >= kafkaProperties.outboxMaxRetries) {
                outboxEvent.status = OutboxEventStatus.FAILED
            }
        }
    }
}

Kafka가 정상이라면 이벤트는 PUBLISHED가 됩니다.
Kafka가 죽어 있다면 이벤트는 PENDING으로 남거나, 재시도 횟수를 초과하면 FAILED가 됩니다.

이 구조의 장점은 명확합니다.

운영자는 “이벤트가 유실됐는지”를 추측하지 않아도 됩니다.
outbox_events 테이블을 보면 됩니다.

  • PENDING이 쌓인다 → Kafka 발행이 막혀 있음
  • FAILED가 증가한다 → 재시도 초과 이벤트가 있음
  • Kafka 복구 후에도 남아 있다 → Relay 또는 재큐잉 확인 필요

Ko_Chat에서는 실패한 Outbox 이벤트를 다시 처리하기 위해 다음과 같은 운영 API를 둡니다.

POST /api/v1/admin/messaging/outbox/requeue-failed

이 API는 FAILED 이벤트를 다시 PENDING 상태로 되돌려 Relay가 재처리할 수 있게 합니다.


6. Consumer 실패는 DLQ로 보냅니다

Kafka 발행이 성공했다고 모든 처리가 끝나는 것은 아닙니다.
Consumer 쪽에서도 실패가 날 수 있습니다.

예를 들어 첨부파일 인덱싱 중 예외가 발생하거나, Milvus 연결이 불안정할 수 있습니다.
이 경우 메시지를 계속 재시도만 하면 consumer lag가 끝없이 증가할 수 있습니다.

그래서 Ko_Chat에서는 Kafka consumer 실패를 DLQ로 보냅니다.

val recoverer = DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ ->
    val dlqTopic = when (record.topic()) {
        kafkaProperties.topics.messageEvents ->
            kafkaProperties.topics.messageEventsDlq

        kafkaProperties.topics.attachmentEvents ->
            kafkaProperties.topics.attachmentEventsDlq

        else -> "${record.topic()}.dlq"
    }

    TopicPartition(dlqTopic, record.partition())
}

return DefaultErrorHandler(
    recoverer,
    FixedBackOff(delayMs, maxRetries)
)

DLQ로 보낸 이벤트는 dlq_events 테이블에도 저장하고, 필요하면 replay API를 통해 원본 토픽으로 다시 발행할 수 있도록 했습니다.

여기서 중요한 것은 “무조건 자동 재시도”가 아닙니다.
재시도해도 계속 실패하는 이벤트는 따로 격리해야 합니다.

운영에서 가장 위험한 상황은 실패한 이벤트 하나가 전체 consumer 처리를 계속 막는 것입니다.
DLQ는 그런 이벤트를 옆으로 빼서 전체 흐름을 살리는 장치입니다.


7. Milvus는 채팅의 필수 경로가 아닙니다

Milvus는 첨부파일 벡터 검색을 위한 후처리 인프라입니다.
중요한 기능이지만, 채팅 메시지 저장과 실시간 전달보다 우선순위가 높지는 않습니다.

그래서 Milvus 초기화가 실패해도 애플리케이션이 죽지 않도록 처리했습니다.

@PostConstruct
fun initialize() {
    try {
        // Milvus collection 초기화
    } catch (ex: Exception) {
        logger.warn(
            "Milvus 초기화 실패 - 첨부파일 벡터 등록이 비활성화됩니다: {}",
            ex.message
        )
    }
}

Milvus가 죽어도 앱은 기동됩니다.
채팅도 됩니다.
다만 첨부파일 벡터 인덱싱은 비활성화되거나 지연됩니다.

이 설계는 의도적인 선택입니다.

벡터 검색을 위해 채팅 전체를 멈추는 것은 비용이 너무 큽니다.
반대로 Milvus 인덱싱이 지연되는 것은 일정 수준 감수할 수 있습니다.

다만 한계도 있습니다.

만약 markProcessed가 실제 인덱싱 성공 전에 호출된다면, 조용한 실패가 발생했을 때 자동 복구가 약해질 수 있습니다.
실서비스라면 다음과 같은 보완이 필요합니다.

  • milvus_indexed=false 기준 재처리 배치
  • 인덱싱 실패 사유 저장
  • 재시도 횟수와 마지막 실패 시각 기록
  • 운영 화면에서 미인덱싱 첨부파일 목록 제공

즉, 현재 구조는 장애 격리의 기반이고, 운영 완성도를 높이려면 재처리 정책을 더 붙여야 합니다.


8. 운영 화면은 트러블슈팅 표의 UI 버전입니다

문서에만 트러블슈팅을 적어두면 실제 운영에서는 한계가 있습니다.
운영자는 지금 쌓인 Outbox 수, DLQ 수, consumer lag를 한눈에 봐야 합니다.

Ko_Chat에서는 /admin/messaging 화면에서 메시징 운영 상태를 확인할 수 있도록 했습니다.

export const getMessagingOperations = (token: string) =>
  getJson('/api/v1/admin/messaging/operations', token)

export const requeueFailedOutbox = (token: string, limit = 50) =>
  postJson(
    `/api/v1/admin/messaging/outbox/requeue-failed?limit=${limit}`,
    {},
    token
  )

운영 화면에서는 다음 정보를 확인할 수 있습니다.

  • Outbox PENDING 개수
  • Outbox FAILED 개수
  • DLQ 이벤트 개수
  • consumer lag
  • 실패 이벤트 재큐잉
  • DLQ replay

여기서 /admin/messaging이 404라면 먼저 app.kafka.enabled=false 여부를 확인해야 합니다.
Ko_Chat에서는 Kafka가 활성화된 경우에만 messaging admin 기능이 열리도록 구성했기 때문입니다.

이 부분도 의도적인 선택입니다.
Kafka를 사용하지 않는 환경에서 불필요한 운영 API가 열릴 필요는 없습니다.


9. 장애 시나리오별 확인 지점

이번 구조에서 장애 대응은 다음처럼 나눌 수 있습니다.

증상먼저 볼 곳의미
메시지가 저장되지 않음DB / 애플리케이션 로그핵심 요청 경로 실패
같은 브라우저에서는 보이지만 다른 탭에 안 보임Redis Pub/Sub / WebSocket실시간 브로드캐스트 문제
PENDING이 계속 쌓임Kafka / Outbox RelayKafka 발행 지연 또는 실패
FAILED가 증가함Outbox retry / requeue API재시도 초과 이벤트 존재
consumer lag 증가Kafka consumer / 처리 시간후처리 지연
DLQ 증가consumer 예외 / payload 검증처리 불가능 이벤트 격리
Milvus 인덱싱 누락Milvus 상태 / 인덱싱 로그검색 후처리 실패

이 표의 목적은 단순합니다.

장애가 났을 때 “어디부터 봐야 하는지”를 줄여주는 것입니다.
운영자는 모든 로그를 다 볼 시간이 없습니다.
좋은 설계는 장애가 난 위치를 최대한 빨리 좁혀줘야 합니다.


10. 이 설계에서 의도적으로 포기한 것들

이번 구조가 모든 문제를 해결하는 것은 아닙니다.
오히려 몇 가지는 명확히 포기했습니다.

첫째, 후처리의 즉시성을 포기했습니다.
감사 로그, 검색 인덱싱, Milvus 벡터 등록은 수 초에서 수 분 정도 지연될 수 있습니다.

둘째, Kafka 장애 시 완전 자동 복구를 보장하지 않았습니다.
Outbox와 DLQ를 통해 재처리 가능한 구조는 만들었지만, 장기 장애나 반복 실패 이벤트는 운영자의 개입이 필요할 수 있습니다.

셋째, exactly-once 처리를 목표로 하지 않았습니다.
대신 at-least-once에 가깝게 처리하고, 이벤트 ID 기반 멱등성으로 중복 처리 가능성을 줄이는 쪽을 선택했습니다.

넷째, Milvus 장애 복구를 완전히 자동화하지 않았습니다.
현재는 graceful degrade에 가깝고, 실서비스 수준으로 가려면 미인덱싱 데이터 재처리 배치가 추가로 필요합니다.

기술 선택은 정답 찾기가 아니라 비용 배분입니다.

Ko_Chat에서는 채팅 UX를 살리는 데 비용을 먼저 썼고, 후처리는 Outbox, DLQ, 운영 API를 통해 복구 가능하게 만드는 데 비용을 썼습니다.


11. 마무리

이번 프로젝트에서 배운 것은, 좋은 백엔드 설계는 화려한 기술을 덧붙이는 일이 아니라 문제가 터졌을 때 어디를 봐야 하는지, 어디까지는 살아남아야 하는지, 어떻게 다시 시작할 수 있는지를 미리 정해두는 일이라는 점입니다.

제게 Outbox는 이벤트 보존용 테이블 이상이었습니다.
Kafka가 멈췄을 때 이벤트가 어디에 고여 있는지 보여주는 창구였고, requeue API와 묶이면 복구 수단이 됐습니다.

장애를 완벽히 없애는 방법은 없습니다.
다만 장애가 나도 어디서 멈췄는지 알 수 있게 만들 수는 있습니다.
그게 이번에 Outbox를 붙이며 얻은 결론입니다.

profile
레거시를 이해하면서도 새로운 기술을 현실적으로 적용할 수 있는 백엔드 개발자가 되는 것이 목표입니다.

0개의 댓글