Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

웹·앱 공용 풀스택 시스템 개발기 (2) — 알림(alerts) 시스템 설계 본문

외주 개발일지

웹·앱 공용 풀스택 시스템 개발기 (2) — 알림(alerts) 시스템 설계

HM소프트 2026. 6. 21. 06:28
반응형

알림을 읽음/안읽음이 아니라 미결·확인중·조치완료 상태와 이력을 가진 일감으로 모델링한다. 플러그인 detector 엔진과 message 지문 dedup, 클라이언트 필터가 100건 윈도에서 무너진 한계를 서버사이드 필터·안정 정렬·일괄 확인으로 옮긴 과정, 데이터 소스 seam까지 다룬다.

2편: 알림은 토스트가 아니라 "일감"이다

1편에서 웹·앱이 공유하는 백엔드 API를 다뤘다. 이번 편은 그 위에 올라간 기능 중 가장 공들인 알림(alerts) 시스템이다. 백엔드의 이상 감지 엔진과, 그것을 소비하는 관리자 웹(Next.js 16 + React 19)을 함께 본다.

대규모 사업장 시스템에서 "알림"은 화면 구석에 잠깐 떴다 사라지는 토스트가 아니다. 관리자가 처리해야 할 일감의 목록이다. 연속 결근, 월간 연장근무 과다, 계약·자격 만료 임박, 비용 급증, 연락처 중복, 미성년 채용, 조기 퇴사 — 이런 "주의가 필요한 상황"을 시스템이 자동으로 찾아내 쌓고, 관리자가 하나씩 확인하고 조치한다. 책임이 따르는 업무(안전·근태·인사)라 "누가 언제 무엇을 처리했는지"까지 남아야 한다.

그래서 알림을 읽음/안읽음이 아니라, 상태와 이력을 가진 객체로 모델링했다. 상태는 미결(PENDING) → 확인중(ACKNOWLEDGED) → 조치완료(RESOLVED)로 흐른다. 이 글은 (1) 이상을 자동으로 찾는 detector 엔진, (2) 그 결과를 화면에 거는 서버사이드 필터링, (3) 상태 이력과 일괄 확인 순서로 다룬다.

이상 감지 엔진 — 플러그인으로 분리한 detector

이상 감지의 종류는 시간이 지나며 늘어난다. 처음엔 결근·연장근무 둘이었는데 지금은 일곱 개다. 새 규칙이 추가될 때마다 거대한 if문에 가지를 치면 금세 손쓸 수 없게 된다. 그래서 각 감지 규칙을 공통 인터페이스를 따르는 독립 detector로 분리하고, 엔진은 그것들을 등록·실행하기만 한다.

detector는 category 키, 설명, 그리고 run() 하나만 가진다. run()은 관리자가 설정한 임계값(AlertConfig)을 받아, 조건을 넘긴 대상들의 DetectionResult 배열을 돌려준다. 엔진은 detector를 category로 등록하고, 활성화된 설정마다 매칭되는 detector를 실행한다.

엔진 설계에서 신경 쓴 두 가지가 있다.

첫째, 중복 방지다. 엔진은 수동(POST /api/alerts/run)으로도, 향후 스케줄러로도 반복 실행된다. 같은 직원의 "5일 연속 결근"이 실행할 때마다 새 알림으로 쌓이면 목록이 쓰레기가 된다. 그래서 message 문자열을 중복 제거 지문(fingerprint)으로 쓴다 — 같은 대상의 같은 상황은 항상 같은 문자열이 되도록 detector가 메시지를 만든다. 엔진은 "같은 메시지의 미결 알림이 이미 있으면 건너뛴다". 처음엔 이걸 결과 하나씩 조회(N+1)했는데, message: { in: [...] }로 한 번에 조회해 정리했다.

둘째, dedup 대상은 PENDING만이라는 점이다. 이미 확인(ACKNOWLEDGED)한 알림은 dedup에서 뺀다. 확인은 "오늘은 숨김"의 의미라, 자정이 지나 같은 상황이 또 감지되면 새 row로 다시 노출돼야 하기 때문이다. 이 작은 구분이 "어제 확인했는데 오늘도 결근인" 케이스를 놓치지 않게 한다.

detector 한 개 들여다보기 — 연속 결근

추상적인 인터페이스보다 실제 detector 하나가 설계 의도를 잘 보여준다. 가장 까다로운 "연속 결근"을 보자. 단순히 "출근 기록 없는 날"을 세면 안 된다. 주말, 공휴일, 승인된 휴가는 결근이 아니기 때문이다.

핵심은 어제부터 과거로 거꾸로 세되, 주말·공휴일은 "건너뛰고" 승인 휴가는 "종료"한다는 차이다. 주말은 연속을 끊지 않지만(금요일 결근 + 월요일 결근 = 2일 연속), 승인 휴가는 정당한 사유라 거기서 카운트를 멈춘다. 그리고 출근 기록(checkIn)을 만나는 순간 streak가 끝난다. 메시지에는 부서·이름·사번·일수를 다 넣는데, 이 문자열 전체가 앞서 말한 dedup 지문이 된다. 같은 사람의 같은 streak는 매번 같은 메시지가 되니, 엔진이 중복을 거른다.

성능을 위해 직원·근태·휴가·공휴일을 각각 한 번씩만 조회해(Promise.all) 메모리에서 Map으로 조합한다. 직원 수만큼 DB를 때리지 않는다.

서버사이드 필터링 — 클라이언트 필터가 무너진 지점

여기가 이번 편의 진짜 교훈이다. 처음엔 화면이 알림을 ?limit=100으로 한 번 받아 브라우저에서 필터링했다. 알림이 적을 땐 멀쩡했다. 그런데 운영에서 알림이 100건을 넘기자 버그가 줄줄이 터졌다.

  • "조치완료로 검색했는데 0건" — 조치완료된 항목이 100건 적재 윈도 에 있어 안 보였다.
  • "방금 처리한 항목이 목록에서 사라짐" — 같은 이유로, 상태가 바뀐 항목이 윈도 밖으로 밀렸다.

원인은 명확했다. 필터링을 클라이언트에서 하려면 전체 데이터가 클라이언트에 있어야 한다. 그런데 전체를 받는 건 느리고 무겁다. 그래서 일부만 받으면, 필터는 그 "일부" 안에서만 동작한다. 데이터가 늘면 반드시 부딪히는 구조적 한계다. 답은 정해져 있다 — 필터·정렬·페이징을 서버로 옮긴다.

화면의 필터 상태(AlertsQuery)를 그대로 백엔드 쿼리스트링으로 직렬화한다. 한글 라벨(미결/주의)을 백엔드 enum(PENDING/WARNING)으로 매핑하는 작은 변환 테이블을 양방향으로 두었다. 백엔드는 이 파라미터로 Prisma where를 조립한다.

정렬에서 의도적으로 한 결정이 있다. status를 정렬 키에서 뺐다. 처음엔 "미결을 위로" 정렬했는데, 그러면 항목을 확인 처리하는 순간 그 행이 목록에서 아래로 휙 밀려난다. 방금 누른 게 어디로 갔는지 사용자가 놓친다. 그래서 정렬은 감지시각 최신순으로 고정하고, 상태·심각도는 행 안의 뱃지와 필터로만 구분한다. 상태를 바꿔도 행은 제자리에 있다. 그리고 동시각 동률을 id로 깨서 페이지를 넘겨도 순서가 흔들리지 않게(안정 정렬) 했다 — 안 그러면 같은 항목이 두 페이지에 보이거나 사라진다.

전환 과정에서 또 한 가지 버그를 잡았다. 백엔드의 카테고리는 영문(ABSENCE_STREAK)인데 화면은 한글 라벨을 쓴다. 매칭에 실패하면 모든 라이브 알림이 첫 번째 룰("무단 결근")로 오표시되는 폴백 버그가 있었다. 영문 카테고리 → 한글 라벨 매핑 테이블을 명시하고, 미정의 카테고리는 폴백 없이 원문 그대로 보여주도록 바꿔 해결했다.

데이터 소스 seam — mock과 live를 같은 인터페이스로

웹 코드에는 useAlertsData(query)라는 훅 하나가 있다. 화면(AlertsView)은 이 훅만 호출하고, 데이터가 가짜(mock)인지 진짜(live 백엔드)인지 모른다. 환경변수로 분기될 뿐, 반환 타입은 동일하다.

// src/lib/data/alerts-source.ts — 같은 시그니처, 다른 구현
const USE_LIVE =
  process.env.NEXT_PUBLIC_LIVE_MODE === "true" || process.env.NEXT_PUBLIC_USE_LIVE_ALERTS === "true";

export function useAlertsData(query: AlertsQuery): AlertsData {
  const mock = useAlerts(); // 훅 규칙상 항상 호출 (분기는 아래에서)
  // ... live 상태(rows/total/pending) 관리 ...

  if (!USE_LIVE) {
    // mock 경로: 클라이언트 필터 + 메모리 페이징. 반환 타입은 live 와 동일.
    const filtered = filterClient(mock.alerts, query);
    return { rows: filtered.slice(start, start + query.pageSize), total: filtered.length, /* ... */ live: false };
  }
  // live 경로: 서버 필터 + 페이지네이션
  return { rows, total, pendingPreview, pendingTotal, /* ... */ live: true };
}

이 "데이터 소스 seam(이음매)" 덕분에 화면 개발과 백엔드 연동을 분리할 수 있었다. 화면은 mock으로 먼저 완성하고, 백엔드가 준비되면 환경변수만 켠다. 화면 코드는 한 줄도 안 바뀐다. (같은 발상이 3편 앱의 데모 모드에서도 반복된다 — 시리즈 전체를 관통하는 패턴이다.)

이 클라이언트는 1편의 응답 봉투({success,data})를 푸는 얇은 래퍼 위에 있다. 401을 받으면 refresh 토큰으로 한 번 자동 재발급하고 원 요청을 재시도한다. 정적 SPA라 모든 호출이 브라우저에서 일어나므로, 이 자동 재시도가 "갑자기 로그아웃되는" 경험을 막아 준다.

// src/lib/api/client.ts — 봉투 해제 + 401 자동 refresh
async function request<T>(method: string, path: string, body?: unknown, _retried = false): Promise<T> {
  const token = getAccessToken();
  const res = await fetch(`${API_BASE}${path}`, {
    method,
    headers: { ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) },
    ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
  });
  if (res.status === 401 && !_retried) {
    if (await tryRefresh()) return request<T>(method, path, body, true); // 1회만 재시도
    clearTokens();
    throw new ApiError("UNAUTHORIZED", "인증이 만료되었습니다. 다시 로그인하세요.", 401);
  }
  return parseEnvelope<T>(res); // {success:true} → data, 아니면 ApiError throw
}

상태 이력 — 누가 언제 무엇을 처리했나

책임이 따르는 업무에서 "이 알림을 누가 언제 확인했는가"는 기능이 아니라 필수다. 그래서 상태 전이를 전부 공용 이력 테이블(StatusHistory)에 남긴다. 이건 알림 전용이 아니라 클레임·급여 등에서도 쓰는 공통 유틸이라, entityType("AlertLog") + entityId로 어떤 엔티티든 기록한다.

// src/lib/status-history.ts — 행위자 해석과 이력 기록
// 인증 컨텍스트 → 행위자(표시 이름은 연결된 직원명 > username 순)
export async function actorFromAuth(auth: { userId: string }): Promise<StatusActor> {
  const user = await prisma.user.findUnique({
    where: { id: auth.userId },
    select: { username: true, employee: { select: { name: true } } },
  });
  return { userId: auth.userId, name: user?.employee?.name ?? user?.username ?? null };
}

// 변경 이력 1건 기록. 트랜잭션 클라이언트를 넘기면 같은 트랜잭션에 포함된다.
export async function recordStatusChange(client, input): Promise<void> {
  await client.statusHistory.create({
    data: {
      entityType: input.entityType, entityId: input.entityId,
      field: input.field ?? "status", fromValue: input.fromValue ?? null, toValue: input.toValue,
      note: input.note?.trim() ? input.note.trim() : null,
      changedById: input.actor?.userId ?? null, changedByName: input.actor?.name ?? null,
    },
  });
}

알림을 확인 처리할 때, 상태를 바꾸는 것과 이력을 남기는 것을 함께 한다. 이때 행위자 이름은 연결된 직원명을 먼저, 없으면 username을 쓴다 — 이력 화면에 "kim123"이 아니라 사람 이름이 보이도록 한 작은 배려다. 화면에서는 이 이력을 발생순(오래된→최신)으로 뒤집어 보여주고, 상태 변경과 단순 메모를 kind로 구분해 타임라인처럼 그린다.

일괄 확인 — "전체 확인"이 실제로 전체를 처리하지 못하던 문제

알림이 많을 때 하나씩 확인하는 건 고역이다. 그래서 "전체 확인" 버튼이 있었는데, 여기에 앞의 클라이언트 필터와 똑같은 함정이 있었다. 화면에 100건만 로드돼 있으니, "전체 확인"이 그 100건만 처리했다. 1000건이 미결이어도 100건만 확인됐다. 사용자는 다 처리된 줄 알고 떠난다.

해결은 서버에서 updateMany로 적재량과 무관하게 전부 처리하는 것이다.

한 건씩 확인할 때도 같은 패턴이다. "확인(acknowledge)"은 영구 처리가 아니라 "오늘은 숨김"이다. acknowledgedAt을 기록하고, 팝업 라우트가 acknowledgedAt < 오늘을 필터해 자정이 지나면 다시 띄운다. 영구 해결은 별도 /resolve로 분리했다. 이미 해결된 알림을 다시 확인하려 하면 409 ALREADY_RESOLVED로 막는다(1편의 ApiErrors.conflict). 일괄이든 단건이든 상태 변경에는 항상 이력이 따라붙는다는 원칙을 지켰다.

정리하며

  • 알림을 상태·이력을 가진 객체로 모델링했다. 읽음/안읽음이 아니라 미결 → 확인중 → 조치완료의 처리 워크플로다.
  • 이상 감지는 공통 인터페이스를 따르는 독립 detector로 분리해, 규칙 추가가 엔진 변경 없이 detector 등록만으로 끝나게 했다. messagededup 지문으로 써 반복 실행의 중복을 막되, 확인된 알림은 dedup에서 빼 자정 후 재노출을 보장했다.
  • 클라이언트 필터는 데이터가 늘면 반드시 무너진다. "조치완료 0건", "처리한 항목 사라짐", "전체 확인이 일부만 처리"가 모두 같은 원인(적재 윈도 한계)이었고, 답은 필터·정렬·페이징·일괄처리를 전부 서버로 옮기는 것이었다.
  • 정렬에서 status를 키에서 빼 상태를 바꿔도 행이 밀리지 않게 하고, id 보조키로 안정 정렬을 보장했다.
  • 데이터 소스 seam(mock↔live, 같은 시그니처)으로 화면 개발과 백엔드 연동을 분리했다 — 환경변수만 켜면 화면 코드 변경 없이 라이브로 전환된다.
  • 상태 변경에는 항상 이력을 붙이고, 행위자는 사람 이름으로 표시했다.

다음 마지막 3편에서는 같은 백엔드를 쓰는 React Native 직원 앱을 다룬다. 데모 모드로 먼저 만들고 운영에 붙이는 과정, 그리고 1편의 기기 바인딩이 앱 쪽에서 어떻게 완성되는지다.

반응형