[BE-50] 관심 목록 컨텍스트 — 관리 상태·우선순위·목표일·태그·메모·이력 (FR-73·74·76·77)
작업 내용 (설계 의도)
근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 5 컨텍스트 경계 (watchlist 신규)”, “watchlist 인터페이스 시그니처”, “상태 전이 표 관리 상태”
변경 사항
-
신규
watchlist바운디드 컨텍스트를 만듭니다.posting에 합류시키지 않는 이유: posting은 “수집·변경·마감 판정” 책임이고, 여기에 사용자 개인 취향(우선순위·태그·제외 사유)을 넣으면 배치가 소유한 데이터와 사용자가 소유한 데이터가 한 애그리게이트에 섞입니다. 라이프사이클도 다릅니다 — 공고는 배치가, 관리 상태는 사용자만 바꿉니다. -
귀속 단위는 중복 그룹입니다(FR-74).
dedupGroupId: Long만 참조하고 posting 타입을 import하지 않습니다(도메인 교차 참조 금지,LayeredArchitectureTest.kt:52-62). -
관리 상태는 3종만 저장합니다(FR-73) —
APPLIED는 저장하지 않고 지원 레코드 존재로 파생합니다. 응답의hasApplication은 application 레이어가 조합해 채웁니다. -
상태 전이 + 이력(FR-76) — 3종 상호 전이를 모두 허용하되, 같은 상태로의 전이는 no-op으로 처리하고 이력을 남기지 않습니다. Success Metrics “마감 전 처리율”이 변경 타임스탬프를 요구하므로 이력이 필수입니다.
-
제외 사유는
EXCLUDED에서만 허용합니다 — 그 외 상태에서 입력하면 400. 이 검증은 UseCase의if + throw가 아니라JobPostingWatchState.updateExclusionReason()내부에 둡니다. -
태그는 애그리게이트 자식입니다 —
@OneToMany(cascade=ALL, orphanRemoval=true)로 매핑해save(state)한 번에 전량 교체됩니다. RepositoryImpl에서deleteAll + saveAll수동 오케스트레이션을 하지 않습니다(no-business-flow-in-infra). 상한 10개·각 30자. -
피처 플래그
watchlist.management가 OFF면 API가 409FEATURE_DISABLED로 응답합니다. -
“미분류”와 “기능 준비 중”을 구분합니다 (C). 배포 1단계는 플래그를 순차로 켜므로 두 상태가 실제로 공존하는 구간이 있고, 같은 404로 내려오면 FE가 다르게 표시할 수 없습니다.
상황 응답 미분류 (기능 ON, 이 그룹에 관리 상태만 없음) 200 { dedupGroupId, watchState: null }기능 준비 중 (플래그 OFF) 409 FEATURE_DISABLED그룹 자체가 없음 404 DEDUP_GROUP_NOT_FOUNDGET이 봉투({ dedupGroupId, watchState })를 반환하도록 바꿉니다 —WatchStateResponse단독 반환이 아닙니다.- 이 결정으로
WATCH_STATE_NOT_FOUND는 쓰이는 곳이 없어져 폐기합니다.DELETE는 없어도 204(멱등), 이력 조회는 없으면 빈 배열입니다.
이 티켓은 BE-48(dedup 그룹)과 병렬 가능합니다 — dedupGroupId는 Long 참조라 컴파일 의존이 없고, 테스트는 임의 Long 값을 씁니다. 그룹 존재 검증(404 DEDUP_GROUP_NOT_FOUND)은 application 레이어가 posting DomainService로 수행하므로 BE-52 이후 연결됩니다.
롤백: 플래그 OFF. 신규 테이블만 추가하므로 역방향 DDL로 안전합니다.
의존
- BE-45 (예외 클래스·에러 코드·플래그 시드)
- DB-02 (
job_posting_watch_states,job_posting_watch_tags,job_posting_watch_state_histories)
다이어그램
처리 흐름
sequenceDiagram participant FE as web(SPA) participant C as WatchStateApiController participant U as UpsertWatchStateUseCase participant D as WatchStateDomainService participant E as JobPostingWatchState participant R as JobPostingWatchStateRepository FE->>C: PUT /api/dedup-groups/{id}/watch-state C->>U: execute(command) U->>D: upsert(dedupGroupId, input) D->>R: findBy(dedupGroupId) alt 없음 D->>E: create(dedupGroupId, status) else 있음 D->>E: transitTo(next, reason) E->>E: 같은 상태면 no-op (이력 미적재) end D->>E: updatePriority / replaceTags / updateExclusionReason E->>E: EXCLUDED 아닌데 사유 입력이면 예외 D->>R: save(state) — 태그·이력 cascade D-->>U: JobPostingWatchState U-->>C: WatchStateResponse
클래스 의존
flowchart LR subgraph Presentation["presentation/watchlist"] Api[WatchStateApiController] end subgraph Application["application/watchlist"] Upsert[UpsertWatchStateUseCase] Get[GetWatchStateUseCase] Clear[ClearWatchStateUseCase] Hist[GetWatchStateHistoriesUseCase] end subgraph Domain["domain/watchlist"] DS[WatchStateDomainService] Entity[JobPostingWatchState] Tag[WatchTag] History[WatchStateHistory] Repo[JobPostingWatchStateRepository] Flag[FeatureFlagGateway] end Api --> Upsert Api --> Get Api --> Clear Api --> Hist Upsert --> DS DS --> Entity DS --> Repo DS --> Flag Entity --> Tag Entity --> History
테스트 케이스
- 관리 상태가 없는 그룹에
INTERESTED를 저장하면 생성되고 이력 1건이 남는다 INTERESTED→PLANNED전이 시 이력에 이전·다음 상태와 변경 시각이 남는다INTERESTED→INTERESTED같은 상태로 저장하면 이력이 추가되지 않는다 (no-op)EXCLUDED로 전이하면서 제외 사유를 입력하면 저장된다INTERESTED상태에서 제외 사유를 입력하면 400WATCH_STATE_INVALID_FIELD다- 우선순위 미지정으로 신규 생성하면
NORMAL이 기본값이다 - 태그 3개를 저장한 뒤 2개로 다시 저장하면 전량 교체되어 2개만 남는다
- 태그 11개를 입력하면 400이다 (경계값)
- 태그 하나가 31자면 400이다 (경계값)
- 중복 태그를 입력하면 중복이 제거되어 저장된다
- 메모 1001자면 400이다 (경계값)
- 관리 상태를 조회하면 상태·우선순위·목표일·메모·태그가 함께 반환된다
- 관리 상태가 없는 그룹을 조회하면 200
{ dedupGroupId, watchState: null }이다 (404가 아님) - 존재하지 않는 그룹을 조회하면 404
DEDUP_GROUP_NOT_FOUND다 - 관리 상태가 없는 그룹의 이력을 조회하면 빈 배열을 반환한다 (404가 아님)
- 관리 상태가 없는 그룹에 DELETE를 호출해도 204다 (멱등)
- 관리 상태를 해제(DELETE)하면 204이고 이력은 보존된다
- 이력 조회는 변경 시각 오름차순으로 반환된다
- 피처 플래그 OFF면 API가 409
FEATURE_DISABLED로 응답한다 (미분류 200과 구분된다) - 태그가 애그리게이트 cascade로 한 번에 저장된다 (RepositoryImpl에 수동 delete/insert 없음)