검증
NumberterKit이 그럼 기존의 Race Condition 문제를 해결했는지와 실제로 성능은 어느정도 개선되었는지에 대해 분석하였습니다.
테스트 케이스를 통한 검증
앞서 수동 테스트로 동작을 확인했지만,
신뢰할 수 있는 결과를 보장하기 위해 XCTestCase를 통한 자동화 테스트도 함께 진행하였습니다.
다음과 같이 Race Condition 재현 테스트를 테스트 케이스로 등록하여,
매번 일관된 결과가 출력되는지 검증했습니다.
import XCTest
final class FormatterProviderTests: XCTestCase {
func testDecimalFormatterRaceSafe() {
let number = NSNumber(value: 1234.5678)
let expectedResults = [
0: "1,235",
1: "1,234.57"
]
let iterations = 20
DispatchQueue.concurrentPerform(iterations: iterations) { i in
let digits = (i % 2 == 0) ? 0 : 2
let formatted = FormatterProvider.decimal(fractionDigits: digits)
.string(from: number)
XCTAssertEqual(formatted, expectedResults[i % 2])
}
}
}
테스트 결과, 모든 스레드에서 요청한 자리수에 맞는 일관된 출력이 검증되었습니다.
성능 테스트
다음으론 SpinLock 기반 공유 Formatter와 매번 새 인스턴스를 생성하는 방식의 성능 차이를 테스트해 보았습니다.
테스트 환경
- 싱글 스레드 100,000회 호출
- 멀티 스레드 (10개의 스레드 × 10,000회 호출)
두 가지 환경에서
- FormatterProvider.decimal(fractionDigits:) (SpinLock 기반 공유 인스턴스)
- FormatterProvider.newDecimal(fractionDigits:) (매번 새 인스턴스 생성)
을 비교하였습니다.
싱글 스레드 테스트 결과
먼저 싱글 스레드에서 100,000번을 호출했을 때의 성능 결과입니다.
import Foundation
import os.lock
import Dispatch
// MARK: - SpinLock Implementation
public final class SpinLock {
private var unfairLock = os_unfair_lock_s()
public init() {}
public func lock() { os_unfair_lock_lock(&unfairLock) }
public func unlock() { os_unfair_lock_unlock(&unfairLock) }
@discardableResult
public func performLocked<T>(_ block: () throws -> T) rethrows -> T {
lock()
defer { unlock() }
return try block()
}
}
// MARK: - Formatter Provider
public enum FormatterProvider {
private static let formatter = NumberFormatter()
private static let lock = SpinLock()
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
}
}
public static func newDecimal(fractionDigits: Int) -> NumberFormatter {
let newFormatter = NumberFormatter()
newFormatter.numberStyle = .decimal
newFormatter.minimumFractionDigits = fractionDigits
newFormatter.maximumFractionDigits = fractionDigits
newFormatter.groupingSeparator = ","
newFormatter.locale = Locale(identifier: "ko_KR")
return newFormatter
}
}
// MARK: - Performance Measurement
func measure(label: String, block: () -> Void) {
let start = DispatchTime.now()
block()
let end = DispatchTime.now()
let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds
let timeInterval = Double(nanoTime) / 1_000_000_000
print("\(label): \(timeInterval) seconds")
}
let iterations = 100_000
measure(label: "Using Shared Formatter with SpinLock") {
for _ in 0..<iterations {
_ = FormatterProvider.decimal(fractionDigits: 2)
}
}
measure(label: "Creating New Formatter Each Time") {
for _ in 0..<iterations {
_ = FormatterProvider.newDecimal(fractionDigits: 2)
}
}
// 결과
Using Shared Formatter with SpinLock: 0.540122208 seconds
Creating New Formatter Each Time: 2.224474 seconds
SpinLock 기반 공유 Formatter | 0.54 (Sec) |
매번 새 인스턴스 생성 | 2.22 (sec) |
제가 구현한 SpinLock 기반 공유 방식이 약 4배 이상 빠른 성능을 보여주었습니다.
멀티 스레드 테스트 결과
다음은 10개의 멀티 스레드에서 각 10,000회씩 호출했을 때의 성능 결과입니다.
// MARK: - Performance Measurement
func measure(label: String, block: @escaping () -> Void) {
let start = DispatchTime.now()
block()
let end = DispatchTime.now()
let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds
let timeInterval = Double(nanoTime) / 1_000_000_000
print("\(label): \(timeInterval) seconds")
}
let iterationsPerThread = 10_000
let threadCount = 10
func runMultiThreadTest(usingSharedFormatter: Bool) {
let group = DispatchGroup()
let queue = DispatchQueue.global(qos: .userInitiated)
let start = DispatchTime.now()
for _ in 0..<threadCount {
queue.async(group: group) {
for _ in 0..<iterationsPerThread {
if usingSharedFormatter {
_ = FormatterProvider.decimal(fractionDigits: 2)
} else {
_ = FormatterProvider.newDecimal(fractionDigits: 2)
}
}
}
}
group.wait()
let end = DispatchTime.now()
let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds
let timeInterval = Double(nanoTime) / 1_000_000_000
print("\(usingSharedFormatter ? "Shared Formatter with SpinLock" : "New Formatter Each Time"): \(timeInterval) seconds")
}
// Run Multithreaded Tests
runMultiThreadTest(usingSharedFormatter: true)
runMultiThreadTest(usingSharedFormatter: false)
// 결과
Shared Formatter with SpinLock: 0.591676209 seconds
New Formatter Each Time: 1.398356292 seconds
SpinLock 기반 공유 Formatter | 0.59 (Sec) |
매번 새 인스턴스 생성 | 1.39 (Sec) |
제가 구현한 SpinLock 기반 공유 방식이 약 2.5배 이상 빠른 성능을 보여주었습니다.
정리하며
이번 테스트를 통해 SpinLock 기반 공유 Formatter 방식이 성능적으로도 충분히 유리하다는 것을 확인할 수 있었습니다.
- 싱글 스레드: 객체 생성 비용 절감으로 최대 4배 성능 향상
- 멀티 스레드: 락의 오버헤드에도 불구하고 최대 2배 성능 향상
이 결과는 락 사용이 무조건 느리다는 일반적인 편견과 달리,
적절한 상황에서는 락이 성능과 안정성 모두를 잡을 수 있다는 가능성을 보여주었습니다.
앞으로도 성능과 구조의 균형을 고민하며 최적의 설계 방안을 찾기 위해 계속 실험해 나가겠습니다.
'iOS > NumberterKit' 카테고리의 다른 글
[NumberterKit] Formatter 최적화: SpinLock 적용기 (0) | 2025.05.09 |
---|---|
[NumberterKit] NumberterKit을 만들게 된 계기와 컨셉 (0) | 2025.04.20 |