제가 다니던 스타트업의 핵심 도메인은 행사를 나타내는 event, 행사의 일자들을 개별적으로 나타내는 daily-event, 그리고 해당 일자의 노선이자 유저가 실제로 예약할 수 있는 노선을 나타내는 shuttle-route으로 이루어져 있었습니다.
셔틀 서비스 특성상 이 세 도메인은 시간에 따라 상태가 변하는 다단계 라이프사이클을 가지고 있었으며, 노선의 운행 시간이 지나면 노선이 종료되고, 일자가 지나면 그 일자가 종료되며, 행사 안의 모든 일자가 종료되면 행사 자체가 종료되는 식이었습니다.
기존 cascading 구조의 한계
기존에는 이 라이프사이클이 cascading 로직으로 구현되어 있었습니다. 처음에는 행사가 종료되면 그 하위에 있는 일자별 행사와 노선들이 모두 같이 종료되는 것이 직관적이라 생각했으나, 오히려 혼동이 가중되는 것을 느끼게 되었습니다.
먼저 어드민에서 운영자가 상태를 조작할 때마다 왜 이런 상태가 됐는지 혼란을 겪었습니다. 일자 하나만 닫았는데 노선까지 같이 닫히는 경우 등이 있었기에, 상태를 조작할 때마다 개발자에게 혹시 이게 다른 거에도 영향을 미칠지 물어보는 경우들이 생기게 되었습니다.
클라이언트 개발자로부터도 비슷한 질문이 들어왔습니다. 어떤 조건에서 어떤 상태로 바뀌는지 한눈에 보이지 않으니 새 화면을 작업할 때마다 동일한 질문이 반복됐습니다.
유지보수 입장에서도 end-event는 end-daily-event use case를 호출하면서 동시에 노선까지 직접 갱신하는 등 use case들이 서로 얽혀있는 구조가 형성되어 있어 한 곳을 수정하면 어디까지 영향이 가는지 예측이 어려웠습니다.
특히 이 문제는 운영 측 요구사항으로 행사에 STAND_BY 상태를 추가하던 중 더욱 체감할 수 있었습니다. 이때 그동안 미뤄두던 tech debt를 청산할 시점이라 판단하여 라이프사이클 구조를 리팩토링하게 되었습니다.
도메인 인과 방향으로 구조 재설계
기존 구조에서는 상위 도메인이 하위 도메인의 상태를 직접 바꿨습니다. 행사를 종료하는 use case는 그 안의 모든 일자를 종료시키고, 그 일자에 속한 모든 노선들을 종료하고, 노선에 걸려있던 빈자리 알림 요청들까지 삭제하는 식으로 한 트랜잭션이 행사/일자/노선 세 레벨에 걸쳐 작업을 수행했습니다.
그러나 도메인의 의미를 기반으로 다시 생각을 해보면 해당 구조가 도메인의 인과 방향과 반대라는 것을 알 수 있습니다. 행사가 종료되었다는 것은 행사 안에 속한 모든 일자들이 종료되었다는 것을 축약하여 나타내는 것입니다. 즉 기존 코드의 전파 방향이었던 행사의 종료가 일자들의 종료를 결정하는 것이 아니라 일자의 종료가 행사의 종료를 결정하는 것이 도메인 의미적으로 옳은 방향이었습니다.
상태 전파 방향 뒤집기
변경된 구조에서 각 도메인은 자기 자신의 종료 조건만 알면 되기에 구조가 훨씬 단순해졌습니다. 노선은 자신의 도착 시간이 지났는지를, 일자는 자신의 날짜가 지났는지를, 행사는 자신의 모든 일자가 끝났는지를 봅니다.
이를 통해 상위 도메인이 하위 도메인을 직접 갱신하지 않도록 하며 각자 자기 영역만 책임지도록 하였습니다.

폴링 기반 상태 진전
이후 하위 도메인이 끝났다는 사실을 상위가 어떻게 인지하도록 할지를 결정해야 했습니다.
선택지는 크게 두 가지였습니다. 도메인 이벤트를 발행해 노선 종료가 일자 종료를 시도시키는 방식이 하나였고, 주기적으로 폴링하며 각 도메인이 자기 종료 조건을 스스로 점검하게 하는 방식이 다른 하나였습니다.
결과적으로는 폴링 방식을 선택했습니다. 도메인 이벤트는 트리거 시점이 명확하다는 장점이 있지만, 노선 종료 이벤트가 유실되거나 처리 중 실패하면 그 위 도메인은 영원히 종료되지 않을 수 있고, 이 경우 별도의 보정 로직이 필요해집니다. 반면 종료 조건은 시간 함수에 의존하는 멱등한 판정이기에 매 주기마다 다시 평가해도 결과가 동일하고 한 번 누락되어도 다음 주기에 자연스럽게 따라잡힙니다.
이를 위해 3시간 주기로 도는 StatusSyncScheduler가 하위에서 상위 순서로 노선 → 일자 → 행사를 차례로 점검합니다. 노선은 도착 시간이 지났는지, 일자는 자신의 날짜가 지났는지, 행사는 모든 일자가 끝났는지를 각자 확인하고, 조건을 만족하면 자기 자신에게만 종료 처리를 합니다.
트랜잭션 경계와 일관성
cascading을 끊으면서 트랜잭션 경계도 다시 그려야 했습니다.
기존 구조에서 행사 종료는 event.end(), 그 안의 모든 일자 종료, 그 일자에 속한 모든 노선의 종료 상태 갱신, 그리고 노선에 걸린 빈자리 알림 요청 삭제까지가 하나의 트랜잭션으로 묶여 있었습니다. 강한 원자성을 얻는 대신 트랜잭션 범위가 행사 하나에 매달린 모든 자손까지 확장되었고, 하위 갱신 한 건의 실패가 행사 종료 전체를 롤백시켰습니다.
새 구조에서는 각 도메인의 상태 변경 use case가 자기 자신에 대한 트랜잭션 하나만 갖습니다. 노선 종료, 일자 종료, 행사 종료가 각각 독립된 트랜잭션이고, 한 레벨의 실패는 그 레벨의 그 항목만 롤백시킵니다.
이 구조를 통해 강한 원자성을 잃었지만 결과적 일관성을 얻을 수 있었습니다. 행사에 속한 일자 10개 중 1개의 종료가 실패하면, 행사 자체의 종료 조건이 충족되지 않아 행사는 그 사이클에서 종료되지 않습니다. 다음 3시간 뒤 사이클에서 실패한 일자가 다시 평가되어 종료되면, 같은 사이클의 행사 점검 단계에서 행사도 이어서 종료됩니다. 즉 한 사이클의 지연 정도로 차이가 메워집니다.
이는 해당 상태 변경이 운영자나 사용자에게 즉시 정합성이 필요한 작업이 아니므로 한 사이클의 지연은 비즈니스적으로 허용 가능했습니다. 또한 사이클 내 실패는 슬랙으로 알림이 가서 운영자가 인지할 수 있도록 했습니다.
상태 전이 규칙을 도메인 내재화
여기서 더 나아가 상태 전이 규칙을 도메인 안에 내재화했습니다.
기존에는 상태 전이 규칙이 어드민 수정 API, 스케줄러, 개별 use case들에 중복되며 흩뿌려져 있었습니다. 이 규칙들을 도메인 메소드 안으로 옮겼습니다.
public end(): Result<void> {
if (this.props.status === EventStatus.ENDED) {
return Result.fail<void>("Event is already ended");
}
if (!this.isAllDailyEventsEnded()) {
return Result.fail<void>("Event를 종료하려면 모든 DailyEvent가 종료 상태여야 합니다.");
}
this.props.status = EventStatus.ENDED;
this.props.updatedAt = getNowDayjs();
return Result.ok<void>();
}
그 결과 규칙이 한 곳에 모이면서 진입점에 따라 검증이 달라지지 않게 되었으며, 상태 변경 규칙을 확인하고 싶을 때 봐야 할 곳이 도메인 메소드 한 곳으로 좁아졌습니다.
마무리
작업 이후로는 운영자가 상태 조작 결과를 예측할 수 있게 됐고 클라이언트 개발자의 동일한 질문이 반복되는 일이 줄었습니다. 새 상태나 새 도메인을 검토할 때도 어디부터 봐야 할지 분명해졌습니다. 실제로 이후 셔틀 상품 도메인을 새로 도입했을 때에도 기존 흐름에 노드 하나를 끼워 넣는 정도의 수정으로 자연스럽게 확장할 수 있었습니다.