도메인 서비스 표준에서 규칙의 자리를 「그 규칙이 무엇을 바꾸나」로 정했다. 바뀌는 애그리거트가 하나면 그 애그리거트의 메서드에, 같은 도메인 안의 애그리거트 둘이 함께 바뀌면 도메인 서비스에 둔다. 그런데 한 칸이 비었다. 다른 도메인의 애그리거트 둘이 함께 바뀌는 경우다. 우리 산후조리 관리 서비스는 계약 · 관리사 · 산모처럼 업무 단위마다 패키지를 하나씩 두고, 그 하나를 도메인이라고 부른다. 계약과 관리사는 패키지가 달라서 다른 도메인이다.
관리사 김○○이 회사를 그만뒀다. 그러면 김○○이 배정된 돌봄 계약들도 무언가 처리해야 한다.
관리사도 바뀌고(그만둔 상태가 된다), 계약도 바뀐다(배정을 정리한다). 둘은 다른 도메인이다. 이 장면은 설명하려고 만든 것이고, 우리 코드에 실제로 있는 흐름은 아니다.
이럴 때 쓰는 방법은 크게 둘이다. 관리사 쪽이 계약 쪽을 직접 부르거나, 「그만뒀다」는 사실을 이벤트로 알리거나. 이 글이 답하는 질문은 하나다 — 도메인 이벤트는 「이미 일어난 일」을 어떻게 말하는 객체이고, 메서드 직접 호출과 무엇이 다른가. 둘 중 무엇을 쓸지는 이 글에서 정하지 않는다. 그건 다음 질문들(#202 · #203)에서 정한다.
직접 호출은 「이거 해 줘」이고 이벤트는 「이런 일이 있었어」다
처음 설명을 들었을 때 코드부터 보니 잘 와닿지 않았다. 그래서 회사 안에서 소식을 전하는 장면으로 먼저 놓는다.
직접 호출은 담당자에게 전화해서 시키는 것이다. 「계약팀이죠? 김○○ 그만뒀으니까 배정 풀어 주세요.」
- 전화하는 사람이 계약팀이 있다는 것을 알아야 한다
- 무엇을 해 달라고 할지도 전화하는 사람이 정한다
- 나중에 급여팀에도 알려야 하면 전화를 한 통 더 건다
이벤트는 공지 게시판에 올리는 것이다. 「알림: 김○○ 님이 11월 6일에 퇴사했습니다.」
- 올리는 사람은 누가 이 글을 읽을지 모른다
- 계약팀은 읽고 배정을 풀고, 급여팀은 읽고 정산한다. 할 일은 읽는 쪽이 정한다
- 새 팀이 생겨도 올리는 사람은 바꿀 것이 없다
코드로 옮기면 이렇게 생긴다. 두 코드 모두 관리사 쪽 Service의 퇴사 처리다.
// 직접 호출 — 계약 쪽을 알고, 무엇을 할지 정해서 시킨다
caregiver = caregiver.resign();
careContracts.unassignCaregiver(caregiverId);
// 이벤트 — 일어난 일만 알린다. 누가 듣는지 모른다
caregiver = caregiver.resign();
events.publish(new CaregiverResigned(caregiverId, resignedAt));
- 두 코드 모두 첫 줄에서 관리사를 「그만둔 상태」로 바꾼다
- 직접 호출은 둘째 줄에서 계약 쪽 창구를 불러 「이 관리사의 배정을 풀어」라고 시킨다. 관리사 쪽 코드가 계약 쪽의 이름(
careContracts)과 할 일(unassignCaregiver)을 안다 - 이벤트는 둘째 줄에서 「이 관리사가 이때 그만뒀다」는 객체를 만들어 내보내기만 한다.
resignedAt은 퇴사를 처리한 시각으로, 이벤트를 만드는 관리사 쪽이 넣는다. 계약 쪽 이름은 이 코드 어디에도 없다
계약 쪽은 따로 그 이벤트를 듣고 있다가 받는다. 관리사 쪽이 계약 쪽을 모르는데 어떻게 받을까. 계약 쪽이 「CaregiverResigned가 나오면 나를 불러 달라」고 미리 등록해 두고, 이벤트가 나오면 가운데 있는 도구(Spring 같은 것)가 등록된 쪽을 대신 불러 준다. 게시판에서 「이 게시판 새 글 알림 받기」를 켜 둔 것과 같다.
// 계약 쪽 — 「그만뒀다」를 들으면 무엇을 할지 여기서 정한다
void on(CaregiverResigned event) {
// 이 관리사가 배정된 계약을 찾아 정리한다
}
events.publish(...)와 on(...)은 모양만 보이려고 쓴 이름이다. 실제로 이벤트를 내보내고 듣는 방법(Spring이 주는 도구 등)은 이 글에서 다루지 않는다.
한 줄로 줄이면, 직접 호출은 명령이고 이벤트는 사실을 알리는 것이다. 명령은 부르는 쪽이 「누가 · 무엇을」 정한다. 이벤트는 부르는 쪽이 「무슨 일이 있었나」만 말하고, 「누가 · 무엇을」은 듣는 쪽이 정한다.
도메인 이벤트는 과거형 이름으로 짓고 바꾸지 않으며 언제와 누구의 일인지를 담는다
이벤트가 「이미 일어난 일」을 알리는 객체라는 데서 모양 세 가지가 따라 나온다.
- 이름은 과거형이다.
CaregiverResigned(그만뒀다)처럼 짓는다.ResignCaregiver(그만두게 하라)라고 지으면 다시 명령이 된다 - 만든 뒤에는 바꾸지 않는다. 이미 일어난 일은 되돌릴 수 없으니, 그 기록도 고치지 않는다. 필드를 모두
final로 둔 불변 객체로 만든다 - 언제, 누구에게 일어났는지를 담는다. 공지 글에 날짜와 사람 이름이 적히는 것과 같다. 위의
CaregiverResigned가caregiverId와resignedAt을 들고 있는 이유다
public final class CaregiverResigned {
private final String caregiverId; // 누구에게
private final LocalDateTime resignedAt; // 언제
public CaregiverResigned(String caregiverId, LocalDateTime resignedAt) {
this.caregiverId = caregiverId;
this.resignedAt = resignedAt;
}
// getter 만 있고 setter 는 없다
}
DDD라는 말을 처음 만든 에릭 에반스가 직접 요약해 공개한 『Domain-Driven Design Reference』(2015) 13쪽의 Domain Events 절이 이 중 둘(바꾸지 않는다 · 언제와 누구의 일인지 담는다)을 말한다. 원문은 「보통(ordinarily · typically)」이라고 적었는데, 이 글은 그것을 예외 없는 모양으로 따른다. 과거형 이름은 원문에 적혀 있지 않고, 「이미 일어난 일」이라는 데서 내가 끌어낸 것이다. 절의 첫 문장은 이렇다.
Something happened that domain experts care about.
「현업(도메인 전문가)이 관심을 두는 무언가가 일어났다」는 뜻이다. 그리고 모양에 대해서는 이렇게 적었다.
Domain events are ordinarily immutable, as they are a record of something in the past. In addition to a description of the event, a domain event typically contains a timestamp for the time the event occurred and the identity of entities involved in the event.
「도메인 이벤트는 과거의 기록이라 보통 바꾸지 않는다. 무슨 일인지에 더해, 보통 그 일이 일어난 시각과 관련된 대상의 식별자를 담는다」는 말이다. 위 코드의 resignedAt이 시각이고, caregiverId가 식별자다.
서버가 떴다는 알림은 도메인 이벤트가 아니다
「이벤트」라는 말은 프로그래밍에서 여러 곳에 쓰인다. 버튼을 눌렀다, 서버가 떴다도 이벤트다. 그런데 에반스는 도메인 이벤트를 그런 것과 따로 놓는다.
These are distinct from system events that reflect activity within the software itself.
「이것들은 소프트웨어 자체의 활동을 나타내는 시스템 이벤트와 다르다」는 뜻이다.
가르는 기준은 앞의 첫 문장에 있다. 현업이 관심을 두는 일인가. 「관리사가 그만뒀다」는 산후조리 업무를 하는 사람이 알고 싶어 하는 일이다. 「서버가 떴다」는 개발자만 관심을 둔다.
우리 코드에서 이벤트를 내보내는 곳(publishEvent)과 듣는 곳(@EventListener · @TransactionalEventListener)을 모두 찾아보니(2026-11-04 코드 기준) 내보내는 곳은 없었고, 듣는 곳은 한 곳뿐이었다. 그것은 시스템 이벤트였다.
// TesseractOcrEngine — 서버가 뜬 직후 문서 인식 도구가 있는지 로그로 알린다
@EventListener(ApplicationReadyEvent.class)
void logAvailability() { ... }
@EventListener(ApplicationReadyEvent.class)는 「서버가 다 떴다」는 Spring의 알림을 듣겠다는 표시다- 들으면 문서 인식 도구(tesseract)가 설치되어 있는지 보고 로그를 남긴다
현업은 이 일에 관심이 없다. 그래서 이건 도메인 이벤트가 아니다. 우리 코드에 도메인 이벤트는 아직 하나도 없다. 이벤트를 내보내는 곳이 없으니, 다른 도메인에 닿는 일은 이벤트가 아닌 길, 곧 그 도메인의 창구를 직접 부르는 길로 이루어진다. 우리 표준이 정한 창구는 상대 도메인에 묻는 XxxProvider이고, 이것을 받아 쓰는 Service가 18개다. 앞의 예에서 쓴 careContracts처럼 상대 도메인에 무언가를 시키는 창구는 표준에 아직 없다.
판단 기준 정리
| 질문 | 직접 호출 | 도메인 이벤트 |
|---|---|---|
| 무엇을 말하나 | 「이거 해 줘」(명령) | 「이런 일이 있었어」(사실) |
| 누가 할 일을 정하나 | 부르는 쪽 | 듣는 쪽 |
| 부르는 쪽이 받는 쪽을 아나 | 안다 (이름과 메서드를 부른다) | 모른다 |
| 이름 | 할 일 (unassignCaregiver) |
과거형 사실 (CaregiverResigned) |
| 담는 것 | 할 일에 필요한 인자 | 언제 · 누구에게 일어났나 |
| 이 알림은 도메인 이벤트인가 | 답 |
|---|---|
| 현업이 관심을 두는 일인가 (관리사가 그만뒀다) | 도메인 이벤트다 |
| 소프트웨어 자체의 일인가 (서버가 떴다) | 시스템 이벤트다 |
이 개념을 정리하기까지
시작은 도메인 서비스 표준이 비워 둔 칸이었다. 다른 도메인의 애그리거트 둘이 함께 바뀌는 규칙을 어디에 두는지는 정하지 않고 넘겼다. 그 자리를 채울 후보가 이벤트라서, 이벤트가 무엇인지부터 물었다.
처음 설명은 코드였고, 「더 쉽게」를 들었다. 직접 호출과 이벤트를 코드 두 벌로 놓았더니 잘 와닿지 않았다. 그래서 「담당자에게 전화해서 시키기」와 「공지 게시판에 올리기」로 바꿨고, 그때 이해가 됐다. 이 글이 비유를 코드보다 앞에 둔 이유다.
「그럼 이벤트로 처리하자는 말인가」라는 물음이 나왔다. 아니다. 이 질문은 「무엇인가」까지만 다룬다. 전화가 나은 때도 있다 — 상대가 바로 처리했는지 알 수 있고, 코드를 따라 읽기도 쉽다. 언제 무엇을 쓸지는 #202(이벤트가 필요해지는 때)와 #203(우리는 어떻게 할지)으로 넘겼다.
근거는 에반스의 원문으로 확인했다. 『Domain-Driven Design Reference』(2015) 13쪽 Domain Events 절을 열어 읽었다. 에반스의 생각을 코드로 옮기는 법을 다룬 본 버논의 『Implementing Domain-Driven Design』(2013)은 열어 보지 못해서 이 글에 쓰지 않았다. 「시스템 이벤트와 다르다」는 대목은 원문을 읽다가 따로 넣었고, 우리 코드에서 이벤트를 듣는 곳을 찾아 실제 시스템 이벤트 하나를 예로 들었다.
도메인 이벤트에서 아직 안 본 것
- 이벤트가 필요해지는 때는 언제인가. 다음 질문(#202)이다
- 우리 코드에서 다른 도메인에 닿을 때 직접 부를까, 이벤트로 알릴까. #203에서 코딩 표준으로 정한다
- 이벤트를 실제로 어떻게 내보내고 듣나. 이 글의
events.publish(...)와on(...)은 모양만 보인 것이다. Spring이 주는 도구나, 트랜잭션이 끝난 뒤에 알리는지 같은 것은 보지 않았다
정리
- 직접 호출은 명령이고, 도메인 이벤트는 사실을 알리는 것이다. 직접 호출은 부르는 쪽이 누가 무엇을 할지 정하고, 이벤트는 듣는 쪽이 정한다
- 이벤트는 이미 일어난 일이다. 그래서 이름은 과거형이고, 만든 뒤에는 바꾸지 않으며, 언제 누구에게 일어났는지를 담는다
- 도메인 이벤트는 현업이 관심을 두는 일만이다. 서버가 떴다 같은 소프트웨어 자체의 알림은 시스템 이벤트다
- 우리 코드에는 도메인 이벤트가 아직 없다. 이벤트를 쓸지는 이 글에서 정하지 않았다
자신만의 철학을 만들어가는 중입니다.
댓글남기기