야간·교대 근무자를 위한 앱 설계 — 알림 타이밍과 심야 화면

모바일 개발

알림다크 모드접근성사용자 리듬시간 처리

이 글은 누구를 위한 것인가

  • 사용자층에 교대·야간 근무자가 섞여 있는 서비스를 만드는 팀
  • "오전 9시 알림"처럼 고정 시각 알림을 쓰고 있는데 이탈이 높은 경우
  • 다크 모드는 넣었지만 심야 사용성은 검증하지 않은 개발자

들어가며

대부분의 앱은 사용자가 낮에 깨어 있다는 가정 위에 만들어진다.

  · "오전 9시에 오늘의 요약을 보내드려요"
  · "하루가 끝나는 밤 11시에 정리해 드립니다"
  · 주간 리포트는 월요일 아침에
  · 다크 모드는 시스템 설정을 따라감 (= 저녁부터)

이 가정이 틀린 사용자에게는 전부 반대로 작동한다. 오전 9시는 막 잠든 시각이고, 밤 11시는 출근 준비 중이며, 월요일 아침은 주말 근무의 끝자락이다.

교대·야간 근무 인구는 생각보다 크다. 의료, 물류, 제조, 보안, 서비스업 상당수가 여기 해당한다. 이 글은 시간을 다루는 코드가 "낮 기준"으로 굳어 있는 지점들을 찾아내는 것에 관한 것이다.


1. 고정 시각 알림이 만드는 일

가장 먼저 손볼 곳이다.

[같은 "오전 9시 알림"이 받는 사람에 따라]

  주간 근무자   출근길. 적절
  야간 근무자   퇴근 후 1시간, 막 잠든 시각 ← 최악
  교대 근무자   주에 따라 다름

한 번 잠을 깨우면 그 사용자는 알림을 영구히 끈다. 그리고 알림을 끈 사용자는 되돌리지 않는다. 잘못된 타이밍 한 번의 비용이 매우 크다.

가장 간단한 개선은 사용자에게 직접 묻는 것이다.

  알림 받을 시간대
  ○ 아침 (7~9시)
  ○ 저녁 (18~20시)
  ○ 심야 (22~24시)
  ○ 직접 설정  [__:__]
  ○ 받지 않음

"직접 설정"과 "받지 않음"을 반드시 넣는다. 프리셋만 두면 어느 것도 맞지 않는 사용자가 앱 알림 자체를 끈다. 서비스 안에서 끄는 것과 OS 레벨에서 끄는 것은 회복 가능성이 완전히 다르다.


2. 묻지 않고 추정하기 — 사용 로그로

설정 화면에 들어가는 사용자는 소수다. 행동에서 추정하는 것이 실효가 크다.

/** 최근 14일의 앱 사용 시각으로 "활동 구간"을 추정 */
function estimateActiveHours(events: Date[]): { start: number; end: number } | null {
  if (events.length < 20) return null;              // 표본 부족 시 추정하지 않음

  const buckets = new Array(24).fill(0);
  for (const e of events) buckets[e.getHours()]++;

  // 원형(24시간)이므로 연속 구간을 순환 탐색
  const total = buckets.reduce((a, b) => a + b, 0);
  let best = { start: 0, len: 24, covered: 0 };

  for (let start = 0; start < 24; start++) {
    let covered = 0;
    for (let len = 1; len <= 18; len++) {
      covered += buckets[(start + len - 1) % 24];
      if (covered >= total * 0.9 && len < best.len) {
        best = { start, len, covered };
      }
    }
  }
  return { start: best.start, end: (best.start + best.len) % 24 };
}

24시간을 원형으로 다루는 것이 핵심이다. 야간 근무자의 활동 구간은 22시 ~ 06시처럼 자정을 넘는다. 선형으로 계산하면 이 구간이 "22시부터 24시까지"로 잘리거나 아예 잡히지 않는다.

[추정 결과 활용]

  활동 구간 안에서 알림 발송
  추정 실패(표본 부족) → 기본값 사용 + 설정 유도
  추정 결과를 사용자에게 확인받기
    "밤 10시~아침 6시에 주로 쓰시네요. 이 시간대로 알림을 맞출까요?"

추정을 조용히 적용하지 않고 확인받는 것이 좋다. 틀렸을 때 사용자가 이유를 알 수 있고, 맞았을 때는 신뢰가 생긴다.


3. OS 방해 금지와 충돌하지 않기

사용자가 낮에 자는 경우, OS의 방해 금지 설정도 낮에 걸려 있다. 여기에 중요도를 잘못 설정한 알림을 보내면 그 설정을 뚫는다.

// Android — 채널을 용도별로 나누고 중요도를 다르게
val channels = listOf(
    NotificationChannel("urgent", "긴급 알림", IMPORTANCE_HIGH),      // 방해 금지 예외 요청 가능
    NotificationChannel("daily",  "일일 요약", IMPORTANCE_DEFAULT),
    NotificationChannel("promo",  "소식·혜택", IMPORTANCE_LOW),        // 소리·진동 없음
)

채널을 하나만 만들고 전부 거기로 보내는 것이 가장 흔한 실수다. 사용자는 "이 앱 알림"을 통째로 끄는 것 말고 선택지가 없어진다. 채널을 나누면 광고성만 끄고 중요한 것은 남길 수 있다. 채널 설계와 중요도 수준은 Android 알림 채널 문서에 정리돼 있다.

[중요도 배정 원칙]

  HIGH     사용자가 놓치면 손해를 보는 것 (예약 시작, 결제 실패)
  DEFAULT  일상적 알림
  LOW      마케팅·추천 — 소리 없음

  ✗ 마케팅을 HIGH로 두는 것
    → 방해 금지를 뚫고 자는 사람을 깨움
    → 앱 알림 전체가 꺼짐

웹이라면 Notifications API로 보내되, 권한 요청 시점 자체를 사용자 활동 구간 안으로 미루는 편이 승인률에 유리하다.


4. 발송 큐 — 시간대별로 나눠 보낸다

알림을 사용자별 시각에 맞추면, 모두에게 한 번에 보내는 배치가 성립하지 않는다.

[이전] 매일 09:00에 전체 발송 — 큐 1개

[이후] 사용자마다 다른 시각 — 시간 단위 큐 24개
// 매시 정각에 그 시각이 배정된 사용자만 처리
async function dispatchHourly(hourUtc: number) {
  const targets = await db.notificationPrefs.findMany({
    where: { enabled: true, sendHourUtc: hourUtc },
    take: 5000,
  });
  // 같은 시각에 몰리면 분산 발송
  await spreadOver(targets, { minutes: 20 });
}

분 단위로 흩뿌리는 것이 필요하다. 사용자 설정이 정각에 몰리기 때문이다(대부분 "오후 9시"를 고르지 "오후 9시 13분"을 고르지 않는다). 20분에 걸쳐 나눠 보내면 푸시 게이트웨이와 서버 부하가 평탄해진다.

시간대(timezone) 처리도 다시 봐야 한다. 사용자 로컬 시각으로 저장하면 서머타임이나 이동 시 어긋나므로, IANA 타임존 + 로컬 시각으로 저장하고 발송 시점에 UTC로 환산한다.

CREATE TABLE notification_prefs (
  user_id      BIGINT PRIMARY KEY,
  enabled      BOOLEAN NOT NULL DEFAULT true,
  local_hour   SMALLINT NOT NULL,       -- 0~23, 사용자 로컬 기준
  timezone     TEXT NOT NULL,           -- 'Asia/Seoul'
  quiet_start  SMALLINT,                -- 절대 보내지 않을 구간
  quiet_end    SMALLINT
);

quiet_startquiet_end보다 큰 경우(예: 08→16, 즉 낮에 자는 사람)를 정상 케이스로 처리해야 한다. 이걸 유효성 검사에서 걸러버리는 구현이 실제로 자주 보인다.

function inQuietHours(hour: number, start: number, end: number): boolean {
  return start <= end
    ? hour >= start && hour < end        // 일반: 23~06 아닌 경우
    : hour >= start || hour < end;       // 자정 넘김: 22~06
}

5. 요일 경계 — "오늘"이 언제 끝나는가

야간 근무자에게 자정은 하루의 중간이다.

  근무 시작 22:00 (월)  →  근무 종료 06:00 (화)

  이 근무는 "월요일 근무"인가 "화요일 근무"인가?
  이 시간에 남긴 기록은 어느 날짜에 들어가야 하는가?

캘린더·기록·통계 기능이 있다면 반드시 답해야 하는 질문이다.

[해법 — 논리적 하루 경계를 설정 가능하게]

  기본값     00:00 (자정)
  야간 사용자 06:00 또는 사용자 지정

  → "오늘의 기록"은 [경계 ~ 다음 경계) 구간
const DAY_BOUNDARY_HOUR = 6;   // 사용자 설정

function logicalDate(at: Date, boundaryHour: number): string {
  const d = new Date(at);
  if (d.getHours() < boundaryHour) d.setDate(d.getDate() - 1);
  return d.toISOString().slice(0, 10);
}

// 새벽 3시의 기록 → 전날 날짜로 귀속

주간 리포트의 시작 요일도 마찬가지다. "월요일 시작"이 기본이지만, 근무 주기가 다르면 의미가 없다. 시작 요일을 설정으로 빼두면 되고, 비용도 크지 않다.


6. 심야 화면 — 다크 모드로 끝나지 않는다

다크 모드는 필요조건이지 충분조건이 아니다.

[어두운 환경에서 실제로 문제가 되는 것]

  · 순백(#FFFFFF) 텍스트 — 눈부심. #E0E0E0 정도가 편함
  · 순흑(#000000) 배경 — OLED 스미어링. #121212 권장
  · 갑작스러운 밝은 화면 전환 (스플래시, 모달)
  · 대비가 너무 높은 강조색
  · 밝기 자동 조절이 안 먹는 전체 화면 이미지

대비를 무조건 높이는 것이 답이 아니다. WCAG 최소 대비 기준을 만족하되, 어두운 환경에서는 최대 대비가 오히려 눈을 피로하게 한다.

:root[data-theme="dark"] {
  --bg: #121212;
  --surface: #1e1e1e;
  --text: #e3e3e3;          /* 순백 대신 */
  --text-dim: #9e9e9e;
  --accent: #7fb3ff;        /* 채도를 낮춘 강조색 */
}

/* 심야 모드 — 다크보다 한 단계 더 낮춤 */
:root[data-theme="night"] {
  --bg: #0d0d0d;
  --text: #b8b8b8;
  --accent: #6a8fbf;
}

"심야 모드"를 별도로 두는 것이 실효가 있다. 다크 모드는 저녁 실내용이고, 완전한 어둠에서는 그것도 밝다.

전환 시점도 prefers-color-scheme을 그대로 따르면 안 된다. 시스템 다크 모드는 일몰 기준으로 도는데, 낮에 자는 사용자에게는 반대다.

[테마 전환 규칙]

  기본     시스템 설정 따름
  옵션     항상 다크 / 항상 라이트 / 사용자 지정 시간대
  ✗       시스템만 따르고 수동 선택지를 안 주는 것

교대근무자의 실제 생활 패턴이 어떤 문제를 만드는지는 당사자 대상 자료를 보면 구체적이다. 토닥의 교대근무 피로 안내는 수면의 질, 회복 루틴, 각성과 휴식의 경계를 나눠 설명하는데, "각성과 휴식의 경계"라는 구분이 앱 설계에도 그대로 적용된다. 사용자가 각성 구간에 있는지 회복 구간에 있는지에 따라 알림과 화면이 달라야 한다는 뜻이다.


7. 접근성 — 피로 상태를 전제로

야간 근무 중이나 직후의 사용자는 주의력과 반응 속도가 평소보다 낮다.

[설계 반영]

  □ 터치 타겟을 기준보다 넉넉하게 (44px 이상)
  □ 파괴적 동작에는 확인 단계 (오조작 확률이 높음)
  □ 실행 취소를 넉넉한 시간 동안 제공 (3초 → 8초)
  □ 자동 로그아웃 시간을 짧게 잡지 않기
  □ 긴 폼은 자동 저장 (중간에 호출·업무로 끊김)

실행 취소 유지 시간이 특히 중요하다. 스낵바가 3초 뒤 사라지는 기본값은 피로한 사용자에게 짧다. 8초 정도로 늘리거나, 명시적으로 닫을 때까지 유지하는 편이 낫다.

자동 저장은 이 사용자층에서 거의 필수다. 근무 중 사용은 언제든 중단된다는 전제로 만들어야 한다.


8. 측정 — 시간대별로 쪼개서 본다

전체 평균 지표만 보면 이 사용자층의 문제가 보이지 않는다.

[시간대별로 분리해서 볼 지표]

  · 알림 열람률       발송 시각대별
  · 알림 해제율       ← 가장 직접적인 신호
  · 세션 시작 시각 분포
  · 오조작률 (실행 취소 사용 빈도) 시각대별
  · 크래시율          시각대별 (저사양 기기 비중이 다를 수 있음)

알림 해제율을 발송 시각대별로 나눠 보는 것이 가장 빠른 진단이다. 특정 시간대의 해제율이 두드러지게 높다면, 그 시각이 누군가의 수면 시간이다.

SELECT
  extract(hour from sent_at AT TIME ZONE tz) AS local_hour,
  count(*)                                    AS sent,
  count(*) FILTER (WHERE opened)              AS opened,
  count(*) FILTER (WHERE disabled_after)      AS caused_optout
FROM notification_log
WHERE sent_at >= now() - interval '30 days'
GROUP BY 1 ORDER BY 1;

9. 정리

  1. 고정 시각 알림을 없앤다 — 프리셋 + 직접 설정 + 끄기
  2. 활동 구간은 24시간 원형으로 추정 (자정 넘김이 정상)
  3. 알림 채널을 용도별로 분리, 마케팅은 LOW
  4. 발송은 시간 단위 큐 + 분 단위 분산
  5. quiet_start > quiet_end 를 정상 케이스로 처리
  6. 논리적 하루 경계를 설정 가능하게
  7. 다크 모드 위에 "심야 모드"를 한 단계 더
  8. 실행 취소 유지 시간을 넉넉하게, 폼은 자동 저장
  9. 알림 해제율을 발송 시각대별로 본다

가장 효과가 큰 건 1번과 9번이다. 해제율을 시각대별로 보면 문제 시간대가 즉시 드러나고, 알림 시각을 사용자가 고르게 하는 것만으로 그 대부분이 사라진다.