LY Corporation Tech Blog

LY Corporation과 LY Corporation Group(LINE Plus, LINE Taiwan and LINE Vietnam)의 기술과 개발 문화를 알립니다.

AttributedString 구조로 풀어낸 대규모 iOS 설정 시스템

환경이 바뀌면서 과거에 내렸던 합리적인 결정이 더 이상 유효하지 않게 되는 일은 흔히 생깁니다. 이 글은 LINE 앱이 성장하면서 동적 설정 배포 시스템인 ‘서비스 설정’의 iOS 클라이언트가 어떤 도전을 받았고, 그 도전을 어떻게 헤쳐나갔는지 소개합니다.

서비스 설정이란?

LINE 앱은 2주마다 새 버전이 배포됩니다. LINE 앱 안에는 여러 팀이 만들어 내는 수많은 서비스가 공존하기 때문에 개별 서비스의 요구 사항에 따라 배포 일정을 바꾸기는 곤란합니다. 이 제약 사항 때문에 ‘서비스 설정’이 필요합니다.

예를 들어 어떤 서비스가 특정 국가의 연휴 기간에 맞춰 신기능을 출시하기를 원한다고 합시다. 이런 경우 해당 서비스는 그 기능을 동적으로 켰다 껐다 할 수 있는 상태로 앱을 배포하고, 기본적으로는 꺼 놓습니다. 이후 원하는 시점에 기능을 켜면 앱 업데이트 없이 신기능을 출시할 수 있습니다.

이와 같은 동적 서비스 배포를 가능하게 하는 시스템이 ‘서비스 설정’ 시스템입니다. 서비스 운영자가 관리자 페이지에서 설정값을 수정하면 서비스 설정 서버는 LINE 앱에 알림을 발송합니다. LINE 앱은 서버에 새 설정값을 요청하고, 서버는 사용자의 지역, 기기, OS 버전 등을 기반으로 적용할 값을 결정해 내려줍니다.

서비스 설정은 기능 토글 외에도 롤백 계획, A/B 테스트, 오류 수집의 샘플링 비율 결정, 특정 UI의 동작 정책 등 다양한 용도로 활용됩니다. 이렇게 여러 설정이 동시에 운용되기 때문에 개별 서비스는 동적 제어가 필요한 기능마다 문자열 ‘키’를 정의하고 키마다 ‘값’을 설정합니다. 서비스 설정 서버는 이 값들을 문자열에서 문자열로 매핑하는 사전 형태로 내려줍니다.

{
    "function.media.image_medium": "1280,70",
    "function.media.image_high": "2048,80",
    "function.media.message.flow.v2.image": "Y",
    ...
}

현재 LINE iOS 앱에서는 700개 가량의 설정 키를 60여 개 모듈에서 사용하고 있습니다.

점점 커진 규모가 만든 문제들

기존 시스템은 일체형(monolithic)으로 개발됐습니다. 일체형 구조라는 제약 때문에 한 파일에 모든 키를 선언해야 했기에 키를 나열한 파일은 7천 줄에 달했고, 대부분의 키가 특정 모듈에서만 사용되는데도 항상 전체 공개로 선언되어야만 했습니다.

프로젝트 초기에는 한 파일에서 모든 키를 관리하는 것이 간단하고 합리적인 접근 방식이었을 것입니다. 하지만 프로젝트와 팀의 규모가 커지면서 이 구조로 인한 문제가 점점 심화되고 있었습니다.

순환 의존 딜레마

일체형 구조의 가장 근본적인 문제는 모듈 간 의존 방향 때문에 설정값을 의미 있는 타입으로 다룰 수 없다는 것이었습니다.

예를 들어보겠습니다. LINE 앱에서는 대화방에 전송할 사진의 품질을 표준 화질과 고화질 중 선택할 수 있습니다. 이때 각 옵션이 실제로 어떤 해상도와 JPEG 압축률에 해당하는지가 서비스 설정으로 결정됩니다. 예를 들어 "1280,70"이라는 값은 이미지를 "최대 1280x1280픽셀, 압축률 0.7"로 전송하라는 것을 의미하는데요. 이런 값은 문자열 그대로 유통하기보다는 ImageTransferQuality 같은 전용 타입으로 다루는 것이 바람직합니다.

여기서 문제는 ‘이 전용 타입을 어디에 선언하느냐’입니다. 관심사 분리 원칙에 따르면 사진 관련 모듈에 선언하는 것이 맞지만, 그렇게 하면 서비스 설정 시스템에서 전용 타입을 직접 반환할 수 없습니다. 사진 모듈은 이미 설정 모듈에 의존하고 있으므로 역방향 의존을 일으키기 때문입니다. 이에 따라 아래에서 볼 수 있듯 설정 모듈에서는 파싱 전의 날 것의 문자열을 노출하고, 사진 모듈에서 매번 파싱을 했습니다.

// LineConfigurationSubsystem 모듈 — 문자열만 반환 가능
public final class LineConfigurationManager {
    public var transferQualityImageMedium: String? { /* ... */ }
}

// LinePhotos 모듈 — 호출부에서 직접 파싱
let string = configurationManager.shared.transferQualityImageMedium
let mediumQuality = ImageTransferQuality.parse(string)

타입을 설정 모듈에 선언하면 파싱 단계는 생략할 수 있지만 사진 화질과 무관한 다른 모듈도 이 타입을 알게 됩니다. 실제로 이런 타입에는 관습적으로 LineConfiguration- 접두사를 붙여 이름 공간이 오염되는 것을 방지해야 했습니다. 어떤 선택이든 단점이 있는 구조적 딜레마였습니다.

불완전한 추상화

기존 구현의 또 다른 특징은 사용하다 보면 구현 세부 내용을 자꾸 배워야 했다는 것입니다.

먼저 서버에서 받은 값을 디코딩하는 로직을 직접 작성해야 했습니다. 기존 구현에서는 함께 사용되는 키를 프로퍼티 그룹이라는 단위로 묶어 구조체로 선언하고, 한 번에 디코딩하였습니다. 아래는 Album 이라는 프로퍼티 그룹에서 albumLikeEnabled라는 불리언 설정값을 디코딩하는 구현입니다.

extension LineConfigurationManagerProperties.Album: Decodable {
    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        albumLikeEnabled = try container
            .decodeBoolIfPresent(forKey: .albumLikeEnabled) ?? false
    }
}

Swift를 오래 사용해 오신 분이라면 눈치채셨을 수도 있는데 디코딩에 사용한 decodeBoolIfPresent(forKey:)는 표준 라이브러리가 정의하는 메서드가 아닙니다. 이 메서드가 왜 필요한지 이해하려면 서비스 설정 서버와의 통신 규약을 살펴봐야 합니다.

서비스 설정 서버는 불리언을 "Y"/"N"의 문자열로 내려보냅니다. iOS 클라이언트는 이것을 프로퍼티 리스트로 인코딩하여 위의 init(from:) 초기화자를 호출하는데요. 표준 decodeIfPresent(_:forKey:) 메서드의 입장에서는 "Y", "N" 같은 문자열을 불리언 값으로 여길 이유가 없으므로 타입 불일치로 판단해 에러를 던지고, 이 호출은 실패합니다.

decodeBoolIfPresent(forKey:) 메서드는 바로 이런 문제를 우회하기 위해 만들어졌는데 표준 메서드와 이름이 비슷해서 혼동하기 쉬웠습니다. 게다가 표준 메서드가 사용돼 초기화에 실패해도 조용히 기본 설정값이 사용됐기 때문에 원인 파악도 어려웠습니다. 이 때문에 표준 메서드를 사용한 잘못된 구현이 코드 리뷰를 통과하고 뒤늦게 발견된 적도 있었습니다.

또 다른 문제는 용도가 불분명한 기본값을 아래와 같이 세 번이나 작성해야 했다는 점입니다.

// 1번째: PropertyGroup의 static default (다른 키의 디코딩 실패 시 폴백)
extension LineConfigurationManagerProperties {
    struct Album: PropertyGroup {
        let albumLikeEnabled: Bool
        static var `default`: Album {
            Album(albumLikeEnabled: false) // ← 1번째 기본값

// 2번째: init(from:)의 폴백 (키 부재 또는 값 이상 시)
extension LineConfigurationManagerProperties.Album: Decodable {
    init(from decoder: any Decoder) throws {
        albumLikeEnabled = try container
            .decodeBoolIfPresent(forKey: .albumLikeEnabled) ?? false // ← 2번째 기본값

// 3번째: defaultConfiguration 딕셔너리 (서버값 미도착 시)
extension LineConfigurationManager {
    static var defaultConfiguration: [String: Any] {
        ["function.album.like.enabled": false] // ← 3번째 기본값

세 기본값의 용도가 미묘하게 다르지만 실제로 구분할 필요가 있는 경우는 없었습니다. 특히 첫 번째 기본값은 같은 프로퍼티 그룹으로 묶인 다른 설정 키의 디코딩이 실패했을 때 사용되는데, 한 키의 디코딩 실패가 다른 키의 값에 영향을 미쳐야 할 이유가 없으니 설계 결함에서 기인한 불필요한 요구 사항이었습니다.

이런 설계 결함들은 이미 서버와의 통신 규약이나 구현 세부 사항을 알고 있는 개발자에게는 당연한 것일 수 있지만, 새로 합류하는 개발자들에게는 암기해야 할 임의의 규칙이 되어 온보딩에서 가르쳐야 할 지식을 하나 더 늘렸습니다.

스레드 안전성 부재

기존 구현은 동시성을 전혀 고려하지 않고 작성되어 있었습니다. 하지만 동시성 문제가 발생할 구조적 요인은 충분했습니다. 프로퍼티 그룹별 디코딩이 지연 평가되고, 서버에서 새 값을 받아올 때마다 기존에 디코딩된 인스턴스가 해제되는 등 변형이 잦았고, 여러 서비스가 서로 다른 스레드에서 설정값을 읽어가는 상황이었기 때문입니다.

문제는 지표로도 드러났습니다. 메모리 해제 후 사용(use-after-free) 때문에 매일 수백 건의 크래시가 발생했고, 이는 꾸준히 버그 티켓을 만들어 내며 핫픽스 릴리스의 원인이 됐습니다. 미정의 동작인 특성상 실제로는 더 많은 기기에 다양한 형태로 영향을 미쳤을 것입니다.

이런 문제도 프로젝트 규모가 작고 Swift Concurrency를 본격적으로 도입하기 전의 코드 베이스에서는 매우 드물게 발생하는, 근본적으로 고치기에는 수지타산이 안 맞는 버그였을 것입니다. 하지만 서비스 수가 늘어나고 동시에 수행되는 작업도 늘어나면서 점점 더 자주 발생하는 해결해야 하는 문제로 부상했습니다.

디버그 오버라이드의 부재

QA 과정에서는 특정 설정값을 임시로 바꿔 테스트하고 싶은 경우가 빈번히 발생합니다. 그러나 서비스 설정 시스템 자체에는 오버라이드 기능이 없었습니다. 따라서 QA 담당자에게 설정값을 오버라이드하는 기능을 제공하려면 별도의 영속 저장 공간을 마련하고, 디버그 메뉴 UI를 선언하고, 현재 값이 무엇인지 알려주는 문구를 작성해야 했습니다. 서로 다른 모듈의 예닐곱 개 파일에서 적게는 서너 줄, 많게는 스무 줄 정도를 매번 수정해야 했습니다.

테스트 대역의 각자도생

설정값들을 제공하는 LineConfigurationManager가 싱글턴이었기 때문에 각 모듈은 자신이 사용할 속성만 추려낸 프로토콜을 정의해 구현이 싱글턴에 직접 의존하는 대신 프로토콜에 의존하도록 만들고, 테스트 대역을 직접 작성하는 방식으로 테스트 가능성을 확보했습니다.

이 방식은 개별 서비스 구현자 입장에서는 합리적인 타협이었겠지만, 프로젝트 전체로 보면 큰 낭비였습니다. 프로젝트 전체에 수십 개의 프로토콜과 테스트 대역이 존재했고, 이런 구현들은 설정 키의 수명 주기에 따라 계속 함께 수정돼야 했습니다. 테스트 대역은 별도 컴파일 타깃이기 때문에, 주의하지 않으면 PR을 보낸 후 CI 빌드에서야 업데이트를 놓쳤다는 것을 알게 되기도 했습니다. 또한 가내수공업으로 제작된 대역들은 품질도 들쭉날쭉해서 새로운 설정값이 있다는 알림을 발신하는 타이밍이 실제 구현과 달라 프로덕션에는 버그가 있지만 테스트에서는 검출되지 않거나, 그 반대의 상황이 발생할 여지도 있었습니다.

개별 팀의 입장에서는 사소한 문제일 수도 있는 이런 불편함은 야금야금 프로젝트 전체의 생산성을 갉아먹고 있었습니다.

해법의 출발점: 검증된 설계에서 배우기

저희는 문제를 정리한 뒤 유사한 문제를 이미 해결한 설계가 있는지 찾았습니다. 해결해야 할 문제의 본질은 다음과 같았습니다.

  • 대량의 키-값 쌍을 타입 안전하게 접근해야 한다.
  • 각 모듈이 독립적으로 자기 키를 정의할 수 있어야 한다.
  • 동시성 환경에서 안전하게 작동해야 한다.

이 조건을 만족하는 선행 설계로 Foundation의 AttributedString을 찾았습니다. AttributedString은 영역마다 속성을 부여할 수 있는 텍스트를 표현한 타입으로, 수많은 텍스트 속성(폰트, 색상, 링크 등)을 타입 안전하게 관리하면서, UIKit, AppKit, SwiftUI 등 여러 프레임워크가 독립적으로 속성을 정의할 수 있도록 설계되어 있습니다. AttributedString의 설계는 Swift Foundation 프로젝트에 오픈소스로 공개돼 있어 내부 구현까지 확인할 수 있었습니다.

타입 구조

이번 프로젝트에서는 AttributedString API의 네 가지 타입에 주목했습니다.

AttributedStringKey

속성 하나를 정의하는 프로토콜입니다. 키의 이름과 값 타입을 선언합니다.

public protocol AttributedStringKey {
    associatedtype Value: Hashable
    static var name: String { get }
}

AttributeScope

관련 키들을 그룹으로 묶는 프로토콜입니다. UIKit 속성, SwiftUI 속성 등 프레임워크별로 스코프를 정의합니다.

extension AttributeScopes {
    struct UIKitAttributes: AttributeScope {
        let font: UIKitAttributes.FontAttribute
        let foregroundColor: UIKitAttributes.ForegroundColorAttribute
    }
}

AttributeDynamicLookup

열거된 멤버가 없는(case가 없는) enum으로, 인스턴스화될 수 없습니다. 오직 다이나믹 멤버 룩업(dynamic member lookup)을 통한 키 패스(key path) 라우팅 대상으로만 기능합니다.

@dynamicMemberLookup
public enum AttributeDynamicLookup {
    public subscript<T: AttributedStringKey>(
        dynamicMember keyPath: KeyPath<AttributeScopes, T>
    ) -> T {
        fatalError("Unreachable")
    }
}

fatalError를 가진 subscript가 ‘실행 시점에는 절대 호출되지 않는다’는 것이 이 설계의 핵심입니다. 컴파일러가 키 패스가 가리키는 T 타입 정보만 추출하면 AttributeDynamicLookup의 역할은 끝이 납니다. 런타임 비용 없이 타입 수준의 라우팅을 구현하는 기법입니다. 이 라우팅에 관해서는 다음 섹션에서 더 자세히 살펴보겠습니다.

AttributeContainer

실제 값을 저장하는 값 타입입니다. 위 세 가지 타입이 제공하는 타입 정보를 이용해 안전한 읽기/쓰기를 제공합니다.

@dynamicMemberLookup
public struct AttributeContainer {
    public subscript<K: AttributedStringKey>(
        dynamicMember keyPath: KeyPath<AttributeDynamicLookup, K>
    ) -> K.Value? { get set }
}

타입 해석 흐름

이 네 가지 타입이 어떻게 협력하는지 이해하기 위해 다음 코드 중 container.font라는 코드 한 줄이 해석되는 과정을 따라가 보겠습니다.

var container = AttributeContainer()
container.font = .systemFont(ofSize: 14)
//        ^^^^
// 이 프로퍼티 접근이 실제로 어떻게 해석되는가?

AttributeContainer에는 font라는 프로퍼티가 없습니다. 대신 @dynamicMemberLookup이 적용되어 있으므로, 컴파일러는 subscript(dynamicMember:)를 찾습니다. 이 subscriptKeyPath<AttributeDynamicLookup, K> 인자를 받습니다.

따라서 컴파일러는 AttributeDynamicLookup에서 font를 찾습니다. AttributeDynamicLookup 역시 font 프로퍼티는 없고 @dynamicMemberLookup 타입이므로 다시 그 subscript를 확인합니다. UIKit은 AttributeDynamicLookupKeyPath<AttributeScopes.UIKitAttributes, K>를 받는 확장 서브스크립트를 선언하고 있으므로 다시 한 번 UIKitAttributes에서 font를 탐색합니다. UIKit의 스코프에는 font 프로퍼티가 있으므로 여기서 해석이 완료됩니다.

결과적으로 컴파일러는 KUIKitAttributes.FontAttribute임을 추론합니다. AttributeContainer의 서브스크립트는 이 타입에서 필요한 정보를 가져와 값을 올바른 키 이름 아래에 저장합니다. 이 전체 과정이 컴파일 타임에 일어나며, 런타임에는 단순한 딕셔너리 읽기/쓰기만 수행됩니다.

container.font
│
▼ @dynamicMemberLookup
AttributeContainer.subscript(dynamicMember: KeyPath<AttributeDynamicLookup, K>)
│
▼ @dynamicMemberLookup
AttributeDynamicLookup.subscript(dynamicMember: KeyPath<AttributeScopes.UIKitAttributes, K>)
│
▼ 프로퍼티 탐색
AttributeScopes.UIKitAttributes.font → UIKitAttributes.FontAttribute
│
▼ 타입 추론 완료
K = UIKitAttributes.FontAttribute, K.Value = UIFont

새로운 속성을 추가할 때는 AttributedStringKey를 구현하고 스코프에 프로퍼티를 추가하는 것만으로 이 체인에 자연스럽게 합류할 수 있습니다. 기존 코드를 수정할 필요 없이 확장이 가능한 구조입니다.

재적용과 변형

조사 결과 AttributedString API 설계에는 새 서비스 설정에 그대로 적용할 수 있는 좋은 부분이 많이 있었습니다. 다만 저희 요구 사항에 맞지 않는 부분도 있었으므로 타입 구조는 거의 그대로 가져오되 그 외 저희와 맞지 않는 부분들은 필요에 맞게 변형했습니다.

먼저 case 없는 enum으로 KeyPath 라우팅을 구현하는 핵심 기법과 스코프를 통한 키 그룹화, @dynamicMemberLookup을 이용한 프로퍼티 접근 문법까지는 동일한 구조를 채택했습니다.

AttributedStringConfiguration
AttributedStringKeyConfigurationKey
AttributeScopeConfigurationScope
AttributeScopesConfigurationScopes
AttributeDynamicLookupConfigurationDynamicLookup
AttributeContainerConfigurationValues
ScopedAttributeContainerScopedConfigurationValues

이하에서는 도메인 차이 때문에 변형한 부분들을 다룹니다. 각 변형이 앞서 제기한 문제들을 어떻게 해소하는지 함께 살펴보겠습니다.

순환 의존 해소: 모듈별 선언을 지원하다

키를 개별 모듈에 정의할 수 있다면 전용값 타입도 같은 모듈에 둘 수 있습니다. 서비스 설정 시스템에는 키 프로토콜만 존재하고 개별 키를 선언하지 않기 때문에 역방향 의존이 발생하지 않습니다.

// 1. 스코프에 키 정의 — 사진 모듈 안에서 선언
extension ConfigurationScopes {
    struct LinePhotos: ConfigurationScope {
        let transferQualityImageMedium: TransferQualityImageMediumKey
        enum TransferQualityImageMediumKey: ConfigurationKey {
            static let name = "function.media.image_medium"
            static let defaultValue: ImageTransferQuality = .standard
        }
    }
}
// 2. DynamicLookup에 subscript 추가 (스코프 단위로 한 번만)
extension ConfigurationDynamicLookup {
    subscript<K: ConfigurationKey>(
        dynamicMember keyPath: KeyPath<ConfigurationScopes.LinePhotos, K>
    ) -> K {
        self[K.self]
    }
}

service.transferQualityImageMedium에 접근하면, AttributedString에서 분석한 것과 동일한 타입 해석 체인이 작동합니다.

service.transferQualityImageMedium
│
▼ ConfigurationService.subscript(dynamicMember: KeyPath<ConfigurationDynamicLookup, K>)
▼ ConfigurationDynamicLookup.subscript(dynamicMember: KeyPath<ConfigurationScopes.LinePhotos, K>)
▼ LinePhotos.transferQualityImageMedium → TransferQualityImageMediumKey
│
▼ K = TransferQualityImageMediumKey, K.Value = ImageTransferQuality
"function.media.image_medium" 값 조회 → decode → ImageTransferQuality

더 이상 설정 모듈에 ImageTransferQuality 같은 값 타입을 선언할 필요가 없습니다. 사진 모듈의 스코프 안에 키와 값 타입을 함께 정의하면 됩니다. 순환 의존 딜레마가 해소됐습니다.

추상화 문제 해결: 키에 디코딩을 캡슐화하다

AttributedStringKey에는 defaultValue, decode, encode가 없습니다. AttributeContainer[ObjectIdentifier: Any]로 값을 저장하므로 별도 디코딩 없이 Value 타입으로 다운캐스팅하면 되고, 속성이 없으면 nil을 반환하면 되기 때문입니다.

하지만 서비스 설정 시스템은 다릅니다. 서버에서 값이 문자열로 오고, 키마다 직렬화 방식이 다르며, 서버값이 없는 경우에도 nil 보다는 기본값을 돌려주는 게 편리합니다. 이에 따라 다음과 같이 decode(from:)encode(_:)를 키 프로토콜에 포함시켜, 기존에 프로퍼티 그룹 초기화자에 작성했던 직렬화 구현을 ‘개별 키 단위’로 분리했습니다. defaultValue는 서버값이 없을 때의 폴백(fallback)으로, 기존의 삼중 기본값을 하나로 통합합니다.

public protocol ConfigurationKey: SendableMetatype {
    associatedtype Value: Sendable
    static var name: String { get }
    static var defaultValue: Value { get }
    static func decode(from string: String) throws -> Value
    static func encode(_ value: Value) throws -> String?
}

동일한 디코딩 로직을 반복해서 구현해야 했던 문제는 decode(from:)encode(_:) 메서드 기본 구현을 제공해서 해결했는데요. 저희는 기본 구현을 제공하기 위해서 값 타입을 다음과 같이 두 개의 프로토콜로 분류했습니다.

// "Y"/"N" boolean, 숫자 등 서비스 설정만의 인코딩 규약이 적용되는 값들
public protocol ConfigurationValueStringRepresentable {
    init?(configurationValueString string: String)
    var configurationValueString: String { get }
}
// JSON으로 직렬화되는 복합값
public protocol ConfigurationValueJSONRepresentable {
    init(fromConfigurationValueJSON decoder: any Decoder) throws
    func encode(toConfigurationValueJSON encoder: any Encoder) throws
}

BoolConfigurationValueStringRepresentable을 따르므로, 연관 타입 ValueBool인 키들은 "Y"/"N"을 올바르게 true, false로 디코딩하는 구현이 자동으로 제공됩니다. 덕분에 직접 디코딩 로직을 작성할 필요도, decodeBoolIfPresent(forKey:)와 표준 메서드 사이에서 혼란스러워할 필요도 없이, 아래와 같이 키를 선언하면 올바른 디코딩 로직이 사용됩니다.

enum AlbumLikeEnabledKey: ConfigurationKey {
    static let name = "function.album.like.enabled"
    static let defaultValue = false
}

스레드 안전성 확보: 컴파일러에게 맡기다

AttributeContainer는 값 타입(value type)입니다. 반면 서비스 설정 시스템은 서버 동기화 시 값이 갱신되고, 대부분의 사용자는 항상 최신값을 읽고 싶어합니다. 따라서 설정 서비스는 참조 타입(reference type)인 ConfigurationService 프로토콜을 중심으로 설계했습니다. 이 부분이 AttributeContainer와 가장 근본적으로 다른 구조적 차이입니다.

참조 타입으로 모델링하다 보니 기존과 동일하게 동시성 문제가 발생할 수 있는 조건이었는데요. 이 문제는 Swift 6의 강력한 Sendable 체크를 통해 정적으로 해결했습니다. 구체적으로 말씀드리자면, 가변 상태를 모두 OSAllocatedUnfairLock 안에 넣고, 록(lock)의 withLock API를 통해서만 상태에 접근하여, 가변 상태에 대한 접근이 모두 동기화된다는 것을 컴파일러가 체크할 수 있도록 했습니다. 결과적으로 @unchecked 어트리뷰트 없이 ConfigurationService 클래스를 Sendable하게 만들어서 사용하는 입장에서도 컴파일러 경고/에러 없이 여러 동시성 영역에서 자유롭게 설정값을 읽어갈 수 있도록 구현했습니다.

록을 사용하다 보니 ‘디코딩을 임계 구역(critical section) 안에서 할 것인가’도 흥미로운 주제였습니다. 사용자 정의 decode(from:) 구현이 다른 록에 접근하면서 데드록이 발생하거나, 다른 설정값을 읽으려고 해 재귀적으로 록에 접근하며 크래시가 발생할 수 있었기 때문입니다. 이를 방지하기 위해 록 안에서는 문자열값만 꺼내고, 디코딩은 록 밖에서 수행하도록 구현했습니다.

QA 문제 해결: 오버라이드를 서비스 설정 시스템에 내장하다

AttributeContainer는 값이 있거나 없거나의 이진 모델이지만, 서비스 설정 시스템은 세 가지 값이 가능합니다. 디버그 빌드에서 개발자가 수동으로 오버라이드한 값의 우선순위가 가장 높고, 다음이 서버값, 마지막이 기본값입니다. 오버라이드값은 서버값과 별도로 저장하므로, 서버가 동기화돼도 오버라이드가 유지됩니다.

enum ValueSource: CaseIterable {
#if DEBUG_MENU_ENABLED
    case overridden // 디버그 메뉴에서 수동 설정한 값
#endif
    case server // 서버에서 받은 값
    case `default` // 키에 정의된 기본값
}

ConfigurationKey 프로토콜을 통해 디버그 UI를 구성할 수 있는 메타데이터도 정의할 수 있도록 구현했습니다.

protocol ConfigurationKey {
#if DEBUG_MENU_ENABLED
    static var debugTitle: String { get }
    static var debugControlType: ConfigurationDebugControlType<Value> { get }
#endif
}

이런 속성들에는 기본 구현이 제공됩니다. 설정 키를 사람이 읽기 쉬운 이름으로 노출하는 debugTitle은 키의 name으로부터 휴리스틱하게 생성했고, 설정값을 바꿀 때 사용할 인풋 컨트롤을 결정하는 debugControlTypeBool 값에 토글을 제공하는 식으로 Value 연관 타입에 따라 결정하도록 구현했습니다.

기존에 파일을 오가며 만들던 디버그 오버라이드 기능이 키 enum이라는 한 위치에 모였고, 충분히 쓸만한 기본 구현이 제공돼 디버그 메뉴에 신경 쓸 필요조차 없어졌습니다.

테스트 문제 해결: 고품질 대역을 제공하다

기존에는 각 모듈이 싱글턴을 꽂아 넣을 프로토콜과 테스트 대역을 직접 작성했습니다. 새 시스템은 ConfigurationService 프로토콜과 함께 MockConfigurationService를 제공합니다.

@Test
func `check calculation`() {
    let service = MockConfigurationService()
    let logic = MyBusinessLogic(configurationService: service)

    // writable subscript로 값 주입
    service.myValue = 7

    #expect(logic.calculate() == 42)
}

테스트 대역은 프로토콜에는 없는 설정자(setter)를 제공하므로 테스트에서 설정값을 자유롭게 설정할 수 있습니다. 또한 실제 구현과 동일한 값 해석 로직(ValueSource 우선순위, decode/encode)을 사용합니다. 덕분에 프로토콜을 직접 정의할 필요도, 대역의 동작이 프로덕션과 달라질 걱정도 없습니다.

마치며

단순히 Foundation의 접근 방식을 차용하는 것만으로 다른 도메인의 요구 사항을 온전히 소화할 수는 없었습니다. 최신값을 읽어갈 수 있도록 참조 타입을 사용해야 하니 동시성에 대한 고민을 새로 해야 했고, 직렬화나 디버그 메뉴 등 도메인 고유의 요구 사항을 프로토콜에 추가하는 변형도 필요했습니다.

새 서비스 설정 시스템은 점진적으로 여러 버전에 걸쳐 배포되고 있습니다. 아직은 수십 개 키 수준이지만 기존 서비스 설정 시스템을 대체해 현존하는 문제들을 해결하고, 더 큰 규모 혹은 전혀 다른 종류의 도전을 받아 대체되기 전까지 잘 사용되기를 바라고 있습니다. 비슷한 문제를 겪는 다른 팀이 있다면 이 글에서 소개한 접근 방식이 도움이 되기를 바랍니다.