데이터 변환의 불편함
iOS 앱을 개발하다 보면 숫자를 문자열로 변환하거나, 통화 및 퍼센트로 표시하는 등의 작업을 반복적으로 하게 됩니다. 저 또한 최근에 개발한 프로젝트가 가상 화폐와 관련된 프로젝트였기 때문에 Int64, Decimal 등의 데이터 타입의 변환이 빈번하게 발생하였습니다. 보통 이런 상황에서 Extension이나 Formatter와 관련된 폴더 및 파일을 생성하고 관리합니다.
하지만 이러한 처리 로직이 프로젝트마다 다르게 작성되고, formatter 설정 방식도 일관되지 않다 보니 유지보수가 어려워지는 문제를 겪게 되었습니다. 특히, 같은 Decimal 타입이라도 어떤 화면에서는 소수점 없이, 어떤 화면에서는 소수점 둘째 자리까지 표현해야 하는 상황이 빈번하게 발생하였고, 이에 따른 formatter의 재사용과 관리가 필요하다고 느꼈습니다.
또한, NumberFormatter는 생성 비용이 크기 때문에 매번 새로 인스턴스를 생성하면 앱 성능에 영향을 줄 수 있다는 점도 고려해야 했습니다. 이러한 문제들을 해결하고자, 포맷터를 일관되게 관리하고 숫자 변환 기능을 공통화할 수 있는 라이브러리가 필요하다고 판단하였습니다.
따라서 Int64, Decimal 등을 간편하게 변환하고 표현할 수 있는 NumberterKit이라는 숫자 관련 라이브러리 패키지를 만들게 되었습니다.
NumberterKit의 컨셉: "가볍고 일관된 숫자 처리"
NumberterKit의 핵심 목표는 “복잡한 숫자 처리 로직을 단순하고 일관되게 사용하는 것”입니다.
특히 다음과 같은 상황을 염두에 두고 설계하였습니다.
상황 | 해결 방식 |
프로젝트마다 다른 Formatter 설정 방식 | 공통 FormatterProvider를 통해 중앙 집중화 |
Decimal 타입을 화면마다 다르게 포맷 | .formatted(fractionDigits:) 로 표현 방식 제어 |
퍼센트, 통화 등 특수한 포맷의 반복 사용 | .percentString(), .currencyString() 등 API 제공 |
NumberFormatter 재사용이 어려움 | static 방식으로 목적별 Formatter 캐싱 |
이처럼 NumberterKit은 실제 앱 개발 흐름에서 자주 쓰이는 수치 처리 작업들을 라이브러리 수준으로 통일함으로써, 생산성과 일관성을 동시에 확보하고자 하였습니다.
주요 설계 기준
- Swifty한 인터페이스
복잡한 formatter 설정 없이, .formatted, .percentString() 처럼 간결한 API 제공
“Omit needless words. When a method performs a conversion, prefer a noun or property if it’s fast and not surprising.” -Swift API Design Guidelines-
- 표현 중심의 명명
- convertToCurrencyFormat() 대신 .currencyString() 처럼 목적이 명확한 이름 사용
- 타입 일관성
- Int, Int64, Double, Decimal 전반에 동일한 인터페이스 제공
- 성능 고려
- formatter를 static으로 관리하고, 멀티스레드 이슈는 용도별 인스턴스로 분리하여 해결
FormatterProvider 구현
NumberFormatter()는 생성 비용이 큰 객체이기 때문에 매번 생성하면 앱 성능에 부정적인 영향을 미칠 수 있다고 판단하였습니다. 따라서 이를 방지하기 위해 포맷터를 재사용할 수 있도록 관리하고자 하였고, 이를 관리하는 유틸리티가 FormatterProvider입니다.
FormartterProvider를 통해 다음과 같은 문제 상황에 대응하고자 하였습니다.
문제 | 결과 |
NumberFormatter() 매번 생성 | 느린 성능, 높은 메모리 사용 |
코드마다 포맷터 설정 반복 | 중복 코드, 버그 발생 가능 |
로케일, 스타일 등 다양성 | 복잡해질수록 실수 가능성 커짐 |
공용 NumberFormatter()의 문제점 및 구현 방향
초기에는 성능을 고려하여 하나의 NumberFormatter()를 static하게 공유하고, 필요할 때 설정을 바꿔서 사용하는 방식으로 구현하였습니다.
public enum FormatterProvider {
private static let formatter = NumberFormatter()
public static func decimal(fractionDigits: Int) -> NumberFormatter {
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = fractionDigits
formatter.maximumFractionDigits = fractionDigits
formatter.groupingSeparator = ","
formatter.locale = Locale(identifier: "ko_KR")
return formatter
}
}
이 방식은 간단하지만, 공유 인스턴스를 여러 스레드에서 동시에 사용하는 것이 안전한지에 대한 의문이 있었습니다.
따라서 직접 테스트를 통해 NumberFormatter에서 race-condition이 발생할 수 있음을 확인하였습니다.
테스트: NumberFormatter의 Race Condition 발생 여부 확인
테스트 목적
NumberFormatter는 Foundation에서 제공하는 클래스(참조 타입)이며 내부 설정이 mutable합니다.
static 프로퍼티로 공유된 인스턴스를 여러 스레드에서 동시에 접근할 경우,
설정 변경이 충돌하며 Race Condition이 발생할 수 있는지를 확인하고자 테스트를 진행하였습니다.
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)")
}
}
예상 결과
예상 결과는 다음과 같아야 합니다.
fractionDigits | 기대 포맷 결과 |
0 | "1,235" |
2 | "1,234.57" |
실제 출력 예 (일부)
하지만 실제 출력 결과는 다음과 같았습니다.
🧵[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 ✅
- digits: 0 요청에서 1,234.57이 출력됨 ❗️
- digits: 2 요청에서 1,235가 출력됨 ❗️
이러한 결과는 요청한 자리수(fractionDigits)와 실제 결과가 일치하지 않음을 의미하며, 다른 스레드가 formatter 설정값을 덮어쓴 결과입니다.
따라서 초기 버전에서는 각 인스턴스에 각각의 목적에 맞는 NumberFormatter()를 정적으로 생성하여 구현하였습니다.
public enum FormatterProvider {
/// 쉼표 포함, 소수점 없음
public static let integerDecimal: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
formatter.groupingSeparator = ","
formatter.locale = Locale(identifier: "ko_KR")
return formatter
}()
/// 통화 포맷 (₩)
public static let wonCurrency: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
formatter.groupingSeparator = ","
formatter.locale = Locale(identifier: "ko_KR")
return formatter
}()
/// 퍼센트 (소수점 둘째 자리 고정)
public static let percent2Digits: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
formatter.groupingSeparator = ","
formatter.locale = Locale(identifier: "ko_KR")
return formatter
}()
}
이러한 방식으로 각 포맷 목적에 맞는 Formatter를 명확히 나누어 스레드 충돌 위험을 낮추고, 포맷 스타일도 목적에 맞게 고정하여 일관성을 유지할 수 있도록 하였습니다.
그리고 FormatterProvider에 구현되어 있는 메서드를 기반으로 각각의 데이터 타입에 대한 변환 메서드를 구현하였습니다.
public extension Decimal {
/// 쉼표 포함된 숫자 포맷 문자열입니다.
///
/// 내부적으로 `FormatterProvider.integerDecimal`을 사용합니다.
///
/// ```swift
/// Decimal(123456).formatted // "123,456"
/// ```
var formatted: String {
FormatterProvider.integerDecimal().string(from: NSDecimalNumber(decimal: self)) ?? self.description
}
/// 지정된 소수점 자리수로 포맷된 문자열을 반환합니다.
///
/// 내부적으로 `FormatterProvider.decimal(fractionDigits:)`를 사용합니다.
///
/// ```swift
/// Decimal(1234.5678).formatted(fractionDigits: 2) // "1,234.57"
/// ```
func formatted(fractionDigits: Int) -> String {
FormatterProvider.decimal(fractionDigits: fractionDigits)
.string(from: NSDecimalNumber(decimal: self)) ?? self.description
}
/// 퍼센트 포맷 문자열을 반환합니다.
///
/// - Parameter withSpacing: `%` 기호 앞에 공백을 넣을지 여부 (기본값: `false`)
///
/// ```swift
/// Decimal(0.1234).percentString(withSpacing: true) // "12.34 %"
/// Decimal(0.1234).percentString(withSpacing: false) // "12.34%"
/// ```
func percentString(withSpacing: Bool = false) -> String {
let value = self * 100
let formatted = FormatterProvider.percent2Digits().string(from: NSDecimalNumber(decimal: value)) ?? self.description
return withSpacing ? "\(formatted) %" : "\(formatted)%"
}
/// 지정된 통화 스타일로 포맷된 문자열을 반환합니다.
///
/// - Parameters:
/// - style: 통화 스타일 (.won, .dollar). 기본값은 `.won`
/// - withSpacing: 통화 기호 앞에 공백을 추가할지 여부. 기본값은 `true`
///
/// ```swift
/// Decimal(1234.56).currencyString(.won) // "1,235 ₩"
/// Decimal(1234.56).currencyString(.won, withSpacing: false) // "1,235₩"
/// ```
func currencyString(_ style: CurrencyStyle = .won, withSpacing: Bool = true) -> String {
let formatter: NumberFormatter = switch style {
case .won: FormatterProvider.wonCurrency()
case .dollar: FormatterProvider.dollarCurrency()
}
let formatted = formatter.string(from: NSDecimalNumber(decimal: self)) ?? self.description
return withSpacing ? "\(formatted) \(style.symbol)" : "\(formatted)\(style.symbol)"
}
}
마무리하며
NumberterKit은 단순히 숫자를 포맷팅하는 기능을 넘어서, 일관성 있는 데이터 표현, 반복 코드 제거, 성능 고려, 재사용성 확보라는 실무적인 문제들을 해결하기 위해 출발한 유틸리티입니다.
특히 Swift 기반 프로젝트에서 흔히 반복되는 Decimal, Double, Int64 등의 포맷 작업을 보다 Swifty하게, 안정적으로 처리할 수 있도록 설계하였고, 향후에는 조건 기반 Formatter 캐싱, 국제화 로케일 대응, 테스트 커버리지 확장 등도 고려하고 있습니다.
실제 프로젝트에서 숫자 처리 로직이 많아질수록 그 가치를 체감하게 될 도구가 되기를 기대하며, 앞으로도 지속적으로 개선해 나갈 예정입니다.
'iOS > NumberterKit' 카테고리의 다른 글
[NumberterKit] 성능 테스트 결과 (0) | 2025.05.16 |
---|---|
[NumberterKit] Formatter 최적화: SpinLock 적용기 (0) | 2025.05.09 |