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

A2A 메모리 레이어: AI 에이전트를 위한 공유 두뇌 구축하기

A2A 메모리 레이어: AI 에이전트를 위한 공유 두뇌 구축하기

모든 것의 시작이 된 문제 우리를 계속 괴롭히던 것이 있었습니다: 왜 AI 에이전트는 대화가 끝나는 순간 모든 것을 잊어버릴까요? 한번 생각해보세요. 챗봇에게 전화보다 이메일을 선호한다고 말합니다. 다음 세션에서는요? 마치 처음 만난 것처럼 행동합니다. 한 상담원에게 주문 문제를 설명하고, 다른 상담원에게 연결되면, 갑자기 처음부터 다시 시작해야 합니다. 우리 모두 겪어본 일입니다.

By Sardor Madaminov
[시리즈 2편] 실무로 배우는 메시지 큐 - RabbitMQ

[시리즈 2편] 실무로 배우는 메시지 큐 - RabbitMQ

들어가며 [시리즈1]에서는 프로세스 내부 메시지 큐를 다뤘습니다. 이번엔 네트워크 메시지 큐인 RabbitMQ를 다룹니다. RabbitMQ 공식 문서나 기술 블로그는 많지만, 실무에서 어떻게 사용하는지에 대한 글은 의외로 적습니다. "Producer가 뭐고 Consumer가 뭔지는 알겠는데, 그래서 실제로는 어떻게 쓰는데?" 이번 글에서는 우리 MES 시스템에서 RabbitMQ를 어떻게 활용하고 있는지 실제 코드와 함께 공유합니다. 우리

By Jeonggil
[시리즈 1편] 실무로 배우는 메시지 큐 - Windows Message Loop

[시리즈 1편] 실무로 배우는 메시지 큐 - Windows Message Loop

들어가며 이 글은 "실무로 배우는 메시지 큐" 시리즈의 첫 번째 글입니다. 실무에서 발견한 문제를 해결하는 과정에서, IME 입력 문제와 해결 과정을 공유합니다. 메시지 큐는 RabbitMQ, Kafka 같은 네트워크 레벨만 있는 게 아닙니다. 우리가 매일 쓰는 Windows 애플리케이션도 메시지 큐 기반으로 동작합니다. * 시리즈1 (이 글): 프로세스 내부의 메시지 큐 - Windows

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

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

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

By Jeonggil