애플사이다의 iOS 개발 일지

Count down 타이머 만들기 - 2개 날짜 비교 (feat. Localization) 본문

iOS

Count down 타이머 만들기 - 2개 날짜 비교 (feat. Localization)

Applecider 2023. 12. 9. 14:12

앱으로 쇼핑을 하다 보면 구매를 부추기는 장치가 여럿 마련되어 있는데

그중 하나가 Time Deal (특정 기간 동안 할인), Flash Sale (짧은 기간동안 한정 수량을 할인) 등의 이벤트이고

일반적으로 1초마다 시간을 업데이트하는 카운트 다운 타이머가 옆에 배치된다.

 

할인이 곧 끝나니 얼른 지르라는 은근한 압박을 주는데

이런 할인에 전혀 영향을 받지 않는 사용자도 있겠지만 

UI/UX 관점에서 초 단위의 숫자가 계속 바뀌어서 시선이 집중되는 효과가 있다.

그래서 Home, PDP, 할인전 화면 등에서 주로 노출한다.

 

Amazon SHEIN 쿠팡 오늘의집

 

이번 포스팅에서는 이러한 Count down 타이머를 구현해보고자 한다.

먼저 요구사항을 정리해보고, 단계별로 만들어 보자.

TODO:

1. 남은 할인기간 계산하기

2. Label에 원하는 format으로 시간 표시하기 

3. Timer를 통해 1초마다 Label 업데이트하기

4. 추가적인 개선사항


1. 남은 할인기간 계산하기 

Time Deal은 할인 종료시점이 정해져 있다.

이번 예제에서는 2023년 12월 31일 24시 (한국 timeZone 기준)에 할인이 끝나고, 서버에서 이 시간을 내려준다고 가정하자.

 

글로벌 서비스라면 종료시점을 ISO8601 format이 적용된 

2024-01-01T00:00:00+09:00 문자열을 서버로부터 받게 될 것이다.

 

TimeManager 싱글턴 객체를 만들고, 현재 시각에서 할인 종료시점까지 남은 시간을 구해보자.

3가지 키 포인트만 알면 쉽다!

 

  • 기본적으로 2개의 시간을 비교할 때 Calendar의 dateComponents 메서드를 활용한다.
  • 그러려면 시간 문자열을 Date 타입으로 변환해야 한다.
  • 이때 변환을 위해 ISO8601DateFormatter이 필요하다.
final class FlashSaleManager { 
    // ✅ calendar를 사용하여 시간 비교
    private let calendar = Calendar.autoupdatingCurrent
    
    // ✅ 서버에서 받아온 시간 문자열을 Date 타입으로 변환하기 위한 formatter
    private let iso8601Formatter: ISO8601DateFormatter = {
        let formatter = ISO8601DateFormatter()
        // "yyyy-MM-dd'T'HH:mm:ssZZZZZ" (ex. 2023-12-31T15:00:00+00:00)
        formatter.formatOptions = [.withInternetDateTime]
        return formatter
    }()

    private func date(from dateText: String) -> Date? {
        return iso8601Formatter.date(from: dateText)
    }

    // ✅ 남은 시간을 구하기 (매개변수에 서버 문자열 전달)
    func remainTime(to dateText: String) -> DateComponents? {
        guard let date2 = date(from: dateText) else { return nil }
        
        let date1 = Date() // 현재 시각
        return calendar.dateComponents([.day, .hour, .minute, .second], from: date1, to: date2)
    }
}
  • 시간을 표현할 format을 설정할 때 dateFormatter를 만드는데 (ex. "AM 3:04", "Apr 28", "Friday, Apr 28, 2023" 등)
    이와 별개로 서버에서 받은 시간을 변환하는 용도로 ISO8601DateFormatter를 만들어두면 편리하다.
  • 서버에서 받은 종료시점 (date2)을 ISO8601DateFormatter를 활용하여 Date 타입으로 변환한다.
    현재 시각 (date1)은 Date()로 구한다.
  • 마지막으로 calendar의 dateComponents 메서드를 활용하여 2개 시간을 비교한다.
    이때 비교할 시간 단위를 설정할 수 있는데, 이번에는 '일/시/분/초' 단위로 지정했다.

2. Label에 원하는 format으로 시간 표시하기 

2개 시간을 비교한 결과를 Label에 표시해보자.

이번 예시에서는 아래처럼 "Ends in dd:hh:mm:ss" 형태로 표현해봤다.

참 쉽죠

 

방금 위에서 만들었던 remainTime 메서드에 로직을 추가했다.

일/시/분/초 단위의 시간 차이를 dateComponents 타입으로 만들었으니

각각을 두 자리의 숫자로 표시해주면 된다.

(최근 개발된 API를 활용할 수 있겠지만 귀찮아서.. 일단.. 이렇게..)

func formattedRemainTime(to dateText: String) -> String? {
    guard let date2 = date(from: dateText) else { return nil }

    let date1 = Date()
    let countdownComponents = calendar.dateComponents([.day, .hour, .minute, .second], from: date1, to: date2)

    guard let days = countdownComponents.day,
          let hours = countdownComponents.hour,
          let minutes = countdownComponents.minute,
          let seconds = countdownComponents.second else { return nil }

    // ✅ 두 자리 숫자로 표시
    return String(format: "%02d:%02d:%02d:%02d", days, hours, minutes, seconds)
}

3. Timer를 통해 1초마다 Label 업데이트하기

이제 카운트 다운을 하기 위해 Timer를 설정해주자.

final class FlashSaleUnitView: UIView {
    private var timer: Timer?
    private lazy var timeBadgeView: TimeBadgeView = createTimeBadgeView()
    
    // ✅ 타이머 설정
    func startTimer() {
        if timer?.isValid != true {
            let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
                self?.changeTimeText()
            }
            self.timer = timer
            RunLoop.main.add(timer, forMode: .common)
        }
    }
    
    // ✅ Label에 남은 시간 텍스트 반영
    private func changeTimeText() {
        // 서버에서 받은 종료시점 "2024-01-01T00:00:00+09:00"
        guard let dueDateText = viewModel.saleDueDate, 
              let remainTimeText = FlashSaleManager.shared.formattedRemainTime(to: dueDateText) else { return }
        
        timeBadgeView.setTime(remainTimeText)
    }
}
  • 타이머를 통해 1초마다 텍스트를 바꿔주면 된다.
  • 유저가 화면 스크롤할 때도 텍스트가 바뀌도록 main run loop에서 timer가 실행되도록 설정했다.
  • 서버에서 받은 할인종료 시점 saleDueDate을 위에서 만든 formattedRemainTime(to:)에 전달해서 남은 기간을 알아내고,
    Label에 반영하면 끝이다.

다른 화면으로 이동했을 때 timer를 정지시키고, 다시 재진입했을 때 실행시키는 로직도 잊지 말고 넣어주자.

timerDelegate를 통해 viewController의 viewWillDisappear/viewWillAppear 시점에 timerStop/timerStart를 호출하도록 했다.

deinit { // view deinit 시점에 호출
    stopTimer()
}

func stopTimer() { // viewController viewWillDisappear 시점에 호출
    if let timer,
       timer.isValid {
        timer.invalidate()
    }
}

 

🔍 참고) 여기까지 따라왔다면 한 가지 의문이 들 수 있다. 

'최초 한 번만 남은 시간을 계산하고, 이후에는 Timer로 -1초씩 해주면 되는 거 아닌가?'

 

그래서 찾아봤는데 카운트 다운할 때 Timer는 부정확하기 때문에 Date를 써야 한다고 한다.
SoF > Don't use NSTimer that way. NSTimer is normally used to fire a selector at some time interval. It isn't high precision (...) 

 

GCD Timer 등 정확도 높은 timer를 만드는 방법도 찾았지만..

Apple Technical Note에서도 비용이 크고, 단 1개 timer만 정확도를 높일 수 있기 때문에 지양하는 방식이라고 명시하고 있어서 패스했다.

(정확도 높은 timer 만들려면 Real time thread라는 녀석한테 일을 시켜야 하는데, 이거 과부하되면 다 망한다는 내용.. 아마도..)

SoF > high precision timer

Apple/TN2169 > Don't use a high precision timer unless you really need it. They consume compute cycles and battery. There can only be a limited number of high precsion timers active at once.

Apple/Dispatch Source > Note that some latency is to be expected for all timers, even when a leeway value of zero is specified.

4. 추가적인 개선사항

1) Label 너비 고정시키기

이 상태로도 좋지만 개선점을 찾아낸 분들이 있을 것 같다.

개인적으로 숫자가 바뀔 때마다 Label 너비가 늘었다 줄었다하는 게... 눈에 너무 거슬렸다.

늘었다가 줄었다가 하는 Label

 

아래처럼 숫자마다 텍스트 너비가 다르기 때문에 발생하는 문제이다.

최대 8 / 최소 1

 

다른 앱을 벤치마킹해봤을 때 

늘었다 줄었다하는 걸 그대로 내버려 둔 곳도 있었고, 너비에 고정값을 지정한 곳도 있었다.

 

이번 예시처럼 backgroundColor를 적용한 경우에는 너비를 고정하는 게 디자인상 깔끔하다고 판단해서

최대 너비를 지정하는 방법으로 개선해봤다.

// view 그릴 때
// ✅ 최대 너비인 숫자 텍스트를 활용
let maxWidthText = "88:48:48:48"
let fixedWidth = maxWidthText.width(font: .systemFont(ofSize: 13, weight: .bold)).ceiling(to: 0)

addSubview(timeLabel)
timeLabel.snp.makeConstraints { make in
    make.width.equalTo(fixedWidth)
}

// extension String
func width(font: UIFont) -> CGFloat {
    let fontAttributes = [NSAttributedString.Key.font: font]
    let size = self.size(withAttributes: fontAttributes)
    return size.width
}
  • 숫자가 "88:48:48:48"일 때 텍스트 너비가 최대가 되기 때문에 이 값을 활용했다.
  • 텍스트 중에는 88이 최대너비이고, 시간을 표현하는 숫자 중에서는 48이 가장 너비가 크므로
    일에는 88, 시/분/초에는 각각 48을 넣어서 텍스트 너비를 구했다.

이제 아래처럼 깔끔하다.

너비 고정 버전



2) 시간 초과된 케이스 처리하기

그리고 현재 시각이 할인 종료기간을 초과했을 때 어떻게 대응할지 고려해보면 좋다.

회사마다 할인전을 운영하는 정책과도 연관이 있을 것이다.

 

아마 할인기간이 종료되면 해당 Unit이 노출되지 않도록 서버에서 처리하겠지만

서버에서 잘못 내려준다면 숫자에 -가 붙게 된다.

서버 고장났을 때

아래처럼 클라이언트 로직에도 방어코드를 추가하면 기부니가 조커든요. ☺️

방어코드 있을 때

 

위에서 만든 formattedRemainTime(to:) 메서드에서 isExpired 여부를 bool 타입으로 반환하도록 개선해주면 된다.

    func formattedRemainTime(to dateText: String) -> (Bool, String)? {
        guard let date2 = date(from: dateText) else { return nil }
        
        let date1 = Date()
        // ✅ 종료시점 초과 여부를 판단
        let isExpired = calendar.compare(date1, to: date2, toGranularity: .second) == .orderedDescending
        let countdownComponents = calendar.dateComponents([.day, .hour, .minute, .second], from: date1, to: date2)
        
        guard let days = countdownComponents.day,
              let hours = countdownComponents.hour,
              let minutes = countdownComponents.minute,
              let seconds = countdownComponents.second else { return nil }
        
        // ✅ 종료된 경우 보여줄 텍스트 지정
        let remainTimeText = isExpired ? "00:00:00:00" : String(format: "%02d:%02d:%02d:%02d", days, hours, minutes, seconds)
        
        return (isExpired, remainTimeText)
    }

 

그리고 formattedRemainTime(to:) 메서드를 호출하는 시점에서

isExpired인 경우 timer를 종료하도록 추가해주자.

private func changeTimeText() {
    // 서버에서 받은 종료시점 "2024-01-01T00:00:00+09:00"
    guard let dueDateText = viewModel.saleDueDate, 
          let remainTimeText = FlashSaleManager.shared.formattedRemainTime(to: dueDateText) else { return }

    timeBadgeView.setTime(remainTimeText)

    // ✅ isExpired 이면 타이머 종료
    if isExpired {
        stopTimer()
    } 
}

2개의 날짜를 비교하고, 원하는 형태로 formatting 하고

timer를 사용하여 Label을 업데이트하는 과정을 거쳐서 Countdown Timer를 만들어봤다.

이커머스 뿐만 아니라 헬스케어, 영상/오디오 컨텐츠 앱에도 활용해 볼 수 있다.

 

로직 자체는 복잡하지 않으니 여러 가지 다른 디자인으로 만들어보면 재미있다.

카운트 다운할 때 애니메이션 효과를 넣는 코드도 구경해보기를 추천한다.

 

 

- Reference

 

🍎 포스트가 도움이 되었다면, 공감🤍 / 구독🍹 / 공유🔗 / 댓글✏️ 로 응원해주세요. 감사합니다.

Comments