알림 개발을 위한 배경지식들

GreatCloud·2026년 4월 6일

백엔드

목록 보기
11/14

현재 진행중인 클라이밍 커뮤니티 서비스에서의 알림 기능 개발을 위한 배경 지식들을 정리하고자 한다.


1. 핵심 기능

1. 구독 관리

누군가 누구를 혹은 무엇을 팔로우 하고 있는지 관리하는 핵심 도메인

  • 구독 대상의 추상화: 사용자뿐만이 아니라 게시판 또는 특정 게시글 혹은 특정 태그 등 다양한 대상을 구독할 수 있도록 설계하는 것이 중요
  • 구독 상태 제어: 구독 신청/해지, 알림 유형(새 댓글, 새 글)에 대한 ON/OFF 설정 기능 필요

2. 이벤트 발행(Event Publication)

시스템 내에서 알림이 발생해야하는 Event(사건)을 포착하는 단계

  • 이벤트 감지(Hooking): Service Layer에 알림 동작 코드를 넣기보단 Spring Event(ApplicationEventPublisher)를 사용하여 결합도를 낮추는 것이 좋은 방향이다.
  • 이벤트 페이로드(Payload) 구성: 알림에 필요한 정보(발행자 ID, 소비자 ID, 링크, 메시지 템플릿 코드등)를 포함한다.

3. 알림 엔진 및 전송(Notification Engine & Delivery)

발행된 이벤트를 실제 메시지로 변환하여 전달하는 과정이다.

  • 알림 라우팅(Routing): 사용자 설정에 따라 어떤 채널로 보낼지 결정한다.
    1. In-App: 서비스 내부 알림함에 저장
    2. Push: FCM(Firebase Cloud Messaging)등을 이용한 모바일/웹 푸시.
    3. External: 이메일, 카카오톡과 같은 외부 메시징 서비스.

4. 알림 수신함 및 상태 관리(Inbox & State)

사용자가 나중에 알림을 확인하고 관리하는 기능

  • 읽음 상태 관리: 사용자가 알림을 확인했는지 여부를 체크하거나 전체 읽음 처리 같은 기능 포함
  • 알림 보관 정책 (Retention): 알람을 제한없이 쌓아두면 DB 부하가 커지기 때문에 '최근 10일 보관' 혹은 '100건 보관'과 같은 삭제 정책이 필요하다.
  • 실시간 연결(Real-time Connectivity): 웹 화면에서의 실시간 알림 뱃지를 갱신하기 위한 SSE(Server-Sent Events) 혹은 WebSocket 연결 관리가 필요하다.

SSE(Server-Sent Events)

1. SSE란 무엇인가

SSE는 서버에서 클라이언트로 실시간 이벤트를 전달하는 '단방향'웹 표준 프로토콜이다.

  • 프로토콜: 표존 HTTP 사용(별도 포트가 불필요하다)
  • 연결 방식: 한 번의 Handshake 후 서버가 지속적으로 Push
  • 특징: 브라우저 자동 재연결 지원, 가볍고 구현이 쉽다.

2. Spring Boot에서의 구현 핵심체

  • SseEmitter: Spring Framework에서 제공하는 SSE 통신용 객체 이 객체를 통해 클라이언트와의 연결을 유지한다.
  • Map<Long, SseEmitter>: 접속한 사용자별로 Emitter 객체를 서버 메모리에 저장하여 관리해야 한다. (Redis Pub/Sub와 같은 Message Queue와 결합하여 분산 서버 환경을 대응함)

예시 코드

사용자가 로그인 후 알림을 구독(Connect)하고 특정 이벤트 발생시 알림을 받는 최소 기능만 포함한다.

  1. NotificationController (연결 엔드포인트)
    클라이언트와 알림 통로를 여는 엔드포인트
@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);
    }
}
  1. NotificationService
@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);
            }
        }
    }
}

프론트 코드는 스킵

MediaType.TEXT_EVENT_STREAM_VALUE?

  • 지속적인 연결
  • 데이터 형태: data: {"key": "value"}\n\n
  • 데이터를 다 보낼 때 까지 유지

emitters ?

서버의 힙(Heap) 메모리에 상주하는 자료구조

  • key(Long): 사용자 식별자(userId) 누구에게 보낼지 찾는 Index 역할
  • Value(SeeEmitter): 해당 사용자와 연결된 실제 네트워크 연결 객체. 이 객체 안에는 클라이언트에게 데이터를 흘려보낼 수 있는 Stream이 들어있다.

그럼 서버 입장에서 SeeEmitter를 Heap에 들고 있다는 건 해당 연결을 끊지 않고 계속 잡고 있겠다는거 아닌가 그럼 강종하거나 끊지 않은 경우에 메모리가 계속 새는거 아닌가? 라는 의문점이 생겼고 알아보았다

  1. 메모리 누수
    사용자가 앱을 강제종료하거나 네트워크가 끊어졌지만 서버는 Map에서 해당 SseEmitter를 삭제하지 않는다면?
    서버 메모리에 쓰이지 않는 객체가 계속쌓임 ->
    쌓이고 쌓이다 쌓이면 OutOfMemoryError(OOM) 발생!
    그렇기에 emitter.onCompletion(), emitter.onTimeout() 콜백을 통해 연결이 끝나면 무조건 Map에서 제거 해야한다. 다시 Handshake를 시도해서 연결 갱신을 하거나 하면 될 듯? 개발하면서 좀 더 찾아보아야겠다.

  2. 동시성 문제
    SNS나 커뮤니티 서비스는 수 많은 사용자가 동시에 접속하고 나간다. 일반적인 HashMap은 synchronized 키워드가 없으므로 여러 Thread가 동시에 접근할 수 있기에 데이터가 꼬일 수 있으니 반드시 ConcurrentHashMap을 사용해야 안전하다.

Gemini 조언

실무형 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를 통한 알림 발송 흐름

  1. 이벤트 발생: "A님이 게시글을 올렸습니다!"

  2. 구독자 조회: DB에서 A님을 팔로우하는 사용자 ID 목록을 가져옵니다. (예: 10번, 20번, 30번)

  3. Map 조회: emitters.get(10L), emitters.get(20L)... 을 차례로 호출합니다.

  4. 존재 확인:

    • null이 아니면: 현재 접속 중이므로 emitter.send()로 즉시 전송.
    • null이면: 접속 중이 아니므로 FCM(푸시 알림) 전송 로직으로 넘김.

emitters는 현재 우리 서버에 '빨대'를 꽂고 있는 사용자가 누구인지 알려주는 실시간 명단입니다.

실무에서는 이 명단이 너무 커지지 않게 잘 관리(삭제)하는 것이 실력이고, 서버가 여러 대일 때 이 명단을 공유하기 위해 Redis라는 공유 장부를 사용하는 것입니다.

라고한다.

내 프로젝트에서는 ConcurrentHashMap 으로도 충분할테지만 경험적인 측면에서는 Redis를 도입해서 만들어보는게 좋을 것 같긴한데 좀 더 고민을 해봐야겠다.

지금까지 알아본 정보들로 시나리오를 짜보면

  1. 사용자가 'A 게시판 구독': RDB 테이블에 정보 저장
  2. A 게시판 게시글 작성: RDB
  3. 알림 대상 조회: RDB 조회
  4. 접속 여부 확인: Heap(Emitters Map) 조회
    5-A 접속중 (Map Not Null): SSE(SseEmitter)로 데이터 전송
    5-B 미접속 (Map Null): FCM(푸시알림)과 같은 서버를 통해 푸시 알림 발송

이 될듯하다.


참고: https://devlog-wjdrbs96.tistory.com/269

profile
모르는 것에 부끄러워 말고 아는 것에 자만하지 말 것

0개의 댓글