읽기/쓰기 분리 & 도메인 이벤트 아키텍처¶
상태: 설계 (구현 예정) · 추적: #50
이 문서는 읽기 경로와 쓰기 경로의 책임을 분리하고, 도메인 간 전파를 외부 broker 없이 in-process Message Bus로 처리하기 위한 설계를 설명합니다. 대상 환경은 단일 프로세스 · 단일 DB · 요청당 단일 세션 · 단일 트랜잭션 · 강한 일관성입니다.
1. 배경¶
현재 구조에는 두 가지 결합 문제가 있습니다.
| 문제 | 현재 동작 | 영향 |
|---|---|---|
| 읽기가 DB를 변경 | GET이 get_db_transactional(commit 세션)을 사용. Timer 조회는 elapsed_time을 ORM에 누적 |
RUNNING 타이머를 조회할 때마다 경과시간이 누적 커밋 (#51) |
| 도메인 간 직접 호출 | TodoService ↔ ScheduleService를 서로 직접 호출 |
강결합, 순환 import, 양방향 전파 누락 (#44) |
목표는 공개 동작(계약)을 바꾸지 않고 구현 결합도만 낮추는 것입니다.
초기 마이그레이션 범위는 Todo/Schedule 전파와 read path 정리입니다. TagService.delete_tag_group()처럼 다른 도메인 쓰기를 호출하는 경로도 존재하지만, 이 문서의 초기 이벤트 카탈로그에는 포함하지 않고 후속 정리 범위로 둡니다.
2. 전체 그림¶
flowchart TD
subgraph write["쓰기 경로"]
WReq[Router] --> WSvc[App Service]
WSvc --> WMut[도메인 상태 변경]
WMut --> WEvt["collect_event(session, evt)"]
WEvt --> WFlush["flush_and_dispatch_events()<br/>flush → drain/handle → flush"]
WFlush --> WResp[응답 DTO 조립]
WResp --> WCommit["commit_with_events()<br/>(안전망 + commit)"]
end
subgraph read["읽기 경로"]
RReq[Router] --> RFac[Read Facade]
RFac --> RQ[권한검증된 쿼리/조립]
RQ --> RDTO[Response DTO]
end
- 쓰기: 도메인 상태를 바꾼 뒤 이벤트를 수집하고, 응답을 조립하기 전에 핸들러를 dispatch하여 자동 생성 리소스가 응답에 즉시 보이게 합니다. 모든 핸들러는 같은 세션/트랜잭션에서 실행되고, 하나라도 실패하면 전체 rollback합니다.
- 읽기: read-only 세션으로 조회하고, 조립은 Read Facade가 담당합니다.
3. 읽기 경로¶
3.1 read-only 세션¶
GET은 get_db(commit 없음, rollback만)를 사용합니다. 단, 다음은 read-only 전환 전에 정리해야 합니다.
- Timer
elapsed_time: 조회 시 ORM을 변경하지 않습니다. 계산을 둘로 나눕니다.calculate_display_elapsed(timer, now)— read-only 표시용 계산accumulate_elapsed_until(timer, now)— pause/stop 등 write transition 전용 누적
- 인증 parent dependency: 모든 인증 라우터에 걸린
get_current_user_synced가 자체get_db_transactional+ 프로필 JIT sync를 엽니다. auth-only 게이트와 profile-sync write 경로로 분리해야 GET이 진짜 read-only가 됩니다.
3.2 Read Facade¶
라우터의 다중 리소스 조립 로직(_build_timer_read_with_relations, _get_related_schedule_reads)을 app/application/read/*(신규 레이어)로 옮깁니다. 라우터는 Facade 호출만 합니다.
권한 불변조건 (위반 시 정보 노출)
Read Facade는 raw CRUD/lazy load로 연관 리소스를 응답에 넣지 않습니다. 권한 판정은 CurrentUser에 강결합돼 있으므로, 연관 리소스마다 try_get_schedule_read/get_*_with_access_check로 개별 권한검증을 거칩니다. 부모 리소스 접근권한은 자식/연결 리소스 접근권한을 보장하지 않습니다 (공유 Timer를 봐도 연결 Schedule이 PRIVATE이면 숨김).
4. 쓰기 경로 — 도메인 이벤트 + Message Bus¶
4.1 이벤트¶
이벤트는 발생한 도메인 사실만 담는 불변 값 객체입니다.
class EventOrigin(str, Enum):
TODO = "todo"
SCHEDULE = "schedule"
@dataclass(frozen=True)
class ScheduleTimeChanged:
schedule_id: UUID
new_start_time: datetime
owner_id: str
origin: EventOrigin # 이벤트를 촉발한 source 도메인
payload 금지 사항
이벤트에 API DTO, ORM instance, response DTO, handler 이름, target service를 넣지 않습니다. 식별자와 도메인 사실만 담습니다.
4.2 이벤트 보관 — 세션 스코프 수집¶
도메인 모델이 SQLModel ORM 클래스의 alias라, 엔티티에 .events 리스트를 직접 붙이면 SQLAlchemy 로드 시 초기화 문제가 생깁니다. 그래서 1차 구현은 세션 스코프 수집을 사용합니다.
helper (app/shared/uow.py) |
역할 |
|---|---|
collect_event(session, event) |
session.info에 이벤트 적재 (직접 접근 금지, helper로만) |
drain_events(session) |
수집된 이벤트를 꺼내 반환 |
clear_events(session) |
이벤트 큐 비우기 |
flush_and_dispatch_events(session) |
flush → drain/handle loop → flush (commit 없음) |
commit_with_events(session) |
위를 한 번 더 보장 후 commit |
session.info는 트랜잭션 aware가 아님
DB rollback이 돼도 session.info의 이벤트 큐는 자동으로 비워지지 않습니다. SAVEPOINT/내부 예외와 섞이면 이미 취소된 변경의 이벤트가 남을 수 있으므로, rollback 경계에서 반드시 clear_events(session)를 호출합니다.
4.3 Message Bus¶
# app/shared/messagebus.py
def handle(events, session):
queue = list(events)
seen = set()
depth = 0
while queue:
if depth > MAX_DISPATCH_DEPTH:
raise RuntimeError("Max event dispatch depth exceeded")
event = queue.pop(0)
key = event_key(event) # 타입 + 주요 식별자
if key in seen:
continue
seen.add(key)
for handler in HANDLERS[type(event)]:
handler(event, session) # 예외는 삼키지 않고 전파
session.flush() # 후속 핸들러가 앞선 변경을 조회 가능
queue.extend(drain_events(session)) # 핸들러가 낳은 후속 이벤트 재수집
depth += 1
register(event_type)데코레이터로 consumer 모듈이 핸들러를 등록합니다. 누가 들을지는 중앙 registry가 정하며, 이벤트 발행자는 모릅니다.register_handlers()는 idempotent해야 합니다(앱 startup·테스트 fixture 양쪽에서 호출되므로). 테스트용reset_handlers()도 제공합니다.
4.4 cascade 무한루프 방지¶
TodoDeadlineChanged → ScheduleTimeChanged → TodoDeadlineChanged 같은 왕복 전파를 차단합니다.
- 이벤트
origin필드 +max_dispatch_depth상한 병행 - 보강: 동일 이벤트(타입+식별자) 중복 dispatch 방지
4.5 핸들러의 권한 불변조건¶
이벤트 핸들러는 내부 projection이므로 사용자 권한 검증을 우회합니다. 대신 owner 불변조건을 강제합니다.
- 핸들러는 변경 대상의
owner_id == event.owner_id를 확인한 뒤에만 수정합니다. current_user를 요구하는 기존 Service를 그대로 호출하지 않고,owner_id기반 lower-level operation을 사용합니다.- 자동 생성 리소스에 visibility를 복사하지 않습니다 (생성 후 별도
/visibilityAPI로 관리, 기본 PRIVATE). - 삭제 핸들러는
delete_visibility_by_resourcecleanup을 유지합니다(orphan visibility 방지).
5. 이벤트 카탈로그 (초기)¶
실제 모듈 간 전파가 필요한 것만 우선 정의합니다.
ScheduleTimeChanged, ScheduleDeleted, TodoDeadlineSet/Changed/Removed, TodoTagsChanged, TodoDeleted, ScheduleCreatedWithTodoOption.
source of truth
Todo deadline이 원본, Schedule start_time은 그 투영입니다. ScheduleTimeChanged는 source_todo_id가 있는(=Todo가 만든) Schedule에 한해 Todo로 역전파하고, origin이 Todo면 다시 전파하지 않습니다.
Schedule 삭제 정책
현재 공개 계약은 Todo deadline 제거 또는 Todo 삭제가 연결 Schedule을 삭제하는 방향입니다. 반대로 source_todo_id가 있는 Schedule을 삭제했을 때 Todo deadline을 자동 제거하는 동작은 현재 계약이 아니므로 초기 마이그레이션에서는 도입하지 않습니다. 필요하면 별도 정책/이슈로 다룹니다.
6. 단계별 적용¶
| 파트 | 내용 | 이슈 |
|---|---|---|
| 1 | 읽기가 DB를 바꾸는 문제 수정 (Timer elapsed_time) |
#51 |
| 2 | GET을 진짜 read-only로 (인증 의존성 분리 포함) | #52 |
| 3 | 읽기 조립을 Read Facade로 | #53 |
| 4 | 이벤트 버스를 테스트로 고정 | #54 |
| 5 | Todo/Schedule 직접 호출을 이벤트로 이전 | #55 |
| 6 | WebSocket을 같은 commit/rollback 규칙으로 | #49 |
각 파트는 기존 테스트 그린 유지가 1차 게이트입니다. 특히 tests/test_shared_resource_filtering.py(부모 공유/자식 PRIVATE 은닉)는 Read Facade 이전 후에도 통과해야 합니다.