[BE-01] FeatureFlag 도메인 코어 계약 (선행 병목)

작업 내용 (설계 의도)

변경 사항

신규 featureflag 도메인의 연관 병목(공통 계약)을 한 wave에 확립한다. 후행 티켓 전부(BE-02 영속화·BE-03 캐시/pubsub·BE-04 도메인서비스·BE-05 애플리케이션·BE-06 평가클라이언트·BE-09 데모)가 import하는 도메인 코어·전략·공용 평가 인터페이스를 한 책임으로 묶어 직렬 사슬을 방지한다. 근거 TDD: ../TDD.md “시스템 역할 경계”·“인터페이스 시그니처”·“도메인 바운디드 컨텍스트 판단”.

  • FeatureFlag (Rich Domain @Entity, 단일 클래스 — 레포 관례): private 생성자 + create() 정적 팩토리 + private var 필드. JPA 하이드레이션은 no-arg 생성자(reconstitute 미사용, 레포 100% 단일클래스 관례 준수). 필드: flagKey·type·status·strategy·description·version: Long(@Version, DB BIGINT — 레포 선례·senior-dba 권고)·감사시각. strategy@Type(JsonStringType::class) + TEXT 컬럼으로 sealed EvaluationStrategy를 직접 매핑(도메인→infra import 없음, AttributeConverter 미사용).
  • 메서드: create()(flagKey 형식·strategy 유효성 검증), evaluate(context)(ARCHIVED면 평가 제외 신호, 아니면 strategy.evaluate 위임), updateStrategy()·archive()·activate()(전이 검증 + 변경 이벤트 register), toSnapshot(). 시각은 메서드 내부 ZonedDateTime.now() 해결(시간 인자 금지).
  • FeatureFlagType enum: RELEASE/OPERATIONAL/EXPERIMENT/ENTITLEMENT — VariantBucketing 전략은 EXPERIMENT에서만 허용 검증.
  • FeatureFlagStatus enum: ACTIVE/ARCHIVED + canTransitTo(next) 캡슐화.
  • sealed EvaluationStrategy + evaluate(context): FeatureEvaluation 다형성: GlobalToggle(enabled)·PercentageRollout(percentage 0..100)·AttributeMatch(attribute,value)·VariantBucketing(variants≤4, weight합100). Variant(name,weight).
  • sealed FeatureEvaluation: On/Off/Assigned(variantName).
  • StableBucketer (object, 순수): bucket(flagKey, userId): Int = Math.floorMod(murmur3_32("$flagKey:$userId"),100) (0..99). Unleash stickiness 참조, flagKey를 salt로.
  • FeatureFlagAuditLog (@Entity, 단일클래스): create(changeType, actorUserId, before?, after). before/after는 @Type(JsonStringType::class) TEXT로 FeatureFlagSnapshot 매핑.
  • FeatureFlagSnapshot (data class): key·type·status·strategy·description (raw String 금지, 타입화).
  • 도메인 인터페이스: FeatureFlagRepository·FeatureFlagAuditLogRepository(repository), FeatureFlagCacheStore·FeatureFlagChangeBroadcaster(gateway) — 시그니처는 TDD “인터페이스 시그니처” 그대로. 감사 조회는 findByFlagKey(key, pageable: Pageable): Page<FeatureFlagAuditLog>(레포 McpAuditLogRepository 선례, occurred_at desc — total 포함 응답용).
  • 도메인 이벤트: FeatureFlagChangedEvent(AbstractDomainEvent, topic=null → Spring 내부 전파). AggregateRoot domainEvents에 register.
  • 예외: FeatureFlagNotFoundException·DuplicateFeatureFlagKeyException·InvalidEvaluationStrategyException·FeatureFlagStatusConflictException (모두 BusinessException 확장, 적절한 ErrorStatus).
  • 공용 평가 계약(common): domain/common/FeatureFlagEvaluator(interface: isEnabled/variant) + domain/common/FeatureContext(VO: userId?·attributes). 다른 도메인이 domain.featureflag를 import하지 않고 평가하도록 common에 배치(선례: DistributedLock·PermissionRepository).
  • domain/common/BusinessException.ktErrorStatus enum에 SERVICE_UNAVAILABLE(503) 추가 — 데모 게이팅용. 기존 GlobalExceptionHandler#handleBusinessException이 자동 매핑(핸들러 변경 불필요).

의존

  • 없음 (선행 병목)

다이어그램

클래스 의존

flowchart LR
    FeatureFlag --> EvaluationStrategy
    FeatureFlag --> FeatureFlagStatus
    FeatureFlag --> FeatureFlagChangedEvent
    FeatureFlag --> AggregateRoot["common.AggregateRoot"]
    EvaluationStrategy --> FeatureEvaluation
    EvaluationStrategy --> StableBucketer
    EvaluationStrategy --> FeatureContext["common.FeatureContext"]
    FeatureFlagRepository -.->|반환| FeatureFlag
    Evaluator["common.FeatureFlagEvaluator"] --> FeatureContext

테스트 케이스

  • GlobalToggle(enabled=true) 평가는 On, false는 Off를 반환한다
  • PercentageRollout(50)에서 동일 userId를 반복 평가하면 항상 같은 판정을 받는다(sticky)
  • 서로 다른 flagKey는 같은 userId·같은 percentage라도 버킷이 독립적으로 분포한다(salt 효과)
  • AttributeMatch(plan,PREMIUM)는 context.attributes에 plan=PREMIUM일 때만 On이다
  • VariantBucketing 2개(50:50)에서 동일 userId는 항상 동일 variant(Assigned)를 받는다
  • PercentageRollout·VariantBucketing 평가 시 userId가 null이면 Off를 반환한다
  • create는 percentage>100·variant>4개·weight합≠100·EXPERIMENT 아닌데 VariantBucketing이면 예외를 던진다
  • ACTIVE→ARCHIVED는 허용되고 ARCHIVED→ARCHIVED는 canTransitTo가 거부한다
  • archive()·activate()·updateStrategy() 호출 시 FeatureFlagChangedEvent가 domainEvents에 적재된다
  • ARCHIVED 플래그의 evaluate는 전략 평가를 수행하지 않는다(평가 제외 신호)
  • StableBucketer.bucket은 0..99 범위를 벗어나지 않는다(음수 해시 floorMod 처리)