이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- domain event와 integration event는 목적, 수명, audience가 어떻게 다른가?
- 내부 도메인 모델을 그대로 Kafka event로 내보내면 어떤 호환성 문제가 생기는가?
- integration event schema에는 어떤 field와 metadata를 안정적으로 포함해야 하는가?
- 백엔드 변경이 downstream consumer를 깨뜨리지 않게 하려면 어떤 mapping 계층이 필요한가?
개요
domain event는 도메인 내부에서 “무슨 일이 일어났는가”를 표현한다.
integration event는 다른 서비스, 데이터 파이프라인, 분석 mart가 장기간 의존할 외부 계약이다.
둘을 분리하지 않으면 내부 리팩터링, enum 변경, 개인정보 필드, 임시 계산 값이 그대로 외부 consumer의 장애가 된다.
목적 차이
| 구분 | Domain Event | Integration Event |
|---|---|---|
| 주 사용자 | 같은 서비스 내부 코드 | 다른 서비스, 데이터 팀, consumer |
| 변경 속도 | 도메인 모델 변화에 맞춰 빠름 | 호환성을 지키며 느리게 변경 |
| 표현 수준 | 내부 상태와 규칙에 가까움 | 외부가 이해할 business fact 중심 |
| 개인정보 | 내부 처리에 필요할 수 있음 | 최소화, 마스킹, 정책 적용 |
| 실패 영향 | 같은 서비스 내부 | downstream 파이프라인 전체 |
domain event는 내부 설계를 돕고, integration event는 조직 간 계약을 만든다.
나쁜 예시
record OrderPaidDomainEvent(
Order order,
User user,
Payment payment,
boolean firstPurchase,
String internalMemo
) {}이 객체를 그대로 Kafka payload로 직렬화하면 다음 문제가 생긴다.
Orderclass field rename이 consumer schema 변경이 된다.User안의 개인정보가 불필요하게 전파된다.- 내부 계산 필드가 외부 계약처럼 굳어진다.
- lazy loading, null, enum 변경이 serialization 장애로 이어질 수 있다.
- consumer는 어떤 field가 안정적인지 알 수 없다.
내부 객체를 외부 계약으로 노출하면 리팩터링 비용이 급격히 커진다.
Integration Event 예시
{
"eventId": "6a0f2d1e-7d0b-4fd3-9a60-1c8f4c3c6c50",
"eventType": "OrderPaid",
"eventVersion": 2,
"eventTime": "2026-06-30T10:15:20Z",
"producer": "order-service",
"aggregateId": "order-10001",
"idempotencyKey": "order-10001:paid:v2",
"payload": {
"orderId": "order-10001",
"customerId": "customer-501",
"paidAmount": 39000,
"currency": "KRW",
"paymentMethod": "CARD",
"orderStatus": "PAID"
}
}integration event는 consumer가 안정적으로 처리할 수 있는 field를 명시한다.
payload에는 “현재 내부 객체”가 아니라 “외부가 알아야 하는 사건”만 둔다.
Mapping 계층
class OrderIntegrationEventMapper {
OrderPaidIntegrationEvent toIntegrationEvent(OrderPaidDomainEvent event) {
Order order = event.order();
return new OrderPaidIntegrationEvent(
UUID.randomUUID(),
"OrderPaid",
2,
event.occurredAt(),
"order-service",
order.id().toString(),
order.id() + ":paid:v2",
new OrderPaidPayload(
order.id().toString(),
order.customerId().toString(),
order.paidAmount().value(),
order.paidAmount().currency(),
order.paymentMethod().name(),
order.status().name()
)
);
}
}이 mapping 계층은 번거로워 보이지만 중요한 완충 장치다.
내부 domain model이 바뀌어도 integration contract를 유지하거나 version을 명시적으로 올릴 수 있다.
Versioning 기준
호환적인 변경:
- optional field 추가
- consumer가 모르는 field를 무시할 수 있는 metadata 추가
- enum 설명 문서 보강
주의가 필요한 변경:
- field rename
- required field 삭제
- enum 의미 변경
- 금액 단위, 시간 기준, key 의미 변경
breaking change가 필요하면 새 event version을 발행하고 consumer 전환 기간을 둔다.
데이터 파이프라인 영향
데이터 팀은 integration event를 source로 사용한다.
따라서 다음 정보가 안정적이어야 한다.
eventId: 중복 처리와 lineage 추적.eventTime: business event가 발생한 시간.publishedAt: broker에 발행된 시간.aggregateId: 재처리와 join 기준.eventVersion: schema 해석 기준.producer: 장애 owner 추적.
이 field가 없으면 mart의 grain, freshness, dedup 기준이 흔들린다.
운영 지표
event_schema_breaking_change_count: 호환성 깨짐이 발견된 횟수.unknown_event_version_count: consumer가 모르는 version 수신 횟수.mapping_failure_count: domain event에서 integration event 변환 실패 횟수.payload_privacy_violation_count: 외부 event에 노출되면 안 되는 field 탐지 횟수.consumer_contract_test_failure: schema contract test 실패 횟수.
이 지표는 단순 publish 성공보다 계약 안정성을 보여준다.
위험 신호!
- domain entity를 그대로 JSON 직렬화해서 topic에 보낸다.
- event version이 없거나 문서와 payload가 다르다.
- consumer가 내부 enum 이름에 직접 의존한다.
- 개인정보 field가 외부 event에 섞인다.
- “내부 리팩터링”이라고 생각한 변경이 mart와 추천 feature를 깨뜨린다.
확인 질문
확인 질문
- domain event와 integration event를 분리하면 내부 리팩터링이 왜 쉬워지는가?
- eventTime과 publishedAt은 각각 어떤 질문에 답하는 시간인가?
- optional field 추가와 required field 삭제의 consumer 영향은 어떻게 다른가?
- mapping 계층이 없으면 개인정보와 내부 enum이 어떻게 외부 계약으로 굳어지는가?
참고 문서
- Apache Kafka Documentation
- Spring for Apache Kafka Reference
- Confluent Schema Registry Documentation
- Martin Kleppmann, Designing Data-Intensive Applications