본문 바로가기

UI|UX 구현

알림 뱃지 시스템 구현하기 - 실시간 업데이트부터 접근성까지 완벽 가이드

{ } UI/UX 구현 알림 뱃지 시스템 구현하기 9 </> Notification Badge Implementation

알림 뱃지 시스템 구현하기 - 실시간 업데이트부터 접근성까지 완벽 가이드

현대 웹 애플리케이션에서 알림 뱃지는 사용자 인게이지먼트를 높이는 핵심 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에 포함하면 서버 로그에 남을 수 있으니, 연결 후 메시지로 전송하는 게 안전해요.

결론

알림 뱃지 시스템 구현은 단순해 보이지만, 실무 수준으로 완성하려면 많은 고려사항이 있어요. 이 글에서 다룬 핵심 내용을 정리하면:

  1. 재사용 가능한 컴포넌트 설계: props를 통해 다양한 스타일과 동작을 지원하도록 설계하세요
  2. 실시간 업데이트: WebSocket으로 서버 푸시 방식 구현하고, 재연결 로직 필수 포함하세요
  3. 접근성: aria 속성과 스크린 리더 지원으로 모든 사용자가 접근 가능하도록 만드세요
  4. 성능 최적화: 싱글톤 패턴으로 연결 공유하고, 메모이제이션으로 불필요한 리렌더링 방지하세요
  5. 보안: WSS 사용하고, 메시지 검증 및 인증 토큰 안전하게 관리하세요

실무에서 적용할 때는 프로젝트 규모에 맞게 선택적으로 구현하세요. 소규모 프로젝트라면 기본 컴포넌트와 폴링으로도 충분할 수 있고, 대규모 실시간 서비스라면 여기서 다룬 모든 최적화가 필요할 거예요.

알림 뱃지를 완성했다면 다음 단계로 푸시 알림 시스템 구축, 알림 우선순위 관리, 알림 설정 UI 구현도 고려해보세요. 사용자 경험을 한 단계 더 높일 수 있을 거예요!