caffeine-library / system-design-interview

🌱 가상 면접 사례로 배우는 대규모 시스템 설계 기초를 읽는 스터디
4 stars 0 forks source link

[additional] You Cannot Have Exactly-Once Delivery #32

Closed binchoo closed 2 years ago

binchoo commented 2 years ago

연관 챕터

30

조사 내용

@caffeine-library/readers-system-design-interview

binchoo commented 2 years ago

딜리버리 시멘틱

at-most-once

한 번만 보내고 신경 끌래요

at-most-once 시멘틱을 사용하는 생산자는, 메시지를 오직 한 번만 전송하고 싶어하며, 해당 메시지의 처리가 실패하더라도 신경쓰지 않는다.

image

소비자에 메시지가 도착한 직후 ACK가 발생하며, 이 ACK 수신에 실패했다고 해서 재전송은 이뤄지지 않는다.

상용 시스템은 at-most-once 시멘틱에서도 ACK가 존재할 수 있다. 다중화된 큐나 소비자가 대상일 때, 이미 ACK한 노드에게 동일 메시지를 전송하지 않도록 조정하는 데 쓰일 수 있다.

at-least-once

알아먹을 때까지 수차례 보내드릴게요

at-least-once 시멘틱을 사용하는 생산자는, 메시지 누락을 원치 않기 때문에 메시지를 여러 번 전송할 수 있다. 소비자로 메시지가 수신 & 처리까지 완료되면 ACK가 발생한다. ACK 수신에 실패한 생산자는 메시지를 재전송한다.

ACK 자체가 유실되는 경우가 있기 때문에, 이 시멘틱은 중복 메시지 발생을 허용하게 된다.

exactly-once

한 번만 보낼테니 알아들으세요

생산자는 단 한번 메시지를 전송한다. 메시지는 유실없이 소비자에게 도달하고, 오류 없이 처리된다. ACK 역시 유실없이 생산자에 도달해야 한다.

binchoo commented 2 years ago

Exactly-Once은 불가능하다

하나의 faulty 프로세스에 의해 분산 프로세스들이 합의를 이룰 수 없음.

CAP이론에서 C와 A는 동시에 달성할 수 없다고 주장함.

위 사실을 메시징 시스템에 빗대어 보면, 생산자와 소비자와 큐가 '메시지 수신과 처리가 정상적이었음'을 수긍하는 시스템은 불가능하다.

하지만 상용 시스템은 exactly-once 시멘틱을 지원하는데 사실은 멱등성을 가진 메시징 설계나, deduplication을 통해 실패 상황을 '완화'하는 것이다.

멱등성을 위한 관점 갖기

노드가 메시지를 중복 수신해도 상태가 변하지 않도록 하는 설계가 필요하다.

Deduplication

노드가 중복된 메시지를 발견하여 의도적으로 무시하면 된다.

알림 시스템이 중복 알림을 만들어 낸 이유

at-most-once 시멘틱은 알림이 누락되는 Unreliable 시스템을 만듭니다.

at-least-once 시멘틱은 언젠가 ACK가 누락되면서 알림 서버가 다시 메시지를 전송하게 되므로, 고객은 방금 수신한 알림을 다시 얻게 됩니다.

exactly-once는 알림 서버 혹은 작업 서버에서 과거 기록(DB따위)을 조회하여 중복된 알림 생산을 차단합니다. 하지만 DB쪽 Failure가 히스토리를 누락할 수 있으니, 여기서 알림 중복의 가능성이 생깁니다.

결국 책에서의 "알림의 중복 수신은 피할 수 없다"는 것이 팩트네요.

JasonYoo1995 commented 2 years ago

본 교재 P.175~176에 다음과 같은 내용이 있습니다.

같은 알림이 여러 번 반복되는 것을 완전히 막는 것은 가능하지 않다. 대부분의 경우 알림은 딱 한 번만 전송되겠지만, 분산 시스템의 특성상 가끔은 같은 알림이 중복되어 전송되기도 할 것이다. 그 빈도를 줄이려면 중복을 탐지하는 메커니즘을 도입하고, 오류를 신중하게 처리해야 한다. ... 중복 전송을 100% 방지하는 것이 왜 불가능한지 알고 싶다면 각주를 참고하기 바란다.

따라서, 본 글에서는 왜 중복 전송을 방지할 수 없는지를 다루고자 합니다.

본론에 앞서, 사전에 알아야 할 2가지 개념을 먼저 소개해드리겠습니다. 첫번째는 멱등성(idempotence)이고, 두번째는 카프카의 오프셋, 커밋, 폴링(Offset, Polling, Commit)입니다.

멱등성(idempotence)

HTTP 통신의 메서드인 POST와 PUT에 대하여, 우리가 알고 있는 정의는 다음과 같습니다.

예를 들어, POST는 /order 라는 URL로 요청할 수 있고 PUT은 /order/{id} 라는 URL로 요청할 수 있습니다.

여기서 한 가지 중요한 특징이 있는데, POST는 똑같은 URL를 여러 번 요청하면 요청한 횟수에 따라 그 결과가 달라집니다. 반면에 PUT는 똑같은 URL를 여러 번 요청해도 그 결과가 동일합니다.

예를 들어, POST로 /order를 세 번 요청하면 /order/1와 /order/2와 /order/3의 위치에 새로운 자원이 생성됩니다. 반면에 PUT으로 /order/2를 여러 번 요청하면, ID가 2인 order 자원의 내용은, 한 번 요청했을 때와 세 번 요청했을 때 둘 다 결과가 동일합니다.

이때, PUT과 같이 똑같은 연산을 여러 번 수행해도 그 결과가 같은 연산을 idempotent한 연산이라고 합니다. (POST는 idempotent하지 않습니다.)

따라서, 실무에서 POST API를 구현할 때 의도치 않은 중복 요청이 들어오면, 중복 처리되지 않도록 하는 장치를 만드는 것은 매우 중요합니다.

Kafka에서 Offset, Polling, Commit

카프카는 대표적인 메시지 큐들 중 하나입니다. 카프카의 구성 요소는, 크게 Kafka Client와 Kafka Broker로 나눌 수 있는데, Kafka Broker는 메시지 큐 자체를 의미하고 Kafka Client는 Broker에 메시지를 삽입하는 Producer와 Broker로부터 메시지를 읽어오는 Consumer로 나눌 수 있습니다.

Kafka Broker에 메시지가 쌓이면, 쌓인 순서에 따라 Index를 부여할 수 있고, 이를 Offset이라고 합니다. Broker에 쌓인 메시지는 여러 개의 Consumer에 의해서 읽힐 수 있는데, 보통 여러 개를 한꺼번에 읽어오게 됩니다. 이렇게 Consumer가 Broker로부터 메시지를 읽어오는 것을 Polling이라고 합니다.

그리고 각 Consumer가 몇 번째 Offset까지 읽었는지에 대한 정보는 Broker에서 관리를 하는데, Consumer가 정상적으로 메시지를 받았다고 간주하여 현재까지 읽은 Offset 정보를 Broker에 Update하는 행위를 Commit이라고 합니다. Commit을 하는 이유는, Polling할 때마다 이어서 Polling을 시작할 Offset을 알아야 하기 때문입니다.

Consumer는 아래 예시 코드와 같이 while 루프 안에서 Polling 후 Commit하는 행위를 반복합니다.

Consumer Code

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props); // Consumer 생성
while (true) { // 반복
ConsumerRecords<String, String> records = consumer.poll(100); // 폴링
for (ConsumerRecord<String, String> record : records) 
System.out.println(record.value()); // 메시지 처리 (메시지를 가공하여 DB에 저장하거나
// 다른 곳에 전송하는 등 다양한 로직으로 대체 가능)
consumer.commitAsync(); // 커밋
}

3가지 종류의 delivery

이제 2가지 개념을 이해했으니, 원래의 얘기로 돌아오겠습니다. 책에서 참고하라고 했던 각주의 링크에는 다음과 같은 3가지 용어가 등장합니다.

1. at-most-once delivery

2. at-least-once delivery

3. exactly-once delivery

세 용어 모두 실제 몇 번 전송했느냐가 아닌, 결과적으로 Receiver가 전달 받은 횟수를 기준으로 delivery의 종류가 결정됩니다.

중복 전송의 불가피성 (Receiver가 전달의 주체인 경우)

중복 전송이 발생하는 사례들을 통해, 왜 중복 전송이 불가피한지 이해해보겠습니다. 이해를 돕기 위해, 위에서 제시했던 코드와 그림을 다시 가져왔습니다.

Consumer Code

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props); // Consumer 생성
while (true) { // 반복
ConsumerRecords<String, String> records = consumer.poll(100); // 폴링
for (ConsumerRecord<String, String> record : records) 
System.out.println(record.value()); // 메시지 처리 (메시지를 가공하여 DB에 저장하거나
// 다른 곳에 전송하는 등 다양한 로직으로 대체 가능)
consumer.commitAsync(); // 커밋
}

Consumer가 Polling하는 시점과 Commit하는 시점 사이에, 에러가 발생하거나 Crash가 발생하면 Consumer를 다시 복구 시키고, 다시 Polling을 하게 됩니다. 이때 Broker에 저장된 Offset부터 다시 Polling을 하기 때문에 복구 전에 전달 받았던, Data를 복구 후에 중복해서 전달 받게 됩니다.

중복 전송의 불가피성 (Sender가 전달의 주체인 경우)

방금의 사례는 Receiver가 주체적으로 Data를 가져올 때 중복 전달이 발생하는 경우였습니다. 이번엔 반대로 Sender가 주체적으로 Data를 전달해주는 경우도 따져보겠습니다.

Sender가 Data를 전달하던 도중, 네트워크 상에서 Data가 유실되거나 Receiver가 죽는다면 Sender는 Timeout 메커니즘을 통해 Data를 재전송할 수 있을 것입니다. 다시 말해 Ack을 정해진 시간 내에 받아야 성공적으로 전달되었다고 판단하고 Ack을 정해진 시간 내에 받지 못한다면 Timeout으로 판단하여 재전송하게 됩니다.

그런데 문제는 Sender가 아직 Ack을 받지 않은 상태에서 Receiver가 죽어서 Ack이 유실된다면 Receiver가 다시 살아났을 때 재전송을 시도할 것이고, 따라서 중복 전송이 발생하게 됩니다.

중복 제거 방법 (Deduplication)

2가지만 사례만으로 중복 전송을 방지하는 것은 불가능하다는 것을 완전히 증명할 수는 없겠지만 더 깊이 들어가긴 어려우므로, 그냥 불가능하다고 결론 짓고 넘어가겠습니다.

이번에는 중복 전송을 불가피하게 받았을 때, Receiver가 중복 처리를 방지하는 방법들을 알아보겠습니다. 즉, Receiver가 실제로는 여러 번 전달 받지만, 겉으로 볼 때는 마치 한 번 전달 받은 것처럼 보이게 해서(=Idempotent하게 만들어서) 내부 처리에 문제가 발생하지 않도록 예방하는 방법을 소개해드리겠습니다.

주어진 요구사항은, Consumer가 Polling한 Data를 RDB에 Insert한다는 것이라고 가정하겠습니다.

중복 해결 방법은, Producer가 메시지에 ID를 담아서 Broker를 거쳐 Consumer에 전달합니다. 그 ID을 RDB의 PK로 저장하면, 중복 발생 시 DB 단에서 무결성 예외가 발생하여 Application(=Consumer) 단으로 예외가 던져지고, App은 던져진 예외를 잡아서 후속 조치를 취하면 됩니다.

Consumer Code

try {
dao.insert(log);
} catch (DataIntegrityViolationException ex) {
logger.error("Log insert error. {}", log, ex); // 후속 조치
}

이것 외에도 다양한 중복 원인에 대하여 다양한 중복 해결 방안이 있습니다만, 분량상 생략하겠습니다.

Idempotence 여부에 따른 구현 방법

결론

책에서 말하는 중복 전송을 방지할 수 없다는 말은 exactly-once delivery처럼 보이게 할 수는 있지만 실제로 exactly-once delivery가 이루어지는 것은 불가능하다는 말과 같습니다.

책 P.179에서 설계한 알림 시스템에서의 Deduplication적용

위 설계도 알고 보면, 제가 지금까지 설명드린 사례들과 굉장히 비슷한 케이스입니다. 'iOS 푸시 알림 큐'는 Kafka에 대응되고 '알림 서버'는 Producer에 대응되고 '작업 서버'는 Consumer에 대응되고 '알림 로그'는 RDB에 대응됩니다.

이때, 알림 메시지가 exactly-once하게 전달되는 것처럼 보이게 하려면 메시지 유실을 방지하기 위해 at-least-once delivery로 구현해야 하고 (=재전송 메커니즘) (알림 메시지는 Idempotent하지 않으므로) Deduplication도 해줘야 합니다. Deduplication은 '알림 로그' DB의 PK와 '작업 서버'의 예외 처리 로직을 통해 수행할 수 있습니다.

참고


@caffeine-library/readers-system-design-interview