
프로그래밍을 처음 배울 때 이벤트는 보통 사용자가 버튼을 클릭하거나 키보드를 누르면 실행되는 동작이라고 배운다.
button.addEventListener(
"click",
() => {
console.log(
"버튼을 클릭했다."
);
}
);
사용자가 버튼을 클릭하면 등록된 함수가 실행된다.
이벤트와 이벤트 핸들러의 기본적인 관계를 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하기 시작하면 이벤트는 화면에서 발생하는 클릭에만 머물지 않는다.
쿠폰 서비스에서는 다음과 같은 일들이 모두 이벤트가 될 수 있다.
각 이벤트는 어떤 일이 발생했다는 사실을 나타내며, 시스템의 다른 반응을 시작할 수 있다.
따라서 실제 서비스에서는 더 많은 질문이 필요하다.
실제 서비스에서 이벤트는 버튼 클릭을 처리하는 문법이 아니다. 사용자와 시스템에서 발생한 사실을 표현하고, 그 사실에 필요한 반응을 책임별로 연결하는 방법이다.
쿠폰 발행 버튼에 이벤트 핸들러를 등록할 수 있다.
<button
onClick={handleClick}
>
쿠폰 발행
</button>
이 코드는 사용자가 버튼을 클릭했다는 UI 이벤트를 처리한다.
하지만 클릭이 곧 쿠폰 발행 완료를 의미하지는 않는다.
async function handleClick() {
await issueCoupon(formData);
}
요청 과정에서는 여러 결과가 발생할 수 있다.
따라서 다음 두 사건은 구분해야 한다.
사용자가 쿠폰 발행 버튼을 클릭했다.
쿠폰이 성공적으로 발행되었다.
첫 번째는 사용자의 의도를 보여주는 UI 이벤트다.
두 번째는 서비스 안에서 확인된 비즈니스 사실이다.
flowchart LR
A[버튼 클릭]
--> B[발행 요청]
--> C[입력·권한·정책 확인]
--> D[쿠폰 저장]
--> E[쿠폰 발행 완료]
클릭 이벤트를 처리했다는 사실만으로 서비스 작업이 완료되었다고 판단해서는 안 된다.
사용자가 쿠폰 발행을 요청했다고 생각해 보자.
issueCoupon({
title: "Coffee Date",
recipientId: "user_chris",
});
이 코드는 아직 발생하지 않은 행동을 요청한다.
이를 명령이라고 볼 수 있다.
type IssueCouponCommand = {
title: string;
recipientId: string;
issuerId: string;
};
명령은 다음과 같은 의미를 가진다.
이 작업을 수행해 달라.
반면 쿠폰이 성공적으로 발행된 뒤 다음 사실을 표현할 수 있다.
type CouponIssuedEvent = {
couponId: string;
recipientId: string;
occurredAt: Date;
};
이벤트는 다음과 같은 의미를 가진다.
이 일이 이미 발생했다.
명령과 이벤트를 구분하면 이름도 자연스럽게 달라진다.
| 종류 | 예시 | 의미 |
|---|---|---|
| 명령 | IssueCoupon | 쿠폰을 발행해 달라 |
| 이벤트 | CouponIssued | 쿠폰이 발행되었다 |
| 명령 | RedeemCoupon | 쿠폰을 사용해 달라 |
| 이벤트 | CouponRedeemed | 쿠폰이 사용되었다 |
| 명령 | PauseCoupon | 쿠폰을 일시 정지해 달라 |
| 이벤트 | CouponPaused | 쿠폰이 일시 정지되었다 |
이벤트 이름을 완료된 사실처럼 표현하면 아직 실행되지 않은 요청과 이미 발생한 결과를 구분하기 쉽다.
다음 이벤트 이름은 기술적 동작을 보여준다.
eventBus.publish(
"database_row_inserted",
data
);
데이터베이스 행이 추가되었다는 사실은 알 수 있지만 서비스에서 무슨 일이 일어났는지는 알기 어렵다.
서비스의 의미를 이름에 담을 수 있다.
eventBus.publish(
"coupon.issued",
{
couponId: coupon.id,
recipientId:
coupon.recipientId,
occurredAt: now,
}
);
이제 구독자는 Prisma나 SQL을 몰라도 쿠폰이 발행되었다는 사실에 반응할 수 있다.
eventBus.subscribe(
"coupon.issued",
sendCouponNotification
);
이벤트 이름은 “어떤 코드가 실행되었는가?”보다 “서비스에서 어떤 사실이 생겼는가?”를 설명해야 한다.
현재 쿠폰 상태만 저장하면 지금 어떤 모습인지는 알 수 있다.
const coupon = {
status: "completed",
remainingUses: 0,
};
하지만 왜 완료되었는지는 알기 어렵다.
상태를 바꾼 행동에서 이벤트를 만들 수 있다.
function redeemCoupon(
coupon: Coupon,
now: Date
) {
const remainingUses =
coupon.remainingUses - 1;
const events: DomainEvent[] = [
{
type: "coupon.redeemed",
couponId: coupon.id,
occurredAt: now,
},
];
if (remainingUses === 0) {
events.push({
type: "coupon.completed",
couponId: coupon.id,
occurredAt: now,
});
}
return {
coupon: {
...coupon,
remainingUses,
status:
remainingUses === 0
? "completed"
: "active",
},
events,
};
}
status: "completed"는 현재 결과를 보여준다.
coupon.completed는 그 결과를 만든 사실을 표현한다.
상태와 이벤트는 서로 경쟁하는 개념이 아니다.
쿠폰 발행 함수 안에서 모든 후속 작업을 직접 처리할 수 있다.
async function issueCoupon(
input: IssueCouponInput
) {
const coupon =
await couponRepository.save(
createCoupon(input)
);
await emailService.send(coupon);
await analytics.track(coupon);
await auditLog.write(coupon);
await recommendation.refresh(
coupon.recipientId
);
return coupon;
}
쿠폰 발행 사용 사례가 이메일, 분석, 감사 로그, 추천 시스템을 모두 알고 있다.
후속 기능이 추가될수록 이 함수의 책임과 실패 가능성이 커진다.
쿠폰 발행이라는 사실을 이벤트로 표현할 수 있다.
await eventBus.publish({
type: "coupon.issued",
couponId: coupon.id,
recipientId:
coupon.recipientId,
occurredAt: now,
});
각 책임은 필요한 반응을 등록한다.
eventBus.subscribe(
"coupon.issued",
sendCouponNotification
);
eventBus.subscribe(
"coupon.issued",
recordCouponAnalytics
);
eventBus.subscribe(
"coupon.issued",
writeCouponAuditLog
);
이제 쿠폰 발행 로직은 후속 작업의 모든 구현을 알 필요가 줄어든다.
하지만 결합이 완전히 사라진 것은 아니다.
발행자는 이벤트의 계약에 의존하고, 구독자는 그 이벤트가 발생한다는 사실에 의존한다.
이벤트는 의존성을 없애는 도구가 아니라 직접적인 호출 관계를 사실 중심의 계약으로 바꾸는 방법이다.
쿠폰을 저장하기 전에 반드시 수신자가 존재하는지 확인해야 한다고 생각해 보자.
const recipient =
await userRepository.findById(
input.recipientId
);
if (!recipient) {
throw new RecipientNotFoundError();
}
이 확인을 비동기 이벤트 핸들러로 넘기면 쿠폰 발행 함수는 결과를 기다리지 않을 수 있다.
eventBus.publish({
type: "recipient.validation_requested",
recipientId:
input.recipientId,
});
수신자가 없더라도 쿠폰이 먼저 저장될 수 있다.
쿠폰 발행의 성공 조건에 필요한 작업은 이벤트 뒤로 숨기면 안 된다.
다음과 같이 구분할 수 있다.
| 작업 | 처리 방식 |
|---|---|
| 입력과 권한 검증 | 발행 전에 직접 처리 |
| 쿠폰 생성 규칙 적용 | 발행 작업 안에서 처리 |
| 데이터베이스 저장 | 성공 조건에 포함 |
| 알림 전송 | 정책에 따라 후속 이벤트 처리 가능 |
| 분석 데이터 기록 | 후속 이벤트 처리 가능 |
| 추천 정보 갱신 | 후속 이벤트 처리 가능 |
이벤트를 사용할지는 “코드를 분리하고 싶은가?”만으로 결정하지 않는다.
이 반응이 원래 작업의 성공 조건에 포함되는가?
이 질문에 따라 직접 호출과 이벤트 처리를 선택해야 한다.
쿠폰 발행 이벤트에 쿠폰 객체 전체를 포함할 수 있다.
eventBus.publish({
type: "coupon.issued",
coupon,
});
간단해 보이지만 쿠폰 객체의 모든 필드가 이벤트 소비자에게 공개된다.
나중에 쿠폰 모델이 바뀌면 여러 소비자가 영향을 받을 수 있다.
민감하거나 필요하지 않은 값도 전달될 수 있다.
type CouponIssuedEvent = {
eventId: string;
couponId: string;
recipientId: string;
occurredAt: string;
version: 1;
};
이벤트를 처리하는 데 필요한 의미만 포함하면 계약의 범위를 줄일 수 있다.
반대로 ID만 전달하면 소비자가 다시 데이터를 조회해야 한다.
const coupon =
await couponRepository.findById(
event.couponId
);
이때 조회한 쿠폰은 이벤트가 발생한 시점과 다른 상태일 수 있다.
따라서 이벤트 데이터는 항상 최소화하면 된다는 뜻도 아니다.
다음 질문으로 결정해야 한다.
React 컴포넌트에서 쿠폰 선택 이벤트를 처리할 수 있다.
<CouponCard
onSelect={() =>
setSelectedCouponId(
coupon.id
)
}
/>
이 이벤트는 컴포넌트 사이의 UI 상호작용을 연결한다.
데이터베이스에 영구 기록할 필요는 없다.
반면 쿠폰 사용 완료 이벤트는 서비스의 사실을 표현한다.
type CouponRedeemedEvent = {
couponId: string;
userId: string;
occurredAt: string;
};
이 이벤트는 알림, 감사 기록, 분석 또는 다른 서비스와 연결될 수 있다.
| 이벤트 종류 | 예시 | 주요 범위 |
|---|---|---|
| DOM 이벤트 | click, submit | 브라우저와 요소 |
| 컴포넌트 이벤트 | onSelect, onClose | UI 컴포넌트 사이 |
| 도메인 이벤트 | coupon.redeemed | 한 서비스의 비즈니스 규칙 |
| 통합 이벤트 | coupon.redemption.completed | 서비스 사이의 계약 |
모두 이벤트라는 이름을 사용하지만 생명주기와 신뢰 수준, 실패 처리 방식은 다르다.
하나의 이벤트 시스템으로 모든 종류를 동일하게 다룰 필요는 없다.
메모리 안에서 이벤트 핸들러를 즉시 실행할 수 있다.
await eventBus.publish(
couponIssued
);
publish가 모든 핸들러의 완료를 기다린다면 알림 전송 실패가 쿠폰 발행 요청까지 실패시킬 수 있다.
쿠폰 저장 성공
→ 이메일 전송 실패
→ API 응답 실패
사용자는 다시 버튼을 누를 수 있고, 쿠폰이 중복 발행될 수도 있다.
메시지 큐에 이벤트를 저장하고 후속 작업을 나중에 처리할 수도 있다.
await eventQueue.enqueue(
couponIssued
);
이 경우 쿠폰 발행 응답은 먼저 성공할 수 있다.
알림은 잠시 뒤 처리되거나 재시도된다.
선택 기준은 단순히 성능이 아니다.
비동기 이벤트는 실패를 없애지 않는다.
실패가 발생하는 위치와 시점을 바꾸며, 재시도와 모니터링 책임을 추가한다.
메시지 시스템은 처리 실패를 복구하기 위해 같은 이벤트를 다시 전달할 수 있다.
async function handleCouponIssued(
event: CouponIssuedEvent
) {
await sendNotification(
event.recipientId
);
}
같은 이벤트가 두 번 처리되면 수신자에게 같은 알림이 두 번 전송될 수 있다.
이벤트 ID를 사용해 이미 처리했는지 확인할 수 있다.
if (
await processedEventRepository
.exists(event.eventId)
) {
return;
}
await sendNotification(
event.recipientId
);
await processedEventRepository
.save(event.eventId);
가능하면 처리 작업과 이벤트 기록을 하나의 트랜잭션으로 보호하거나, 외부 API가 제공하는 멱등성 키를 사용할 수 있다.
순서도 보장되지 않을 수 있다.
coupon.paused
coupon.resumed
네트워크 상황에 따라 resumed가 먼저 도착하면 오래된 paused 이벤트가 최종 상태를 덮어쓸 수 있다.
이벤트 버전이나 대상 데이터의 버전을 비교할 수 있다.
if (
event.aggregateVersion <=
currentVersion
) {
return;
}
이벤트 기반 처리를 설계할 때는 정확히 한 번만, 항상 순서대로 도착한다고 가정하지 않는 편이 안전하다.
쿠폰을 데이터베이스에 저장한 뒤 이벤트를 발행한다고 생각해 보자.
const coupon =
await couponRepository.save(
newCoupon
);
await eventBus.publish({
type: "coupon.issued",
couponId: coupon.id,
});
쿠폰 저장은 성공했지만 프로세스가 이벤트 발행 전에 중단될 수 있다.
그러면 쿠폰은 존재하지만 알림과 분석 작업은 시작되지 않는다.
반대로 이벤트를 먼저 발행하면 구독자가 아직 저장되지 않은 쿠폰을 조회할 수 있다.
데이터 변경과 발행할 이벤트를 같은 데이터베이스 트랜잭션에 기록하는 방법을 사용할 수 있다.
await database.transaction(
async (transaction) => {
await transaction.coupon.create({
data: coupon,
});
await transaction.outbox.create({
data: {
type: "coupon.issued",
payload: {
couponId: coupon.id,
},
},
});
}
);
별도의 작업자가 아웃박스에 기록된 이벤트를 메시지 시스템으로 전달한다.
flowchart LR
A[쿠폰 저장 트랜잭션]
--> B[쿠폰 행 저장]
A --> C[아웃박스 이벤트 저장]
C --> D[이벤트 발행 작업자]
D --> E[메시지 시스템]
E --> F[알림·분석 처리]
모든 서비스에 아웃박스 패턴이 필요한 것은 아니다.
하지만 데이터 저장과 이벤트 발행이 모두 빠지면 안 되는 중요한 작업이라면 두 작업 사이의 실패 가능성을 고려해야 한다.
다음 코드는 쿠폰 하나를 발행한다.
await issueCoupon(input);
하지만 coupon.issued에 많은 핸들러가 연결되어 있다면 실제로는 여러 작업이 실행될 수 있다.
호출부만 읽어서는 전체 영향을 알기 어려워진다.
이벤트를 사용하면 발행자와 소비자의 직접 결합은 줄어들 수 있지만 실행 흐름의 가시성도 낮아질 수 있다.
따라서 다음 정보가 필요하다.
logger.info(
"coupon_event_processed",
{
eventId: event.eventId,
eventType: event.type,
handler:
"sendCouponNotification",
}
);
좋은 이벤트 설계는 코드를 느슨하게 연결하는 것뿐 아니라 운영 중 흐름을 다시 추적할 수 있게 만든다.
button.onclick = () => {
analytics.track(
"coupon_issued"
);
};
클릭 후 검증이나 저장이 실패할 수 있다.
const coupon =
await issueCoupon(input);
analytics.track(
"coupon_issued",
{
couponId: coupon.id,
}
);
성공이 확정된 시점의 사실을 기록해야 한다.
eventBus.publish(
"issue_coupon",
data
);
요청인지 완료된 사실인지 불분명하다.
execute(
"IssueCoupon",
command
);
publish(
"CouponIssued",
event
);
수행 요청과 발생한 결과를 구분한다.
publish({
type: "coupon.issued",
coupon,
});
내부 구조와 민감한 정보가 소비자 계약으로 퍼질 수 있다.
publish({
type: "coupon.issued",
eventId,
couponId: coupon.id,
recipientId:
coupon.recipientId,
occurredAt,
});
이벤트의 의미에 필요한 데이터만 계약으로 제공한다.
publish({
type:
"coupon.validation_requested",
});
필수 검증이 끝나기 전에 쿠폰이 저장될 수 있다.
원래 작업이 성공하기 위해 반드시 필요한 규칙은 작업 내부에서 확인한다.
await giveReward(
event.userId
);
재전달되면 보상이 중복될 수 있다.
이벤트 ID, 고유 제약 또는 멱등성 키를 이용해 반복 처리를 안전하게 만든다.
await eventQueue.enqueue(
event
);
큐에 넣는 데 성공해도 소비자가 처리하지 못할 수 있다.
재시도, 최종 실패 저장소, 모니터링과 추적 방식을 함께 설계해야 한다.
publish("coupon_title_trimmed");
publish("coupon_id_created");
publish("coupon_object_built");
하나의 작업을 이해하기 위해 지나치게 많은 간접 흐름을 따라가야 한다.
다른 책임이 실제로 반응해야 하는 중요한 사실인지 확인한 뒤 이벤트로 표현한다.
이벤트는 어떤 일이 발생했을 때 코드를 실행하도록 연결하는 방법이다.
button.addEventListener(
"click",
handleClick
);
입문 단계에서는 버튼 클릭과 핸들러의 관계를 이해하는 것으로 충분하다.
하지만 실제 서비스의 이벤트는 화면 상호작용보다 넓은 의미를 가진다.
type CouponIssuedEvent = {
eventId: string;
couponId: string;
recipientId: string;
occurredAt: string;
};
CouponIssued는 버튼을 눌렀다는 뜻이 아니다.
검증과 비즈니스 규칙을 통과하고 쿠폰이 실제로 발행되었다는 사실을 의미한다.
좋은 이벤트 설계는 다음 내용을 분명하게 만든다.
이벤트의 흐름은 다음과 같이 볼 수 있다.
flowchart LR
A[사용자 또는 시스템 행동]
--> B[비즈니스 규칙 확인]
--> C[상태 변경 확정]
--> D[이벤트 발행]
--> E[독립된 책임의 반응]
--> F[재시도와 모니터링]
이벤트를 설계한다는 것은 결국 다음 질문에 답하는 일이다.
서비스에서 어떤 사실이 발생했으며, 그 사실을 알아야 하는 책임들이 어떻게 안전하고 추적 가능한 방식으로 반응하게 할 것인가?
이벤트는 버튼 클릭을 처리하는 문법이 아니다.
사용자 행동과 시스템의 상태 변화를 의미 있는 사실로 표현하고, 그 사실에 필요한 반응을 연결하는 방법이다.