저는 데이터를 다루는 대부분의 상황에서, 사용자 유스케이스에 따라 데이터를 저장하거나 수정하는 작업을 RDB 기반의 CRUD 방식으로 처리해 왔습니다.
자연스럽게 테이블을 만들고, INSERT, UPDATE, DELETE 같은 쓰기 작업을 통해 상태를 변경하는 것이 저에겐 너무나 익숙하고 당연한 일이었죠.
그러던 중 “이벤트 소싱 이해” 번역서의 베타리더를 모집한다는 소식을 접했고, 운 좋게 정식 출간 전 원고를 먼저 읽어볼 수 있는 기회를 얻게 됐습니다.
책을 읽던 중, 옮긴이의 말에서 이런 문장이 눈에 들어왔어요.
“망치를 든 사람에겐 모든 게 못으로 보인다.”
항상 RDB와 CRUD가 유일한 해답이라고만 생각한 저는 이 문장을 보고 정말 망치로 머리를 ‘툭’ 맞은 듯한 느낌이었습니다.
그래서 '이벤트 소싱' 이라는 전혀 다른 방식의 데이터 모델링은 굉장히 신선 했던 것 같아요.
'상태' 가 아닌 '사건' 을 저장하는 이벤트 소싱
이벤트 소싱(Event Sourcing)은 시스템에서 발생한 모든 사실을, 발생 순서대로 불변 이벤트로 저장하는 방식입니다.
우리는 보통, 데이터를 다룰 때 대부분 CRUD 방식을 사용합니다.
사용자의 행동에 따라 데이터를 INSERT, UPDATE, DELETE 로 변경하고 그 결과만 데이터베이스에 기록하죠.
예를 들어, 고객 등급을 프리미엄으로 바꾼다면, 단순히 등급 = '프리미엄'이라는 최신 상태만 저장합니다.
하지만 그 고객이 언제, 왜, 어떤 과정을 거쳐 그렇게 되었는지는, 별도의 이력 테이블을 두지 않는 이상 알 수 없습니다.
하지만 이벤트 소싱은 다릅니다. 이벤트 소싱은 “지금 어떤 상태인가?”보다 “어떤 일이 있었는가?”에 초점을 맞춥니다.
앞선 고객 예시를 이벤트 소싱으로 바꿔보면, 아래와 같은 이벤트들이 순서대로 저장됩니다
- 고객가입됨
- 고객주소변경됨
- 고객등급변경됨
이런 식으로 상태 변화를 모두 이벤트로 기록하면, 시스템이 현재 어떤 상태인지뿐 아니라 어떻게 그 상태에 도달했는지까지 알 수 있어요. 마치 이력을 복기하는 느낌처럼 말이죠.
이벤트 소싱은 단지 저장 방식만 다른 것이 아닙니다.
시스템의 모든 변화를 불변의 기록으로 남기기 때문에 정보를 잃지 않으면서 사용자의 행동 흐름을 쉽게 추론할 수 있고, 필요하다면 과거 상태를 재구성하거나 되돌리는 것을 가능하게 하는 새로운 모델링 패러다임 입니다.
(개인적으로는 JPA를 처음 접했을 때와 같은 느낌이었습니다.)
이벤트 소싱을 구성하는 핵심 요소
이벤트 소싱은 단순히 "이벤트를 저장하는 방식"이 아니라, 시스템 전체를 이벤트를 중심으로 구성하는 모델링 방식입니다. 이 방식을 구성하는 핵심 요소들을 하나씩 살펴볼게요.
🔹 이벤트(Event)
시스템에서 무언가 의미 있는 일이 발생했을 때 그것을 표현한 것입니다.
예: 장바구니에상품추가됨(ItemAddedToCart), 주문완료됨(OrderCompleted)
- 주로 과거형 네이밍을 사용합니다.
- 불변 데이터이며, 한 번 저장되면 수정할 수 없습니다.(오히려 변경할 수 없기에 시스템 유지관리가 단순)
🔹 커맨드(Command)
사용자가 시스템에 무언가 하라고 요청하는 명령입니다.
예: 상품추가요청(AddItem), 주문요청(SubmitOrder)
- 쉽게 말해, 사용자가 “무엇을 하고 싶은지”를 나타냅니다.
- 커맨드를 처리한 결과로 시스템 내에서 이벤트가 발생합니다.
- 즉, 커맨드는 “무슨 일이 일어나야 하는지”를 설명하고, 이벤트는 “실제로 일어난 일”을 기록합니다.
🔹 이벤트 스트림(Event Stream)
특정 Aggregate(도메인 객체)에 대해 저장된 이벤트들의 시간순으로 나열한 배열입니다.
예: 한 사용자의 장바구니에 대한 모든 이벤트 흐름
- 각 스트림은 도메인 객체 하나에 대응됩니다.
- 발생한 이벤트들이 시간 순서대로 쌓이는 곳으로, 이벤트 스토어 내에서 특정 Aggregate에 속한 이벤트들의 집합입니다.
- 이벤트의 순서를 보장하며, 이 순서대로 이벤트를 재생함으로써 해당 Aggregate의 현재 상태를 재구성하는 근거가 됩니다.
🔹 이벤트 스토어(Event Store)
발생한 이벤트를 시간 순서대로 저장하는 저장소입니다.
기존의 RDB 테이블이 아니라, 이벤트 중심의 데이터 저장소를 의미합니다.
- 이벤트 스토어는 여러 이벤트 스트림들을 포함합니다.
- 시스템은 이벤트 스토어에서 해당 Aggregate의 이벤트 스트림을 리플레이(재생) 하여 상태를 복원합니다.
🔹 프로젝션(Projection)
프로젝션은 이벤트를 읽어서 조회하기 편한 형태의 데이터(읽기 모델)로 바꾸는 과정입니다.
예를 들어, 여러 이벤트를 모아 “지금 장바구니에 담긴 상품 목록”을 만드는 것이죠.
- 하나의 프로젝션은 하나 또는 여러 이벤트 스트림에서 이벤트를 가져와 특정 화면이나 뷰를 만듭니다.
- 복잡한 비즈니스 로직 없이, 단순히 현재 상태를 구성하는 데 집중해요.
- 여러 프로젝션을 만들어 다양한 형태의 데이터를 제공할 수 있습니다.
🔹 읽기 모델(Read Model)
프로젝션을 통해 만들어진, 사용자 화면이나 API 응답에 바로 보여줄 수 있는 데이터 구조입니다.
- 시스템에 이미 저장된 이벤트를 바탕으로 데이터를 만들어서 조회할 수 있도록 합니다.
- 이벤트 소싱에서는 쓰기 모델(커맨드 모델)과 분리되어 있습니다.(CQRS)
이제 위에서 살펴본 핵심 요소들을 실제로 어떻게 활용하는지, 회원 등급 변경이라는 간단한 예제로 설명해볼게요.
1. 커맨드(Command) — “회원 등급을 프리미엄으로 변경해주세요”
사용자가 시스템에 요청을 합니다. 이때 전달되는 요청이 바로 커맨드 입니다.
회원등급변경요청(ChangeMembershipLevelToPremium)
이는 “이 회원의 등급을 프리미엄으로 바꿔달라”는 명령이죠.
2. 커맨드 처리와 이벤트(Event) 발생
시스템은 이 커맨드를 받아 비즈니스 규칙을 검증하고, 처리 결과로 이벤트를 만듭니다.
회원등급변경됨(MembershipLevelChangedToPremium)
이 이벤트는 “회원 등급이 실제로 프리미엄으로 변경되었다”는 사실을 불변으로 기록합니다.
3. 이벤트 스트림(Event Stream)에 저장
발생한 이벤트는 해당 회원 Aggregate의 이벤트 스트림에 시간 순서대로 저장됩니다.
예를 들어, 이 회원에 대해 아래와 같이 여러 이벤트가 차곡차곡 쌓일 수 있습니다.
회원가입됨(MemberRegistered)
주소변경됨(AddressChanged)
회원등급변경됨(MembershipLevelChangedToPremium)
4. 이벤트 스토어(Event Store)에 보관
이 모든 이벤트 스트림은 이벤트 소싱 시스템의 중심 저장소인 이벤트 스토어에 보관됩니다.
이벤트 스토어는 각 회원별로, 또는 각 도메인 객체별로 이벤트를 모아두고, 필요할 때 재생해서 현재 상태를 재구성할 수 있게 합니다.
5. 프로젝션(Projection) — 읽기 모델 구성
시스템은 저장된 이벤트들을 읽어서, 사용자가 쉽게 볼 수 있는 형태로 가공합니다.
예를 들어, 여러 이벤트를 모아 현재 회원의 프로필 화면에 보여줄 “회원 등급”을 포함한 정보를 만듭니다.
{
회원ID: 123,
이름: '홍길동',
등급: '프리미엄',
가입일: '2023-01-01'
}
6. 읽기 모델을 통한 조회
사용자는 이 읽기 모델을 조회해, 현재 회원의 상태(프리미엄 등급)를 확인할 수 있습니다.
이처럼 이벤트 소싱 시스템은 커맨드 → 이벤트 → 이벤트 스트림 → 이벤트 스토어 → 프로젝션 → 읽기 모델의 흐름으로 동작합니다.
그럼 이제 전통적인 CRUD 방식과 이벤트 소싱을 코드 레벨에서 비교해보겠습니다.
전통적인 CRUD 방식과 이벤트 소싱의 코드 비교
쓰기 작업의 유스케이스와 읽기 작업의 유스케이스를 분리해 비교해 보겠습니다.
쓰기 작업 CRUD 방식 - 회원 등급 변경
// 회원 정보 상태를 직접 변경하는 전통적인 방식
@Entity
class Member(
@Id
val memberId: String,
var membershipLevel: MembershipLevel
// ...
) {
fun upgradeToPremium() {
membershipLevel = MembershipLevel.PREMIEM
}
}
@Service
class MemberService {
@Transaction
fun upgradeMemberToPremium(
memberId: String
) {
val member = memberRepository.findById(memberId) // DB에서 상태 조회 후
.orElseThrow { IllegalArgumentException("회원이 존재하지 않습니다.") }
member.upgradeToPremium() // 상태 직접 변경 (Dirty Checking
}
}
- membershipLevel 값을 직접 바꾸고 최신 상태만 DB에 저장합니다.
- 과거 변경 이력은 별도로 저장하지 않습니다.
- 시스템이 “현재 상태”만 알고, “어떤 과정으로 여기까지 왔는지는 모르는” 구조입니다.
쓰기 작업 이벤트 소싱 방식 - 회원 등급 변경
아래 예시는 이벤트 소싱 전용 프레임워크 없이,이벤트 소싱의 핵심 개념과 흐름을 쉽게 이해할 수 있는 POJO 형태로 구현한 코드입니다.
실제로는 프레임워크를 주로 사용합니다.(ex. Axon)
1. 이벤트 정의
interface MemberEvent
data class MemberRegistered(
val memberId: String,
val initialLevel: String
) : MemberEvent
data class MembershipLevelChanged(
val memberId: String,
val newLevel: String
) : MemberEvent
- MemberEvent는 시스템에서 일어난 중요한 사실을 나타내는 이벤트들의 공통 인터페이스입니다.
- 이벤트는 불변(immutable)하며, 발생한 사실을 과거 시제로 표현합니다.
- 예를 들어 MembershipLevelChanged 이벤트는 회원 등급이 어떻게 변경되었는지를 구체적으로 기록합니다.
2. Aggregate (도메인 객체)
// Aggregate
class MemberAggregate(
val memberId: String,
var membershipLevel: MembershipLevel
) {
private val changes = mutableListOf<MemberEvent>()
fun upgradeToPremium() {
applyChange(
event = MembershipLevelChanged(
memberId = memberId,
newLevel = MembershipLevel.PREMIUM
)
)
}
private fun applyChange(event: MemberEvent) {
when (event) {
is MemberEvent.MemberRegistered -> {
membershipLevel = event.initialLevel
}
is MemberEvent.MembershipLevelChanged -> {
membershipLevel = event.newLevel
}
}
changes.add(event)
}
fun getUncommittedChanges(): List<MemberEvent> = changes
fun markChangesCommitted() = changes.clear()
}
- Aggregate는 이벤트를 기반으로 자신의 상태를 재구성하고, 새로운 이벤트를 생성하는 책임을 가집니다.
- applyChange 함수는 이벤트가 발생할 때마다 상태를 변경하고, 변경된 이벤트를 별도 리스트에 저장합니다.
- 이렇게 저장된 변경 이벤트는 나중에 이벤트 스토어에 커밋됩니다.
3. Service 레이어
@Service
class MemberService(
private val eventStore: EventStore
) {
@Transactional
fun upgradeMemberToPremium(
memberId: String
) {
// 해당 회원에 저장된 모든 이벤트 불러오기 (이벤트 스트림 로드)
val events = eventStore.loadEvents(memberId)
val aggregate = MemberAggregate(memberId, MembershipLevel.BASIC)
// 불러온 이벤트들을 차례로 적용하여 현재 상태 복원
events.forEach { aggregate.applyChange(it) }
// 등급 업그레이드 명령 실행 -> 이벤트 생성 및 상태 변경
aggregate.upgradeToPremium()
// 변경된 이벤트들을 이벤트 스토어에 저장 (커밋 전 이벤트)
eventStore.saveEvents(memberId, aggregate.getUncommittedChanges())
// 커밋된 이벤트 목록 초기화
aggregate.markChangesCommitted()
}
}
- 상태 변화를 모두 이벤트로 기록하여 시스템의 변경 이력을 보존합니다.
- 이벤트를 순서대로 재생해 현재 상태를 언제든 재구성할 수 있습니다.
4. 이벤트 스토어
interface EventStore {
fun loadEvents(aggregateId: String): List<MemberEvent>
fun saveEvents(aggregateId: String, events: List<MemberEvent>)
}
- 이벤트 스토어는 이벤트를 시간순으로 저장하고 관리하는 저장소입니다.
- 실무에서는 보통 데이터베이스, 메시지 큐, 또는 전문 이벤트 스토어 솔루션이 구현체가 됩니다.
읽기 작업 CRUD 방식 - 회원 정보 조회
data MemberInfo(
val memberId: Long,
val name: String,
var joinedAt: LocalDateTime,
var membershipLevel: MembershipLevel
// ...
)
@Service
class MemberService(
private val memberRepository: MemberRepository
) {
fun getMemberInfo(memberId: String): MemberInfo {
val member = memberRepository.findById(memberId)
.orElseThrow { IllegalArgumentException("회원이 존재하지 않습니다.") }
return MemberInfo(
memberId = member.memberId,
name = member.name,
joinedAt = member.joinedAt,
membershipLevel = member.membershipLevel,
// ...
)
}
}
- 전통적인 방식에서는 RDB에 저장된 현재 상태를 직접 조회합니다.
- 단순한 SQL 조회만으로 정보를 얻을 수 있어 성능도 좋고 구현도 간단합니다.
- 하지만 이 방식은 "어떻게 이 상태가 되었는가?"에 대한 변경 이력 정보는 알 수 없습니다
읽기 작업 이벤트 소싱 방식 - 회원 정보 조회
1. 프로젝션 Entity, DTO
// 읽기 모델로 사용되는 Projection Entity
// 실제로는 RDB 테이블로 매핑됩니다
@Entity
data class MemberProjection(
@Id
val memberId: String,
var name: String,
var joinedAt: LocalDateTime,
var membershipLevel: MembershipLevel
// ... 회원 정보를 보여줄 요소로 구성
)
data MemberInfo(
val memberId: Long,
val name: String,
var joinedAt: LocalDateTime,
var membershipLevel: MembershipLevel
// ...
)
2. 프로젝션 이벤트 핸들러
@Component
class MembershipLevelProjection(
private val projectionRepository: MemberProjectionRepository
) {
fun on(event: MemberEvent) {
when (event) {
// 회원 가입 이벤트가 발생하면, 그 정보를 기반으로 Projection 테이블에 회원 정보를 저장
is MemberRegistered -> {
projectionRepository.save(
MemberProjection(
memberId = event.memberId,
name = event.name,
joinedAt = event.registeredAt,
membershipLevel = event.initialLevel
)
)
}
is MembershipLevelChanged -> {
// 등급 변경 이벤트가 발생하면, Projection 테이블의 값을 업데이트
val projection = projectionRepository.findById(event.memberId)
.orElseThrow { IllegalStateException("회원이 존재하지 않습니다.") }
projection.membershipLevel = event.newLevel
projectionRepository.save(projection)
}
}
}
}
data class MemberRegistered(
val memberId: String,
val name: String,
val registeredAt: LocalDateTime,
val initialLevel: MembershipLevel
) : MemberEvent
상태를 만들기 위한 모든 정보가 이벤트 안에 포함되어 있어야 합니다.
3. 읽기 서비스
@Service
class MemberQueryService(
private val projectionRepository: MemberProjectionRepository
) {
fun getMemberInfo(memberId: String): MemberDto {
val projection = projectionRepository.findById(memberId)
.orElseThrow { IllegalArgumentException("회원이 존재하지 않습니다.") }
return MemberDto(
memberId = projection.memberId,
name = projection.name,
joinedAt = projection.joinedAt,
membershipLevel = projection.membershipLevel
)
}
}
- 이벤트 소싱에서는 회원 상태를 DB에 직접 저장하지 않습니다.
- 대신 MemberRegistered, MembershipLevelChanged 같은 이벤트를 저장하고,
- 이 이벤트들을 읽어들여 상태를 계산하거나, 별도의 Projection 테이블에 미리 반영해 둡니다.
- Projection은 단순 조회에 최적화된 읽기 모델로, 읽기 모델을 RDB로 예시를 들었을 뿐, 다른 저장소가 될 수 있습니다.
이벤트 소싱을 도입하기 전에 고려할 점
처음엔 이벤트 소싱이 마치 ‘모든 문제를 해결해주는 모델링 방식’처럼 느껴질 수도 있지만,
실제로 적용할 때는 몇 가지 고려할 점이 있을 것 같아요.
✅ 러닝 커브
- 저처럼 CRUD 방식에 익숙한 개발자에게는 이벤트 중심의 모델링이 낯설고 어렵게 느껴질 수 있을 것 같습니다.
- 특히 상태를 직접 변경하지 않고, 모든 변화가 이벤트를 통해 일어나도록 설계하려면 도메인 중심 설계(DDD) 와 CQRS 대한 이해도 함께 필요합니다.
✅ 인프라 복잡도가 증가합니다.
- 이벤트 스토어, 프로젝션, 이벤트 처리기 등 기존 CRUD 기반 아키텍처보다 컴포넌트가 많아집니다.
- 단순한 CRUD만으로 충분한 시스템이라면 과도한 설계일 수 있어요.
✅ 데이터 정합성은 ‘최종 일관성’에 기반합니다
- 이벤트 처리와 읽기 모델이 비동기로 동작하기 때문에, 즉시 데이터 일관성을 보장하기 어렵고 최종 일관성에 의존하게 됩니다.
- 결국 읽기 모델의 일관성은 약간 지연될 수밖에 없습니다.
✅ 분산 환경에서 강력한 이점을 가집니다
- 이벤트 소싱은 이벤트 스트림을 통해 상태 변화를 기록하므로, 여러 서비스나 서버에 걸쳐 데이터 일관성을 관리하기가 용이합니다.
- 마이크로서비스나 분산 시스템 환경에서 확장성과 복원력을 확보하는 데는 더 적합한 것 같습니다.
그런데도 이벤트 소싱을 고려할만한 이유
그럼에도 불구하고 이벤트 소싱이 매력적인 이유는 분명합니다.
- 모든 변경 이력의 저장하기 때문에 언제든 과거 상태로 돌아가거나, 시스템을 복기할 수 있습니다.
- 상태가 아니라 행동(이벤트)을 중심으로 기록되기에, 문제 발생 시 원인을 추적하기 훨씬 쉽습니다.
- 기존 시스템에서 별도 로그나 집계를 통해 얻던 데이터가 이벤트 소싱에선 ‘처음부터 존재’합니다.
마무리 — 도구를 하나 더 알게 되었다는 것
이벤트 소싱을 이해하게되면서 느꼈던 가장 큰 수확은 단지 기술 하나를 배웠다는 것보다도, ‘세상을 보는 새로운 시각’을 얻게 되었다는 점 이었어요.
RDB 기반의 CRUD만이 전부라고 믿었던 저에게 이벤트 소싱은 분명히 낯설었고, 처음엔 부담스럽기도 했습니다.
하지만 지금은 적어도 이런 생각을 할 수 있게 되었어요.
“이 유스케이스는 정말 CRUD로만 풀 수 있는 문제일까?
아니면 이벤트 기반 접근이 더 나은 선택일 수도 있지 않을까?”
도구가 늘어나면 선택의 폭이 넓어지고 그 선택의 폭은 결국 더 나은 시스템을 만드는 바탕이 되어주는 것 같아요.
제 글이 이벤트 소싱을 이해하는데 도움이 되었거나, 이벤트 소싱에 대해 더 깊이있게 학습하고 싶으시다면 "이벤트 소싱 이해" 서적을 추천드립니다!
이벤트 소싱 이해
이벤트 모델링과 이벤트 소싱을 결합하여 시스템을 계획하고 구현하는 일련의 과정을 다룬 책으로, 소프트웨어 시스템을 유지 관리하는 실용적인 접근 방법을 안내합니다. 입문서는 아니지만
leanpub.com
'외부활동 > 베타리더, 리뷰어, 서평' 카테고리의 다른 글
| 『그림으로 이해하는 알고리즘』 서적 리뷰 (5) | 2024.09.08 |
|---|
댓글