์ด ๊ธ์ ์จํ(On-Fit) ํ๋ก์ ํธ์ ์ฑํ ๊ธฐ๋ฅ์ ์ค์ ๋ก ๊ตฌํํ ๊ณผ์ ์ ์ฒด๋ฅผ ์ ๋ฆฌํ ๊ฒ์ด๋ค.
๋จ์ํ ์ฝ๋๋ง ๋์ดํ๋ ๊ฒ์ด ์๋๋ผ,
DB ์คํค๋ง โ Rooms ์์ฑ ๋ก์ง โ Participants ๊ด๋ฆฌ โ ์ฑํ ๋ฐฉ ๋ฆฌ์คํธ API ๊ตฌ์ฑ โ ํ๋ฉด ๋ ๋๋ง โ ๋ผ์ฐํ ๋ฌธ์ ํด๊ฒฐ ๊ณผ์ ๊น์ง
์ค์ ๊ตฌํ ํ๋ฆ์ ์์ธํ ๊ธฐ๋กํ๋ค.
์จํ์์ ์ฑํ ์ ๊ฒ์๊ธ(Post) ์ ์ค์ฌ์ผ๋ก ์ด๋ฃจ์ด์ง๋ค.
์ฆ ๊ตฌ์กฐ๋ ์ด๋ ๊ฒ ๋๋ค:
posts ---1:1---> rooms ---1:N---> participants ---1:N---> messages
| ์ปฌ๋ผ | ์ค๋ช |
|---|---|
| id (PK) | ๊ฒ์๊ธ ID |
| title | ์ ๋ชฉ |
| sport | ์ข ๋ชฉ |
| location | ์์น |
| date_time | ๋ ์ง/์๊ฐ |
| level | ๋์ด๋ |
| author_id | ์์ฑ์ |
| room_id | ํด๋น ๊ฒ์๊ธ๊ณผ ์ฐ๊ฒฐ๋ ์ฑํ ๋ฐฉ ID |
| ์ปฌ๋ผ | ์ค๋ช |
|---|---|
| id (PK) | |
| name | ์ฑํ ๋ฐฉ ์ด๋ฆ (๋ณดํต ๊ฒ์๊ธ ์ ๋ชฉ) |
| post_id | ์ด๋ค ๊ฒ์๊ธ์ ์ฑํ ๋ฐฉ์ธ์ง |
| host_id | ๋ฐฉ์ฅ |
| created_at | ์์ฑ ์๊ฐ |
| ์ปฌ๋ผ | ์ค๋ช |
|---|---|
| room_id | ์ด๋ค ๋ฐฉ์ธ์ง |
| user_id | ์ ์ ๊ฐ ๋๊ตฌ์ธ์ง |
| role | host / member |
| joined_at | ์ฐธ์ฌ ์๊ฐ |
| ์ปฌ๋ผ | ์ค๋ช |
|---|---|
| room_id | ์ด๋ค ๋ฐฉ์ธ์ง |
| sender_id | ๋ณด๋ธ ์ฌ๋ |
| content | ํ ์คํธ |
| created_at | ์๊ฐ |
๊ฒ์๊ธ์ด ์์ฑ๋๋ฉด ๋์์ ์ฑํ ๋ฐฉ๋ ์์ฑํ๊ณ ์ถ๊ธฐ ๋๋ฌธ์
์๋์ ๊ฐ์ API๋ฅผ ๋ง๋ค์๋ค.
/api/chat/route.tsimport { 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 });
}
}
์ด API๋ ๊ฐ์ฅ ์ค์ํ ๋ถ๋ถ์ด๋ค.
ํ๋ก ํธ์ ์ฑํ ๋ฆฌ์คํธ ํ๋ฉด์์ ํ์ํ ๋ชจ๋ ์ ๋ณด๋ฅผ ์กฐํฉํด ๋ฐํํ๋ค.
/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 });
}
/chat/list/page.tsximport 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>
);
}
์ฒ์์๋ 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>
);
}
/chat/[id] ์์ธ ํ์ด์ง์ฌ๊ธฐ์๋
์ API ํ๋๋ก ๋ถ๋ฌ์จ๋ค.
(์ถํ ๋ฉ์์ง ์ค์๊ฐ ๊ธฐ๋ฅ(Supabase Realtime)์ ๋ถ์ผ ์ ์๋ค.)
์ฒ์ ๊ตฌ์กฐ๋:
app/(main)/layout.tsx โ MainLayoutShell
ํ์ง๋ง MainLayoutShell ์์์ ์๋์ฒ๋ผ ์กฐ๊ฑด์ด ๋ค์ด์์๋ค:
pathname.startsWith('/chat') ? <></> : <Header/>
์ด ์กฐ๊ฑด ๋๋ฌธ์
/chat, /chat/list, /chat/123 ๋ชจ๋ ํค๋๊ฐ ๋ณด์ด์ง ์๋ ๋ฌธ์ ๋ฐ์.
๋๋ ์ํ๋ ๋์์ ๋ค์ ์ ๋ฆฌํ๋ค:
| ๊ฒฝ๋ก | ํค๋ | BottomNav |
|---|---|---|
/chat | O | O |
/chat/... | X | X |
| ๊ทธ ์ธ | O | O |
๊ทธ๋์ ์ต์ข MainLayoutShell์ ์ด๋ ๊ฒ ์์ ํ๋ค:
const isChatRoot = pathname === "/chat";
const isChatOther = pathname.startsWith("/chat") && !isChatRoot;
๊ทธ ํ ์กฐ๊ฑด ๋ถ๊ธฐ ์ ์ฒด๋ฅผ ์์ ํด์
์ํ๋ UI ๊ตฌ์กฐ๋ฅผ ์๋ฒฝํ๊ฒ ํด๊ฒฐํ๋ค.
์ฒ์์๋ ์นด๋ ์๋์ sport ํ๊ทธ๊ฐ ์ ๋์๋ค.
์ฝ์๋ก ํ์ธํด๋ณด๋:
sport: null
tag: null
์์ธ์ API์์ posts ๋ฐ์ดํฐ๋ฅผ select ํ ๋
์กด์ฌํ์ง ์๋ ์ปฌ๋ผ date, time ์ select ํ๊ณ ์์ด์ ์ฟผ๋ฆฌ๊ฐ ์คํจํ๊ณ
๊ฒฐ๊ณผ์ ์ผ๋ก postsById๊ฐ ๋น ๊ฐ์ฒด๊ฐ ๋์ด sport๊ฐ null ๋ก ๋จ์ด์ง ๊ฒ์ด์๋ค.
ํด๊ฒฐ: ์ปฌ๋ผ๋ช
์ date_time์ผ๋ก ์์
.select("id, title, sport, location, date_time")
์ดํ sport ์ ์ ์ถ๋ ฅ.
์คํ๋ ์๋ชป๋ ์ปฌ๋ผ ๋๋ฌธ์ join ์ ์ฒด๊ฐ ์คํจํ ์ ์๋ค.
ํนํ layout ๊ธฐ์ค์ด ๋ณต์กํ ๋๋ Link๊ฐ ๊ฐ์ฅ ๋ฏฟ์ ์ ์๋ค.
๋ด๊ฐ ์ฐธ์ฌํ ๋ฐฉ์ ํํฐ๋งํ๋ ๋ชจ๋ ๊ธฐ๋ฐ.
์ด๋ฒ ์ฑํ ๊ธฐ๋ฅ ๊ตฌํ์ ๊ฝค ๋ง์ ๊ตฌ์กฐ๋ฅผ ๋ค๋ค์ง๋ง
๊ฒฐ๊ณผ์ ์ผ๋ก Supabase + Next.js App Router ์กฐํฉ์ผ๋ก
์ ๋ง ๊ฐ๋ ฅํ๊ณ ๊น๋ํ ์ค์๊ฐ ์ฑํ ๊ธฐ๋ฐ ๊ตฌ์กฐ๋ฅผ ๋ง๋ค ์ ์์๋ค.