[BE-50] 관심 목록 컨텍스트 — 관리 상태·우선순위·목표일·태그·메모·이력 (FR-73·74·76·77)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 5 컨텍스트 경계 (watchlist 신규)”, “watchlist 인터페이스 시그니처”, “상태 전이 표 관리 상태”

변경 사항

  1. 신규 watchlist 바운디드 컨텍스트를 만듭니다. posting에 합류시키지 않는 이유: posting은 “수집·변경·마감 판정” 책임이고, 여기에 사용자 개인 취향(우선순위·태그·제외 사유)을 넣으면 배치가 소유한 데이터와 사용자가 소유한 데이터가 한 애그리게이트에 섞입니다. 라이프사이클도 다릅니다 — 공고는 배치가, 관리 상태는 사용자만 바꿉니다.

  2. 귀속 단위는 중복 그룹입니다(FR-74). dedupGroupId: Long만 참조하고 posting 타입을 import하지 않습니다(도메인 교차 참조 금지, LayeredArchitectureTest.kt:52-62).

  3. 관리 상태는 3종만 저장합니다(FR-73) — APPLIED는 저장하지 않고 지원 레코드 존재로 파생합니다. 응답의 hasApplication은 application 레이어가 조합해 채웁니다.

  4. 상태 전이 + 이력(FR-76) — 3종 상호 전이를 모두 허용하되, 같은 상태로의 전이는 no-op으로 처리하고 이력을 남기지 않습니다. Success Metrics “마감 전 처리율”이 변경 타임스탬프를 요구하므로 이력이 필수입니다.

  5. 제외 사유는 EXCLUDED에서만 허용합니다 — 그 외 상태에서 입력하면 400. 이 검증은 UseCase의 if + throw가 아니라 JobPostingWatchState.updateExclusionReason() 내부에 둡니다.

  6. 태그는 애그리게이트 자식입니다 — @OneToMany(cascade=ALL, orphanRemoval=true)로 매핑해 save(state) 한 번에 전량 교체됩니다. RepositoryImpl에서 deleteAll + saveAll 수동 오케스트레이션을 하지 않습니다(no-business-flow-in-infra). 상한 10개·각 30자.

  7. 피처 플래그 watchlist.management가 OFF면 API가 409 FEATURE_DISABLED 로 응답합니다.

  8. “미분류”와 “기능 준비 중”을 구분합니다 (C). 배포 1단계는 플래그를 순차로 켜므로 두 상태가 실제로 공존하는 구간이 있고, 같은 404로 내려오면 FE가 다르게 표시할 수 없습니다.

    상황응답
    미분류 (기능 ON, 이 그룹에 관리 상태만 없음)200 { dedupGroupId, watchState: null }
    기능 준비 중 (플래그 OFF)409 FEATURE_DISABLED
    그룹 자체가 없음404 DEDUP_GROUP_NOT_FOUND
    • GET이 봉투({ 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건이 남는다
  • INTERESTEDPLANNED 전이 시 이력에 이전·다음 상태와 변경 시각이 남는다
  • INTERESTEDINTERESTED 같은 상태로 저장하면 이력이 추가되지 않는다 (no-op)
  • EXCLUDED로 전이하면서 제외 사유를 입력하면 저장된다
  • INTERESTED 상태에서 제외 사유를 입력하면 400 WATCH_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 없음)