๐ŸŸข ์˜จํ•(ON-FIT) โ€“ ์ฑ„ํŒ…๋ฐฉ ๊ธฐ๋Šฅ ๊ตฌํ˜„ ์ „ ๊ณผ์ • ์ •๋ฆฌ (Supabase + Next.js App Router)

์กฐ์ค€ํ˜•ยท2025๋…„ 11์›” 17์ผ

์˜จํ•

๋ชฉ๋ก ๋ณด๊ธฐ
7/16

์ด ๊ธ€์€ ์˜จํ•(On-Fit) ํ”„๋กœ์ ํŠธ์˜ ์ฑ„ํŒ… ๊ธฐ๋Šฅ์„ ์‹ค์ œ๋กœ ๊ตฌํ˜„ํ•œ ๊ณผ์ • ์ „์ฒด๋ฅผ ์ •๋ฆฌํ•œ ๊ฒƒ์ด๋‹ค.

๋‹จ์ˆœํžˆ ์ฝ”๋“œ๋งŒ ๋‚˜์—ดํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ,

DB ์Šคํ‚ค๋งˆ โ†’ Rooms ์ƒ์„ฑ ๋กœ์ง โ†’ Participants ๊ด€๋ฆฌ โ†’ ์ฑ„ํŒ…๋ฐฉ ๋ฆฌ์ŠคํŠธ API ๊ตฌ์„ฑ โ†’ ํ™”๋ฉด ๋ Œ๋”๋ง โ†’ ๋ผ์šฐํŒ… ๋ฌธ์ œ ํ•ด๊ฒฐ ๊ณผ์ •๊นŒ์ง€

์‹ค์ œ ๊ตฌํ˜„ ํ๋ฆ„์„ ์ƒ์„ธํžˆ ๊ธฐ๋กํ•œ๋‹ค.


๐Ÿ“Œ 1. ์ฑ„ํŒ… ๊ธฐ๋Šฅ์˜ ๊ธฐ๋ณธ ๊ตฌ์กฐ

์˜จํ•์—์„œ ์ฑ„ํŒ…์€ ๊ฒŒ์‹œ๊ธ€(Post) ์„ ์ค‘์‹ฌ์œผ๋กœ ์ด๋ฃจ์–ด์ง„๋‹ค.

  • ์‚ฌ์šฉ์ž๊ฐ€ ์šด๋™ ๋ฒˆ๊ฐœ ๊ธ€(Post)์„ ์˜ฌ๋ฆฌ๋ฉด
  • ํ•ด๋‹น ๊ฒŒ์‹œ๊ธ€์— ๋Œ€ํ•ด ํ•˜๋‚˜์˜ ์ฑ„ํŒ…๋ฐฉ(Room) ์„ ๋งŒ๋“ ๋‹ค
  • ์ฑ„ํŒ…๋ฐฉ์—๋Š” ์ฐธ์—ฌ์ž(participants) ๊ฐ€ ๋“ฑ๋ก๋œ๋‹ค
  • ๋ฉ”์‹œ์ง€(messages)๊ฐ€ ์Œ“์ด๊ณ 
  • ์‚ฌ์šฉ์ž๋Š” ๋‚ด๊ฐ€ ์ฐธ์—ฌํ•œ ์ฑ„ํŒ…๋ฐฉ๋งŒ ๋ฆฌ์ŠคํŠธ์—์„œ ํ™•์ธํ•œ๋‹ค

์ฆ‰ ๊ตฌ์กฐ๋Š” ์ด๋ ‡๊ฒŒ ๋œ๋‹ค:

posts ---1:1---> rooms ---1:N---> participants ---1:N---> messages

๐Ÿ“Œ 2. Supabase ํ…Œ์ด๋ธ” ์„ค๊ณ„

posts

์ปฌ๋Ÿผ์„ค๋ช…
id (PK)๊ฒŒ์‹œ๊ธ€ ID
title์ œ๋ชฉ
sport์ข…๋ชฉ
location์œ„์น˜
date_time๋‚ ์งœ/์‹œ๊ฐ„
level๋‚œ์ด๋„
author_id์ž‘์„ฑ์ž
room_idํ•ด๋‹น ๊ฒŒ์‹œ๊ธ€๊ณผ ์—ฐ๊ฒฐ๋œ ์ฑ„ํŒ…๋ฐฉ ID

rooms

์ปฌ๋Ÿผ์„ค๋ช…
id (PK)
name์ฑ„ํŒ…๋ฐฉ ์ด๋ฆ„ (๋ณดํ†ต ๊ฒŒ์‹œ๊ธ€ ์ œ๋ชฉ)
post_id์–ด๋–ค ๊ฒŒ์‹œ๊ธ€์˜ ์ฑ„ํŒ…๋ฐฉ์ธ์ง€
host_id๋ฐฉ์žฅ
created_at์ƒ์„ฑ ์‹œ๊ฐ

participants

์ปฌ๋Ÿผ์„ค๋ช…
room_id์–ด๋–ค ๋ฐฉ์ธ์ง€
user_id์œ ์ €๊ฐ€ ๋ˆ„๊ตฌ์ธ์ง€
rolehost / member
joined_at์ฐธ์—ฌ ์‹œ๊ฐ

messages

์ปฌ๋Ÿผ์„ค๋ช…
room_id์–ด๋–ค ๋ฐฉ์ธ์ง€
sender_id๋ณด๋‚ธ ์‚ฌ๋žŒ
contentํ…์ŠคํŠธ
created_at์‹œ๊ฐ„

๐Ÿ“Œ 3. ๊ฒŒ์‹œ๊ธ€ โ†’ ์ฑ„ํŒ…๋ฐฉ ์ƒ์„ฑ API ๋งŒ๋“ค๊ธฐ

๊ฒŒ์‹œ๊ธ€์ด ์ƒ์„ฑ๋˜๋ฉด ๋™์‹œ์— ์ฑ„ํŒ…๋ฐฉ๋„ ์ƒ์„ฑํ•˜๊ณ  ์‹ถ๊ธฐ ๋•Œ๋ฌธ์—

์•„๋ž˜์™€ ๊ฐ™์€ API๋ฅผ ๋งŒ๋“ค์—ˆ๋‹ค.

/api/chat/route.ts

import { createSupabaseServerClient } from "@/lib/route-helpers";
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  try {
    const { postId } = await req.json();

    if (!postId) {
      return NextResponse.json({ error: "Missing postId" }, { status: 400 });
    }

    const supabase = await createSupabaseServerClient();

    // 1) ๊ฒŒ์‹œ๊ธ€ ์ •๋ณด ์กฐํšŒ
    const { data: post } = await supabase
      .from("posts")
      .select("title, author_id")
      .eq("id", postId)
      .single();

    // 2) ์ฑ„ํŒ…๋ฐฉ ์ƒ์„ฑ
    const { data: room } = await supabase
      .from("rooms")
      .insert({
        name: post.title,
        post_id: postId,
        host_id: post.author_id,
        created_at: new Date().toISOString(),
      })
      .select("id")
      .single();

    // 3) ํ˜ธ์ŠคํŠธ๋ฅผ participants ํ…Œ์ด๋ธ”์— ์ž๋™ ๋“ฑ๋ก
    await supabase.from("participants").insert({
      room_id: room.id,
      user_id: post.author_id,
      role: "host",
      joined_at: new Date().toISOString(),
    });

    // 4) posts์— room_id ์—…๋ฐ์ดํŠธ
    await supabase
      .from("posts")
      .update({ room_id: room.id })
      .eq("id", postId);

    return NextResponse.json({ roomId: room.id }, { status: 201 });
  } catch (err) {
    return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
  }
}

๐Ÿ“Œ 4. โ€œ๋‚ด๊ฐ€ ์ฐธ์—ฌํ•œ ์ฑ„ํŒ…๋ฐฉ ๋ฆฌ์ŠคํŠธโ€ API ๋งŒ๋“ค๊ธฐ

์ด API๋Š” ๊ฐ€์žฅ ์ค‘์š”ํ•œ ๋ถ€๋ถ„์ด๋‹ค.

ํ”„๋ก ํŠธ์˜ ์ฑ„ํŒ… ๋ฆฌ์ŠคํŠธ ํ™”๋ฉด์—์„œ ํ•„์š”ํ•œ ๋ชจ๋“  ์ •๋ณด๋ฅผ ์กฐํ•ฉํ•ด ๋ฐ˜ํ™˜ํ•œ๋‹ค.

  • participants์—์„œ ๋ฐฉ ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ
  • rooms ํ…Œ์ด๋ธ” ์—ฐ๊ฒฐ
  • posts ํ…Œ์ด๋ธ” ์—ฐ๊ฒฐํ•˜์—ฌ ์ œ๋ชฉ, ์Šคํฌ์ธ  ์ข…๋ฅ˜ ๊ฐ€์ ธ์˜ค๊ธฐ
  • messages ํ…Œ์ด๋ธ”์—์„œ ๊ฐ ๋ฐฉ์˜ โ€œ๋งˆ์ง€๋ง‰ ๋ฉ”์‹œ์ง€โ€๊นŒ์ง€ ๊ฐ€์ ธ์˜ค๊ธฐ

/api/chat/rooms GET

๐Ÿ‘‰ ์ด API๋Š” ๋งค์šฐ ๋ณต์žกํ•˜์ง€๋งŒ ์ฑ„ํŒ… ๋ฆฌ์ŠคํŠธ ํ™”๋ฉด์„ ์œ„ํ•ด ๋ฐ˜๋“œ์‹œ ํ•„์š”ํ•˜๋‹ค.

์ตœ์ข… ์™„์„ฑ๋ณธ์€ ์•„๋ž˜์™€ ๊ฐ™๋‹ค:

import {
  createSupabaseServerClient,
  fail,
  ok,
  requireUserOr401,
} from "@/lib/route-helpers";

export async function GET() {
  const supabase = await createSupabaseServerClient();

  // 1) ๋กœ๊ทธ์ธ ์œ ์ € ๊ฒ€์‚ฌ
  const { ok: hasUser, user, response } = await requireUserOr401(supabase);
  if (!hasUser || !user) return response;

  // 2) ๋‚ด๊ฐ€ ์ฐธ์—ฌํ•œ ๋ฐฉ(room_id) ๊ฐ€์ ธ์˜ค๊ธฐ
  const { data: myParticipants } = await supabase
    .from("participants")
    .select("room_id, role, joined_at")
    .eq("user_id", user.id);

  if (!myParticipants || myParticipants.length === 0)
    return ok({ items: [] });

  const roomIds = myParticipants.map((p) => p.room_id);

  // 3) rooms ํ…Œ์ด๋ธ”
  const { data: rooms } = await supabase
    .from("rooms")
    .select("id, name, post_id, created_at")
    .in("id", roomIds);

  // 4) posts ํ…Œ์ด๋ธ” ์—ฐ๊ฒฐ (sport, title)
  const postIds = rooms.map((r) => r.post_id);

  const { data: posts } = await supabase
    .from("posts")
    .select("id, title, sport, date_time")
    .in("id", postIds);

  const postsById: any = {};
  posts?.forEach((post) => (postsById[post.id] = post));

  // 5) ๋งˆ์ง€๋ง‰ ๋ฉ”์‹œ์ง€ ๊ฐ€์ ธ์˜ค๊ธฐ
  const { data: messages } = await supabase
    .from("messages")
    .select("room_id, content, created_at")
    .in("room_id", roomIds)
    .order("created_at", { ascending: false });

  const lastMessageByRoom: any = {};
  messages?.forEach((msg) => {
    if (!lastMessageByRoom[msg.room_id]) {
      lastMessageByRoom[msg.room_id] = msg;
    }
  });

  // 6) participants ๋ฐ์ดํ„ฐ๋กœ role, ์ฐธ์—ฌ์ž ์ˆ˜ ๊ณ„์‚ฐ
  const countByRoom: any = {};
  const roleByRoom: any = {};
  myParticipants.forEach((p) => {
    countByRoom[p.room_id] = (countByRoom[p.room_id] || 0) + 1;
    roleByRoom[p.room_id] = p.role;
  });

  // 7) ์ตœ์ข… ์‘๋‹ต ๋ณ€ํ™˜
  const items = rooms.map((room) => {
    const post = postsById[room.post_id];
    const lastMsg = lastMessageByRoom[room.id];

    return {
      roomId: room.id,
      title: post?.title ?? room.name,
      sport: post?.sport ?? null,
      tag: post?.sport ?? null,
      currentParticipants: countByRoom[room.id] ?? 1,
      lastMessage: lastMsg?.content ?? "",
      lastMessageTime: lastMsg?.created_at ?? room.created_at,
      role: roleByRoom[room.id],
    };
  });

  return ok({ items });
}

๐Ÿ“Œ 5. ์ฑ„ํŒ…๋ฐฉ ๋ฆฌ์ŠคํŠธ UI ์ƒ์„ฑ

/chat/list/page.tsx

import ChatRoomList from "../components/ChatRoomList";

export default function ChatListPage() {
  return <ChatRoomList />;
}

๋ฆฌ์ŠคํŠธ UI๋Š” ์ด๋ ‡๊ฒŒ ๊ตฌ์„ฑํ–ˆ๋‹ค:

ChatRoomList.tsx

'use client';

import { useEffect, useState } from 'react';
import { api } from '@/lib/axios';
import ChatRoomCard from './ChatRoomCard';

export default function ChatRoomList() {
  const [rooms, setRooms] = useState([]);

  useEffect(() => {
    api.get('/api/chat/rooms').then((res) => {
      setRooms(res.data.items);
    });
  }, []);

  return (
    <div>
      {rooms.map((room) => (
        <ChatRoomCardkey={room.roomId}
          roomId={room.roomId}
          title={room.title}
          member={room.currentParticipants}
          time={room.lastMessageTime}
          chatting={room.lastMessage}
          tag={room.tag}
        />
      ))}
    </div>
  );
}

๐Ÿ“Œ 6. ์ฑ„ํŒ… ์นด๋“œ ์ปดํฌ๋„ŒํŠธ & ํŽ˜์ด์ง€ ์ด๋™ ์ฒ˜๋ฆฌ

์ฒ˜์Œ์—๋Š” useRouter()๋ฅผ ์จ์„œ push ํ–ˆ๋Š”๋ฐ

Next.js App Router์—์„œ๋Š” ์ข…์ข…

NextRouter was not mounted

์—๋Ÿฌ๊ฐ€ ๋‚ฌ๋‹ค.

๊ทธ๋ž˜์„œ Link ๋ฐฉ์‹์œผ๋กœ ํ•ด๊ฒฐํ–ˆ๋‹ค.

ChatRoomCard.tsx

'use client'

import Link from "next/link"
import { Card, CardHeader } from "@/components/common/Card";
import StatusBadge from "@/components/main/StatusBadge";
import Image from "next/image";

export default function ChatRoomCard({
  roomId,
  title,
  member,
  time,
  chatting,
  tag
}) {
  return (
    <Link href={`/chat/${roomId}`} className="block">
      <Card className="m-4 relative cursor-pointer">
        <CardHeader className="flex-row gap-3 items-start">
          <Image src="/onfit.png" width={40} height={40} alt="profile" />

          <div className="gap-2">
            <div className="flex flex-row gap-2">
              <h3>{title}</h3>
              <StatusBadge>{member}</StatusBadge>
              <span className="text-xs absolute right-3">{time}</span>
            </div>

            <p className="text-xs mt-2 mb-2">{chatting}</p>
            <StatusBadge>{tag}</StatusBadge>
          </div>
        </CardHeader>
      </Card>
    </Link>
  );
}

๐Ÿ“Œ 7. /chat/[id] ์ƒ์„ธ ํŽ˜์ด์ง€

์—ฌ๊ธฐ์„œ๋Š”

  • ํ•ด๋‹น ๋ฐฉ์˜ ์ฐธ๊ฐ€์ž ๋ชฉ๋ก
  • ๊ฒŒ์‹œ๊ธ€ ์ •๋ณด
  • ๋ฉ”์‹œ์ง€ ๋ชฉ๋ก

์„ API ํ•˜๋‚˜๋กœ ๋ถˆ๋Ÿฌ์˜จ๋‹ค.

(์ถ”ํ›„ ๋ฉ”์‹œ์ง€ ์‹ค์‹œ๊ฐ„ ๊ธฐ๋Šฅ(Supabase Realtime)์„ ๋ถ™์ผ ์ˆ˜ ์žˆ๋‹ค.)


๐Ÿ“Œ 8. ๋ ˆ์ด์•„์›ƒ ๋ฌธ์ œ ํ•ด๊ฒฐ ๊ณผ์ • (/chat์—์„œ ํ—ค๋” ์•ˆ ๋‚˜์˜ด)

์ฒ˜์Œ ๊ตฌ์กฐ๋Š”:

app/(main)/layout.tsx โ†’ MainLayoutShell

ํ•˜์ง€๋งŒ MainLayoutShell ์•ˆ์—์„œ ์•„๋ž˜์ฒ˜๋Ÿผ ์กฐ๊ฑด์ด ๋“ค์–ด์žˆ์—ˆ๋‹ค:

pathname.startsWith('/chat') ? <></> : <Header/>

์ด ์กฐ๊ฑด ๋•Œ๋ฌธ์—

/chat, /chat/list, /chat/123 ๋ชจ๋‘ ํ—ค๋”๊ฐ€ ๋ณด์ด์ง€ ์•Š๋Š” ๋ฌธ์ œ ๋ฐœ์ƒ.

๋‚˜๋Š” ์›ํ•˜๋Š” ๋™์ž‘์„ ๋‹ค์‹œ ์ •๋ฆฌํ–ˆ๋‹ค:

๊ฒฝ๋กœํ—ค๋”BottomNav
/chatOO
/chat/...XX
๊ทธ ์™ธOO

๊ทธ๋ž˜์„œ ์ตœ์ข… MainLayoutShell์€ ์ด๋ ‡๊ฒŒ ์ˆ˜์ •ํ–ˆ๋‹ค:

const isChatRoot = pathname === "/chat";
const isChatOther = pathname.startsWith("/chat") && !isChatRoot;

๊ทธ ํ›„ ์กฐ๊ฑด ๋ถ„๊ธฐ ์ „์ฒด๋ฅผ ์ˆ˜์ •ํ•ด์„œ

์›ํ•˜๋Š” UI ๊ตฌ์กฐ๋ฅผ ์™„๋ฒฝํ•˜๊ฒŒ ํ•ด๊ฒฐํ–ˆ๋‹ค.


๐Ÿ“Œ 9. ์ฑ„ํŒ…๋ฐฉ sport ํ‘œ์‹œ ๋ฒ„๊ทธ ๋ฌธ์ œ ํ•ด๊ฒฐ

์ฒ˜์Œ์—๋Š” ์นด๋“œ ์•„๋ž˜์— sport ํƒœ๊ทธ๊ฐ€ ์•ˆ ๋‚˜์™”๋‹ค.

์ฝ˜์†”๋กœ ํ™•์ธํ•ด๋ณด๋‹ˆ:

sport: null
tag: null

์›์ธ์€ API์—์„œ posts ๋ฐ์ดํ„ฐ๋ฅผ select ํ•  ๋•Œ

์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ปฌ๋Ÿผ date, time ์„ select ํ•˜๊ณ  ์žˆ์–ด์„œ ์ฟผ๋ฆฌ๊ฐ€ ์‹คํŒจํ–ˆ๊ณ 

๊ฒฐ๊ณผ์ ์œผ๋กœ postsById๊ฐ€ ๋นˆ ๊ฐ์ฒด๊ฐ€ ๋˜์–ด sport๊ฐ€ null ๋กœ ๋–จ์–ด์ง„ ๊ฒƒ์ด์—ˆ๋‹ค.

ํ•ด๊ฒฐ: ์ปฌ๋Ÿผ๋ช…์„ date_time์œผ๋กœ ์ˆ˜์ •

.select("id, title, sport, location, date_time")

์ดํ›„ sport ์ •์ƒ ์ถœ๋ ฅ.


๐Ÿ“Œ 10. ์ •๋ฆฌ โ€“ ๋ฐฐ์šด ์ 

โœ” Supabase๋ฅผ ์“ธ ๋•Œ SELECT ์ปฌ๋Ÿผ๋ช…์€ ๋ฐ˜๋“œ์‹œ ์‹ค์ œ ์Šคํ‚ค๋งˆ์™€ ์ผ์น˜ํ•ด์•ผ ํ•œ๋‹ค

์˜คํƒ€๋‚˜ ์ž˜๋ชป๋œ ์ปฌ๋Ÿผ ๋•Œ๋ฌธ์— join ์ „์ฒด๊ฐ€ ์‹คํŒจํ•  ์ˆ˜ ์žˆ๋‹ค.

โœ” App Router์—์„œ๋Š” router.push๋ณด๋‹ค Link๊ฐ€ ๋” ์•ˆ์ •์ ์ด๋‹ค

ํŠนํžˆ layout ๊ธฐ์ค€์ด ๋ณต์žกํ•  ๋•Œ๋Š” Link๊ฐ€ ๊ฐ€์žฅ ๋ฏฟ์„ ์ˆ˜ ์žˆ๋‹ค.

โœ” layout์—์„œ pathname ์กฐ๊ฑด ๋ถ„๊ธฐ ์‹ค์ˆ˜ํ•˜๋ฉด ์ „์ฒด UI๊ฐ€ ์•„๋‹Œ ๊ณณ์—์„œ ์‚ฌ๋ผ์ง„๋‹ค

โœ” participants ํ…Œ์ด๋ธ”์€ ๋ชจ๋“  ์ฑ„ํŒ… ๋กœ์ง์˜ ํ•ต์‹ฌ์ด๋‹ค

๋‚ด๊ฐ€ ์ฐธ์—ฌํ•œ ๋ฐฉ์„ ํ•„ํ„ฐ๋งํ•˜๋Š” ๋ชจ๋“  ๊ธฐ๋ฐ˜.

โœ” API๋Š” ์ž‘์—… ํ๋ฆ„ ๋‹จ์œ„๋กœ ๋‚˜๋ˆ„๋Š” ๊ฒƒ์ด ์ข‹๋‹ค

  • ๊ฒŒ์‹œ๊ธ€ โ†’ ๋ฐฉ ์ƒ์„ฑ
  • ๋‚ด๊ฐ€ ์ฐธ์—ฌํ•œ ๋ฐฉ ๋ฆฌ์ŠคํŠธ
  • ๋ฐฉ ์ƒ์„ธ
  • ๋ฉ”์‹œ์ง€ ์ „์†ก ์ด๋ ‡๊ฒŒ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ์œ ์ง€๋ณด์ˆ˜์—๋„ ์ข‹๋‹ค.

โœจ ๋งˆ๋ฌด๋ฆฌ

์ด๋ฒˆ ์ฑ„ํŒ… ๊ธฐ๋Šฅ ๊ตฌํ˜„์€ ๊ฝค ๋งŽ์€ ๊ตฌ์กฐ๋ฅผ ๋‹ค๋ค˜์ง€๋งŒ

๊ฒฐ๊ณผ์ ์œผ๋กœ Supabase + Next.js App Router ์กฐํ•ฉ์œผ๋กœ

์ •๋ง ๊ฐ•๋ ฅํ•˜๊ณ  ๊น”๋”ํ•œ ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… ๊ธฐ๋ฐ˜ ๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ์—ˆ๋‹ค.

profile
์ฝ”๋ฆฐ์ด

0๊ฐœ์˜ ๋Œ“๊ธ€