현재 진행중인 클라이밍 커뮤니티 서비스에서의 알림 기능 개발을 위한 배경 지식들을 정리하고자 한다.
누군가 누구를 혹은 무엇을 팔로우 하고 있는지 관리하는 핵심 도메인
시스템 내에서 알림이 발생해야하는 Event(사건)을 포착하는 단계
발행된 이벤트를 실제 메시지로 변환하여 전달하는 과정이다.
사용자가 나중에 알림을 확인하고 관리하는 기능
SSE는 서버에서 클라이언트로 실시간 이벤트를 전달하는 '단방향'웹 표준 프로토콜이다.
SseEmitter: Spring Framework에서 제공하는 SSE 통신용 객체 이 객체를 통해 클라이언트와의 연결을 유지한다.Map<Long, SseEmitter>: 접속한 사용자별로 Emitter 객체를 서버 메모리에 저장하여 관리해야 한다. (Redis Pub/Sub와 같은 Message Queue와 결합하여 분산 서버 환경을 대응함)사용자가 로그인 후 알림을 구독(Connect)하고 특정 이벤트 발생시 알림을 받는 최소 기능만 포함한다.
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
private final NotificationService notificationService;
public NotificationController(NotificationService notificationService) {
this.notificationService = notificationService;
}
@GetMapping(value = "/subscribe/{userId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@PathVariable Long userId) {
return notificationService.subscribe(userId);
}
}
@Service
public class NotificationService {
// 사용자별 Emitter 저장소 (실제 서비스는 동시성 고려하여 ConcurrentHashMap 사용)
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
public SseEmitter subscribe(Long userId) {
// 1. 타임아웃 설정을 포함한 Emitter 생성 (기본 30초, 현재 코드에서는 1시간 설정)
SseEmitter emitter = new SseEmitter(60 * 60 * 1000L);
emitters.put(userId, emitter);
// 2. 연결 종료/타임아웃 시 맵에서 삭제 처리
emitter.onCompletion(() -> emitters.remove(userId));
emitter.onTimeout(() -> emitters.remove(userId));
// 3. 더미 데이터 전송 (연결 직후 데이터가 없으면 503 에러가 발생할 수 있음)
sendToClient(userId, "Connected! [userId=" + userId + "]");
return emitter;
}
public void sendNotification(Long userId, Object data) {
sendToClient(userId, data);
}
private void sendToClient(Long userId, Object data) {
SseEmitter emitter = emitters.get(userId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event()
.id(String.valueOf(userId))
.name("notification") // 이벤트 이름
.data(data)); // 실제 데이터
} catch (IOException e) {
emitters.remove(userId);
}
}
}
}
프론트 코드는 스킵
서버의 힙(Heap) 메모리에 상주하는 자료구조
그럼 서버 입장에서 SeeEmitter를 Heap에 들고 있다는 건 해당 연결을 끊지 않고 계속 잡고 있겠다는거 아닌가 그럼 강종하거나 끊지 않은 경우에 메모리가 계속 새는거 아닌가? 라는 의문점이 생겼고 알아보았다
메모리 누수
사용자가 앱을 강제종료하거나 네트워크가 끊어졌지만 서버는 Map에서 해당 SseEmitter를 삭제하지 않는다면?
서버 메모리에 쓰이지 않는 객체가 계속쌓임 ->
쌓이고 쌓이다 쌓이면 OutOfMemoryError(OOM) 발생!
그렇기에 emitter.onCompletion(), emitter.onTimeout() 콜백을 통해 연결이 끝나면 무조건 Map에서 제거 해야한다. 다시 Handshake를 시도해서 연결 갱신을 하거나 하면 될 듯? 개발하면서 좀 더 찾아보아야겠다.
동시성 문제
SNS나 커뮤니티 서비스는 수 많은 사용자가 동시에 접속하고 나간다. 일반적인 HashMap은 synchronized 키워드가 없으므로 여러 Thread가 동시에 접근할 수 있기에 데이터가 꼬일 수 있으니 반드시 ConcurrentHashMap을 사용해야 안전하다.
실무형 EmitterRepository 구조
단순히 서비스 클래스에 Map을 두기보다, 별도의 Repository 인터페이스로 추상화하는 것이 좋습니다. 그래야 나중에 Redis 등으로 확장하기 편합니다.
@Repository
public class SseEmitterRepository {
// 실무에서는 사용자 ID뿐만 아니라, 한 사용자가 여러 기기(폰, 태플릿)를 쓸 수 있으므로
// Key를 'userId_timestamp' 형태로 더 세분화하기도 합니다.
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
public void save(Long userId, SseEmitter emitter) {
emitters.put(userId, emitter);
}
public void deleteById(Long userId) {
emitters.remove(userId);
}
public SseEmitter get(Long userId) {
return emitters.get(userId);
}
}
emitters를 통한 알림 발송 흐름
이벤트 발생: "A님이 게시글을 올렸습니다!"
구독자 조회: DB에서 A님을 팔로우하는 사용자 ID 목록을 가져옵니다. (예: 10번, 20번, 30번)
Map 조회: emitters.get(10L), emitters.get(20L)... 을 차례로 호출합니다.
존재 확인:
emitters는 현재 우리 서버에 '빨대'를 꽂고 있는 사용자가 누구인지 알려주는 실시간 명단입니다.
실무에서는 이 명단이 너무 커지지 않게 잘 관리(삭제)하는 것이 실력이고, 서버가 여러 대일 때 이 명단을 공유하기 위해 Redis라는 공유 장부를 사용하는 것입니다.
라고한다.
내 프로젝트에서는 ConcurrentHashMap 으로도 충분할테지만 경험적인 측면에서는 Redis를 도입해서 만들어보는게 좋을 것 같긴한데 좀 더 고민을 해봐야겠다.
지금까지 알아본 정보들로 시나리오를 짜보면
이 될듯하다.