육아앱 직접 만든 개발자의 회고 (3) — 야간모드와 테마 시스템 구현기
1. 들어가며: 다크모드만으로는 부족했다
대부분의 앱이 다크모드를 지원합니다. 그런데 새벽 3시에 어둠 속에서 앱을 켜봤을 때, 일반적인 다크모드도 꽤 밝다는 것을 느끼게 됩니다. 짙은 회색 배경에 파란색 포인트 컬러가 어둠 속에서는 충분히 눈을 자극합니다.
DailyBaby의 야간모드는 이 지점에서 출발했습니다. 단순히 배경을 어둡게 하는 것이 아니라, OLED 디스플레이에서 픽셀이 실제로 꺼지는 순수 블랙 배경과 따뜻한 색조의 UI 요소로 구성된 별도의 테마를 만들기로 했습니다. 눈의 피로를 줄이는 것은 물론, 겨우 재운 아기가 화면 불빛에 깨지 않도록 하는 것이 목표였습니다.
2. 세 개의 테마, 두 개의 어둠
DailyBaby는 세 가지 테마를 가지고 있습니다. 라이트, 다크, 나이트. 다만 라이트 테마는 초기 개발 시 만들어두었을 뿐, 실제로는 사용하지 않습니다. 육아앱의 주 사용 시간대를 고려하면 항상 어두운 테마가 기본이 되어야 한다고 판단했습니다.
핵심은 다크와 나이트의 차이입니다.
다크 테마:
배경: #17171C (짙은 회색)
텍스트: #ECECEC (밝은 회색)
포인트 컬러: #3182F6 (토스 블루)
밝기감: 약 70%
나이트 테마:
배경: #000000 (순수 블랙)
텍스트: #FF6B5B (따뜻한 레드-오렌지)
포인트 컬러: 따뜻한 톤 계열
밝기감: 약 50%
나이트 테마에서 가장 신경 쓴 부분은 색온도입니다. 블루라이트가 수면을 방해한다는 것은 잘 알려진 사실이기 때문에, 나이트 테마의 모든 컬러를 따뜻한 톤으로 전환했습니다. 기본 텍스트 색상조차 차가운 화이트가 아닌 따뜻한 레드-오렌지 계열로 변경했습니다.
3. 기록 유형별 색상 매핑 문제
DailyBaby에는 8가지 기록 유형이 있고, 각 유형마다 고유한 색상이 지정되어 있습니다. 수유는 파란색, 수면은 청록색, 기저귀는 보라색 같은 식입니다. 이 색상 체계는 홈 화면, 캘린더, 통계 차트 등 앱 전체에서 일관되게 사용됩니다.
문제는 나이트 모드에서 이 색상들을 어떻게 처리할 것인가였습니다. 밝은 파란색은 어둠 속에서 너무 강렬합니다. 그렇다고 모든 색상을 단순히 어둡게 만들면 기록 유형 간의 구분이 어려워집니다.
기록 색상 전략 (수도코드):
일반 기록 색상:
feeding: #4A90D9 (파랑)
sleep: #6ECBCE (청록)
diaper: #B39DDB (보라)
...
야간 기록 색상 (NightRecordColors):
feeding: 일반 색상의 채도 낮춤 + 밝기 50% 감소
sleep: 동일 규칙
diaper: 동일 규칙
...
색상 선택 로직:
IF colorScheme === "night"
→ NightRecordColors[recordType]
ELSE
→ RecordColors[recordType]
단순히 밝기만 낮추는 것이 아니라, 채도도 함께 조정했습니다. 채도를 유지한 채 밝기만 낮추면 색이 탁해 보이고, 반대로 채도까지 너무 낮추면 회색처럼 보여서 구분이 안 됩니다. 각 색상마다 수동으로 야간 버전을 조정해서 어둠 속에서도 유형이 구분되면서 눈에 부담이 없는 지점을 찾았습니다.
수면 기록의 네 가지 색상
수면 기록은 특히 복잡했습니다. 수면에는 "낮잠"과 "밤잠" 두 가지 유형이 있고, 각 유형을 색상으로 구분해야 합니다. 여기에 다크 모드와 나이트 모드 변형까지 합치면 조합이 네 가지가 됩니다.
수면 색상 매트릭스:
다크 테마 나이트 테마
낮잠(nap): 밝은 청록 어두운 청록
밤잠(night): 진한 남색 어두운 남색
통계 차트에서 낮잠과 밤잠을 스택 차트로 보여줄 때 이 구분이 특히 중요합니다. 나이트 모드에서도 두 색상의 차이가 명확하게 보여야 하므로, 밝기 차이를 충분히 두되 전체적인 톤은 따뜻하게 유지하는 방향으로 조정했습니다.
4. 자동 전환 스케줄링
야간모드를 매번 수동으로 켜고 끄는 것은 번거롭습니다. 밤 10시가 되면 자동으로 야간모드로 전환되고, 아침 8시가 되면 다크모드로 돌아오는 자동 전환 기능을 구현했습니다.
자정 경계 문제
시간 범위 비교는 간단해 보이지만, 자정을 걸치는 경우가 문제입니다. "22시부터 08시까지"라는 범위에서 현재 시간이 23시인지 01시인지에 따라 판단이 달라집니다. 단순히 start <= current && current < end 비교로는 이 경우를 처리할 수 없습니다.
자정 경계 처리 (수도코드):
function isInNightModeRange(시작시간, 종료시간):
현재분 = 현재시 × 60 + 현재분
시작분 = 시작시 × 60 + 시작분
종료분 = 종료시 × 60 + 종료분
IF 시작분 > 종료분:
// 자정을 넘기는 경우 (예: 22:00 ~ 08:00)
RETURN 현재분 >= 시작분 OR 현재분 < 종료분
ELSE:
// 같은 날 안의 범위 (예: 18:00 ~ 22:00)
RETURN 현재분 >= 시작분 AND 현재분 < 종료분
시간을 "분"으로 변환해서 비교하고, 시작 시간이 종료 시간보다 큰 경우(자정을 걸치는 경우)에는 OR 조건으로 처리합니다. 이 로직 하나가 "밤 10시부터 다음 날 아침 6시까지"를 정확하게 판단합니다.
세 가지 활성화 시점
자동 야간모드가 체크되는 시점은 세 곳입니다.
자동 야간모드 활성화 시점:
1. 앱 최초 실행 시
→ useEffect에서 현재 시간 확인 → 범위 안이면 야간모드 활성화
2. 앱이 백그라운드에서 포그라운드로 복귀할 때
→ AppState 'change' 이벤트 리스너
→ 잠들기 전에 앱을 열어두고, 새벽에 다시 보면 자동 전환됨
3. 사용자가 수동으로 토글할 때
→ 설정 화면에서 직접 켜기/끄기
2번이 특히 중요합니다. 현실적인 시나리오를 생각해보면, 밤 9시에 아기를 재우면서 앱을 사용하다가 같이 잠들고, 새벽 2시에 아기가 울어서 일어나 앱을 다시 켜는 경우가 빈번합니다. 이때 앱이 백그라운드에서 포그라운드로 전환되면서 자동으로 야간모드가 켜져야 합니다.
5. 테마 훅 설계: useColorScheme
테마를 컴포넌트에서 사용하는 인터페이스도 중요합니다. DailyBaby의 useColorScheme 훅은 매우 단순한 구조를 가지고 있습니다.
useColorScheme 훅 (수도코드):
function useColorScheme():
isNightMode = AppSettingsStore에서 가져오기
IF isNightMode → return "night"
ELSE → return "dark"
이 훅이 반환하는 값은 "dark" 또는 "night" 두 가지뿐입니다. 컴포넌트에서는 이 값을 키로 사용해서 Colors 객체에서 적절한 색상을 가져옵니다.
컴포넌트에서의 테마 사용 (수도코드):
function RecordItem(record):
colorScheme = useColorScheme() // "dark" 또는 "night"
theme = Colors[colorScheme] // 해당 테마의 전체 색상 객체
textColor = theme.text // 테마별 텍스트 색상
backgroundColor = theme.background // 테마별 배경 색상
// 기록 유형 색상
IF colorScheme === "night":
recordColor = NightRecordColors[record.type]
ELSE:
recordColor = RecordColors[record.type]
이 패턴의 장점은 새로운 컴포넌트를 만들 때 테마 지원을 위해 해야 할 일이 명확하다는 것입니다. useColorScheme()을 호출하고, 반환된 값으로 색상을 선택하면 됩니다.
6. 테마 변경 시 전체 앱 리렌더링 문제
테마가 전환될 때 앱의 모든 컴포넌트가 리렌더링되어야 합니다. Zustand의 isNightMode 상태가 변경되면 useColorScheme()을 구독하고 있는 모든 컴포넌트가 리렌더를 트리거합니다.
문제는 이 리렌더가 "한 번에" 일어나야 한다는 것입니다. 일부 컴포넌트는 다크 테마이고 일부는 나이트 테마인 "찢어진" 상태가 보이면 안 됩니다. 다행히 Zustand의 상태 업데이트가 동기적으로 처리되고, React의 배치 업데이트 덕분에 모든 구독 컴포넌트가 같은 렌더 사이클 내에서 업데이트됩니다.
다만 RecordItem에 적용한 React.memo의 커스텀 비교 함수에서 colorScheme을 비교 대상에 포함시키지 않으면, 테마가 바뀌어도 기존 기록 아이템들의 색상이 갱신되지 않는 문제가 있었습니다.
memo 비교에서 테마 누락 → 버그 (수도코드):
// 잘못된 비교 (colorScheme 누락)
memo(RecordItem, (prev, next) => {
return prev.record.id === next.record.id // ← 같으면 리렌더 안 함
})
// 올바른 비교
memo(RecordItem, (prev, next) => {
return prev.record.id === next.record.id
&& prev.colorScheme === next.colorScheme // ← 테마 변경도 감지
})
이 버그는 야간모드를 수동으로 토글할 때만 발생했기 때문에 늦게 발견했습니다. 자동 전환 시에는 앱이 새로 마운트되면서 전체 리렌더가 일어나서 문제가 드러나지 않았던 것입니다.
7. 설정 화면의 UX 고민
야간모드 설정 화면에서도 몇 가지 UX 결정이 있었습니다.
시작 시간과 종료 시간을 설정할 때 자유 입력 대신 프리셋 옵션을 제공했습니다. 시작 시간은 오후 5시부터 11시까지, 종료 시간은 오전 5시부터 9시까지 선택할 수 있습니다.
자유 입력을 제공하지 않은 이유는 두 가지입니다. 첫째, 시간 피커 UI가 플랫폼마다 다르게 동작해서 일관성을 보장하기 어렵습니다. 둘째, 현실적으로 야간모드가 필요한 시간대는 이 범위를 벗어나지 않습니다. "새벽 2시에 시작해서 새벽 4시에 끝나는 야간모드"를 설정할 사용자는 거의 없습니다.
설정 화면 자체도 야간모드가 켜져 있을 때 즉시 반영됩니다. 사용자가 야간모드를 켜면 설정 화면의 배경이 바로 블랙으로 바뀌면서 "아, 이렇게 바뀌는구나"를 직관적으로 확인할 수 있습니다.
8. 마치며
야간모드 구현을 통해 배운 것은 "간단해 보이는 기능도 실제로 쓸 만하게 만들려면 디테일이 중요하다"는 점입니다.
- 두 가지 어둠: 다크 모드와 야간 모드를 분리한 이유
- 색상 매핑: 기록 유형별 주간/야간 색상을 수동 조정한 과정
- 자정 경계: 시간 범위 비교에서 자정을 걸치는 케이스 처리
- 세 가지 시점: 자동 전환이 정확히 언제 트리거되어야 하는지
- 리렌더 일관성: memo 비교에서 테마를 누락하면 생기는 버그
특히 "자정 경계 문제"와 "memo 비교에서 테마 누락"은 실제 사용하면서 발견한 버그였습니다. 개발 환경에서는 테마를 전환하는 경우가 드물다 보니 QA 단계에서 놓치기 쉬운 부분입니다.
다음 편에서는 가족 공유와 데이터 동기화를 다룹니다. Supabase의 Row-Level Security로 가족 간 데이터 접근 제어를 구현한 과정과, 오프라인 퍼스트 아키텍처에서의 동기화 전략을 이야기하겠습니다.