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
용어
정의
SSOT
Single 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으로 비활성화하는 롤백 수단
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#handleBusinessException이 BusinessException.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 금지 준수).
미채택(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에 두면 발생하는 레이어 위반 회피). 전략 내부는 쿼리 대상 아님
feature_flags·feature_flag_audit_logs를 단독 소유. 기존 어느 도메인도 “런타임 토글”을 소유하지 않음
독립 소유 → 분리
라이프사이클
플래그는 어떤 비즈니스 엔티티와도 무관하게 생성/아카이브. 킬스위치는 배포 라이프사이클에 붙지만 특정 도메인에 종속 안 됨
독립 → 분리
변경 주기
플랫폼 역량. 런타임에 비즈니스 기능과 독립적으로 변경
독립 → 분리
횡단 소비
goods·booking 등 모든 도메인이 평가를 소비. 특정 도메인에 두면 도메인 교차 참조 유발
분리 + 공용 계약을 common에
미채택(기존 도메인 합류) 사유: 런타임 토글을 소유할 자연스러운 기존 도메인이 없다. operator(운영자 인박스)에 넣으면 데이터 소유·라이프사이클이 어긋나고, 모든 소비 도메인이 operator를 import하는 교차 참조가 생긴다.
도메인 교차 참조 회피 설계(핵심): domain.rental이 domain.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).
// 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 Root
create()(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(호출부 기본값 처리)
평가는 로컬 스냅샷만 읽으므로 무영향. 미스 시 MySQL 직접. pub/sub 미수신분은 @Scheduled 전체 리프레시(안전망)로 복구. redis-degraded 경보
읽기 지속(FR-11)
Redis+MySQL 모두 접근 불가 + 로컬에 값 있음
마지막 성공 스냅샷으로 평가
읽기 지속(FR-11)
로컬·Redis·MySQL 모두 없음(최초 기동 등)
호출부 지정 기본값(default) 반환
안전(FR-2·User Scenario 6)
정의되지 않은 key
LocalStore/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)로 내부 리스너 중복 방지 여지 확보.
상태 전이 표
현재 상태 × 이벤트
다음 상태
거부/비고
(없음) × create
ACTIVE
key 중복이면 거부 400
ACTIVE × updateStrategy
ACTIVE
strategy 유효성 위반 시 거부 400
ACTIVE × archive
ARCHIVED
감사 로그 + 전파. 평가 대상 제외
ARCHIVED × archive
ARCHIVED
거부 409 (이미 아카이브, canTransitTo false)
ARCHIVED × activate
ACTIVE
감사 로그 + 전파
ACTIVE × activate
ACTIVE
거부 409 (이미 활성)
ARCHIVED × updateStrategy
ARCHIVED
거부 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.
페이징 형태 확정(senior-pm 정합 #1): 감사 로그는 total 포함 페이지 응답(FE S5 “1/3” 총페이지 표시 요구 충족). 플래그 목록(GET /admin/feature-flags)은 활성 플래그가 소량(수십 개)이고 페이징 UI가 없어 배열 직반환 유지로 확정 — 두 훅의 형태가 다른 것은 화면(목록 vs 이력 모달)이 달라 정상. FE는 이 계약을 못박아 소비한다.
데모 API (FR-9): base /feature-demo, permitAll(SecurityConfig에 /feature-demo/** 1줄 추가), X-User-Id 헤더로 평가 컨텍스트.
메서드
경로
요청
성공
실패
GET
/feature-demo/hello
header 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_keyUNIQUE 인덱스(평가·중복검증 조회), status 인덱스(findAllActive·목록). flag_type VARCHAR(ENUM 금지), status VARCHAR. strategy_config TEXT(JSON 컬럼 금지→TEXT, 코드 매핑 @Type(JsonStringType)). versionBIGINT(낙관적 락, 도메인 @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).
① 플래그 생성 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 + 킬스위치 내장.
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) 생성 → 수 초 내 전 인스턴스 반영 → 200
E2E ①②③ 통과
플래그 OFF/archive → 즉시 503 (킬스위치)
5. 퍼센티지 검증
demo.feature.hello를 PercentageRollout 10→50→100
userId sticky 확인(E2E ④)
percentage 0 또는 archive
배포 순서: 스키마 먼저 → 코드(플래그 없음=다크) → 플래그 ON. 하위 호환 가산만, API 버저닝 불필요.
롤백: 최악의 경우 게이팅된 플래그 archive/OFF 한 번으로 해당 기능 즉시 비활성(기존 도메인 무영향). 시스템 자체 롤백은 코드 직전 태그 재배포([private-deploy-convention] 이미지 태그 핀).
캐시 히트율(<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-03
senior-pm 정합 보완 — ① 감사 로그 페이징 계약을 ListFeatureFlagAuditLogsResponse(total 포함, Page 선례)로 확정·목록은 배열 유지 못박음 ② Observability 지표를 티켓으로 조작화(BE-03/06/12) + NFR 실측 경계 명문화(20000TPS는 형제 과제 위임, 킬스위치 3초 다중인스턴스 경량 E2E는 BE-10) ③ strategy_config 매핑 문구를 @Type(JsonStringType)로 동기화·@Version Long/BIGINT 고정 ④ REDIS-01 계약 티켓 신설로 유령 참조 제거