피처 플래그 TDD

Background

근거 PRD: ./PRD.md (검수 PASS).

sports-application의 여러 규칙·설계 문서가 “롤백 = 피처 플래그 OFF”를 전제하지만, 실제 레포에는 런타임 토글 시스템이 없다. 존재하는 것은 기동 시점 고정 방식(@Profile, application.yml boolean)뿐이며, 타게팅·이력·관리 화면·런타임 변경을 지원하지 않는다(PRD Problem Definition). 본 과제는 MySQL SSOT(Single Source of Truth) + Redis 캐시(look-aside) + Redis pub/sub 실시간 전파로 중앙화된 피처 플래그 시스템을 구축하고, 신규 데모 기능 1개를 이 시스템으로 게이팅해 킬스위치·퍼센티지 롤아웃을 E2E로 증명한다.

Overview

항목내용
무엇FeatureFlag 신규 도메인 + 서버사이드 평가 엔진(전략 3종 sealed 다형성) + MySQL SSOT/Redis 캐시/Redis pub/sub 전파 + 관리 REST API + 감사 로그 + 데모 게이팅 E2E
재배포 없는 런타임 토글·점진 롤아웃·A/B·엔타이틀먼트·변경 이력을 확보하고, 20000TPS 경로에서 평가가 병목이 되지 않게 캐시 계층을 처음부터 둔다
어떻게MySQL 쓰기 → AFTER_COMMIT 이벤트 → Redis 캐시 갱신 + pub/sub 브로드캐스트 → 전 인스턴스 로컬 인메모리 스냅샷 갱신. 평가는 로컬 스냅샷만 읽어 P95 1ms, Redis/MySQL 장애 시 마지막 성공 스냅샷 → 호출부 기본값 폴백
도메인 영향신규 도메인 featureflag + 소비 데모 슬라이스 featuredemo. 기존 12개 도메인 코드 무변경(공용 평가 클라이언트는 domain.common에 인터페이스만 추가)

Terminology

용어정의
SSOTSingle Source of Truth. 플래그 상태의 원본은 MySQL
look-aside 캐시애플리케이션이 캐시 미스 시 원본(MySQL)을 읽고 캐시를 채우는 방식
로컬 스냅샷인스턴스별 인메모리 플래그 사본(L1). 평가는 이것만 읽는다. pub/sub로 갱신되고, 장애 시 마지막 성공값(fallback)으로도 쓰인다
안정 키(stable key)버케팅 입력이 되는 결정론적 키 = userId. 동일 사용자는 항상 동일 판정
버케팅murmur3_32("{flagKey}:{userId}")를 0~99로 정규화. 퍼센티지 롤아웃·EXPERIMENT variant 배정이 공유
평가 컨텍스트(FeatureContext)호출부가 평가 시 주입하는 입력 — userId(nullable) + attributes: Map
평가 클라이언트도메인 서비스가 보일러플레이트 없이 주입해 호출하는 공용 진입점 FeatureFlagEvaluator(common)
킬스위치RELEASE 플래그 OFF로 게이팅된 엔드포인트를 즉시 503으로 비활성화하는 롤백 수단

Define Problem

AS-IS

  • FeatureFlag 그린필드grep -rni featureflag src/main 0건. 재사용/충돌 대상 없음.
  • Redis pub/sub 미사용RedisMessageListenerContainer·MessageListener·convertAndSend 0건. Redis는 캐시(CacheConfig.kt#cacheManager)·분산락(infrastructure/lock/RedisDistributedLock.kt = SET NX EX + Lua compare-and-del, Redisson 아님)·JWT 블랙리스트·리프레시토큰·인기상품 ZSet 용도로만 존재. pub/sub는 신규 인프라.
  • 도메인 이벤트 인프라 재사용 가능domain/common/AggregateRoot.kt(registerEvent/pullDomainEvents), domain/common/DomainEvent.kt(AbstractDomainEvent: eventId·occurredAt·topic), domain/common/DomainEventPublisher.kt, infrastructure/messaging/RoutingDomainEventPublisher.kt(@Primary, topic null이면 Spring 내부 이벤트). 내부 전파는 이 경로를 그대로 쓴다.
  • 엔티티 = JPA 엔티티 단일 클래스domain/*/entity/@Entity가 도메인 클래스에 직접 부착(예: domain/operator/entity/OperatorInboxNotification.kt@Entity + create() 정적 팩토리 + private var + markRead()/archive() Rich 메서드). infrastructure에 별도 @Entity 0건, reconstitute 패턴 0건. JPA는 no-arg 생성자로 하이드레이션.
  • 감사 로그는 도메인 전용 방식 — 범용 AuditLog/@Auditable/AOP 없음. MCP만 domain/mcp/entity/McpAuditLog.kt + McpAuditLogAsyncRecorder(수동 명시 기록, AOP 아님) 선례.
  • 관리자 인가SecurityConfig.kt#configureAuthorization에서 /admin/**hasRole("ADMIN"). UserRoleName enum에 ADMIN 존재. actor 식별은 SecurityAuditorAware(SecurityContext principal.id). 대부분 도메인 API는 AUTH-04 TODO로 permitAll + X-User-Id 헤더 임시 방식.
  • 예외 → HTTP 매핑presentation/exception/GlobalExceptionHandler.kt#handleBusinessExceptionBusinessException.status(ErrorStatus).httpStatus로 자동 매핑. ErrorStatus = NOT_FOUND(404)/CONFLICT(409)/UNPROCESSABLE(422)/BAD_REQUEST(400)/UNAUTHORIZED(401)/FORBIDDEN(403)/INTERNAL(500). 503(SERVICE_UNAVAILABLE) 미정의 — 데모 게이팅용으로 enum에 추가 필요(추가만 하면 핸들러 변경 불필요).
  • QueryDSL 정착QueryDslConfig.kt(JPAQueryFactory), @Query 금지 detekt 룰(no-jpa-query). CustomRepository interface + QueryDslRepositoryImpl + JpaRepository 결합 패턴(선례: infrastructure/operator/mysql/*, infrastructure/mcp/mysql/McpAuditLog*).
  • Flyway 순번식src/main/resources/db/migration/V{N}__설명.sql, 최신 V37(타임스탬프 아님). 형제 과제가 V38~V41 계획을 문서상 점유(B2B·마케팅) — 번호는 senior-dba가 조정.
  • JSON 매핑io.hypersistence:hypersistence-utils-hibernate-63:3.9.0 의존성 존재하나 코드 사용 0건. private-db-schema-convention은 JSON 컬럼 금지 → TEXT 또는 정규화.

TO-BE

  • 신규 도메인 featureflag: FeatureFlag(Rich Domain @Entity 단일 클래스, create() + private var), FeatureFlagType/FeatureFlagStatus(canTransitTo), sealed EvaluationStrategy(4) + FeatureEvaluation, StableBucketer, FeatureFlagAuditLog, Repository/Gateway 인터페이스.
  • domain.common에 공용 평가 계약 추가: FeatureFlagEvaluator(interface), FeatureContext(VO). 다른 도메인은 이것만 주입(도메인 교차 import 금지 준수).
  • 인프라: FeatureFlagRepositoryImpl(MySQL) + RedisFeatureFlagCacheStore + RedisFeatureFlagChangeBroadcaster + FeatureFlagRedisPubSubConfig(RedisMessageListenerContainer, 신규) + LocalFeatureFlagStore(인메모리 L1/스냅샷) + FeatureFlagEvaluatorImpl.
  • 관리 REST API /admin/feature-flags(hasRole ADMIN, SecurityConfig 무변경) + 감사 로그 조회.
  • 데모 슬라이스 featuredemo: GET /feature-demo/helloRELEASE 플래그 demo.feature.hello로 게이팅. OFF/미존재 → 503, ON → 200, 퍼센티지 → userId sticky.

Architecture Benchmarking (의무)

제품/사례해결 방식참고할 패턴미참고 사유
Unleash (self-hosted) — Stickiness, Gradual RolloutflexibleRollout 전략이 stickinessId(기본 userId)를 groupId:userId로 묶어 MurmurHash→0~100 정규화, 값 < rollout%면 노출. 동일 사용자 항상 동일 판정채택StableBucketermurmur3_32("{flagKey}:{userId}")→0~99. 퍼센티지 롤아웃과 EXPERIMENT variant 배정이 이 버케터를 공유(FR-3). flagKey를 salt로 써 플래그 간 노출 집합 상관관계 제거Unleash의 constraint/segment 등 범용 규칙 엔진·다중 전략 조합은 PRD Non-Goals(전략 3종 한정). 명시적 userId 리스트 타게팅도 Non-Goal
LaunchDarklyRedis 연동, 아키텍처 딥다이브서버 SDK가 Redis 등 외부 저장소를 백엔드로 한 인메모리 read-through 캐시에서 즉시 평가하고, 스트리밍 연결로 변경을 각 SDK 인스턴스에 실시간 push채택 — 평가는 인스턴스 로컬 인메모리(L1)에서만 읽어 P95 1ms(NFR). Redis = 공유 캐시 + pub/sub 스트리밍 대체. 외부 저장소 장애 시 마지막 성공 스냅샷 유지(FR-11) = LD SDK의 캐시 지속 정책LD의 폴링/스트리밍 SDK 프로토콜·relay proxy·client-side 평가는 과함. 단일 브로커 pub/sub로 충분(Non-Goals: 다중 리전)
Netflix Fast Properties / Archaius (배경 참고)중앙 저장소 변경을 폴링으로 각 인스턴스 프로퍼티에 반영폴링 안전망 개념만 참고(pub/sub 유실 대비 주기 전체 리프레시)폴링 주기(수십 초)는 킬스위치 3초 목표에 미달 — pub/sub를 1차, 폴링은 안전망으로만

Possible Solutions

방안 비교 — 변경 전파 (핵심)

방안설명왜 채택 / 미채택
A. MySQL SSOT + Redis 캐시 + Redis pub/sub + 로컬 스냅샷쓰기는 MySQL. AFTER_COMMIT에 Redis 캐시 갱신 + pub/sub 브로드캐스트. 전 인스턴스가 로컬 인메모리 스냅샷을 갱신. 평가는 로컬만 읽음채택 — PRD FR-5/6 명시. 평가 P95 1ms(로컬), 전파 P95 3초(pub/sub), Redis/MySQL 장애 시에도 로컬 스냅샷으로 읽기 지속(FR-11·NFR). LaunchDarkly 캐시 모델과 정합
B. Redis 캐시만 + 폴링pub/sub 없이 각 인스턴스가 주기적으로 Redis/MySQL 폴링미채택(1차) — 킬스위치 3초 목표를 폴링 주기로 달성하려면 초당 폴링 필요(부하). 단, 주기 전체 리프레시는 pub/sub 유실 대비 안전망으로 병행
C. Kafka 이벤트로 전파기존 Kafka로 flag.changed 발행, 각 인스턴스 컨슘미채택 — 브로드캐스트(전 인스턴스 동일 수신)에는 컨슈머 그룹당 파티션 관리가 과함. Redis pub/sub가 fan-out 브로드캐스트에 정확히 맞고 이미 Redis 상주
D. DB 매 요청 조회캐시 없이 매 평가 MySQL 조회미채택 — 20000TPS 경로에서 명백한 병목(PRD Problem). 되돌리기 어려움

방안 비교 — 평가 전략 표현

방안설명왜 채택 / 미채택
E. sealed class 다형성EvaluationStrategy sealed + evaluate(context) 오버라이드. when 전수 분기(else 없이 컴파일러가 누락 검출)채택 — 분기 대신 다형성(private-be-code-convention). 전략 4종 고정, 신규 전략 추가 시 컴파일 에러로 누락 방지. Entity가 flag.evaluate()로 위임(Rich Domain)
F. type별 if/when 분기평가 서비스에서 type 보고 if/else미채택 — 분기 로직이 서비스에 흩어짐. 컨벤션 no-external-state-check 위반 소지
G. 범용 규칙 엔진(표현식 트리)JSON 규칙을 파싱해 임의 조건 평가미채택 — PRD Non-Goals(“범용 규칙 엔진 안 만든다”). 지금 규모에 과함

방안 비교 — 엔드포인트 게이팅 방식

방안설명왜 채택 / 미채택
H. 명시적 평가 클라이언트 호출도메인 서비스가 evaluator.isEnabled(key, ctx, default) 호출, false면 FeatureDisabledException→503채택 — 단순·테스트 용이. FR-12 공용 클라이언트로 보일러플레이트 최소(주입 1개 + 한 줄 호출). 게이팅 지점이 코드로 드러남
I. @FeatureGate AOP 애노테이션컨트롤러 메서드에 애노테이션, 애스펙트가 평가+503미채택(1차) — 지금 게이팅 지점 1곳. 애스펙트/평가 컨텍스트 추출 규약이 과함. 게이팅 지점이 늘면 Open Questions로 재검토

방안 비교 — 전략/스냅샷 영속화

방안설명왜 채택 / 미채택
J. TEXT 컬럼 + @Type(JsonStringType::class) 도메인 @Entity 필드 직접 매핑strategy_config TEXT·before/after_snapshot TEXT에 JSON 저장, 도메인 @Entity 필드를 @Type(JsonStringType::class)(hypersistence-utils)로 sealed EvaluationStrategy/FeatureFlagSnapshot data class에 직접 매핑채택 — private-db-schema-convention “JSON 컬럼 금지 → TEXT” 충족 + be-convention 명시 권장(@Type(JsonStringType), raw String/Map 금지) 정렬. @Type은 Hibernate/라이브러리 애노테이션이라 domain→infra import 없음(AttributeConverter를 infra에 두면 발생하는 레이어 위반 회피). 전략 내부는 쿼리 대상 아님
K. 전략 정규화(컬럼 분해 + variants 자식 테이블)strategy_type/toggle_enabled/rollout_percentage/match_* 컬럼 + feature_flag_variants 테이블미채택(1차) — 자식 테이블·컬럼 다수로 단일 클래스 @Entity가 복잡. 전략 내부 쿼리 필요 없음. senior-dba가 감사 지표 요건상 필요하다 판단하면 전환 여지

단순함 우선 결론: A(Redis 캐시+pub/sub+로컬 스냅샷, 폴링 안전망 병행) + E(sealed 다형성) + H(명시적 클라이언트) + J(TEXT+Converter). 범용 규칙 엔진(G)·AOP(I)·명시적 ID 타게팅은 미채택.

Detail Design

도메인 바운디드 컨텍스트 경계 판단 (의무)

결정: featureflag 신규 도메인으로 분리한다. 기존 도메인 합류하지 않는다.

판단 축검토결론
데이터 소유feature_flags·feature_flag_audit_logs를 단독 소유. 기존 어느 도메인도 “런타임 토글”을 소유하지 않음독립 소유 → 분리
라이프사이클플래그는 어떤 비즈니스 엔티티와도 무관하게 생성/아카이브. 킬스위치는 배포 라이프사이클에 붙지만 특정 도메인에 종속 안 됨독립 → 분리
변경 주기플랫폼 역량. 런타임에 비즈니스 기능과 독립적으로 변경독립 → 분리
횡단 소비goods·booking 등 모든 도메인이 평가를 소비. 특정 도메인에 두면 도메인 교차 참조 유발분리 + 공용 계약을 common에
  • 미채택(기존 도메인 합류) 사유: 런타임 토글을 소유할 자연스러운 기존 도메인이 없다. operator(운영자 인박스)에 넣으면 데이터 소유·라이프사이클이 어긋나고, 모든 소비 도메인이 operator를 import하는 교차 참조가 생긴다.
  • 도메인 교차 참조 회피 설계(핵심): domain.rentaldomain.product를 import 못 하듯, 다른 도메인은 domain.featureflag를 import할 수 없다(domain.common만 허용). 따라서 공용 평가 계약 FeatureFlagEvaluator(interface) + FeatureContext(VO)를 domain.common에 정의한다 — 선례: DistributedLock·PermissionRepository·DomainEventPublisher가 이미 common에 상주. 구현체 FeatureFlagEvaluatorImpl는 infrastructure에서 domain.featureflag를 참조(infra→domain 허용). 이로써 소비 도메인은 domain.featureflag를 전혀 모른 채 common 인터페이스만 주입한다.
  • 데모 슬라이스 featuredemo는 별도 소비 도메인 — FeatureFlagEvaluator(common)만 주입해 게이팅. domain.featureflag를 import하지 않아 도메인 격리를 스스로 증명한다.

서버 토폴로지 설계 (의무)

후보과제 특성 매칭채택 여부
단일 API 서버 (요청-응답)평가는 인프로세스 로컬 조회(동기). 관리 API도 요청-응답채택 — 지금 규모 단일 인스턴스로 충분. 다중 인스턴스(⑧ 스케일아웃) 시 pub/sub가 인스턴스 수만큼 리스너·커넥션 선형 증가(NFR 반영)
별도 워커 서버 (비동기 처리)변경 전파는 AFTER_COMMIT 이벤트 + pub/sub로 인프로세스 처리미채택 — 독립 비동기 워크로드 없음
스케줄러 서버 (주기 실행)pub/sub 유실 대비 전체 리프레시(안전망)·정리 후보 탐지(FR-14)미채택(전용 서버) — API 서버 내 @Scheduled로 수용
소켓 서버 (실시간 양방향)전파는 서버→서버 브로드캐스트(Redis pub/sub). 클라이언트 실시간 push는 Non-Goals(client-side 평가 없음)미채택

결론: 단일 API 서버 + 인스턴스별 Redis pub/sub 리스너(신규) + @Scheduled 안전망/정리탐지. pub/sub 리스너 스레드·전용 Redis 커넥션이 인스턴스마다 1개씩 추가되며, 다중 인스턴스 전개 시 선형 증가를 용량·Observability에 반영(NFR).

시스템 역할 경계 (의무)

단위역할소유 데이터/책임노출 인터페이스의존
FeatureFlag (Entity, domain)플래그 Aggregate Root. 평가 위임·상태 전이·생성 검증 캡슐화key/type/status/strategy/descriptionevaluate(context)·updateStrategy()·archive()·activate()·toSnapshot()common만
EvaluationStrategy (sealed, domain)전략 다형성. 전략별 평가 규칙전략 파라미터evaluate(context): FeatureEvaluationFeatureContext, StableBucketer
StableBucketer (object, domain)안정 키 해시 버케팅(순수)murmur3_32 정규화bucket(flagKey, userId): Int(0..99)(없음)
FeatureFlagStatus/FeatureFlagType (enum, domain)상태 전이 규칙·종류전이 표canTransitTo(next)(없음)
FeatureFlagAuditLog (Entity, domain)변경 감사 이력changeType/actor/before/after/occurredAtcreate(...)common만
FeatureFlagRepository (interface, domain)플래그 영속화 계약아래 시그니처(없음)
FeatureFlagAuditLogRepository (interface, domain)감사 로그 영속화·조회아래 시그니처(없음)
FeatureFlagCacheStore (gateway interface, domain)Redis 캐시 계약put/get/evict(없음)
FeatureFlagChangeBroadcaster (gateway interface, domain)변경 전파(pub/sub) 계약broadcast(key)(없음)
FeatureFlagEvaluator (interface, domain.common)공용 평가 진입점(FR-12)isEnabled/variant(없음)
FeatureContext (VO, domain.common)평가 입력userId?·attributes(없음)
FeatureFlagDomainService (domain)CRUD 오케스트레이션 + 감사 기록 + 변경 이벤트 등록 + 전파(캐시/broadcast)도메인 규칙create/update/archive/activate/get*/propagate위 Repository·Gateway·DomainEventPublisher
*UseCase (application)트랜잭션 경계·오케스트레이션@Transactionalexecute(command)FeatureFlagDomainService
FeatureFlagEvaluatorImpl (infra)로컬→Redis→MySQL→스냅샷→기본값 폴백 후 flag.evaluate폴백 체인FeatureFlagEvaluator 구현LocalStore·CacheStore·Repository
LocalFeatureFlagStore (infra)인스턴스 인메모리 L1 + 마지막 성공 스냅샷ConcurrentHashMapput/get/refresh/getAllKeysCacheStore·Repository
RedisFeatureFlagCacheStore (infra)Redis look-aside 캐시 구현키/TTLCacheStore 구현StringRedisTemplate
RedisFeatureFlagChangeBroadcaster (infra)pub/sub 발행 구현채널Broadcaster 구현StringRedisTemplate
FeatureFlagRedisPubSubConfig + FeatureFlagChangeSubscriber (infra)RedisMessageListenerContainer(신규) + 수신 시 로컬 갱신리스너 스레드·커넥션(내부)LocalStore
FeatureFlagRepositoryImpl/...AuditLogRepositoryImpl (infra)MySQL 매핑(JPA+QueryDSL)테이블Repository 구현Jpa/QueryDsl Repo
FeatureFlagAdminApiController (presentation)관리 REST 라우팅·상태코드 매핑/admin/feature-flagsUseCase들
FeatureFlagChangedEventListener (presentation)AFTER_COMMIT에 전파 트리거@TransactionalEventListenerUseCase 경유
FeatureDemoApiController + GetDemoGreetingUseCase + FeatureDemoDomainService (featuredemo)데모 게이팅(FR-9)/feature-demo/helloFeatureFlagEvaluator(common)

인터페이스 시그니처 (구현자 간 해석 차이 제거)

// domain/common/FeatureFlagEvaluator.kt  (공용 평가 진입점 — FR-2·FR-12)
interface FeatureFlagEvaluator {
    fun isEnabled(key: String, context: FeatureContext, default: Boolean): Boolean
    fun variant(key: String, context: FeatureContext, default: String): String
}

// domain/common/FeatureContext.kt
data class FeatureContext(
    val userId: Long?,
    val attributes: Map<String, String> = emptyMap(),
) {
    companion object {
        fun of(userId: Long?): FeatureContext = FeatureContext(userId)
        fun anonymous(): FeatureContext = FeatureContext(null)
    }
}

// domain/featureflag/repository/FeatureFlagRepository.kt
interface FeatureFlagRepository {
    fun save(featureFlag: FeatureFlag): FeatureFlag
    fun findByKey(key: String): FeatureFlag?          // Optional 금지 — nullable
    fun findById(id: Long): FeatureFlag?
    fun findAllActive(): List<FeatureFlag>            // 부트스트랩·목록
    fun findAll(status: FeatureFlagStatus?, type: FeatureFlagType?): List<FeatureFlag> // QueryDSL 동적
    fun existsByKey(key: String): Boolean
}

// domain/featureflag/repository/FeatureFlagAuditLogRepository.kt
interface FeatureFlagAuditLogRepository {
    fun save(log: FeatureFlagAuditLog): FeatureFlagAuditLog
    // 레포 페이징 관례(McpAuditLogRepository 선례) — Spring Page 반환, occurred_at desc
    fun findByFlagKey(key: String, pageable: Pageable): Page<FeatureFlagAuditLog>
}

// domain/featureflag/gateway/FeatureFlagCacheStore.kt
interface FeatureFlagCacheStore {
    fun put(snapshot: FeatureFlagSnapshot)
    fun get(key: String): FeatureFlagSnapshot?
    fun evict(key: String)
}

// domain/featureflag/gateway/FeatureFlagChangeBroadcaster.kt
interface FeatureFlagChangeBroadcaster {
    fun broadcast(key: String)   // pub/sub 채널에 변경된 flagKey 발행
}

// domain/featureflag/strategy/EvaluationStrategy.kt
sealed class EvaluationStrategy {
    abstract fun evaluate(context: FeatureContext): FeatureEvaluation
    data class GlobalToggle(val enabled: Boolean) : EvaluationStrategy()
    data class PercentageRollout(val percentage: Int) : EvaluationStrategy()      // 0..100
    data class AttributeMatch(val attribute: String, val value: String) : EvaluationStrategy()
    data class VariantBucketing(val variants: List<Variant>) : EvaluationStrategy() // 최대 4, weight 합 100
}
data class Variant(val name: String, val weight: Int)

sealed class FeatureEvaluation {
    data object On : FeatureEvaluation()
    data object Off : FeatureEvaluation()
    data class  Assigned(val variantName: String) : FeatureEvaluation()
}

// application/featureflag/usecase — 각 UseCase execute 시그니처
CreateFeatureFlagUseCase.execute(command: CreateFeatureFlagCommand): FeatureFlagResponse
UpdateFeatureFlagUseCase.execute(command: UpdateFeatureFlagCommand): FeatureFlagResponse
ArchiveFeatureFlagUseCase.execute(command: ArchiveFeatureFlagCommand): FeatureFlagResponse
ActivateFeatureFlagUseCase.execute(command: ActivateFeatureFlagCommand): FeatureFlagResponse
GetFeatureFlagUseCase.execute(key: String): FeatureFlagResponse
ListFeatureFlagsUseCase.execute(command: ListFeatureFlagsCommand): List<FeatureFlagResponse>
GetFeatureFlagAuditLogsUseCase.execute(command: GetAuditLogsCommand): ListFeatureFlagAuditLogsResponse  // total 포함(FE "1/3")
PropagateFeatureFlagChangeUseCase.execute(key: String)  // AFTER_COMMIT 트리거

클래스 역할 정의

도메인 모델

클래스명역할핵심 책임
FeatureFlag플래그 Aggregate Rootcreate()(key 형식·strategy 유효성 검증), evaluate(context)(ARCHIVED면 평가 안 함 신호, 아니면 strategy.evaluate), updateStrategy()·archive()·activate()(전이 검증 + 변경 이벤트 register), toSnapshot()
EvaluationStrategy전략 sealed 다형성GlobalToggle→On/Off, PercentageRollout→userId 버킷<percentage면 On, AttributeMatch→속성 equals, VariantBucketing→버킷 가중 매핑 Assigned. userId 없으면 %·variant는 Off(호출부 기본값 처리)
StableBucketer안정 키 버케팅murmur3_32("{flagKey}:{userId}")Math.floorMod(hash,100) = 0..99. 결정론적 = sticky
FeatureFlagStatus상태 enumACTIVE↔ARCHIVED canTransitTo
FeatureFlagType종류 enumRELEASE/OPERATIONAL/EXPERIMENT/ENTITLEMENT (EXPERIMENT만 VariantBucketing 허용 검증)
FeatureFlagAuditLog감사 이력 Entitycreate(changeType, actorUserId, before?, after)
FeatureFlagSnapshot캐시/감사용 값 data classkey·type·status·strategy·description (raw String 아님, 타입화)

서비스 클래스

클래스명역할입력 → 출력의존
FeatureFlagDomainServiceCRUD·감사·전파 오케스트레이션command → FeatureFlag / propagate(key)FeatureFlagRepository, FeatureFlagAuditLogRepository, FeatureFlagCacheStore, FeatureFlagChangeBroadcaster, DomainEventPublisher
FeatureFlagEvaluatorImpl폴백 체인 후 위임 평가(key, context, default) → Boolean/StringLocalFeatureFlagStore, FeatureFlagCacheStore, FeatureFlagRepository
LocalFeatureFlagStore인메모리 L1 + 마지막 성공 스냅샷refresh(key)/get(key)FeatureFlagCacheStore, FeatureFlagRepository
FeatureDemoDomainService데모 게이팅greet(userId) → Greeting / throw 503FeatureFlagEvaluator(common)

실패 경로·동시성·멱등 (의무)

시나리오처리결과
평가 시 로컬 스냅샷 미스LocalStore가 Redis 캐시 조회 → 미스면 MySQL(findByKey) → 로컬·Redis 채움(look-aside)정상 평가
Redis 장애(캐시·pub/sub)평가는 로컬 스냅샷만 읽으므로 무영향. 미스 시 MySQL 직접. pub/sub 미수신분은 @Scheduled 전체 리프레시(안전망)로 복구. redis-degraded 경보읽기 지속(FR-11)
Redis+MySQL 모두 접근 불가 + 로컬에 값 있음마지막 성공 스냅샷으로 평가읽기 지속(FR-11)
로컬·Redis·MySQL 모두 없음(최초 기동 등)호출부 지정 기본값(default) 반환안전(FR-2·User Scenario 6)
정의되지 않은 keyLocalStore/Repo에서 null → 호출부 기본값기본값(FR-2)
ARCHIVED 플래그 평가평가 대상에서 제외 → 호출부 기본값기본값(User Scenario 9)
관리 API 동시 수정(같은 key)단일 row 낙관적 락(@Version) → 충돌 시 409(OptimisticLock 기존 핸들러). 재조회 후 재시도는 호출자 몫유실 없음
pub/sub 메시지 유실1차 pub/sub, 2차 @Scheduled(예: 30초) 전체 리프레시가 드리프트 수렴최종 일관성
pub/sub 중복 수신수신 처리 = 해당 key 재조회 후 로컬 덮어쓰기(멱등) — 재적용해도 동일 상태멱등
MySQL 장애 중 관리 쓰기쓰기 실패 허용(NFR). 평가(읽기)는 로컬로 지속쓰기만 실패
  • 동시성: 플래그 쓰기는 저빈도·단일 row → 낙관적 락(@Version)으로 충분. 분산 락 불필요(미채택 — 지금 규모에 과함).
  • 멱등: pub/sub 수신은 “해당 key 재조회 후 로컬 덮어쓰기”라 본질적 멱등. 변경 이벤트 eventId(AbstractDomainEvent)로 내부 리스너 중복 방지 여지 확보.

상태 전이 표

현재 상태 × 이벤트다음 상태거부/비고
(없음) × createACTIVEkey 중복이면 거부 400
ACTIVE × updateStrategyACTIVEstrategy 유효성 위반 시 거부 400
ACTIVE × archiveARCHIVED감사 로그 + 전파. 평가 대상 제외
ARCHIVED × archiveARCHIVED거부 409 (이미 아카이브, canTransitTo false)
ARCHIVED × activateACTIVE감사 로그 + 전파
ACTIVE × activateACTIVE거부 409 (이미 활성)
ARCHIVED × updateStrategyARCHIVED거부 409 (아카이브 상태 수정 불가)

Component Diagram (Mermaid flowchart LR)

flowchart LR
    subgraph Presentation
        AdminCtl["FeatureFlagAdminApiController"]
        DemoCtl["FeatureDemoApiController"]
        ChgLsnr["FeatureFlagChangedEventListener"]
    end
    subgraph Application
        AdminUC["관리 UseCase들"]
        PropUC["PropagateChangeUseCase"]
        DemoUC["GetDemoGreetingUseCase"]
    end
    subgraph Domain
        DS["FeatureFlagDomainService"]
        Flag["FeatureFlag + EvaluationStrategy"]
        Eval["common.FeatureFlagEvaluator"]
    end
    subgraph Infra
        EvalImpl["FeatureFlagEvaluatorImpl"]
        Local["LocalFeatureFlagStore"]
        Redis["Redis Cache + PubSub"]
        MySQL["RepositoryImpl (MySQL)"]
    end
    AdminCtl --> AdminUC --> DS
    ChgLsnr --> PropUC --> DS
    DemoCtl --> DemoUC --> Eval
    DS --> MySQL
    DS --> Redis
    EvalImpl -.->|implements| Eval
    EvalImpl --> Local
    Local --> Redis
    Local --> MySQL
    Flag --> Eval

Sequence Diagram — 변경 전파 (킬스위치)

sequenceDiagram
    participant A as AdminController
    participant U as ArchiveUseCase
    participant D as FeatureFlagDomainService
    participant L as ChangedEventListener(AFTER_COMMIT)
    participant R as Redis(Cache+PubSub)
    participant I as 타 인스턴스 Subscriber
    A->>U: execute(archive command)
    U->>D: archive(key)
    D->>D: flag.archive() + 감사 저장 + save(MySQL)
    D-->>U: 커밋
    U-->>L: FeatureFlagChangedEvent
    L->>D: propagate(key)
    D->>R: cacheStore.put(snapshot) + broadcast(key)
    R-->>I: publish(key)
    I->>I: LocalStore.refresh(key) → 로컬 갱신(수 초)

Sequence Diagram — 평가 (폴백 포함)

sequenceDiagram
    participant S as 소비 도메인 서비스
    participant E as FeatureFlagEvaluator
    participant L as LocalFeatureFlagStore
    participant R as Redis Cache
    participant M as MySQL
    S->>E: isEnabled(key, context, default)
    E->>L: get(key)
    alt 로컬 히트
        L-->>E: snapshot
    else 로컬 미스
        L->>R: get(key)
        alt Redis 히트
            R-->>L: snapshot (로컬 채움)
        else Redis 미스/장애
            L->>M: findByKey(key)
            M-->>L: flag or null (성공 시 채움)
        end
        L-->>E: snapshot or null
    end
    E-->>S: null/archived면 default, 아니면 flag.evaluate(context)

API 계약 (private-senior-fe 인계용 — FR-7·FR-9)

관리 API: base /admin/feature-flags, 인가 hasRole("ADMIN")(SecurityConfig 무변경), actor = SecurityContext principal.id.

메서드경로요청성공실패
POST/admin/feature-flags{key, type, description, strategy}201 FeatureFlagResponse400 (key 중복·percentage>100·variant>4·weight합≠100·EXPERIMENT 아닌데 variant)
GET/admin/feature-flags?status=&type=200 FeatureFlagResponse[] (배열 직반환, 빈 목록 [])
GET/admin/feature-flags/{key}200 FeatureFlagResponse404
PUT/admin/feature-flags/{key}{description, strategy}200 FeatureFlagResponse400 / 404 / 409(ARCHIVED 수정)
POST/admin/feature-flags/{key}/archive200 {key, status:"ARCHIVED"}404 / 409(이미 ARCHIVED)
POST/admin/feature-flags/{key}/activate200 {key, status:"ACTIVE"}404 / 409(이미 ACTIVE)
GET/admin/feature-flags/{key}/audit-logs?page=&size=200 ListFeatureFlagAuditLogsResponse (total 포함)404

페이징 형태 확정(senior-pm 정합 #1): 감사 로그는 total 포함 페이지 응답(FE S5 “1/3” 총페이지 표시 요구 충족). 플래그 목록(GET /admin/feature-flags)은 활성 플래그가 소량(수십 개)이고 페이징 UI가 없어 배열 직반환 유지로 확정 — 두 훅의 형태가 다른 것은 화면(목록 vs 이력 모달)이 달라 정상. FE는 이 계약을 못박아 소비한다.

strategy 요청/응답 형태 (strategyType 판별):

{ "strategyType": "GLOBAL_TOGGLE", "enabled": true }
{ "strategyType": "PERCENTAGE_ROLLOUT", "percentage": 50 }
{ "strategyType": "ATTRIBUTE_MATCH", "attribute": "plan", "value": "PREMIUM" }
{ "strategyType": "VARIANT_BUCKETING", "variants": [ {"name":"A","weight":50}, {"name":"B","weight":50} ] }

FeatureFlagResponse: { id, key, type, status, description, strategy, createdAt, updatedAt } (시각 ISO-8601, ZonedDateTime). FeatureFlagAuditLogResponse: { changeType, actorUserId, before, after, occurredAt } (before/after = FeatureFlagSnapshot 또는 null). ListFeatureFlagAuditLogsResponse (레포 ListMcpAuditLogsResponse 선례 미러): { content: FeatureFlagAuditLogResponse[], totalElements: long, totalPages: int, pageNumber: int, pageSize: int }. of(page: Page<FeatureFlagAuditLog>)로 생성. 기본 size=20, page=0.

데모 API (FR-9): base /feature-demo, permitAll(SecurityConfig에 /feature-demo/** 1줄 추가), X-User-Id 헤더로 평가 컨텍스트.

메서드경로요청성공실패
GET/feature-demo/helloheader X-User-Id(optional)200 {message, flagKey, userId, servedAt} (플래그 demo.feature.hello 평가 ON)503 (OFF·미존재·롤아웃 미포함 = FeatureDisabledException)

ERD

feature_flags·feature_flag_audit_logs 신규 테이블. 상세 컬럼·인덱스·DDL은 private-senior-dba(design-db) + private-mysql-implementer(마이그레이션)에서 확정. 요약만.

erDiagram
    FEATURE_FLAG ||--o{ FEATURE_FLAG_AUDIT_LOG : "변경 이력"
    FEATURE_FLAG {
        bigint id PK
        varchar flag_key "UNIQUE, 평가 키"
        varchar flag_type "RELEASE/OPERATIONAL/EXPERIMENT/ENTITLEMENT"
        varchar status "ACTIVE/ARCHIVED"
        varchar description
        text strategy_config "JSON→EvaluationStrategy (@Type JsonStringType)"
        bigint version "낙관적 락(@Version Long)"
        datetime created_at "6"
        datetime updated_at "6"
        bigint created_by
        bigint updated_by
    }
    FEATURE_FLAG_AUDIT_LOG {
        bigint id PK
        varchar flag_key "대상 키"
        varchar change_type "CREATED/UPDATED/ARCHIVED/ACTIVATED"
        bigint actor_user_id "변경자"
        text before_snapshot "JSON→FeatureFlagSnapshot, nullable"
        text after_snapshot "JSON→FeatureFlagSnapshot"
        datetime occurred_at "6"
    }

senior-dba/mysql-implementer 요구사항 (design 수준 근거)

  • feature_flags: flag_key UNIQUE 인덱스(평가·중복검증 조회), status 인덱스(findAllActive·목록). flag_type VARCHAR(ENUM 금지), status VARCHAR. strategy_config TEXT(JSON 컬럼 금지→TEXT, 코드 매핑 @Type(JsonStringType)). version BIGINT(낙관적 락, 도메인 @Version 필드 타입 Long — 레포 선례). created_by/updated_by BIGINT(FK 컬럼 금지, 일반 컬럼). 시각 DATETIME(6). 모든 컬럼·테이블 COMMENT 필수.
  • feature_flag_audit_logs: 복합 인덱스 (flag_key, occurred_at) — 플래그별 최신순 페이징 조회가 대상 쿼리. before_snapshot/after_snapshot TEXT(스냅샷 이력은 정규화보다 TEXT가 적합). 보존 1년(NFR) — 파티셔닝/아카이브는 senior-dba 판단(P2).
  • BOOLEAN 미사용(strategy 내부 enabled는 TEXT JSON 안). 필요 시 TINYINT(1) 컨벤션 적용.
  • 마이그레이션 번호: 현재 최신 V37. 형제 과제(B2B·마케팅)가 V38~V41을 문서상 계획 → senior-dba가 dev 머지 순서 기준으로 배정(권고 V38+, 충돌 시 재배정, MEMORY “먼저 머지되는 쪽 순차 점유” 방침). 2개 테이블 = 1개 마이그레이션 파일 권장.
  • 전략 정규화 대안(방안 K): 감사/운영상 전략 내부 쿼리가 필요해지면 컬럼 분해 + feature_flag_variants 자식 테이블로 전환 여지. 1차는 TEXT + @Type(JsonStringType)(방안 J).

Testing Plan

레벨대상범위
domainFeatureFlag·EvaluationStrategy·StableBucketer·FeatureFlagStatuscreate 검증(key 형식·percentage 0..100·variant≤4·weight합100·EXPERIMENT만 variant), 전략별 evaluate 전수(Global/Percentage/Attribute/Variant), 버케팅 stickiness(동일 userId 동일 결과·flagKey salt로 플래그 간 독립), 상태 전이(canTransitTo 전수·거부), ARCHIVED 평가 제외
domainFeatureFlagDomainServicecreate/update/archive/activate 시 감사 로그 기록·변경 이벤트 register, 중복 key 거부, propagate가 cacheStore.put+broadcast 호출 — Repository/CacheStore/Broadcaster/Publisher MockK
application각 UseCaseexecute가 DomainService 위임·@Transactional 경계 (DomainService MockK)
infraFeatureFlagRepositoryImpl·AuditLogRepositoryImplTestcontainers MySQL. 저장·findByKey·findAllActive·동적 필터(QueryDSL)·감사 페이징·strategy TEXT round-trip
infraRedisFeatureFlagCacheStore·RedisFeatureFlagChangeBroadcaster·FeatureFlagChangeSubscriberTestcontainers Redis. put/get/evict·TTL, broadcast→subscriber 수신→LocalStore refresh, 채널 계약
infraFeatureFlagEvaluatorImpl·LocalFeatureFlagStore폴백 체인: 로컬 히트/미스→Redis→MySQL→스냅샷→기본값. Redis 다운(로컬 유지), 전부 다운(default), 미정의 key(default), archived(default)
presentationFeatureFlagAdminApiControllerMockMvc. 상태코드 매핑(201/200/400/404/409), 목록 빈 상태, strategy 직렬화, audit 조회
presentationFeatureDemoApiController·FeatureFlagChangedEventListener게이팅 200/503, AFTER_COMMIT 전파 트리거
scenario데모 게이팅 E2E (FR-9·Success Metrics)① 플래그 생성 ON→/feature-demo/hello 200 ② archive/OFF→503 (재배포 없이) ③ 재활성 ON→수 초 내 200 ④ 퍼센티지 50%→동일 userId 반복 호출 시 판정 일관(sticky) ⑤ archived→기본값·활성목록 제외 ⑥ 관리 변경 성공 건수 == 감사 로그 건수(커버리지 100%)
scenario다중 인스턴스 킬스위치 전파 (SM3, 경량)2개 구독자(로컬 compose 스케일아웃 2 인스턴스 또는 동일 Redis에 붙은 2 Spring 컨텍스트/2 구독자)에서 플래그 변경 후 양쪽 로컬 반영이 3초 이내임을 시각차 로그로 검증. 20000TPS 부하 실측은 형제 과제 위임(경계)

핵심 실패 경로(테스트가 잡아야 함): Redis 다운 시 평가 지속·redis-degraded 경보 / 미정의 key·archived → 기본값 / percentage>100·중복 key·variant>4 → 400 / 이미 ARCHIVED archive → 409 / pub/sub 수신 멱등(중복 수신 동일 상태).

Release Scenario — 무중단 배포 (의무)

신규 도메인 순수 가산. 기존 12개 도메인·스키마 무변경. expand-contract + 킬스위치 내장.

단계작업전환 조건롤백
1. 스키마(먼저)V{N} feature_flags·feature_flag_audit_logs 가산(기존 테이블 무변경)마이그레이션 성공역방향 DDL로 두 테이블 drop (참조 코드 미배포 상태라 안전)
2. 코드 배포featureflag/featuredemo 전 코드 + ErrorStatus.SERVICE_UNAVAILABLE 추가 + SecurityConfig /feature-demo/** permitAll. 플래그 미생성 상태 → /feature-demo/hello는 기본값 OFF로 503(다크)배포 성공, 기존 회귀 0코드 롤백(직전 태그 재배포). 스키마 가산이라 잔존 안전
3. 부트스트랩 확인기동 시 findAllActive로 로컬 스냅샷·Redis 워밍. pub/sub 리스너 기동 로그 확인리스너·커넥션 정상
4. 데모 플래그 ON관리 API로 demo.feature.hello(RELEASE, GlobalToggle ON) 생성 → 수 초 내 전 인스턴스 반영 → 200E2E ①②③ 통과플래그 OFF/archive → 즉시 503 (킬스위치)
5. 퍼센티지 검증demo.feature.hello를 PercentageRollout 10→50→100userId sticky 확인(E2E ④)percentage 0 또는 archive
  • 배포 순서: 스키마 먼저 → 코드(플래그 없음=다크) → 플래그 ON. 하위 호환 가산만, API 버저닝 불필요.
  • 롤백: 최악의 경우 게이팅된 플래그 archive/OFF 한 번으로 해당 기능 즉시 비활성(기존 도메인 무영향). 시스템 자체 롤백은 코드 직전 태그 재배포([private-deploy-convention] 이미지 태그 핀).

Observability (PRD Operations 구체화)

본 과제가 발신하는 지표·경보 (티켓으로 조작화 — senior-pm 정합 #2)

지표/경보Micrometer 이름·형태발신 지점(티켓)대응 Success Metric
플래그별 평가 횟수counter feature_flag_evaluations_total{key}BE-06 EvaluatorImplFR-13
로컬 캐시 히트/미스counter `feature_flag_cache_access_total{layer=localredis,result=hitmiss}`
pub/sub 전파 지연gauge/timer feature_flag_propagation_lag_seconds (변경 occurredAt → 로컬 반영 시각차)발신 BE-03(수신 타임스탬프 기록) + 게이지화 BE-12킬스위치 3초
로컬 스냅샷 크기·부트스트랩 성공gauge feature_flag_local_snapshot_size·feature_flag_bootstrap_successBE-12
pub/sub 리스너 스레드·전용 커넥션 상태gauge feature_flag_pubsub_listener_active·feature_flag_pubsub_connectionsBE-12커넥션 누수·리스너 미기동 조기 발견(NFR)
Redis 폴백 진입 경보event redis-degraded(warning, source=feature-flag)BE-03/BE-06 폴백 catchMySQL/Redis 장애 시 읽기 지속
부트스트랩 실패 경보event(critical, source=feature-flag)BE-12
  • 캐시 히트율(<95%)·전파 지연(P95>3초) 임계 경보와 대시보드 패널 구성 자체는 ⑦·⑥ 형제 과제가 지표를 소비해 수행한다 — 본 과제는 위 지표를 정확히 발신하는 것까지 책임진다.

NFR 실측 경계 (senior-pm 정합 #2 — 위임/자체 완료조건 분리)

항목본 과제 완료조건형제 과제 위임
캐시 히트율 99%(SM1)지표 발신 + 단위·통합 테스트에서 look-aside 동작 검증20000TPS 부하 하 실측은 [상시 트래픽 시뮬레이터]·[옵저버빌리티 스택 도입]
평가 P95 5ms@20000TPS(SM2)로컬 인메모리 평가 경로(P95 1ms 설계)·평가 카운터 발신20000TPS 부하 실측(레이턴시 히스토그램)은 [상시 트래픽 시뮬레이터]·[옵저버빌리티]
킬스위치 3초 다중인스턴스 전파(SM3)다중 인스턴스(로컬 compose 스케일아웃 또는 2 구독자) 경량 E2E로 3초 내 전파 검증(BE-10)프로덕션 규모 다중 인스턴스 실측은 [배포 파이프라인 스케일아웃]·[옵저버빌리티]
감사 커버리지 100%(SM4)E2E에서 변경 성공 건수 == 감사 적재 건수(BE-10)

20000TPS 부하 실측 자체는 본 과제 범위 밖(형제 과제 소관). 본 과제는 그 실측을 가능케 하는 지표 발신 + 킬스위치 3초 전파의 경량 다중인스턴스 검증까지만 완료조건으로 둔다.

정리 후보

  • 정리 후보(FR-14, P2): @Scheduled가 90일 무변경 ACTIVE RELEASE 플래그를 탐지 → 옵저버빌리티 대시보드 또는 ⑥ 채널로 정기 통지(BE-11).

Open Questions

  • EXPERIMENT variant 상한: PRD Open Q → 최대 4개, weight 합 100 결정(create/update 검증). 초과 필요 시 후속.
  • 감사 보존 1년: 1차 TEXT 이력 유지. 파티셔닝/아카이브·조회 빈도 재평가는 senior-dba 후속(P2).
  • 명시적 ID 리스트 타게팅: Non-Goal 유지. 필요 확인 시 후속 과제.
  • @FeatureGate AOP: 게이팅 지점이 다수로 늘면 도입 재검토(현재 명시적 호출).
  • 다중 인스턴스 pub/sub 커넥션 선형 증가: ⑧ 스케일아웃 시 커넥션 풀·리스너 컨테이너 튜닝 필요 — Observability로 모니터.
  • 데모 게이팅 대상 확정(PRD Open Q): 아래 결정 참조.

데모 게이팅 대상 결정 (PRD Open Question 해소)

결정: 신규 최소 데모 엔드포인트 GET /feature-demo/hello를 신설해 게이팅한다 (기존 엔드포인트 재사용 아님).

후보장점미채택/채택 사유
신규 GET /feature-demo/hello (채택)부작용 0, 기존 도메인·테스트와 완전 격리(Single Writer 충돌 없음), 킬스위치(503)·퍼센티지 sticky를 직접·독립적으로 증명, FeatureFlagEvaluator(common)만 주입해 도메인 격리·FR-12 보일러플레이트 없음을 스스로 증명채택 — 가장 단순하고 증명력 높음. PRD가 “신규 데모 기능” 명시 허용
기존 GET /weather 게이팅실제 엔드포인트미채택 — 외부 KMA 호출·기존 테스트/흐름에 영향, 503 전환이 다른 시나리오를 오염. 증명 신호가 흐려짐
기존 GET /products/popular 게이팅실제 트래픽 경로미채택 — goods 도메인 코드 수정 유발(무변경 원칙 위배), 회귀 위험

Document History

날짜변경 내용
2026-07-03최초 작성 — AS-IS 실측(featureflag/pub-sub 0건, 단일클래스 @Entity, /admin/** hasRole ADMIN, ErrorStatus 503 부재, V37 순번식), 신규 도메인 분리 + common 평가 계약, 전파방안 A(Redis 캐시+pub/sub+로컬 스냅샷), 전략 sealed 다형성, TEXT+Converter 영속화, 데모 /feature-demo/hello 신설 결정, API 계약, 무중단 배포, Observability
2026-07-03senior-pm 정합 보완 — ① 감사 로그 페이징 계약을 ListFeatureFlagAuditLogsResponse(total 포함, Page 선례)로 확정·목록은 배열 유지 못박음 ② Observability 지표를 티켓으로 조작화(BE-03/06/12) + NFR 실측 경계 명문화(20000TPS는 형제 과제 위임, 킬스위치 3초 다중인스턴스 경량 E2E는 BE-10) ③ strategy_config 매핑 문구를 @Type(JsonStringType)로 동기화·@Version Long/BIGINT 고정 ④ REDIS-01 계약 티켓 신설로 유령 참조 제거