최근 라즈베리 파이(IoT)에서 수집한 활력 징후(심박수, 호흡수 등) 데이터를 프론트엔드로 실시간 전달하는 백엔드 서버를 개발하고 있습니다. 이 과정에서 SSE(Server-Sent Events)를 활용한 실시간 통신 원리를 깊게 이해하게 되었고, JPA를 사용할 때 Optional을 처리하는 로직을 제 방식대로 깔끔하게 리팩토링한 경험을 공유하고자 합니다.
1. SSE(Server-Sent Events) 통신: 헷갈렸던 데이터의 흐름
초기에는 라즈베리 파이에서 서버로 데이터를 쏘는 부분(POST /api/vitals)에 프론트엔드로 데이터를 전달하는 로직이 섞여 있어서 통신 방식에 대한 혼란이 있었습니다.
"클라이언트가 조회를 하려면 무조건 GET 방식을 써야 하는데, 왜 POST 요청 안에서 클라이언트에게 데이터를 쏴주고 있지?"
이 혼란은 SSE의 본질인 "단방향 Push"와 "스트림(Stream)"의 개념을 깨달으면서 완벽하게 해결되었습니다. 서버에는 총 3개의 문(API)이 분리되어 있어야 했습니다.
- 데이터 수신 (POST): 라즈베리 파이가 측정한 데이터를 서버로 던지는 문.
- 실시간 파이프 연결 (GET): 프론트엔드가 처음 접속해 EventSource로 "나 계속 데이터 받을게!" 하고 수도관(SSE)을 열어두는 문.
- 과거 기록 조회 (GET): 파이프 연결 전의 과거 데이터를 한 번에 가져가는 일반적인 문.
즉, 라즈베리 파이가 POST로 데이터를 던지자마자, 서버는 DB에 이를 저장함과 동시에 미리 열려있던 프론트엔드의 GET 파이프(SSE)**를 통해 데이터를 물이 흘러가듯(Stream) 강제로 밀어 넣는 것이었습니다.
단순히 API를 만드는 것을 넘어, ConcurrentHashMap을 이용해 여러 유저의 SseEmitter(수도관)를 스레드 안전하게 관리하고, 연결이 끊기면 장부에서 삭제하는 디테일한 로직까지 고민해 볼 수 있는 좋은 경험이었습니다.
2. 논리적 모순 해결: Optional과 orElseThrow의 올바른 사용법
기기를 조회하는 로직을 작성하던 중, 기존 로직의 불필요한 점을 발견하고 구조를 개선했습니다. 기존 코드는 아래와 같이 작성되어 있었습니다.
[수정 전 코드: 논리적 모순]
public Optional<DeviceDto.ResponseDevice> getDeviceStatus(Long userId) {
Device device = deviceRepository.findByUserId(userId)
.orElseThrow(() -> new IllegalArgumentException("연결 된 디바이스가 없습니다."));
boolean isConnected = checkConnection(device.getLastConnectedAt());
return new DeviceDto.ResponseDevice(...); // 타입 에러 발생!
}
이 코드는 다음과 같은 두 가지 문제가 있었습니다.
- orElseThrow()를 사용해 예외 처리를 마쳤음에도, 메서드의 반환 타입을 Optional로 선언했습니다.
- 예외가 터지지 않고 맨 밑의 return까지 도달했다는 것은 **"기기가 무조건 100% 존재한다"**는 뜻인데, 굳이 "없을 수도 있다"는 의미의 Optional로 다시 포장할 필요가 없었습니다.
[수정 후 코드: 깔끔한 단일 객체 반환]
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)을 반환하는 것이 논리적으로 타당한가?"를 치열하게 고민하게 되었습니다. 앞으로도 프론트엔드와 유연하게 소통하며, 불필요한 리소스 낭비가 없는 단단한 백엔드 서버를 구축해 나가고 싶습니다.
'개발 > 백엔드' 카테고리의 다른 글
| 🚀 [Spring Boot] IoT 헬스케어 시스템의 실시간 데이터 파이프라인 구축기 (SSE + 데이터 샘플링) (0) | 2026.05.10 |
|---|---|
| [백엔드 개발기] 실시간 IoT 헬스케어 생체 데이터 파이프라인 구축 및 아키텍처 설계 (2) | 2026.05.08 |
| [JPA/Spring] 엔티티 설계의 정석: @NoArgsConstructor(access = AccessLevel.PROTECTED)와 @Builder의 조화 (0) | 2026.03.11 |
| 멀티캠퍼스 [현대 이지웰] Spring Boot 활용하여 기본 CRUD 기능 구현 (0) | 2025.12.01 |
| 멀티캠퍼스 [현대 이지웰] 인터페이스 기반 프로젝션이란? (0) | 2025.11.24 |