캘린더 앱의 시간대: 우리가 틀렸던 것들

종일 일정은 몇 시간인가, 매주 월요일 9시는 서머타임이 바뀌면 몇 시인가. WhenWeave를 만들며 부딪힌 시간 문제와 그때마다 정한 규칙.

whenweave시간대설계

캘린더 앱을 만들면 시간에 대해 알고 있다고 생각했던 것들이 하나씩 틀립니다. WhenWeave에서 부딪힌 것들과, 그때마다 정한 규칙을 적어둡니다. 도쿄에 사는 사람과 서울에 사는 사람이 같은 앱을 쓰고, 그중 한 명이 뉴욕으로 출장을 가는 상황을 상정했습니다.

규칙 1. 순간과 벽시계는 다르다

“9월 17일 오전 9시”는 시간이 아닙니다. 어디의 9시인지가 빠져 있습니다. WhenWeave는 모든 일정을 두 가지로 저장합니다. 시작과 끝의 순간(UTC 기준 timestamptz)과, 그 일정이 작성된 시간대(Asia/Tokyo 같은 IANA 이름). 순간은 계산용이고 시간대는 표시용입니다. 기기의 시간대가 곧 일정의 시간대라고 가정하지 않습니다. 출장 중에 만든 일정은 출장지 시간대로 저장되고, 돌아와서 보면 내 시간대로 환산되어 보입니다.

한 가지 함정이 있었습니다. 에뮬레이터에서 앱을 처음 실행했을 때 기기 시간대가 GMT로 잡혔습니다. GMT는 IANA 시간대 이름이 아니라서 서버가 거부했고, 앱은 “자동으로 설정할 수 없었다”며 수동 선택 화면을 띄웠습니다. 이건 버그가 아니라 설계대로 동작한 것이었지만, 그런 기기가 실제로 있다는 걸 그날 알았습니다.

규칙 2. 종일 일정은 24시간이 아니다

“9월 17일 종일”을 어떻게 저장할까. 처음엔 그날 0시부터 24시간으로 저장했습니다. 그런데 서머타임이 시작되는 날은 하루가 23시간이고, 끝나는 날은 25시간입니다. 그래서 종일 일정은 날짜 경계로 저장합니다. 시작은 그 날짜의 현지 자정, 끝은 다음 날짜의 현지 자정. 경과 시간은 고정하지 않습니다. 데이터베이스는 “시작이 끝보다 앞”이라는 것만 검사하고, 자정 경계인지는 애플리케이션이 검사합니다.

규칙 3. 반복 규칙은 무한히 펼치지 않는다

“매주 월요일”은 일정이 하나지만 화면에는 여러 개가 보입니다. 우리는 이 인스턴스들을 저장하지 않습니다. 질문이 올 때마다(“9월 1일부터 10월 12일까지”) 그 범위만큼만 펼칩니다. 저장하면 규칙을 바꿀 때마다 수천 행을 다시 써야 하고, 끝이 없는 반복은 어차피 저장할 수 없습니다.

펼칠 때 기준은 일정의 시간대입니다. 뉴욕의 “매주 월요일 9시”는 3월 둘째 주에 서머타임이 시작되면 UTC로는 한 시간 당겨지지만, 뉴욕 벽시계로는 계속 9시입니다. 우리 테스트에는 이 주가 반드시 들어갑니다. 2026년 3월 8일 일요일이 그날입니다.

규칙 4. “이번만”은 예외 행이다

매주 회의를 이번 주만 30분 미루려면 어떻게 하는가. 시리즈를 둘로 쪼개는 방법도 있지만, 우리는 예외 행을 씁니다. 원래 규칙이 만들었을 순간(originalStartAt)을 열쇠로, “이 인스턴스는 취소” 또는 “이 인스턴스는 이 시각으로 이동”을 한 줄 저장합니다. 펼친 뒤, 자르기 전에 예외를 적용합니다. 옮겨진 인스턴스는 열쇠를 그대로 갖고 있어서, 질문 범위 밖으로 나갔다가 다음 범위에 다시 나타날 수도 있습니다.

“이번부터 계속”은 다릅니다. 이건 예외가 아니라 정말로 시리즈를 둘로 나눕니다. 앞 시리즈는 그 직전에 끝나고(UNTIL), 뒤 시리즈는 새 일정으로 시작합니다. 공유 설정은 복사됩니다. 이렇게 하면 기존의 모든 읽기 코드가 두 시리즈를 그냥 두 개의 보통 일정으로 다룰 수 있습니다.

규칙 5. 시계는 하나만

서버(Cloudflare Workers)의 시계와 데이터베이스(PostgreSQL)의 시계는 다릅니다. 게다가 Workers 안에서는 Date.now()가 I/O가 있을 때만 앞으로 갑니다. 초대 코드의 만료 시각을 서버 시계로 계산하고 데이터베이스 시계로 검사하면, 둘 중 빠른 쪽만큼 유효 기간이 늘거나 줄어듭니다. 그래서 만료·수정·삭제 시각은 전부 데이터베이스의 now()로 찍습니다. API 코드에 new Date()는 남아 있지 않습니다.

규칙 6. 두 사람의 빈 시간은 각자의 낮으로 계산한다

FindWhen은 “평일 9시부터 22시 사이”처럼 시간대를 정해 검색합니다. 이 “9시”는 각자의 시간대에서의 9시입니다. 도쿄 사람의 낮과 서울 사람의 낮은 겹치지만, 뉴욕 출장 중인 사람의 낮은 겹치지 않을 수 있습니다. 결과에는 “상대의 공유 범위 안에서만 계산했다”는 표시가 같이 나갑니다. 상대가 빈 시간 공유를 켜지 않았다면, 우리가 아는 것만으로 계산했다고 정직하게 말하는 편이 낫습니다.


이 규칙들은 전부 테스트에 있습니다. 서머타임 전환일, 자정을 넘는 일정, 한 해 마지막 주, 시간대가 다른 두 사람. 캘린더 앱에서 “대충 맞는” 시간은 없습니다. 틀린 시간에 나타난 약속은 안 나타난 약속보다 나쁩니다.

Back to the blog