이 글에서 얻는 것

  • 피처 플래그로 배포와 릴리스를 분리하는 방법을 이해합니다
  • 카나리 릴리스A/B 테스트 구현 패턴을 알아봅니다
  • 피처 플래그 베스트 프랙티스와 관리 전략을 익힙니다

왜 피처 플래그인가?

문제: 빅뱅 릴리스

flowchart LR
    subgraph "❌ 전통적 배포"
        D1[개발 완료] --> D2[전체 배포]
        D2 --> D3{문제 발생?}
        D3 -->|Yes| D4[롤백]
        D3 -->|No| D5[성공]
    end
    
    style D4 fill:#ffebee,stroke:#c62828

문제점:

  • 모든 사용자에게 동시 노출
  • 문제 시 전체 롤백 필요
  • 테스트 환경과 프로덕션 차이

해결: 피처 플래그

flowchart LR
    subgraph "✅ 피처 플래그"
        F1[배포] --> F2[플래그 OFF]
        F2 --> F3[1% 사용자 ON]
        F3 --> F4[10% 사용자 ON]
        F4 --> F5[100% 사용자 ON]
    end
    
    style F2 fill:#e8f5e9,stroke:#2e7d32
    style F3 fill:#e8f5e9,stroke:#2e7d32
    style F4 fill:#e8f5e9,stroke:#2e7d32

배포 ≠ 릴리스: 코드는 배포하되, 기능 노출은 점진적으로


기본 구현

간단한 피처 플래그

@Component
public class FeatureFlags {
    
    @Value("${feature.new-checkout:false}")
    private boolean newCheckoutEnabled;
    
    @Value("${feature.dark-mode:false}")
    private boolean darkModeEnabled;
    
    public boolean isNewCheckoutEnabled() {
        return newCheckoutEnabled;
    }
    
    public boolean isDarkModeEnabled() {
        return darkModeEnabled;
    }
}

// 사용
@Service
public class CheckoutService {
    
    @Autowired
    private FeatureFlags featureFlags;
    
    public CheckoutResult checkout(Cart cart) {
        if (featureFlags.isNewCheckoutEnabled()) {
            return newCheckoutFlow(cart);  // 새 로직
        } else {
            return legacyCheckoutFlow(cart);  // 기존 로직
        }
    }
}

동적 플래그 (DB 기반)

@Entity
@Table(name = "feature_flags")
public class FeatureFlag {
    @Id
    private String key;
    private boolean enabled;
    private Integer rolloutPercentage;  // 0-100
    private String targetUserIds;        // 특정 사용자
    private LocalDateTime expiresAt;     // 만료일
}

@Service
public class DynamicFeatureFlags {
    
    @Autowired
    private FeatureFlagRepository repository;
    
    @Cacheable("featureFlags")
    public boolean isEnabled(String key) {
        return repository.findById(key)
            .map(FeatureFlag::isEnabled)
            .orElse(false);
    }
    
    public boolean isEnabledForUser(String key, String userId) {
        FeatureFlag flag = repository.findById(key).orElse(null);
        if (flag == null) return false;
        
        // 특정 사용자 체크
        if (flag.getTargetUserIds() != null 
            && flag.getTargetUserIds().contains(userId)) {
            return true;
        }
        
        // 롤아웃 비율 체크
        if (flag.getRolloutPercentage() != null) {
            int hash = Math.abs(userId.hashCode() % 100);
            return hash < flag.getRolloutPercentage();
        }
        
        return flag.isEnabled();
    }
}

점진적 롤아웃

카나리 릴리스

flowchart TB
    subgraph "Canary Release"
        LB[Load Balancer]
        
        LB -->|95%| V1[Version 1\n기존 버전]
        LB -->|5%| V2[Version 2\n새 버전]
        
        V2 -->|모니터링| M[Metrics]
        M -->|이상 없음| Expand[비율 확대]
        M -->|문제 발견| Rollback[롤백]
    end
    
    style V2 fill:#fff3e0,stroke:#ef6c00

사용자 기반 롤아웃

@Service
public class RolloutService {
    
    /**
     * 사용자 ID 해시 기반 일관된 롤아웃
     * 같은 사용자는 항상 같은 그룹에 속함
     */
    public boolean isInRollout(String userId, int percentage) {
        int hash = Hashing.murmur3_32()
            .hashString(userId, StandardCharsets.UTF_8)
            .asInt();
        int bucket = Math.abs(hash % 100);
        return bucket < percentage;
    }
}

// 사용
if (rolloutService.isInRollout(user.getId(), 10)) {
    // 10%의 사용자에게 새 기능 노출
    showNewFeature();
} else {
    showOldFeature();
}

단계별 롤아웃 전략

# feature-rollout.yaml
features:
  new-payment:
    stages:
      - name: "내부 테스터"
        percentage: 0
        userIds: ["internal-1", "internal-2", "qa-team"]
      - name: "얼리 어답터"
        percentage: 1
        startDate: "2024-01-15"
      - name: "확대"
        percentage: 10
        startDate: "2024-01-17"
      - name: "전체 공개"
        percentage: 100
        startDate: "2024-01-20"

A/B 테스트

구현

@Service
public class ABTestService {
    
    public String getVariant(String experimentId, String userId) {
        // 일관된 변이 할당
        String seed = experimentId + ":" + userId;
        int hash = Math.abs(seed.hashCode() % 100);
        
        Experiment experiment = experimentRepository.findById(experimentId)
            .orElseThrow();
        
        int cumulative = 0;
        for (Variant variant : experiment.getVariants()) {
            cumulative += variant.getPercentage();
            if (hash < cumulative) {
                return variant.getName();
            }
        }
        
        return "control";  // 기본값
    }
}

// 사용
String variant = abTestService.getVariant("checkout-button-color", userId);

switch (variant) {
    case "control":
        return "blue";
    case "variant-a":
        return "green";
    case "variant-b":
        return "orange";
    default:
        return "blue";
}

결과 추적

@Service
public class ExperimentTracker {
    
    @Autowired
    private MetricsService metricsService;
    
    public void trackConversion(String experimentId, String variant, String userId) {
        metricsService.increment(
            "ab_test.conversion",
            Tags.of(
                "experiment", experimentId,
                "variant", variant
            )
        );
        
        // 이벤트 저장
        eventStore.save(new ConversionEvent(experimentId, variant, userId));
    }
}

피처 플래그 도구

오픈소스 / SaaS 비교

도구유형특징
Unleash오픈소스셀프 호스팅, 무료
Flagsmith오픈소스/SaaS다양한 SDK
LaunchDarklySaaS엔터프라이즈급
Split.ioSaaSA/B 테스트 특화

Unleash 연동 예시

<dependency>
    <groupId>io.getunleash</groupId>
    <artifactId>unleash-client-java</artifactId>
    <version>8.4.0</version>
</dependency>
@Configuration
public class UnleashConfig {
    
    @Bean
    public Unleash unleash() {
        return new DefaultUnleash(
            UnleashConfig.builder()
                .appName("my-app")
                .instanceId("instance-1")
                .unleashAPI("http://unleash-server/api/")
                .fetchTogglesInterval(10)  // 10초마다 동기화
                .build()
        );
    }
}

@Service
public class FeatureService {
    
    @Autowired
    private Unleash unleash;
    
    public boolean isEnabled(String feature) {
        return unleash.isEnabled(feature);
    }
    
    public boolean isEnabledForUser(String feature, String userId) {
        UnleashContext context = UnleashContext.builder()
            .userId(userId)
            .build();
        return unleash.isEnabled(feature, context);
    }
}

베스트 프랙티스

플래그 수명 관리

flowchart LR
    Create[플래그 생성] --> Active[활성 사용]
    Active --> Rollout[100% 롤아웃]
    Rollout --> Cleanup[코드 정리]
    Cleanup --> Delete[플래그 삭제]
    
    style Cleanup fill:#fff3e0,stroke:#ef6c00
// ❌ 안티패턴: 영구적인 플래그
if (featureFlags.isEnabled("new-checkout")) {  // 2년째 유지 중...
    newCheckout();
}

// ✅ 플래그에 만료일 설정
@FeatureFlag(
    name = "new-checkout",
    description = "새로운 결제 플로우",
    owner = "payment-team",
    expiresAt = "2024-06-01"  // 정리 기한
)
public boolean isNewCheckoutEnabled() { ... }

플래그 정리 자동화

@Scheduled(cron = "0 0 9 * * MON")  // 매주 월요일
public void checkExpiredFlags() {
    List<FeatureFlag> expired = repository.findExpiredFlags();
    
    for (FeatureFlag flag : expired) {
        slackService.notify(
            flag.getOwner(),
            String.format("피처 플래그 '%s' 정리가 필요합니다. 만료일: %s",
                flag.getName(), flag.getExpiresAt())
        );
    }
}

테스트 전략

@Test
void newCheckout_whenEnabled_shouldUseNewFlow() {
    // Given
    when(featureFlags.isNewCheckoutEnabled()).thenReturn(true);
    
    // When
    CheckoutResult result = checkoutService.checkout(cart);
    
    // Then
    assertThat(result.getFlow()).isEqualTo("NEW");
}

@Test
void newCheckout_whenDisabled_shouldUseLegacyFlow() {
    // Given
    when(featureFlags.isNewCheckoutEnabled()).thenReturn(false);
    
    // When
    CheckoutResult result = checkoutService.checkout(cart);
    
    // Then
    assertThat(result.getFlow()).isEqualTo("LEGACY");
}

요약

피처 플래그 유형

유형용도수명
릴리스 플래그점진적 롤아웃단기
실험 플래그A/B 테스트중기
운영 플래그킬 스위치, 기능 토글장기
권한 플래그유료 기능, 권한영구

핵심 원칙

  1. 배포 ≠ 릴리스: 코드 배포와 기능 노출 분리
  2. 점진적 롤아웃: 1% → 10% → 100%
  3. 모니터링 필수: 새 기능의 메트릭 추적
  4. 정리 문화: 사용 완료된 플래그는 제거