NumberFormatter()를 개발하던 도중, static Formatter 공유 방식이 멀티스레드 환경에서 Race Condition을 유발한다는 테스트 결과를 확인하였습니다.
이번 개선에서는 해당 테스트를 동일하게 반복 실행하여,
SpinLock 동기화 적용 전후의 차이를 직접 검증하였습니다.
결과적으로, SpinLock을 적용한 이후에는 설정 충돌 없이 일관된 결과가 출력됨을 확인할 수 있었습니다.
공용 NumberForamtter() 인스턴스 접근
앞선 블로그에서 작성했듯이, 다음과 같은 문제점이 발생하였습니다.
public extension Decimal {
func formatted(fractionDigits: Int) -> String {
FormatterProvider.decimal(fractionDigits: fractionDigits)
.string(from: NSDecimalNumber(decimal: self)) ?? self.description
}
}
func testRaceCondition() {
let number = Decimal(string: "1234.56789")!
DispatchQueue.concurrentPerform(iterations: 20) { i in
let digits = (i % 2 == 0) ? 0 : 2
let result = number.formatted(fractionDigits: digits)
print("🧵[\(i)] digits: \(digits) → \(result)")
}
}
// 실제 출력 결과
🧵[0] digits: 0 → 1,234.57 ❗️
🧵[3] digits: 2 → 1,235 ❗️
🧵[5] digits: 2 → 1,235 ❗️
🧵[9] digits: 2 → 1,235 ❗️
🧵[13] digits: 2 → 1,234.57 ✅
🧵[16] digits: 0 → 1,235 ✅
이러한 결과는 요청한 자리수(fractionDigits)와 실제 결과가 일치하지 않음을 의미하며,
다른 스레드가 formatter 설정값을 덮어써서 발생한 결과입니다.
즉, 한 스레드가 .minimumFractionDigits = 2 설정을 하던 도중, 다른 스레드가 .minimumFractionDigits = 0 설정을 하게 되면 두 개 이상의 스레드가 동시에 동일한 공유 자원에 접근하고, 그 순서나 타이밍에 따라 잘못된 동작이 발생하는 레이스 컨디션이 발생했음을 의미합니다.
SpinLock 기법 도입
이전 테스트를 통해 공유 Formatter의 설정 충돌이 실제로 발생함을 확인하였습니다.
설정을 공유하는 구조에서는 멀티스레드 환경에서 Race Condition이 피할 수 없는 문제였고,
이로 인해 설정 꼬임과 잘못된 출력이 반복되었습니다.
이 문제를 해결하기 위해 락(Lock)을 통한 접근 제어가 필요하다고 판단하였습니다.
하지만 어떤 락을 사용하는 것이 가장 적절할지 고민이 있었습니다.
락의 성능, 안정성, 재진입 가능 여부 등 다양한 기준을 검토하며 적절한 선택지를 찾고자 하였습니다.
RxSwift의 SpinLock 구현 사례에서 얻은 인사이트
이 과정에서 평소 알고 있던 RxSwift의 DisposeBag 구현 방식이 떠올랐습니다.
RxSwift는 내부적으로 SpinLock이라는 이름의 동기화 기법을 사용하고 있었기 때문입니다.
직접 RxSwift 소스를 다시 살펴보니,
이름은 SpinLock이지만 NSRecursiveLock 기반의 RecursiveLock을 사용하고 있었습니다.
이 선택이 궁금해 RxSwift의 CHANGELOG와 공식 문서를 살펴보게 되었고,
락 선택이 단순히 "빠른 것"을 고르는 문제가 아니라
구조적 요구사항에 따라 달라질 수 있다는 사실을 다시 한번 깨닫게 되었습니다.
특히 RxSwift는 내부적으로
- Observer 체인,
- Disposable 처리,
- Scheduler 동작
등 같은 스레드에서 여러 번 락을 잡아야 하는 구조를 가지고 있었기 때문에,
재진입이 가능한 NSRecursiveLock을 선택한 것이었습니다.
이 과정을 통해
락의 종류에도 재진입 가능 여부, 성능, 디버깅 유연성 등
각각의 특징과 장단점이 다르다는 사실을 명확하게 인지하게 되었습니다.
따라서 여러 락들을 비교해보았습니다.
락의 특징 비교
이렇게 락의 필요성을 확인한 뒤, 각각의 락이 가지는 특징과 제한사항을 다음과 같이 비교해보았습니다.
항목 | NSLock | NSRecursiveLock | os_unfair_lock |
재진입 가능 여부 | ❌ 불가 | ✅ 가능 | ❌ 불가 |
성능 | ⚪ 보통 | ❌ 느림 | ✅ 빠름 |
디버깅 유연성 | ❌ 제한적 | ⚪ 리소스 추적 가능 | ⚪ 제한적 |
사용 용도 | 간단한 락 | 재진입이 필요한 복잡한 구조 | 고성능 단순 락 |
지원 플랫폼 | 모든 iOS 버전 | 모든 iOS 버전 | iOS 10.0+ |
NSLock
- 장점: 간단하고 모든 iOS 버전에서 사용 가능
- 단점: 오너십 검증이 없어 잘못된 unlock이 무시되고, 디버깅이 어렵고 성능이 다소 떨어짐
NSRecursiveLock
- 장점: 같은 스레드에서 여러 번 락을 잡을 수 있어 재진입 가능
- 단점: 성능이 가장 느리고, 복잡한 재귀적 락이 필요한 경우에만 적합
os_unfair_lock (SpinLock)
- 장점: 가장 가벼운 성능, 오너십 검증 지원, 재진입이 필요 없는 단순 구조에 최적
- 단점: 재진입 불가, iOS 10.0 이상에서만 사용 가능
최종 선택: os_unfair_lock 기반 SpinLock 기법 도입
NumberFormatter는
- 재진입이 필요 없고
- 매번 새 인스턴스를 만들기보다는 공유 인스턴스를 재사용하는 것이 유리하며
- 빠른 성능이 요구되는 포맷팅 작업이라는 점을 고려할 때
가장 적합한 선택은 os_unfair_lock 기반의 경량 락(SpinLock)이라고 판단하였습니다.
결국, NumberFormatter의 사용 특성상 재진입이 필요 없고,
빠른 접근이 필요한 포맷팅 작업에 적합하다고 판단하여
os_unfair_lock 기반 SpinLock 기법을 도입하게 되었습니다.
적용 및 결과
NumberterKit에선 다음과 같이 SpinLock을 정의하고 적용하였습니다.
import os.lock
/// `os_unfair_lock`을 기반으로 한 경량 락 구현체입니다.
///
/// 이 락은 매우 빠른 성능을 제공하지만, **재진입(reentrant)** 을 허용하지 않기 때문에
/// 동일한 스레드에서 중첩으로 `lock()`을 호출하면 데드락이 발생할 수 있습니다.
///
/// 주로 **락 유지 시간이 짧고, 재귀 호출이 발생하지 않는 임계 구역**에 적합합니다.
/// 예: 배열에 하나 추가, 설정값 변경 등 단순한 동기화 작업.
///
/// `os_unfair_lock`은 Apple이 `OSSpinLock`의 priority inversion 문제를 해결하기 위해 도입한 락입니다.
///
/// ⚠️ 주의: 이 락은 **같은 스레드에서 중복 `lock()` 호출이 필요한 경우에는 사용하면 안 됩니다.**
public final class SpinLock {
/// 내부적으로 사용하는 unfair lock 객체입니다.
private var unfairLock = os_unfair_lock_s()
/// 새로운 `SpinLock` 인스턴스를 생성합니다.
public init() {}
/// 락을 획득합니다.
///
/// 락이 이미 다른 스레드에 의해 획득된 경우, 현재 스레드는 블로킹됩니다.
public func lock() {
os_unfair_lock_lock(&unfairLock)
}
/// 락을 해제합니다.
///
/// 반드시 `lock()`을 먼저 호출한 이후에 호출되어야 합니다.
public func unlock() {
os_unfair_lock_unlock(&unfairLock)
}
/// 락을 획득한 뒤, 전달된 클로저를 실행하고 종료 후 락을 해제합니다.
///
/// - Parameter block: 락 안에서 실행할 임계 구역 코드
/// - Returns: 클로저의 반환값
@discardableResult
public func performLocked<T>(_ block: () throws -> T) rethrows -> T {
lock()
defer { unlock() }
return try block()
}
}
FormatterProvider 또한 리팩토링을 진행하였습니다.
public enum FormatterProvider {
private static let formatter = NumberFormatter()
private static let lock = SpinLock()
/// 지정된 소수점 자리수를 가진 NumberFormatter를 반환합니다.
public static func decimal(fractionDigits: Int) -> NumberFormatter {
lock.performLocked {
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = fractionDigits
formatter.maximumFractionDigits = fractionDigits
formatter.groupingSeparator = ","
formatter.locale = Locale(identifier: "ko_KR")
return formatter
}
}
}
이제 FormatterProvider.decimal(fractionDigits:) 는
여전히 하나의 formatter 인스턴스를 공유하지만,
SpinLock을 통해 설정 변경 시 스레드 안전성을 보장합니다.
적용 후 테스트
앞서 테스트한 Race Condition 재현 코드를 동일하게 실행해보았고, 원하는 대로 출력됨을 확인할 수 있었습니다.
func testDecimalFormatterRaceSafe() {
let number = NSNumber(value: 1234.5678)
DispatchQueue.concurrentPerform(iterations: 20) { i in
let digits = (i % 2 == 0) ? 0 : 2
let formatted = FormatterProvider.decimal(fractionDigits: digits).string(from: number)
print("🧵[\(i)] digits: \(digits) → \(formatted ?? "nil")")
}
}
// 출력
🧵[0] digits: 0 → 1,235
🧵[1] digits: 2 → 1,234.57
🧵[2] digits: 0 → 1,235
🧵[3] digits: 2 → 1,234.57
마무리하며
이번 개선을 통해 다음과 같은 교훈을 얻을 수 있었습니다.
- 락 선택은 성능과 안정성의 균형이 중요하다.
- RxSwift의 사례를 통해 단순히 "빠름"만 추구하기보다
구조적 요구사항에 맞는 설계가 우선되어야 한다는 점을 다시 한번 배울 수 있었다. - NumberterKit의 경우 재진입이 필요 없는 구조이기 때문에 경량 락인 SpinLock이 가장 적절한 선택이었다.
- 성능과 안정성을 모두 확보하며, 메모리 낭비 없이 단일 인스턴스를 재사용할 수 있게 되었다.
앞으로도 최적화의 관점에서 구조적 요구사항과 실사용 환경을 고려한 설계 개선을 지속적으로 시도해 볼 계획입니다.
'iOS > NumberterKit' 카테고리의 다른 글
[NumberterKit] 성능 테스트 결과 (0) | 2025.05.16 |
---|---|
[NumberterKit] NumberterKit을 만들게 된 계기와 컨셉 (0) | 2025.04.20 |