mimizae 님의 블로그
실시간 알림 구현하기 본문

사이드 프로젝트 kick-on의 1.4v 스프린트에서 실시간 알림!!!!! 구현을 맡았다. 😮💨
실시간 알림을 구현하는 방법에는 아래 3가지가 있다.
1. 폴링 방식
2. 웹소켓 방식
3. SSE 방식
우리 프로젝트에서는 2번을 채택했기 때문에 각각의 장단점을 가볍게 훑고 웹소켓을 이용한 실시간 알림에 대해 클라이언트 단에서의 UI 설계 및 기능 구현 로직 중심으로 설명하겠다.
서버 로직이 어떻게 되어있는지는 이 velog를 참고하길 바란다!!
1. 폴링(Polling) 방식
폴링은 클라이언트가 주기적으로 서버에 "새로운 알림이 있나요?" 라고 물어보는 방식이다.
장점
- 구현이 매우 간단하다. HTTP 요청-응답 모델을 사용하기 때문에 별도의 기술이나 라이브러리가 필요 없고 거의 모든 환경에서 사용 가능하다.
단점
- 비효율적이다. 대부분의 요청이 "새로운 알림이 없다"는 응답을 받기 때문에 불필요한 네트워크 트래픽과 서버 부하를 유발한다.
- 알림을 즉시 받을 수 없으며, 설정된 폴링 주기에 따라 알림 도착 시간이 지연된다.
2. SSE(Server-Sent Events) 방식
SSE는 서버에서 클라이언트로 단방향 스트리밍을 지원하는 기술이다. 즉, 서버는 클라이언트에게 데이터를 계속 보낼 수 있지만, 클라이언트는 서버로 데이터를 보낼 수 없다.
장점
- 웹소켓과 마찬가지로 실시간성이 뛰어나다. 서버에서 새로운 이벤트가 발생하면 즉시 클라이언트에게 전달된다.
- HTTP/1.1 프로토콜을 기반으로 하여 웹소켓보다 방화벽이나 프록시를 통과하기 쉽다.
- 웹소켓보다 구현이 간단하며, 별도의 프로토콜 핸들링이 필요 없다.
단점
- 단방향 통신만 가능하다. 클라이언트가 서버로 메시지를 보내야 하는 경우(예: 채팅)에는 적합하지 않다. (즉, 알림 같은 기능에는 매우 적합하다는 뜻.)
3. 웹 소켓(WebSocket) 방식
웹 소켓은 클라이언트와 서버가 한 번 연결을 맺으면, 양쪽이 서로에게 자유롭게 데이터를 주고받을 수 있는 양방향 통신 채널을 생성한다.
장점
- 실시간성이 가장 뛰어나다!! 서버에 새로운 알림이 생기면 즉시 클라이언트에게 푸시할 수 있으며 폴링 방식에 비해 네트워크 오버헤드가 적어 효율적이다.
단점
- 방화벽이나 프록시 설정에 따라 연결 문제가 발생할 수 있다. (이런 문제를 해결하기 위해 Socket.IO나 SockJS 같은 라이브러리를 사용)
UI 설계
아래 보이는 피그마 화면을 보면, 알림 화면을 모바일에서는 페이지 그리고 데스크탑과 태블릿에서는 모달 형태로 띄워야 했다.
다행히!! 모달이냐 페이지냐의 특성에 따라 헤더만 다를 뿐 디자인은 동일했기에 내부 요소들을 공통으로 사용할 수 있었다!! 😮💨

우리 프로젝트에서는 Next.js의 route 그룹 기능을 활용해 URL 구조를 해치지 않으면서 라우트를 논리적으로 묶고 있다.
특히 알림과 마찬가지로 로그인도 모바일에서는 페이지로 접근해야 했기에 mobile-only라는 그룹을 새로 추가해, 모바일 기기에서만 접근 가능한 화면들을 별도로 구분하였다.

이 그룹의 공통 레이아웃(layout.tsx)에는 MobileGate라는 컴포넌트를 배치했다. MobileGate는 useIsMobile 훅을 통해 접속 환경이 모바일인지 여부를 판별한다.
'use client';
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import useIsMobile from '@/lib/hooks/useIsMobile';
export default function MobileGate({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile();
const router = useRouter();
const hasAlerted = useRef(false);
useEffect(() => {
if (isMobile === null) return; // 모바일 여부 판별 전이면 대기
if (!isMobile && !hasAlerted.current) {
alert('모바일에서만 접근 가능합니다.');
hasAlerted.current = true;
router.replace('/');
}
}, [isMobile, router]);
// 모바일 여부가 확인되기 전에는 렌더 차단
if (!isMobile) return null;
return <>{children}</>;
}
컴포넌트 동작은 다음과 같다.
- 초기 판별 대기:
isMobile 값이 아직 확정되지 않았을 때는 아무 화면도 렌더링하지 않는다. (잘못된 화면 노출을 방지) - 모바일이 아닐 경우:
모바일 기기가 아닐 경우, 안내 메시지를 띄운 뒤 router.replace('/')를 통해 루트 페이지로 리다이렉트한다.그리고 useRef로 hasAlerted 상태를 관리하여, 알림이 중복으로 뜨지 않도록 제어한다. - (여기서 replace를 사용한 이유는, 브라우저 히스토리에 잘못 접근한 페이지를 남기지 않기 위함이다. 만약 push를 사용하면 사용자가 뒤로가기를 눌렀을 때 다시 모바일 전용 페이지로 돌아와 또다시 리다이렉트되는 불필요한 루프가 발생할 수 있다!!)
- 모바일일 경우:
정상적으로 자식 컴포넌트를 렌더링하여 모바일 전용 화면을 표시한다.
이런 구조를 사용하면, mobile-only 그룹에 속한 모든 페이지는 자동으로 MobileGate의 제약을 거치게 되어 “모바일 전용 페이지 접근 보장”이라는 일관성을 확보할 수 있다.
또한 판별 로직이 레이아웃 단에서 처리되므로, 각 페이지마다 반복적으로 모바일 체크 코드를 작성할 필요가 없다!! 👍🏻👍🏻

알림 모달과 페이지는 모바일과 데스크탑 모두에서 내브바의 알림 버튼을 통해 접근할 수 있다.
이 버튼은 기존의 프로필 버튼과 나란히 배치되어야 했는데, 두 버튼을 감싸는 div의 높이가 달라서 단순히 정렬했을 때는 위치가 맞지 않았다.
그래서 두 버튼을 나란히 보이게 하려면 각각 다른 top 값을 줄 수밖에 없었다.
높이가 달랐던 이유는, 알림 버튼과 프로필 버튼을 묶은 div를 flex로 배치했기 때문이다.
flex 레이아웃에서는 자식 요소들이 주로 한 축(main axis) 을 기준으로 배치되기 때문에, 각 항목의 높이(cross axis)는 내부 콘텐츠나 개별 스타일에 따라 달라질 수 있다.
따라서 알림 버튼 div에 h-full을 적용하더라도, 부모 요소의 높이가 명확히 정의되지 않으면 원하는 대로 맞춰지지 않는다.
그래서 grid로 수정했다!
grid 레이아웃은 행(row)과 열(column)이라는 공통의 셀 단위로 자식 요소들을 배치한다.
같은 그리드 셀에 들어간 요소들은 높이를 공유하기 때문에, 한쪽 요소(예: 프로필 버튼)의 높이에 따라 다른 요소(예: 알림 버튼)도 자동으로 맞춰진다.
이 덕분에 굳이 버튼마다 top 값을 따로 조정하지 않아도, 두 버튼이 자연스럽게 수직 정렬되었다!!

그런데 이렇게 프로필 버튼 없이 알림 버튼 단독으로 내브바에 위치하는 경우도 존재했다.
두 버튼이 grid로 묶여 있었던 터라, 알림 버튼의 높이가 프로필 버튼의 높이에 맞춰져 있으니 프로필 버튼이 렌더링되지 않으면, 알림 버튼 위쪽에 불필요한 간격이 생겨 모달 위치가 어색해지는 문제가 발생했다.
UI 자체가 크게 깨지진 않았지만, 원인을 따져보면 프로필 버튼의 존재 여부에 따라 알림 버튼의 높이가 달라지는 구조가 문제였으니 모달 위치를 개별적으로 조정하기보다는, 버튼 높이를 고정하거나 알림 버튼을 동일한 높이의 래퍼(div)로 감싸는 방식으로 해결 방향을 잡았다.
이렇게 하면 프로필 버튼이 없어도 알림 버튼의 높이가 안정적으로 유지되고, 모달 위치 역시 일관되게 맞출 수 있다. 👍🏻👍🏻👍🏻
Shout out to 구스탄... 값진 리뷰였습니다...
const notifications = useNotificationStore((state) => state.notifications);
...
...
<NoticeHeader isModal={true} onClose={onCloseModal} />
<div className="flex-1 rounded-b-[0.625rem] overflow-y-auto no-scrollbar">
{notifications.map((notice) => (
<NoticeItem
key={notice.pk}
pk={notice.pk}
type={notice.type}
read={notice.read}
teamLogo={notice.teamLogo}
redirectUrl={notice.redirectUrl}
relativeTime={notice.relativeTime}
content={notice.content}
isModal={true}
onCloseModal={onCloseModal}
/>
))}
</div>
위에서 설명했듯 화면을 어떻게 띄우느냐가 다를 뿐 디자인은 동일해서 헤더와 아이템 두 컴포넌트를 공통으로 사용했다.
아이템으로 전달되는 값들은 아래의 기능 구현에서 설명할 예정인 zustand를 활용해 전역으로 관리되는 알림 데이터 값들이다!!
기능 구현
다음으로는 알림 데이터를 어떻게!! 관리했는지 알아보자.
전체적인 흐름부터 설명하겠다.
1. 사용자 사이트 접속
↓
2. layout.tsx (root) → <NotificationInitializer /> → useNotificationSocket() 실행
↓
3. SockJS + STOMP로 웹소켓 연결
↓
4. /topic/notify/{userId} 구독 시작
↓
5. 알림 오면 zustand 상태 업데이트 → 알림 뱃지/목록에 바로 반영
↓
6. 유저가 로그아웃하거나, 브라우저 닫으면 연결 종료
'use client';
import useNotificationSocket from '@/lib/hooks/useNotificationSocket';
import { useCurrentUserInfoStore } from '@/lib/store/useCurrentUserInfoStore';
import { useNotificationStore } from '@/lib/store/useNotificationStore';
import { getNotificationList, getUnreadNotifications } from '@/services/apis/notifications/notifications.api';
import { useEffect } from 'react';
export default function NotificationInitializer() {
const { currentUserInfo } = useCurrentUserInfoStore();
const setUnreadCount = useNotificationStore((s) => s.setUnreadCount);
const setNotifications = useNotificationStore((s) => s.setNotifications);
useNotificationSocket(currentUserInfo?.id);
useEffect(() => {
if (!currentUserInfo?.id) return;
const fetchInitialData = async () => {
try {
const [unread, list] = await Promise.all([getUnreadNotifications(), getNotificationList()]);
console.log('알림 초기 목록', unread, list);
if (unread) setUnreadCount(unread.data.count);
if (list) setNotifications(list.data);
} catch (error) {
console.error('알림 초기 데이터 로딩 실패:', error);
}
};
fetchInitialData();
}, [currentUserInfo?.id]);
return null;
}
사용자가 서비스에 접속하면,
페이지를 렌더링하기 전에 가장 바깥쪽(루트) 레이아웃부터 실행되는데,
이때 루트 레이아웃 안에 있는 NotificationInitializer는 두 가지 일을 한다.
1. 초기 데이터 로딩
사용자가 로그인했을 때 (currentUserInfo?.id 존재 시)
- getUnreadNotifications() → 안 읽은 알림 수
- getNotificationList() → 알림 목록
두 값을 한 번에 불러와서 zustand 전역 상태에 저장한다.
(setUnreadCount, setNotifications)
➡️ 즉, 페이지 진입 시 서버 상태를 클라이언트 전역 상태로 동기화하는 역할.
2. 소켓 연결 트리거
useNotificationSocket(currentUserInfo?.id)를 호출해서 실시간 알림 구독이 켜지도록 한다.
NotificationInitializer를 루트 레이아웃에 넣었기 때문에 사이트 진입 시 최초 한 번 실행되고, 그 후에는 라우팅이 바뀌더라도 레이아웃이 유지되니 소켓 연결도 계속 살아있게 된다!!
'use client';
import { useEffect, useRef } from 'react';
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
import { useNotificationStore } from '../store/useNotificationStore';
import { SOCKET_URL } from '@/services/config/constants';
export default function useNotificationSocket(userId: string | null) {
const clientRef = useRef<Client | null>(null);
useEffect(() => {
if (!userId) return;
console.log(userId);
// userId가 바뀌면 기존 연결 끊기
if (clientRef.current) {
clientRef.current.deactivate();
clientRef.current = null;
}
const socket = new SockJS(SOCKET_URL);
const client = new Client({
webSocketFactory: () => socket,
reconnectDelay: 5000,
debug: (str) => console.log('%c[STOMP DEBUG]', 'color: gray', str), // 디버깅 로그
});
client.onConnect = (frame) => {
console.log('[STOMP] 연결 성공', frame);
const subscription = client.subscribe(`/topic/notify/${userId}`, (message) => {
console.log('[STOMP] 메시지 수신 RAW:', message);
try {
const newNotification = JSON.parse(message.body);
console.log('[STOMP] 파싱된 알림:', newNotification);
useNotificationStore.getState().addNotification(newNotification);
} catch (e) {
console.error('[STOMP] 메시지 파싱 실패', e);
}
});
console.log('[STOMP] 구독 완료:', subscription.id);
};
client.onStompError = (frame) => {
console.error('[STOMP ERROR]', frame.headers['message'], frame.body);
};
client.onWebSocketError = (error) => {
console.error('[WebSocket ERROR]', error);
};
client.onWebSocketClose = (event) => {
console.warn('[WebSocket] 연결이 닫힘', event);
};
client.activate();
return () => {
client.deactivate(); // cleanup
};
}, [userId]);
}
useNotificationSocket 커스텀 훅은 아래와 같은 일을 한다!
1. WebSocket 연결 관리
- userId가 없으면 연결 안 함.
- userId가 바뀌면 기존 연결 끊고 새로 연결.
2. SockJS + STOMP 클라이언트 생성
- webSocketFactory로 SockJS 객체 생성.
- reconnectDelay: 5000 → 연결 끊기면 5초 후 재연결 시도.
- debug 옵션 → 디버깅 로그 출력.
3. 연결 이벤트 핸들링
- onConnect: 연결 성공 → 해당 userId 채널 구독 시작.
- onStompError: STOMP 프로토콜 오류 처리.
- onWebSocketError: 웹소켓 레벨 오류 처리.
- onWebSocketClose: 연결 닫힘 이벤트 로그.
4. 알림 수신 처리
- /topic/notify/${userId} 구독.
- 서버에서 알림 메시지 오면 → JSON.parse 후 useNotificationStore.getState().addNotification 호출.
- 즉, 실시간으로 알림 Store 업데이트!!
얼핏 보면 NotificationInitializer에서 소켓 연결까지 처리할 법하다고 생각할 것 같다.
커스텀 훅으로 분리한 이유는 역할을 명확히 나누고 코드 재사용성을 높이기 위함이었다!!
만약 소켓 연결 로직을 NotificationInitializer 안에 직접 다 넣는다면, UI 초기화 역할뿐만 아니라 소켓 연결, 구독, 이벤트 처리, 연결 해제 같은 여러 가지 책임을 동시에 떠안아 무거워질 것이다!!
그리고 만약 다른 페이지나 다른 초기화 컴포넌트에서도 소켓 연결이 필요하다면, 그냥 이 훅만 가져다 쓰면 된다. (구독 경로만 바꿔주면 된다.)
import { NotificationItem } from '@/services/apis/notifications/notifications.type';
import { create } from 'zustand';
interface NotificationStore {
notifications: NotificationItem[];
unreadCount: number;
setNotifications: (data: NotificationItem[]) => void;
addNotification: (data: NotificationItem) => void;
setUnreadCount: (count: number) => void;
markAsRead: (id: number) => void;
}
export const useNotificationStore = create<NotificationStore>((set, get) => ({
notifications: [],
unreadCount: 0,
setNotifications: (data) => set({ notifications: data }),
addNotification: (data) =>
set((state) => {
// 중복 알림 방지
if (state.notifications.some((n) => n.pk === data.pk)) {
return state;
}
return {
notifications: [data, ...state.notifications],
unreadCount: state.unreadCount + 1,
};
}),
setUnreadCount: (count) => set({ unreadCount: count }),
markAsRead: (id) =>
set((state) => {
const wasUnread = state.notifications.some((n) => n.pk === id && !n.read);
return {
notifications: state.notifications.map((n) => (n.pk === id ? { ...n, read: true } : n)),
unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
};
}),
}));
보너스) 여기에서 알림 데이터를 zustand로 전역 관리한 이유는?
알림 데이터는 헤더(내브바), 알림 모달/드롭다운에 필요하며 어떤 페이지에서도 동일한 알림 상태를 유지해야 한다.
즉, 특정 컴포넌트 하나에서만 쓰는 데이터가 아니라, 여러 UI 컴포넌트에서 동시에 필요한 상태이다.
소켓에서 새 알림 들어올 때마다, 관련된 모든 컴포넌트에 알림을 뿌려줘야 하는데, 로컬 상태로 하면 그게 번거롭고 꼬이기 쉽다고 판단했다!!
🌀🌀🌀
지난 프로젝트에서 다른 팀원 분이 채팅을 구현하시는 것을 보고 지레 겁을 먹었는데, 채팅에 비하면 진짜 껌이었다... 😅 흐름을 생각하면서 순서대로 구현하니 실제로 구독이 성공하고 메세지를 받아왔을 때 착착 흐름대로 진행되는 게 신기했다!!
아쉬운 점이 있다면, 서버와의 소통에서 오류가 발생하면 어떤 오류 때문이었는지 여쭤보지 않고 그저 기다리고 있었다는 점이었다... 물론 들어도 생소한 개념일 테지만 (nginx 설정 같은 거...;;)
근데 이쯤 되면 의문이 들 수도 있다. 알림 기능에 가장 적합한 방식은 SSE인 듯한데,
그런데 왜 웹 소켓을 선택했느냐?
이유는 아래와 같음!!. . .

서버 팀원 분의 velog를 훔쳐왔다. 질타가 아니다... 왜냐면 나도??? 😡😡 웹 소켓 방식만 알았고, 그 중에서도 socket.io를 이용해 구현되는 줄 알고 한 술 더 떠서 socket.io 설치까지 해뒀었기 때문이다. 😅😅😅 반성하게 된다... (내친김에 채팅? ㄷㄷ 취소합니다)
다른 좋은 기회가 있다면 SSE로 실시간 알림을 구현해 보면 좋을 것 같다!
'FE' 카테고리의 다른 글
| 낙관적 업데이트 적용하기 (0) | 2025.12.05 |
|---|---|
| 스크롤 감지 Tab-Bar 구현하기 (0) | 2025.11.26 |
| Virtual DOM이란? (0) | 2025.11.07 |
| SEO에 대한 고찰 (2) | 2025.10.16 |
| aws lambda로 이미지 리사이징 기능 구현하기 (0) | 2025.10.12 |