주문은 끝났는데 분석 서버가 응답하지 않을 때

쇼핑몰의 주문 완료 기록을 분석 시스템과 이상 거래 탐지 시스템에 보낸다고 생각해 보자. 두 곳을 매번 직접 호출하면 한쪽의 응답 지연이 주문 처리까지 따라올 수 있다. 호출을 분리하더라도, 실패한 전달을 어디에 남기고 언제 다시 시도할지는 여전히 정해야 한다. 여기서 필요한 것은 더 빠른 분석 서버보다 먼저 기록을 받아 둘 공간이다.

Cloudflare가 10월 1일 공개 베타로 발표한 K2는 이런 사이에 놓는 이벤트 저장 서비스다. 이벤트를 만드는 쪽은 기록을 추가하고, 읽는 쪽은 자신의 처리 위치를 옮긴다. 누군가 읽었다고 기록이 바로 없어지지 않으므로 다른 시스템도 같은 데이터를 읽거나, 보관 중인 과거 기록부터 다시 처리할 수 있다.

이 구조를 로그라고 부르지만 사람이 보는 오류 메시지 파일에 한정되는 말은 아니다. 클릭, 주문, 센서 관측값처럼 시간에 따라 들어오는 기록을 계속 덧붙이는 구조를 뜻한다. 분석 서버가 잠시 멈춘 뒤 따라잡을 여지가 생기되, 무기한 보관을 약속하는 것은 아니다. 복구에 필요한 시간이 설정한 보관 기간 안에 들어오는지 먼저 계산해야 한다.

근거 [1] [2] [6]

같은 기록을 두 번 쓰려면 작업자가 아니라 구독을 나눈다

K2에서 데이터를 읽는 단위는 구독이다. 분석용 구독과 이상 거래 탐지용 구독을 별도로 만들면 각각 전체 기록을 독립적으로 읽는다. 반대로 분석용 구독 하나에 작업자를 여러 개 붙이면, 그 안에서는 서로 다른 묶음을 나눠 처리한다. 모든 시스템이 같은 기록을 봐야 하는 상황에서 작업자 수만 늘리면 원하는 배포 구조가 되지 않는다.

새 구독을 만들 때는 읽기 시작점도 정한다. earliest는 아직 남아 있는 가장 오래된 기록부터, latest는 구독 생성 시점 이후의 새 기록부터 시작한다. 과거 데이터를 이용해 새 집계를 만드는 경우와 지금부터의 알림만 받는 경우에 필요한 선택이 다르다. 구독의 시작점은 생성 후 변경할 수 없으므로 시험할 때부터 의도를 명시하는 편이 좋다.

예를 들어 일별 매출 집계가 늦어져도 이상 거래 탐지는 자기 구독에서 계속 처리하게 할 수 있다. 다만 독립 구독을 추가하면 같은 데이터가 다시 읽히는 만큼 소비량도 늘어난다. 아래 표는 문서의 구독 구조를 주문 처리에 적용한 예시다. 실제 운영 사례나 처리 성능 측정값은 아니다.

원하는 동작구독 구성작업자의 역할
분석 서버 두 대로 나눠 처리분석 구독 1개서로 다른 기록 묶음을 분담
분석과 이상 거래 탐지가 모두 읽기서로 다른 구독 2개각 시스템이 전체 기록을 독립적으로 처리
남은 과거 기록으로 집계 다시 만들기earliest로 새 구독 생성보관 중인 가장 오래된 기록부터 시작

공식 구독 동작을 바탕으로 정리한 설계 예시. 구독 수와 작업자 수는 서로 다른 설정이다.

근거 [2] [3]

처리 성공을 알리기 전에 멈추면 같은 기록이 다시 온다

작업자가 기록 묶음을 받으면 5분 동안 처리할 권한을 임시로 부여받는다. 문서의 lease가 이 시간 제한이다. 처리를 마친 뒤 ack로 완료를 알리고, 실패했다면 nack로 반납하거나 오래 걸리는 작업의 기간을 연장한다. 기간 안에 완료를 알리지 않으면 다른 작업자에게 기록이 다시 전달될 수 있다.

여기서 서버가 데이터베이스 저장은 끝냈지만 ack를 보내기 전에 죽는 경우를 생각해 봐야 한다. 다음 작업자는 같은 주문을 다시 받는다. 주문 이벤트를 받을 때마다 무조건 매출에 더하면 집계가 두 번 올라간다. 이벤트 식별자를 저장하고 이미 처리한 식별자를 걸러내는 등, 반복 수신이 결과를 바꾸지 않도록 애플리케이션을 설계해야 한다.

또 하나는 순서다. 순서가 있는 로그라는 설명이 여러 작업자의 완료 순서까지 보장한다는 뜻은 아니다. 공식 소비 문서는 같은 구독을 공유하는 작업자 사이의 처리 순서를 보장하지 않는다고 명시한다. 서로 의존하는 상태 변경을 병렬 처리한다면 이벤트의 버전이나 발생 시점을 별도로 검토해야 한다. 작업자를 늘리는 것만으로 처리량과 정합성을 동시에 해결했다고 볼 수 없다.

근거 [3]

R2에 묶어서 저장하는 대신 즉시 응답과는 거리가 있다

Cloudflare는 K2의 저장 기반으로 R2 객체 스토리지를 사용한다. 개별 기록을 파일 뒤에 계속 붙이는 대신 잠시 모았다가 묶음 파일로 기록하는 방식이다. 회사가 공개한 초기 버전의 쓰기 지연은 p99에서 약 1초다. 이는 공급자가 설명한 측정값이며 모든 요청의 지연이나 한국 사용자의 응답 시간을 보증하는 수치는 아니다.

따라서 오래 보관하고 여러 소비자가 읽는 분석 기록과, 사용자의 요청에 즉시 결과를 돌려줘야 하는 동작을 구분해야 한다. 이미지 변환 작업처럼 항목별 재시도와 실패 처리가 중요한 경우에는 Queues, 최종 목적지가 R2나 Iceberg 테이블인 수집 작업에는 Basin Pipelines가 더 맞을 수 있다. K2는 직접 만든 소비자가 여러 목적지로 처리하는 경우를 먼저 검토할 만하다.

기존 Kafka 프로그램의 연결 주소만 바꿔 쓸 수 있다고 가정해서도 안 된다. 발표에서 Kafka 클라이언트 호환과 낮은 지연의 Express 계층은 향후 계획으로 제시됐다. 도입 검토는 계획된 기능이 아니라 현재 제공되는 HTTP API와 Workers 연결을 기준으로 해야 한다.

근거 [1]

첫 실험은 인증을 켠 스트림 하나부터

공식 시작 문서는 Workers Paid 계정, 계정 ID, API 토큰, curl과 jq를 준비하도록 안내한다. 스트림 설정에는 K2 Config Write, 쓰기에는 K2 Produce, 읽기에는 K2 Consume 권한이 필요하다. 개발 환경에서는 먼저 작은 시험 기록을 보내고, 구독에서 읽은 다음 ack 후 다시 요청했을 때 처리 상태가 달라지는지 확인하는 흐름이 적절하다.

아래는 스트림 생성 요청의 최소 예시다. 중요한 부분은 http.authentication을 true로 명시한 점이다. 이 값을 생략하면 스트림 주소를 아는 사람이 쓰기 요청을 보낼 수 있다. 브라우저에서 직접 쓰게 하겠다고 비밀 토큰을 JavaScript에 넣는 것은 해결책이 아니다. 비공개 데이터를 받아야 한다면 인증을 담당하는 서버 경로부터 정해야 한다.

기록 내용은 바이트로 다룬다. HTTP에서는 content를 base64로 인코딩하고 필요한 형식 정보를 headers에 넣을 수 있다. 또한 K2가 붙이는 시간은 서버가 수신한 시점이다. 사용자가 실제로 클릭한 시간이나 기기가 측정한 시간이 필요하면 원래 이벤트의 필드로 함께 보내야 한다. 늦게 도착한 기록을 분석할 때 두 시간이 섞이지 않도록 하기 위해서다.

bash · 공식 인터페이스를 사용한 생성 요청 예시. 이 글에서는 실행하지 않았으며 Workers Paid 계정과 권한이 있는 토큰이 필요하다.
# ACCOUNT_ID와 CLOUDFLARE_API_TOKEN은 서버 환경 변수로 설정
curl --fail-with-body --silent --show-error \
  "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/k2/streams" \
  --request POST \
  --header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "order_events_test",
    "retention_seconds": 86400,
    "http": {"enabled": true, "authentication": true}
  }'

근거 [4] [5] [6]

무료 베타라는 말 옆에 적어 둘 세 가지 한도

베타 기간에는 K2 사용량을 청구하지 않지만 Workers Paid 가입 조건은 별개다. 발표에 적힌 향후 예상 단가는 쓰기와 읽기 각각 GB당 0.04달러, 보관은 GB·월당 0.02달러다. 아직 청구되지 않는 예상 단가를 확정된 현재 요금으로 견적서에 넣어서는 안 된다. Workers 계정 비용과 소비자를 실행하는 비용도 따로 본다.

현재 제한 문서에서 우선 확인할 숫자는 계정 전체 저장 10GB, 기본 보관 7일, 일반 설정의 최대 보관 30일이다. 스트림당 용량이라고 착각하면 여러 스트림을 만든 뒤 계획보다 빨리 계정 한도에 닿을 수 있다. 보관 기간을 짧게 잡으면 저장 부담은 줄지만 장애 후 되돌아갈 수 있는 시간도 짧아진다.

요청 크기도 제한된다. 쓰기 요청은 5MB, 레코드 하나는 내용과 헤더를 포함해 약 1MB가 기준이다. 긴 본문이나 바이너리 파일까지 이벤트에 넣기보다, 별도 저장한 자료의 식별자와 처리에 필요한 정보만 보낼 수 있는지 검토할 만하다. 또한 보관 기간이 끝난 기록은 백그라운드에서 지워지므로 정확히 특정 초에 삭제되는 장치로 쓰지 않아야 한다.

근거 [1] [6] [7] [8]

갈아타기 전에 재처리 한 번을 끝까지 해 본다

첫 검증에서는 정상 전달보다 중간에 멈추는 상황을 넣는 것이 유용하다. 몇 개의 식별 가능한 시험 이벤트를 보내고, 소비자가 저장한 뒤 ack만 하지 않은 상태로 중단해 본다. 다시 전달됐을 때 결과가 중복되지 않는지 확인하면 애플리케이션이 맡아야 할 처리가 드러난다. 이는 권하는 시험 절차이지 이 글에서 측정한 결과는 아니다.

다음으로 구독 두 개를 만들어 한쪽만 멈췄을 때 다른 쪽이 계속 진행하는지, 오래된 기록부터 시작했을 때 필요한 구간이 남아 있는지 점검한다. 관찰할 것은 단순 수신 건수보다 원래 이벤트 수와 최종 처리 수의 차이, 재처리 시간, 실패한 묶음의 상태다. 데이터가 도착했다는 사실과 목적지에 정확히 반영됐다는 사실을 분리해서 보아야 한다.

K2는 이벤트를 여러 시스템에 전달하면서 생기는 시간 차이를 흡수하는 선택지다. 기존 파이프라인 전체를 한꺼번에 교체하기보다, 즉시 응답이 필요하지 않고 중복 처리 방식을 통제할 수 있는 기록부터 병행 시험하는 편이 비교하기 쉽다. 그 시험에서 실제로 줄어든 운영 작업과 새로 생긴 소비자 처리 코드를 함께 봐야 전환의 이익을 판단할 수 있다.

근거 [2] [3] [4]