[Localized Number] 3편: NumberFormatter의 제약사항과 보완점

baecheese·2021년 2월 19일
0
post-thumbnail

목차

🌎 서론

2편까지 보았을 때, '그럼 NumberFormatter 쓰면 되는 것 아닌가?' 라고 생각할 수 있지만
사용자의 환경과 서비스 스펙에 따라 많은 변수와 제약사항이 있을 수 있습니다.

단적인 예로 Number String을 받는 Textfield에는
다양한 특수숫자 혹은 separator가 들어올 수 있고, (1편 - 다양한 숫자와 소숫점, 천단위 부분 참고)
third party keyboard를 써서 시스템 키보드와는 다른 방식으로 input 될 수 있고,
사용자가 어디선가 긁어온 텍스트를 복사/붙여넣기 할 수도 있습니다.

많은 이유로 Input 데이터는 올바른 포맷일 것이라는 보장이 없습니다.
이는 여러가지 버그를 만듭니다. 변환이 불가할 수도 있고, 아예 잘못된 값으로 변환이 될 수도 있습니다.

때문에 NumberFormatter가 숫자 텍스트를 올바르게 NSNumber로 변환해주는 조건을 알아내고,
이를 토대로 제약사항을 보완할 수 있는 기능을 개발하고자 합니다.

🎰 숫자 텍스트의 조건

🙆🏻‍♀️ 올바른 숫자 포맷

  1. 지정 국가의 숫자로 이루어진 경우
    • 🇺🇸 en_US ) 123
    • 🇺🇸 en_US ) 12000
    • 🇳🇵 ne ) १२३
  2. 1이면서 소수점 구분 문자(decimal separator)가 있는 경우
    • 🇺🇸 en_US ) 1.23
    • 🇻🇳 vi ) 1,23
  3. 1이면서 그룹 구분 문자(grouping separator)가 있는 경우
    • 🇺🇸 en_US ) 1,200
    • 🇻🇳 vi ) 1.200
  4. 1이면서 소숫점 구분 문자, 천단위 구분 문자가 모두 있는 경우
    • 🇺🇸 en_US ) 1,203.3
    • 🇻🇳 vi ) 1.203,3

🙅🏻‍♀️ 올바르지 않은 숫자 포맷

  1. decimal/grouping separator 외에 숫자가 아닌 문자가 포함된 경우
    • 10️⃣0️⃣0️⃣ (이모지 포함)
    • looo (알파벳 LOOO)
    • 100-100 (특수문자 포함)
  2. decimal separator가 한 개 이상 들어간 경우
    • 🇺🇸 en_US ) 1.0.2 (.이 두 개)
  3. grouping separator와 decimal separator의 순서가 뒤집힌 경우
    • 🇺🇸 en_US ) 1,000.12 (o)
    • 🇺🇸 en_US ) 1.000,12 (x)
  4. grouping separator가 올바르게 기재되지 않은 경우
    • 🇺🇸 en_US ) 1,000,000 (o)
    • 🇺🇸 en_US ) 1,0000,00 (x)
  • 4번의 경우, thousandSeparator MacOS에서만 제공이 되므로, 라이브러리에서는 에러처리하지 않겠음.

🧐 NumberFormatter의 제약사항

1) 설정한 국가와 다른 국가의 Number String을 넣었을 때, 올바른 값으로 변환이 될까?

Numberen_USneur_INfr_FR
only number123123१२३۱۲۳123
decimal separator123.4123.4१२३.४۱۲۳٫۴123,4
grouping separator123456789123,456,789१२३,४५६,७८९۱۲۳٬۴۵۶٬۷۸۹123 456 789
all123456789.123123,456,789.123१२३,४५६,७८९.१२३۱۲۳٬۴۵۶٬۷۸۹٫۱۲۳123 456 789,123

올바른 숫자 포맷이긴 하나,
NumberFormatter에 Locale로 설정한 국가와 다른 국가의 포맷일 경우,
어디까지 보장이 되나 테스트해봤습니다.

예를 들어, numberFormatter.locale = Locale(identifier: "en_US")로 설정해놓고
Urdu (India)۱۲۳٬۴۵۶٬۷۸۹٫۱۲۳가 잘 변환이 되는지 테스트 합니다.

선정 기준은 숫자 표기가 다르거나 grouping/decimal separator가 다른 것들입니다.

< 테스트 코드 >

func testMatchingDiffrentIdentifier() throws {
    let numbers_en: [String] = [
        "123",
        "123.4",
        "123,456,789",
        "123,456,789.123"
    ]
    let numbers_ne: [String] = [
        "१२३",
        "१२३.४",
        "१२३,४५६,७८९",
        "१२३,४५६,७८९.१२३"
    ]
    let numbers_ur_IN: [String] = [
        "۱۲۳",
        "۱۲۳٫۴",
        "۱۲۳٬۴۵۶٬۷۸۹",
        "۱۲۳٬۴۵۶٬۷۸۹٫۱۲۳"
    ]
    let numbers_fr_FR: [String] = [
        "123",
        "123,4",
        "123 456 789",
        "123 456 789,123"
    ]
    let identifiers: [String] = ["en_US", "ne", "ur_IN", "fr_FR"]
    let numbersDictionary: [String: [String]] = [
        "en_US": numbers_en,
        "ne": numbers_ne,
        "ur_IN": numbers_ur_IN,
        "fr_FR": numbers_fr_FR
    ]
    for identifier in identifiers {
        let numberFormatter = NumberFormatter()
        numberFormatter.numberStyle = .decimal
        numberFormatter.locale = Locale(identifier: identifier)
        for (identifier_compare, numbers_compare) in numbersDictionary {
            // identifier가 다른 NumberString 테스트
            guard identifier != identifier_compare else { continue }
            let originNumbers = numbersDictionary[identifier]!
            let compareNumbers = numbers_compare
            for index in 0...originNumbers.count - 1 {
                let origin = originNumbers[index]
                let compare = compareNumbers[index]
                let currentOriginNumber = numberFormatter.number(from: origin)
                let currentCompareNumber = numberFormatter.number(from: compare)
                guard nil != currentCompareNumber else {
                    // 🔥 String -> NSNumber로 변환 실패 시 로그
                    log.error(
                        message:
                            debugMessage(
                                originIdentifier: identifier,
                                compareIdentifier: identifier_compare,
                                origin: origin,
                                compare: compare,
                                num_origin: currentOriginNumber,
                                num_compare: currentCompareNumber
                            )
                    )
                    continue
                }
                let localizedNumberString_origin = numberFormatter.string(from: currentOriginNumber!)
                let localizedNumberString_compare = numberFormatter.string(from: currentCompareNumber!)
                XCTAssertEqual(localizedNumberString_origin, localizedNumberString_compare)
                log.info(
                    message:
                        debugMessage(
                            originIdentifier: identifier,
                            compareIdentifier: identifier_compare,
                            origin: origin,
                            compare: compare,
                            num_origin: currentOriginNumber,
                            num_compare: currentCompareNumber,
                            numberString_origin: localizedNumberString_origin,
                            numberString_compare: localizedNumberString_compare
                        )
                )
            }
        }
    }
}

테스트 결과

  1. separator 없이 모두 숫자인 경우, NumberFormatter에 설정된 Locale에 상관 없이 올바른 NSNumber 값으로 변환된다.
    • 아라비아 숫자를 쓰는 identifier로 Locale을 설정했을 때, 특수 숫자를 input해도 변환 가능 (반대의 경우도 가능)
  2. groupingSeparator 또는 decimalSeparator가 포함되어있는 경우는 올바른 변환을 보장할 수 없다.

2) 다른 국가/언어의 Number String이 섞여 있을 때, 올바르게 변환될까?

let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal
numberFormatter.locale = Locale(identifier: "en_US")

// all number
(lldb) po numberFormatter.number(from: "123१२३۱۲۳")Optional<NSNumber>
  - some : 123123123
  
// decimalSeparator
(lldb) po numberFormatter.number(from: "123.१२३۱۲۳")Optional<NSNumber>
  - some : 123.123123

// groupingSeparator
(lldb) po numberFormatter.number(from: "123,१२३,۱۲۳")
nil
    
// groupingSeparator, decimalSeparator
(lldb) po numberFormatter.number(from: "123,१२३,۱۲۳.1")
nil

// decimalSeparator이 여러 개 일 때
(lldb) po numberFormatter.number(from: "123.१२३.۱۲۳.1")
nil

// groupingSeparator, decimalSeparator 순서가 바뀌었을 때
(lldb) po numberFormatter.number(from: "123.१२३.۱۲۳,1")
nil

테스트 결과

  • groupingSeparator가 있으면 변환이 되지 않는다.
  • 이외에 올바른 숫자 포맷일 경우 변환 가능하다.

🧚🏻‍♀️ 제약사항을 기반으로 보완되어야 할 점

1) input과 output의 Locale 설정을 분리합니다.

우리는 input이 어떤 Locale의 포맷일지 알 수 없습니다.
그러나 NumberFormatter는 output에 대한 Locale 만을 설정합니다.

문제 상황 재현

// 베트남은 소숫점을 ","으로 표기
let vietnameseNumberString: String ="1,234" // Double(1.234)
let numberFormatter: NumberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal
numberFormatter.locale = Locale(identifier: "en_US") // 미국은 천단위를 ","로 표기
let currentCompareNumber = numberFormatter.number(from: vietnameseNumberString)
print(currentCompareNumber) // Int(1234)
let localizedNumberString_origin = numberFormatter.string(from: currentCompareNumber!)
print(localizedNumberString_origin) // String("1,234")
  • input 값은 Double(1.234)인데, 변환된 output 값은 Int(1234)가 되었습니다.
  • 베트남과 미국의 groupingSeparator, decimalSeparator가 반대이기 때문입니다.
    • 베트남(vi) - groupingSeparator(.) / decimalSeparator(,)
    • 미국(en_US) - groupingSeparator(,) / decimalSeparator(.)

이렇게 NumberFormatter에 설정된 locale과 다른 숫자 포맷이 들어왔을 경우,
전혀 다른 숫자로 변환될 수 있는 위험이 있습니다.

때문에, input 데이터에 대한 Locale이 필수적으로 설정되어야 한다는 것입니다.

2) Locale 설정을 생략하지 못하도록 해야합니다.

let numberFormatter: NumberFormatter = NumberFormatter()
// numberFormatter.locale = .current (생략 가능)
  • NumberFormatter에서는 locale을 따로 설정해주지 않으면 default로 .current가 설정 됩니다.
  • 아무 locale을 넣지 않았을 때, default로 current가 설정 되는 것은 문제가 발생했을 때 원인파악을 어렵게 합니다.
  • 때문에 input, output 모두 Locale의 identifier 또는 current 설정을 생략할 수 없도록 합니다.

3) 에러를 리턴할 수 있어야 합니다.

  • 숫자 변환 실패시, 원인에 대한 피드백으로 올바른 데이터 input을 유도합니다.

😇 이러한 보완점들을 바탕으로 LocalizedNumberFormatter와 LocaliedNumberField 라이브러리를 개발해보겠습니다....!
* https://github.com/baecheese/LocalizedNumberField


🔜 .. 4편에서 계속 .. 🔜

profile
iOS Developer

0개의 댓글