오늘도 해가 지고 들어가는구나...

낚시하는 곰·2025년 6월 29일
1

jungle TIL

목록 보기
20/20

project 시작 전 전체 폴더 구조 설계

아래 코드들은 폴더 구조가 필요한 이유를 좀 더 구체적으로 설명하기 위한 예시 코드입니다.

우선순위

  • utils folder를 따로 생성하는 이유가 뭐임? 해당 folder에 어떤 file을 담을건데?
  • hooks 자주 쓰는 동작을 커스텀 해야 되는 이유가 뭐임?
  • auth 폴더를 생성하면 되는데 인증관련 로직을 clerk 라이브러리를 사용해서 인증할꺼거든. 이러면 auth 폴더를 따로 관리할 필요가 있을까?
  • app하위의 folder 구조를 어떻게 설계해야 할 지 고민

fetch api로 데이터 전송 어떻게 하지?

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도 하드코딩해서 박아놨기 때문에 유지보수하는 데 좋지 않은 구조라고 한다.

Next.js action

서버(user server)에서 직접 실행된다.

form을 서버 함수에서 바로 제출하는 용도로 사용됨.

단점으로는 코드가 간단한 거에 비해 제한되는 부분이 많다.

채팅처럼 즉각적인 전송/반응이 중요한 UI는 TipTap + zustand + fetch 조합이 유리하다.

app/api/message/route.ts는 서버에서 메시지를 받아서 DB에 저장해주는 백엔드의 역할을 하는 것이다.

constants 폴더를 생성해야 되는 이유

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 폴더를 생성해야 되는 이유

// 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 폴더를 생성해야 되는 이유

// 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;
}**

접근 권한 확인

hooks 폴더를 생성해야 되는 이유

# 백엔드 (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으로 만들어서 사용하는 것이 좋아보인다.

대규모 프로젝트도 아니고 tailwind를 사용하기 때문에 styles는 필요없음

최종 폴더 구조

app

  • page.tsx
  • layout.tsx (현재 로그인 기능이 해당 file에 있는데 page에서 관리해야 한다.)
  • global.css
  • workspaceId
    • layout.tsx(최상단바, 사이드바, 탭, 프로필)
    • @sidebar
      • page.tsx
    • @profile
      • page.tsx
      • [tabId]
        • layout.tsx(tab 헤더, message, chatting text)
        • @tabHeader
          • page.tsx
        • @messageArea
          • page.tsx
        • @chatArea
          • page.tsx
    • admin
      • 회원
        • page.tsx
      • 그룹
        • page.tsx
      • 역할관리
        • page.tsx

hooks

  • useWebSocket.ts
  • useMobile.ts

auth

(실제 회원가입 & 로그인 기능을 추가한다면)

  • login.ts
  • logout.ts
  • vlidation.ts(email, password check)

(구글 소셜 로그인을 사용한다면)

  • providers.ts(구글 소셜 로그인을 사용한다면)

(공통)

  • storage.ts(로그인에 필요한 토큰을 여기서 관리한다고 함)
  • guards.ts(실제로 워크스페이스 접근 권한을 확인한다고 함)

component

  • chat-text-area
    • tiptap.tsx
    • toolbar.tsx
    • styles.scss
  • tiptap-icons
    • icons~
  • ui
    • shardcn ui~

utils

  • websocket.ts
  • message.ts

public

  • 각종 image~

store

  • channelStore.ts
profile
취업 준비생 낚곰입니다!! 반갑습니다!!

0개의 댓글