1. 개요

이번에 개발하는 프로젝트 요구사항에 실시간으로 기기의 정보를 받아와 관리자 화면에 보여줘야하는 요구사항이 있어 실시간성 기술이 필요한 기술을 사용하게 되었습니다. 제미나이와 토론하며 얻은 지식과 기술적 결정의 배경을 정리해, 비슷한 고민을 하는 분들과 나누고자 합니다.
2. 기존에 웹 소켓을 사용했던 배경

AWS 웹소켓은 수만 개의 커넥션을 직접 관리해 줍니다. 애플리케이션 서버는 연결 시 커넥션 정보만 저장하면, 이벤트 기반으로 쉽게 데이터를 전달할 수 있어요. 커넥션 리소스도 AWS가 처리하므로 서버 부담과 유지보수 비용이 모두 줄어듭니다.
이런 장점 덕분에 기존 애플리케이션은 소켓으로 실시간 조회를 제공했습니다. 하지만 IDC 환경에서는 상황이 다릅니다. AWS가 제공하던 기능을 직접 만들고 관리해야 하기 때문입니다. 그래서 소켓 서버를 외주로 맡길지, 직접 개발할지 고민했습니다.
3. SSE 통신을 선택한 이유?

개발자가 적은 스타트업에서는 오버엔지니어링보다 언더엔지니어링이 낫습니다. 한 명 한 명의 리소스가 중요한 환경에서 복잡한 설계는 유지보수 부담이 크기 때문입니다. 그래서 가장 빠르게 익히고 쉽게 유지보수할 수 있는 기술을 선택해야 했습니다.
SSE는 HTTP 기반이라 기존 애플리케이션에 쉽게 적용할 수 있습니다. 단방향 통신이라 디버깅도 간편합니다. 또한 서버가 원하는 시점에 이벤트를 보낼 수 있어서, 기기 상태가 변할 때 상태를 변경해야하는 요구사항에 가장 적합한 기술이라 판단했습니다.
3. SSE의 개념

SSE(Server-Sent Events)는 서버가 클라이언트 요청 없이도 데이터를 전달할 수 있는 단방향 통신 채널입니다. SSE 통신 흐름은 다음과 같습니다.
SSE의 통신 흐름
- 클라이언트의 연결 요청 (HTTP GET): 클라이언트는 서버에 표준 HTTP GET 요청을 보냅니다. 이때 가장 중요한 포인트는 헤더에 Accept: text/event-stream을 포함하여 "나는 스트림 형태의 응답을 원한다"고 명시하는 것 입니다.
- 서버의 응답 헤더 설정: 서버는 이 요청을 받고 연결을 유지하기 위해 다음과 같은 특수한 HTTP 응답 헤더를 보냅니다.
- Content-Type: text/event-stream: 데이터를 스트림으로 보냄을 명시합니다.
- Cache-Control: no-cache: 브라우저가 데이터를 캐싱하지 않도록 합니다.
- Connection: keep-alive: TCP 연결을 끊지 않고 유지합니다.
- 지속적인 데이터 송신 (Server Push): 연결이 확립되면 서버는 필요한 시점마다 데이터를 전송합니다. 데이터는 반드시 특정 형식을 따라야 하며, 메시지의 끝은 두 개의 줄바꿈(\n\n)으로 구분합니다
- 자동 재연결 (Automatic Reconnection): 네트워크 불안정으로 연결이 끊기면, 브라우저는 자동으로 재연결을 시도합니다. 이때 마지막으로 수신한 id 값을 Last-Event-ID 헤더에 담아 보내어, 서버가 누락된 데이터부터 다시 보낼 수 있게 돕습니다.
SSE 메시지 예시
id: 1
event: message
data: {"status": "processing"}
4. SSE를 Spring Boot에서 구현할 때 유의사항

스프링 환경에서 SSE를 구현할 때 유의할 점은 두 가지입니다. 첫째, SSE는 커스텀 헤더를 사용할 수 없습니다. 둘째, 연결이 끊어지면 DB 커넥션 풀처럼 리소스를 정리해야 합니다. 셋째 다수의 커넥션을 관리해야할 때 멀티 스레드 처리의 안정적인 자료구조 선택하는 것입니다.
예시1: 파라미터로 토큰 정보를 받아 처리하기
// Controller
@GetMapping(value = "/sse/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect(@RequestParam("token") String token) {
// 헤더 대신 쿼리 파라미터로 인증
validateToken(token);
SseEmitter emitter = new SseEmitter(60_000L);
sseService.addEmitter(emitter);
return emitter;
}
예시2: 연결 종료나 에러 발생 시 리소스를 정리하기
@Service
public class SseService {
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
public void addEmitter(SseEmitter emitter) {
emitters.add(emitter);
// 연결 종료 시 리소스 정리
emitter.onCompletion(() -> {
emitters.remove(emitter);
cleanupResources(emitter);
});
emitter.onTimeout(() -> {
emitters.remove(emitter);
cleanupResources(emitter);
});
emitter.onError(e -> {
emitters.remove(emitter);
cleanupResources(emitter);
});
}
private void cleanupResources(SseEmitter emitter) {
// DB 커넥션, 캐시 등 리소스 정리
log.info("SSE 연결 종료 - 리소스 정리 완료");
}
}
예시3: 멀티스레드 처리에 안정적인 자료구조 사용하기
리스트 자료구조
CopyOnWriteArrayList는 ArrayList와 달리 요소 추가 시 락을 점유합니다. 그 뒤 기존 배열을 복사해서 수정하고, 참조 주소를 새 배열로 변경합니다. 멀티스레드 환경에서 안전하지만, 배열을 통째로 복사하다 보니 쓰기 비용이 큽니다.
| 특징 | ArrayList | CopyOnWriteArrayList |
| 스레드 안전성 | 비안전 (Thread-Unsafe) | 안전 (Thread-Safe) |
| 변경 방식 | 기존 배열을 직접 수정 | 새로운 배열을 복사하여 수정 (COW 전략) |
| 반복자 | Fail-Fast: 순회 중 수정 시 예외 발생 | Fail-Safe: 스냅샷을 순회하여 예외 없음 |
| 쓰기 성능 | 빠름 (배열 확장 시에만 비용 발생) | 매우 느림 (매 수정마다 배열 복사 발생) |
| 읽기 성능 | 빠름 | 매우 빠름(락 없이 읽기 가능) |
@Service
public class SseService {
// 단일 프로세스에서는 자료구조로 관리
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
public void addEmitter(SseEmitter emitter) {
emitters.add(emitter);
emitter.onCompletion(() -> emitters.remove(emitter));
emitter.onTimeout(() -> emitters.remove(emitter));
emitter.onError(e -> emitters.remove(emitter));
}
public void broadcast(String data) {
emitters.forEach(emitter -> {
try {
emitter.send(SseEmitter.event().data(data));
} catch (IOException e) {
emitters.remove(emitter);
}
});
}
}
해시 맵 자료구조
HashMap은 락 없이 데이터를 수정할 수 있습니다. 한 스레드가 데이터를 읽는 동안 다른 스레드가 수정하거나 삭제할 수 있어서 스레드 안전성이 떨어집니다.
반면 ConcurrentHashMap은 버킷마다 락이 있어서 같은 버킷을 동시에 처리할 수 없습니다. 또한 일부 작업에는 CAS(Compare-And-Swap)를 활용합니다. 일반적인 방식은 '값 확인 → 값 변경'을 따로 처리해서 읽는 중간에 다른 스레드가 끼어들 수 있습니다.
하지만 CAS는 '현재 값이 A면 B로 바꿔라'를 하나의 작업으로 처리해서 끼어들 틈이 없습니다. 락처럼 대기하지 않고 실패하면 바로 재시도하기 때문에 성능도 좋습니다. 그리고 스레드가 값을 복사해서 들고 있지 않고 원본을 직접 바라보기 때문에, 멀티스레드 환경에서 안전합니다
| 특징 | HashMap | ConcurrentHashMap |
| 스레드 안전 | 안전하지 않음(Thread-unsafe) | 안전함(Thread-safe) |
| 동기화 방식 | 없음 (수동 동기화) | CAS(Compare-And-Swap) & 분할 잠금(Lock Striping) |
| Null 허용 | Key(1개), Value(여러 개) 허용 | Key, Value 모두 불허용 |
| 성능 | 단일 스레드에서 가장 빠름 | 멀티 스레드 환경에서 효율적 |
| Iterator | Fail-fast(반복 중 수정 시 예외 발생) | Fail-safe(반복 중 수정해도 안전) |
@Service
public class SseService {
// 사용자별로 Emitter 관리
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
public void addEmitter(String userId, SseEmitter emitter) {
emitters.put(userId, emitter);
emitter.onCompletion(() -> emitters.remove(userId));
emitter.onTimeout(() -> emitters.remove(userId));
emitter.onError(e -> emitters.remove(userId));
}
// 특정 사용자에게 전송
public void sendToUser(String userId, String data) {
SseEmitter emitter = emitters.get(userId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event().data(data));
} catch (IOException e) {
emitters.remove(userId);
}
}
}
// 전체 사용자에게 전송
public void broadcast(String data) {
emitters.forEach((userId, emitter) -> {
try {
emitter.send(SseEmitter.event().data(data));
} catch (IOException e) {
emitters.remove(userId);
}
});
}
}
멀티 프로세스 환경에서 스레드 안정성 있게 처리하기

로드 밸런싱을 하는 멀티 프로세스 환경이라면 애플리케이션의 자료구조만으로는 부족합니다. 서로 다른 프로세스가 각자 데이터를 보유하고 있기 때문입니다. 요청이 어느 프로세스로 가느냐에 따라 구독 중인 SseEmitter 수가 달라질 수 있습니다. 이런 경우에는 Redis 같은 외부 인프라를 활용하는 것이 좋습니다.
@Service
@RequiredArgsConstructor
public class SseService {
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
private final RedisTemplate<String, String> redisTemplate;
public void addEmitter(SseEmitter emitter) {
emitters.add(emitter);
emitter.onCompletion(() -> emitters.remove(emitter));
emitter.onTimeout(() -> emitters.remove(emitter));
emitter.onError(e -> emitters.remove(emitter));
}
// 메시지 발행: 모든 서버에 전달
public void publish(String data) {
redisTemplate.convertAndSend("sse-channel", data);
}
// 메시지 수신: 자기 서버의 emitters에게 전달
public void broadcast(String data) {
emitters.forEach(emitter -> {
try {
emitter.send(SseEmitter.event().data(data));
} catch (IOException e) {
emitters.remove(emitter);
}
});
}
}
4. 성급한 추상화하지 않기
지금까지 SSE의 기본 개념과 구현 방법을 알아봤습니다. 더 깊이 들어가면 다양한 상황을 마주할 수 있습니다. 사용자마다 전달해야 하는 이벤트가 다르다면? 네트워크가 끊겨 메시지를 받지 못한 사용자는 어떻게 처리할까?
하지만 이런 문제들을 미리 고민해서 해결할 필요는 없습니다. 실제로 그런 요구사항이 생겼을 때 구현해도 늦지 않습니다. 지금 당장은 이벤트 구독 로직과 메시지 전달 로직을 애플리케이션 이벤트로 분리하는 것만으로도 충분합니다.
@Service
@RequiredArgsConstructor
public class NotificationService {
private final ApplicationEventPublisher eventPublisher;
public void notify(String userId, String data) {
// 메시지 전달만 담당
eventPublisher.publishEvent(new SseMessageEvent(userId, data));
}
}
// 이벤트 수신 후 SSE 전송
@EventListener
public void handleSseMessage(SseMessageEvent event) {
SseEmitter emitter = emitters.get(event.getUserId());
if (emitter != null) {
try {
emitter.send(SseEmitter.event().data(event.getData()));
} catch (IOException e) {
emitters.remove(event.getUserId());
}
}
}
후기
요즘 클로드와 제미나이의 학습 모드를 사용하고 있습니다. 예전에는 AI가 설명만 하고 끝났는데, 이제는 질문을 던지고 의견을 주고받습니다. 정말 동료와 함께 개발하는 느낌이에요.
혼자 개발하다 보면 내가 아는 범위에 갇히거나, 하나의 방법에 꽂혀 더 좋은 대안을 놓칠 때가 많습니다. 하지만 AI 도구를 활용하면서 달라졌습니다. 한 명의 개발자가 팀 수준의 결과물을 만들고, 시니어 엔지니어와 협업한 것 같은 안정적인 코드를 작성할 수 있게 되었습니다.
몇 달 전만 해도 AI에 의존적이었던 것 같습니다. 그래서 기본기부터 다시 학습한다는 각오를 다지면서 이전에 읽지 못했던 책들을 다시 펼쳐보았습니다. AI를 학습 도구로 적극 활용하되, 내가 제어할 수 있는 범위 안에서만 핸들링하였고 모르는 영역은 확실히 이해하고 넘어가는 루프를 만들면서 나아갔습니다. 이 과정을 반복하면서 점점 자신감이 붙으면서 학습 속도와 작업의 효율을 높일 수 있었던 것 같습니다.
앞으로도 종종 AI와 함께 사고를 정리하는 글을 남겨보겠습니다.
'spring > SpringBoot' 카테고리의 다른 글
| 스프링에서 Redirect시 401 Error 트러블 슈팅 (0) | 2023.03.27 |
|---|---|
| (Spring Security)스프링 환경에서 JWT 토큰 발급 (0) | 2022.03.21 |
| (node.js) express 프로젝트 구조 (1) | 2021.08.31 |
| (Spring Boot) 동작 원리 (0) | 2021.08.10 |
| (스프링 부트) 커스텀어노테이션으로 중복코드 방지 (0) | 2021.07.14 |