커스텀 훅 가이드라인
1. Custom Hooks
-
개발자가 정의한 훅을 의미함
이를 이용해서 반복적인 로직을 함수로 뽑아서 사용할 수 있다.
💡 1. 상태 관리 로직의 재활용 가능
-
클래스 컴포넌트보다 적은 양의 코드로 동일한 로직 구현 가능
-
함수형으로 작성하기 때문에 단순 명료함 (e.g.
useAxios
)
-
예시
- React 공식 문서에 있는 컴포넌트
javascriptfunction 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 이다.
javascriptfunction 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은 가능하다.
-
적용 방법
javascriptfunction 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
typescriptinterface 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 };
};
- 사용법
typescriptconst { data, loading, error, excute, setData } = useAxios<ResponseType, PayloadType>(
"api/endpoint",
"GET"
);
-
파라미터
endpoint
: API 엔드 포인트 URLmethod
: HTTP 메소드(deafult : “GET”)
-
반환값
data
: API 응답 데이터loading
: 요청 진행 상태 (boolean)error
: 에러 메시지 (string 또는 null)excute
: 요청을 실행하는 함수setData
: 데이터를 설정하는 함수
-
사용 예제
-
GET 요청
typescriptinterface 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 요청
typescriptinterface 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 함수
typescriptimport { 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, // 메시지 타입 정의 객체 }; };
- 상태 관리
-
주요 기능
- WebSocket 연결 관리
- 컴포넌트 마운트 시 자동으로 WebSocket 연결 생성
- 컴포넌트 언마운트 시 연결 해제
- 연결 상태 관리 및 제공
- 토픽 구독 관리
- 특정 토픽에 대한 구독 기능
- 구독한 토픽으로부터 메시지 수신 시 콜백 함수 실행
- 구독 해제 기능
- 메시지 전송
- 지정된 대상으로 메시지 전송
- 추가 헤더 지원
기본 상수 정의
SOCKET_TOPICS
토픽 경로를 동적으로 생성하는 함수 제공
typescriptconst SOCKET_TOPICS = { ROOM: (roomId: string) => `/topic/room/${roomId}`, CHAT: (roomId: string) => `/topic/chat/${roomId}`, GAME: (roomId: string) => `/topic/game/${roomId}`, };
MESSAGE_TYPES
각 도메인에서 사용되는 메시지 타입을 정의
typescriptconst MESSAGE_TYPES = { ROOM: { KICK: "KICK", JOIN: "JOIN", // ... }, GAME: { STATUS: { ATTACK: "ATTACK", // ... }, // ... }, CHAT: { SEND: "SEND", RECEIVE: "RECEIVE", }, };
사용 예제
- 기본 설정 및 구독
typescriptimport { 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> ); };
- 게임 상태 관리
typescriptimport { 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> ); };
- WebSocket 연결 관리
3. 심화 : 컴포넌트를 더욱 멍청하게
이 Custom Hook 을 활용해서 바로 컴포넌트에 적용 시키는 것도 물론 좋지만, 지금 구현한 훅의 경우 기본 통신 훅 (인프라 계층 훅) 인데, 바로 컴포넌트에 적용하게 되면 컴포넌트 하나에 의존하게 되는 로직이 여러 개가 또 생길 수 있는 문제가 발생한다. 이는 컴포넌트가 통신 로직에 과도하게 의존하게 되고, 내부에 많은 로직이 혼합된다.
→ 이를 해결하기 위해 중간 계층의 훅(도메인 훅)을 추가하는 방안 생각
도메인 계층 훅의 필요성
인프라 계층 훅(useAxios
, useWebSocket
)과 UI 컴포넌트 사이에 도메인 계층 훅 도입 시 장점
- 비즈니스 로직 캡슐화 : 특정 도메인에 관련된 모든 로직을 한 곳에 모아 관리할 수 있음
- 상태 관리 중앙화: 도메인 관련 상태를 중앙에서 관리하여 일관성을 유지함
- 컴포넌트 재사용성 증가: UI 컴포넌트는 로직에 의존하지 않아 다양한 상황에서 재사용 가능
- 테스트 용이성 : 비즈니스 로직을 UI 와 분리하여 단위 테스트를 더 쉽게 작성 가능
도메인 계층 훅 구현 예시
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
};
};
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
};
};
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>
);
};
정리
주의 사항
- 명확한 책임 분리: 각 도메인 훅은 하나의 명확한 책임을 가져야 함
- 내부 로직 캡슐화 : 복잡한 비즈니스 로직은 훅 내부에 캡슐화하고, 컴포넌트에는 필요한 값과 간단한 함수만 노출
- 일관된 인터페이스 : 유사한 도메인 훅들은 일관된 인터페이스를 유지하여 사용자가 쉽게 이해할 수 있게 해야함
- 에러 처리 내장 : 도메인 훅 내에서 에러 상태를 관리하고 적절한 에러 처리 로직을 포함해야 함
- 의존성 최소화 : 도메인 훅 간의 의존성을 최소화 하고, 필요한 경우 컴포지션 패턴을 사용
- 상태 관리 최적화 : 필요한 상태만 정의하고, 불필요한 리렌더링을 방지하기 위해 메모이제이션 기법을 적용함
→ 이런 계층적 접근 방식은 애플리케이션의 복잡성이 증가할 수록 더 큰 이점을 발휘, 팀 단위 개발 환경에서 코드베이스의 일관성과 유지보수성을 향상시킬 수있음