[BE-53] 지원 ↔ 관리 상태 연동 — Layer 1 도메인 이벤트 (FR-75)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 12 지원 ↔ 관리 상태 연동”, “상태 전이 표 관리 상태”

변경 사항

FR-75는 지원 생성/철회가 관리 상태를 바꾸도록 요구합니다. applicationwatchlist는 서로 다른 컨텍스트라 직접 참조가 금지됩니다(LayeredArchitectureTest.kt:52-62).

  1. Layer 1 ApplicationEvent를 채택합니다(private-be-architecture-rule 판단 기준표 적용 — 구독자가 발행자와 같은 앱·같은 배포 단위이고, 원 트랜잭션 커밋 후 재계산 가능한 파생 처리라 내구성이 필요 없습니다). Kafka는 도입하지 않습니다.

  2. application 도메인이 단일 sealed 타입 JobApplicationEvent(변이: Created·Removed)를 Entity @Transient 큐에 적재하고, DomainService가 pullDomainEvents()DomainEventPublisher.publishAll()로 발행합니다. ApplicationEventPublisher를 UseCase·DomainService에 직접 주입하지 않습니다.

  3. 구독은 presentation layer JobApplicationLifecycleListener@Async @TransactionalEventListener(phase = AFTER_COMMIT)입니다. 커밋 전 상태를 파생 처리가 보는 사고를 막습니다. 리스너에 비즈니스 로직을 두지 않고 UseCase를 경유합니다.

  4. 파생 UseCase가 jobPostingId → dedupGroupId → 관리 상태 복구를 조합합니다 — posting DomainService로 그룹 id를 얻고 watchlist DomainService에 넘기는 크로스 컨텍스트 조합은 application 레이어 책임입니다.

  5. 복구 규칙(FR-75) — 지원 생성: EXCLUDEDINTERESTED로 복구, 그 외는 유지. 지원 철회·삭제: 이력 기준 직전 상태로 복귀, 이력이 없으면 INTERESTED. 5-1. 복구 메서드를 이 티켓이 직접 도입합니다 (BE-50에서 제거됨 — 전제 변경). BE-50이 이 메서드들을 선구현했다가 프로덕션 호출부 0건(p2 지적)으로 제거했습니다. FR-75는 TDD상 이 티켓 소유이므로 BE-50에 있다고 전제하지 말고 자기 범위로 재도입하세요.

    재도입 대상위치
    restoreOnApplied() / revertOnApplicationRemoved()JobPostingWatchState (엔티티 — 판정·전이·이력 적재)
    restoreOnApplied(dedupGroupId) / revertOnApplicationRemoved(dedupGroupId)WatchStateDomainService (조회 + 위임 + 저장)
    APPLICATION_CREATED_REASON / APPLICATION_REMOVED_REASONWatchStateHistory (이력 사유 상수)

    제거되지 않아 그대로 쓸 수 있는 것: WatchStateDomainService.findGroupIdsBy, JobPostingWatchStateRepository.findAllBy(Collection) — BE-52·BE-65에 소비자가 있어 유지됐습니다. 5-2. 재도입 시 Testcontainers 실 DB 통합 테스트가 필수입니다 — Mock 단위 테스트만으로는 안 됩니다.

    BE-50이 이 코드를 제거한 진짜 이유는 호출부 부재만이 아니라 실제로 깨져 있었기 때문입니다. MockK 단위 테스트로만 덮여 있어 통과했지만, 리뷰어가 실 DB로 호출하자 죽었습니다.

    PROBE_RESTORE success=false err=DataIntegrityViolationException:
      Duplicate entry '1-백엔드' for key 'job_posting_watch_tags.uk_job_posting_watch_tags_state_name'
    
    • 이 경로는 태그 유니크 제약(uk_job_posting_watch_tags_state_name)을 건드립니다. 상태 전이 + 이력 적재 + 태그 컬렉션이 한 애그리게이트에서 함께 flush되기 때문입니다.
    • JPA 제약 위반은 noRollbackFor로 구제되지 않습니다(TDD “실패를 영속화하는 경로의 트랜잭션 설계” 계약 2와 같은 계열) — flush 시점에 트랜잭션이 rollback-only로 마킹됩니다. Mock으로는 flush가 일어나지 않아 이 결함이 재현되지 않습니다.
    • 따라서 복구 경로마다 Testcontainers MySQL 통합 테스트를 둡니다. 태그가 있는 상태에서 복구가 실제로 커밋되는지 확인해야 합니다.
    • 원인이었던 tags bag × histories Set 카테시안 곱은 BE-50에서 수정됐습니다 — 재도입 시 그 수정이 유지되는지도 통합 테스트로 고정하세요.
  6. 실패 경로@Retryable 3회 후 최종 실패는 ERROR 로그로 남깁니다. 파생 실패가 지원 생성(원 트랜잭션)을 롤백하지 않으며, 사용자가 화면에서 수동 변경할 수 있어 복구 경로가 있습니다.

  7. 기존 AsyncConfigjobPostingEvaluationTaskExecutor 선례를 따라 전용 executor를 추가합니다(core 1 / max 2 / queue 20 — 지원 생성 빈도가 낮음).

롤백: 리스너를 비활성화해도 지원 생성 자체는 정상 동작합니다(파생 처리만 멈춤).

의존

  • BE-50 (watchlist 도메인)
  • BE-48 (dedup 그룹 — 공고 → 그룹 id 조회)

다이어그램

처리 흐름

sequenceDiagram
    participant FE as web(SPA)
    participant C as ApplicationApiController
    participant U as CreateApplicationUseCase
    participant AD as ApplicationDomainService
    participant P as DomainEventPublisher
    participant L as JobApplicationLifecycleListener
    participant RU as RestoreWatchStateUseCase
    participant WD as WatchStateDomainService
    FE->>C: POST /api/applications
    C->>U: execute(command)
    U->>AD: create(command)
    AD->>P: publishAll(JobApplicationEvent.Created)
    U-->>C: 201 (원 트랜잭션 커밋)
    P->>L: AFTER_COMMIT (@Async)
    L->>RU: execute(jobPostingId)
    RU->>RU: jobPostingId → dedupGroupId 조회
    RU->>WD: restoreOnApplied(dedupGroupId)
    WD->>WD: EXCLUDED면 INTERESTED로 복구 + 이력

클래스 의존

flowchart LR
    subgraph Presentation["presentation/watchlist"]
        Listener[JobApplicationLifecycleListener]
    end
    subgraph Application["application/watchlist"]
        Restore[RestoreWatchStateOnApplicationUseCase]
        Revert[RevertWatchStateOnRemovalUseCase]
    end
    subgraph Domain["domain"]
        AD[ApplicationDomainService]
        Event[JobApplicationEvent]
        Pub[DomainEventPublisher]
        PD[PostingDomainService]
        WD[WatchStateDomainService]
    end
    AD --> Event
    AD --> Pub
    Pub --> Listener
    Listener --> Restore
    Listener --> Revert
    Restore --> PD
    Restore --> WD
    Revert --> PD
    Revert --> WD

테스트 케이스

  • [실 DB 통합 테스트 필수] EXCLUDED 관리 상태 그룹의 공고에 지원을 생성하면 관리 상태가 INTERESTED로 복구되고 이력 1건이 추가된다
  • [실 DB 통합 테스트 필수] 태그가 등록된 관리 상태에 복구를 실행해도 uk_job_posting_watch_tags_state_name 유니크 위반 없이 커밋된다 (BE-50에서 실 DB로 드러난 결함의 회귀 가드)
  • [실 DB 통합 테스트 필수] 태그 2건 + 이력 2건이 있는 상태에서 복구해도 태그가 중복 증식하지 않는다 (bag × Set 카테시안 곱 회귀 가드)
  • [실 DB 통합 테스트 필수] 지원 철회 복구 경로도 태그가 있는 상태에서 정상 커밋된다
  • restoreOnApplied·revertOnApplicationRemoved가 프로덕션 호출부(리스너 경유 UseCase)를 갖는다 (호출부 없는 코드 금지 — BE-50 p2 재발 방지)
  • INTERESTED 상태에서 지원을 생성하면 상태가 그대로 유지된다
  • 관리 상태가 없는 그룹의 공고에 지원해도 예외 없이 no-op이다
  • 지원을 철회하면 이력 기준 직전 상태로 복귀한다
  • 이력이 없는 상태에서 지원을 철회하면 INTERESTED로 복귀한다
  • 이벤트 리스너가 실패해도 지원 생성 트랜잭션은 커밋된 상태로 남는다
  • 리스너 실패 시 3회 재시도 후 ERROR 로그를 남기고 종료한다
  • 이벤트가 원 트랜잭션 커밋 이후에 실행된다 (AFTER_COMMIT — 커밋 전 상태를 읽지 않는다)
  • 같은 이벤트가 두 번 처리돼도 관리 상태가 동일하다 (멱등)
  • dedup_group_id가 없는 공고(dedup 배치 미실행)에 지원하면 no-op이다
  • ApplicationEventPublisher가 UseCase·DomainService에 직접 주입되지 않는다 (아키텍처 검증)