개발/백엔드

🚀 [Spring Boot] IoT 헬스케어 시스템의 실시간 데이터 파이프라인 구축기 (SSE + 데이터 샘플링)

taecostudy76 2026. 5. 10. 10:14

📌 1. 들어가며: 쏟아지는 센서 데이터, 어떻게 감당할 것인가?

현재 라즈베리파이를 활용한 '실시간 피보호자 헬스케어 모니터링 시스템'의 백엔드를 개발 중이다. 센서로부터 심박수, 호흡수, 낙상 여부 등의 생체 데이터가 1초 단위로 서버에 전송되고 있는데, 여기서 큰 고민에 빠졌다.

"1초마다 들어오는 데이터를 그대로 DB에 save() 하면, 유저가 늘어날수록 DB I/O 부하가 폭발하지 않을까?"

하지만 보호자 앱에서는 1. 1초 단위의 실시간 그래프를 봐야 하고, 2. 긴급 상황 발생 시 즉시 알림이 가야 한다. 이 딜레마를 해결하기 위해 데이터의 목적에 따라 처리 방식을 나누는 '3-Track 데이터 파이프라인'을 설계하고 적용한 과정을 기록해 본다.


📌 2. 아키텍처 설계: 3-Track 파이프라인

단순히 Controller -> Service -> DB 저장으로 이어지던 일차원적인 로직을, 중간에 VitalService를 '총괄 매니저'로 두어 3갈래로 데이터를 분배하도록 개선했다.

  1. Track A (실시간 모니터링): 1초마다 들어오는 데이터는 DB를 거치지 않고, 가공(DTO 변환) 후 곧바로 SseService를 통해 프론트엔드로 전송한다.
  2. Track B (블랙박스/긴급 상황): 심박수 비정상이나 낙상이 감지되면, 증거 보존을 위해 즉시 EmergencyEvent 테이블에 저장하고 알림을 발생시킨다.
  3. Track C (일반 데이터 샘플링): 가장 부하가 심한 일반 생체 데이터(Vital 테이블)는 10초 주기로 1개씩만 솎아내어(Sampling) 저장한다. 이를 통해 DB 용량과 부하를 1/10 수준으로 최적화했다.

📌 3. 핵심 구현: 동시성을 고려한 10초 샘플링 로직

가장 핵심이 되는 '10초 샘플링' 로직을 구현할 때, 단순히 서버에 long lastTime 변수 하나를 두면 여러 기기에서 동시에 데이터가 들어올 때 초시계가 꼬이는 문제(동시성 문제)가 발생한다.

이를 해결하기 위해 ConcurrentHashMap을 사용하여 '기기별 독립적인 초시계 장부'를 만들었다.

💻 트래픽 최적화를 적용한 코드 (VitalService.java)

Java
 
@Service
@RequiredArgsConstructor
public class VitalService {

    // ... (의존성 주입 생략)

    // 💡 기기별로 마지막 저장 시간을 기록하는 스레드 안전한 Map 장부
    private final Map<String, Long> lastSavedTimeMap = new ConcurrentHashMap<>();

    @Transactional
    public void getVitalData(VitalRequestDto vitalDto) {
        // 1. 유저 및 기기 확인
        Device device = deviceRepository.findBySerialNum(vitalDto.serialNum()).orElseThrow(...);
        Users user = device.getUser();

        // 2. 긴급 상황 검사 (Track B)
        boolean isFallEvent = emergencyEventService.checkFallEvent(vitalDto, user, device);
        boolean isHREvent = emergencyEventService.checkHeartBreathEvent(vitalDto, user, device);
        boolean isEmergency = isFallEvent || isHREvent; // 하나라도 발생하면 긴급!

        // 3. 실시간 SSE 전송 (Track A)
        String statusStr = isEmergency ? "위험" : "정상";
        ResponseUserVital sseData = new ResponseUserVital(user.getId(), vitalDto.heartRate(), vitalDto.breathRate(), statusStr);
        sseService.sendVitalDataToClient(user.getId(), sseData);

        // 4. 10초 샘플링 DB 저장 (Track C)
        long currentTime = System.currentTimeMillis();
        long lastSavedTime = lastSavedTimeMap.getOrDefault(vitalDto.serialNum(), 0L);

        // 마지막 저장 시점으로부터 10초(10,000ms)가 지났을 때만 DB에 Insert
        if (currentTime - lastSavedTime >= 10000) {
            Vital newVital = Vital.builder()
                .user(user)
                .device(device)
                .heartRate(vitalDto.heartRate())
                .breathRate(vitalDto.breathRate())
                .isFallDetected(isEmergency)
                .isPresent(vitalDto.isPresent())
                .build();
            
            vitalRepository.save(newVital);
            
            // 🚨 [주의] 저장 후 반드시 장부의 시간을 현재 시간으로 갱신해야 함!
            lastSavedTimeMap.put(vitalDto.serialNum(), currentTime);
        }
    }
}

📌 4. 오늘 마주친 트러블슈팅 & 배운 점

💡 1. DTO와 Entity의 엄격한 분리

초기에는 라즈베리파이에서 받은 데이터 형식과 프론트로 보내는 형식이 혼재될 뻔했다.

  • 외부 통신(택배 상자): VitalRequestDto, ResponseUserVital (DTO 패키지)
  • 내부 DB(창고): Vital, EmergencyEvent (Entity 패키지) 역할을 명확히 분리하니, 서비스 레이어(VitalService)가 단순한 비즈니스 로직이 아니라 데이터를 가공하고 라우팅하는 훌륭한 파이프라인 매니저 역할을 수행하게 되었다.

💡 2. 상태(State) 갱신의 중요성

초기 코드 작성 시 10초마다 save()는 작성해 놓고, 정작 lastSavedTimeMap.put()으로 타이머를 초기화하는 코드를 빼먹는 실수를 했다. 이 한 줄이 빠지면 첫 10초 이후에는 서버가 무조건 10초가 넘었다고 판단하여 다시 1초마다 DB에 저장하는 폭주 기관차가 되어버린다. 조건문을 통과하여 동작을 수행했다면, 반드시 기준이 되는 상태값을 업데이트해야 함을 뼈저리게 느꼈다.


📌 5. Next Step 🏃‍♂️

1초 단위의 거대한 데이터 흐름을 제어하는 뼈대는 완성되었다. 다음 작업으로는 아래 두 가지를 진행할 예정이다.

  • 데이터 안정화: 센서 데이터가 일시적으로 튀는 노이즈 현상을 막기 위해 이동 평균(Moving Average) 알고리즘 구현하기
  • 배치 처리(Batch): 매일 새벽, 하루 동안 쌓인 샘플링 데이터를 분석해 평균/최소/최대 수치를 계산하고 HealthStatus 테이블에 요약 저장하는 스케줄러 개발하기