[시리즈 1편] 그림으로 풀어낸 SaaS 알림 시스템

[시리즈 1편] 그림으로 풀어낸 SaaS 알림 시스템

들어가며

제조업 IoT 플랫폼에서 N대 이상의 설비를 실시간으로 모니터링하고, 설비가 연속으로 꺼졌을 때 담당자에게 즉시 알림을 보내는 기능을 개발하게 되었습니다.

데이터는 실시간으로 쌓이지만, 설비이상을 체크하는 스케줄러 주기는 1분으로 설정하였습니다.


시스템 아키텍처

기존 인프라와 Push 기능은 이미 구축되어 있었습니다.

저는 중간에 들어가는 Alert Scheduler만 구현하면 되는 상황이었습니다.

┌──────────────────────────────────────────────────────────┐
│  설비 IoT 센서 (실시간)                                  │
│  - N개 회사, M개 공장, 수백 대 설비                      │
└──────────────────────────────────────────────────────────┘
                        ↓
┌──────────────────────────────────────────────────────────┐
│  IoT Hub                                                 │
│  - 실시간 데이터 수집                                    │
└──────────────────────────────────────────────────────────┘
                        ↓
┌──────────────────────────────────────────────────────────┐
│  Dataframe (Stream Processing)                           │
│  - 스트림 데이터 처리                                    │
└──────────────────────────────────────────────────────────┘
                        ↓
┌──────────────────────────────────────────────────────────┐
│  MSSQL Monitoring 테이블                                 │
│  - 실시간 적재 (분당 수천 건)                            │
│  - id_machine, onoff_monitored, monitored_at_datetime    │
└──────────────────────────────────────────────────────────┘
                        ↓
┌──────────────────────────────────────────────────────────┐
│  ⚙️ Alert Scheduler (구현 부분)                         │
│  - @Scheduled(cron = "0 * * * * *") - 매 분 0초 실행    │
│  - ShedLock: 다중 인스턴스 중복 실행 방지                │
│  - 연속 OFF 검증 + 3-Way 분기 처리                       │
└──────────────────────────────────────────────────────────┘
                        ↓
        ┌───────────────┴───────────────┐
        ↓                               ↓
┌─────────────────────┐      ┌──────────────────────┐
│  Notification DB    │      │  Mobile App Push     │
│  (웹 알림)          │      │  (Expo)              │
│  - 기존 인프라 활용 │      │  - 기존 인프라 활용  │
└─────────────────────┘      └──────────────────────┘

도메인 구조: Company → Factory → Machine (3-Tier 멀티테넌시)


핵심 서비스 로직 순서도

매 1분마다
  ↓
┌─────────────────────────────────────┐
│ 1. 활성화된 Alert Rule 조회         │
│    (회사별 알림 규칙)               │
└─────────────────────────────────────┘
  ↓
┌─────────────────────────────────────┐
│ 2. 각 Company별 처리                │
│    ┌─────────────────────────────┐ │
│    │ ① 설비 ID 목록 조회         │ │
│    │ ② 모니터링 데이터 배치 조회 │ │
│    │ ③ 알림 상태 배치 조회       │ │
│    │ ④ 메모리 그룹핑             │ │
│    └─────────────────────────────┘ │
└─────────────────────────────────────┘
  ↓
┌─────────────────────────────────────┐
│ 3. 각 설비별 처리                   │
│    ┌─────────────────────────────┐ │
│    │ 연속 OFF 검증               │ │
│    │   - 데이터 개수 체크        │ │
│    │   - 모두 OFF 상태?          │ │
│    │   - 시간 간격 3분 이내?     │ │
│    └─────────────────────────────┘ │
└─────────────────────────────────────┘
  ↓
조건 충족 시
  ↓
┌─────────────────────────────────────┐
│ 4. 3-Way 분기                       │
│    - 재전송 주기 도래? → 재발송    │
│    - 신규 알림? → 발송              │
│    - 재전송 대기? → 스킵            │
└─────────────────────────────────────┘
  ↓
┌─────────────────────────────────────┐
│ 5. 알림 발송                        │
│    - DB 저장 (웹)                   │
│    - Expo Push (모바일)             │
└─────────────────────────────────────┘
  ↓
┌─────────────────────────────────────┐
│ 6. Bulk 알림 해제                   │
│    (조건 해소된 설비)               │
└─────────────────────────────────────┘

핵심 개선: N+1 → 배치 쿼리

Before: N+1 지옥

for (AlertRule rule : activeRules) {
    for (Machine machine : getMachines(rule.getCompanyId())) {
        ...
    }
}

회사 × 설비 × 쿼리 = 수천 번의 DB 호출 → 스케줄러 실행 시간 오버


After: 배치 쿼리 + 메모리 그룹핑

private void checkRuleForCompany(AlertRuleDto rule) {
    // 1. Company에 속한 설비 ID 조회
    List<String> machineIds = machineRepository
        .findMachineIdsByCompanyId(rule.getIdCompany(), "Y");

    if (machineIds.isEmpty()) return;

    // 2. 모니터링 데이터 조회 및 그룹핑
    Map<String, List<MonitoringAlertDto>> dataByMachine =
        fetchMonitoringDataByMachine(machineIds, rule.getCount());

    // 3. 알림 상태 조회 (2회)
    List<String> machineIdsWithAlerts = alertMachineRepository
        .findMachineIdsWithUnresolvedAlerts(machineIds, rule.getRuleType());

    LocalDateTime resendThreshold = LocalDateTime.now()
        .minusMinutes(rule.getResendIntervalMinutes());
    Map<String, AlertMachineDto> resendAlertMap =
        fetchAlertsNeedingResend(machineIds, rule.getRuleType(), resendThreshold);

    // 4. 각 설비별 알림 조건 체크 및 처리
    List<String> machineIdsToResolve = processAlertsForMachines(
        machineIds, rule, dataByMachine, machineIdsWithAlerts, resendAlertMap);

    // 5. 알림 일괄 해제
    bulkResolveAlerts(machineIdsToResolve, rule.getRuleType());
}

핵심 메서드 분리 (단일 책임 원칙):

// 모니터링 데이터 조회 + 그룹핑
private Map<String, List<MonitoringAlertDto>> fetchMonitoringDataByMachine(
    List<String> machineIds, int count) {
        ...
}

// 재전송 필요한 알림 조회
private Map<String, AlertMachineDto> fetchAlertsNeedingResend(
    List<String> machineIds, String alertType, LocalDateTime threshold) {
        ...
}

// 각 설비 처리
private List<String> processAlertsForMachines(...) {
    List<String> machineIdsToResolve = new ArrayList<>();
        ...
}

연속 OFF 검증: "진짜 연속인가?"

3단계 필터링

public boolean checkConsecutiveOff(List<MonitoringAlertDto> data, int count) {
    // ① 개수 체크
    if (data.size() < count) return false;

    // ② 모두 OFF?
    boolean allOff = data.stream()
        .allMatch(d -> "N".equals(d.getOnoffMonitored()) ||
                       "0".equals(d.getOnoffMonitored()));
    if (!allOff) return false;

    // ③ 시간 간격 3분 이내? (연속성 검증)
    for (int i = 0; i < data.size() - 1; i++) {
        long minutes = Duration.between(
            data.get(i+1).getMonitoredAt(),
            data.get(i).getMonitoredAt()
        ).toMinutes();

        if (minutes > 3) return false;  // 간격 초과 = 연속 아님
    }
    return true;
}

시각화:

✅ 연속 OFF (알림 발송)          ❌ 간격 초과 (알림 미발송)
━━━━━━━━━━━━━━━━━━━━━          ━━━━━━━━━━━━━━━━━━━━━
10:03  N (OFF)                  10:10  N (OFF)
      ↕ 1분                            ↕ 5분 ✗
10:02  N (OFF)                  10:05  N (OFF)
      ↕ 1분                            ↕ 5분 ✗
10:01  N (OFF)                  10:00  N (OFF)

왜 3분? 1분, 2분은 애매해서 일단 3분으로 설정했고, 언제든 바꿀 수 있음.


재전송 로직 & 알림 발송

처리 흐름

연속 OFF 감지
  ↓
┌──────────────────────────────────────┐
│ 3-Way 분기                           │
│                                      │
│ resendAlert 존재?                    │
│   ├─ Yes → 재전송 (기존 해제+신규)    │
│   └─ No                              │
│        └─ hasExistingAlert?          │
│             ├─ No → 신규 알림         │
│             └─ Yes → 스킵 (재전송 대기)│
└──────────────────────────────────────┘
  ↓
┌──────────────────────────────────────┐
│ PushNotificationService              │
│ (기존 인프라 재사용)                 │
│                                      │
│ • Notification DB 저장 (웹)         │
│ • Expo Push 발송 (모바일)           │
└──────────────────────────────────────┘

타임라인:

Day 1 10:00  신규 알림 발송 🔔
Day 1~3      스킵 (재전송 대기)
Day 4 10:00  재알림 발송 🔔 (3일 경과)
Day 5 14:00  설비 ON → 자동 해제 ✅

최종 성과

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
항목                Before    After
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DB 쿼리              수천      수 회
실행 시간            45초      2초
1분 주기 유지        ❌        ✅
알림 지연            수 분     실시간
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

핵심 교훈

1. 복잡한 로직은 단일 책임으로 분리

생각보다 로직이 복잡해서 checkRuleForCompany() 하나에 다 넣으면 수백줄 메서드가 됨.

단일 책임 원칙으로 메서드 분리:

  • fetchMonitoringDataByMachine(): 데이터 조회 + 그룹핑
  • fetchAlertsNeedingResend(): 재전송 대상 조회
  • processAlertsForMachines(): 각 설비 처리
  • bulkResolveAlerts(): 일괄 해제

각 메서드가 하나의 책임만 → 테스트, 유지보수 쉬워짐.

2. 단순해 보이는 기능도 SaaS에서는 복잡도 증가

처음엔 "설비 몇번 꺼지면 알림 보내기"라는 단순한 기능으로 생각했지만, SaaS 환경에서는:

  • 멀티테넌시 (회사별 데이터 격리)
  • 실시간성 요구사항 (1분 주기)
  • 재전송 로직 (3-Way 분기)
  • False Positive 방지 (연속성 검증)

등으로 복잡도가 급증했습니다. 순서도와 아키텍처 다이어그램을 그려가며 설계하니 놓칠 수 있는 엣지 케이스를 사전에 발견할 수 있었고, 커뮤니케이션도 훨씬 수월했습니다.


마치며

단순한 알림 시스템도 실시간성, 정확성, 성능을 모두 충족하려면 복잡도가 급증합니다.

특히 제조업 IoT 환경에서는 1분의 지연도 큰 손실로 이어지고, False Positive는 신뢰도 하락, N+1은 시스템 마비를 야기합니다.

이 글이 비슷한 문제를 겪는 분들께 도움이 되길 바랍니다.

감사합니다.


Read more

Lumen - AI Agent를 위한 지속 가능한 두뇌

Lumen - AI Agent를 위한 지속 가능한 두뇌

왜 만들었는가 AI 에이전트는 모든 대화를 기억상실증 상태에서 시작합니다. Claude Code, Cursor, Codex, Mastra 하네스, LangChain 파이프라인 — 이 도구들은 세상을 알지만 당신의 세상은 전혀 모릅니다. 당신이 읽은 200편의 논문, 당신이 출시하는 코드베이스, 지난 분기에 내린 아키텍처 결정, 새벽 2시에 그 버그를 잡아냈을 때 마침내 통했던 트래젝토리. 모든 세션이 같은 컨텍스트를

By Sardor Madaminov

200 OK, 텅 빈 body — Starlette Race Condition 장애 분석기

발생일: 2026-04-23 / 해결일: 2026-04-27 영향 범위: report-dev.machine365.ai 전체 API 들어가며 API가 200 OK를 반환하는데 body가 비어있다. 프론트엔드에는 아무것도 안 뜨고, Swagger UI(/docs)도 빈 화면. 그런데 로컬에서 돌리면 멀쩡하다. 배경은 이랬다. 미터링(Metering) 기능을 만들면서 API 호출 로그를 수집할 미들웨어를 작성했다. Spring Boot 백엔드에 먼저 적용하고, Python

By Jeonggil