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로 전환할 수 있습니다. 중요한 것은 플래그의 수명을 관리하고, 더 이상 필요 없는 플래그를 적극적으로 제거하는 것입니다.