StackDay. Date는 날짜가 아니다

StackDay는 일정 주기의 습관 수행 여부를 기록하는 앱이고, MVP에서는 그 주기를 매일로 고정했다. 지금까지 구현할 때, “어느 날에 수행했는가"와 “정확히 언제 기록했는가"를 모두 Foundation의 Date로 표현했다. 두 정보는 비슷해 보이지만 다르다. 이 차이를 타입으로 구분하지 않으면 날짜 검증, 중복 기록 검사, 통계 계산마다 같은 해석을 반복하게 된다. 이번 글에서는 그 문제가 실제 코드에서 어떻게 드러났고, LocalDay·Clock·TimeZone을 어떤 경계로 나눴는지 정리한다. Date타입이 저장하는 것은 순간 Date는 연, 월, 일이나 자정 같은 달력 개념을 저장하지 않는다. ...

August 18, 2026

StackDay. 비즈니스 규칙과 Entity Identity 분리하기

현재 StackDay MVP에서는 하나의 Habit에 대해 하루 한 번만 Completion을 기록할 수 있다. 그래서 처음에는 (habitID, completedOn) 조합을 Completion의 identity처럼 사용해도 될 것 같았고, 실제로 그런 코드도 있었다. 하지만 Completion 삭제를 구현하면서 이 둘을 분리해야 한다는 점을 알게 되었다. struct Completion: Equatable, Identifiable { let id: UUID let habitID: UUID let completedOn: Date let recordedAt: Date } 같은 날의 기록을 찾는 조건 같은 Habit의 같은 날 Completion을 찾는 데에는 habitID와 completedOn이 필요하다. 현재 정책에서는 이 조건으로 중복 기록도 막을 수 있다. 하지만 이것은 현재 정책에서 필요한 조회 조건이다. 이후 시간 단위로 습관을 기록하거나, 하루에 여러 번 수행하는 Habit이 생기면 같은 조합으로 여러 Completion이 존재할 수 있다. ...

August 16, 2026

StackDay. UseCase와 Entity는 각각 어디까지 책임져야 할까

CreateHabitUseCase를 시작으로 여러 유즈케이스를 구현하면서, 도메인 규칙을 어디에 두어야 할지 계속 고민하게 됐다. Habit을 생성할 때 이름의 유효성을 누가 검증해야 하는지, Completion을 남길 때 해당 날짜에 기록할 수 있는지는 누가 판단해야 하는지처럼 비슷한 문제가 반복해서 나타났다. 처음에는 유즈케이스가 사용자의 작업을 처리하니 이런 검증도 함께 담당하면 된다고 생각할 수 있었다. 하지만 구현을 진행할수록 엔티티가 스스로 보장해야 하는 규칙과 유즈케이스 작업의 흐름을 위해 판단해야 하는 규칙을 구분할 필요가 있었다. 이 포스트에서는 Habit의 생성과 Completion 기록을 중심으로 어떤 고민을 했는지와 어떻게 해결했는지를 정리한다. ...

August 14, 2026

StackDay. Repository의 save를 insert와 update로 나누기

유즈케이스를 구현하면서 Habit과 Completion을 저장할 Repository 계약을 좀 정리했다. 처음에는 create와 update 둘 다 save 하나로 저장하면 간단해 보였지만, 두 기능이 기대하는 실패 조건이 서로 다르다는 점에서 계약을 나눌 필요가 있었다. save는 어떤 동작인가 save를 upsert로 구현하면 호출하는 쪽은 편하다. ID가 없으면 삽입하고, 이미 있으면 갱신하면 된다. protocol HabitRepository { func save(_ habit: Habit) async throws } 문제는 이 동작이 실패해야 하는 상황까지 조용히 성공으로 바꾼다는 점이다. (물론 UUID라 현실적으로 겹칠 일은 없지만) Habit을 생성하는 작업은 같은 ID가 이미 있으면 실패해야 하고, 보관처럼 기존 Habit을 바꾸는 작업은 대상이 없으면 실패해야 한다. ...

August 14, 2026

StackDay. 책임 분리와 타입 분리

이전 작업에서 HabitStreak을 구현하면서 계산 결과와 계산 로직을 분리했다. HabitStreak은 현재 스트릭과 최장 스트릭이라는 값만 표현하고, 계산은 HabitStreakCalculator가 담당한다. struct HabitStreak: Equatable { let current: Int let longest: Int } struct HabitStreakCalculator { func calculate( habit: Habit, completions: [Completion], referenceDate: Date, calendar: Calendar = .current ) -> HabitStreak { // ... } } 같은 원리로 HabitStatistics도 파생 값과 계산 책임을 분리해서 구현했다. HabitStatistics HabitStatistics는 Habit의 수행 기록을 요약한 파생 값이다. MVP에서는 다음 값을 제공한다. struct HabitStatistics: Equatable { let totalCompletedDays: Int let eligibleTrackingDays: Int let completionRate: Double let streak: HabitStreak } totalCompletedDays는 추적 기간 안에서 완료한 날짜의 수이고, eligibleTrackingDays는 시작일부터 기준일까지 추적 대상이 된 날짜의 수다. 아카이브된 Habit은 archivedOn을 마지막 추적일로 사용한다. ...

August 13, 2026

StackDay. 엔티티와 계산 책임 분리하기

앞서 도메인, 데이터 모델링한 내용을 실제 코드로 옮기기 시작했다. 모델 자체의 구조는 이미 정해져 있었지만 코드로 구현하니 Habit Entry같은 파생 값들은 어디서 생성해야 하는지 책임이 정해지지 않았다. Completion 먼저 습관을 수행했다는 사실을 기록하는 Completion을 구현했다. struct Completion: Equatable { let id: UUID let habitID: UUID let completedOn: Date let recordedAt: Date } Completion은 저장되는 원본 데이터이므로 완료 사실을 표현하는 데 필요한 값만 가진다. 미래의 날짜인지, Habit의 시작일 이전인지, 같은 날짜의 Completion이 이미 존재하는지와 같은 검증은 Completion 하나만으로 판단할 수 없기 때문에 넣지 않았다. ...

August 12, 2026

StackDay. 데이터 모델링, Entry가 아닌 Completion을 관리해야 하는 이유

StackDay의 MVP 범위와 정책을 가지고 앱의 도메인과 데이터를 모델링한다. 도메인 모델링 -> 데이터 모델링 -> 유즈케이스 검증 순서로 진행하고, 각각의 단계가 이전 단계를 검증하는 방향이다. 앱의 핵심 동작 이전 포스트에서 정한 앱의 컨셉과 정책에서부터, 이 앱이 어떤 동작을 해야 할지 먼저 정리했다. 사용자가 습관을 만들면 그날부터 습관의 추적이 시작된다. 사용자는 매일 습관을 수행했는지 기록하고, 앱은 누적된 기록을 바탕으로 현재 스트릭과 통계를 보여준다. 더 이상 이어가지 않을 습관은 추적을 종료할 수 있다. ...

August 11, 2026

StackDay. 0. Concept

시작하기 애플 리마인더나 마이크로소프트 To do를 쓰면 매일 해야하는 일들을 관리할 수 있다. 나도 매일 LeetCode Daily를 풀고, Bing 출석체크를 하고, 매일 TIL 을 적으려고 한다. 하지만 기본적으로 Reminder, Todo 앱과 습관 형성 앱은 차이가 있다. 첫 번째로 기능 요구사항이 다르다. 애플 리마인더는 매일 반복해야 하는 태스크를 하지 않았을 경우에, 날짜가 새로 갱신되지 않고, 그 날짜에 계속 남아있는다. 마이크로소프트 To do는 하루를 빠뜨리면 그 태스크가 사라지지 않고 누적된다. 내가 원하는 습관 형성 앱은 리마인더의 방식, 마이크로소프트 투 두의 방식 등등을 포괄할 수 있는 앱이었다. ...

August 8, 2026

SniffMEET. Xcode 프로젝트 파일 정리하기

작업 내역 Xcode에서 프로젝트 파일 관련 경고가 많이 떠서 정리했다. 작업 내역 null 파일 제거 320043702CDC9A0D00D08B6D /* (null) in Sources */ = {isa = PBXBuildFile; }; 320043722CDC9E5F00D08B6D /* (null) in Sources */ = {isa = PBXBuildFile; }; 320043762CDCA18E00D08B6D /* (null) in Sources */ = {isa = PBXBuildFile; }; 320043782CDCA49100D08B6D /* (null) in Sources */ = {isa = PBXBuildFile; }; 3200437C2CDCA6F300D08B6D /* (null) in Sources */ = {isa = PBXBuildFile; }; 이전에 있었던 구조 변경이나, git 충돌/머지 등 다양한 이유로 프로젝트 파일 안에 (null) 파일을 참조하는 라인이 많이 있었다. ...

June 5, 2025

SniffMEET. Diffable Data Source에서 이미지가 갱신되지 않은 이유

들어가며 SniffMeet의 메이트 리스트 화면은 UITableViewDataSource를 직접 구현해서 셀을 구성하고 있었다. 이후 테이블 뷰와 컬렉션 뷰에서 Diffable DataSource를 사용해보기로 했고, 먼저 메이트 리스트 화면에 적용해보려고 했다. 처음에는 어렵지 않을 거라고 생각했다. 섹션과 아이템 타입을 만들고, snapshot을 적용하면 기존 reloadData()보다 깔끔하게 업데이트할 수 있을 것 같았다. 하지만 실제로는 이미지 갱신이 제대로 동작하지 않았다. 더 정확히는 프로필 이미지 데이터는 받아오는데, Diffable DataSource가 셀을 다시 구성할 때는 이미지가 계속 nil로 남아 있었다. 결국 이 작업은 성공적으로 마무리하지 못했다. 그래도 실패한 이유를 다시 정리해보니, 문제는 Diffable DataSource API 자체가 아니라 item의 identity와 표시 상태를 제대로 구분하지 못한 데 있었다. ...

January 20, 2025