Chrome Alarms API로 자동 잠금 타이머 구현하기

Service Worker가 종료되면 setTimeout도 사라집니다. chrome.alarms API로 Service Worker 수명과 독립적인 자동 잠금 타이머를 구현했습니다.

1. 문제 상황

요구사항: 비활성 시 자동 잠금

PIN 보호 기능이 있는 크롬 확장을 개발 중입니다. 보안을 위해 일정 시간 비활성 시 자동으로 잠금되어야 합니다.

[사용자가 PIN 입력하여 잠금 해제]
          |
          | 15분 비활성
          v
[자동으로 다시 잠금됨]

첫 번째 시도: setTimeout

가장 직관적인 방법으로 setTimeout을 사용했습니다:

// background.js (Service Worker)

let autoLockTimer = null;

function startAutoLockTimer(minutes) {
  clearTimeout(autoLockTimer);

  autoLockTimer = setTimeout(() => {
    lockPin();
    console.log('Auto-locked due to inactivity');
  }, minutes * 60 * 1000);
}

// PIN 해제 시
async function handleUnlockPin(pin) {
  if (await verifyPin(pin)) {
    setPinUnlocked(true);
    startAutoLockTimer(15);  // 15분 후 자동 잠금
    return { ok: true };
  }
  return { ok: false };
}

문제: Service Worker가 종료됨

Manifest V3에서 Background는 Service Worker로 동작합니다. Service Worker는 비활성 상태가 되면 30초~5분 후 종료됩니다.

[PIN 잠금 해제]
      |
   setTimeout(15분) 설정
      |
   (30초 후)
      |
   Service Worker 종료됨! 💥
      |
   setTimeout 콜백 사라짐
      |
   15분 후에도 잠금 안 됨 😱

결과:

  • setTimeout이 실행되기 전에 Service Worker가 종료
  • 타이머가 사라지고 자동 잠금이 동작하지 않음

Manifest V2 vs V3 차이

항목 Manifest V2 Manifest V3
Background 지속적 페이지 Service Worker
수명 항상 활성 비활성 시 종료
setTimeout 안정적 신뢰할 수 없음
해결책 - chrome.alarms

2. 해결 방법: chrome.alarms API

핵심 아이디어

chrome.alarms API는 Service Worker 수명과 독립적으로 동작합니다. 설정된 시간이 되면 Chrome이 Service Worker를 깨워서 알람 이벤트를 전달합니다.

[알람 설정]
      |
   chrome.alarms.create('myAlarm', { delayInMinutes: 15 })
      |
   (Service Worker 종료됨)
      |
   (15분 후)
      |
   Chrome이 Service Worker를 다시 시작
      |
   chrome.alarms.onAlarm 이벤트 발생 ✓

권한 추가

// manifest.json
{
  "manifest_version": 3,
  "permissions": [
    "alarms"   // ← 추가
  ]
}

기본 API

// 알람 생성
chrome.alarms.create('alarmName', {
  delayInMinutes: 15,        // 15분 후 1회 실행
  // 또는
  periodInMinutes: 60,       // 60분마다 반복
  // 또는
  when: Date.now() + 900000  // 특정 시각에 실행 (Unix timestamp ms)
});

// 알람 삭제
chrome.alarms.clear('alarmName');

// 모든 알람 삭제
chrome.alarms.clearAll();

// 알람 조회
chrome.alarms.get('alarmName', (alarm) => {
  console.log(alarm);  // { name, scheduledTime, periodInMinutes }
});

// 모든 알람 조회
chrome.alarms.getAll((alarms) => {
  console.log(alarms);
});

// 알람 이벤트 리스너
chrome.alarms.onAlarm.addListener((alarm) => {
  console.log('Alarm fired:', alarm.name);
});

3. 자동 잠금 구현

3.1 상수 및 설정 정의

// background.js

const AUTO_LOCK_ALARM_NAME = 'pinAutoLock';
const AUTO_LOCK_TIMEOUT_KEY = 'autoLockTimeout';
const DEFAULT_AUTO_LOCK_MINUTES = 15;

// 자동 잠금 설정 조회
async function getAutoLockSettings() {
  const data = await chrome.storage.local.get(AUTO_LOCK_TIMEOUT_KEY);
  return data[AUTO_LOCK_TIMEOUT_KEY] ?? {
    enabled: true,
    minutes: DEFAULT_AUTO_LOCK_MINUTES
  };
}

// 자동 잠금 설정 저장
async function setAutoLockSettings(settings) {
  await chrome.storage.local.set({ [AUTO_LOCK_TIMEOUT_KEY]: settings });
}

3.2 알람 타이머 관리

// 자동 잠금 타이머 시작/재설정
async function resetAutoLockTimer() {
  // 1. 기존 알람 삭제
  await chrome.alarms.clear(AUTO_LOCK_ALARM_NAME);

  // 2. PIN이 활성화되어 있는지 확인
  const settings = await getSettings();
  if (!settings.pinEnabled) return;

  // 3. PIN이 해제된 상태인지 확인
  const unlocked = await isPinUnlocked();
  if (!unlocked) return;

  // 4. 자동 잠금 설정 확인
  const autoLock = await getAutoLockSettings();
  if (!autoLock.enabled || autoLock.minutes <= 0) return;

  // 5. 새 알람 생성
  await chrome.alarms.create(AUTO_LOCK_ALARM_NAME, {
    delayInMinutes: autoLock.minutes
  });

  console.debug(`[AutoLock] Timer set for ${autoLock.minutes} minutes`);
}

// 자동 잠금 타이머 취소
async function clearAutoLockTimer() {
  await chrome.alarms.clear(AUTO_LOCK_ALARM_NAME);
  console.debug('[AutoLock] Timer cleared');
}

3.3 알람 이벤트 핸들러

// 알람 이벤트 리스너 (Service Worker 시작 시 등록)
chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === AUTO_LOCK_ALARM_NAME) {
    const settings = await getSettings();

    // PIN이 여전히 활성화되어 있으면 잠금
    if (settings.pinEnabled) {
      await setPinUnlocked(false);
      console.debug('[AutoLock] Locked due to inactivity');
    }
  }
});

3.4 PIN 관련 함수에 통합

// PIN 잠금 해제 시
async function handleUnlockPin(pin) {
  const isValid = await verifyPin(pin);
  if (!isValid) {
    return { ok: false, reason: 'invalid-pin' };
  }

  await setPinUnlocked(true);
  await resetAutoLockTimer();  // ← 타이머 시작

  return { ok: true };
}

// PIN 수동 잠금 시
async function handleLockPin() {
  await setPinUnlocked(false);
  await clearAutoLockTimer();  // ← 타이머 취소

  return { ok: true };
}

// PIN 비활성화 시
async function handleDisablePin() {
  await setPinUnlocked(false);
  await clearAutoLockTimer();  // ← 타이머 취소
  await chrome.storage.local.remove('pinMeta');

  return { ok: true };
}

3.5 활동 감지 시 타이머 리셋

사용자가 확장을 사용할 때마다 타이머를 리셋하면 더 좋은 UX를 제공할 수 있습니다:

// Debounced 활동 감지 (빈번한 리셋 방지)
let activityResetTimer = null;
const ACTIVITY_DEBOUNCE_MS = 5000; // 5초 디바운스

async function onUserActivity() {
  const settings = await getSettings();
  if (!settings.pinEnabled) return;

  const unlocked = await isPinUnlocked();
  if (!unlocked) return;

  // 디바운스: 기존 타이머 취소하고 새로 설정
  if (activityResetTimer) {
    clearTimeout(activityResetTimer);
  }

  activityResetTimer = setTimeout(async () => {
    await resetAutoLockTimer();
    console.debug('[AutoLock] Timer reset due to user activity');
  }, ACTIVITY_DEBOUNCE_MS);
}

// 메시지 핸들러에서 활동 감지
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  (async () => {
    const activityMessages = [
      'getState', 'listBookmarks', 'addBookmark', 'removeBookmark',
      'listRecent', 'removeFromRecent', 'getSettings'
    ];

    if (activityMessages.includes(msg.type)) {
      onUserActivity();
    }

    // 실제 메시지 처리...
  })();
  return true;
});

// 시크릿 모드 브라우징 활동 감지
chrome.tabs.onCreated.addListener((tab) => {
  if (tab.incognito) onUserActivity();
});

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (tab.incognito && changeInfo.status === 'complete') {
    onUserActivity();
  }
});

chrome.tabs.onActivated.addListener(async (activeInfo) => {
  const tab = await chrome.tabs.get(activeInfo.tabId);
  if (tab?.incognito) onUserActivity();
});

chrome.windows.onFocusChanged.addListener(async (windowId) => {
  if (windowId === chrome.windows.WINDOW_ID_NONE) return;
  const window = await chrome.windows.get(windowId);
  if (window?.incognito) onUserActivity();
});

4. 설정 UI 구현

4.1 Options 페이지 HTML

<!-- options.html -->
<div class="setting-row">
  <div class="setting-info">
    <span class="setting-title" data-i18n="settingAutoLockTitle">Auto-Lock</span>
    <span class="setting-desc" data-i18n="settingAutoLockDesc">
      Automatically lock after inactivity
    </span>
  </div>

  <div class="setting-controls">
    <label class="toggle">
      <input type="checkbox" id="autoLockToggle">
      <span class="toggle-track"><span class="toggle-thumb"></span></span>
    </label>

    <select id="autoLockMinutes" class="select-sm">
      <option value="5">5 min</option>
      <option value="10">10 min</option>
      <option value="15" selected>15 min</option>
      <option value="30">30 min</option>
      <option value="60">60 min</option>
    </select>
  </div>
</div>

4.2 Options 페이지 JavaScript

// options.js

const els = {
  autoLockToggle: document.getElementById('autoLockToggle'),
  autoLockMinutes: document.getElementById('autoLockMinutes'),
};

// 초기 설정 로드
async function loadAutoLockSettings() {
  const res = await sendMessage({ type: 'getAutoLockSettings' });
  const settings = res.settings || { enabled: true, minutes: 15 };

  els.autoLockToggle.checked = settings.enabled;
  els.autoLockMinutes.value = settings.minutes;
  els.autoLockMinutes.disabled = !settings.enabled;
}

// 토글 변경 핸들러
els.autoLockToggle.addEventListener('change', async () => {
  const enabled = els.autoLockToggle.checked;
  const minutes = parseInt(els.autoLockMinutes.value, 10);

  await sendMessage({
    type: 'setAutoLockSettings',
    enabled,
    minutes
  });

  els.autoLockMinutes.disabled = !enabled;
  showToast(i18n.t('toastSaved'));
});

// 시간 변경 핸들러
els.autoLockMinutes.addEventListener('change', async () => {
  const enabled = els.autoLockToggle.checked;
  const minutes = parseInt(els.autoLockMinutes.value, 10);

  await sendMessage({
    type: 'setAutoLockSettings',
    enabled,
    minutes
  });

  showToast(i18n.t('toastSaved'));
});

4.3 Background 메시지 핸들러

// background.js - 메시지 핸들러에 추가

if (msg.type === 'getAutoLockSettings') {
  const autoLock = await getAutoLockSettings();
  sendResponse({ settings: autoLock });
}
else if (msg.type === 'setAutoLockSettings') {
  const enabled = msg.enabled !== undefined ? !!msg.enabled : true;
  const minutes = Math.max(1, Math.min(60, Number(msg.minutes) || DEFAULT_AUTO_LOCK_MINUTES));

  await setAutoLockSettings({ enabled, minutes });

  // 설정 변경 후 타이머 재설정
  if (enabled && await isPinUnlocked()) {
    await resetAutoLockTimer();
  } else {
    await clearAutoLockTimer();
  }

  sendResponse({ ok: true });
}

5. 고급 패턴

5.1 반복 알람

// 매 시간마다 실행되는 알람
chrome.alarms.create('hourlyCheck', {
  periodInMinutes: 60
});

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'hourlyCheck') {
    performHourlyTask();
  }
});

5.2 특정 시각에 알람

// 다음 날 오전 9시에 알람
function scheduleAt9AM() {
  const now = new Date();
  const target = new Date();

  target.setHours(9, 0, 0, 0);

  // 이미 9시가 지났으면 내일로
  if (target <= now) {
    target.setDate(target.getDate() + 1);
  }

  chrome.alarms.create('dailyReminder', {
    when: target.getTime()
  });
}

5.3 알람 상태 확인

// 현재 활성화된 알람 확인
async function getActiveAlarms() {
  return new Promise((resolve) => {
    chrome.alarms.getAll((alarms) => {
      resolve(alarms);
    });
  });
}

// 특정 알람이 설정되어 있는지 확인
async function isAlarmSet(name) {
  return new Promise((resolve) => {
    chrome.alarms.get(name, (alarm) => {
      resolve(!!alarm);
    });
  });
}

// 사용
const alarms = await getActiveAlarms();
console.log('Active alarms:', alarms);

if (await isAlarmSet('pinAutoLock')) {
  console.log('Auto-lock is scheduled');
}

5.4 알람 남은 시간 계산

async function getRemainingTime(alarmName) {
  return new Promise((resolve) => {
    chrome.alarms.get(alarmName, (alarm) => {
      if (!alarm) {
        resolve(null);
        return;
      }

      const remaining = alarm.scheduledTime - Date.now();
      resolve({
        totalMs: remaining,
        minutes: Math.ceil(remaining / 60000),
        seconds: Math.ceil(remaining / 1000)
      });
    });
  });
}

// 사용
const remaining = await getRemainingTime('pinAutoLock');
if (remaining) {
  console.log(`Auto-lock in ${remaining.minutes} minutes`);
}

6. Before/After 비교

Before: setTimeout (불안정)

// ❌ Service Worker 종료 시 타이머 사라짐
let timer = null;

function startAutoLock(minutes) {
  clearTimeout(timer);
  timer = setTimeout(() => {
    lockPin();
  }, minutes * 60 * 1000);
}

function handleUnlock() {
  setPinUnlocked(true);
  startAutoLock(15);  // Service Worker 종료되면 동작 안 함
}

After: chrome.alarms (안정)

// ✅ Service Worker 수명과 독립적
const ALARM_NAME = 'pinAutoLock';

async function startAutoLock(minutes) {
  await chrome.alarms.clear(ALARM_NAME);
  await chrome.alarms.create(ALARM_NAME, {
    delayInMinutes: minutes
  });
}

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === ALARM_NAME) {
    await lockPin();  // Service Worker가 종료되어도 Chrome이 깨워서 실행
  }
});

async function handleUnlock() {
  await setPinUnlocked(true);
  await startAutoLock(15);  // 항상 안정적으로 동작
}

7. 주의사항

7.1 최소 시간 제한

// chrome.alarms의 최소 지연 시간은 약 1분
// 1분 미만으로 설정하면 1분으로 자동 조정됨

chrome.alarms.create('test', {
  delayInMinutes: 0.1  // 6초 → 실제로는 ~1분 후 실행
});

// 개발 모드에서는 더 짧은 시간 가능 (약 30초)

7.2 알람 이름 유일성

// 같은 이름의 알람은 덮어씀
chrome.alarms.create('myAlarm', { delayInMinutes: 5 });
chrome.alarms.create('myAlarm', { delayInMinutes: 10 });
// → 10분 후 1회만 실행

// 여러 알람이 필요하면 다른 이름 사용
chrome.alarms.create('alarm1', { delayInMinutes: 5 });
chrome.alarms.create('alarm2', { delayInMinutes: 10 });

7.3 Service Worker 시작 시 리스너 등록

// ⚠️ 중요: 알람 리스너는 Service Worker 시작 시 즉시 등록해야 함
// 조건문 안에 넣으면 알람이 발생해도 처리되지 않을 수 있음

// ❌ 잘못된 예
async function init() {
  const settings = await getSettings();
  if (settings.pinEnabled) {
    chrome.alarms.onAlarm.addListener(handleAlarm);  // 조건부 등록
  }
}

// ✅ 올바른 예
// 파일 최상위 레벨에서 무조건 등록
chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === 'pinAutoLock') {
    const settings = await getSettings();
    if (settings.pinEnabled) {  // 핸들러 내부에서 조건 체크
      await lockPin();
    }
  }
});

7.4 브라우저 재시작 후 알람 복원

// 알람은 브라우저 종료 시 사라질 수 있음
// Service Worker 시작 시 필요한 알람이 있는지 확인하고 재생성

chrome.runtime.onStartup.addListener(async () => {
  const unlocked = await isPinUnlocked();
  const settings = await getSettings();

  if (unlocked && settings.pinEnabled) {
    const alarm = await new Promise(r => chrome.alarms.get('pinAutoLock', r));
    if (!alarm) {
      // 알람이 없으면 재생성
      await resetAutoLockTimer();
    }
  }
});

8. 완전한 구현 코드

// background.js - 자동 잠금 관련 전체 코드

const AUTO_LOCK_ALARM_NAME = 'pinAutoLock';
const AUTO_LOCK_TIMEOUT_KEY = 'autoLockTimeout';
const DEFAULT_AUTO_LOCK_MINUTES = 15;

// ============================================
// 설정 관리
// ============================================

async function getAutoLockSettings() {
  const data = await chrome.storage.local.get(AUTO_LOCK_TIMEOUT_KEY);
  return data[AUTO_LOCK_TIMEOUT_KEY] ?? {
    enabled: true,
    minutes: DEFAULT_AUTO_LOCK_MINUTES
  };
}

async function setAutoLockSettings(settings) {
  await chrome.storage.local.set({ [AUTO_LOCK_TIMEOUT_KEY]: settings });
}

// ============================================
// 타이머 관리
// ============================================

async function resetAutoLockTimer() {
  await chrome.alarms.clear(AUTO_LOCK_ALARM_NAME);

  const settings = await getSettings();
  if (!settings.pinEnabled) return;

  const unlocked = await isPinUnlocked();
  if (!unlocked) return;

  const autoLock = await getAutoLockSettings();
  if (!autoLock.enabled || autoLock.minutes <= 0) return;

  await chrome.alarms.create(AUTO_LOCK_ALARM_NAME, {
    delayInMinutes: autoLock.minutes
  });

  console.debug(`[AutoLock] Timer set: ${autoLock.minutes} min`);
}

async function clearAutoLockTimer() {
  await chrome.alarms.clear(AUTO_LOCK_ALARM_NAME);
  console.debug('[AutoLock] Timer cleared');
}

// ============================================
// 알람 이벤트 핸들러 (최상위 레벨에서 등록)
// ============================================

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === AUTO_LOCK_ALARM_NAME) {
    const settings = await getSettings();
    if (settings.pinEnabled) {
      await setPinUnlocked(false);
      console.debug('[AutoLock] Locked due to inactivity');
    }
  }
});

// ============================================
// 브라우저 시작 시 알람 복원
// ============================================

chrome.runtime.onStartup.addListener(async () => {
  const unlocked = await isPinUnlocked();
  if (unlocked) {
    await resetAutoLockTimer();
  }
});

// ============================================
// 메시지 핸들러 (일부)
// ============================================

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  (async () => {
    if (msg.type === 'unlockPin') {
      const result = await handleUnlockPin(msg.pin);
      if (result.ok) {
        await resetAutoLockTimer();  // ← 잠금 해제 시 타이머 시작
      }
      sendResponse(result);
    }
    else if (msg.type === 'lockPin') {
      await setPinUnlocked(false);
      await clearAutoLockTimer();  // ← 잠금 시 타이머 취소
      sendResponse({ ok: true });
    }
    else if (msg.type === 'getAutoLockSettings') {
      const settings = await getAutoLockSettings();
      sendResponse({ settings });
    }
    else if (msg.type === 'setAutoLockSettings') {
      const enabled = !!msg.enabled;
      const minutes = Math.max(1, Math.min(60, Number(msg.minutes) || 15));
      await setAutoLockSettings({ enabled, minutes });

      if (enabled && await isPinUnlocked()) {
        await resetAutoLockTimer();
      } else {
        await clearAutoLockTimer();
      }
      sendResponse({ ok: true });
    }
  })();
  return true;
});

9. 베스트 프랙티스

DO (해야 할 것)

  • [x] manifest.json에 alarms 권한 추가
  • [x] 알람 리스너는 Service Worker 최상위에서 등록
  • [x] 알람 이름은 상수로 관리
  • [x] 알람 생성 전 기존 알람 삭제
  • [x] 브라우저 재시작 시 알람 복원 로직

DON'T (하지 말아야 할 것)

  • [ ] Service Worker에서 setTimeout으로 장시간 타이머
  • [ ] 알람 리스너를 조건부로 등록
  • [ ] 1분 미만의 정밀한 타이밍 기대
  • [ ] 알람 이름 하드코딩 (상수 사용 권장)

10. FAQ

Q: setTimeout 대신 항상 chrome.alarms를 사용해야 하나요?

A: 아닙니다. 짧은 시간(수 초)의 타이머는 setTimeout이 더 정확합니다. chrome.alarms는 최소 ~1분의 제한이 있고, 정확한 시간을 보장하지 않습니다. Service Worker 종료 이후에도 동작해야 하는 장시간 타이머에만 chrome.alarms를 사용하세요.

Q: 알람이 정확히 설정된 시간에 발생하나요?

A: 아닙니다. Chrome은 배터리와 성능을 위해 알람을 약간 지연시킬 수 있습니다. 정확한 시간이 중요한 경우에는 적합하지 않습니다.

Q: 여러 알람을 사용할 때 성능 문제가 있나요?

A: 일반적인 사용에서는 문제없습니다. 하지만 수백 개의 알람을 생성하면 성능에 영향을 줄 수 있습니다. 필요 없는 알람은 정리하세요.

Q: content script에서 chrome.alarms를 사용할 수 있나요?

A: 아닙니다. chrome.alarms는 background (Service Worker)에서만 사용 가능합니다. content script에서 타이머가 필요하면 background에 메시지를 보내서 처리하세요.


11. 참고 자료


시리즈 목차

  1. JavaScript URL 비교와 정규화
  2. Web Crypto API로 안전한 해싱 구현하기
  3. CSS 변수와 다크 모드 구현하기
  4. 크롬 확장 프로젝트 구조 정리하기
  5. 크롬 확장 다국어(i18n) 구현하기
  6. 크롬 확장 공유 모듈 설계
  7. Chrome Storage로 실시간 상태 동기화
  8. Chrome Alarms API로 자동 잠금 타이머 ← 현재 글