[시리즈 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은 시스템 마비를 야기합니다.
이 글이 비슷한 문제를 겪는 분들께 도움이 되길 바랍니다.
감사합니다.
