이 글은 누구를 위한 것인가
- 새 기능을 앱 업데이트 없이 켜고 끄고 싶은 팀
- A/B 테스트를 모바일 앱에서 구현하고 싶은 개발자
- 프로덕션 버그 발생 시 즉시 기능을 비활성화해야 하는 팀
들어가며
앱 업데이트를 기다리지 않고 새 기능을 켜거나 버그를 막을 수 있다면? Feature Flag는 코드 배포와 기능 출시를 분리한다. 앱스토어 심사 없이 실시간으로 제어 가능하다.
이 글은 bluebutton.kr의 Feature Flag 운영 전략 을 참고하여 작성했습니다.
1. Feature Flag 설계
[Feature Flag 도구 비교]
Firebase Remote Config:
비용: 무료
강점: Firebase 생태계, 쉬운 설정
약점: 실시간성 제한 (캐시 TTL), 복잡한 타겟팅
적합: 소규모, 단순 ON/OFF
LaunchDarkly:
비용: 유료 (월 $75~)
강점: 실시간, 복잡한 타겟팅, 감사 로그
약점: 비용
적합: 엔터프라이즈, 복잡한 A/B 테스트
자체 구현:
비용: 개발 비용
강점: 완전한 제어, 데이터 프라이버시
약점: 개발/운영 부담
적합: 특수 요구사항, 데이터 민감
[Flag 유형]
Release Flag: 기능 출시 제어 (임시)
Experiment Flag: A/B 테스트 (임시)
Ops Flag: 운영 제어 (긴급 시 사용, 영구)
Permission Flag: 사용자 권한 (영구)
[단계적 출시 전략]
1% → 5% → 20% → 50% → 100%
각 단계에서 메트릭 모니터링
문제 감지 시 즉시 롤백
2. Firebase Remote Config 구현
// iOS Swift
import FirebaseRemoteConfig
class FeatureFlagManager {
static let shared = FeatureFlagManager()
private let remoteConfig = RemoteConfig.remoteConfig()
// 플래그 키 타입 안전 정의
enum Flag: String {
case newCheckoutUI = "new_checkout_ui"
case aiRecommendations = "ai_recommendations"
case darkMode = "dark_mode_enabled"
case maxCartItems = "max_cart_items"
case promotionBannerText = "promotion_banner_text"
}
func configure() async {
// 기본값 설정
let defaults: [String: NSObject] = [
Flag.newCheckoutUI.rawValue: false as NSObject,
Flag.aiRecommendations.rawValue: false as NSObject,
Flag.darkMode.rawValue: true as NSObject,
Flag.maxCartItems.rawValue: 20 as NSObject,
Flag.promotionBannerText.rawValue: "" as NSObject,
]
remoteConfig.setDefaults(defaults)
// 캐시 설정
let settings = RemoteConfigSettings()
settings.minimumFetchInterval = 300 // 5분 (개발 중: 0)
remoteConfig.configSettings = settings
// 초기 패치
await fetchAndActivate()
}
func fetchAndActivate() async {
do {
let status = try await remoteConfig.fetchAndActivate()
print("Remote Config 상태: \(status)")
} catch {
print("Remote Config 패치 실패: \(error)")
}
}
// 타입별 접근자
func isEnabled(_ flag: Flag) -> Bool {
remoteConfig[flag.rawValue].boolValue
}
func intValue(_ flag: Flag) -> Int {
Int(remoteConfig[flag.rawValue].numberValue)
}
func stringValue(_ flag: Flag) -> String {
remoteConfig[flag.rawValue].stringValue ?? ""
}
// A/B 테스트: 사용자를 그룹에 할당
func abTestVariant(for flag: Flag, userId: String) -> String {
let hash = abs(userId.hashValue) % 100
let value = remoteConfig[flag.rawValue].stringValue ?? "control"
return value // Remote Config에서 사용자 그룹별 값 설정
}
}
// 뷰에서 사용
struct CheckoutView: View {
private let flags = FeatureFlagManager.shared
var body: some View {
if flags.isEnabled(.newCheckoutUI) {
NewCheckoutView()
} else {
LegacyCheckoutView()
}
}
}
// 자체 Feature Flag 구현 (간단 버전)
class LocalFeatureFlags {
private var flags: [String: Any] = [:]
func loadFromServer() async throws {
let (data, _) = try await URLSession.shared.data(
from: URL(string: "https://api.myapp.com/feature-flags")!
)
let response = try JSONDecoder().decode([String: AnyCodable].self, from: data)
flags = response.mapValues { $0.value }
// 캐시 저장
if let data = try? JSONSerialization.data(withJSONObject: flags) {
UserDefaults.standard.set(data, forKey: "cached_flags")
}
}
func isEnabled(_ key: String, default defaultValue: Bool = false) -> Bool {
flags[key] as? Bool ?? defaultValue
}
}
struct AnyCodable: Codable {
let value: Any
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let bool = try? container.decode(Bool.self) { value = bool }
else if let int = try? container.decode(Int.self) { value = int }
else if let str = try? container.decode(String.self) { value = str }
else { value = "" }
}
func encode(to encoder: Encoder) throws {}
}
struct NewCheckoutView: View { var body: some View { Text("새 결제 UI") } }
struct LegacyCheckoutView: View { var body: some View { Text("기존 결제 UI") } }
// Android Kotlin
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings
import kotlinx.coroutines.tasks.await
class FeatureFlagManager private constructor() {
private val remoteConfig = FirebaseRemoteConfig.getInstance()
companion object {
val shared = FeatureFlagManager()
object Flags {
const val NEW_CHECKOUT_UI = "new_checkout_ui"
const val AI_RECOMMENDATIONS = "ai_recommendations"
const val MAX_CART_ITEMS = "max_cart_items"
}
}
suspend fun configure() {
val settings = FirebaseRemoteConfigSettings.Builder()
.setMinimumFetchIntervalInSeconds(300)
.build()
remoteConfig.setConfigSettingsAsync(settings).await()
val defaults = mapOf(
Flags.NEW_CHECKOUT_UI to false,
Flags.AI_RECOMMENDATIONS to false,
Flags.MAX_CART_ITEMS to 20,
)
remoteConfig.setDefaultsAsync(defaults).await()
fetchAndActivate()
}
suspend fun fetchAndActivate() {
try {
remoteConfig.fetchAndActivate().await()
} catch (e: Exception) {
// 캐시된 값 사용
}
}
fun isEnabled(flag: String) = remoteConfig.getBoolean(flag)
fun getInt(flag: String) = remoteConfig.getLong(flag).toInt()
fun getString(flag: String) = remoteConfig.getString(flag)
}
마무리
Feature Flag의 핵심 가치는 "배포와 출시를 분리"하는 것이다. 코드를 미리 배포해두고 플래그로 활성화하면 앱스토어 심사를 기다리지 않아도 된다. Firebase Remote Config로 시작해서 타겟팅이 복잡해지면 LaunchDarkly로 마이그레이션하라. 오래된 플래그는 주기적으로 코드에서 제거해야 기술 부채가 쌓이지 않는다.