Azure Event Hubs를 이용한 비동기 마이크로서비스 아키텍처

시작하기

Azure Event Hubs는 메시지 스트림을 쉽게 구현할 수 있는 기능들을 제공합니다. 이 글은 Azure Event Hubs로 메시지 스트림을 만들고 분산된 비동기 서비스에 필요한 기반구조를 구축하는 하나의 방법을 제시합니다.

이 글은 메시지 스트림을 이용한 비동기 마이크로서비스 아키텍처에서 제시한 아키텍처를 구현하는 한가지 방법을 설명하기 때문에 이전 글을 읽지 않았다면 일부 내용을 이해하기 어려울 수 있습니다. 따라서 이전 글을 먼저 읽어보시기를 추천합니다.

Azure Event Hubs

Azure Event Hubs는 Microsoft Azure가 제공하는 관리되는 메시징 도구입니다. 주요 특징은 다음과 같습니다.

  • AMQP와 HTTP 프로토콜을 사용합니다.
  • 메시지 발행자와 구독자 사이에 약한 결합을 제공합니다.
  • 분할된 소비자(partitioned consumer) 모델을 제공합니다.
  • 소비자 그룹(consumer groups)을 통해 각 응용프로그램은 독립적으로 메시지를 소비할 수 있습니다.

Azure Event Hubs에 대한 자세한 학습은 여기를 방문하세요.

메시지 발행

도메인 모델은 다양한 메시지를 만들어냅니다. 메시지는 주로 명령과 이벤트로 이뤄집니다.

  • 명령은 명확하게 지정된 대상이 행위를 수행하도록 전달되는 메시지입니다. 행위를 나타내는 명령형 동사와 관련 데이터로 구성됩니다. 명령은 명령을 수신하는 쪽에서 정의합니다.
  • 이벤트는 도메인 모델에서 헹위가 수행된 과거의 사건을 나타내는 메시지입니다. 이벤트는 수신 대상을 지정하지 않습니다. 행위를 나타내는 과거형 동사와 관련 데이터로 구성됩니다. 이벤트는 이벤트를 발생시키는 쪽에서 정의합니다.

강력한 형식을 사용하는 언어의 경우 메시지 형식 이름으로 동사를 표현하고 속성을 통해 데이터를 구성합니다. 다음 코드는 사용자를 생성하는 명령과 사용자가 생성된 이벤트를 C# 클래스로 나타냅니다.

public sealed class CreateUser
{
    public string Email { get; set; }
    public string Password { get; set; }
}

public sealed class UserCreated
{
    public Guid UserId { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
}

메시지 발행의 전반적인 과정은 다음 그림으로 표현될 수 있습니다.

메시지 발행

참고 자료

메시지 버스(Message Bus)

메시지 버스는 도메인 모델과 메시지 스트림 구현체 사이에서 메시지 발행 기능을 추상화합니다. 도메인 모델은 메시지 버스 인터페이스만을 통해 명령과 이벤트를 발행하며 구체적인 메시지 스트림 구현체(Azure Event Hubs)에는 관심을 가지지 않습니다.

public interface IMessageBus
{
    Task SendAsync(object message);
}

이 글에서는 자세하게 다루지 않지만 분할된(partitioned) 메시징의 장점을 잘 활용하려면 분할 키(partition key)를 함께 입력받는 것이 좋습니다.

강력한 형식 기반 JSON 직렬화

메시지는 동사와 명사로 이뤄집니다. 수신자에게 둘 다 온전히 전달되어야 메시지의 의미가 훼손되지 않습니다. 메시지의 동사를 전달하는 방법은 다양합니다. 각 서비스들이 사용하는 언어와 플랫폼 구성에 따라 여러가지 선택안이 있습니다. 여기서는 .NET 플랫폼에서 구축된 서비스들이 JSON 문서를 통해 메시지를 주고받는 시나리오의 한가지 해결책을 설명합니다.

JSON 직렬화는 메시지를 전달하기 위해 많이 사용되는 방법이지만 JSON 자체적으로는 강력한 형식을 지원하지 않습니다. 따라서 강력한 형식의 이름으로 명령이나 이벤트의 행위를 나타낼 때 형식에 대한 정보가 별도로 처리되지 않으면 메시지를 수신하는 쪽에서 혼란을 겪을 수 있습니다. 예를 들어 다음 두 이벤트가 존재하는 경우를 가정합니다.

public sealed class Liked
{
    public Guid UserId { get; set; }
    public Guid PostId { get; set; }
}

public sealed class Unliked
{
    public Guid UserId { get; set; }
    public Guid PostId { get; set; }
}

Liked 이벤트와 Unliked 이벤트를 형 정보 없이 JSON 직렬화한 결과는 각각 다음과 같습니다.

{
    "UserId": "a870be84-2ff8-4d78-8f6a-9a632b9a5457",
    "PostId": "12b98449-105c-4761-a689-ddc7f5b0b8cc"
}
{
    "UserId": "3a336c0d-fefb-4ae8-b751-f6e592537c60",
    "PostId": "1fe95c13-3855-4dad-9b61-02e751b1c3fb"
}

서비스가 위와 같은 메시지를 수신했다면 Liked 이벤트인지 Unliked 이벤트인지 구분할 수 없습니다.

어떤 JSON 직렬화 도구들은 강력한 형식에 대한 정보를 제공합니다. Newtonsoft.Json 패키지는 JsonSerializerSettings 클래스를 통해 형 정보를 직렬화에 포함시킬 수 있습니다. Newtonsoft.Json 패키지로 형 정보와 함께 Liked 이벤트를 직렬화하는 코드 및 결과는 다음과 같습니다.

var evt = new Liked { UserId = userId, PostId = postId };
var settings = new JsonSerializerSettings
{
    TypeNameHandling = TypeNameHandling.All,
    TypeNameAssemblyFormat = FormatterAssemblyStyle.Simple
};
string json = JsonConvert.SerializeObject(evt, settings);
{
    "$type": "Your.Namespace.Liked, Your.Assembly",
    "UserId": "5a74839d-c9bd-49df-8fcc-8ec5fa133065",
    "PostId": "55fac1ab-5da0-47dd-a301-43055c03bd60"
}

$type 속성에 강력한 형식에 대한 정보를 기록했습니다. Newtonsoft.Json 패키지는 역직렬화에도 $type 속성을 이용합니다.

var evt = (Liked)JsonConvert.DeserializeObject(json, settings);

강력한 형식의 JSON 직렬화를 사용할 경우 메시지 형식은 도메인 모델과 별도의 바이너리로 분리되는 것이 좋습니다. 그렇게 해야만 서비스간 비즈니스 논리를 서로 참조하지 않고 메시지 스키마에 대해서만 공유할 수 있습니다.

메시지 계약

메시지 필터

메시지 버스로 전달되는 모든 메시지를 중앙화된 메시지 스트림으로 전달할 수 있습니다. 메시징에 괄목할만한 문제가 없다면 단순하기 때문에 좋은 방법입니다. 하지만 메시지 스트림에 과도한 부하가 발생하는 등의 성능적 이슈가 발생하면 메시지 버스와 메시지 스트림 사이에 필터를 설치해 메세지 흐름을 분기할 수 있습니다. 메시지 필터는 메시지 스트림에 메시지를 전달하기 전에 메시지가 어떤 경로로 전달될지를 결정합니다. 여유 자원이 있다면 필터링 대상 메시지 유형을 동적으로 관리하는 것도 고려할 수 있습니다.

public interface IMessageBusFilter
{
    Task<bool> FilterAsync(object message);
}

다음 코드는 메시지 필터가 적용된 메시지 버스입니다.

public class FilteredMessageBus : IMessageBus
{
    private readonly IMessageBusFilter _filter;
    private readonly IMessageBus _messageBus;

    public FilteredMessageBus(
        IMessageBusFilter filter, IMessageBus messageBus)
    {
        _filter = filter;
        _messageBus = messageBus;
    }

    public async Task SendAsync(object message)
    {
        if (false == await _filter.FilterAsync(message))
            await _messageBus.SendAsync(message);
    }
}

메시지 필터는 여러 계층의 하위 필터로 합성될 수 있습니다. IMessageBusFilter 인터페이스에 합성(composite) 패턴을 사용하면 FilteredMessageBus는 구체적인 필터 목록에 관여하지 않고 여러 필터 계층을 처리할 수 있습니다.

public class CompositeMessageBusFilter : IMessageBusFilter
{
    private IMessageBusFilter[] _implementors;

    public CompositeMessageBusFilter(params IMessageBusFilter[] implementors)
    {
        _implementors = implementors;
    }

    public async Task<bool> FilterAsync(object message)
    {
        foreach (IMessageBusFilter filter in _implementors)
        {
            if (await filter.FilterAsync(message))
                return true;
        }

        return false;
    }
}

메시지 필터

대상 지정 메시지 전송(Sending targeted messages)

수신자가 명확한 메시지의 흐름이 메시지 스트림에 커다란 부하를 준다면 해당 메시지를 대상을 지정한 경로를 통해 전달하여 부하를 줄일 수 있습니다. 이때 메시지를 전송할 수단은 API, 메시지 큐, 웹훅 등이 있습니다.

인메모리(In-memory) 메시지 스트림

인메모리 메시지 스트림은 대상이 지정된 메시지 전송의 하나의 예입니다. 메시지의 대상이 발신 서비스와 함께 호스팅되고 있는 경우 서비스 인스턴스의 메모리를 사용하면 입출력 비용을 줄이고 매우 빠르게 메시지를 전달할 수 있습니다. Rx(Reactive Extensions)는 메모리 기반 비동기 메시지 스트림을 구현하기 좋은 도구입니다.

소비자 그룹

Azure Event Hubs는 소비자 그룹(Consumer Groups)을 지원합니다. 각 서비스를 위한 소비자 그룹을 만들면 메시지가 서비스들에 독립적으로 전달됩니다.

메시지 구독

각 서비스는 메시지 스트림으로부터 메시지를 전달받습니다. 전달받은 메시지는 일련의 과정을 통해 처리됩니다. 메시지 처리 과정은 다양하게 구성될 수 있습니다. 여기서는 다음 그림처럼 나열된 과정을 설명합니다.

메시지 구독

분할(Partition)

Azure Event Hubs는 분할(partition)을 통해 병렬적인 메시지 처리를 제공합니다. 분할이 제공하는 하류 병렬화(downstream parallelism)를 통해 서비스는 수평확장될 수 있습니다. 분할은 독립된 일련의 메시지 집합으로 하나의 메시지는 하나의 분할에 할당됩니다. 하나의 서비스는 하나 이상의 인스턴스로 구성됩니다. 각 서비스 인스턴스들은 적절히 분할을 배분받아 메시지를 처리합니다.

참고 자료

이벤트 처리기 호스트와 이벤트 처리기

이벤트 처리기 호스트(event processor host)는 소비자 그룹의 구독 기능을 추상화합니다.

여기서 말하는 ‘이벤트’는 Azure Event Hubs의 문맥에서 사용되는 것으로 위에서 설명된 메시지를 명령과 이벤트로 구분한 것과는 차이가 있습니다. 오히려 이 글이 전반적으로 사용하는 ‘메시지’ 어휘와 유사합니다.

이벤트 처리기 호스트를 사용해야만 Azure Event Hubs를 사용할 수 있는 것은 아닙니다. 하지만 이벤트 처리기 호스트를 사용하면 메시지 구독자 서비스는 손쉽게 여러 인스턴스들 간의 분할 구독을 관리할 수 있습니다. 이벤트 처리기 호스트는 필요에 따라 분할을 서비스 인스턴스에 재분배합니다. 클라이언트는 이벤트 처리기(event processor)를 등록해 분할 구독에 대한 연결과 연결끊김, 그리고 메시지 수신을 처리하면 됩니다. 이벤트 처리기 호스트는 상태 관리를 위한 데이터 저장소로 Azure Blob 저장소를 사용합니다. 현재 이벤트 처리기 호스트는 .NET 플랫폼을 위한 구현체만 제공됩니다.

참고 자료

메시지 처리기(Message processor)

서비스 인스턴스가 메시지 스트림으로부터 메시지를 전달받았다면 메시지 처리 과정이 시작됩니다. 메시지 처리 과정은 목적과 상황에 따라 다양하게 구성될 수 있습니다. 여기서는 그 중 한가지 구성법에 대해 설명합니다.

메시지 처리기는 일련의 메시지 핸들러(message handler) 집합으로 구성할 수 있습니다. 가장 높은 우선순위를 가진 메시지 핸들러가 제일 처음 메시지를 처리할 수 있는 기회를 얻습니다. 메시지 핸들러가 전달된 메시지를 처리할 수 있다면 처리하고 그렇지 않다면 다음 핸들러에게 기회를 넘겨줍니다. 준비된 모든 핸들러가 메시지를 처리하지 못하면 메시지는 버려집니다.

메시지 핸들러 인터페이스와 메시지 처리기 클래스는 다음과 같이 정의됩니다.

public interface IMessageHandler
{
    Task<bool> TryHandleAsync(object message);
}

public class MessageProcessor
{
    private IMessageHandler[] _handlers;

    public MessageProcessor(params IMessageHandler[] handlers)
    {
        _handlers = handlers;
    }

    public async Task ProcessAsync(object message)
    {
        foreach (IMessageHandler handler in _handlers)
        {
            if (await handler.TryHandleAsync(message))
                return;
        }
    }
}

메시지 핸들러 목록을 구성하는 방법은 물리적으로 혹은 논리적으로 다양합니다. 그 중 한가지 구성법으로 다음 메시지 핸들러들을 배치할 수 있습니다.

메시지 처리기

박동(Heartbeat) 핸들러

서비스가 배포된 후 혹은 주기적으로 메시지가 서비스에 잘 전달되는지에 대한 테스트가 필요합니다. 메시지 처리 과정의 맨 앞에 박동(heartbeat) 메시지 핸들러를 배치하고 박동 메시지를 보내면 도메인 논리를 수행하지 않으면서 메시지 수신 여부를 테스트할 수 있습니다. 그리고 단순히 서비스가 메시지 스트림으로부터 메시지를 잘 받고있는지를 확인하는 것 외에 특정 메시지가 잘 복원(역직렬화, deserialize)되는지를 검사하는 것고 고려해볼 수 있습니다.

박동 메시지 핸들러는 메시지가 박동 검사를 위한 것임을 인지하면 박동 응답기(heartbeat responder)에 해당 메시지를 넘겨줍니다. 박동 응답기는 다양한 방법으로 박동 여부를 응답할 수 있는데 응답 대상으로는 다음과 같은 것들이 있습니다.

  • 데이터베이스 혹은 저장소
  • 이메일
  • 협업 도구(Slack, …)

명령 핸들러

명령 핸들러는 명령 메시지를 수신하면 명령 메시지를 분석해 저장소로부터 필요한 도메인 모델을 복원하고 명령을 실행한 뒤 다시 도메인 모델을 저장합니다. 또는 새 엔터티를 생성하는 명령이라면 새 엔터티를 만들어 저장합니다. 예를 들어 사용자를 생성하는 명령과 사용자 이름을 변경하는 명령에 대해 명령 핸들러는 다음과 같은 메서드를 실행합니다.

public class CreateUser
{
    public string Email { get; set; }
    public string Password { get; set; }
}

public class ChangeUserName
{
    public Guid UserId { get; set; }
    public string NewUserName { get; set; }
}

public async Task HandleAsync(CreateUser command)
{
    var user = new User(command.Email, command.Password);
    IUserRepository repository = _userRepositoryFactory.Invoke();
    await repository.SaveAsync(user);
}

public async Task HandleAsync(ChangeUserName command)
{
    IUserRepository repository = _userRepositoryFactory.Invoke();
    User user = await repository.GetAsync(command.UserId);
    user.ChangeUserName(command.NewUserName);
    await repository.SaveAsync(user);
}

이벤트 핸들러

이벤트 핸들러는 이벤트 메시지를 수신하면 이벤트 메시지를 분석해 처리합니다. 이벤트 처리 작업은 다음과 같은 것들이 있습니다.

  • 후속 명령 전달
  • 집계 정보, 캐시 등 관련 데이터 갱신
  • 알림 및 메일 발송

체크포인트

Azure Event Hubs는 분할(partition)에 들어있는 메시지가 구독자에게 전달되어 정상적으로 처리되었음을 확인하기 위해 체크포인트(checkpoint)를 제공합니다. 메시지 처리 중에 서비스 인스턴스가 중지된 경우 체크포인트되지 않은 메시지는 분할에 대한 구독이 재활성화되면 다시 전달받을 수 있습니다. 그렇기 때문에 정상적으로 처리된 메세지에 대해서는 체크포인트를 해줘야합니다.

마무리

Azure Event Hubs를 사용해 비동기 마이크로서비스 아키텍처를 위한 메시지 스트림을 구축하고 메시지 발행 및 수신 기능을 구현하는 방법을 설명했습니다. 이것은 메시지 스트림 기반 비동기 아키텍처를 구축하는 단지 하나의 예시일 뿐이며 블로그 게시물의 한계로 인해 다루지 못한 고민거리도 많고 각 플랫폼과 기존 아키텍처, 운영환경 등에 따라 다양한 대안을 고안할 수 있습니다. 하지만 이 글에서 설명한 방법은 반응형 아키텍처, 특히 CQRS와 Event sourcing을 잘 지원하고 비동기 마이크로서비스 아키텍팅을 시작하기 위한 가이드가 될 수 있습니다.

Advertisements

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Google+ photo

Google+의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

%s에 연결하는 중