Akka.Hosting 액터 패턴
이 스킬을 사용해야 할 때
이 스킬은 다음과 같은 경우에 사용하세요:
- 도메인 객체(사용자, 주문, 청구서 등)를 나타내는 엔티티 액터를 구축할 때
- 단위 테스트(클러스터링 없음)와 프로덕션(클러스터 샤딩)에서 모두 작동하는 액터가 필요할 때
- akka-reminders로 예약된 작업을 설정할 때
- Akka.Hosting 확장 메서드로 액터를 등록할 때
- 재사용 가능한 액터 구성 패턴을 만들 때
핵심 원칙
- 실행 모드 추상화 - 동일한 액터 코드가 로컬(테스트) 또는 클러스터(프로덕션)에서 실행됩니다.
- 로컬용 GenericChildPerEntityParent - 클러스터 오버헤드 없이 샤딩 의미 체계를 모방합니다.
- 라우팅을 위한 메시지 추출기 - Akka.Cluster.Sharding의 IMessageExtractor 인터페이스를 재사용합니다.
- Akka.Hosting 확장 메서드 - 잘 조합되는 유연한(Fluent) 구성을 제공합니다.
- 테스트 용이성을 위한 ITimeProvider - DateTime.Now 대신 ActorSystem.Scheduler를 사용합니다.
실행 모드
액터 동작을 제어하는 열거형을 정의합니다:
/// <summary>
/// Akka.NET이 구성되는 방식을 결정합니다.
/// </summary>
public enum AkkaExecutionMode
{
/// <summary>
/// 순수 로컬 액터 시스템 - 리모팅, 클러스터링 없음.
/// ShardRegion 대신 GenericChildPerEntityParent를 사용합니다.
/// 단위 테스트 및 간단한 시나리오에 이상적입니다.
/// </summary>
LocalTest,
/// <summary>
/// ShardRegion을 사용한 전체 클러스터링.
/// 통합 테스트 및 프로덕션에 사용합니다.
/// </summary>
Clustered
}
GenericChildPerEntityParent
클러스터가 필요 없이 클러스터 샤딩 의미 체계를 모방하여, 하위 엔티티로 메시지를 라우팅하는 경량 부모 액터입니다:
using Akka.Actor;
using Akka.Cluster.Sharding;
/// <summary>
/// 엔티티당 하나의 하위 액터를 가지는 제네릭 부모 액터입니다.
/// </summary>
/// <remarks>
/// 일관된 라우팅을 위해 Akka.Cluster.Sharding의 IMessageExtractor를 재사용합니다.
/// 클러스터링 오버헤드가 필요 없는 단위 테스트에 이상적입니다.
/// </remarks>
public sealed class GenericChildPerEntityParent : ReceiveActor
{
public static Props CreateProps(
IMessageExtractor extractor,
Func<string, Props> propsFactory)
{
return Props.Create(() =>
new GenericChildPerEntityParent(extractor, propsFactory));
}
private readonly IMessageExtractor _extractor;
private readonly Func<string, Props> _propsFactory;
public GenericChildPerEntityParent(
IMessageExtractor extractor,
Func<string, Props> propsFactory)
{
_extractor = extractor;
_propsFactory = propsFactory;
ReceiveAny(message =>
{
var entityId = _extractor.EntityId(message);
if (entityId is null) return;
// 기존 하위 액터를 가져오거나 새로 생성
Context.Child(entityId)
.GetOrElse(() => Context.ActorOf(_propsFactory(entityId), entityId))
.Forward(_extractor.EntityMessage(message));
});
}
}
메시지 추출기
Akka.Cluster.Sharding의 IMessageExtractor를 구현하는 추출기를 만듭니다:
using Akka.Cluster.Sharding;
/// <summary>
/// 강력한 형식의 ID를 기반으로 엔티티 액터로 메시지를 라우팅합니다.
/// </summary>
public sealed class OrderMessageExtractor : HashCodeMessageExtractor
{
public const int DefaultShardCount = 40;
public OrderMessageExtractor(int maxNumberOfShards = DefaultShardCount)
: base(maxNumberOfShards)
{
}
public override string? EntityId(object message)
{
return message switch
{
IWithOrderId msg => msg.OrderId.Value.ToString(),
_ => null
};
}
}
// 특정 엔티티를 대상으로 하는 메시지용 인터페이스 정의
public interface IWithOrderId
{
OrderId OrderId { get; }
}
// 강력한 형식의 ID 사용
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
Akka.Hosting 확장 메서드
실행 모드를 추상화하는 확장 메서드를 만듭니다:
using Akka.Cluster.Hosting;
using Akka.Cluster.Sharding;
using Akka.Hosting;
public static class OrderActorHostingExtensions
{
/// <summary>
/// 로컬 및 클러스터 모드를 모두 지원하는 OrderActor를 추가합니다.
/// </summary>
public static AkkaConfigurationBuilder WithOrderActor(
this AkkaConfigurationBuilder builder,
AkkaExecutionMode executionMode = AkkaExecutionMode.Clustered,
string? clusterRole = null)
{
if (executionMode == AkkaExecutionMode.LocalTest)
{
// 비클러스터 모드: GenericChildPerEntityParent 사용
builder.WithActors((system, registry, resolver) =>
{
var parent = system.ActorOf(
GenericChildPerEntityParent.CreateProps(
new OrderMessageExtractor(),
entityId => resolver.Props<OrderActor>(entityId)),
"orders");
registry.Register<OrderActor>(parent);
});
}
else
{
// 클러스터 모드: ShardRegion 사용
builder.WithShardRegion<OrderActor>(
"orders",
(system, registry, resolver) =>
entityId => resolver.Props<OrderActor>(entityId),
new OrderMessageExtractor(),
new ShardOptions
{
StateStoreMode = StateStoreMode.DData,
Role = clusterRole
});
}
return builder;
}
}
여러 액터 구성하기
모든 도메인 액터를 등록하는 편의 메서드를 만듭니다:
public static class DomainActorHostingExtensions
{
/// <summary>
/// 샤딩 지원과 함께 모든 주문 도메인 액터를 추가합니다.
/// </summary>
public static AkkaConfigurationBuilder WithOrderDomainActors(
this AkkaConfigurationBuilder builder,
AkkaExecutionMode executionMode = AkkaExecutionMode.Clustered,
string? clusterRole = null)
{
return builder
.WithOrderActor(executionMode, clusterRole)
.WithPaymentActor(executionMode, clusterRole)
.WithShipmentActor(executionMode, clusterRole)
.WithNotificationActor(); // 싱글톤, 샤딩 불필요
}
}
예약을 위해 ITimeProvider 사용하기
테스트 가능한 시간 기반 로직을 위해 ActorSystem의 Scheduler를 ITimeProvider로 등록합니다:
public static class SharedAkkaHostingExtensions
{
public static IServiceCollection AddAkkaWithTimeProvider(
this IServiceCollection services,
Action<AkkaConfigurationBuilder, IServiceProvider> configure)
{
// ActorSystem의 Scheduler를 사용하여 ITimeProvider 등록
services.AddSingleton<ITimeProvider>(sp =>
sp.GetRequiredService<ActorSystem>().Scheduler);
return services.ConfigureAkka((builder, sp) =>
{
configure(builder, sp);
});
}
}
// 액터에서 ITimeProvider 주입
public class SubscriptionActor : ReceiveActor
{
private readonly ITimeProvider _timeProvider;
public SubscriptionActor(ITimeProvider timeProvider)
{
_timeProvider = timeProvider;
// DateTime.UtcNow 대신 _timeProvider.GetUtcNow() 사용
// 이를 통해 테스트에서 시간을 제어할 수 있습니다.
}
}
Akka.Reminders 통합
재시작 후에도 유지되는 내구성 있는 예약 작업을 위해 akka-reminders를 사용합니다:
using Akka.Reminders;
using Akka.Reminders.Sql;
using Akka.Reminders.Sql.Configuration;
using Akka.Reminders.Storage;
public static class ReminderHostingExtensions
{
/// <summary>
/// PostgreSQL 스토리지로 akka-reminders를 구성합니다.
/// </summary>
public static AkkaConfigurationBuilder WithPostgresReminders(
this AkkaConfigurationBuilder builder,
string connectionString,
string schemaName = "reminders",
string tableName = "scheduled_reminders",
bool autoInitialize = true)
{
return builder.WithLocalReminders(reminders => reminders
.WithResolver(sys => new GenericChildPerEntityResolver(sys))
.WithStorage(system =>
{
var settings = SqlReminderStorageSettings.CreatePostgreSql(
connectionString,
schemaName,
tableName,
autoInitialize);
return new SqlReminderStorage(settings, system);
})
.WithSettings(new ReminderSettings
{
MaxSlippage = TimeSpan.FromSeconds(30),
MaxDeliveryAttempts = 3,
RetryBackoffBase = TimeSpan.FromSeconds(10)
}));
}
/// <summary>
/// 테스트용 인메모리 스토리지로 akka-reminders를 구성합니다.
/// </summary>
public static AkkaConfigurationBuilder WithInMemoryReminders(
this AkkaConfigurationBuilder builder)
{
return builder.WithLocalReminders(reminders => reminders
.WithResolver(sys => new GenericChildPerEntityResolver(sys))
.WithStorage(system => new InMemoryReminderStorage())
.WithSettings(new ReminderSettings
{
MaxSlippage = TimeSpan.FromSeconds(1),
MaxDeliveryAttempts = 3,
RetryBackoffBase = TimeSpan.FromMilliseconds(100)
}));
}
}
하위-엔티티-당-부모를 위한 사용자 정의 리마인더 리졸버
리마인더 콜백을 GenericChildPerEntityParent 액터로 라우팅합니다:
using Akka.Actor;
using Akka.Hosting;
using Akka.Reminders;
/// <summary>
/// 리마인더 대상을 GenericChildPerEntityParent 액터로 확인합니다.
/// </summary>
public sealed class GenericChildPerEntityResolver : IReminderActorResolver
{
private readonly ActorSystem _system;
public GenericChildPerEntityResolver(ActorSystem system)
{
_system = system;
}
public IActorRef ResolveActorRef(ReminderEntry entry)
{
var registry = ActorRegistry.For(_system);
return entry.Key switch
{
var k when k.StartsWith("order-") =>
registry.Get<OrderActor>(),
var k when k.StartsWith("subscription-") =>
registry.Get<SubscriptionActor>(),
_ => throw new InvalidOperationException(
$"알 수 없는 리마인더 키 형식: {entry.Key}")
};
}
}
싱글톤 액터 (샤딩되지 않음)
인스턴스가 하나만 있어야 하는 액터의 경우:
public static AkkaConfigurationBuilder WithEmailSenderActor(
this AkkaConfigurationBuilder builder)
{
return builder.WithActors((system, registry, resolver) =>
{
var actor = system.ActorOf(
resolver.Props<EmailSenderActor>(),
"email-sender");
registry.Register<EmailSenderActor>(actor);
});
}
레지스트리를 위한 마커 타입
부모로 등록된 액터를 참조해야 할 때:
/// <summary>
/// OrderActor의 GenericChildPerEntityParent인 주문 관리자를 검색하기 위한 ActorRegistry의 마커 타입입니다.
/// </summary>
public sealed class OrderManagerActor;
// 확장 메서드에서 사용
registry.Register<OrderManagerActor>(parent);
// 컨트롤러/서비스에서 사용
public class OrderService
{
private readonly IActorRef _orderManager;
public OrderService(IRequiredActor<OrderManagerActor> orderManager)
{
_orderManager = orderManager.ActorRef;
}
public async Task<OrderResponse> CreateOrder(CreateOrderCommand cmd)
{
return await _orderManager.Ask<OrderResponse>(cmd);
}
}
액터의 DI 범위 관리
액터는 자동 DI 범위를 가지지 않습니다. ASP.NET 컨트롤러(각 HTTP 요청이 범위를 생성)와 달리, 액터는 장기 실행됩니다. 범위 서비스(예: DbContext)가 필요한 경우, IServiceProvider를 주입하고 수동으로 범위를 생성하세요.
패턴: 메시지당 범위
public sealed class OrderProcessingActor : ReceiveActor
{
private readonly IServiceProvider _serviceProvider;
private readonly IActorRef _notificationActor;
public OrderProcessingActor(
IServiceProvider serviceProvider,
IRequiredActor<NotificationActor> notificationActor)
{
_serviceProvider = serviceProvider;
_notificationActor = notificationActor.ActorRef;
ReceiveAsync<ProcessOrder>(HandleProcessOrder);
}
private async Task HandleProcessOrder(ProcessOrder msg)
{
// 이 메시지에 대한 범위 생성 - 처리 후 폐기됨
using var scope = _serviceProvider.CreateScope();
// 범위 내에서 범위 서비스 해결
var orderRepository = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
var paymentService = scope.ServiceProvider.GetRequiredService<IPaymentService>();
var emailComposer = scope.ServiceProvider.GetRequiredService<IOrderEmailComposer>();
// 범위 서비스를 사용하여 작업 수행
var order = await orderRepository.GetByIdAsync(msg.OrderId);
var payment = await paymentService.ProcessAsync(order);
// 범위가 폐기될 때 DbContext 변경 사항이 커밋됨
}
}
이 패턴의 이유
| 이점 | 설명 |
|---|---|
| 메시지당 새로운 DbContext | 메시지 간에 오래된 엔티티 추적 없음 |
| 적절한 폐기 | 각 메시지 후 데이터베이스 연결 해제 |
| 격리 | 하나의 메시지 오류가 다른 메시지의 상태를 손상시키지 않음 |
| 테스트 가능 | 테스트에서 모의 IServiceProvider를 주입 가능 |
싱글톤 서비스 - 직접 주입
무상태, 스레드 안전 서비스의 경우 (범위 불필요) 직접 주입합니다:
public sealed class NotificationActor : ReceiveActor
{
private readonly IEmailLinkGenerator _linkGenerator; // 싱글톤 - OK!
private readonly IMjmlTemplateRenderer _renderer; // 싱글톤 - OK!
public NotificationActor(
IEmailLinkGenerator linkGenerator,
IMjmlTemplateRenderer renderer)
{
_linkGenerator = linkGenerator;
_renderer = renderer;
Receive<SendOrderConfirmation>(msg =>
{
var link = _linkGenerator.GenerateFor(msg.OrderId);
var body = _renderer.Render("order-confirmation", msg);
// 이메일 전송...
});
}
}