|
액터모델의 메일박스로 통해 전달되는 이벤트들은 다양한 DB장치를 이용해 영속화가 가능하며 상태프로그래밍을 할수 있습니다. |

데이터베이스는 지루하면 안된다라는 슬로건을 내건 모던 DB
| 항목 | 설명 |
|---|---|
| Document Store | JSON 기반의 문서 저장 (MongoDB처럼) |
| Full-Text Search 내장 | Lucene 기반 검색엔진 포함 (Elasticsearch 대체 가능) |
| Graph-Like Traversal 지원 | Include, Load, RelatedDocuments 로 Graph traversal 흉내 가능 |
| 벡터 검색 (Vector Search) | 6.0 이상 버전에서 Vector search 지원 (Preview → Stable 예정) |
| ACID 트랜잭션 지원 | NoSQL 중 드물게 단일 DB 내 ACID 지원 |
| 자동 인덱싱/쿼리 최적화 | 쿼리 기반으로 자동 인덱싱 생성 |
| Change Vector / ETL 기능 내장 | 다른 Raven 클러스터 또는 외부 시스템으로 데이터 복제 가능 |
| 클라우드 + 온프렘 지원 | 다양한 배포 환경 대응 |
| Sharding + Replication | 분산 구조 대응 가능 (Sharded DB) |
| 기존 시스템 | RavenDB로 대체 가능 여부 | 설명 |
|---|---|---|
| MongoDB (Document DB) | ✅ 완전 대체 | JSON 기반 문서 저장, 컬렉션 → 문서 분리 모델 |
| Elasticsearch | ✅ 부분 대체 | Full-text 검색 지원, 복잡한 분석쿼리는 제한적이나 일반 검색에는 충분 |
| Neo4j (Graph DB) | ⚠️ 간단한 관계 트래버설은 가능 | 명시적 Graph 모델링은 어려움 (복잡한 네트워크 분석에는 부적합) |
| Vector DB (예: Weaviate, Milvus) | ✅ 단순 벡터 검색은 대체 가능 | 다차원 벡터 검색 API 제공, 모델링+쿼리 결합 쉬움 |
| RDB (CRUD/정형) | ⚠️ 단순 CRUD는 가능, 복잡한 조인과 트랜잭션은 제한적 | 정형 테이블 기반보다는 문서 중심 모델 필요 |
version: '3.8'
services:
ravendb:
image: ravendb/ravendb:ubuntu-latest
container_name: ravendb
ports:
- "9000:8080"
environment:
- RAVEN_Setup_Mode=None
- RAVEN_License_Eula_Accepted=true
volumes:
- ravendb_data:/ravendb/data
- ravendb_logs:/ravendb/logs
volumes:
ravendb_data:
ravendb_logs: |





public class Member
{
public string Id { get; set; } // RavenDB는 기본적으로 Id를 문서 키로 사용
public string Name { get; set; }
public string Email { get; set; }
public int Age { get; set; }
}
public class MemberRepository
{
private readonly IDocumentStore _store;
public MemberRepository(IDocumentStore store)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
}
public void AddMember(Member member)
{
using (var session = _store.OpenSession())
{
session.Store(member);
session.SaveChanges();
}
}
public Member GetMemberById(string id)
{
using (var session = _store.OpenSession())
{
return session.Load<Member>(id);
}
}
public void UpdateMember(Member member)
{
using (var session = _store.OpenSession())
{
var existingMember = session.Load<Member>(member.Id);
if (existingMember != null)
{
existingMember.Name = member.Name;
existingMember.Email = member.Email;
existingMember.Age = member.Age;
session.SaveChanges();
}
}
}
public void DeleteMember(string id)
{
using (var session = _store.OpenSession())
{
var member = session.Load<Member>(id);
if (member != null)
{
session.Delete(member);
session.SaveChanges();
}
}
}
} |
public class MemberRepositoryTest : TestKitXunit
{
private readonly IDocumentStore _store;
private readonly MemberRepository _repository;
public MemberRepositoryTest(ITestOutputHelper output) : base(output)
{
// RavenDB 임베디드 서버 초기화
_store = new DocumentStore
{
Urls = new[] { "http://localhost:9000" }, // 로컬 RavenDB URL
Database = "net-core-labs"
};
_store.Initialize();
// MemberRepository 초기화
_repository = new MemberRepository(_store);
}
[Fact]
public void AddMember_ShouldAddMemberSuccessfully()
{
// Arrange
var member = new Member
{
Name = "John Doe",
Email = "john.doe@example.com",
Age = 30
};
// Act
_repository.AddMember(member);
// Assert
var retrievedMember = _repository.GetMemberById(member.Id);
Assert.NotNull(retrievedMember);
Assert.Equal("John Doe", retrievedMember.Name);
}
[Fact]
public void UpdateMember_ShouldUpdateMemberSuccessfully()
{
// Arrange
var member = new Member
{
Name = "Jane Doe",
Email = "jane.doe@example.com",
Age = 25
};
_repository.AddMember(member);
// Act
member.Age = 26;
_repository.UpdateMember(member);
// Assert
var updatedMember = _repository.GetMemberById(member.Id);
Assert.NotNull(updatedMember);
Assert.Equal(26, updatedMember.Age);
}
[Fact]
public void DeleteMember_ShouldDeleteMemberSuccessfully()
{
// Arrange
var member = new Member
{
Name = "Mark Smith",
Email = "mark.smith@example.com",
Age = 40
};
_repository.AddMember(member);
// Act
_repository.DeleteMember(member.Id);
// Assert
var deletedMember = _repository.GetMemberById(member.Id);
Assert.Null(deletedMember);
}
} |


반경검색/FullText검색및 Vector검색등 고급 검색기능을 단일DB만사용해 검색할수 있습니다.
using ActorLib.Persistent.Model;
using Raven.Client.Documents;
using Raven.Client.Documents.Indexes.Vector;
using Raven.Client.Documents.Linq;
namespace ActorLib.Persistent;
public class TravelReviewRepository
{
private readonly IDocumentStore _store;
public TravelReviewRepository(IDocumentStore store)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
}
public void AddReview(TravelReview review)
{
using (var session = _store.OpenSession())
{
session.Store(review);
session.SaveChanges();
}
}
public List<TravelReview> SearchReviews(string keyword, double latitude, double longitude, double radiusKm, string category = null)
{
using (var session = _store.OpenSession())
{
// 명시적으로 변수로 선언
var keywordValue = keyword;
var categoryValue = category;
IRavenQueryable<TravelReview> query = session.Query<TravelReview>();
if (!string.IsNullOrEmpty(keywordValue))
{
query = query.Search(r => r.Content, keywordValue); // 제목 검색 추가
}
if (!string.IsNullOrEmpty(categoryValue))
{
query = query.Where(r => r.Category == categoryValue); // 제목 검색 추가
}
// RavenDB에서 서버 측 필터링 후 클라이언트 측에서 반경 필터링
var results = query.ToList();
return results.Where(r =>
6371 * Math.Acos(
Math.Cos(DegToRad(latitude)) * Math.Cos(DegToRad(r.Latitude)) *
Math.Cos(DegToRad(r.Longitude) - DegToRad(longitude)) +
Math.Sin(DegToRad(latitude)) * Math.Sin(DegToRad(r.Latitude))
) <= radiusKm).ToList();
}
}
public List<TravelReview> SearchReviewsByRadius(double latitude, double longitude, double radiusKm)
{
using (var session = _store.OpenSession())
{
return session.Query<TravelReview>()
.Spatial(
r => r.Point(x => x.Latitude, x => x.Longitude),
criteria => criteria.WithinRadius(radiusKm, latitude, longitude))
.ToList();
}
}
public List<TravelReview> SearchReviewsByVector(float[] vector, int topN = 5)
{
using (var session = _store.OpenSession())
{
var results = session.Query<TravelReview>()
.VectorSearch(
field => field.WithEmbedding(x => x.TagsEmbeddedAsSingle, VectorEmbeddingType.Single),
queryVector => queryVector.ByEmbedding(new RavenVector<float>(vector)),
0.85f,
topN)
.Customize(x => x.WaitForNonStaleResults())
.ToList();
return results;
}
}
private double DegToRad(double degrees)
{
return degrees * (Math.PI / 180);
}
} |

CRUD또는 검색기능만 이용하려고 RavenDB를 조사한것은 아니며 Akka.net의 Persist기능이 제공하는 CQRS장치와 연동이됨으로
이벤트 소싱이 필요한 기능을 만들고자 할때, 가장 심플한 방식으로 구현하고 강력하게 이용할수 있는 방식중 하나가 될수 있습니다.

RavenDB가 Akka.net의 Persitence 기능을 지원하며 이 코드를 이해하기전 이벤트 소싱패턴을 액터모델을 함께 이용했을때 특징을 먼저 살펴보고 작동가능한 구현된 샘플코드도 유닛테스트를 통해 살펴보겠습니다.
Akka.NET의 액터모델은 CQRS (Command Query Responsibility Segregation) 및 이벤트 소싱 (Event Sourcing) 패턴을 구현하기에 매우 적합한 구조를 제공합니다. 특히 Akka.Persistence 모듈과의 연계를 통해 상태 저장과 복구, 그리고 이벤트 기반 모델링이 가능해집니다. 아래에 그 기능과 장점을 정리해드립니다.
| 구성요소 | 설명 |
|---|---|
PersistentActor |
이벤트를 저장하고 재생할 수 있는 상태 유지 액터 |
Snapshotting |
빠른 복구를 위해 특정 시점의 상태를 저장 |
Event Journal |
모든 상태 변화(event)를 append-only 로그로 저장 |
Read Model Actor |
쿼리에 최적화된 projection을 담당 |
Command Handler Actor |
명령(Command)을 받아 이벤트로 전환 및 persistence 수행 |
2. CQRS(이벤트 소싱) with ActorModel
![]()

상태 격리: 각 액터는 고유 상태를 가지며 병렬 처리에 유리함.
비동기 메시지 기반 처리: 병목 없이 고성능 분산 처리 가능.
자연스러운 도메인 분리: 액터 자체가 DDD의 Aggregate Root 역할을 하기에 적합.
이벤트 이력 관리: 모든 상태 변화가 이벤트로 저장되므로 과거 상태 추적 가능 (감사, 재처리).
읽기/쓰기 분리: 쿼리와 명령이 분리되어 성능 최적화 가능.
스냅샷 사용 가능: 복구 속도 향상.
스케일 아웃 용이: 각 액터가 독립적으로 배포/스케일링 가능.
Akka.Cluster와 연계 시 분산 시스템 구성도 가능
Akka.Remote로 마이크로서비스 간 통신에도 활용 가능
Pluggable Journal Backend: SQL, NoSQL, 이벤트 스토어 등 다양한 저장소와 연동 가능
고객 주문 시스템: OrderActor가 PlaceOrderCommand를 받고 OrderPlacedEvent를 생성 및 저장
챗봇 세션 관리: SessionActor가 메시지를 이벤트로 저장하여 상태 기반 대화 흐름 관리
금융 거래 기록: AccountActor가 Withdrawn, Deposited 이벤트를 기록하여 완전한 거래 이력 확보
public class SalesActor: ReceivePersistentActor
{
// The unique actor id
public override string PersistenceId => "sales-actor";
// The state that will be persisted in SNAPSHOTS
private SalesActorState _state;
public SalesActor(long expectedProfit, TaskCompletionSource<bool> taskCompletion)
{
_state = new SalesActorState
{
totalSales = 0
};
// Process a sale:
Command<Sale>(saleInfo =>
{
if (_state.totalSales < expectedProfit)
{
// Persist an EVENT to RavenDB
// ===========================
// The handler function is executed after the EVENT was saved successfully
Persist(saleInfo, _ =>
{
// Update the latest state in the actor
_state.totalSales += saleInfo.Price;
ConsoleHelper.WriteToConsole(ConsoleColor.Black,
$"Sale was persisted. Phone brand: {saleInfo.Brand}. Price: {saleInfo.Price}");
// Store a SNAPSHOT every 5 sale events
// ====================================
if (LastSequenceNr != 0 && LastSequenceNr % 5 == 0)
{
SaveSnapshot(_state.totalSales);
}
});
}
else if (!taskCompletion.Task.IsCompleted)
{
Sender.Tell(new StopSimulate());
ConsoleHelper.WriteToConsole(ConsoleColor.DarkMagenta,
$"Sale not persisted: " +
$"Total sales have already reached the expected profit of {expectedProfit}");
ConsoleHelper.WriteToConsole(ConsoleColor.DarkMagenta,
_state.ToString());
taskCompletion.TrySetResult(true);
}
});
// Handle a SNAPSHOT success msg
Command<SaveSnapshotSuccess>(success =>
{
ConsoleHelper.WriteToConsole(ConsoleColor.Blue,
$"Snapshot saved successfully at sequence number {success.Metadata.SequenceNr}");
// Optionally, delete old snapshots or events here if needed
// DeleteMessages(success.Metadata.SequenceNr);
});
// Recover an EVENT
Recover<Sale>(saleInfo =>
{
_state.totalSales += saleInfo.Price;
ConsoleHelper.WriteToConsole(ConsoleColor.DarkGreen,
$"Event was recovered. Price: {saleInfo.Price}");
});
// Recover a SNAPSHOT
Recover<SnapshotOffer>(offer =>
{
var salesFromSnapshot = (long) offer.Snapshot;
_state.totalSales = salesFromSnapshot;
ConsoleHelper.WriteToConsole(ConsoleColor.DarkGreen,
$"Snapshot was recovered. Total sales from snapshot: {salesFromSnapshot}");
});
}
} |
이 SalesActor 클래스는 **Akka.NET의 ReceivePersistentActor**를 상속하여 **이벤트 소싱(Event Sourcing)**과 스냅샷(Snapshot) 기능을 활용해 상태를 안정적으로 유지하는 **영속 액터(Persistent Actor)**입니다. 아래에 코드 전체 흐름과 구성 요소를 설명드리겠습니다.
판매 이벤트(Sale)를 처리하고 누적된 매출을 상태로 유지
특정 조건(예: 매출 목표 달성)에 도달하면 처리 중단
RavenDB 등을 백엔드로 사용해 이벤트를 저장
일정 횟수마다 스냅샷 저장하여 복구 최적화
PersistenceIdpublic override string PersistenceId => "sales-actor"; |
액터의 고유 식별자로 이벤트와 스냅샷을 식별하기 위한 키
RavenDB 또는 다른 Akka.Persistence backend에서 이 키로 데이터를 저장/조회함
private SalesActorState _state; |
현재 액터 상태를 저장하는 내부 객체
여기서는 totalSales (누적 매출액)를 관리
Command<Sale>)
Command<Sale>(saleInfo => { ... }); |
외부에서 Sale 메시지를 받으면 처리
조건에 따라 이벤트를 Persist하거나 무시
Persist(saleInfo, _ =>
{
_state.totalSales += saleInfo.Price;
if (LastSequenceNr % 5 == 0) {
SaveSnapshot(_state.totalSales);
}
}); |
Persist()는 이벤트 소싱의 핵심: 이벤트를 저장한 후 상태를 변경
5개의 Sale 이벤트마다 SaveSnapshot() 호출 → 빠른 복구용
Sender.Tell(new StopSimulate()); taskCompletion.TrySetResult(true); |
매출 목표(expectedProfit)에 도달하면, 더 이상 이벤트를 저장하지 않고 종료 시그널 전송
TaskCompletionSource는 테스트나 외부 시뮬레이션 종료에 사용
Command<SaveSnapshotSuccess>(success => {
Console.WriteLine(...);
}); |
스냅샷이 성공적으로 저장되었을 때 로깅
필요시 DeleteMessages() 같은 클린업 가능
Recover<>)이벤트 복구
Recover<Sale>(saleInfo => {
_state.totalSales += saleInfo.Price;
}); |
스냅샷 복구
Recover<SnapshotOffer>(offer => {
_state.totalSales = (long)offer.Snapshot;
});
|
가장 최근의 스냅샷부터 시작 → 남은 이벤트만 재생
| 기능 | 설명 |
|---|---|
| 🎯 명령-이벤트 분리 | Sale은 명령(Command), Persist된 이벤트로 상태 변경 |
| 🧠 이벤트 소싱 기반 상태 | 상태 변경 내역은 전부 이벤트로 기록되어 리플레이 가능 |
| 💾 스냅샷 | 매 5건 이벤트마다 현재 상태 저장으로 복구 최적화 |
| 🔄 복구 내장 | 스냅샷 → 이벤트 순으로 복원함 |
| 🚦 목표 달성시 종료 | 외부 TaskCompletionSource로 흐름 제어 |
public class SalesSimulatorActor : ReceiveActor
{
private readonly IActorRef _salesActor;
private ICancelable scheduler;
public SalesSimulatorActor(IActorRef salesActor)
{
_salesActor = salesActor;
// Schedule the first sale simulation immediately and then every 2 seconds:
scheduler = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(TimeSpan.Zero,
TimeSpan.FromSeconds(2), Self, new StartSimulate(), Self);
Receive<StartSimulate>(HandleStart);
Receive<StopSimulate>(HandleStop);
}
private void HandleStart(StartSimulate message)
{
ConsoleHelper.WriteToConsole(ConsoleColor.Black,
$"About to simulate a sale...");
Random random = new Random();
string[] products = { "Apple", "Google", "Nokia", "Xiaomi", "Huawei" };
var randomBrand = products[random.Next(products.Length)];
var randomPrice = random.Next(1, 6) * 100; // 100, 200, 300, 400, or 500
var nextSale = new Sale(randomPrice, randomBrand);
_salesActor.Tell(nextSale);
}
private void HandleStop(StopSimulate message)
{
scheduler.Cancel();
ConsoleHelper.WriteToConsole(ConsoleColor.DarkRed,
"Simulation stopped");
}
} |


여기서 설명하고 작동된 액터모델을 통해 작동된 이벤트 스토어를 확인할수 있습니다.
이벤트 소싱은 저널과 스냅샷이 함께 이용되며 CRUD대비 이벤트를 모두 저장하기때문에 데이터가 비대해질수 있으며
CRUD처럼 마지막 상태데이터를 CQRS방식으로 이용하려면 DurableState Persist를 이용할수도 있습니다.
DurableState는 이벤트 발생시 변화되는 객체의 마지막 상태를 저장하는것을 의미하며
상태로 설계된 객체가 복원을 위해 이용될수 있으며 CRUD랑 유사하게 작동하며
Journal은 발생 이벤트를 모두 기록해 이벤트 소싱과 같이 시계열이 필요하고 이벤트 재생이 필요한곳에 이용될수 있습니다.
SnapShot은 Journal과 함께 이용되어 이벤트소스로부터 너무 많은 재생이 일어나지않게 방지를 하는 장치입니다.
이러한 개발패러다임의 변화는 데이터중심적 기획및 설계도 중요하지만 이벤트드리븐 도입을 통한 이벤트 스토밍 방식으로 복잡한 도메인모델을 가속화하는 방법론으로 확장됩니다. -개발만 이것을 적용한다고 가치있게 작동하지않으며 DDD를 학습하는 개념으로 시작합니다.

이상 RavenDB을 Akka.net과 함께 이용해 이벤트 소싱을 구현하는 샘플을 간략하게 살펴보았으며
여기서 설명된 내용은 Akka.net기반 액터모델을 중심으로한 다양한 샘플기능들을 살펴볼수 있습니다.