아래 코드들은 폴더 구조가 필요한 이유를 좀 더 구체적으로 설명하기 위한 예시 코드입니다.
src/
├── components/
│ └── chatTextArea/
│ └── tiptap.tsx ← UI 컴포넌트
├── api/
│ └── message.ts ← fetch wrapper (백엔드 호출)
이라고 가정을 했을 때 클라이언트에서 fetch로 api쪽에 데이터를 보내기 위해서 src/api/message.ts에서
// src/api/message.ts
export async function sendMessage({ content, workspaceId, channelId }: {
content: string;
workspaceId: string;
channelId: string;
}) {
const res = await fetch(`https://your-backend.com/workspaces/${workspaceId}/channels/${channelId}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("메시지 전송 실패");
return await res.json();
}
위와 같이 보내주는 것임.
아래를 보면
// src/components/chatTextArea/tiptap.tsx
import { sendMessage } from '@/api/message';
import { useParams } from 'next/navigation';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { useState } from 'react';
export default function TipTap() {
const [content, setContent] = useState('');
const [isSending, setIsSending] = useState(false);
const params = useParams();
const editor = useEditor({
extensions: [StarterKit],
content: '',
onUpdate: ({ editor }) => {
setContent(editor.getText());
},
});
const handleSubmit = async () => {
if (!content.trim()) return;
try {
setIsSending(true);
await sendMessage({
content: content.trim(),
workspaceId: params.workspaceId as string,
channelId: params.channelId as string,
});
editor?.commands.clearContent();
} catch (err) {
console.error('메시지 전송 실패:', err);
} finally {
setIsSending(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div>
<EditorContent editor={editor} onKeyDown={handleKeyDown} />
<button onClick={handleSubmit} disabled={isSending}>Send</button>
</div>
);
}
먼저 api/message.ts를 import 해온다.
만약 전송 button을 click을 하면 setContent()를 가져와서 content를 sendMessage()의 props로 전달함. sendMessage()에서 전달받은 content를 백으로 전송하는 구조.
위에서 말하는 const params = useParams();는 동적 라우팅 폴더를 만들었을 때 해당 사용자가 접근하는 주소에 따라서( 만약 /post/123이라면 ) 123을 id: 123이라는 객체로 반환한다.
const [feeds, setFeeds] = useState([]);
useEffect(() => {
fetch("http://localhost:4000/feed")
.then((res) => res.json())
.then((data) => setFeeds(data));
}, []);
이렇게 기능 구현한 file에서 fetch로 데이터를 보낸다면 재사용이 불가능하고 api도 하드코딩해서 박아놨기 때문에 유지보수하는 데 좋지 않은 구조라고 한다.
서버(user server)에서 직접 실행된다.
form을 서버 함수에서 바로 제출하는 용도로 사용됨.
단점으로는 코드가 간단한 거에 비해 제한되는 부분이 많다.
채팅처럼 즉각적인 전송/반응이 중요한 UI는 TipTap + zustand + fetch 조합이 유리하다.
app/api/message/route.ts는 서버에서 메시지를 받아서 DB에 저장해주는 백엔드의 역할을 하는 것이다.
export const API_ENDPOINTS = {
MESSAGES: '/api/messages',
CHANNELS: '/api/channels',
USERS: '/api/users',
WORKSPACES: '/api/workspaces',
} as const;
api endpoint를 관리할 수 있음.
import { API_ENDPOINTS } from '@/utils/endpoints';
export async function sendMessage(data: { content: string }) {
const res = await fetch(API_ENDPOINTS.MESSAGES, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
});
return res.json();
}
이런 식으로 import해서 사용하면 messages의 api주소라는 것을 한 눈에 알 수 있고, 수정하기도 쉽다.
// constants/websocket.ts
export const WEBSOCKET_URL = process.env.NODE_ENV === 'production'
? 'wss://api.your-domain.com/ws'
: 'ws://localhost:8000/ws';
export const WEBSOCKET_EVENTS = {
// 메시지 관련
MESSAGE_SENT: 'message_sent',
MESSAGE_RECEIVED: 'message_received',
MESSAGE_UPDATED: 'message_updated',
MESSAGE_DELETED: 'message_deleted',
// 사용자 관련
USER_JOINED: 'user_joined',
USER_LEFT: 'user_left',
USER_TYPING: 'user_typing',
USER_STOPPED_TYPING: 'user_stopped_typing',
// 채널 관련
CHANNEL_CREATED: 'channel_created',
CHANNEL_UPDATED: 'channel_updated',
CHANNEL_DELETED: 'channel_deleted',
// 연결 관련
CONNECT: 'connect',
DISCONNECT: 'disconnect',
RECONNECT: 'reconnect',
ERROR: 'error',
} as const;
export const WEBSOCKET_RECONNECT = {
MAX_RETRIES: 5,
INITIAL_DELAY: 1000, // 1초
MAX_DELAY: 30000, // 30초
MULTIPLIER: 2, // 지수 백오프
} as const;
위 코드는 정확히 모른다. 하지만 우리는 websocket을 사용해야 한다. 왜냐하면 실시간 통신을 할꺼니까. 채팅을 사용자가 친다면 실시간으로 화면에 띄워줘야 하니까 websocket을 사용해야 한다. 그러면 위 file은 왜 만드는 걸까?
일단 이렇게 해서 WebSocket을 사용한다면 위와 같이 상수값을 따로 빼서 관리할 경우 재사용
// utils/message.ts
export interface Message {
id: string;
content: string;
userId: string;
channelId: string;
createdAt: string;
updatedAt?: string;
type: 'text' | 'image' | 'file';
}
export function groupMessagesByDate(messages: Message[]): Record<string, Message[]> {
return messages.reduce((groups, message) => {
const date = new Date(message.createdAt).toDateString();
if (!groups[date]) {
groups[date] = [];
}
groups[date].push(message);
return groups;
}, {} as Record<string, Message[]>);
}
export function isConsecutiveMessage(
current: Message,
previous: Message | undefined,
timeThreshold = 5 * 60 * 1000 // 5분
): boolean {
if (!previous) return false;
const isSameUser = current.userId === previous.userId;
const timeDiff = new Date(current.createdAt).getTime() - new Date(previous.createdAt).getTime();
return isSameUser && timeDiff < timeThreshold;
}
message관리. 위는 어디까지나 예시 코드입니다.
message를 보낼 때 보내는 사람의 sendId와 createAt 같은 정보가 필요하다.
위 정보는 백엔드에서 만들어진 메시지를 클라이언트가 가져와서 화면에 던지기 위해서 사용함.
만약 시간을 띄운다거나 날짜 같은 걸 메시지 창에 띄우지 않는다면 utils 구조는 딱히 필요가 없어 보임.
// utils/url.ts
export function buildChannelUrl(workspaceId: string, channelId: string): string {
return `/client/${workspaceId}/channel/${channelId}`;
}
export function buildDMUrl(workspaceId: string, userId: string): string {
return `/client/${workspaceId}/dm/${userId}`;
}
export function buildProfileUrl(workspaceId: string, userId: string): string {
return `/client/${workspaceId}/profile/${userId}`;
}
export function parseChannelUrl(url: string): { workspaceId?: string; channelId?: string } {
const match = url.match(/\/client\/([^\/]+)\/channel\/([^\/]+)/);
if (match) {
return { workspaceId: match[1], channelId: match[2] };
}
return {};
}
client는 수정할 것임. Slack-LMS처럼 채널, DM, 프로필 등 다양한 URL 패턴이 있는 경우 URL 빌더 함수가 필요하다.
// 하드코딩 ❌
const url = `/workspace/${workspaceId}/channel/${channelId}?messageId=${messageId}`;
// url builder 사용 ✅
const url = buildChannelUrl(workspaceId, channelId, { messageId });
사용은 url을 하드코딩하지 않고 채널 url을 해당 file을 import해서 사용할 수 있음.
url builder를 사용한다고 해서 url이 폴더 구조인 next에서 url을 변경할 수 있는 것이 아니라 폴더 구조의 변경이 필요할 때 url을 하드코딩 했다면 이것을 수동으로 일일이 변경해줘야 하는 문제가 생긴다.
// auth/storage.ts
import { AuthTokens, User } from './types';
export const tokenStorage = {
// 토큰 저장
setTokens: (tokens: AuthTokens): void => {
localStorage.setItem('accessToken', tokens.accessToken);
localStorage.setItem('refreshToken', tokens.refreshToken);
localStorage.setItem('tokenExpiresAt',
(Date.now() + tokens.expiresIn * 1000).toString()
);
},
// 액세스 토큰 조회
getAccessToken: (): string | null => {
return localStorage.getItem('accessToken');
},
// 리프레시 토큰 조회
getRefreshToken: (): string | null => {
return localStorage.getItem('refreshToken');
},
// 토큰 만료 확인
isTokenExpired: (): boolean => {
const expiresAt = localStorage.getItem('tokenExpiresAt');
if (!expiresAt) return true;
return Date.now() > parseInt(expiresAt);
},
// 토큰 삭제
clearTokens: (): void => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('tokenExpiresAt');
},
// 사용자 정보 저장
setUser: (user: User): void => {
localStorage.setItem('user', JSON.stringify(user));
},
// 사용자 정보 조회
getUser: (): User | null => {
const userStr = localStorage.getItem('user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
},
// 사용자 정보 삭제
clearUser: (): void => {
localStorage.removeItem('user');
},
// 모든 인증 데이터 삭제
clearAll: (): void => {
tokenStorage.clearTokens();
tokenStorage.clearUser();
}
};
export const sessionStorage = {
// 임시 데이터 저장 (비밀번호 재설정 등)
setTemporaryData: <T>(key: string, data: T): void => {
sessionStorage.setItem(key, JSON.stringify(data));
},
getTemporaryData: <T>(key: string): T | null => {
const item = sessionStorage.getItem(key);
if (!item) return null;
try {
return JSON.parse(item);
} catch {
return null;
}
},
removeTemporaryData: (key: string): void => {
sessionStorage.removeItem(key);
}
};
storage.ts에서 토큰을 관리한다고 함
// auth/providers.ts (OAuth)
export const oauthProviders = {
// 구글 OAuth
google: {
getAuthUrl: (): string => {
const params = new URLSearchParams({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
redirect_uri: `${window.location.origin}/auth/callback/google`,
response_type: 'code',
scope: 'openid email profile',
access_type: 'offline',
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
},
handleCallback: async (code: string): Promise<AuthResponse> => {
const response = await fetch('/api/auth/google/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
return response.json();
}
},
// 깃허브 OAuth
github: {
getAuthUrl: (): string => {
const params = new URLSearchParams({
client_id: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID!,
redirect_uri: `${window.location.origin}/auth/callback/github`,
scope: 'user:email',
});
return `https://github.com/login/oauth/authorize?${params}`;
},
handleCallback: async (code: string): Promise<AuthResponse> => {
const response = await fetch('/api/auth/github/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
return response.json();
}
}
};
구글 소셜 로그인을 사용한다면
// auth/guards.ts
import { redirect } from 'next/navigation';
import { tokenStorage } from './storage';
import { authApi } from './api';
// 서버 컴포넌트용 인증 가드
export async function requireAuth(): Promise<User> {
const token = tokenStorage.getAccessToken();
if (!token || tokenStorage.isTokenExpired()) {
redirect('/auth/login');
}
try {
const user = await authApi.getCurrentUser();
return user;
} catch (error) {
tokenStorage.clearAll();
redirect('/auth/login');
}
}
// 워크스페이스 접근 권한 확인
export async function requireWorkspaceAccess(workspaceId: string): Promise<User> {
const user = await requireAuth();
if (!user.workspaces.includes(workspaceId)) {
redirect('/workspaces');
}
return user;
}
**// 관리자 권한 확인
export async function requireAdmin(): Promise<User> {
const user = await requireAuth();
if (user.role !== 'admin') {
redirect('/unauthorized');
}
return user;
}**
접근 권한 확인
# 백엔드 (FastAPI) - 이미 구현되어 있음
@router.websocket("/{client_id}")
async def websocket_endpoint(websocket: WebSocket, channel_id: str, client_id: int):
await connection.connect(channel_id, websocket) # 연결 관리
try:
while True:
data = await websocket.receive_text() # 메시지 받기
await connection.broadcast(channel_id, f"Client #{client_id}: {data}") # 브로드캐스트
except WebSocketDisconnect:
connection.disconnect(channel_id, websocket) # 연결 해제
// 프론트엔드 - 이건 누가 처리해야 할까?
const ws = new WebSocket('ws://localhost:8000/ws/123?channel_id=general');
ws.onopen = () => console.log('연결됨');
ws.onmessage = (event) => {
const message = event.data;
// 받은 메시지를 화면에 어떻게 표시할까?
// 어떤 컴포넌트에서 처리할까?
};
ws.onclose = () => console.log('연결 종료');
ws.onerror = () => console.log('에러 발생');
// 메시지 보내기
ws.send('Hello World');
백에서 전달받은 데이터를 화면에다 실시간으로 뿌려주기 위해
// ❌ 매번 컴포넌트마다 WebSocket 코드 반복
// components/ChatRoom.tsx
function ChatRoom() {
const [messages, setMessages] = useState([]);
const [ws, setWs] = useState(null);
useEffect(() => {
const websocket = new WebSocket('ws://localhost:8000/ws/123?channel_id=general');
websocket.onopen = () => console.log('연결됨');
websocket.onmessage = (event) => {
setMessages(prev => [...prev, event.data]); // 메시지 추가
};
websocket.onclose = () => console.log('연결 종료');
websocket.onerror = () => console.log('에러');
setWs(websocket);
return () => websocket.close();
}, []);
const sendMessage = (message) => {
if (ws) ws.send(message);
};
return <div>채팅 UI</div>;
}
// components/UserList.tsx
function UserList() {
const [users, setUsers] = useState([]);
const [ws, setWs] = useState(null);
useEffect(() => {
// 똑같은 WebSocket 코드 또 반복! 😱
const websocket = new WebSocket('ws://localhost:8000/ws/123?channel_id=general');
websocket.onopen = () => console.log('연결됨');
websocket.onmessage = (event) => {
// 사용자 목록 업데이트 로직
};
// ... 반복
}, []);
}
실시간 통신 같은 경우 재사용성을 높이기 위해서 hook으로 만들어서 사용하는 것이 좋아보인다.
app
hooks
auth
(실제 회원가입 & 로그인 기능을 추가한다면)
(구글 소셜 로그인을 사용한다면)
(공통)
component
utils
public
store