알림 뱃지 시스템 구현하기 - 실시간 업데이트부터 접근성까지 완벽 가이드
현대 웹 애플리케이션에서 알림 뱃지는 사용자 인게이지먼트를 높이는 핵심 UI 요소예요. 새로운 메시지, 업데이트, 이벤트를 시각적으로 표시하여 사용자의 즉각적인 반응을 유도하죠. 하지만 단순해 보이는 이 작은 동그라미 하나를 제대로 구현하려면 상태 관리, 실시간 업데이트, 접근성, 성능 최적화 등 고려할 사항이 많아요. 이 글에서는 5년간 실무에서 다양한 알림 뱃지 시스템을 구축하면서 배운 노하우를 모두 공유할게요. 바로 복사해서 쓸 수 있는 코드와 함께 실전 팁을 담았으니, 끝까지 읽고 나면 완성도 높은 알림 뱃지 시스템을 만들 수 있을 거예요.
기본 알림 뱃지 컴포넌트 설계하기
알림 뱃지 시스템의 핵심은 재사용 가능하고 확장 가능한 컴포넌트 설계예요. 먼저 React로 기본 뱃지 컴포넌트를 만들어볼게요.
// NotificationBadge.jsx
import React from 'react';
import './NotificationBadge.css';
/**
* 알림 뱃지 컴포넌트
* @param {number} count - 표시할 알림 개수
* @param {number} max - 최대 표시 숫자 (기본값: 99)
* @param {boolean} showZero - 0일 때도 표시할지 여부
* @param {string} variant - 뱃지 스타일 ('primary' | 'danger' | 'warning')
* @param {ReactNode} children - 뱃지가 붙을 대상 요소
*/
const NotificationBadge = ({
count = 0,
max = 99,
showZero = false,
variant = 'primary',
children,
position = 'top-right'
}) => {
// 표시할 숫자 계산 - max값을 초과하면 "99+" 형태로 표시
const displayCount = count > max ? `${max}+` : count;
// count가 0이고 showZero가 false면 뱃지를 렌더링하지 않음
const shouldShow = count > 0 || showZero;
return (
<div className="badge-container">
{children}
{shouldShow && (
<span
className={`notification-badge badge-${variant} badge-${position}`}
// 접근성을 위한 aria 속성 추가
role="status"
aria-label={`${count}개의 새로운 알림`}
>
{displayCount}
</span>
)}
</div>
);
};
export default NotificationBadge;
/* NotificationBadge.css */
.badge-container {
position: relative;
display: inline-block;
}
.notification-badge {
position: absolute;
min-width: 20px;
height: 20px;
padding: 2px 6px;
font-size: 12px;
font-weight: 600;
line-height: 16px;
text-align: center;
border-radius: 10px;
color: white;
/* 뱃지가 항상 위에 보이도록 z-index 설정 */
z-index: 10;
/* 부드러운 애니메이션 효과 */
transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out;
}
/* 위치별 스타일 */
.badge-top-right {
top: -8px;
right: -8px;
}
.badge-top-left {
top: -8px;
left: -8px;
}
.badge-bottom-right {
bottom: -8px;
right: -8px;
}
/* 컬러 variant */
.badge-primary {
background-color: #2563eb;
}
.badge-danger {
background-color: #dc2626;
}
.badge-warning {
background-color: #f59e0b;
}
/* 숫자가 3자리 이상일 때 패딩 조정 */
.notification-badge:has([data-length="3"]) {
padding: 2px 4px;
font-size: 11px;
}
/* 호버 시 살짝 확대되는 효과 */
.notification-badge:hover {
transform: scale(1.1);
}
/* 새 알림이 추가될 때 애니메이션 */
@keyframes badge-pop {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.notification-badge.badge-new {
animation: badge-pop 0.3s ease-out;
}
이렇게 기본 컴포넌트를 설계하는 이유는 명확해요. max prop으로 숫자 상한선을 조절할 수 있고, variant로 시각적 우선순위를 표현할 수 있죠. 특히 showZero 옵션은 "읽지 않은 알림이 없음"을 명시적으로 보여줘야 할 때 유용해요.
실시간 알림 카운트 업데이트 구현하기
알림 뱃지의 진짜 가치는 실시간 업데이트에 있어요. WebSocket이나 Server-Sent Events(SSE)를 활용한 실시간 시스템을 구현해볼게요.
// hooks/useNotificationCount.js
import { useState, useEffect, useCallback } from 'react';
/**
* 실시간 알림 카운트를 관리하는 커스텀 훅
* @param {string} userId - 사용자 ID
* @param {string} wsUrl - WebSocket 서버 URL
*/
export const useNotificationCount = (userId, wsUrl) => {
const [count, setCount] = useState(0);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
// WebSocket 연결 초기화
const ws = new WebSocket(`${wsUrl}?userId=${userId}`);
ws.onopen = () => {
console.log('WebSocket 연결 성공');
setIsConnected(true);
setError(null);
// 연결 즉시 현재 카운트 요청
ws.send(JSON.stringify({
type: 'REQUEST_COUNT',
userId
}));
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
switch(data.type) {
case 'INITIAL_COUNT':
// 초기 카운트 설정
setCount(data.count);
break;
case 'COUNT_INCREMENT':
// 새 알림 발생 시 카운트 증가
setCount(prevCount => prevCount + 1);
// 브라우저 알림 권한이 있으면 푸시 알림 표시
if (Notification.permission === 'granted') {
new Notification('새로운 알림', {
body: data.message || '새로운 알림이 도착했습니다',
icon: '/notification-icon.png'
});
}
break;
case 'COUNT_UPDATE':
// 서버에서 전체 카운트 동기화
setCount(data.count);
break;
default:
console.warn('알 수 없는 메시지 타입:', data.type);
}
} catch (err) {
console.error('메시지 파싱 오류:', err);
}
};
ws.onerror = (err) => {
console.error('WebSocket 오류:', err);
setError('연결 오류가 발생했습니다');
setIsConnected(false);
};
ws.onclose = () => {
console.log('WebSocket 연결 종료');
setIsConnected(false);
// 3초 후 재연결 시도 (exponential backoff 적용 권장)
setTimeout(() => {
console.log('재연결 시도 중...');
}, 3000);
};
// 컴포넌트 언마운트 시 연결 정리
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
};
}, [userId, wsUrl]);
// 알림을 읽음 처리하는 함수
const markAsRead = useCallback(async (notificationIds) => {
try {
const response = await fetch('/api/notifications/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: notificationIds })
});
if (response.ok) {
const { newCount } = await response.json();
setCount(newCount);
}
} catch (err) {
console.error('읽음 처리 실패:', err);
}
}, []);
// 모든 알림 읽음 처리
const markAllAsRead = useCallback(async () => {
try {
const response = await fetch('/api/notifications/read-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
if (response.ok) {
setCount(0);
}
} catch (err) {
console.error('전체 읽음 처리 실패:', err);
}
}, [userId]);
return {
count,
isConnected,
error,
markAsRead,
markAllAsRead
};
};
WebSocket을 사용하는 이유는 HTTP 폴링보다 훨씬 효율적이기 때문이에요. 서버에서 새 알림이 생성되면 즉시 클라이언트로 푸시하므로 네트워크 오버헤드가 최소화되죠. 재연결 로직을 반드시 구현해야 하는데, 네트워크가 불안정한 모바일 환경에서 특히 중요해요.
Vue 3 Composition API로 구현하기
Vue를 사용하는 프로젝트를 위해 동일한 기능을 Vue 3 Composition API로 구현해볼게요.
<!-- NotificationBadge.vue -->
<template>
<div class="badge-container">
<slot></slot>
<transition name="badge-fade">
<span
v-if="shouldShow"
:class="badgeClasses"
role="status"
:aria-label="`${count}개의 새로운 알림`"
>
{{ displayCount }}
</span>
</transition>
</div>
</template>
<script setup>
import { computed, defineProps, toRefs } from 'vue';
const props = defineProps({
count: {
type: Number,
default: 0
},
max: {
type: Number,
default: 99
},
showZero: {
type: Boolean,
default: false
},
variant: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'danger', 'warning'].includes(value)
},
position: {
type: String,
default: 'top-right'
}
});
const { count, max, showZero, variant, position } = toRefs(props);
// 표시할 카운트 계산
const displayCount = computed(() => {
return count.value > max.value ? `${max.value}+` : count.value;
});
// 뱃지 표시 여부
const shouldShow = computed(() => {
return count.value > 0 || showZero.value;
});
// 동적 클래스 바인딩
const badgeClasses = computed(() => [
'notification-badge',
`badge-${variant.value}`,
`badge-${position.value}`
]);
</script>
<style scoped>
/* 위의 CSS와 동일하지만 Vue scoped 스타일로 적용 */
.badge-container {
position: relative;
display: inline-block;
}
.notification-badge {
position: absolute;
min-width: 20px;
height: 20px;
padding: 2px 6px;
font-size: 12px;
font-weight: 600;
line-height: 16px;
text-align: center;
border-radius: 10px;
color: white;
z-index: 10;
}
.badge-top-right {
top: -8px;
right: -8px;
}
.badge-primary {
background-color: #2563eb;
}
.badge-danger {
background-color: #dc2626;
}
.badge-warning {
background-color: #f59e0b;
}
/* Vue transition 애니메이션 */
.badge-fade-enter-active,
.badge-fade-leave-active {
transition: all 0.3s ease;
}
.badge-fade-enter-from {
opacity: 0;
transform: scale(0.5);
}
.badge-fade-leave-to {
opacity: 0;
transform: scale(0.5);
}
</style>
// composables/useNotificationCount.js
import { ref, onMounted, onUnmounted } from 'vue';
export function useNotificationCount(userId, wsUrl) {
const count = ref(0);
const isConnected = ref(false);
const error = ref(null);
let ws = null;
let reconnectTimeout = null;
const connect = () => {
ws = new WebSocket(`${wsUrl}?userId=${userId}`);
ws.onopen = () => {
isConnected.value = true;
error.value = null;
ws.send(JSON.stringify({
type: 'REQUEST_COUNT',
userId
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'INITIAL_COUNT' || data.type === 'COUNT_UPDATE') {
count.value = data.count;
} else if (data.type === 'COUNT_INCREMENT') {
count.value++;
}
};
ws.onerror = () => {
error.value = '연결 오류';
isConnected.value = false;
};
ws.onclose = () => {
isConnected.value = false;
// 3초 후 재연결
reconnectTimeout = setTimeout(connect, 3000);
};
};
const markAsRead = async (notificationIds) => {
try {
const response = await fetch('/api/notifications/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: notificationIds })
});
if (response.ok) {
const { newCount } = await response.json();
count.value = newCount;
}
} catch (err) {
console.error('읽음 처리 실패:', err);
}
};
const markAllAsRead = async () => {
try {
await fetch('/api/notifications/read-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
count.value = 0;
} catch (err) {
console.error('전체 읽음 처리 실패:', err);
}
};
onMounted(() => {
connect();
});
onUnmounted(() => {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
});
return {
count,
isConnected,
error,
markAsRead,
markAllAsRead
};
}
Vue 3의 Composition API는 로직 재사용성이 뛰어나요. useNotificationCount composable을 여러 컴포넌트에서 재사용할 수 있고, 테스트하기도 훨씬 쉽죠.
접근성(A11y) 고려사항과 구현
알림 뱃지는 시각적 요소지만, 스크린 리더 사용자도 이 정보에 접근할 수 있어야 해요. WCAG 2.1 가이드라인을 준수하는 접근성 구현을 해볼게요.
// AccessibleNotificationBadge.jsx
import React, { useEffect, useRef } from 'react';
const AccessibleNotificationBadge = ({
count,
max = 99,
children,
announceChanges = true // 카운트 변경을 음성으로 알릴지 여부
}) => {
const displayCount = count > max ? `${max}+` : count;
const prevCountRef = useRef(count);
const announceRef = useRef(null);
useEffect(() => {
// 카운트가 증가했을 때만 음성 안내
if (announceChanges && count > prevCountRef.current) {
const increment = count - prevCountRef.current;
// aria-live 영역에 메시지 설정
if (announceRef.current) {
announceRef.current.textContent =
`새로운 알림 ${increment}개. 총 ${count}개의 읽지 않은 알림이 있습니다.`;
}
}
prevCountRef.current = count;
}, [count, announceChanges]);
return (
<>
{/* 시각적으로 숨겨진 라이브 리전 - 스크린 리더만 읽음 */}
<div
ref={announceRef}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
<div className="badge-container">
{children}
{count > 0 && (
<span
className="notification-badge"
role="status"
aria-label={`${count}개의 읽지 않은 알림`}
// 숫자만 읽히지 않도록 aria-hidden으로 숨김
aria-hidden="false"
>
<span aria-hidden="true">{displayCount}</span>
</span>
)}
</div>
</>
);
};
export default AccessibleNotificationBadge;
/* 스크린 리더 전용 스타일 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* 키보드 포커스 시각화 - 접근성의 핵심 */
.badge-container:focus-within .notification-badge {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* 고대비 모드 지원 */
@media (prefers-contrast: high) {
.notification-badge {
border: 2px solid currentColor;
font-weight: 700;
}
}
/* 애니메이션 감소 모드 지원 */
@media (prefers-reduced-motion: reduce) {
.notification-badge {
transition: none;
}
.badge-fade-enter-active,
.badge-fade-leave-active {
transition: none;
}
}
접근성 구현에서 가장 중요한 부분은 aria-live 영역이에요. 새 알림이 올 때마다 스크린 리더가 자동으로 읽어주도록 하면, 시각 장애인 사용자도 실시간 알림을 인지할 수 있죠. aria-live="polite"는 현재 읽고 있는 내용을 방해하지 않고 다음에 읽어주라는 의미예요. 긴급한 알림이라면 aria-live="assertive"를 사용할 수도 있지만, 남용하면 사용자 경험을 해칠 수 있어요.
성능 최적화와 메모리 관리
알림 뱃지가 여러 개 있는 페이지에서는 성능 최적화가 필수예요. 불필요한 리렌더링을 방지하고 메모리 누수를 막는 방법을 알아볼게요.
// NotificationManager.js - 싱글톤 패턴으로 WebSocket 연결 관리
class NotificationManager {
constructor() {
if (NotificationManager.instance) {
return NotificationManager.instance;
}
this.ws = null;
this.listeners = new Map(); // 컴포넌트별 리스너 관리
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000; // 초기 재연결 대기 시간
NotificationManager.instance = this;
}
/**
* WebSocket 연결 초기화
* 여러 컴포넌트가 있어도 연결은 하나만 유지
*/
connect(userId, wsUrl) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
return; // 이미 연결되어 있으면 스킵
}
this.ws = new WebSocket(`${wsUrl}?userId=${userId}`);
this.ws.onopen = () => {
console.log('NotificationManager 연결 성공');
this.reconnectAttempts = 0; // 성공 시 재연결 카운터 리셋
this.reconnectDelay = 1000;
this.ws.send(JSON.stringify({
type: 'REQUEST_COUNT',
userId
}));
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// 등록된 모든 리스너에게 메시지 전달
this.listeners.forEach((callback) => {
callback(data);
});
};
this.ws.onerror = (err) => {
console.error('WebSocket 오류:', err);
};
this.ws.onclose = () => {
console.log('WebSocket 연결 종료');
this.attemptReconnect(userId, wsUrl);
};
}
/**
* Exponential backoff 재연결 로직
*/
attemptReconnect(userId, wsUrl) {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('최대 재연결 시도 횟수 초과');
this.notifyListeners({
type: 'CONNECTION_FAILED',
message: '서버 연결에 실패했습니다'
});
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`${delay}ms 후 재연결 시도 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
setTimeout(() => {
this.connect(userId, wsUrl);
}, delay);
}
/**
* 컴포넌트별 리스너 등록
* @param {string} id - 컴포넌트 고유 ID
* @param {Function} callback - 메시지 수신 콜백
*/
subscribe(id, callback) {
this.listeners.set(id, callback);
// 정리 함수 반환 (useEffect cleanup에서 사용)
return () => {
this.listeners.delete(id);
// 마지막 리스너가 제거되면 연결 종료
if (this.listeners.size === 0) {
this.disconnect();
}
};
}
/**
* 모든 리스너에게 메시지 전달
*/
notifyListeners(data) {
this.listeners.forEach((callback) => {
callback(data);
});
}
/**
* WebSocket 연결 종료
*/
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/**
* 메시지 전송
*/
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
console.warn('WebSocket이 연결되지 않음');
}
}
}
// 싱글톤 인스턴스 export
export const notificationManager = new NotificationManager();
// hooks/useOptimizedNotificationCount.js
import { useState, useEffect, useCallback, useMemo } from 'react';
import { notificationManager } from '../NotificationManager';
/**
* 최적화된 알림 카운트 훅
* 싱글톤 매니저를 사용하여 WebSocket 연결 공유
*/
export const useOptimizedNotificationCount = (userId, wsUrl) => {
const [count, setCount] = useState(0);
const [isConnected, setIsConnected] = useState(false);
// 컴포넌트 고유 ID 생성 (재렌더링 시에도 유지)
const componentId = useMemo(() =>
`notification-${Math.random().toString(36).substr(2, 9)}`,
[]
);
useEffect(() => {
// 메시지 핸들러 정의
const handleMessage = (data) => {
switch(data.type) {
case 'INITIAL_COUNT':
case 'COUNT_UPDATE':
setCount(data.count);
setIsConnected(true);
break;
case 'COUNT_INCREMENT':
setCount(prev => prev + 1);
break;
case 'CONNECTION_FAILED':
setIsConnected(false);
break;
}
};
// 매니저에 리스너 등록 및 연결
const unsubscribe = notificationManager.subscribe(componentId, handleMessage);
notificationManager.connect(userId, wsUrl);
// cleanup: 리스너 제거
return unsubscribe;
}, [userId, wsUrl, componentId]);
// 메모이제이션된 함수들
const markAsRead = useCallback(async (notificationIds) => {
try {
const response = await fetch('/api/notifications/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: notificationIds }),
// 성능을 위해 credentials는 필요할 때만 포함
credentials: 'same-origin'
});
if (response.ok) {
const { newCount } = await response.json();
setCount(newCount);
}
} catch (err) {
console.error('읽음 처리 실패:', err);
}
}, []);
const markAllAsRead = useCallback(async () => {
try {
await fetch('/api/notifications/read-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }),
credentials: 'same-origin'
});
setCount(0);
} catch (err) {
console.error('전체 읽음 처리 실패:', err);
}
}, [userId]);
return {
count,
isConnected,
markAsRead,
markAllAsRead
};
};
싱글톤 패턴을 사용하는 이유는 명확해요. 페이지에 알림 뱃지가 10개 있다고 해서 WebSocket 연결을 10개 만들 필요는 없잖아요. 하나의 연결로 모든 뱃지를 업데이트할 수 있죠. 또한 Exponential backoff 재연결 로직으로 네트워크 오류 시에도 안정적으로 재연결할 수 있어요.
흔한 실수와 해결 방법
실무에서 알림 뱃지를 구현하다 보면 자주 마주치는 실수들이 있어요. 대표적인 것들을 정리해볼게요.
실수 1: 카운트 동기화 문제
잘못된 예시:
// ❌ 여러 곳에서 카운트를 독립적으로 관리
const [headerCount, setHeaderCount] = useState(0);
const [sidebarCount, setSidebarCount] = useState(0);
// 한쪽만 업데이트되면 불일치 발생
useEffect(() => {
fetch('/api/notifications/count')
.then(res => res.json())
.then(data => setHeaderCount(data.count)); // sidebarCount는 업데이트 안 됨!
}, []);
올바른 예시:
// ✅ 전역 상태 관리로 단일 소스 유지
import { create } from 'zustand';
const useNotificationStore = create((set) => ({
count: 0,
setCount: (count) => set({ count }),
incrementCount: () => set((state) => ({ count: state.count + 1 })),
resetCount: () => set({ count: 0 })
}));
// 어디서든 동일한 카운트 참조
function Header() {
const count = useNotificationStore(state => state.count);
return <NotificationBadge count={count} />;
}
function Sidebar() {
const count = useNotificationStore(state => state.count);
return <NotificationBadge count={count} />;
}
실수 2: WebSocket 메모리 누수
잘못된 예시:
// ❌ cleanup 없이 WebSocket 연결
useEffect(() => {
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
setCount(JSON.parse(event.data).count);
};
// cleanup 함수가 없어서 컴포넌트 언마운트 시 연결이 유지됨
}, [wsUrl]);
올바른 예시:
// ✅ 반드시 cleanup에서 연결 종료
useEffect(() => {
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
setCount(JSON.parse(event.data).count);
};
// cleanup 함수로 메모리 누수 방지
return () => {
if (ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
};
}, [wsUrl]);
실수 3: 과도한 리렌더링
잘못된 예시:
// ❌ 매 렌더링마다 새로운 함수 생성
function NotificationIcon() {
const { count } = useNotificationCount();
// 부모가 리렌더링될 때마다 새로운 함수 생성
const handleClick = () => {
console.log('클릭');
};
return (
<NotificationBadge count={count}>
<Icon onClick={handleClick} />
</NotificationBadge>
);
}
올바른 예시:
// ✅ useCallback으로 함수 메모이제이션
function NotificationIcon() {
const { count } = useNotificationCount();
// 함수를 메모이제이션하여 불필요한 리렌더링 방지
const handleClick = useCallback(() => {
console.log('클릭');
}, []); // 의존성이 없으므로 한 번만 생성
return (
<NotificationBadge count={count}>
<Icon onClick={handleClick} />
</NotificationBadge>
);
}
이런 실수들은 초기에는 눈에 띄지 않지만, 사용자가 늘어나고 알림이 빈번해지면 성능 문제로 직결돼요. 특히 WebSocket 메모리 누수는 디버깅하기 어려운 버그를 만들어내죠.
실전 활용 예제
마지막으로 실무에서 바로 쓸 수 있는 완성형 예제를 보여드릴게요.
// App.jsx - 실전 통합 예제
import React from 'react';
import NotificationBadge from './components/NotificationBadge';
import { useOptimizedNotificationCount } from './hooks/useOptimizedNotificationCount';
import { BellIcon, MessageIcon, CartIcon } from './components/Icons';
function App() {
const userId = 'user123';
const wsUrl = 'wss://api.example.com/notifications';
const {
count,
isConnected,
markAsRead,
markAllAsRead
} = useOptimizedNotificationCount(userId, wsUrl);
// 카테고리별 카운트 (실제로는 서버에서 받아오거나 필터링)
const messageCount = Math.floor(count * 0.6);
const cartCount = Math.floor(count * 0.3);
const generalCount = count - messageCount - cartCount;
const handleNotificationClick = async () => {
// 알림 패널 열기
const panel = document.getElementById('notification-panel');
panel.classList.toggle('open');
// 모든 알림 읽음 처리
await markAllAsRead();
};
return (
<div className="app">
<header className="header">
<div className="header-left">
<h1>My App</h1>
</div>
<div className="header-right">
{/* 연결 상태 표시 */}
{!isConnected && (
<span className="connection-warning" role="alert">
연결 끊김
</span>
)}
{/* 메시지 알림 */}
<NotificationBadge
count={messageCount}
variant="primary"
position="top-right"
>
<button
className="icon-button"
aria-label="메시지"
>
<MessageIcon />
</button>
</NotificationBadge>
{/* 장바구니 알림 */}
<NotificationBadge
count={cartCount}
variant="warning"
position="top-right"
>
<button
className="icon-button"
aria-label="장바구니"
>
<CartIcon />
</button>
</NotificationBadge>
{/* 전체 알림 */}
<NotificationBadge
count={generalCount}
max={99}
variant="danger"
position="top-right"
>
<button
className="icon-button"
aria-label={`알림 ${count}개`}
onClick={handleNotificationClick}
>
<BellIcon />
</button>
</NotificationBadge>
</div>
</header>
{/* 알림 패널 */}
<div id="notification-panel" className="notification-panel">
<div className="panel-header">
<h2>알림</h2>
<button onClick={markAllAsRead}>
모두 읽음
</button>
</div>
<div className="panel-body">
{/* 알림 목록 렌더링 */}
</div>
</div>
</div>
);
}
export default App;
/* App.css - 실전 스타일 */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header-right {
display: flex;
gap: 1rem;
align-items: center;
}
.icon-button {
position: relative;
padding: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 0.5rem;
transition: background 0.2s;
}
.icon-button:hover {
background: #f3f4f6;
}
.icon-button:focus {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.connection-warning {
color: #dc2626;
font-size: 0.875rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
background: #fee2e2;
border-radius: 0.25rem;
}
.notification-panel {
position: fixed;
top: 60px;
right: -400px;
width: 400px;
height: calc(100vh - 60px);
background: white;
box-shadow: -2px 0 8px rgba(0,0,0,0.1);
transition: right 0.3s ease;
z-index: 1000;
}
.notification-panel.open {
right: 0;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
/* 모바일 반응형 */
@media (max-width: 768px) {
.notification-panel {
width: 100%;
right: -100%;
}
.header-right {
gap: 0.5rem;
}
.icon-button {
padding: 0.375rem;
}
}
이 예제는 실제 프로덕션에서 바로 사용할 수 있는 수준이에요. 카테고리별 알림 분리, 연결 상태 표시, 반응형 디자인까지 모두 포함했죠.
보안 고려사항
알림 시스템은 사용자 데이터를 다루므로 보안이 중요해요.
// 보안이 강화된 WebSocket 연결
const connectSecurely = (userId, wsUrl) => {
// 1. HTTPS/WSS만 허용
if (!wsUrl.startsWith('wss://')) {
throw new Error('보안 연결(WSS)만 허용됩니다');
}
// 2. 인증 토큰 포함
const token = localStorage.getItem('authToken');
if (!token) {
throw new Error('인증이 필요합니다');
}
// 3. 토큰을 URL이 아닌 초기 메시지로 전송 (URL 로깅 방지)
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'AUTHENTICATE',
token: token,
userId: userId
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// 4. 메시지 검증
if (!data.type || !data.timestamp) {
console.error('잘못된 메시지 형식');
return;
}
// 5. 타임스탬프 검증 (replay attack 방지)
const messageAge = Date.now() - new Date(data.timestamp).getTime();
if (messageAge > 60000) { // 1분 이상 된 메시지 거부
console.warn('오래된 메시지 무시');
return;
}
// 정상 처리
handleMessage(data);
};
return ws;
};
WebSocket 보안에서 가장 중요한 것은 WSS(WebSocket Secure) 사용이에요. HTTP와 HTTPS의 관계처럼, WS와 WSS도 암호화 여부가 다르죠. 또한 인증 토큰을 URL에 포함하면 서버 로그에 남을 수 있으니, 연결 후 메시지로 전송하는 게 안전해요.
결론
알림 뱃지 시스템 구현은 단순해 보이지만, 실무 수준으로 완성하려면 많은 고려사항이 있어요. 이 글에서 다룬 핵심 내용을 정리하면:
- 재사용 가능한 컴포넌트 설계: props를 통해 다양한 스타일과 동작을 지원하도록 설계하세요
- 실시간 업데이트: WebSocket으로 서버 푸시 방식 구현하고, 재연결 로직 필수 포함하세요
- 접근성: aria 속성과 스크린 리더 지원으로 모든 사용자가 접근 가능하도록 만드세요
- 성능 최적화: 싱글톤 패턴으로 연결 공유하고, 메모이제이션으로 불필요한 리렌더링 방지하세요
- 보안: WSS 사용하고, 메시지 검증 및 인증 토큰 안전하게 관리하세요
실무에서 적용할 때는 프로젝트 규모에 맞게 선택적으로 구현하세요. 소규모 프로젝트라면 기본 컴포넌트와 폴링으로도 충분할 수 있고, 대규모 실시간 서비스라면 여기서 다룬 모든 최적화가 필요할 거예요.
알림 뱃지를 완성했다면 다음 단계로 푸시 알림 시스템 구축, 알림 우선순위 관리, 알림 설정 UI 구현도 고려해보세요. 사용자 경험을 한 단계 더 높일 수 있을 거예요!
'UI|UX 구현' 카테고리의 다른 글
| 탭 컴포넌트 접근성 완벽 가이드 - WAI-ARIA 패턴으로 구현하는 법 (1) | 2026.03.16 |
|---|