완성 프로젝트 - 습관 기록 엔진
이 장에서 배우는 것
앞의 장들에서 상수와 변수, 컬렉션, 제어 흐름, 옵셔널, 함수, 구조체, 열거형, 프로토콜, 오류 처리를 하나씩 익혔다. 이 장에서는 그 도구를 모아 습관 기록 앱의 계산 부분을 하나의 프로그램으로 완성한다. 화면은 만들지 않는다. 물 마시기·운동·독서 기록을 받아 연속 달성일을 계산하고, 한 주의 보고서를 글자로 출력한다. 마지막에는 이 코드를 나중에 SwiftUI 화면과 어떻게 이어 붙이는지, 코드가 어느 자리에 놓이는지를 짚는다.
- 습관·기록·통계를 값 타입으로 나누어 설계한다.
- Calendar 에 시간대를 고정해, 같은 날의 기록이 같은 날로 묶이게 한다.
- 오늘 아직 목표를 못 채운 경우까지 고려해 연속 달성일을 계산한다.
- 주간 보고서를 항상 같은 순서와 모양으로 출력한다.
- 화면 코드와 로직 코드의 경계가 어디인지 설명한다.
문제 상황
습관 앱을 처음 만들 때 흔히 이런 순서로 일이 진행된다. 화면부터 그리고, 버튼을 누르면 숫자가 올라가게 만든다. 며칠 뒤 "연속 7일 달성" 배지를 넣자는 요구가 온다. 계산 코드를 화면 안에 써 두었다면 이때부터 곤란해진다. 계산을 확인하려면 앱을 실행하고 버튼을 일곱 번 눌러야 한다. 어제와 오늘의 경계를 확인하려면 휴대폰 시계를 바꿔야 한다.
날짜에서 더 자주 사고가 난다. 새벽 한 시에 물을 마신 기록이 어느 날에 속하는지, 해외에서 기기의 시간대가 바뀌면 연속 기록이 끊기는지 같은 문제다. 사용자는 이런 오류를 한 번만 겪어도 앱을 신뢰하지 않는다.
해결 방향은 단순하다. 계산 코드를 화면과 분리해 입력(기록)과 출력(숫자와 글자)만 있는 형태로 만든다. 그러면 터미널에서 몇 줄만 돌려 보고 결과를 눈으로 검증할 수 있다. 이 책이 처음부터 화면 없이 로직만 키워 온 이유가 이것이다.
타입 설계와 날짜 다루기
타입을 나누는 기준
먼저 "무엇이 변하지 않고, 무엇이 쌓이는가"를 가른다. 습관의 이름과 하루 목표는 한 번 정하면 거의 바뀌지 않는다. 기록은 하루에도 여러 번 쌓인다. 통계는 기록에서 언제든 다시 계산할 수 있는 값이다. 다시 계산할 수 있는 값은 저장하지 않는 편이 안전하다. 저장해 두면 기록과 어긋날 수 있기 때문이다.
| 타입 | 맡은 일 | 가진 값 | 바뀌는가 |
|---|---|---|---|
| Habit | 습관의 정의 | id, 이름, 하루 목표, 단위 | 만든 뒤 바뀌지 않는다 |
| Entry | 기록 한 건 | 습관 id, 날짜, 양 | 만든 뒤 바뀌지 않는다 |
| WeekStats | 한 주 요약 | 날짜별 달성 표시, 합계, 연속 일수 | 계산할 때마다 새로 만든다 |
| HabitEngine | 기록 저장과 계산 | 습관 목록, 기록 목록, 달력 | 기록이 추가될 때 바뀐다 |
모두 구조체다. 앞 장에서 다룬 것처럼 구조체는 값 타입이라 복사해도 원본에 영향을 주지 않는다. 계산 결과를 담는 WeekStats 같은 타입이 도중에 바뀔 걱정이 없다는 뜻이다. 클래스는 필요하지 않다.
그림처럼 의존은 한 방향으로만 흐른다. Habit 과 Entry 는 다른 타입을 모른다. HabitEngine 이 둘을 받아 쓰고, 날짜 계산은 DayCalendar 에 맡긴다. 한 타입이 한 가지 일만 하므로 고칠 곳이 어디인지 바로 알 수 있다.
Habit 의 초기화 코드에는 max(1, dailyGoal) 이 들어 있다. 하루 목표가 0 이하이면 "아무것도 안 해도 달성"이 되어 연속 계산이 끝나지 않을 수 있다. 값이 만들어지는 순간에 막아 두면 이후의 모든 계산이 그 가정을 믿고 쓸 수 있다.
날짜는 Calendar 에 시간대를 고정해서 다룬다
Swift 의 Date 는 "어느 한 순간"을 나타낸다. 그 순간이 몇 월 며칠인지는 시간대(time zone)에 따라 달라진다. 한국 시간 7시 30분은 협정 세계시(UTC)로는 전날 22시 30분이다. 기록을 "날짜"별로 묶으려면 어느 시간대 기준인지 정해야 한다.
Calendar.current 는 기기 설정을 따른다. 편하지만 기기 설정이 바뀌면 같은 기록이 다른 날로 계산된다. 테스트하는 컴퓨터마다 결과가 달라지기도 한다. 그래서 이 장에서는 Calendar 를 직접 만들고 시간대를 한국 표준시(UTC+9, 3600초 단위로 9시간)로 고정한다. 한국에는 일광절약시간제가 없으므로 고정 오프셋이면 충분하다. 시간대 이름 데이터가 없는 환경에서도 같은 결과가 나온다는 장점이 있다.
| 하고 싶은 일 | 사용하는 기능 | 주의할 점 |
|---|---|---|
| 순간을 그날 0시로 바꾸기 | startOfDay(for:) | 기록을 저장할 때 한 번 바꾼다 |
| 며칠 더하거나 빼기 | date(byAdding:value:to:) | 초 단위로 직접 계산하지 않는다 |
| 월·일·요일 꺼내기 | component(_:from:) | 요일은 일요일이 1 이다 |
| 연월일로 날짜 만들기 | date(from:) | 실패하면 nil 을 돌려준다 |
이 기능들을 DayCalendar 라는 작은 구조체에 모아 둔다. 엔진의 나머지 코드는 Foundation 의 Calendar 를 직접 만지지 않고 "며칠 뒤", "그 주의 월요일" 같은 우리 말로 묻는다. 시간대나 한 주의 시작 요일을 바꾸고 싶으면 이 구조체만 고치면 된다.
한 주의 시작은 월요일로 정한다. 요일 번호는 일요일이 1, 월요일이 2, … 토요일이 7 이다. 월요일로부터 며칠 지났는지는 (요일 번호 + 5) % 7 로 구한다. 월요일은 7 % 7 = 0, 수요일은 9 % 7 = 2, 일요일은 6 % 7 = 6 이다.
자세한 동작은 Calendar 공식 문서에서 확인할 수 있다.
연속 달성일 계산과 주간 보고서
하루 합계로 바꾸기
같은 날 기록이 여러 건일 수 있다. 물은 아침에 3잔, 저녁에 5잔을 마실 수 있다. 그래서 먼저 습관 하나의 기록을 "날짜 → 그날 합계" 딕셔너리로 만든다. 날짜를 그날 0시로 맞춰 저장했기 때문에, 같은 날의 기록은 같은 키에 더해진다. 키가 없는 날은 default: 0 으로 0 을 돌려받는다. 기록이 없는 날은 0 이라고 보는 것이다.
현재 연속 달성일
연속 달성일은 "오늘부터 거꾸로 가며 목표를 채운 날이 며칠 이어지는가"이다. 여기에는 판단이 하나 필요하다. 지금이 저녁 8시이고 오늘 아직 물을 다 못 마셨다면 연속 기록은 0 일까. 하루가 끝나지 않았으므로 아직 끊긴 것이 아니다. 이 책의 규칙은 이렇다.
- 오늘 목표를 채웠으면 오늘부터 센다.
- 오늘 아직 못 채웠으면 어제부터 센다. 오늘은 진행 중이므로 연속을 끊지 않는다.
- 목표 미달인 날을 만나면 거기서 센다.
그림은 예제 데이터의 물 마시기다. 오늘(10월 11일)은 5잔이라 목표 8잔에 못 미친다. 그래서 어제인 10월 10일부터 거꾸로 센다. 10월 4일까지 7일 동안 목표를 채웠고, 10월 3일(6잔)에서 멈춘다.
가장 긴 연속 달성일
최장 기록은 방향이 다르다. 목표를 채운 날만 모아 날짜순으로 정렬한 뒤 앞에서부터 훑는다. 앞 날짜의 다음 날이면 현재 연속을 1 늘리고, 아니면 1 로 다시 시작한다. 지금까지의 최댓값을 따로 기억한다. 딕셔너리에서 꺼낸 키는 순서가 정해져 있지 않으므로 sorted() 가 반드시 필요하다.
주간 보고서
보고서는 월요일부터 일요일까지 7일을 만들고, 습관마다 WeekStats 를 계산해 한 줄씩 출력한다. 습관 목록은 배열이므로 순서가 정해져 있고, 날짜도 배열이다. 딕셔너리를 그대로 순회해 출력하지 않기 때문에 실행할 때마다 같은 글자가 나온다. 이것이 나중에 "출력이 같은가"로 로직을 검사할 수 있게 하는 조건이다.
앱 화면과 연결할 때 이 코드의 자리
이 장의 코드는 SwiftUI 를 쓰지 않는다. 그런데 화면을 붙일 때 이 코드를 고칠 필요가 거의 없다. 화면은 세 가지 일만 하면 된다. 사용자의 입력을 record 로 넘기고, 계산 결과를 받아 보여 주고, 결과가 바뀌면 다시 그린다. 계산과 규칙은 모두 엔진 안에 있다.
아래는 연결 방식을 보여 주는 개념 스케치다. 이 책의 컴파일 대상이 아니며, Xcode 의 앱 프로젝트에서 쓰는 코드다.
import SwiftUI
import Observation
@Observable
final class HabitStore {
var engine: HabitEngine
init(engine: HabitEngine) {
self.engine = engine
}
func record(_ habit: Habit, amount: Int) {
engine.record(habit, at: Date(), amount: amount)
}
}
struct ReportView: View {
let store: HabitStore
var body: some View {
let today = Date()
Text(store.engine.weeklyReport(weekContaining: today, today: today))
.font(.system(.body, design: .monospaced))
}
}
화면 쪽 클래스는 엔진을 소유하고 변화를 알리는 일만 한다. 눈여겨볼 점은 Date() 를 그대로 넘긴다는 것이다. 현재 시각이 어느 날에 속하는지는 엔진의 DayCalendar 가 정한다. 화면은 시간대를 몰라도 된다. 디스크 저장이나 서버 동기화가 필요해지면 Entry 목록을 저장하는 계층이 엔진 옆에 하나 더 생길 것이다. 그때도 엔진은 입력으로 받은 기록만 계산하면 된다.
완성 코드
아래를 main.swift 한 파일로 저장한다. 표준 입력은 읽지 않고, 실행하면 보고서를 출력하고 끝난다.
import Foundation
// 날짜: 시간대를 고정한 달력
struct DayCalendar {
private let calendar: Calendar
init() {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 9 * 3600) ?? .gmt
self.calendar = calendar
}
func moment(_ year: Int, _ month: Int, _ day: Int, hour: Int = 0, minute: Int = 0) -> Date {
let parts = DateComponents(year: year, month: month, day: day, hour: hour, minute: minute)
return calendar.date(from: parts) ?? Date(timeIntervalSince1970: 0)
}
func startOfDay(_ moment: Date) -> Date {
calendar.startOfDay(for: moment)
}
func addingDays(_ count: Int, to day: Date) -> Date {
calendar.date(byAdding: .day, value: count, to: day) ?? day
}
func mondayOfWeek(containing day: Date) -> Date {
let weekday = calendar.component(.weekday, from: day)
let daysSinceMonday = (weekday + 5) % 7
return addingDays(-daysSinceMonday, to: startOfDay(day))
}
func weekdayName(_ day: Date) -> String {
let names = ["일", "월", "화", "수", "목", "금", "토"]
return names[calendar.component(.weekday, from: day) - 1]
}
func label(_ day: Date) -> String {
let month = calendar.component(.month, from: day)
let date = calendar.component(.day, from: day)
return "\(twoDigits(month))/\(twoDigits(date))(\(weekdayName(day)))"
}
private func twoDigits(_ number: Int) -> String {
number < 10 ? "0\(number)" : "\(number)"
}
}
// 타입: 습관, 기록, 주간 통계
struct Habit {
let id: Int
let name: String
let dailyGoal: Int
let unit: String
init(id: Int, name: String, dailyGoal: Int, unit: String) {
self.id = id
self.name = name
self.dailyGoal = max(1, dailyGoal)
self.unit = unit
}
}
struct Entry {
let habitID: Int
let day: Date
let amount: Int
}
struct WeekStats {
let habit: Habit
let marks: [Bool]
let total: Int
let currentStreak: Int
let longestStreak: Int
var achievedDays: Int {
marks.filter { $0 }.count
}
}
// 엔진: 기록을 쌓고 계산한다
struct HabitEngine {
let calendar: DayCalendar
let habits: [Habit]
private var entries: [Entry] = []
init(calendar: DayCalendar, habits: [Habit]) {
self.calendar = calendar
self.habits = habits
}
var entryCount: Int {
entries.count
}
mutating func record(_ habit: Habit, at moment: Date, amount: Int) {
guard amount > 0 else { return }
entries.append(Entry(habitID: habit.id, day: calendar.startOfDay(moment), amount: amount))
}
mutating func recordDaily(_ amounts: [Int], for habit: Habit, startingAt first: Date) {
for (offset, amount) in amounts.enumerated() {
record(habit, at: calendar.addingDays(offset, to: first), amount: amount)
}
}
func dailyTotals(of habit: Habit) -> [Date: Int] {
var result: [Date: Int] = [:]
for entry in entries where entry.habitID == habit.id {
result[entry.day, default: 0] += entry.amount
}
return result
}
func currentStreak(of habit: Habit, today: Date) -> Int {
let totals = dailyTotals(of: habit)
var cursor = today
if totals[cursor, default: 0] < habit.dailyGoal {
cursor = calendar.addingDays(-1, to: cursor)
}
var count = 0
while totals[cursor, default: 0] >= habit.dailyGoal {
count += 1
cursor = calendar.addingDays(-1, to: cursor)
}
return count
}
func longestStreak(of habit: Habit) -> Int {
let totals = dailyTotals(of: habit)
let achieved = totals.filter { $0.value >= habit.dailyGoal }.keys.sorted()
var best = 0
var run = 0
var previous: Date? = nil
for day in achieved {
if let previous, calendar.addingDays(1, to: previous) == day {
run += 1
} else {
run = 1
}
best = max(best, run)
previous = day
}
return best
}
func weekStats(of habit: Habit, days: [Date], today: Date) -> WeekStats {
let totals = dailyTotals(of: habit)
let marks = days.map { totals[$0, default: 0] >= habit.dailyGoal }
let total = days.reduce(0) { $0 + totals[$1, default: 0] }
return WeekStats(
habit: habit,
marks: marks,
total: total,
currentStreak: currentStreak(of: habit, today: today),
longestStreak: longestStreak(of: habit)
)
}
func weeklyReport(weekContaining day: Date, today: Date) -> String {
let monday = calendar.mondayOfWeek(containing: day)
let days = (0..<7).map { calendar.addingDays($0, to: monday) }
let sunday = days[6]
var lines = ["주간 보고서: \(calendar.label(monday)) ~ \(calendar.label(sunday))"]
lines.append("요일: " + days.map { calendar.weekdayName($0) }.joined(separator: " "))
let allStats = habits.map { weekStats(of: $0, days: days, today: today) }
for stats in allStats {
let marks = stats.marks.map { $0 ? "●" : "○" }.joined(separator: " ")
lines.append(
"\(stats.habit.name) | \(marks) | 합계 \(stats.total)\(stats.habit.unit)"
+ " | 달성 \(stats.achievedDays)/\(days.count)일"
+ " | 연속 \(stats.currentStreak)일 (최장 \(stats.longestStreak)일)"
)
}
if let best = allStats.max(by: { $0.achievedDays < $1.achievedDays }) {
lines.append("가장 꾸준한 습관: \(best.habit.name) (\(best.achievedDays)/\(days.count)일)")
}
return lines.joined(separator: "\n")
}
}
// 실행: 예제 기록을 넣고 보고서를 출력한다
let calendar = DayCalendar()
let water = Habit(id: 1, name: "물 마시기", dailyGoal: 8, unit: "잔")
let exercise = Habit(id: 2, name: "운동", dailyGoal: 30, unit: "분")
let reading = Habit(id: 3, name: "독서", dailyGoal: 20, unit: "쪽")
var engine = HabitEngine(calendar: calendar, habits: [water, exercise, reading])
let firstDay = calendar.moment(2026, 10, 1)
engine.recordDaily([8, 8, 6, 8, 8, 8, 9, 8, 8, 8, 3], for: water, startingAt: firstDay)
engine.recordDaily([30, 0, 45, 30, 0, 25, 30, 0, 35, 30, 30], for: exercise, startingAt: firstDay)
engine.recordDaily([10, 25, 30, 20, 0, 15, 20, 22, 0, 20, 20], for: reading, startingAt: firstDay)
// 같은 날의 추가 기록: 시각이 달라도 같은 날 합계에 더해진다
engine.record(water, at: calendar.moment(2026, 10, 11, hour: 21, minute: 40), amount: 2)
engine.record(exercise, at: calendar.moment(2026, 10, 6, hour: 7, minute: 30), amount: 15)
let today = calendar.moment(2026, 10, 11)
print("습관 \(engine.habits.count)개, 기록 \(engine.entryCount)건")
print("")
print(engine.weeklyReport(weekContaining: today, today: today))
줄별 해설
DayCalendar
init 안의 지역 변수 calendar 는 같은 이름의 저장 프로퍼티를 가린다. 지역 변수에 시간대를 설정한 뒤 self.calendar 에 한 번만 대입하므로, 프로퍼티는 let 으로 선언할 수 있다. TimeZone(secondsFromGMT:) 는 옵셔널을 돌려주기 때문에 ?? .gmt 로 기본값을 둔다. 실제로는 9시간이 항상 유효하므로 기본값은 쓰이지 않는다.
moment 는 연월일시분으로 Date 를 만든다. 시각을 생략하면 0시 0분이다. 날짜 조합이 잘못되어 nil 이 나오면 1970년 기준 값을 돌려주는데, 예제는 항상 올바른 날짜만 넣는다. addingDays 도 같은 방식으로 nil 일 때 원래 날짜를 돌려준다.
mondayOfWeek 는 앞서 설명한 (요일 번호 + 5) % 7 로 월요일까지의 거리를 구해 그만큼 뺀다. weekdayName 의 배열은 일요일부터 시작하므로 요일 번호에서 1 을 뺀 자리를 읽는다. label 은 "10/05(월)" 모양으로 월과 일을 두 자리로 맞춘다.
타입 정의
Habit 의 사용자 정의 초기화는 하루 목표를 1 이상으로 보정한다. Entry 는 이미 그날 0시로 정리된 날짜를 가진다. WeekStats 의 achievedDays 는 저장하지 않고 marks 에서 계산하는 프로퍼티다. 값이 두 곳에 있지 않으니 어긋날 일이 없다.
HabitEngine
entries 는 private 이라 밖에서는 record 로만 추가할 수 있다. 외부에서 임의의 값을 끼워 넣지 못하는 것이 장점이다. record 의 guard amount > 0 else { return } 는 0 이하의 양을 조용히 버린다. recordDaily 가 0 을 그대로 넘겨도 되는 것은 이 guard 덕분이다.
dailyTotals 는 해당 습관의 기록만 골라 날짜별로 더한다. result[entry.day, default: 0] += entry.amount 한 줄이 "키가 없으면 0 에서 시작해 더한다"를 표현한다.
currentStreak 의 첫 if 가 오늘 미달이면 어제로 옮기는 규칙이다. 이어지는 while 은 목표를 채운 날이 이어지는 동안 하루씩 거슬러 올라가며 센다. 기록이 없는 날은 0 이므로 목표(1 이상)보다 작아 반복이 끝난다. 앞서 목표를 1 이상으로 보정한 것이 여기서 종료를 보장한다.
longestStreak 의 if let previous, … == day 는 옵셔널 바인딩과 조건을 쉼표로 이은 것이다. 앞 날짜가 있고 그 다음 날이 지금 날짜일 때만 run 을 늘린다. previous 는 첫 반복에서 nil 이므로 else 로 가서 1 이 된다.
weekStats 는 7일 각각의 달성 여부를 marks 로, 7일 합계를 total 로 만든다. weeklyReport 는 월요일에서 7일을 만들어 습관 순서대로 한 줄씩 붙이고, 달성 일수가 가장 많은 습관을 마지막 줄에 적는다. 문자열 연결의 + 로 긴 한 줄을 세 줄로 나누었다.
실행부
예제 데이터는 10월 1일부터 11일까지 열하루다. 물은 10월 3일에 6잔으로 목표를 못 채웠고, 오늘인 11일에는 3잔에 저녁 추가 2잔을 더해 5잔이다. 운동의 추가 기록은 6일 아침 7시 30분이다. UTC 로 계산하면 5일 22시 30분이라 전날로 밀려난다. 시간대를 고정했기 때문에 이 15분은 6일 합계 25분에 더해져 40분이 된다. 이것이 시간대를 고정하는 이유를 보여 주는 장면이다.
실행 결과
$ swiftc main.swift -o app
$ ./app
습관 3개, 기록 30건
주간 보고서: 10/05(월) ~ 10/11(일)
요일: 월 화 수 목 금 토 일
물 마시기 | ● ● ● ● ● ● ○ | 합계 54잔 | 달성 6/7일 | 연속 7일 (최장 7일)
운동 | ○ ● ● ○ ● ● ● | 합계 165분 | 달성 5/7일 | 연속 3일 (최장 3일)
독서 | ○ ○ ● ● ○ ● ● | 합계 97쪽 | 달성 4/7일 | 연속 2일 (최장 3일)
가장 꾸준한 습관: 물 마시기 (6/7일)
손으로 확인해 보자. 독서는 10월 2일부터 4일까지 25, 30, 20쪽으로 3일 이어진 것이 최장이고, 오늘 기준으로는 10일과 11일 두 날이다. 9일이 0쪽이기 때문이다.
실무에서 자주 틀리는 것
Date() 를 그대로 날짜 키로 쓴다
Date 는 초 단위 순간이므로, 같은 날의 두 기록이 서로 다른 키가 된다.
// 틀린 코드: 시각까지 다른 키가 된다
result[moment, default: 0] += amount
// 고친 코드: 그날 0시로 맞춘 뒤 키로 쓴다
result[calendar.startOfDay(moment), default: 0] += amount
이 장의 엔진은 record 에서 한 번만 맞춰 저장하므로, 이후 계산은 신경 쓰지 않아도 된다.
하루를 86400초로 계산한다
하루가 항상 24시간은 아니다. 일광절약시간제가 있는 지역에서는 하루가 23시간이나 25시간인 날이 있다.
// 틀린 코드
let tomorrow = day.addingTimeInterval(24 * 60 * 60)
// 고친 코드: 달력에게 하루를 더해 달라고 한다
let tomorrow = calendar.date(byAdding: .day, value: 1, to: day) ?? day
딕셔너리 순회 순서에 기대어 출력한다
딕셔너리의 순회 순서는 정해져 있지 않다. 실행할 때마다 순서가 달라질 수 있어, 출력으로 검증하기 어렵고 연속 계산도 틀린다.
// 틀린 코드: 순서가 보장되지 않는다
for (day, amount) in totals {
print(day, amount)
}
// 고친 코드: 키를 정렬해서 순회한다
for day in totals.keys.sorted() {
print(day, totals[day, default: 0])
}
오늘 미달이면 연속을 바로 0 으로 한다
하루가 아직 끝나지 않았는데 연속이 0 이 되면 사용자는 아침마다 기록이 사라진 것처럼 느낀다.
// 틀린 코드: 오늘이 미달이면 곧바로 0
var cursor = today
var count = 0
while totals[cursor, default: 0] >= goal {
count += 1
cursor = calendar.addingDays(-1, to: cursor)
}
// 고친 코드: 오늘이 미달이면 어제부터 센다
var cursor = today
if totals[cursor, default: 0] < goal {
cursor = calendar.addingDays(-1, to: cursor)
}
한눈에 보기
| 주제 | 핵심 | 이 장의 코드 | 기억할 점 |
|---|---|---|---|
| 타입 설계 | 변하지 않는 값과 계산 값을 가른다 | Habit, Entry, WeekStats | 계산할 수 있는 값은 저장하지 않는다 |
| 날짜 | 시간대를 고정한 Calendar | DayCalendar | 저장할 때 그날 0시로 맞춘다 |
| 일 단위 이동 | 달력에게 하루를 더하게 한다 | addingDays | 86400초를 쓰지 않는다 |
| 연속 일수 | 오늘 미달이면 어제부터 센다 | currentStreak | 최장은 정렬한 뒤 앞에서부터 훑는다 |
| 출력 | 배열 순서로 만들어 항상 같은 글자 | weeklyReport | 딕셔너리는 sorted 로 순회한다 |
| 화면 연결 | 엔진은 계산만, 화면은 입력과 표시만 | HabitStore 스케치 | Date() 를 그대로 넘겨도 된다 |
연습 문제
- 주간 보고서의 습관 줄 끝에 달성률을 붙이려 한다. 달성 일수를 7 로 나눈 값을 정수 백분율로 구하는 식을 쓰고, 예제 데이터의 세 습관 값을 구하라.
mondayOfWeek에서 일요일(요일 번호 1)과 목요일(요일 번호 5)이 각각 월요일로부터 며칠 떨어진 것으로 계산되는지 구하라.- 물 마시기 기록에서 10월 10일이 5잔으로 바뀌었다고 하자. 오늘(11일)이 5잔인 상태에서 현재 연속 달성일과 최장 연속 달성일은 각각 얼마인가.
- HabitEngine 을 SwiftUI 의 View 구조체 안에서 매번 새로 만들면 왜 문제가 되는지 두 문장으로 설명하라.
정답과 해설
- 식은
achievedDays * 100 / 7이다. 정수 나눗셈이라 소수점 아래는 버려진다. 물은 6 * 100 / 7 = 85, 운동은 5 * 100 / 7 = 71, 독서는 4 * 100 / 7 = 57 이다. 이 장의 규칙대로 7 대신days.count를 쓰면 하루 수를 바꿀 때도 고칠 곳이 없다. - 일요일은 (1 + 5) % 7 = 6 이므로 6일 전이 월요일이다. 목요일은 (5 + 5) % 7 = 3 이므로 3일 전이다. 일요일을 한 주의 끝으로 보는 규칙이 6 이라는 값으로 드러난다.
- 오늘 5잔은 미달이므로 어제인 10일부터 센다. 그런데 10일도 5잔이라 미달이어서 바로 멈추고 현재 연속은 0 이다. 최장은 10월 4일부터 9일까지의 6일이다. 10월 1일부터 2일까지는 2일이므로 6 이 최댓값이 된다.
- View 구조체는 화면이 다시 그려질 때마다 새로 만들어질 수 있고, 그때마다 엔진이 새로 만들어지면 쌓아 둔 기록이 사라진다. 엔진은 화면의 수명과 별개로 유지되는 곳, 예를 들어 앞에서 본 HabitStore 같은 객체가 소유해야 한다.