date(from:) return nil

sanghoon Ahn·2024년 3월 2일
0

Daily Issue

목록 보기
9/9
post-thumbnail

Daily Issue #9

안녕하세요, szzang입니다.

오랜만에 돌아온 Dailly Issue 입니다.

이번엔 빠르게 들어가 보겠습니다 ~ 🏃

무엇이 문제입니까?

private func isContain() -> Bool {
    let current = Date()

    let start = "2024-01-15T10:00:00"
    let end = "2024-02-08T17:50:00"

    let startDate = Date(kstString: start)!
    let endDate = Date(kstString: end)!

    let calendar = Calendar.current
		
    return calendar.compare(current, to: startDate, toGranularity: .second) == .orderedDescending &&
        calendar.compare(current, to: endDate, toGranularity: .second) == .orderedAscending
}

extension Date {
		// 이전에 작성되어있던 코드
		init?(kstString: String) {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.timeZone = TimeZone(secondsFromGMT: 32400) // GMT+0900 (GMT+9) KST

        if let date = dateFormatter.date(from: kstString) {
            self.init(timeIntervalSince1970: date.timeIntervalSince1970)
        } else {
            return nil
        }
    }
}

냅다 코드 부어버리기

특정 날짜 범위 안에 오늘의 날짜가 포함되는지 확인하는 코드를 이전에 작성되어있던 Date Extension을 활용하여 작성했습니다.

Production에서는 Crash를 일으킬 여지가 있어 사용을 지양하는 forced-optional-binding이지만 고정된 날짜 String을 기반으로 Date를 생성하므로 문제가 없는 코드가 될 줄 알았습니다.

심지어 로컬 디바이스의 시간을 바꾸어서도 테스트 해보았을 때는 이상 없었구요.

그렇게 기능을 배포했고..

아뿔싸.

Crashlytics에 불이났습니다.

isContain() 함수의 return Line에서 crash가 발생한다는 레포트가 꾸준히 올라옵니다 😡

return Line에서 Crash가 보고되었기 때문에 의심가는 부분은 nil Date와 compare 하다가 crash가 발생할것 이라는 예상이였습니다.

허겁지겁 방어코드를 삽입하여 배포했고, 더이상 crash는 보고되지 않았습니다

private func isContain() -> Bool {
    let current = Date()

    let start = "2024-01-15T10:00:00"
    let end = "2024-02-08T17:50:00"

    guard
        let startDate = Date(kstString: start),
        let endDate = Date(kstString: end)
    else {
        return false
    }

    let calendar = Calendar.current
		
    return calendar.compare(current, to: startDate, toGranularity: .second) == .orderedDescending &&
        calendar.compare(current, to: endDate, toGranularity: .second) == .orderedAscending
}

방어코드는 아래와 같은 아이디어로 삽입했습니다.

먼저 날짜를 비교하는 compare(_:to:toGranularity:)는 Calendar의 instance methods이며 ComparisonResult를 return하기 때문에

“전달받는 prarameter는 모두 non-Optional Date이고,

그렇다면 이때 nil을 전달하면 당연히 Crash가 발생할것이다 ”

current는 Date 타입이고, Date(kstString:)은 failable initializer 라서 Date? 타입이라 nil이 할당될 수 있습니다.

아, 그러면 Date(kstString:)에서 initialize가 실패했구나.

“아니 가변 문자열도 아니고 고정된 문자열으로 Date를 생성하는데 실패할 이유가 있나?”

(k-않이시에이팅)

오늘은 이 문제를 깊숙하게 들여다보려고 합니다.

물론 production에서 forced-optional-binding을 사용한 필자가 잘못한게 맞습니다.

처음부터 방어코드를 넣었다면 들여다보지 않아도 될 문제이기 때문에 반박시 여러분의 의견이 무조건 맞습니다.

Date(kstString:)을 자세히 파보자

init?(kstString: String) {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
    dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    dateFormatter.timeZone = TimeZone(secondsFromGMT: 32400) // GMT+0900 (GMT+9) KST

    if let date = dateFormatter.date(from: kstString) {
        self.init(timeIntervalSince1970: date.timeIntervalSince1970)
    } else {
        return nil
    }
}

DateFormatter instance를 생성하고

dateFormat을 설정하고

locale과 timeZone을 설정합니다.

하나씩 천천히 살펴보죠.

dateFormat은 초까지 비교해야 하므로 yyyy-MM-dd'T'HH:mm:ss 를 사용했습니다.

Date Format 에 사용되는 각 항목에 대한 의미는 자세한 설명은 문서Apple Developer Documentation를 참고하시기 바랍니다.

다음은 locale이네요

먼저 Locale(identifier:)는 전달된 locale identifier로 Locale을 생성한다. 이때 locale identifier는 IETF 언어태그입니다.

그렇다면 “en_US_POSIX”는 무슨 의미일까요?

문서에 따르면 DateFormatter의 locale을 “en_US”를 사용하게 되면 US 시간을 가져오되, 변경사항을 반영한다고 합니다.

즉, 고정된 시간이 아니라 사용자에 의해 변경된 시간을 가져오게 됩니다.

반면 “en_US_POSIX”은 변경사항의 반영 없이 항상 영어/미국 의 시간을 가져오게된다는 의미입니다.

마지막으로는 timeZone입니다.

TimeZone(secondsFromGMT:)은 GMT로부터 seconds 차이값으로 timeZone을 생성할 수 있습니다.

KST는 GMT로부터 +32400초(= 60초 60분 9시간) 차이나기 때문에 위와 같이 초기화 해주었습니다.

자, 이제 init?(kstString: String)에서 사용되는 dateFormatter는 아래와 같은 특성을 지니고

  • yyyy-MM-dd'T'HH:mm:ss dateFormat을 사용하여 Date를 표시한다.
  • Locale은 “en_US_POSIX”이다
  • TimeZone은 KST(GMT+9)이다.

해당 dateFormatter로 dateFormatter.string(from: Date()) 를 print 해보면 꽤 정확한 한국 현재시간이 나오게됩니다.

그러면 이제 반대로 String으로 Date를 생성해보고, 다시 String으로 print 해서 비교해보죠

let std = "2024-03-24T13:30:00"

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.timeZone = TimeZone(secondsFromGMT: 32400)

let stringToDate = dateFormatter.date(from: std)
print(stringToDate)
print(dateFormatter.string(from: stringToDate!))

///
"Optional(2024-03-24 04:30:00 +0000)"
"2024-03-24T13:30:00"

Date와 Date를 formatter로 표현한 String입니다

TimeZone의 9시간 차이만 있고, 크게 다른것은 없습니다.

좋습니다, 그러면 Date가 nil이 되어 발생한 문제이니 마지막 코드를 살펴보죠

if let date = dateFormatter.date(from: kstString) {
    self.init(timeIntervalSince1970: date.timeIntervalSince1970)
} else {
    return nil
}

dateFormatter를 통해 String으로 Date를 만들고, date(from:)은 failure initializer이기 때문에 optional binding을 통해 성공했을 때만 self를 초기화 하는 코드인데요,

date(from:)은 그러면 언제 실패하는것일까요 🤔

문서에는 receiver의 current settings system format을 사용하여 전달된 string을 parsing한다고 되어있다.

또한 parsing에 실패한다면 nil을 return 한다고 적혀있습니다.

“그렇다면 디바이스의 셋팅을 바꾸면 실패하는 경우가 생길까?”

시간 포맷, 날짜, 국가, 언어 등등 다양하게 바꾸어 테스트를 해보았고, nil을 리턴하는 경우는 없었습니다.


킹치만.. 프로덕션에서는 꽤 잘 발생했던것 같은데 ..

어떤 문제가 있었던것일까..

이번글은 뾰족하게 답을 내지 못했습니다..

이런날도 있는거지 뭐 ..


앞으로는 String으로부터 Date를 만들때는 nil이 생성될 수 있음을 주의해야겠다는

다짐과 함께 글을 마치도록 하겠습니다.

읽어주셔서 감사합니다 :)

profile
hello, iOS

0개의 댓글