[BE-53] 지원 ↔ 관리 상태 연동 — Layer 1 도메인 이벤트 (FR-75)
작업 내용 (설계 의도)
근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 12 지원 ↔ 관리 상태 연동”, “상태 전이 표 관리 상태”
변경 사항
FR-75는 지원 생성/철회가 관리 상태를 바꾸도록 요구합니다. application과 watchlist는 서로 다른 컨텍스트라 직접 참조가 금지됩니다(LayeredArchitectureTest.kt:52-62).
-
Layer 1 ApplicationEvent를 채택합니다(
private-be-architecture-rule판단 기준표 적용 — 구독자가 발행자와 같은 앱·같은 배포 단위이고, 원 트랜잭션 커밋 후 재계산 가능한 파생 처리라 내구성이 필요 없습니다). Kafka는 도입하지 않습니다. -
application도메인이 단일 sealed 타입JobApplicationEvent(변이:Created·Removed)를 Entity@Transient큐에 적재하고, DomainService가pullDomainEvents()→DomainEventPublisher.publishAll()로 발행합니다.ApplicationEventPublisher를 UseCase·DomainService에 직접 주입하지 않습니다. -
구독은 presentation layer
JobApplicationLifecycleListener의@Async @TransactionalEventListener(phase = AFTER_COMMIT)입니다. 커밋 전 상태를 파생 처리가 보는 사고를 막습니다. 리스너에 비즈니스 로직을 두지 않고 UseCase를 경유합니다. -
파생 UseCase가
jobPostingId → dedupGroupId → 관리 상태 복구를 조합합니다 — posting DomainService로 그룹 id를 얻고 watchlist DomainService에 넘기는 크로스 컨텍스트 조합은 application 레이어 책임입니다. -
복구 규칙(FR-75) — 지원 생성:
EXCLUDED면INTERESTED로 복구, 그 외는 유지. 지원 철회·삭제: 이력 기준 직전 상태로 복귀, 이력이 없으면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 통합 테스트를 둡니다. 태그가 있는 상태에서 복구가 실제로 커밋되는지 확인해야 합니다.
- 원인이었던
tagsbag ×historiesSet 카테시안 곱은 BE-50에서 수정됐습니다 — 재도입 시 그 수정이 유지되는지도 통합 테스트로 고정하세요.
- 이 경로는 태그 유니크 제약(
-
실패 경로 —
@Retryable3회 후 최종 실패는 ERROR 로그로 남깁니다. 파생 실패가 지원 생성(원 트랜잭션)을 롤백하지 않으며, 사용자가 화면에서 수동 변경할 수 있어 복구 경로가 있습니다. -
기존
AsyncConfig의jobPostingEvaluationTaskExecutor선례를 따라 전용 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에 직접 주입되지 않는다 (아키텍처 검증)