[시리즈 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

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

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

이 글은 1편 - 그림으로 풀어낸 SaaS 알림 시스템의 후속편입니다. 들어가며 1편에서는 설비 연속 OFF 알림 기능의 핵심 로직과 어떤식으로 해결했는지 그림으로 알아봤습니다. 이번 글에서는 실무에서 마주한 진짜 고민들을 공유합니다: * 왜 3개의 새로운 테이블이 필요했나? * 어떻게 확장 가능한 구조를 만들었나? * SMS 14원짜리 알림이 왜 무서운가? * 운영 레벨로 나가기까지 무엇을 준비했나?

By Jeonggil
2025, ISACA Korea, Conference

2025, ISACA Korea, Conference

ISACA_2025_공동학술대회_발표자료ISACA_2025_공동학술대회_발표자료.pdf244 MBdownload-circle 이한수, InfoBank Partner * 스타트업 * 자유로움, 행복, 만족하는 삶 * TIPS 정부 지원 * 집중 * Always 수익 개선 * Like Sports (배려, 매너, 열정과 노력, 승복과 존중) 공성배, Megazone Vice P * Ai Native * 실행속도 * 비용 통제 * MIT 최근 통계 * 95% Projects failed * AI * C Level의

By Hyonsok
풀스택으로 구현하는 전력 사용량 비교 대시보드: BFF 패턴부터 자동 Fallback UI까지

풀스택으로 구현하는 전력 사용량 비교 대시보드: BFF 패턴부터 자동 Fallback UI까지

전력 사용량 비교 서비스를 구현하며 배운 BFF 패턴, 비동기 병렬 처리, 그리고 사용자 경험 설계 이야기 들어가며 전력 사용량 비교 분석 기능을 개발하면서 흥미로운 고민에 직면했습니다. 클라이언트에게 4가지 서로 다른 데이터를 제공해야 하는데, 각각을 독립적인 API로 개발할지, 아니면 하나의 API로 통합해서 제공할지 결정해야 했습니다. API 설계 방향 결론부터 말하자면, 단일

By Jeonggil
Claude Code와 Obsidian MCP 연동 가이드

Claude Code와 Obsidian MCP 연동 가이드

소개 Claude Code는 Anthropic의 공식 CLI 도구로, MCP(Model Context Protocol)를 통해 다양한 외부 도구와 연동할 수 있습니다. 이 가이드에서는 Claude Code와 Obsidian을 연동하여 AI 에이전트가 여러분의 노트를 읽고 편집할 수 있도록 설정하는 방법을 소개합니다. MCP(Model Context Protocol)란? MCP는 AI 모델이 외부 데이터 소스 및 도구와 상호작용할

By Kyeongrok.kim