원하는 정보만 쏙쏙: 웹 푸시와 키워드 알림 시스템 구축기

곽태욱·2026년 1월 29일

정보 과잉의 시대입니다. 매일 수천 개의 새로운 콘텐츠가 쏟아져 나오지만, 사용자가 진짜 원하는 정보는 그중 극히 일부입니다. "내가 좋아하는 작가의 신작이 나왔을 때", "특정 태그의 작품이 업데이트되었을 때"만 알림을 받고 싶어 하는 니즈는 점점 커지고 있습니다.

이번 글에서는 Next.js와 Service Worker, 그리고 Web Push API를 활용하여, 사용자가 설정한 정교한 키워드 조건에 따라 실시간으로 알림을 발송하는 시스템을 사이드 프로젝트에서 어떻게 구축했는지 소개합니다.

1. 전체 아키텍처

사이드 프로젝트의 알림 시스템은 크게 세 가지 파트로 구성됩니다.

  1. Subscription (구독): 사용자의 브라우저(Device)가 알림을 받을 준비를 하고 서버에 등록하는 과정
  2. Matching (매칭): 새로 수집된 데이터와 사용자의 키워드 설정을 비교하여 알림 대상을 선별하는 과정
  3. Delivery (발송): 선별된 알림을 실제 사용자의 기기로 전송하고, 결과를 처리하는 과정

2. Web Push Subscription

웹 푸시는 앱 설치 없이 브라우저만으로 시스템 레벨의 알림을 받을 수 있는 강력한 기능입니다. 이를 구현하기 위해 VAPID(Voluntary Application Server Identification) 프로토콜을 사용했습니다.

2-1 Service Worker 등록

먼저 클라이언트(브라우저)에서 사용자의 권한을 요청하고, 푸시 매니저를 통해 구독 객체를 생성합니다.

// 브라우저에서 알림 권한 요청 및 구독 생성
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
})

// 서버에 구독 정보 전송
await subscribeToNotifications({
  subscription: subscription.toJSON(),
  userAgent: navigator.userAgent,
})

2-2 구독 정보 저장

서버는 전달받은 구독 정보(endpoint, p256dh, auth)를 DB에 저장합니다. 이때 한 사용자가 여러 기기(PC, 태블릿, 모바일)를 사용할 수 있으므로, userIdendpoint를 복합 키로 하여 중복을 방지합니다.

3. 키워드 매칭 엔진

이 시스템의 핵심은 "수많은 사용자의 다양한 조건을 어떻게 효율적으로 검사할 것인가?"입니다.
단순히 SELECT * FROM criteria WHERE ... 쿼리를 날리는 방식은 데이터가 많아질수록 DB에 엄청난 부하를 줍니다.

그래서 인메모리 매칭(In-Memory Matching) 전략을 통해 이 문제를 해결했습니다.

매칭 알고리즘

OptimizedNotificationMatcher 클래스는 다음과 같은 과정을 통해 수천 건의 매칭을 순식간에 처리합니다.

  1. 메타데이터 정규화: 새로 들어온 콘텐츠의 태그, 작가, 제목 등을 정규화(Trim, Lowercase)합니다.
  2. 역색인(Inverted Index) 활용: 콘텐츠가 가진 속성(type, value)을 가진 모든 알림 조건(NotificationCondition)을 DB에서 한 번에 조회합니다.
  3. 조건 검증 (AND/NOT): 조회된 조건들을 사용자 설정(Criteria)별로 그룹화하여 검증합니다.
    • 포함(Include) 조건: 사용자가 설정한 모든 태그가 포함되어야 함 (AND 연산)
    • 제외(Exclude) 조건: 설정한 태그 중 하나라도 포함되면 안 됨 (NOT OR 연산)
// 매칭 로직 예시 (단순화됨)
for (const [criteriaId, rule] of criteriaMap) {
  // 1. 제외 조건 체크: 하나라도 맞으면 탈락
  if (hasAnyMatch(contentTags, rule.excludedTags)) continue;

  // 2. 포함 조건 체크: 모두 다 맞아야 통과
  if (matchAll(contentTags, rule.includedTags)) {
    notifications.push({ userId: rule.userId, contentId });
  }
}

이 방식을 통해 콘텐츠의 개수가 늘어나도 DB 쿼리 횟수를 최소화(배치당 1~2회)하고, 복잡한 로직을 애플리케이션 레벨에서 빠르게 처리할 수 있었습니다.

4. 웹 푸시 알림 발송 및 최적화

매칭이 완료되면 실제 푸시를 발송할 차례입니다. 하지만 무턱대고 보내면 안 됩니다. 사용자의 피로도를 고려하고 리소스를 아껴야 하기 때문입니다.

4-1. 방해 금지 모드와 일일 제한

사용자가 설정한 방해 금지 시간(예: 밤 10시 ~ 아침 7시)에는 알림을 보내지 않습니다. 또한, 알림 폭탄을 막기 위해 일일 최대 발송 횟수를 제한합니다.

// WebPushService.ts 내부 로직
const remainingToday = settings.maxPerDay - dailyCount
if (remainingToday <= 0 || isQuietHour(settings)) {
  return // 발송 스킵
}

4-2. 죽은 구독 정리 (410 Gone)

웹 푸시 구독은 브라우저 쿠키 삭제, 권한 취소 등의 이유로 만료될 수 있습니다. 만료된 구독으로 계속 요청을 보내는 것은 낭비입니다.
web-push 라이브러리로 발송 시 410 Gone 에러가 발생하면, 해당 구독 정보를 DB에서 즉시 삭제하여 다음번 발송 효율을 높입니다.

try {
  await webpush.sendNotification(subscription, payload)
} catch (error) {
  if (error.statusCode === 410) {
    // 만료된 구독 삭제
    await db.delete(webPushTable).where(eq(webPushTable.id, subscription.id))
  }
}

5. 마치며

이렇게 구축된 알림 시스템은 다음과 같은 장점을 가집니다.

  • 정확성: 사용자가 원하는 키워드 조합(AND/NOT)을 완벽하게 지원합니다.
  • 효율성: 인메모리 매칭으로 대량의 데이터 처리 시에도 DB 부하가 적습니다.
  • 사용자 친화적: 방해 금지 모드와 스마트폰/PC 등 멀티 디바이스를 지원합니다.

단순한 알림 기능을 넘어, 사용자에게 나만을 위한 개인 비서 같은 경험을 제공하는 것. 이것이 제가 기술을 통해 구현하고자 했던 가치입니다.

profile
이유와 방법을 알려주는 메모장 겸 블로그 (Frontend, AI, 경제, 책)

0개의 댓글