Spring Feature Flag 전략 심화

Feature Flag란?

Feature Flag(Feature Toggle)는 코드 배포와 기능 릴리스를 분리하는 기법입니다. 새 기능을 코드에 포함하되 플래그로 활성/비활성을 제어하여, 배포 없이 기능을 켜고 끌 수 있습니다. 점진적 롤아웃, A/B 테스트, 긴급 킬 스위치 등 운영에서 핵심적인 역할을 합니다.

유형 목적 수명 예시
Release Flag 미완성 기능 숨기기 단기 (릴리스까지) 새 결제 시스템
Experiment Flag A/B 테스트 중기 (실험 기간) 추천 알고리즘 비교
Ops Flag 운영 제어 장기 캐시 전략 전환
Permission Flag 사용자별 기능 제한 영구 프리미엄 전용 기능

FF4j: Java 네이티브 Feature Flag

FF4j는 Java 생태계를 위한 Feature Flag 라이브러리로, Spring Boot와 자연스럽게 통합됩니다.

// build.gradle.kts
dependencies {
    implementation("org.ff4j:ff4j-core:2.1")
    implementation("org.ff4j:ff4j-spring-boot-starter:2.1")
    implementation("org.ff4j:ff4j-web:2.1")  // 관리 콘솔
    implementation("org.ff4j:ff4j-store-springjdbc:2.1")  // DB 저장소
}
@Configuration
public class FeatureFlagConfig {

    @Bean
    public FF4j ff4j(DataSource dataSource) {
        FF4j ff4j = new FF4j();

        // DB 기반 저장소 (운영 환경)
        ff4j.setFeatureStore(new FeatureStoreSpringJdbc(dataSource));
        ff4j.setPropertiesStore(new PropertyStoreSpringJdbc(dataSource));
        ff4j.setEventRepository(new EventRepositorySpringJdbc(dataSource));

        // 감사 로그 활성화
        ff4j.audit(true);

        // 기본 피처 등록
        ff4j.createFeature(
            new Feature("new-payment-system", false,
                "새 결제 시스템 v2")
        );

        ff4j.createFeature(
            new Feature("recommendation-v2", false,
                "개선된 추천 알고리즘")
        );

        return ff4j;
    }
}

서비스 레이어에서 사용

Feature Flag를 서비스 로직에서 사용하여 기능을 분기합니다.

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final FF4j ff4j;
    private final LegacyPaymentGateway legacyGateway;
    private final NewPaymentGateway newGateway;

    public PaymentResult processPayment(PaymentRequest request) {
        if (ff4j.check("new-payment-system")) {
            // 새 결제 시스템
            return newGateway.charge(request);
        }
        // 기존 결제 시스템
        return legacyGateway.charge(request);
    }
}

AOP 기반 Feature Flag

FF4j의 @Flip 어노테이션으로 메서드 레벨에서 선언적으로 기능을 전환합니다.

public interface RecommendationService {
    List<Product> recommend(String userId);
}

@Component
@Primary
public class RecommendationV1 implements RecommendationService {
    @Override
    public List<Product> recommend(String userId) {
        // 기존 알고리즘: 인기순 기반
        return popularityBasedRecommend(userId);
    }
}

@Component
public class RecommendationV2 implements RecommendationService {
    @Override
    public List<Product> recommend(String userId) {
        // 새 알고리즘: 협업 필터링
        return collaborativeFilteringRecommend(userId);
    }
}

// AOP로 피처 플래그에 따라 구현체 전환
@Component
@Aspect
public class FeatureFlagAspect {

    private final FF4j ff4j;
    private final RecommendationV2 v2;

    @Around("execution(* RecommendationV1.recommend(..))")
    public Object switchRecommendation(ProceedingJoinPoint pjp) throws Throwable {
        if (ff4j.check("recommendation-v2")) {
            String userId = (String) pjp.getArgs()[0];
            return v2.recommend(userId);
        }
        return pjp.proceed();
    }
}

점진적 롤아웃: 비율 기반 전략

전체 사용자의 일정 비율에게만 새 기능을 노출하는 점진적 롤아웃 전략입니다.

// 점진적 롤아웃 전략 등록
Feature newCheckout = new Feature("new-checkout-flow");
newCheckout.setFlippingStrategy(
    new PonderationStrategy(30.0)  // 30% 사용자에게 활성화
);
ff4j.createFeature(newCheckout);

// 커스텀 전략: 사용자 ID 기반 일관된 비율 할당
public class ConsistentPercentageStrategy extends AbstractFlipStrategy {

    private double percentage;

    @Override
    public boolean evaluate(String featureName, FeatureStore store,
                           FlippingExecutionContext ctx) {
        String userId = ctx.getString("userId");
        if (userId == null) return false;

        // 해시 기반으로 일관된 그룹 할당
        int hash = Math.abs(userId.hashCode() % 100);
        return hash < percentage;
    }
}
// 컨트롤러에서 사용자 컨텍스트 전달
@GetMapping("/checkout")
public ResponseEntity<CheckoutResponse> checkout(
        @AuthenticationPrincipal UserDetails user) {

    FlippingExecutionContext ctx = new FlippingExecutionContext();
    ctx.putString("userId", user.getUsername());

    if (ff4j.check("new-checkout-flow", ctx)) {
        return ResponseEntity.ok(newCheckoutService.process());
    }
    return ResponseEntity.ok(legacyCheckoutService.process());
}

Unleash: 오픈소스 Feature Management

팀 규모가 크면 Unleash 같은 전용 플랫폼이 더 적합합니다. 웹 대시보드, SDK, 메트릭을 제공합니다.

// build.gradle.kts
dependencies {
    implementation("io.getunleash:unleash-client-java:9.2.0")
}
@Configuration
public class UnleashConfig {

    @Bean
    public Unleash unleash() {
        return new DefaultUnleash(
            UnleashConfig.builder()
                .appName("order-service")
                .instanceId("order-service-1")
                .unleashAPI("http://unleash:4242/api")
                .apiKey("default:development.xxxx")
                .fetchTogglesInterval(10)  // 10초마다 갱신
                .sendMetricsInterval(60)
                .build()
        );
    }
}

@Service
@RequiredArgsConstructor
public class OrderService {

    private final Unleash unleash;

    public OrderResponse createOrder(OrderRequest request, String userId) {
        UnleashContext context = UnleashContext.builder()
            .userId(userId)
            .addProperty("region", request.getRegion())
            .addProperty("plan", request.getPlan())
            .build();

        if (unleash.isEnabled("express-delivery", context)) {
            return processWithExpressDelivery(request);
        }
        return processStandard(request);
    }
}

Spring Profiles과의 차이

Spring Profiles도 환경별 설정을 분리하지만, Feature Flag와는 목적이 다릅니다.

구분 Spring Profiles Feature Flag
변경 시점 재시작 필요 런타임 즉시
대상 환경 (dev/prod) 기능/사용자
세분화 전체 애플리케이션 사용자/비율/조건별
사용 사례 DB URL, 로그 레벨 기능 롤아웃, A/B 테스트

킬 스위치 패턴

장애 발생 시 특정 기능을 즉시 비활성화하는 킬 스위치를 구현합니다.

@Service
@RequiredArgsConstructor
public class ExternalApiService {

    private final FF4j ff4j;
    private final ExternalClient externalClient;
    private final FallbackService fallbackService;

    public ApiResponse callExternalApi(String request) {
        // 킬 스위치 확인
        if (!ff4j.check("external-api-enabled")) {
            log.warn("외부 API 킬 스위치 활성화 — 폴백 사용");
            return fallbackService.getCachedResponse(request);
        }

        try {
            return externalClient.call(request);
        } catch (Exception e) {
            log.error("외부 API 호출 실패", e);
            // 자동 킬 스위치: 연속 실패 시 자동 비활성화
            errorCounter.incrementAndGet();
            if (errorCounter.get() > 10) {
                ff4j.disable("external-api-enabled");
                log.error("연속 실패 — 킬 스위치 자동 활성화");
            }
            return fallbackService.getCachedResponse(request);
        }
    }
}

REST API로 플래그 관리

운영 중 플래그를 관리할 수 있는 API를 제공합니다.

@RestController
@RequestMapping("/api/features")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class FeatureFlagController {

    private final FF4j ff4j;

    @GetMapping
    public Map<String, Boolean> getAllFeatures() {
        return ff4j.getFeatures().entrySet().stream()
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                e -> e.getValue().isEnable()
            ));
    }

    @PostMapping("/{featureId}/enable")
    public ResponseEntity<Void> enable(@PathVariable String featureId) {
        ff4j.enable(featureId);
        log.info("Feature {} 활성화", featureId);
        return ResponseEntity.ok().build();
    }

    @PostMapping("/{featureId}/disable")
    public ResponseEntity<Void> disable(@PathVariable String featureId) {
        ff4j.disable(featureId);
        log.info("Feature {} 비활성화", featureId);
        return ResponseEntity.ok().build();
    }
}

테스트에서의 Feature Flag

테스트 시에는 Feature Flag 상태를 명시적으로 제어합니다.

@SpringBootTest
class PaymentServiceTest {

    @Autowired private FF4j ff4j;
    @Autowired private PaymentService paymentService;

    @BeforeEach
    void setUp() {
        // 테스트 전 모든 플래그 초기화
        ff4j.getFeatures().keySet().forEach(ff4j::disable);
    }

    @Test
    void newPaymentSystem_활성화시_새게이트웨이_사용() {
        ff4j.enable("new-payment-system");

        PaymentResult result = paymentService.processPayment(request);

        assertThat(result.getGateway()).isEqualTo("NEW_GATEWAY");
    }

    @Test
    void newPaymentSystem_비활성화시_레거시_사용() {
        ff4j.disable("new-payment-system");

        PaymentResult result = paymentService.processPayment(request);

        assertThat(result.getGateway()).isEqualTo("LEGACY_GATEWAY");
    }
}

실전 팁

  • 기술 부채 관리: Release Flag는 릴리스 후 반드시 제거합니다. 오래된 플래그는 코드 복잡도를 높이는 기술 부채입니다. 만료일을 메타데이터에 기록하세요
  • 플래그 네이밍: enable-new-checkout보다 checkout-v2처럼 기능 중심으로 명명합니다
  • DB 저장소: 프로덕션에서는 인메모리 대신 DB나 Redis 저장소를 사용하여 여러 인스턴스 간 동기화합니다
  • ArchUnit과 결합: Feature Flag 관련 코드가 특정 패턴을 따르도록 아키텍처 테스트를 추가합니다
  • 모니터링: 플래그 변경 이벤트를 구조화 로깅으로 기록하여 장애 추적에 활용합니다

마무리

Feature Flag는 배포와 릴리스를 분리하는 현대 소프트웨어 개발의 핵심 프랙티스입니다. FF4j로 시작하여 팀이 성장하면 Unleash나 LaunchDarkly로 전환할 수 있습니다. 중요한 것은 플래그의 수명을 관리하고, 더 이상 필요 없는 플래그를 적극적으로 제거하는 것입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux