[프로젝트 회고] 모두의 후기 Notification 기능과 SSE 알림 구조
들어가며
모두의 후기 프로젝트에서는 사용자가 리뷰를 작성하고, 댓글을 달고, 북마크를 하며 서로 반응할 수 있는 서비스 흐름을 만들고자 했다.
그 과정에서 내가 맡았던 기능 중 하나가 알림 기능이었다.
처음에는 알림 목록을 저장하고 조회하는 기능부터 시작했고, 이후에는 SSE(Server-Sent Events)를 이용한 실시간 알림 구조까지 확장하는 방향으로 작업했다.
알림 기능은 단순히 데이터를 저장하는 기능처럼 보이지만, 실제로는 사용자의 서비스 경험과 꽤 직접적으로 연결되어 있었다.
사용자가 자신의 글에 댓글이 달렸는지, 누군가 반응했는지 바로 알 수 있다면 서비스에 다시 들어올 이유가 생긴다. 그래서 알림은 기능적으로도 중요하지만, 서비스의 상호작용성을 높이는 장치라고 생각했다.
Notification 도메인 설계
먼저 알림을 저장하기 위한 기본 구조를 만들었다.
구현한 내용은 다음과 같다.
Notification엔티티NotificationTypeEnum- Notification Repository
- Notification Service
- Notification Controller
- 알림 목록 조회 API
- 읽지 않은 알림 여부 조회 API
- 읽음/삭제 처리 API
- 알림 응답 DTO
- NotificationMapper
알림은 단순 메시지만 저장하는 것이 아니라, 어떤 종류의 알림인지 구분할 수 있어야 했다. 그래서 NotificationType을 두고 댓글, 북마크 등 이벤트 성격에 따라 타입을 나눌 수 있도록 했다.
또한 알림 목록을 내려줄 때 게시글 제목이 너무 길면 화면에서 보기 좋지 않을 수 있어서, 제목 절단 로직도 함께 적용했다.
API 흐름
알림 API는 사용자 기준으로 동작하도록 설계했다.
대표적인 API 흐름은 다음과 같다.
- 내 알림 목록 조회
- 읽지 않은 알림 존재 여부 확인
- 알림 읽음 처리
- 알림 삭제 처리
이때 읽음과 삭제는 서로 다른 의미를 가진다.
읽음 처리는 사용자가 알림을 확인했다는 상태 변경이고, 삭제 처리는 알림 목록에서 더 이상 보이지 않게 하는 처리다. 그래서 요청에서 isRead와 isDelete를 동시에 true로 보내는 경우는 잘못된 요청으로 보고 400 응답을 반환하도록 테스트했다.
이런 작은 검증이지만, API를 쓰는 쪽에서 실수했을 때 서버가 명확하게 막아주는 것이 중요하다고 느꼈다.
테스트 작성
알림 기능은 MockMvc 기반 통합 테스트를 작성했다.
테스트에서는 다음 시나리오를 확인했다.
- 알림이 없는 사용자에게 빈 목록 반환
- 알림 제목이 일정 길이를 넘으면 절단 처리되는지 확인
- 여러 알림이 정상적으로 목록에 포함되는지 확인
- 읽지 않은 알림 여부가 Boolean으로 반환되는지 확인
- PATCH 요청으로 읽음/삭제 처리가 되는지 확인
isRead와isDelete가 동시에 true일 때 400 반환
처음에는 테스트 코드를 작성하는 시간이 기능 구현보다 길게 느껴질 때도 있었다.
하지만 알림처럼 상태가 바뀌는 기능은 테스트가 없으면 나중에 수정할 때 불안했다.
특히 읽음/삭제 처리처럼 작은 조건이 많은 기능은 테스트가 있어야 마음 놓고 리팩토링할 수 있었다.
SSE를 선택한 이유
실시간 알림을 구현하는 방법은 여러 가지가 있다.
- Polling
- Long Polling
- WebSocket
- SSE
이번 프로젝트에서는 서버에서 클라이언트로 알림을 보내는 단방향 흐름이 중심이었다. 채팅처럼 양방향 통신이 필요한 기능은 아니었다.
그래서 WebSocket보다는 SSE가 더 적합하다고 판단했다.
SSE는 HTTP 기반으로 동작하고, 서버가 클라이언트에게 이벤트를 지속적으로 보낼 수 있다. 브라우저의 EventSource를 사용할 수 있어 프론트엔드 연동도 비교적 단순하다.
무엇보다 Polling처럼 클라이언트가 계속 서버에 요청을 보내지 않아도 되기 때문에, 불필요한 요청을 줄일 수 있다는 장점이 있었다.
Emitter 관리의 중요성
SSE를 구현하면서 가장 중요하게 봤던 부분은 SseEmitter의 생명주기 관리였다.
사용자가 페이지를 새로고침하거나, 네트워크가 끊기거나, 브라우저를 닫으면 기존 연결은 정리되어야 한다. 그렇지 않으면 서버에 사용하지 않는 emitter가 계속 남아 메모리 누수로 이어질 수 있다.
그래서 NotificationEmitterRegistry를 두고 emitter를 관리하는 구조를 설계했다.
주요 역할은 다음과 같다.
- 사용자별 emitter 저장
- 연결 완료 시 emitter 제거
- timeout 발생 시 emitter 제거
- error 발생 시 emitter 제거
- 동일 사용자의 중복 연결 처리
동시 접속 상황을 고려해 Thread-safe한 자료구조를 사용해야 했다.
여러 요청이 동시에 들어올 수 있기 때문에 단순한 Map으로 관리하면 안전하지 않을 수 있었다.
책임 분리
알림 기능을 구현하면서 신경 쓴 점은 책임을 나누는 것이었다.
알림을 생성하는 비즈니스 로직과 SSE 연결을 관리하는 인프라 로직은 성격이 다르다.
그래서 다음처럼 나누려고 했다.
- NotificationService: 알림 생성, 조회, 읽음/삭제 처리
- NotificationMapper: 응답 변환과 제목 절단 처리
- NotificationEmitterRegistry: SSE 연결 저장 및 제거
- Controller: 요청과 응답의 진입점
이렇게 나누면 나중에 SSE 방식이 바뀌거나, 알림 저장 정책이 바뀌어도 변경 범위를 줄일 수 있다.
리뷰를 받으며 느낀 점
PR 리뷰에서 “기능별로 브랜치를 더 쪼개면 좋겠다”는 피드백을 받았다.
생각해보면 알림 기능 하나에도 읽음, 삭제, 읽지 않음 조회, 목록 조회, SSE 연결 등 여러 기능이 들어 있었다.
나는 하나의 알림 기능이라고 생각했지만, 리뷰하는 입장에서는 변경 범위가 넓게 느껴질 수 있었다.
이 피드백을 통해 기능 단위를 더 작게 나누는 것이 협업에서 얼마나 중요한지 알게 되었다.
작업자는 전체 맥락을 알고 있지만, 리뷰어는 PR에 올라온 변경 내용을 보고 판단해야 한다.
그래서 PR은 구현 단위뿐 아니라 리뷰 가능한 단위로 나누는 것이 좋겠다는 생각을 했다.
마무리
Notification과 SSE 기능을 구현하면서 단순 API 개발을 넘어, 연결 상태와 서버 자원 관리까지 고민하게 되었다.
특히 SSE는 “연결을 열어두는 기능”이기 때문에, 기능이 동작하는 것만큼이나 연결을 잘 정리하는 것이 중요했다.
이번 작업을 통해 실시간 기능은 화려한 기능처럼 보이지만, 내부적으로는 생명주기 관리와 예외 처리, 책임 분리가 더 중요하다는 것을 배웠다.
다음에는 이 기능을 운영하면서 마주친 DB 커넥션 풀 문제와 SSE 중복 연결 문제를 어떻게 정리했는지 따로 적어보려고 한다.
Leave a comment