메시지 순서 지정

Pub/Sub는 가용성이 높고 확장성이 뛰어난 메시지 전송 서비스를 제공합니다. 이러한 특성을 구현하는 대가로, 구독자가 메시지를 수신하는 순서는 보장되지 않습니다. 순서 지정이 보장되지 않는다는 점은 번거로울 수 있지만, 실제로 엄격한 순서를 요구하는 사용 사례는 극히 적습니다.

이 문서에는 메시지 순서에 대한 개요와 장단점이 포함되어 있으며, Pub/Sub로 이동할 때 현재 워크플로의 순서에 따른 메시징을 처리하는 방법과 사용 사례에 대한 설명이 포함되어 있습니다.

순서란 무엇인가요?

순서대로 정렬된 메시지라는 개념은 표면상 아주 간단해 보입니다. 다음 이미지는 메시지 전송 서비스를 통해 이동하는 메시지를 조감도로 나타낸 것입니다.

어떤 순서?

게시자가 주제에 관한 메시지를 Pub/Sub로 전송합니다. 메시지는 구독을 통해 구독자에게 전송됩니다. 동기식 전송 계층에서 작동하는 동기식 게시자, 동기식 구독자, 동기식 메시지 전달 서버가 각각 하나씩 있다면, 순서라는 개념은 아주 단순합니다. 메시지는 게시자가 절대적인 시간이나 시퀀스를 통해 게시에 성공하는 순간 순서가 지정됩니다.

이렇게 단순한 사례에서도 메시지 순서를 보장하면 처리량에 다양한 제한이 적용됩니다. 메시지 순서를 진정으로 보장하는 유일한 방법은 메시지 전달 서비스가 구독자에게 메시지를 한 번에 하나씩 전달하고, 구독자가 현재 메시지를 수신하고 처리했음을 서비스가 (일반적으로 구독자가 서비스에 전달하는 확인을 통해) 확인하기 전까지 다음 메시지 전달을 대기하는 것입니다. 구독자에게 메시지를 한 번에 하나씩 전송하면 처리량을 확장할 수 없지만, 특정 메시지의 최초 전달은 순서대로 진행할 수 있어, 언제든 재전달을 시도할 수 있습니다. 이렇게 할 경우 구독자에게 많은 메시지를 한 번에 전송할 수 있습니다. 하지만 이 방법으로 순서 제한을 완화하더라도, 단일 게시자/메시지 전달 서비스/구독자 사례에서 벗어나는 순간 '순서'는 의미가 없어집니다.

순서가 꼭 있어야 하나요?

메시지 순서 정의는 게시자와 구독자에 따라 복잡한 작업이 될 수 있습니다. 무엇보다도 하나의 구독에서 여러 구독자가 메시지를 처리할 수도 있습니다.

여러 구독자

이 경우 메시지가 순서대로 구독으로 들어오더라도, 구독자가 메시지를 처리하는 순서는 보장할 수 없습니다. 처리 순서가 중요하다면 구독자는 Cloud FirestoreCloud SQL 같은 일부 ACID 스토리지 시스템을 통해 조정해야 합니다.

또한 같은 주제에 여러 게시자가 존재해도 순서 지정이 어려워집니다.

여러 게시자

서로 다른 게시자가 게시한 메시지의 순서를 어떻게 할당할 수 있을까요? 게시자 본인들이 조율하거나, 메시지 전달 서비스가 들어오는 모든 메시지에 순서라는 개념을 직접 적용해야 합니다. 각 메시지는 순서 정보를 포함해야 합니다. 타임스탬프(클럭 드리프트를 예방하려면 모든 서버가 같은 출처에서 획득한 타임스탬프여야 함) 또는 (단일 출처에서 ACID 보장과 함께 획득한) 시퀀스 번호가 순서 정보가 될 수 있습니다. 메시지 순서를 보장하는 다른 메시지 시스템의 경우에는 여러 게시자가 단일 서버를 통해 단일 구독자에게 메시지를 전송하도록 시스템을 효과적으로 제한하는 설정을 요구합니다.

확장된 메시지 전달 서비스 노드

위의 예시에서 사용한 추상적인 메시지 전송 서비스가 단일 동기식 서버라면 서비스 자체는 순서를 보장할 수 있습니다. 하지만 Pub/Sub 같은 메시지 전송 서비스는 서버 역할의 측면에서도 서버 수의 측면에서도 단일한 서버가 아닙니다. 사실 게시자와 구독자, 그리고 Pub/Sub 시스템 간에는 여러 레이어가 존재합니다. 다음은 Pub/Sub가 메시지 전송 시스템일 때 발생하는 일을 자세히 설명한 도표입니다.

메시지 전송

도표에서처럼 단일 메시지는 여러 경로를 통해 게시자에서 구독자로 이동합니다. 이러한 구조의 장점은 높은 가용성(개별 서버가 작동하지 않아도 시스템 전체가 지연되지 않음)과 높은 확장성(메시지를 여러 서버에 분산해 처리량 극대화)입니다. 이와 같은 분산 시스템의 장점은 검색, 광고, Gmail과 같이 Pub/Sub를 실행하는 동일한 시스템을 기반으로 구축된 Google 제품에 중요한 역할을 했습니다.

순서는 어떻게 처리해야 하나요?

지금쯤이면 메시지 순서 지정이 상당히 복잡한 작업인 이유와 Pub/Sub가 순서 지정의 필요성을 강조하지 않는 이유가 이해되셨을 것입니다. 가용성과 확장성을 확보하려면 순서에 대한 의존을 최소화해야 합니다. 순서에 대한 의존은 다양한 양식으로 나타나는데, 아래에서 각 양식에 관한 설명과 함께 일반적인 사용 사례 및 해결책을 확인하실 수 있습니다.

순서가 전혀 중요하지 않음

일반적인 사용 사례: 독립 작업 큐, 이벤트 관련 통계 수집

Pub/Sub에는 순서가 전혀 중요하지 않은 사용 사례가 가장 적합합니다. 예를 들어 구독자가 처리해야 하는 독립 작업이 있다면 각 작업은 메시지가 되며, 해당 메시지를 수신하는 구독자가 그 작업을 처리하게 됩니다. 또한 서버에서 클라이언트가 처리한 모든 작업에 관한 통계를 수집하고 싶다면 각 이벤트의 메시지를 게시한 다음 구독자가 메시지를 수집하고 결과를 영구 스토리지에 업데이트하게 하면 됩니다.

최종 결과의 순서가 중요함

일반적인 사용 사례: 로그, 상태 업데이트

이 범주에 해당하는 사용 사례의 경우, 메시지를 처리하는 순서는 중요하지 않습니다. 최종 결과의 순서를 올바르게 지정하기만 하면 됩니다. 예를 들어 처리 후 디스크에 저장되는 대조된 로그를 생각해 보겠습니다. 로그 이벤트는 여러 게시자가 전송합니다. 이 경우 로그 이벤트가 처리되는 실제 순서는 중요하지 않습니다. 중요한 것은 최종 결과를 시간 기준으로 정렬하여 액세스할 수 있는가입니다. 따라서 사용자는 타임스탬프를 게시자의 모든 이벤트에 연결하고, 구독자가 정렬된 타임스탬프를 이용한 저장이나 검색을 허용하는 기본 데이터 스토리지(Cloud Firestore 등)에 메시지를 저장하게 해야 합니다.

최신 상태에만 액세스하면 되는 상태 업데이트에도 같은 방법이 적용됩니다. 예를 들어 다양한 재고의 현재 가격을 계속 추적하지만 이전 기록은 상관이 없고 최신 가격만 중요한 상황을 가정해 보겠습니다. 사용자는 타임스탬프를 각 재고 표시기에 연결하고 현재 저장된 가격보다 최근의 가격만 저장하면 됩니다.

처리한 메시지의 순서가 중요함

일반적인 사용 사례: 임계 값을 적용해야 하는 거래 데이터

메시지를 처리하는 순서에 전적으로 의존하는 사례가 가장 복잡한 사례입니다. 엄격한 메시지 순서 지정을 강제하는 솔루션에서는 성능과 처리량이 희생됩니다. 사용자는 정말로 필요할 때만, 그리고 1초에 처리하는 메시지 양을 늘리도록 확장할 필요가 없을 때만 순서에 의존해야 합니다. 메시지를 순서대로 처리하려면 구독자는 다음 중 하나를 충족해야 합니다.

  • 처리되지 않은 메시지의 전체 목록과 처리 순서를 알고 있음

  • 현재 수신하는 메시지 중에서 아직 수신하지 않았지만 먼저 처리해야 하는 메시지가 있는지 판단할 방법이 있음

첫 번째 옵션을 적용하려면 각 메시지에 고유 식별자를 할당하고 메시지를 처리해야 하는 순서를 영구적인 장소(Cloud Firestore 등)에 저장하면 됩니다. 구독자는 영구 스토리지를 확인하여 처리해야 하는 다음 메시지가 있는지 파악하고, 다음에 이 메시지만 처리하도록 하고, 수신한 다른 메시지는 전체 순서에 뜰 때까지 처리하지 않고 대기합니다. 이 시점에는 영구 스토리지 자체를 메시지 큐로 사용함으로써 Pub/Sub를 통한 메시지 전송에 의존하지 않는 방법도 고려해 봐야 합니다.

후자를 이용하려면 Cloud Monitoring으로 pubsub.googleapis.com/subscription/oldest_unacked_message_age 측정항목을 추적하면 됩니다(자세한 설명은 지원되는 측정항목을 참조). 구독자는 일시적으로 모든 메시지를 일부 영구 스토리지에 넣고 메시지를 확인합니다. 구독자는 가장 오래된 미확인 메시지의 생성일을 정기적으로 확인하고, 저장소에 있는 메시지의 게시 타임스탬프를 점검합니다. 가장 오래된 미확인 메시지 이전에 게시된 모든 메시지는 수신이 보장되며, 따라서 이러한 메시지를 영구 저장소에서 제거해 순서대로 처리할 수 있게 됩니다.

단일 동기식 게시자와 단일 구독자가 있다면 시퀀스 숫자를 이용해 순서를 보장할 수도 있습니다. 이 방식은 영구 카운터를 사용해야 합니다. 각 메시지에 대해 게시자는 다음을 수행합니다.

Node.js

Pub/Sub 클라이언트를 만드는 방법은 Pub/Sub 클라이언트 라이브러리를 참조하세요.

/**
 * TODO(developer): Uncomment these variables before running the sample.
 */
// const topicName = 'YOUR_TOPIC_NAME';
// const data = JSON.stringify({foo: 'bar'});

// Imports the Google Cloud client library
const {PubSub} = require('@google-cloud/pubsub');

// Creates a client; cache this for further use
const pubSubClient = new PubSub();

async function publishOrderedMessage() {
  // Publishes the message as a string, e.g. "Hello, world!" or JSON.stringify(someObject)
  const dataBuffer = Buffer.from(data);

  const attributes = {
    // Pub/Sub messages are unordered, so assign an order ID and manually order messages
    counterId: `${getPublishCounterValue()}`,
  };

  // Publishes the message
  const messageId = await pubSubClient
    .topic(topicName)
    .publish(dataBuffer, attributes);

  // Update the counter value
  setPublishCounterValue(parseInt(attributes.counterId, 10) + 1);
  console.log(`Message ${messageId} published.`);

  return messageId;
}

return await publishOrderedMessage();

구독자는 다음을 수행합니다.

Node.js

Pub/Sub 클라이언트를 만드는 방법은 Pub/Sub 클라이언트 라이브러리를 참조하세요.

/**
 * TODO(developer): Uncomment these variables before running the sample.
 */
// const subscriptionName = 'YOUR_SUBSCRIPTION_NAME';
// const timeout = 1000;

// Imports the Google Cloud client library
const {PubSub} = require('@google-cloud/pubsub');

// Creates a client; cache this for further use
const pubSubClient = new PubSub();

async function listenForOrderedMessages() {
  // References an existing subscription, e.g. "my-subscription"
  const subscription = pubSubClient.subscription(subscriptionName);

  // Create an event handler to handle messages
  const messageHandler = function (message) {
    // Buffer the message in an object (for later ordering)
    outstandingMessages[message.attributes.counterId] = message;

    // "Ack" (acknowledge receipt of) the message
    message.ack();
  };

  // Listen for new messages until timeout is hit
  subscription.on('message', messageHandler);
  await new Promise(r => setTimeout(r, timeout * 1000));
  subscription.removeListener('message', messageHandler);

  // Pub/Sub messages are unordered, so here we manually order messages by
  // their "counterId" attribute which was set when they were published.
  const outstandingIds = Object.keys(outstandingMessages).map(counterId =>
    Number(counterId, 10)
  );
  outstandingIds.sort();

  outstandingIds.forEach(counterId => {
    const counter = getSubscribeCounterValue();
    const message = outstandingMessages[counterId];

    if (counterId < counter) {
      // The message has already been processed
      message.ack();
      delete outstandingMessages[counterId];
    } else if (counterId === counter) {
      // Process the message
      console.log(
        '* %d %j %j',
        message.id,
        message.data.toString(),
        message.attributes
      );
      setSubscribeCounterValue(counterId + 1);
      message.ack();
      delete outstandingMessages[counterId];
    } else {
      // Have not yet processed the message on which this message is dependent
      return false;
    }
  });
}

return await listenForOrderedMessages();

두 해결책 모두 메시지 게시와 처리에 지연 시간을 도입합니다. 게시자에는 동기 단계를 적용해 순서를 생성하고, 구독자에는 순서에 맞지 않는 메시지에 지연을 적용해 순서를 실행합니다.

요약

메시지 전달 서비스를 처음 사용할 때는 메시지를 순서대로 전달하는 것이 바람직해 보입니다. 순서가 중요한 메시지를 처리하는 데 필요한 코드를 단순화하기 때문입니다. 하지만 어떤 메시지 전송 서비스를 사용하더라도 메시지를 순서대로 처리하려면 가용성과 확장성을 대폭 희생해야 합니다. Pub/Sub와 같은 인프라로 빌드된 Google 제품의 경우 가용성과 확장성은 매우 중요한 특성이며, 바로 이 점 때문에 순서에 따른 메시지 전송을 제공하지 않습니다. 가능하다면 메시지의 순서에 의존하지 않도록 애플리케이션을 설계해야 합니다. 그러면 Pub/Sub 확장 기능으로 쉽게 확장하여 모든 메시지를 빠르고 안정적으로 전송할 수 있습니다.

Pub/Sub에 메시지 순서 지정 양식을 도입하기로 했다면 Cloud FirestoreCloud SQL을 참조하여 이 문서에서 설명한 전략 도입 방법을 확인하세요.