이전 포스팅 에서 Replication 이 무엇인지, ISR 은 무엇인지 설명했다. 하지만 해당 포스팅만 가지고는 ISR 이 어떤 의미를 갖는 지 명확한 이해가 어려워 추가적으로 공부를 하고 정리를 할 필요를 느꼈다.
In-Sync Replica
ISR 의 존재 의의(?) 에 대해 이해를 하고자 하면, 카프카 프로듀서의 acks 옵션에 대한 이해가 필요하다. 해서, 해당 내용을 먼저 짚고 넘어가보려 한다.
ACKS
acks 는 간단하게 말해 확인의 의미이다. 프로듀서가 메시지를 보내고, 브로커에 잘 전달되었는 지 확인하기 위한 옵션으로 [1, 0, -1(all)] 값으로 설정할 수 있다.
acks = 0 프로듀서가 메시지를 보내고, 브로커에게 메시지 전달이 정상적으로 되었는 지를 확인하지 않는다. 메시지를 보내기만 하기 때문에, 성능 면에서는 세 가지 옵션 중 가장 월등하지만 메시지 전달이 정상적으로 되고 있는 지를 보장하지 않는다. 이 옵션은 메시지 유실보다 메시지 처리량이 더 중요하게 생각될 때 적용할 수 있다.
acks = 1 프로듀서가 메시지를 보내고, 앞선 포스팅에서 설명한 리더에게 메시지가 제대로 전달되었는 지를 확인한다. 리더는 메시지를 받아 로그 파일에 이를 추가하고, 다른 팔로워들이 메시지를 복제하는 것을 기다리지 않고 잘 전달되었다고 프로듀서에게 알린다. 이 옵션은 리더가 메시지를 받은 것을 보장하지만, 복제가 되었는 지를 확인하지 않기 때문에 리더가 메시지를 받고 바로 장애가 발생할 경우 메시지가 유실될 수 있다. 세 옵션 중, 지연 시간 / 처리량 / 안정성 면에서 가장 중도의 길을 걷는 옵션이다.
acks = all (-1) 프로듀서가 메시지를 보내고, 리더와 ISR 팔로워들이 메시지를 모두 전달받았는 지를 확인한다. 리더는 모든 ISR 들이 메시지를 복제했음을 확인한 뒤 프로듀서에게 메시지의 전달이 잘 이루어졌다고 알린다. 이 옵션을 적용하면, 메시지의 전달에 있어 상대적으로 매우 긴 시간이 필요하지만, 다른 옵션들에 비해 월등한 안정성을 제공한다.
시스템에 따라 어떤 옵션을 사용할 지는 차이가 있다. 안정성이 중요한 경우, acks=all 옵션을 사용할 것이고 처리량이 중요한 경우, acks=0 옵션을 사용할 수 있다. 둘 모두 적당히 필요할 경우 acks=1 을 사용할 수 있다. 옵션 중 어떤 것이 더 좋고 나쁨의 개념이 아니기 때문에 옵션에 따른 동작을 잘 이해하고 사용하는 것이 중요할 것 같다.
What is ISR for?
ISR 이 무엇인지는 지난 포스팅에서 설명했으니 넘어가고, ISR 이 대체 왜 필요한 지를 살펴보자. ISR 은 카프카에서 Latency 와 Safety 간 Tradeoff 로서 작용한다. 프로듀서 입장에서 봤을 때, 메시지 손실이 발생하는 것이 시스템에 끔찍한 문제를 가져온다면 acks=all 옵션을 적용해 모든 ISR 들이 메시지를 복제한 것을 확인할 것이다. 근데 이 때, 하나의 복제본이 사라지거나 지연된다면 전체 파티션이 느려지거나 사용할 수 없는 문제로 이어질 수 있다. 결국 ISR 이 존재하는 목적은, Replica 들이 느려지거나 죽은 경우에도 Fault Tolerant 한 시스템을 유지하는 것이다.
ISR 은 이전에 설명했듯, 내부의 팔로워들이 지속적으로 리더의 메시지를 풀링해 동기화를 유지해야 하고 빠른 성능을 제공할 수 있어야 한다. 빠르게 응답이 불가능하다면 해당 팔로워는 ISR 에서 제외될 것이다. 근데 만약, 모든 팔로워들이 느려져서 ISR 내에 리더만 존재하는 경우가 생긴다면 어떻게 될까?
프로듀서는 메시지의 유실을 막기 위해 모든 ISR 이 메시지를 저장한 것을 확인하는데, ISR 내에 리더만 존재하기 때문에 acks=1 옵션과 동일하게 동작할 것이다. 결국 acks=all 옵션을 적용했음에도 메시지 유실에 대한 가능성이 충분히 존재하게 된다.
이를 막기 위한 방안으로, min.insync.replicas 라는 옵션이 존재한다. 이 옵션값이 2로 설정되면, ISR 내에 하나의 replica 만 존재할 경우 전달받는 메시지를 거절한다. 이것이 메시지 유실을 막기 위한 safety 를 위해 존재하는 ISR 의 존재 의의라 볼 수 있다.
Minimum In-Sync Replica
min.insyc.replicas 는 위에서 설명했듯 프로듀서가 정상적으로 메시지를 전달하기 위해 ISR 내의 replica 가 최소 몇 개 존재해야 하는 지를 의미한다. 이 값은 크게 설정할 경우, 그만큼 데이터 안정성이 보장된다고 할 수 있지만 반면에 가용성을 하락시킬 수 있다. 예를 들어 노드가 3개 존재할 때 min.insync.replicas 를 3으로 설정하면, 하나의 노드만 장애가 발생해도 어떤 메시지도 받을 수 없게 된다.
이 옵션은 메시지 처리량에 직접적으로 영향을 주지 않는다. 직접적으로 영향을 주는 옵션은 위에서 본 프로듀서의 acks 옵션이고, 이 옵션은 데이터의 유실을 막기 위한 옵션으로 생각하면 된다.
Kafka 는 Fault Tolerant 를 위해 Replication 을 지원한다. Replication 이 무엇인지 간단하게 말하자면, 메시지를 복제해 관리하고 이를 통해 특정 브로커에 장애가 발생해도 다른 브로커가 해당 브로커의 역할을 대신할 수 있도록 하는 것이다.
Replication Factor
Replication Factor 는 토픽의 파티션의 복제본을 몇 개를 생성할 지에 대한 설정이다. 카프카에서는 Replication Factor 를 임의로 지정해 토픽을 생성할 수 있는데, Factor 를 2 로 설정하면 복제본을 한 개 생성하고 3 으로 설정하면 두 개의 복제본을 생성한다는 의미이다. 이해를 위해 다음의 그림을 통해 설명을 덧붙이겠다.
위의 그림은 topic01 과 topic02 모두 replication factor 를 1로 설정한 경우이다. (여기서 Partition 은 고려하지 않겠다.) 이에 대해 topic01 는 replication.factor 를 2로, topic02 는 3으로 설정한 경우를 다음 그림에서 볼 수 있다.
이와 같이 Replication Factor 를 조정해 replication 의 수를 몇 개로 설정할 지 관리자가 조정할 수 있다. replication 수가 많을수록 브로커 장애 시 토픽에 저장된 데이터의 안전성이 보장되기 때문에 중요 데이터의 경우는 replication factor 를 크게 설정하는 것이 좋다. 물론 replication 이 많아지면 그만큼 데이터의 복제로 인한 성능 하락이 있을 수 있기 때문에 무조건 크게 설정하는 것이 권장되지는 않는다.
Leader & Follower
Rabbit MQ 의 경우 복제본이 2개 존재하는 경우 하나를 Master, 다른 하나를 Mirrored 라 표현하는데 이러한 용어는 어플리케이션마다 상이하다. Kafka 에서는 Leader / Follower 라는 용어를 사용하는데, 위 그림에서 Replication 을 표현했던 것에 Leader, Follower 를 연결해 보자.
topic01 은 두 개의 복제본을 가지고 있어 하나는 Leader / 다른 하나는 Follower 로 구성되고, topic02 는 하나의 Leader / 두 개의 Follower 로 구성된다. 카프카는 Leader 에게 특별한 기능을 부여했는데, 그 기능이란 다음과 같다.
Topic 으로 통하는 모든 데이터의 Read/Write 는 오직 Leader 를 통해서 이루어진다.
위와 같이 기능을 하기 위해, 리더는 항상 정상적으로 동작해야 한다. 하지만 어떻게 그렇게 할 수 있을까? 리더도 똑같은 브로커 중 하나일 뿐인데 장애가 발생하지 않으리란 보장이 있을까?
답을 간단하게 얘기하자면, 리더와 팔로워는 변경될 수 있다. 카프카는 리더가 장애가 발생하면 기존의 팔로워 중 하나가 리더가 될 수 있는 Failover 방식을 채용하고 있다. 자세한 것은 아래의 ISR 에서 이어서 설명하겠다.
ISR (In-Sync Replication)
ISR 이라는 용어는 다소 생소하지만 간단하게 얘기하면 Replication Group 이라 표현할 수 있다. 위에서 토픽 별로 Replication Factor 를 설정해 Replication 을 생성했는데, 각각의 토픽으로 묶인 Replication 들을 ISR 이라 칭한다.
위 그림에서 보는 것과 같이 하나의 ISR 에는 하나의 Leader 와 n 개의 Follower 가 존재한다. ISR 의 규칙은 다음과 같다.
ISR 내의 모든 Follower 들은 Leader 가 될 수 있다.
Broker Down Situation
위의 규칙으로 인해 리더가 장애가 발생할 경우 ISR 내의 팔로워 중 하나가 리더가 되는 것이다. 이 부분에 대해서는 추가적으로 설명이 필요하다. 우선은 장애가 발생했을 때 어떤 일들이 벌어지는 지 그림과 같이 이해해보자.
만약 위와 같이 broker1 이 down 되었다고 하자. topic01 의 리더와 topic02 의 팔로워가 다운되었기 때문에 다음과 같은 변화가 생긴다.
topic01 의 경우, ISR 내에 하나의 팔로워가 존재하고, 위에서 설명한 누구든 ISR 내의 follower 는 leader 가 될 수 있다는 조건을 충족하기에 기존의 팔로워가 새로운 리더가 된다. 이때, 일시적으로 리더가 존재하지 않기 때문에 Read/Write 의 Timeout 이 발생할 수 있지만, Retry 가 일어나면 새로운 리더에 Read/Write 할 수 있으므로 장애 상황은 아니다.
topic02 는 팔로워 하나가 다운되었고, 다운된 팔로워가 ISR 에서 제외된다. topic01 과 다르게 리더는 변하지 않았기 때문에 아무런 특이사항 없이 read/write 가 계속 이루어진다.
추가적으로 브로커 2까지 다운되었다고 가정해보자. topic01 의 경우 ISR 내의 더 이상의 팔로워가 없기 때문에 리더를 넘겨줄 수 없고, 리더가 없기 때문에 read/write 를 지속할 수 없다. 즉, topic01 의 경우는 read/write 가 불가능한 장애 상황이 된 것이다. topic02 의 경우는 ISR 내에 남은 팔로워가 있기 때문에 해당 팔로워가 리더를 이어받아 read/write 를 지속한다.
위의 예제를 통해, 브로커에 장애가 발생한 경우 리더가 어떻게 변경되고, ISR 이 변경되는지 설명했다. 추가적으로 이와 같은 동작을 위해 카프카가 내부적으로 어떤 동작들을 하는 지 알아보자.
먼저 우리가 토픽을 만들고 옵션으로 Replication Factor 를 설정하면, 설정에 맞게 위의 예제와 같이 ISR 이 구성된다. ISR 이 구성되면 리더와 팔로워는 각자 역할이 주어지고 그 역할을 수행하기 시작하는데, 그 역할은 다음과 같다.
Leader : 팔로워를 감시하고 팔로워들 중 자신보다 일정 기간 뒤쳐진 팔로워가 발생하면 해당 팔로워가 리더가 될 자격이 없다고 판단하고, 뒤쳐진 팔로워를 ISR 에서 제외한다. Follower : 리더와 동일한 내용을 유지하기 위해 일정 주기마다 리더로부터 데이터를 가져온다.
위에서 설명한 역할을 그림을 통해 살펴보자. 우선 리더의 입장에서, 리더는 ISR 내의 팔로워들을 감시하고, 뒤쳐진 팔로워를 ISR 에서 제외한다. 추가적으로, 브로커가 다운되는 경우에 팔로워는 리더로부터 데이터를 가져오지 못하고, 이러한 상황이 일정 시간동안 지속되면 리더는 해당 팔로워가 뒤쳐졌기 때문에 팔로워에게 리더를 넘길 수 없다 판단해 해당 팔로워를 ISR 에서 제외시키는 것이다.
반대로, 팔로워 입장에서 보면 ISR 내의 팔로워는 언제든 리더가 될 수 있어야 하기 때문에 리더와 동일한 데이터를 유지하는 것이 매우 중요하다. 따라서 팔로워는 리더를 계속 바라보며 컨슈머들이 카프카에서 데이터를 가져가는 것과 동일한 방법으로 주기적으로 데이터를 풀링한다. 매우 짧은 주기마다 새로운 데이터를 체크하며 리더와 동기화를 하게 된다.
결국 동일한 ISR 내에서 리더와 팔로워는 데이터 정합성을 위해 각자의 방식으로 서로를 계속 체크하며 Fault-Tolerance 한 메시지 시스템을 제공하는 것이다.
All Down Situation
모든 브로커가 다운되는 상황은 거의 발생하지 않지만, 발생할 수 있는 가능성이 아주 조금이라도 존재하기 때문에 이에 대해 인지하고 대응할 방안을 고려하는 것이 필요하다.
그림을 통해 순서대로 상황을 설명하자면 다음과 같다.
프로듀서가 위와 같이 파란색 메시지를 카프카에 보냈고, Leader 와 두 Follower 모두 메시지를 전달받았다. 하지만 메시지가 전달되자마자 브로커3 이 다운되었다.
다음으로 프로듀서가 초록색 메시지를 보냈고, 위와 마찬가지로 동작하는 리더 / 팔로워에게 모두 메시지가 전달되었다. 하지만 브로커2가 메시지를 받고 바로 다운되었다.
세 번째로 프로듀서가 분홍색 메시지를 보냈고, 리더에게만 전달되었다. 하지만 리더가 동작중이던 브로커1이 바로 다운되면서, 결국 모든 브로커가 다운되었다.
이러한 경우에 대응할 수 있는 방법이 두 가지가 있다.
마지막까지 리더였던 브로커1이 다시 Up 되고 리더가 될 때까지 기다린다.
리더 / 팔로워 상관없이 가장 빨리 Up 이 되는 브로커의 파티션이 리더가 된다.
카프카는 기본 설정으로 2번 방법을 사용한다. 동작 방식을 설명하기 위해, 가장 적은 메시지를 보유한 브로커3 이 가장 먼저 Up 되는 상황을 살펴보자.
파란색 메시지만 저장한 브로커3 이 Up 되고, 새로운 리더가 되었다. 새로운 리더가 된 후 프로듀서가 보라색 메시지를 보냈고, 리더에게 잘 전달되었다.
이후 브로커1 / 브로커2 가 모두 Up 되었고, 자동으로 팔로워로 이들이 연결되면서 리더로부터 데이터를 복제한다. 이 과정에서 결과적으로 초록색, 분홍색 메시지는 손실되었다.
위의 예제에서, 카프카의 설정을 변경함으로써 1번 방법으로 대응할 수도 있다. 1번 방법은 모든 브로커가 다운되는 상황에서도 데이터 손실 가능성이 적기 때문에 좋은 대응방안이 될 수 있다. 하지만 여기에는 조건이 붙는데, 마지막 리더인 브로커1 이 반드시 Up 되어야 하고, 또 가장 먼저 Up 되어야 한다. 그렇게 될 경우 거의 손실 없이 모든 데이터의 복구가 가능하다. 이와 같은 장점이 있는 반면, 최악의 경우 브로커1이 기계적 결함으로 Up 이 되지 않거나 Up 되는 데 오랜 시간이 걸릴 경우 장애 상황인 채로 막연히 기다리게 되는 문제가 있다.
위와 같은 문제로 인해, 일반적으로 빠른 장애 대응이 가능한 2번 방법을 적용하고 데이터의 손실을 감안하는 방법이 효율적일 수 있다. 물론 위와 같이 클러스터 전체가 다운되는 경우는 거의 발생하지 않지만, 앞서 언급했듯 가능성이 있다는 것만으로 대응할 준비는 필요하다.
카프카의 컨슈머가 poll() 을 호출할 때마다, 컨슈머 그룹은 카프카에 저장되어 있지만 아직 읽지 않은 메시지들을 가져와 처리한다. 이렇게 할 수 있는 것은 컨슈머 그룹이 카프카의 메시지를 어디까지 읽었는 지를 저장하고 있기 때문인데, 어디까지 읽었는 지를 Offset 이라는 값으로 저장하게 된다. 이전 포스팅 에서 다뤘듯, 카프카에 저장되는 각 레코드들은 파티션 별로 독립적인 Offset 값을 가지게 되고, 컨슈머 그룹이 파티션 별로 마지막으로 읽은 레코드의 Offset 을 저장함으로써 읽은 메시지와 읽지 않은 메시지를 구분한다.
그러면 컨슈머에 어떤 문제가 발생해 기존에 관리하던 오프셋 정보가 날아간다면? 컨슈머들은 파티션 별로 모든 메시지를 다시 읽고 중복 처리해야 할까?
정답부터 말하자면, 그렇지 않다. 카프카의 컨슈머 그룹은 위와 같은 문제가 발생하는 것을 막기 위해 Offset 을 내부적으로 저장하기도 하지만 카프카 내에 별도로 내부적으로 사용하는 토픽을 생성하고, 그 토픽에 오프셋 정보를 저장하게 되어있다.
위와 같이 오프셋 정보를 카프카의 내부 토픽에 저장하는 것을 Commit 이라 하는데, 카프카에서는 커밋과 관련된 여러 방법을 제공한다.
Commit
위에서 언급했듯, 컨슈머는 내부적으로 오프셋 정보를 저장하고 이를 사용해 읽지 않은 메시지들만 파티션에서 읽어올 수 있다. 하지만 어떤 상황에는 내부적으로 존재하는 정보를 사용할 수 없는 경우가 존재하는데, 다음의 경우에 그러한 문제들이 생길 수 있다.
컨슈머가 갑자기 다운될 경우
컨슈머 그룹에 새로운 컨슈머가 조인할 경우
위의 경우에, 컨슈머 그룹은 기존에 컨슈머 별로 할당된 파티션에 문제가 생긴다. 새로 조인한 컨슈머가 파티션을 배정받지 않아 노는 경우가 생기거나, 기존에 파티션을 배정받아 처리하던 컨슈머가 다운됨으로 인해 해당 파티션을 처리할 컨슈머가 존재하지 않게 된다.
컨슈머 그룹은 이를 해결하기 위해 그룹 내에서 컨슈머들의 리밸런싱을 수행하게 되는데, 이 때 컨슈머들이 기존에 배정받은 파티션과 다른 파티션을 할당받게 될 수 있어 내부적으로 저장한 오프셋 정보를 사용할 수 없게 된다.
카프카에서는 이를 위해 내부 토픽에 오프셋 정보를 저장하고, 오프셋 값을 업데이트하는 것을 커밋이라 부른다. 리밸런스를 거치게 되면 컨슈머들이 기존과 다른 파티션을 할당받을 수 있기 때문에 가장 최근에 커밋된 오프셋 값을 가져와 메시지를 다시 읽는 과정을 수행한다.
커밋은 어플리케이션의 설정에 따라 자동으로 오프셋 정보를 커밋하는 방법과 수동으로 오프셋을 커밋하는 방법이 나눠진다.
자동 커밋
컨슈머 어플리케이션들이 기본적으로 사용하는 자동 커밋 방식은, 설정한 주기에 따라 poll() 을 호출할 때 자동으로 마지막 오프셋을 커밋한다.
컨슈머 옵션 중 enable.auto.commit = true 로 설정하면 자동 커밋 방식을 사용해 오프셋을 관리할 수 있다. 커밋을 하기 위한 주기는 기본적으로는 5초마다 커밋을 하게 되고, 이 주기 또한 auto.commit.interval.ms 값을 조정해 변경이 가능하다.
자동으로 커밋을 하는 방식은 오프셋 관리를 자동으로 하기 때문에 편리한 장점이 있는 반면, 리밸런싱으로 인한 문제가 생길 수 있다는 점을 염두해야 한다.
예를 들어, 5초마다 자동으로 커밋을 하게 어플리케이션을 설정했다고 하자. 매 5초마다 커밋이 발생할텐데 커밋이 발생하고 3초 뒤에 리밸런싱이 이루어진다면 어떻게 될까? 3초동안 처리한 메시지들에 대해서는 커밋이 이루어지지 않아 리밸런싱이 수행된 후 다른 컨슈머에 의해 다시 메시지를 읽고 중복으로 처리하게 될 수 있다.
자동 커밋의 주기를 줄인다고 해도, 중복이 발생하는 메시지의 개수를 줄일 뿐 중복 자체를 없앨 수는 없다. 그러면 이 문제를 해결하기 위해 고려해야 하는 것은 어떤 것이 있을까? 카프카는 이와 같이 메시지의 중복 수신이 발생할 수 있기 때문에 메시지 처리에 있어 멱등성을 생각해야 한다. 중복으로 메시지를 처리하더라도 결과에 변동이 되지 않도록 어플리케이션의 설계를 잘 해야 한다.
수동 커밋
수동 커밋은 자동 커밋과 다르게, 메시지 처리가 완벽하게 이루어지지 않으면 메시지를 읽지 않은 것으로 간주해야 할 경우 사용된다.
예를 들어, 컨슈머가 메시지를 가져와 DB 에 저장하는 상황이라 가정하자. 만약 자동 커밋을 사용한다면, DB 에 저장하는 로직이 실패할 경우에도 메시지의 오프셋은 커밋되었기 때문에 메시지의 손실이 발생할 수 있다. 이러한 경우를 방지하기 위해서, DB 에 저장되는 로직이 성공한 것을 확인한 후 메시지의 오프셋을 커밋해 메시지가 손실되지 않고 처리될 수 있도록 해야 한다.
일반적으로는 위와 같이 메시지 처리의 결과에 따라 오프셋 커밋을 결정할 때 사용하는 것이 수동 커밋인데, 이 또한 자동 커밋 방식과 마찬가지로 메시지의 중복이 발생할 수 있다. 위와 같은 DB 저장 오류 시, 해당 메시지부터 다시 읽어와 처리를 수행하게 될텐데 해당 메시지 이후의 메시지들은 정상적으로 DB 에 저장되었다고 하자. 그러면 결국 오류가 발생한 메시지 외의 다른 메시지들은 중복으로 DB 에 저장될 것이다.
결국 어떤 커밋 방식을 사용한다 하더라도 메시지가 중복되는 것은 피할 수 없는 문제이다. 이는 카프카에서 기본적으로 Exactly-Once 방식이 아니라 At-Least-Once 방식을 채용하고 있기 때문인데, 중복은 발생할 수 있지만 손실은 발생하지 않는다는 원칙이다.
카프카에서도 Exactly-Once 를 사용할 수 있는 방식은 있지만 기본적으로는, 메시지의 중복을 처리하는 것은 결국 카프카를 사용하는 개발자의 몫이고 이 문제의 해결을 위해 많은 고민을 필요로 한다.
이 포스팅의 주요 목적은 카프카에서 핵심적인 역할을 하는 파티션에 대해 공부하는 것이다. 파티션에 대해 이해를 해야 카프카의 구조나 동작에 대해 빠르게 이해할 수 있고, 올바르게 사용할 수 있다.
Events, Streams, and Topics
파티션에 대해 공부하기 전에, 카프카에서 사용되는 몇 가지 개념과 그것들이 파티션과 어떻게 연관이 있는지를 알아보자.
Events
이벤트란, 과거에 일어난 사실을 뜻한다. 이벤트는 발생함으로 인해 변화된 상태를 가지고 시스템 사이를 오가는, 불변하는 데이터이다.
Streams
이벤트 스트림이란, 관련된 이벤트들을 뜻한다.
Topics
이벤트 스트림이 카프카에서는 토픽이란 이름으로 저장된다. 카프카의 세계에서는 토픽이 구체화된 이벤트 스트림을 뜻한다. 토픽은 연관된 이벤트들을 묶어 영속화하는데, 이는 데이터베이스의 테이블이나 파일 시스템의 폴더들에 비유할 수 있다.
토픽은 카프카에서 Producer 와 Consumer 를 분리하는 중요한 컨셉이다. Producer 는 카프카의 토픽에 메시지를 저장 (Push) 하고 Consumer 는 저장된 메시지를 읽어 (Pull) 온다. 하나의 토픽에 여러 Producer / Consumer 가 존재할 수 있다.
위의 개념들은 아래의 그림으로도 설명이 가능하다. Event 가 관련된 것들끼리 모여 Stream 을 이루게 되고, 이것이 카프카에 저장될 때 Topic 의 이름으로 저장된다.
Partitions
이제 본격적으로, 파티션에 대한 내용을 다루려 한다. 위에서 설명한 카프카의 토픽들은 여러 파티션으로 나눠진다. 토픽이 카프카에서 일종의 논리적인 개념이라면, 파티션은 토픽에 속한 레코드를 실제 저장소에 저장하는 가장 작은 단위이다. 각각의 파티션은 Append-Only 방식으로 기록되는 하나의 로그 파일이다.
참고한 포스팅에서 Record 와 Message 는 위의 그림에서 표현하는 것과 같이 파티션 내에 저장되는 메시지라는 의미로 사용되었다.
Offsets and the ordering of messages
파티션의 레코드는 각각이 Offset 이라 불리는, 파티션 내에서 고유한 레코드의 순서를 의미하는 식별자 정보를 가진다. 하지만 카프카는 일반적으로 메시지의 순서를 보장하지 않는다. 위에서 각각의 파티션은 Append-Only 방식으로 레코드를 기록한다 했고, 내부적으로 Offset 이라는 정보를 사용하는데 왜 메시지의 순서가 보장되지 않는 것일까? 자세한 내용은 아래의 내용을 보면 이해할 수 있고, 간단하게는 파티션 간에는 순서가 보장되지 않으므로 결국 메시지의 순서가 보장되지 않는다 할 수 있다.
Offset 정보는 카프카에 의해서 관리되고, 값이 계속 증가하며 불변하는 숫자 정보이다. 레코드가 파티션에 쓰여질 때 항상 기록의 맨 뒤에 쓰여지고, 다음 순서의 Offset 값을 갖게 된다. 오프셋은 파티션에서 레코드를 읽고 사용하는 Consumer 에게 특히 유용한데, 자세한 내용은 뒤에서 설명하겠다.
아래의 그림은 세 개의 파티션을 갖는 토픽을 보여준다. 레코드는 각 파티션의 마지막에 추가된다. 아래에서 볼 수 있듯, 메시지는 파티션 내에서는 유의미한 순서를 가지고 이를 보장하지만, 파티션 간에는 보장되지 않는다.
Partitions are the way that Kafka provides Scalability
카프카의 클러스터는 브로커라 불리는 하나 혹은 그 이상의 서버들로 이루어지는데, 각각의 브로커는 전체 클러스터에 속한 레코드의 서브셋을 가진다.
카프카는 토픽의 파티션들을 여러 브로커에 배포하는데 이로 인해 얻을 수 있는 다음과 같은 이점들이 있다.
만약 카프카가 한 토픽의 모든 파티션을 하나의 브로커에 넣을 경우, 해당 토픽의 확장성은 브로커의 I/O 처리량에 의해 제약된다. 파티션들을 여러 브로커에 나눔으로써, 하나의 토픽은 수평적으로 확장될 수 있고 이로 인해 브로커의 처리 능력보다 더 큰 성능을 제공할 수 있게 된다.
토픽은 여러 Consumer 에 의해 동시에 처리될 수 있다. 단일 브로커에서 모든 파티션을 제공하면 지원할 수 있는 Consumer 수가 제약되는데, 여러 브로커에서 파티션을 나누어 제공함으로써 더 많은 Consumer 들이 동시에 토픽의 메시지를 처리할 수 있게 된다.
동일한 Consumer 의 여러 인스턴스가 서로 다른 브로커에 있는 파티션에 접속함으로써 매우 높은 메시지 처리량을 가능케 한다. 각 Consumer 인스턴스는 하나의 파티션에서 메시지를 제공받고, 각각의 레코드는 명확한 처리 담당자가 존재함을 보장할 수 있다.
Partitions are the way that Kafka provides Redundancy
카프카는 여러 브로커에 동일 파티션의 복사본을 두 개 이상 유지한다. 이러한 복사본을 Replica 라 하는데, 브로커에 장애가 발생했을 때에도 장애가 발생한 브로커가 소유한 파티션의 복사본을 통해 컨슈머에게 지속적으로 메시지를 제공할 수 있게 한다.
파티션 복제는 다소 복잡해, 지금은 파티션에 대한 정리에 집중하고 다음 포스팅에서 Replica 를 주제로 다시 정리하려 한다.
Writing Records to Partitions
위에서 파티션이 무엇인지, 어떤 방식으로 사용되고 어떤 장점을 갖는지를 정리해 봤다. 그러면 파티션에 레코드를 어떻게 작성하고 어떻게 읽을까? 우선 레코드의 작성법부터 살펴보자. Producer 는 각각의 레코드가 어떤 파티션에 쓰여질 것인지를 어떻게 결정할까? 다음과 같이 세 가지의 방법이 존재한다.
Using a Partition Key
Producer 는 파티션 키를 사용해서 특정한 파티션에 메시지를 전달할 수 있다. 파티션 키는 Application Context 에서 파생될 수 있는 어떤 값이든 될 수 있다. 고유한 장비의 ID 나 사용자의 ID 는 좋은 파티션 키를 만들 수 있다.
기본적으로 파티션 키는 해싱 함수를 통해 전달되고 해싱 함수는 동일한 키를 갖는 모든 레코드들이 동일한 파티션에 도착하는 것을 보장한다. 파티션 키를 지정할 경우 다음 그림과 같이 관련된 이벤트를 동일한 파티션에 유지함으로써 전달된 순서가 그대로 유지되는 것을 보장할 수 있다.
파티션 키를 사용한 방법이 위와 같이 발생한 이벤트의 순서를 보장할 수 있다는 장점을 가진 반면, 키가 제대로 분산되지 않는 경우 특정 브로커만 메시지를 받는 단점도 존재한다.
예를 들어, 고객의 ID 가 파티션 키로 사용되고 어떤 고객이 트래픽의 90% 를 생성한다 하자. 이 경우, 대부분의 어플리케이션 수행 동안 하나의 파티션에서 90% 의 트래픽을 감당하게 된다. 전체 트래픽이 작은 경우는 이를 무시할 수 있지만, 만약 트래픽이 많은 경우에 이와 같은 상황이 발생한다면 이는 종종 브로커의 장애로 이어질 수 있다.
이런 이유로, 파티션 키를 사용하고자 한다면 이들이 잘 분산되는 지를 확인할 필요가 있다.
Allowing Kafka to decide the partition
Producer 가 레코드를 생성할 때 파티션 키를 명시하지 않으면, Kafka 는 Round-Robin 방식을 사용해 파티션을 배정한다. 이러한 레코드들은 해당 토픽의 파티션들에 고루 분배된다.
그러나, 파티션 키가 사용되지 않을 경우 레코드들의 순서는 보장되지 않는다. 위에서 파티션 키를 사용할 경우와 반대로, 순서가 보장되지 않는 대신 파티션의 분배는 고루 이루어진다.
Writing a customer partitioner
특정 상황에서는, Producer 가 서로 다른 비즈니스 규칙을 사용한 자체 파티셔너 구현을 사용할 수도 있다.
파티션 분배를 위해 어떤 전략을 사용하는 것이 좋을 지는 항상 명확하지 않다. 메시지 간 순서가 반드시 보장되어야 할 수도 있고, 순서와 관계없이 이벤트의 처리가 가능할 수 있다.
무엇이 좋고 나쁘다의 개념보다는, 각각의 방법이 이러한 장단점이 있으니 상황에 따라 유의해 사용하자. 로 생각을 하면 좋을 것 같다.
Reading records from partitions
Producer 가 레코드 생성할 때 어떤 파티션에 이를 저장할 지를 위에서 봤으니, 이제는 Consumer 가 파티션에서 레코드를 어떻게 읽는 지를 알아볼 차례이다.
보통의 pub/sub 모델과는 달리, Kafka 는 메시지를 Consumer 에 전달 (push) 하지 않는다. 대신, Consumer 가 카프카의 파티션으로부터 메시지를 읽어 (pull) 가야 한다. 컨슈머는 브로커의 파티션과 연결해, 메시지들이 쓰여진 순서대로 메시지를 읽는다.
메시지의 Offset 은 Consumer 측의 커서와 같이 동작한다. Consumer 는 메시지의 오프셋을 추적하고 이를 통해 이미 소비한 메시지를 저장한다. 메시지를 읽고 난 뒤 Consumer 는 파티션의 다음 Offset 으로 커서를 이동하고, 다시 메시지를 읽고, .. 이 과정을 계속 반복한다.
각 파티션에서 마지막으로 소비된 메시지의 Offset 을 기억함으로써 Consumer 는 어떤 시점에 파티션의 어떤 위치에서 재시작할 수 있다. 이를 통해 Consumer 는 장애를 복구한 뒤 메시지 소비를 재시작할 수 있다.
파티션은 하나 혹은 그 이상의 Consumer 들로부터 소비될 수 있고, 각각은 서로 다른 Offset 을 가지고 메시지를 읽을 수 있다. 카프카는 Consumer Group 이라는 개념을 사용하는데, 이는 어떤 토픽을 소비하는 Consumer 들을 그룹으로 묶은 것이다. 동일한 Consumer Group 에 있는 Consumer 들은 동일한 Group-Id 를 부여받는다. 파티션 내의 메시지는 그룹 내의 Consumer 중 하나에 의해서만 소비되는 것을 보장한다.
아래의 그림은 Consumer 그룹 내에서 파티션들이 어떻게 소비되는 지를 보여준다.
Consumer Group 은 Consumer 들이 병렬적으로 메시지를 소비할 수 있도록 한다. 병렬 처리를 가능케 함으로써 처리량은 매우 높아지지만, 그룹의 Maximum Parallelism 은 토픽의 파티션 개수와 동일하다.
예를 들어, N 개의 파티션이 존재하는 토픽에 N + 1 개의 Consumer 가 있는 경우, N 개의 Consumer 들은 파티션을 각각 배정받지만 남은 Consumer 는 다른 Consumer 가 장애가 발생하지 않는 한, Idle 상태로 파티션에 배정되기를 기다리게 된다. 이는 Hot Failover 를 구현하는 좋은 전략이 될 수 있다.
아래의 그림은 위의 예시를 표현한 것이다.
여기서 중요한 것은, Consumer 의 개수가 토픽의 병렬성 정도를 결정하지 않고, 결정하는 것은 파티션의 개수라는 것이다. 하지만 파티션을 늘리는 것이 항상 장점으로 작용하지는 않는다. 파티션의 개수에 따른 이점과 문제점은 추후에 다시 정리해보도록 하겠다.