1회만 실행되는 시멘틱 (Exactly-Once Semantics) 은 가능하다 - 카프카는 어떻게 해냈나?
카프카 0.11 (컨플루언트 플랫폼 3.3) 부터, 카프카 커뮤니티가 기대하던 1회만 실행되는 시맨틱 을 사용할 수 있습니다. 이 글에서 Kakfa Streams API 를 이용해 Kafka 의 1회만 실행되는 시맨틱이 무엇이고 왜 어려운지, 그리고 새로운 멱등성과 트랜잭션 기능이 어떻게 Kafka 의 1회만 실행되는 시맨틱을 구현했는지 설명하겠습니다.
1회만 실행되는 시멘틱은 정말 어려운 문제다.
여러분들이 무슨 생각을 하는지 알고 있습니다. 정확히 1회전송은 실 사용하기엔 너무 많은 비용을 필요로 하기에 불가능하거나, 아얘 잘못 알고 있거나라는 사실을요. 여러분만 그렇게 생각하는 것이 아닙니다. 제 몇몇 직장 동료들도 분산 시스템에서 정확히 1회 전송은 가장 어려운 문제 중에 하나라는 것을 인지하고 있습니다.
또 일부는 정확히 1회 전송은 불가능할 것이라고 명쾌하게 말하기도 했죠.
자, 저는 정확히 1회 전송 (과 정확히 1회 전송하는 stream processing 을 지원하는 것) 이 해결하기 어려운 문제라는 것을 부정하지는 않겠습니다. 그렇지만 전 또 Confluent 의 똑똑한 분산 시스템 엔지니어들이 오픈소스 커뮤니티와 몇 년간 협업해 Apache Kafka 에서 이 문제를 해결하는 것을 목격했습니다. 그럼, 바로 메시징 시멘틱을 살펴보는 것으로 시작하죠.
정확히 1회 전송 시맨틱이 뭐죠? 메시징 시멘틱 설명
분산 주제-구독 (publish-subscribe) 메시징 시스템에서, 시스템을 구성하는 컴퓨터들은 언제든지 다른 컴퓨터와 상관없이 오류 (원문에서 fail) 가 일어날 수 있습니다. Kafka 의 경우엔 각각의 브로커가 언제든지 오류를 일으킬 수 있고, producer 가 topic 으로 메시지를 보낼 때 네트워크 문제로 실패할 수도 있습니다. producer 가 이런 오류를 해결하는 방법에 따라 다양한 시멘틱이 존재합니다.
At-least-once semantics (최소 1번 시맨틱): producer 가 ack=all 로 설정되어 있고 Kafka 브로커로부터 ack 를 받았다는 뜻은 메시지가 Kafka topic 에 정확히 한 번 입력되었다는 것을 의미합니다. 그러나 producer 가 ack 를 받는 데 시간 초과가 나거나 에러를 받으면, 메시지가 Kafka topic 으로 들어가지 않았다고 생각해 메시지를 재전송할 수도 있습니다. 만약 브로커가 메시지를 Kafka topic 에 쓰고 ack 를 보내기 전에 오류가 발생했다면 이 메시지 재전송은 두 번 기록되게 되고, consumer 가 적어도 한 번 이상 같은 메시지를 받게 됩니다. 모두들 발랄한 기부자 (원문 cheerful giver) 를 좋아하지만, 이런 방식은 중복된 일을 발생시키고 부정확한 결과를 만들어낼 수 있습니다.
At-most-once semantics (최대 1번 시맨틱): producer 가 ack 를 받는 데 시간 초과가 나거나 에러를 받은 경우에도 재전송을 하지 않으면 메시지가 Kafka topic 에 기록되지 않을 수 있고, 결과적으로 consumer 에게 메시지가 전송되지 않을 수 있습니다. 대부분의 경우엔 그러겠지만, 메시지 중복을 막기 위해선 가끔은 메시지가 누락될 수도 있다는 것을 인지해야 합니다.
Exactly-once semantics (정확히 1회 시맨틱): producer 가 메시지를 재전송하더라도 consumer 에게 무조건 한 번 메시지가 오도록 하는 방식입니다. 정확히 1회 시맨틱은 가장 매력적인 보장 방법이지만 전반적인 이해가 부족한 부분이기도 합니다. 그 이유는 이 방식이 메시징 시스템 자체와 메지시를 전송하고 받는 어플리케이션의 협력을 요구하기 때문입니다. 예를 들어, 메시지를 consume 한 뒤 offset 되돌리면, 되돌린 시점부터 바로 최근까지의 모든 메시지를 다시 받게 될 텐데요, 이건 정확히 1회 시맨틱을 위해 왜 메시징 시스템과 클라이언트 어플리케이션이 협력해야 하는지를 보여줍니다.
필수적으로 처리되어야 하는 오류
정확히 1회 시맨틱을 지원하기 위해 해결해야 하는 문제를 서술하기 위해, 간단한 예제 하나로 시작해 보겠습니다.
partition 이 1개인 Kafka topic "EoS" 로 단일 프로세스 producer 로 구성된 어플리케이션이 "Hello Kafka" 라는 메시지를 보낸다고 가정해 보겠습니다. 더 나아가 단일 인스턴스 consumer 가 해당 topic 의 끝에서 메시지를 받아 출력해 준다고 생각해 보겠습니다. consumer 가 메시지를 pull 해서 받아오고, 처리하고, 메시지의 처리가 완료되었다고 메시지를 commit 하면, 그 메시지를 다시 받지 않습니다. 설령 consumer 애플리케이션에 오류가 발생해 재시작된다 하더라도요.
그러나, 이런 행복한 상황만 있다고 기대할 순 없습니다. 큰 규모에선 실패할 거라고 생각할 수 없는 부분에서도 항상 문제가 일어납니다.
브로커의 오류 가능성 : 카프카는 모든 메시지를 영속성을 띤 partition 에 저장하고 메시지를 n 번 복제하는 고가용성, 영속성, 지속성을 지닌 시스템입니다. 그 결과로, 카프카는 n-1 개의 브로커가 실패하더라도 괜찮습니다. 적어도 하나의 브로커만 살아있으면 그 partition 은 사용 가능하다는 뜻이니까요. Kafka 의 복제 프로토콜은 leader replica (메인 레플리카) 에 메시지가 성공적으로 기록되었다면, 가능한 모든 replica 에 메시지가 기록되는 것을 보장합니다.
producer -> 브로커 RPC 통신 오류 가능성 : Kafka의 지속성은 producer 가 브로커로부터 ack 를 받는 것에 의존합니다. ack 를 받지 못했다는 것이 요청 자체가 실패했다고 단정지을 수는 없습니다. 브로커는 메시지를 정상적으로 기록한 뒤 오류가 나서 producer 에게 ack 를 보내지 못했을 수도 있고, 메시지를 topic 에 기록하기 전에 오류가 날 수도 있기 때문입니다. producer 가 실패의 원인을 알 수 없는 상황에서는 결국 메시지가 제대로 전송되지 않았다고 생각할 수밖에 없고, 따라서 메시지를 다시 전송합니다. 몇몇 상황에서는 이런 행동이 몇몇 메시지가 Kafka partition 로그에 중복 기록되고, 결과적으로 consumer 가 한 번 이상 메시지를 받게 됩니다.
클라이언트의 오류 가능성 : 정확히 1회 전송은 클라이언트의 실패에도 대응 가능해야 합니다. 그럼 클라이언트가 정말 오류가 났는지, 아니면 브로커로부터 잠시 분리되었는지, 잠시 작동이 멈췄는지 어떻게 알 수 있을까요? 영구적인 오류와 일시적인 오류를 판단할 수 있는 것은 중요합니다. 정확성을 위해, 브로커는 zombie producer 가 발행한 메시지를 거부해야 하니까요. consumer 도 동일한데, 새로운 클라이언트가 작동하면, 실패한 이전 클라이언트가 남긴 상태와 관련없이 복구된 후 안전한 지점 (offset 을 의미하는 듯) 부터 메시지를 processing 해야 하니까요. 이 뜻은 consumer 의 메시지 offset (consumed offset) 은 producer 의 결과 (producer output) 과 항상 동기화되어야 함을 뜻합니다.
Apache Kafka 에서의 정확히 1회 시맨틱
0.11.x 버전 이전에는, Apache Kafka 는 적어도 1회 전송 시맨틱과 partition 별 메시지 정렬을 지원했습니다. 위의 예제에서 보듯이, 그 뜻은 producer 의 재전송은 중복된 메시지를 초래할 수 있음을 의미합니다. 새로운 정확히 1회 시맨틱 기능을 위해 우린 3개의 서로 다른 방법으로 Kakfa 의 소프트웨어 처리 시맨틱을 강화시켰습니다.
멱등성 (idempotence) : partition 별 정확히 1회 순서 시맨틱
멱등 작업은 한 번 수행할 떄와의 차이를 걱정할 필요 없이 여러 번 실행할 수 있습니다. 이제부터 producer 의 전송 작업은 멱등적입니다. producer 가 재전송할 수 있는 에러 상황에선 (아직까지도 이 경우에선 producer 가 여러 번 메시지를 보냅니다) 동일한 메시지들은 브로커에 있는 Kafka log 에 한 번만 기록됩니다. 단일 파티션의 경우 멱등적인 producer 의 전송은 producer 나 브로커의 에러에 관계없이 중복 메시지의 가능성을 없앱니다. 이 기능으로 파티션별 정확히 1회 시맨틱 (중복이나 데이터 손실 가능성이 없고, 정렬된 시맨틱) 을 적용시키려면, producer 를 "enable.idempotence=true" 로 설정해 주세요.
이 기능이 어떻게 작동하냐고요? 근본적으로 이 방식은 TCP 와 비슷하게 작동합니다. Kafka 로 보내진 메시지 배치들은 브로커가 중복 발행된 메시지를 중복 제거하기 위한 sequence 번호를 부여받습니다. (짧은 인메모리 연결에 대해서만 보장하는) TCP 와는 다르게, 이 sequence 번호는 replica 들의 로그에 영구적으로 기록됩니다. 따라서 leader 브로커가 실패하더라도, 넘겨받은 새로운 leader 도 중복을 탐지할 수 있습니다. 이 방식의 오버헤드는 생각보다 낮습니다. 단순히 각 배치 메시지에 몇 개의 숫자 필드가 추가되는 것뿐이니까요. 이 기사 뒤에서 보듯이, 멱등적인 producer 에 대해 무시해도 될 만한 퍼포먼스 부하만 걸립니다.
트랜잭션 : 여러 partition 에 대한 원자적 기록
두 번째로, Kafka 는 이제 새로운 트랜잭션 API 로 여러 partition 에 대해 원자적 쓰기를 제공합니다. 이건 producer 가 보낸 배치 메시지가 consumer 들이 모두 볼 수 있거나, 모두 볼 수 없게 되는 것을 뜻합니다. 또한 이 기능은 처리한 메시지들에 대해 consumer offset 을 저장할 때도 동일한 트랜잭션을 사용해 end-to-end 로 정확히 1회 시맨틱을 보장함을 의미합니다. 이 트랜잭션 API 를 사용하는 예제 코드 조각입니다.
위의 코드 조각은 여러 partition 들에 대해 원자적 쓰기를 하기 위해 어떻게 새로운 producer API 를 사용하는지를 설명하고 있습니다. 트랜잭션 메시지의 일부를 한 topic partition 이 가지고 있고, 다른 partition 이 가지지 않고 있을 지도 모른다는 것은 의미가 없습니다 (모두가 가지고 있거나, 그렇지 않거나가 중요하다).
consumer 부분에서, 트랜잭션 메시지들을 읽기 위해선 isolation.level consumer 설정을 조절하는 두 가지 옵션이 있습니다.
read_committed: 트랜잭션이 아닌 메시지들을 읽는 것에 추가로, 트랜잭션이 커밋된 후에 해당 메시지들을 읽는 옵션입니다.
read_uncommitted: 트랜잭션이 커밋되기 전에 정렬된 offset 에 따라 모든 메시지들을 읽습니다. 현재의 Kafka consumer 시맨틱과 비슷합니다.
트랜잭션을 사용하기 위해선, consumer 가 올바른 isolation.level 을 사용하도록 설정하고 새로운 producer API 를 사용해야 하며, producer 에 transactional.id 설정을 고유한 id 로 부여해줘야 합니다. 이 고유 id 는 어플리케이션 재시작에 상관없는 트랜잭션 상태를 보장하기 위해 필요합니다.
1회만 실행되는 시멘틱 (Exactly-Once Semantics) 은 가능하다 - 카프카는 어떻게 해냈나?
카프카 0.11 (컨플루언트 플랫폼 3.3) 부터, 카프카 커뮤니티가 기대하던 1회만 실행되는 시맨틱 을 사용할 수 있습니다. 이 글에서 Kakfa Streams API 를 이용해 Kafka 의 1회만 실행되는 시맨틱이 무엇이고 왜 어려운지, 그리고 새로운 멱등성과 트랜잭션 기능이 어떻게 Kafka 의 1회만 실행되는 시맨틱을 구현했는지 설명하겠습니다.
1회만 실행되는 시멘틱은 정말 어려운 문제다.
여러분들이 무슨 생각을 하는지 알고 있습니다. 정확히 1회전송은 실 사용하기엔 너무 많은 비용을 필요로 하기에 불가능하거나, 아얘 잘못 알고 있거나라는 사실을요. 여러분만 그렇게 생각하는 것이 아닙니다. 제 몇몇 직장 동료들도 분산 시스템에서 정확히 1회 전송은 가장 어려운 문제 중에 하나라는 것을 인지하고 있습니다. 또 일부는 정확히 1회 전송은 불가능할 것이라고 명쾌하게 말하기도 했죠. 자, 저는 정확히 1회 전송 (과 정확히 1회 전송하는 stream processing 을 지원하는 것) 이 해결하기 어려운 문제라는 것을 부정하지는 않겠습니다. 그렇지만 전 또 Confluent 의 똑똑한 분산 시스템 엔지니어들이 오픈소스 커뮤니티와 몇 년간 협업해 Apache Kafka 에서 이 문제를 해결하는 것을 목격했습니다. 그럼, 바로 메시징 시멘틱을 살펴보는 것으로 시작하죠.
정확히 1회 전송 시맨틱이 뭐죠? 메시징 시멘틱 설명
분산 주제-구독 (publish-subscribe) 메시징 시스템에서, 시스템을 구성하는 컴퓨터들은 언제든지 다른 컴퓨터와 상관없이 오류 (원문에서 fail) 가 일어날 수 있습니다. Kafka 의 경우엔 각각의 브로커가 언제든지 오류를 일으킬 수 있고, producer 가 topic 으로 메시지를 보낼 때 네트워크 문제로 실패할 수도 있습니다. producer 가 이런 오류를 해결하는 방법에 따라 다양한 시멘틱이 존재합니다.
필수적으로 처리되어야 하는 오류
정확히 1회 시맨틱을 지원하기 위해 해결해야 하는 문제를 서술하기 위해, 간단한 예제 하나로 시작해 보겠습니다.
partition 이 1개인 Kafka topic "EoS" 로 단일 프로세스 producer 로 구성된 어플리케이션이 "Hello Kafka" 라는 메시지를 보낸다고 가정해 보겠습니다. 더 나아가 단일 인스턴스 consumer 가 해당 topic 의 끝에서 메시지를 받아 출력해 준다고 생각해 보겠습니다. consumer 가 메시지를 pull 해서 받아오고, 처리하고, 메시지의 처리가 완료되었다고 메시지를 commit 하면, 그 메시지를 다시 받지 않습니다. 설령 consumer 애플리케이션에 오류가 발생해 재시작된다 하더라도요.
그러나, 이런 행복한 상황만 있다고 기대할 순 없습니다. 큰 규모에선 실패할 거라고 생각할 수 없는 부분에서도 항상 문제가 일어납니다.
Apache Kafka 에서의 정확히 1회 시맨틱
0.11.x 버전 이전에는, Apache Kafka 는 적어도 1회 전송 시맨틱과 partition 별 메시지 정렬을 지원했습니다. 위의 예제에서 보듯이, 그 뜻은 producer 의 재전송은 중복된 메시지를 초래할 수 있음을 의미합니다. 새로운 정확히 1회 시맨틱 기능을 위해 우린 3개의 서로 다른 방법으로 Kakfa 의 소프트웨어 처리 시맨틱을 강화시켰습니다.
멱등성 (idempotence) : partition 별 정확히 1회 순서 시맨틱
멱등 작업은 한 번 수행할 떄와의 차이를 걱정할 필요 없이 여러 번 실행할 수 있습니다. 이제부터 producer 의 전송 작업은 멱등적입니다. producer 가 재전송할 수 있는 에러 상황에선 (아직까지도 이 경우에선 producer 가 여러 번 메시지를 보냅니다) 동일한 메시지들은 브로커에 있는 Kafka log 에 한 번만 기록됩니다. 단일 파티션의 경우 멱등적인 producer 의 전송은 producer 나 브로커의 에러에 관계없이 중복 메시지의 가능성을 없앱니다. 이 기능으로 파티션별 정확히 1회 시맨틱 (중복이나 데이터 손실 가능성이 없고, 정렬된 시맨틱) 을 적용시키려면, producer 를 "enable.idempotence=true" 로 설정해 주세요.
이 기능이 어떻게 작동하냐고요? 근본적으로 이 방식은 TCP 와 비슷하게 작동합니다. Kafka 로 보내진 메시지 배치들은 브로커가 중복 발행된 메시지를 중복 제거하기 위한 sequence 번호를 부여받습니다. (짧은 인메모리 연결에 대해서만 보장하는) TCP 와는 다르게, 이 sequence 번호는 replica 들의 로그에 영구적으로 기록됩니다. 따라서 leader 브로커가 실패하더라도, 넘겨받은 새로운 leader 도 중복을 탐지할 수 있습니다. 이 방식의 오버헤드는 생각보다 낮습니다. 단순히 각 배치 메시지에 몇 개의 숫자 필드가 추가되는 것뿐이니까요. 이 기사 뒤에서 보듯이, 멱등적인 producer 에 대해 무시해도 될 만한 퍼포먼스 부하만 걸립니다.
트랜잭션 : 여러 partition 에 대한 원자적 기록
두 번째로, Kafka 는 이제 새로운 트랜잭션 API 로 여러 partition 에 대해 원자적 쓰기를 제공합니다. 이건 producer 가 보낸 배치 메시지가 consumer 들이 모두 볼 수 있거나, 모두 볼 수 없게 되는 것을 뜻합니다. 또한 이 기능은 처리한 메시지들에 대해 consumer offset 을 저장할 때도 동일한 트랜잭션을 사용해 end-to-end 로 정확히 1회 시맨틱을 보장함을 의미합니다. 이 트랜잭션 API 를 사용하는 예제 코드 조각입니다.
위의 코드 조각은 여러 partition 들에 대해 원자적 쓰기를 하기 위해 어떻게 새로운 producer API 를 사용하는지를 설명하고 있습니다. 트랜잭션 메시지의 일부를 한 topic partition 이 가지고 있고, 다른 partition 이 가지지 않고 있을 지도 모른다는 것은 의미가 없습니다 (모두가 가지고 있거나, 그렇지 않거나가 중요하다).
consumer 부분에서, 트랜잭션 메시지들을 읽기 위해선
isolation.level
consumer 설정을 조절하는 두 가지 옵션이 있습니다.read_committed
: 트랜잭션이 아닌 메시지들을 읽는 것에 추가로, 트랜잭션이 커밋된 후에 해당 메시지들을 읽는 옵션입니다.read_uncommitted
: 트랜잭션이 커밋되기 전에 정렬된 offset 에 따라 모든 메시지들을 읽습니다. 현재의 Kafka consumer 시맨틱과 비슷합니다.트랜잭션을 사용하기 위해선, consumer 가 올바른
isolation.level
을 사용하도록 설정하고 새로운 producer API 를 사용해야 하며, producer 에transactional.id
설정을 고유한 id 로 부여해줘야 합니다. 이 고유 id 는 어플리케이션 재시작에 상관없는 트랜잭션 상태를 보장하기 위해 필요합니다.