background
React5분

커스텀 훅 가이드라인

2025년 5월 8일

1. Custom Hooks


  • 개발자가 정의한 훅을 의미함

    이를 이용해서 반복적인 로직을 함수로 뽑아서 사용할 수 있다.

    💡 1. 상태 관리 로직의 재활용 가능

    1. 클래스 컴포넌트보다 적은 양의 코드로 동일한 로직 구현 가능

    2. 함수형으로 작성하기 때문에 단순 명료함 (e.g. useAxios )

예시

  • React 공식 문서에 있는 컴포넌트
javascript
function FriendStatus(props) {
	const [isOnline, setIsOnline] = useState(null);
	useEffect(() => {
		function handleStatusChange(status) {
			setIsOnline(status.isOnline);
		}
		ChatAPI.subscribeToFriendStatus(
		props.friend.id, hanldeStatusChange);
		return () => {
			ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
		};
	});
	
	if (isOnline === null) {
		return 'Loading...';
	}
	return isOnline ? 'Online' : 'Offline';
}
function FriendListItem(props) {
	const [isOnline, setIsOnline] = useState(null);
	useEffcet(() => {
		function handleStatusChange(status) {
			setIsOnline(status.isOnline);
		}
		ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
		return () => {
			ChatAPI.unsubscribeFromFriendStatus(props.friend.is, hanldeStatusChange);
		};
	});
	
	return (
		<li style={{ color: isOnline ? 'green' : 'black' }}>
		{props.friend.name}
		</li>
	);
}
  • FriendStatus 컴포넌트의 역할은 사용자들이 온, 오프라인인 지 확인 하고 FriendListItem 컴포넌트는 사용자들의 상태에 따라 온라인이면 초록색으로 오프라인이면 검정색으로 표시하는 역할이다.
  • 이 두 컴포넌트는 정확히 동일하게 친구의 상태를 확인하는 로직이 존재한다. 이 로직을 빼내서 두 컴포넌트에서 공유하고자 만들 수 있는 것이 바로 Custom Hook 이다.
javascript
function useFriendStatus(friendID) {
	const [isOnline, setIsOnline] = useState(null);
	
	useEffect(() => {
		function handleStatusChange(status) {
			setIsOnline(status.isOnline);
		}
		
		ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
		return () => {
			ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
		}
	})
	
	return isOnline;
}
  • 이와 같이 두 컴포넌트에서 동일하게 사용되는 로직을 분리하여 함수 useFriendStatus 로 만든다. 단, Custom Hook 을 정의할 때는 일종의 규칙이 필요하다.

    💡 - Custom Hook 을 정의할 때는 함수 이름 앞에 use 를 붙이는 것이 규칙이다.

    • 대개의 경우 프로젝트 내에 hooks 디렉토리에 Custom Hook을 위치시킨다.

    • Custom Hook으로 만들 때 함수는 조건부 함수가 아니어야 한다. 즉 return 하는 값은 조건부여서는 안된다. 그렇기 때문에 useFriendStatus 훅은 온라인 상태의 여부를 boolean 타입으로 반환하고 있다.

  • Custom Hook은 Hook 내부에 useState 와 같은 React 내장 Hook을 사용하여 작성할 수 있다.

  • 일반 함수 내부에서는 React 내장 Hook을 불러 사용할 수 없지만 Custom Hook은 가능하다.

  • 적용 방법

javascript
function FriendStatus(props) {
	const isOnline = useFriendStatus(props.friend.id);
	
	if (isOnline === null) {
		return 'Loading...';
	}
	return isOnline ? 'Online' : 'Offline';
}
function FriendListItem(props) {
	const isOnline = useFriendStatus(props.friend.id);
	
	return (
		<li style={{ color: isOnline ? 'green' : 'black' }}>
		{props.friend.name}
		</li>
	)
}
  • 이런 식으로 로직을 분리해서 Custom Hook으로 만들면 컴포넌트는 UI 를 그려주기만 하는 기존의 기능에 충실할 수 있다.
  • 유의할 점은 같은 Custom Hook을 사용했다고 해서 두 개의 같은 컴포넌트가 같은 state 를 공유하는 것이 아니다. 그저 로직만 공유할 뿐, state 는 컴포넌트 내에서 독립적으로 정의되어 있다.

Custom Hook의 예시


여러 url을 사용하는 axios 통신에서 사용하는 useAxios Hook

typescript
interface CustomConfig {
  url?: string;
  headers?: Record<string, string>;
  params?: Record<string, any>;
}

interface Response<T> {
  isSuccess: boolean;
  result: T;
  message: string;
  code?: string;
}

export const useAxios = <T, P = void>(endpoint: string, method: "GET" | "POST" | "PATCH" | "DELETE" = "GET") => { 
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [data, setData] = useState<T | null>(null);
  
  const execute = useCallback(
    async (payload?: P, config: CustomConfig = {}) => {
      setLoading(true);
      setError(null);
      
      try {
        let response;
        const url = config?.url || endpoint;
        const headers = config.headers || {};
        const params = config.params;
        
        switch (method) {
          case 'GET':
            response = await axiosInstance.get<Response<T>>(url, { headers, params });
            break;
          case 'POST':
            response = await axiosInstance.post<Response<T>>(url, payload, { headers, params });
            break;
          case 'PATCH':
            response = await axiosInstance.patch<Response<T>>(url, payload, { headers, params });
            break;
          case 'DELETE':
            response = await axiosInstance.delete<Response<T>>(url, { headers, data: payload, params });
            break;
          default:
            throw new Error(`지원하지 않는 HTTP 메소드: ${method}`);
        }
        
        const responseData = response.data;
        
        if (responseData.isSuccess) {
          setData(responseData.result);
          return { 
            success: true, 
            data: responseData.result, 
            message: responseData.message
          };        
        } else {
          setError(responseData.message);
          return {
            success: false,
            error: responseData.message,
            code: responseData.code,
          };
        }
      } catch (err: any) {
        const errorMessage = err.response?.data?.message || err.message || '알 수 없는 오류 발생';
        setError(errorMessage);
        return { success: false, error: errorMessage };
      } finally {
        setLoading(false);
      }
    },
    [endpoint, method]
  );
  
  return { data, loading, error, execute, setData };
};
  • 사용법
typescript
const { data, loading, error, excute, setData } = useAxios<ResponseType, PayloadType>(
	"api/endpoint",
	"GET"
);
  • 파라미터

    • endpoint : API 엔드 포인트 URL
    • method : HTTP 메소드(deafult : “GET”)
  • 반환값

    • data : API 응답 데이터
    • loading : 요청 진행 상태 (boolean)
    • error : 에러 메시지 (string 또는 null)
    • excute : 요청을 실행하는 함수
    • setData : 데이터를 설정하는 함수
  • 사용 예제

  • GET 요청

    typescript
    interface User {
    	id: number;
    	name: string;
    	email: string;
    }
    
    const { data, loading, error, excute } = useAxios<User[]>("/api/users");
    
    useEffect(() => {
    	excute();
    }, [excute]);
    
    if (loading) return <div>로딩 중...</div>;
    if (error) return <div>에러 : {error} </div>;
    if (data) {
    	return (
    		<ul>
    			{data.map(user => (
    				<li key={user.id}>{user.name} ({user.email})</li>
    			))}
    		</ul>
    	)
    }
    
  • POST 요청

    typescript
    interface CreateUserPayload {
      name: string;
      email: string;
    }
    
    interface UserResponse {
      id: number;
      name: string;
      email: string;
    }
    
    const { loading, error, execute } = useAxios<UserResponse, CreateUserPayload>("/api/users", "POST");
    
    const handleSubmit = async (formData) => {
      const result = await execute({
        name: formData.name,
        email: formData.email
      });
      
      if (result.success) {
        alert(`사용자가 생성되었습니다: ${result.data.name}`);
      } else {
        alert(`오류: ${result.error}`);
      }
    };
    
  • 구현 세부 사항

    • 상태 관리
      • loading : 요청 진행 중 여부
      • error : 발생한 오류 메시지
      • data : 성공적으로 받아온 데이터
    • execute 함수
    • execute 함수는 실제 HTTP 요청 수행, 다음과 같은 파라미터를 받음
      • payload : 요청 본문에 포함될 데이터
      • config : 추가 설정
    • 응답 구조
    typescript
    {
      isSuccess: boolean;
      result: T;
      message: string;
      code?: string;
    }
    
    • 반환값
    • execute 함수의 반환 값:
      • 성공 : { success: true, data: 응답 데이터, message: 응답메시지 }
      • 실패 : { success: false, error: 에러 메시지, code?: 에러 코드 }

    WebSocket 통신을 관리하는 useWebSocket 함수

    typescript
    import { Client, IMessage } from "@stomp/stompjs";
    import { useCallback, useEffect, useState } from "react";
    import { createStompClient, sendMessage, subscribeToTopic } from "../service/stompService";
    
    /**
     * WebSocket 토픽 정의
     * 각 도메인별(ROOM, CHAT, GAME)로 토픽을 구분하고 roomId를 파라미터로 받아 동적 토픽 생성
     * 백엔드와 협의 후 변경될 수 있음
     */
    export const SOCKET_TOPICS = {
      ROOM: (roomId: string) => `/topic/room/${roomId}`,
      CHAT: (roomId: string) => `/topic/chat/${roomId}`,
      GAME: (roomId: string) => `/topic/game/${roomId}`,
    };
    
    /**
     * WebSocket 메시지 타입 정의
     * 각 도메인별 가능한 액션 타입을 상수로 정의
     * 백엔드와 협의 후 변경될 수 있음
     */
    export const MESSAGE_TYPES = {
      ROOM: {
        KICK: "KICK",     // 방에서 유저 강퇴
        JOIN: "JOIN",     // 방 입장
        LEAVE: "LEAVE",   // 방 퇴장
        UPDATE: "UPDATE", // 방 정보 업데이트
        READY: "READY",   // 준비 상태 변경
        START: "START",   // 게임 시작
      },
      GAME: {
        STATUS: {
          ATTACK: "ATTACK", // 공격 액션
          DAMAGE: "DAMAGE", // 데미지 받음
          DEAD: "DEAD",     // 캐릭터 사망
          END: "END",       // 게임 종료
        },
        PROBLEM: "PROBLEM", // 문제 전송/수신
        ANSWER: "ANSWER",   // 답변 제출/결과
      },
      CHAT: {
        SEND: "SEND",       // 메시지 전송
        RECEIVE: "RECEIVE",  // 메시지 수신 
      },
    };
    
    /**
     * WebSocket 연결 및 관리를 위한 React 훅
     * 
     * 주요 기능:
     * - WebSocket 클라이언트 생성 및 연결 관리
     * - 토픽 구독 및 구독 해제
     * - 메시지 전송
     * 
     * @returns WebSocket 관련 상태 및 함수들
     */
    export const useWebSocket = () => {
      // WebSocket 클라이언트 상태
      const [client, setClient] = useState<Client | null>(null);
      // 연결 상태
      const [connected, setConnected] = useState(false);
      // 활성 구독 목록 (토픽별로 관리)
      const [subscriptions, setSubscripitons] = useState<Record<string, any>>({});
    
      // 컴포넌트 마운트 시 WebSocket 클라이언트 초기화
      useEffect(() => {
        // stompService에서 STOMP 클라이언트 생성
        const stompClient = createStompClient();
    
        // 연결 성공 이벤트 핸들러
        stompClient.onConnect = () => {
          setConnected(true);
        };
    
        // 연결 해제 이벤트 핸들러
        stompClient.onDisconnect = () => {
          setConnected(false);
        };
    
        // 클라이언트 활성화 (연결 시작)
        stompClient.activate();
        setClient(stompClient);
    
        // 컴포넌트 언마운트 시 정리 함수
        return () => {
          if (stompClient.active) {
            stompClient.deactivate();
          }
        };
      }, []);
    
      /**
       * 토픽 구독 함수
       * 
       * @param topic 구독할 토픽 경로
       * @param callback 메시지 수신 시 실행할 콜백 함수
       * @returns 구독 객체 또는 실패 시 null
       */
      const subscribe = useCallback(
        (topic: string, callback: (message: IMessage) => void) => {
          // 클라이언트가 없거나 연결되지 않은 경우
          if (!client || !connected) return null;
    
          // 토픽 구독 수행
          const subscribtion = subscribeToTopic(client, topic, callback);
    
          // 구독 성공 시 상태에 추가
          if (subscribtion) {
            setSubscripitons((prev) => ({
              ...prev,
              [topic]: subscribtion,
            }));
          }
    
          return subscribtion;
        },
        [client, connected]
      );
    
      /**
       * 토픽 구독 해제 함수
       * 
       * @param topic 구독 해제할 토픽 경로
       */
      const unsubscribe = useCallback((topic: string) => {
        if (subscriptions[topic]) {
            // 구독 객체 해제
            subscriptions[topic].unsubscribe();
            // 상태에서 제거
            setSubscripitons(prev => {
                const newSubs = { ...prev };
                delete newSubs[topic];
                return newSubs;
            });
        }
      }, [subscriptions]);
    
      /**
       * 메시지 전송 함수
       * 
       * @param destination 메시지를 전송할 대상 경로
       * @param body 전송할 메시지 본문 (객체)
       * @param headers 추가 헤더 (선택적)
       * @returns 전송 성공 여부
       */
      const send = useCallback((destination: string, body: any, headers = {}) => {
        // 클라이언트가 없거나 연결되지 않은 경우
        if (!client || !connected) return false;
    
        // 메시지 전송
        sendMessage(client, destination, body, headers);
        return true;
      }, [client, connected]);
    
      // 훅에서 제공하는 기능과 상태 반환
      return {
        client,        // STOMP 클라이언트 객체
        connected,     // 연결 상태
        subscribe,     // 구독 함수
        unsubscribe,   // 구독 해제 함수
        send,          // 메시지 전송 함수
        topics: SOCKET_TOPICS,     // 토픽 정의 객체
        messageTypes: MESSAGE_TYPES, // 메시지 타입 정의 객체
      };
    };
    
  • 주요 기능

    1. WebSocket 연결 관리
      1. 컴포넌트 마운트 시 자동으로 WebSocket 연결 생성
      2. 컴포넌트 언마운트 시 연결 해제
      3. 연결 상태 관리 및 제공
    2. 토픽 구독 관리
      1. 특정 토픽에 대한 구독 기능
      2. 구독한 토픽으로부터 메시지 수신 시 콜백 함수 실행
      3. 구독 해제 기능
    3. 메시지 전송
      1. 지정된 대상으로 메시지 전송
      2. 추가 헤더 지원

    기본 상수 정의

    SOCKET_TOPICS

    토픽 경로를 동적으로 생성하는 함수 제공

    typescript
    const SOCKET_TOPICS = {
      ROOM: (roomId: string) => `/topic/room/${roomId}`,
      CHAT: (roomId: string) => `/topic/chat/${roomId}`,
      GAME: (roomId: string) => `/topic/game/${roomId}`,
    };
    

    MESSAGE_TYPES

    각 도메인에서 사용되는 메시지 타입을 정의

    typescript
    const MESSAGE_TYPES = {
      ROOM: {
        KICK: "KICK",
        JOIN: "JOIN",
        // ...
      },
      GAME: {
        STATUS: {
          ATTACK: "ATTACK",
          // ...
        },
        // ...
      },
      CHAT: {
        SEND: "SEND",
        RECEIVE: "RECEIVE",
      },
    };
    

    사용 예제

    • 기본 설정 및 구독
    typescript
    import { useWebSocket } from '../hooks/useWebSocket';
    import { useEffect } from 'react';
    
    const ChatRoom = ({ roomId }) => {
      const { connected, subscribe, unsubscribe, send, topics, messageTypes } = useWebSocket();
      
      useEffect(() => {
        // 연결 상태 확인
        if (!connected) return;
        
        // 채팅방 토픽 구독
        const chatTopic = topics.CHAT(roomId);
        subscribe(chatTopic, (message) => {
          const receivedMessage = JSON.parse(message.body);
          console.log('채팅 메시지 수신:', receivedMessage);
          // 메시지 처리 로직...
        });
        
        // 컴포넌트 언마운트 시 구독 해제
        return () => {
          unsubscribe(chatTopic);
        };
      }, [connected, roomId, subscribe, unsubscribe, topics]);
      
      // 메시지 전송 함수
      const sendChatMessage = (text) => {
        const destination = `/app/chat/${roomId}`;
        const payload = {
          type: messageTypes.CHAT.SEND,
          content: text,
          timestamp: new Date().toISOString()
        };
        
        send(destination, payload);
      };
      
      return (
        <div>
          {/* 채팅 UI */}
          <button onClick={() => sendChatMessage('안녕하세요!')}>
            메시지 전송
          </button>
        </div>
      );
    };
    
    • 게임 상태 관리
    typescript
    import { useWebSocket } from '../hooks/useWebSocket';
    import { useEffect, useState } from 'react';
    
    const GameScreen = ({ roomId }) => {
      const { connected, subscribe, unsubscribe, send, topics, messageTypes } = useWebSocket();
      const [gameState, setGameState] = useState({
        players: [],
        currentTurn: null,
        gameStatus: 'waiting'
      });
      
      useEffect(() => {
        if (!connected) return;
        
        // 게임 상태 토픽 구독
        const gameTopic = topics.GAME(roomId);
        subscribe(gameTopic, (message) => {
          const data = JSON.parse(message.body);
          
          switch (data.type) {
            case messageTypes.GAME.STATUS.ATTACK:
              // 공격 처리 로직
              break;
            case messageTypes.GAME.PROBLEM:
              // 문제 표시 로직
              break;
            case messageTypes.GAME.STATUS.END:
              // 게임 종료 처리
              break;
            default:
              console.log('알 수 없는 게임 메시지:', data);
          }
          
          // 게임 상태 업데이트
          setGameState(prevState => ({
            ...prevState,
            ...data.gameState
          }));
        });
        
        return () => {
          unsubscribe(gameTopic);
        };
      }, [connected, roomId, subscribe, unsubscribe, topics, messageTypes]);
      
      // 공격 액션 전송
      const sendAttack = (targetId) => {
        const destination = `/app/game/${roomId}/action`;
        const payload = {
          type: messageTypes.GAME.STATUS.ATTACK,
          targetId,
          timestamp: new Date().toISOString()
        };
        
        send(destination, payload);
      };
      
      return (
        <div>
          {/* 게임 UI */}
          <div>현재 턴: {gameState.currentTurn}</div>
          <div>
            {gameState.players.map(player => (
              <button 
                key={player.id}
                onClick={() => sendAttack(player.id)}
                disabled={gameState.currentTurn !== '내 아이디'}
              >
                {player.name} 공격하기
              </button>
            ))}
          </div>
        </div>
      );
    };
    

3. 심화 : 컴포넌트를 더욱 멍청하게

Custom Hook 을 활용해서 바로 컴포넌트에 적용 시키는 것도 물론 좋지만, 지금 구현한 훅의 경우 기본 통신 훅 (인프라 계층 훅) 인데, 바로 컴포넌트에 적용하게 되면 컴포넌트 하나에 의존하게 되는 로직이 여러 개가 또 생길 수 있는 문제가 발생한다. 이는 컴포넌트가 통신 로직에 과도하게 의존하게 되고, 내부에 많은 로직이 혼합된다.

→ 이를 해결하기 위해 중간 계층의 훅(도메인 훅)을 추가하는 방안 생각

도메인 계층 훅의 필요성


인프라 계층 훅(useAxios , useWebSocket )과 UI 컴포넌트 사이에 도메인 계층 훅 도입 시 장점

  1. 비즈니스 로직 캡슐화 : 특정 도메인에 관련된 모든 로직을 한 곳에 모아 관리할 수 있음
  2. 상태 관리 중앙화: 도메인 관련 상태를 중앙에서 관리하여 일관성을 유지함
  3. 컴포넌트 재사용성 증가: UI 컴포넌트는 로직에 의존하지 않아 다양한 상황에서 재사용 가능
  4. 테스트 용이성 : 비즈니스 로직을 UI 와 분리하여 단위 테스트를 더 쉽게 작성 가능

도메인 계층 훅 구현 예시

  1. useRoom 훅 (WebSocket 활용)
typescript
// useRoom.ts
import { useWebSocket } from './useWebSocket';
import { useState, useEffect, useCallback } from 'react';

export interface RoomUser {
  id: string;
  name: string;
  isReady: boolean;
}

export interface RoomInfo {
  id: string;
  name: string;
  maxUsers: number;
  users: RoomUser[];
  owner: string;
  status: 'waiting' | 'playing';
}

export const useRoom = (roomId: string) => {
  const { topics, messageTypes, subscribe, unsubscribe, send, connected } = useWebSocket();
  const [roomInfo, setRoomInfo] = useState<RoomInfo | null>(null);
  const [isJoined, setIsJoined] = useState(false);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // 방 정보 초기 로드 및 실시간 업데이트 구독
  useEffect(() => {
    if (!connected || !roomId) return;
    setIsLoading(true);
    
    // 방 토픽 구독
    const roomTopic = topics.ROOM(roomId);
    const subscription = subscribe(roomTopic, (message) => {
      const data = JSON.parse(message.body);
      
      switch (data.type) {
        case messageTypes.ROOM.UPDATE:
          setRoomInfo(data.room);
          break;
        case messageTypes.ROOM.JOIN:
          setRoomInfo(prev => {
            if (!prev) return data.room;
            return {
              ...prev,
              users: [...prev.users, data.user]
            };
          });
          
          if (data.user.id === 'current-user-id') { // 실제 구현시 사용자 ID 확인 필요
            setIsJoined(true);
          }
          break;
        case messageTypes.ROOM.LEAVE:
          setRoomInfo(prev => {
            if (!prev) return prev;
            return {
              ...prev,
              users: prev.users.filter(user => user.id !== data.userId)
            };
          });
          
          if (data.userId === 'current-user-id') { // 실제 구현시 사용자 ID 확인 필요
            setIsJoined(false);
          }
          break;
        case messageTypes.ROOM.KICK:
          if (data.userId === 'current-user-id') { // 실제 구현시 사용자 ID 확인 필요
            setIsJoined(false);
            setError('방에서 강퇴되었습니다.');
          }
          break;
      }
      
      setIsLoading(false);
    });
    
    // 초기 방 정보 요청
    send(`/app/room/${roomId}/info`, {
      type: 'GET_INFO'
    });
    
    return () => {
      if (subscription) {
        unsubscribe(roomTopic);
      }
    };
  }, [connected, roomId, subscribe, unsubscribe, send, topics, messageTypes]);
  
  // 방 입장 함수
  const joinRoom = useCallback(() => {
    if (!connected || !roomId) return false;
    
    send(`/app/room/${roomId}/join`, {
      type: messageTypes.ROOM.JOIN,
      userId: 'current-user-id', // 실제 구현시 사용자 ID 필요
      userName: 'User Name' // 실제 구현시 사용자 이름 필요
    });
    
    return true;
  }, [connected, roomId, send, messageTypes]);
  
  // 방 퇴장 함수
  const leaveRoom = useCallback(() => {
    if (!connected || !roomId || !isJoined) return false;
    
    send(`/app/room/${roomId}/leave`, {
      type: messageTypes.ROOM.LEAVE,
      userId: 'current-user-id' // 실제 구현시 사용자 ID 필요
    });
    
    return true;
  }, [connected, roomId, isJoined, send, messageTypes]);
  
  // 준비 상태 변경 함수
  const toggleReady = useCallback(() => {
    if (!connected || !roomId || !isJoined) return false;
    
    send(`/app/room/${roomId}/ready`, {
      type: messageTypes.ROOM.READY,
      userId: 'current-user-id' // 실제 구현시 사용자 ID 필요
    });
    
    return true;
  }, [connected, roomId, isJoined, send, messageTypes]);
  
  // 게임 시작 함수 (방장만 가능)
  const startGame = useCallback(() => {
    if (!connected || !roomId || !isJoined || !roomInfo || roomInfo.owner !== 'current-user-id') 
      return false;
    
    send(`/app/room/${roomId}/start`, {
      type: messageTypes.ROOM.START
    });
    
    return true;
  }, [connected, roomId, isJoined, roomInfo, send, messageTypes]);
  
  return {
    roomInfo,
    isJoined,
    isLoading,
    error,
    joinRoom,
    leaveRoom,
    toggleReady,
    startGame,
    isConnected: connected
  };
};
  1. useChat 훅 (WebSocket 활용)
typescript
// useChat.ts
import { useWebSocket } from './useWebSocket';
import { useState, useEffect, useCallback } from 'react';

export interface ChatMessage {
  id: string;
  userId: string;
  userName: string;
  content: string;
  timestamp: string;
}

export const useChat = (roomId: string) => {
  const { topics, messageTypes, subscribe, unsubscribe, send, connected } = useWebSocket();
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  
  // 채팅 메시지 구독
  useEffect(() => {
    if (!connected || !roomId) return;
    setIsLoading(true);
    
    const chatTopic = topics.CHAT(roomId);
    const subscription = subscribe(chatTopic, (message) => {
      const data = JSON.parse(message.body);
      
      if (data.type === messageTypes.CHAT.RECEIVE) {
        setMessages(prev => [...prev, data.message]);
      }
      
      setIsLoading(false);
    });
    
    // 초기 메시지 불러오기
    send(`/app/chat/${roomId}/history`, {
      type: 'GET_HISTORY',
      limit: 50 // 최근 50개 메시지만 요청
    });
    
    return () => {
      if (subscription) {
        unsubscribe(chatTopic);
      }
    };
  }, [connected, roomId, subscribe, unsubscribe, send, topics, messageTypes]);
  
  // 메시지 전송 함수
  const sendMessage = useCallback((content: string) => {
    if (!connected || !roomId || !content.trim()) return false;
    
    send(`/app/chat/${roomId}/send`, {
      type: messageTypes.CHAT.SEND,
      userId: 'current-user-id', // 실제 구현시 사용자 ID 필요
      userName: 'User Name',     // 실제 구현시 사용자 이름 필요
      content: content.trim(),
      timestamp: new Date().toISOString()
    });
    
    return true;
  }, [connected, roomId, send, messageTypes]);
  
  return {
    messages,
    isLoading,
    sendMessage,
    isConnected: connected
  };
};
  1. useUserInfo 훅 (Axios 활용)
typescript
// useUserProfile.ts
import { useAxios } from './useAxios';
import { useState, useEffect, useCallback } from 'react';

export interface UserProfile {
  id: string;
  name: string;
  email: string;
  avatar: string;
  level: number;
  joinDate: string;
  stats: {
    wins: number;
    losses: number;
    rank: string;
  };
}

export interface ProfileUpdateData {
  name?: string;
  avatar?: string;
}

export const useUserProfile = (userId?: string) => {
  // 현재 로그인한 사용자 프로필이면 userId를 생략 가능
  const isCurrentUser = !userId;
  const endpoint = isCurrentUser ? '/api/users/me' : `/api/users/${userId}`;
  
  // GET 요청에 대한 Axios 훅
  const { data, loading, error, execute: fetchProfile } = useAxios<UserProfile>(endpoint);
  
  // PATCH 요청에 대한 Axios 훅 (프로필 업데이트)
  const updateEndpoint = isCurrentUser ? '/api/users/me' : `/api/users/${userId}`;
  const { loading: updating, error: updateError, execute: executeUpdate } = 
    useAxios<UserProfile, ProfileUpdateData>(updateEndpoint, "PATCH");
  
  // 컴포넌트 마운트 시 프로필 로드
  useEffect(() => {
    fetchProfile();
  }, [fetchProfile]);
  
  // 프로필 업데이트 함수
  const updateProfile = useCallback(async (updateData: ProfileUpdateData) => {
    if (!isCurrentUser) {
      return { success: false, error: '다른 사용자의 프로필은 수정할 수 없습니다.' };
    }
    
    return await executeUpdate(updateData, {});
  }, [isCurrentUser, executeUpdate]);
  
  return {
    profile: data,
    isLoading: loading,
    error,
    isUpdating: updating,
    updateError,
    updateProfile,
    refreshProfile: fetchProfile
  };
};

계층화된 훅 사용하는 컴포넌트 예시

typescript
// ChatRoomScreen.tsx
import React, { useState } from 'react';
import { useRoom } from '../hooks/useRoom';
import { useChat } from '../hooks/useChat';
import { UserList } from './UserList';
import { ChatBox } from './ChatBox';
import { GameControls } from './GameControls';

const ChatRoomScreen: React.FC<{ roomId: string }> = ({ roomId }) => {
  // 도메인 훅 사용
  const { 
    roomInfo, 
    isJoined, 
    joinRoom, 
    leaveRoom, 
    toggleReady, 
    startGame 
  } = useRoom(roomId);
  
  const { messages, sendMessage } = useChat(roomId);
  
  // UI 상태만 관리
  const [chatInput, setChatInput] = useState('');
  
  const handleSendMessage = () => {
    if (chatInput.trim()) {
      sendMessage(chatInput);
      setChatInput('');
    }
  };
  
  if (!roomInfo) {
    return <div>방 정보를 불러오는 중...</div>;
  }
  
  return (
    <div className="chat-room">
      <h1>{roomInfo.name}</h1>
      
      {/* 방 제어 버튼 */}
      <div className="room-controls">
        {!isJoined ? (
          <button onClick={joinRoom}>입장하기</button>
        ) : (
          <>
            <button onClick={toggleReady}>준비 상태 변경</button>
            <button onClick={leaveRoom}>나가기</button>
            {roomInfo.owner === 'current-user-id' && (
              <button 
                onClick={startGame}
                disabled={!roomInfo.users.every(user => user.isReady)}
              >
                게임 시작
              </button>
            )}
          </>
        )}
      </div>
      
      {/* 사용자 목록 컴포넌트 */}
      <UserList users={roomInfo.users} currentUserId="current-user-id" />
      
      {/* 채팅창 컴포넌트 */}
      <ChatBox 
        messages={messages}
        inputValue={chatInput}
        onInputChange={(e) => setChatInput(e.target.value)}
        onSend={handleSendMessage}
      />
      
      {/* 게임 시작 시 표시될 게임 컨트롤 */}
      {roomInfo.status === 'playing' && (
        <GameControls roomId={roomId} />
      )}
    </div>
  );
};

정리


주의 사항

  1. 명확한 책임 분리: 각 도메인 훅은 하나의 명확한 책임을 가져야 함
  2. 내부 로직 캡슐화 : 복잡한 비즈니스 로직은 훅 내부에 캡슐화하고, 컴포넌트에는 필요한 값과 간단한 함수만 노출
  3. 일관된 인터페이스 : 유사한 도메인 훅들은 일관된 인터페이스를 유지하여 사용자가 쉽게 이해할 수 있게 해야함
  4. 에러 처리 내장 : 도메인 훅 내에서 에러 상태를 관리하고 적절한 에러 처리 로직을 포함해야 함
  5. 의존성 최소화 : 도메인 훅 간의 의존성을 최소화 하고, 필요한 경우 컴포지션 패턴을 사용
  6. 상태 관리 최적화 : 필요한 상태만 정의하고, 불필요한 리렌더링을 방지하기 위해 메모이제이션 기법을 적용함

→ 이런 계층적 접근 방식은 애플리케이션의 복잡성이 증가할 수록 더 큰 이점을 발휘, 팀 단위 개발 환경에서 코드베이스의 일관성과 유지보수성을 향상시킬 수있음

태그

#React