개발/백엔드

[Spring Boot] IoT 실시간 모니터링: SSE 통신 원리와 Optional 리팩토링 경험기

taecostudy76 2026. 4. 16. 15:34

최근 라즈베리 파이(IoT)에서 수집한 활력 징후(심박수, 호흡수 등) 데이터를 프론트엔드로 실시간 전달하는 백엔드 서버를 개발하고 있습니다. 이 과정에서 SSE(Server-Sent Events)를 활용한 실시간 통신 원리를 깊게 이해하게 되었고, JPA를 사용할 때 Optional을 처리하는 로직을 제 방식대로 깔끔하게 리팩토링한 경험을 공유하고자 합니다.

1. SSE(Server-Sent Events) 통신: 헷갈렸던 데이터의 흐름

초기에는 라즈베리 파이에서 서버로 데이터를 쏘는 부분(POST /api/vitals)에 프론트엔드로 데이터를 전달하는 로직이 섞여 있어서 통신 방식에 대한 혼란이 있었습니다.

"클라이언트가 조회를 하려면 무조건 GET 방식을 써야 하는데, 왜 POST 요청 안에서 클라이언트에게 데이터를 쏴주고 있지?"

이 혼란은 SSE의 본질인 "단방향 Push"와 "스트림(Stream)"의 개념을 깨달으면서 완벽하게 해결되었습니다. 서버에는 총 3개의 문(API)이 분리되어 있어야 했습니다.

  1. 데이터 수신 (POST): 라즈베리 파이가 측정한 데이터를 서버로 던지는 문.
  2. 실시간 파이프 연결 (GET): 프론트엔드가 처음 접속해 EventSource로 "나 계속 데이터 받을게!" 하고 수도관(SSE)을 열어두는 문.
  3. 과거 기록 조회 (GET): 파이프 연결 전의 과거 데이터를 한 번에 가져가는 일반적인 문.

즉, 라즈베리 파이가 POST로 데이터를 던지자마자, 서버는 DB에 이를 저장함과 동시에 미리 열려있던 프론트엔드의 GET 파이프(SSE)**를 통해 데이터를 물이 흘러가듯(Stream) 강제로 밀어 넣는 것이었습니다.

단순히 API를 만드는 것을 넘어, ConcurrentHashMap을 이용해 여러 유저의 SseEmitter(수도관)를 스레드 안전하게 관리하고, 연결이 끊기면 장부에서 삭제하는 디테일한 로직까지 고민해 볼 수 있는 좋은 경험이었습니다.

2. 논리적 모순 해결: Optional과 orElseThrow의 올바른 사용법

기기를 조회하는 로직을 작성하던 중, 기존 로직의 불필요한 점을 발견하고 구조를 개선했습니다. 기존 코드는 아래와 같이 작성되어 있었습니다.

[수정 전 코드: 논리적 모순]

Java
 
public Optional<DeviceDto.ResponseDevice> getDeviceStatus(Long userId) {

    Device device = deviceRepository.findByUserId(userId)
        .orElseThrow(() -> new IllegalArgumentException("연결 된 디바이스가 없습니다."));

    boolean isConnected = checkConnection(device.getLastConnectedAt());

    return new DeviceDto.ResponseDevice(...); // 타입 에러 발생!
}

이 코드는 다음과 같은 두 가지 문제가 있었습니다.

  1. orElseThrow()를 사용해 예외 처리를 마쳤음에도, 메서드의 반환 타입을 Optional로 선언했습니다.
  2. 예외가 터지지 않고 맨 밑의 return까지 도달했다는 것은 **"기기가 무조건 100% 존재한다"**는 뜻인데, 굳이 "없을 수도 있다"는 의미의 Optional로 다시 포장할 필요가 없었습니다.

[수정 후 코드: 깔끔한 단일 객체 반환]

Java
 
public DeviceDto.ResponseDevice getDeviceStatus(Long userId) {

    // 1. 없으면 바로 예외를 던지고 종료!
    Device device = deviceRepository.findByUserId(userId)
        .orElseThrow(() -> new IllegalArgumentException("연결 된 디바이스가 없습니다."));

    // 2. 예외가 안 터졌다면 무조건 객체가 있으므로 안전하게 로직 실행
    boolean isConnected = checkConnection(device.getLastConnectedAt());

    // 3. Optional 껍질 없이 DTO 알맹이만 깔끔하게 반환
    return new DeviceDto.ResponseDevice(
        device.getId(),
        isConnected,
        device.getSerialNum()
    );
}

[리팩토링의 결과] "기기가 1대만 존재한다"는 비즈니스 로직에 맞춰 반환값을 단일 객체로 변경했고, 중간에 orElseThrow()로 방어벽을 세웠으니 반환 타입에서 과감하게 Optional을 제거했습니다. 덕분에 코드가 위에서 아래로 물 흐르듯 자연스러워졌고, 이 메서드를 호출하는 컨트롤러 입장에서도 껍질을 깔 필요 없이 바로 DTO를 사용할 수 있게 되었습니다.

💡 마무리하며

이번 경험을 통해 "돌아가는 코드"를 짜는 것을 넘어, "왜 이 통신 방식을 사용하는가?", "이 타입(Optional)을 반환하는 것이 논리적으로 타당한가?"를 치열하게 고민하게 되었습니다. 앞으로도 프론트엔드와 유연하게 소통하며, 불필요한 리소스 낭비가 없는 단단한 백엔드 서버를 구축해 나가고 싶습니다.