๐Ÿš€ ์˜จํ•(on-fit) Next.js + Supabase๋กœ ์ฑ„ํŒ…๋ฐฉ โ€˜์ฝ์Œ ์ฒ˜๋ฆฌโ€™ ๊ธฐ๋Šฅ ๊ตฌํ˜„ํ•˜๊ธฐ

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

์˜จํ•

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

์˜จํ•(On-Fit) ํ”„๋กœ์ ํŠธ์—์„œ ์ฑ„ํŒ…๋ฐฉ ์ฝ์Œ ์ฒ˜๋ฆฌ ๊ธฐ๋Šฅ์„ ์ง์ ‘ ์„ค๊ณ„ํ•˜๊ณ  ๊ตฌํ˜„ํ–ˆ๋‹ค.

๋‹จ์ˆœํžˆ โ€œ์•ˆ ์ฝ์€ ๋ฉ”์‹œ์ง€ ๊ฐœ์ˆ˜๋งŒ ๋ณด์—ฌ์ฃผ๋ฉด ๋˜๋Š” ๊ฑฐ ์•„๋‹˜?โ€์ด๋ผ๊ณ  ์ƒ๊ฐํ–ˆ์ง€๋งŒ,

๋ง‰์ƒ ๊ตฌํ˜„ํ•˜๋ ค๋‹ˆ ์•„ํ‚คํ…์ฒ˜๋ถ€ํ„ฐ ์ •ํ•ด์•ผ ํ•  ๊ฒŒ ๊ฝค ๋งŽ์•˜๋‹ค.

์ด ๊ธ€์—์„œ๋Š” ๋‚ด๊ฐ€ ์ด ๊ธฐ๋Šฅ์„ ์–ด๋–ป๊ฒŒ ๊ตฌ์กฐํ™”ํ–ˆ๊ณ ,

์™œ ์ด๋Ÿฐ ๋ฐฉ์‹์œผ๋กœ ์„ค๊ณ„ํ–ˆ๋Š”์ง€,

๊ทธ๋ฆฌ๊ณ  ์‹ค์ œ ๊ตฌํ˜„ ์ฝ”๋“œ๋ฅผ ์ •๋ฆฌํ•ด๋ณธ๋‹ค.


๐Ÿ“Œ 1. ํ•ด๊ฒฐํ•ด์•ผ ํ–ˆ๋˜ ๋ฌธ์ œ

์˜จํ•์˜ ์ฑ„ํŒ… ๊ธฐ๋Šฅ์—์„œ ๋‚˜๋Š” ๋‹ค์Œ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋งŒ์กฑ์‹œํ‚ค๊ณ  ์‹ถ์—ˆ๋‹ค:

  • ์ฑ„ํŒ…๋ฐฉ ๋ฆฌ์ŠคํŠธ์—์„œ unreadCount(์•ˆ ์ฝ์€ ๋ฉ”์‹œ์ง€ ๊ฐœ์ˆ˜)๋ฅผ ํ‘œ์‹œํ•œ๋‹ค
  • ์‚ฌ์šฉ์ž๊ฐ€ ์ฑ„ํŒ…๋ฐฉ์— ๋“ค์–ด๊ฐ€๋Š” ์ˆœ๊ฐ„ โ†’ ํ•ด๋‹น ๋ฐฉ ๋ฉ”์‹œ์ง€๋ฅผ ์ฝ์Œ ์ฒ˜๋ฆฌํ•œ๋‹ค
  • ์ฝ์Œ ์ฒ˜๋ฆฌ๋œ ๋‚ด์šฉ์€ ์„œ๋ฒ„(DB) ๊ธฐ์ค€์œผ๋กœ ๊ด€๋ฆฌํ•ด์•ผ ํ•œ๋‹ค
  • ์ฑ„ํŒ…๋ฐฉ์„ ๋‚˜๊ฐ”๋‹ค ๋‹ค์‹œ ๋“ค์–ด์˜ค๋ฉด ๋ฑƒ์ง€๊ฐ€ ์‚ฌ๋ผ์ ธ์•ผ ํ•œ๋‹ค
  • ์ƒˆ ๋ฉ”์‹œ์ง€๊ฐ€ ์˜ค๋ฉด ๋‹ค์‹œ ๋ฑƒ์ง€๊ฐ€ ์ƒ๊ฒจ์•ผ ํ•œ๋‹ค

์ฆ‰, ์นด์นด์˜คํ†กยท์ธ์Šคํƒ€ DM์ฒ˜๋Ÿผ ์ž์—ฐ์Šค๋Ÿฌ์šด UX๊ฐ€ ํ•„์š”ํ–ˆ๋‹ค.


๐Ÿงฑ 2. ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„

โœ” ํ•ต์‹ฌ ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ: messages.read_by

๋จผ์ € Supabase(Postgres)์˜ messages ํ…Œ์ด๋ธ”์—

โ€œ๋ˆ„๊ฐ€ ์ด ๋ฉ”์‹œ์ง€๋ฅผ ์ฝ์—ˆ๋Š”์ง€โ€ ์ €์žฅํ•  ๊ตฌ์กฐ๊ฐ€ ํ•„์š”ํ–ˆ๋‹ค.

๊ทธ๋ž˜์„œ read_by ๋ฐฐ์—ด ์ปฌ๋Ÿผ์„ ํ•˜๋‚˜ ์ถ”๊ฐ€ํ–ˆ๋‹ค.

alter table messages
add column read_by text[] default '{}';

์˜ˆ์‹œ:

read_by = ['user1', 'user2']

์ด ๋ฉ”์‹œ์ง€๋ฅผ ์ฝ์€ ์œ ์ €๋“ค์˜ ๋ชฉ๋ก์„ ๋‹ด๋Š”๋‹ค.

์ด ๊ตฌ์กฐ๋ฅผ ์ฑ„ํƒํ•œ ์ด์œ :

  • unreadCount ๊ณ„์‚ฐ์ด ๋งค์šฐ ์‰ฌ์›Œ์ง
  • ์—ฌ๋Ÿฌ ์œ ์ €๊ฐ€ ์ฐธ์—ฌํ•˜๋Š” ์ฑ„ํŒ…๋ฐฉ์—์„œ๋„ ํ™•์žฅ์„ฑ ์žˆ์Œ
  • ์‹ค์‹œ๊ฐ„ ๋ฐ˜์‘(Realtime)์„ ๋ถ™์ผ ๋•Œ๋„ ์œ ๋ฆฌํ•จ

โœ” API ๋ถ„๋ฆฌ ์ „๋žต: โ€œ์กฐํšŒโ€์™€ โ€œํ–‰์œ„โ€๋Š” ๋‚˜๋ˆˆ๋‹ค

์ด ๊ธฐ๋Šฅ์„ ์„ค๊ณ„ํ•˜๋ฉด์„œ ๊ฐ€์žฅ ์ค‘์š”ํ•˜๊ฒŒ ํ•œ ๊ฒฐ์ •์€ API๋ฅผ ๋‘ ๊ฐœ๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์ด์—ˆ๋‹ค.

1) GET /api/chat/rooms

โ†’ ์ฑ„ํŒ…๋ฐฉ ๋ฆฌ์ŠคํŠธ + unreadCount ๊ณ„์‚ฐ

โ†’ ์กฐํšŒ ์ „์šฉ

2) POST /api/chat/read

โ†’ ์œ ์ €๊ฐ€ ๋ฐฉ์— ๋“ค์–ด๊ฐ„ ์ˆœ๊ฐ„ ๋ฉ”์‹œ์ง€๋ฅผ ์ฝ์Œ ์ฒ˜๋ฆฌ

โ†’ ํ–‰์œ„(Action) ์ „์šฉ

์ด๋ ‡๊ฒŒ ๋ถ„๋ฆฌํ•œ ์ด์œ ๋Š” ๊ฐ„๋‹จํ•˜๋‹ค.

  • *์กฐํšŒ(GET)**๋Š” ์บ์‹ฑํ•ด๋„ ๋˜๊ณ , SSR์—์„œ ๋ถˆ๋Ÿฌ๋„ ๋จ
  • *ํ–‰์œ„(POST/PATCH)**๋Š” DB๋ฅผ ์ˆ˜์ •ํ•˜๋Š” API๋ผ ๋ณ„๋„ ๊ด€๋ฆฌํ•ด์•ผ ํ•จ

๋‘˜์„ ์„ž์–ด๋ฒ„๋ฆฌ๋ฉด ์œ ์ง€๋ณด์ˆ˜๋„ ํ™•์žฅ๋„ ์• ๋งคํ•ด์ง„๋‹ค.


๐Ÿ“ฆ 3. unreadCount ๊ณ„์‚ฐ ๋กœ์ง

unread์˜ ์กฐ๊ฑด์€ ์ด๋ ‡๋‹ค:

  1. sender_id !== user.id (๋‚ด๊ฐ€ ๋ณด๋‚ธ ๋ฉ”์‹œ์ง€๋Š” ์ œ์™ธ)
  2. read_by ๋ฐฐ์—ด์— ๋‚ด user.id๊ฐ€ ์—†์Œ

๋‹ค์Œ ์ฝ”๋“œ๋กœ room๋ณ„ unreadCount๋ฅผ ๋งŒ๋“ค์–ด๋ƒˆ๋‹ค.

const unreadCountByRoom: Record<string, number> = {};

(messages ?? []).forEach((m) => {
  const rid = m.room_id as string;
  const senderId = m.sender_id as string | null;
  const readBy = (m.read_by ?? []) as string[];

  if (senderId !== user.id && !readBy.includes(user.id)) {
    unreadCountByRoom[rid] = (unreadCountByRoom[rid] ?? 0) + 1;
  }
});

๊ทธ๋ฆฌ๊ณ  ์ตœ์ข… ์‘๋‹ต์— ํฌํ•จ:

unreadCount: unreadCountByRoom[rid] ?? 0

์ด์ œ ํ”„๋ก ํŠธ์—์„œ๋Š” ๋‹จ์ˆœํžˆ unreadCount๋งŒ ๋ฐ›์•„ ์“ฐ๋ฉด ๋œ๋‹ค.


๐Ÿ›  4. ์ฝ์Œ ์ฒ˜๋ฆฌ API ๋งŒ๋“ค๊ธฐ

POST /api/chat/read

์œ ์ €๊ฐ€ ๋ฐฉ์— ์ง„์ž…ํ•˜์ž๋งˆ์ž ํ˜ธ์ถœ๋˜๋Š” API๋‹ค.

  • ํ•ด๋‹น ๋ฐฉ ๋ฉ”์‹œ์ง€ ์ค‘ โ†’ ๋‚ด๊ฐ€ ์ฝ์ง€ ์•Š์€ ๋ฉ”์‹œ์ง€๋ฅผ ๋ชจ๋‘ ์ฐพ์•„ โ†’ read_by์— ๋‚ด userId ์ถ”๊ฐ€
export async function POST(req: Request) {
  const supabase = await createSupabaseServerClient();

  const { ok: hasUser, user, response } = await requireUserOr401(supabase);
  if (!hasUser || !user) return response;

  const { roomId } = await req.json();

  const { data: messages } = await supabase
    .from("messages")
    .select("id, read_by, sender_id")
    .eq("room_id", roomId);

  const targets = messages.filter((m) => {
    return m.sender_id !== user.id && !m.read_by.includes(user.id);
  });

  await Promise.all(
    targets.map((m) =>
      supabase.from("messages").update({
        read_by: [...m.read_by, user.id],
      }).eq("id", m.id)
    )
  );

  return ok({ updated: targets.length });
}

Promise.all๋กœ ๋ณ‘๋ ฌ ์—…๋ฐ์ดํŠธํ•˜์—ฌ ์„ฑ๋Šฅ๋„ ํ™•๋ณดํ–ˆ๋‹ค.


๐Ÿ“ฑ 5. ํ”„๋ก ํŠธ์—์„œ ์ฝ์Œ ์ฒ˜๋ฆฌ ํŠธ๋ฆฌ๊ฑฐํ•˜๊ธฐ

์ฑ„ํŒ…๋ฐฉ ์นด๋“œ๋ฅผ ๋ˆŒ๋ €์„ ๋•Œ:

  1. /api/chat/read ํ˜ธ์ถœ (์ฝ์Œ ์ฒ˜๋ฆฌ)
  2. /chat/[roomId]๋กœ ์ด๋™
const handleChatRoomCard = () => {
  api.post("/api/chat/read", { roomId });
  router.push(`/chat/${roomId}`);
};

์ด์ œ ์‚ฌ์šฉ์ž๊ฐ€ ๋ฐฉ์— ๋“ค์–ด๊ฐ€๋Š” ์ˆœ๊ฐ„ DB์— read_by๊ฐ€ ๊ฐฑ์‹ ๋œ๋‹ค.

๋‹ค์‹œ ์ฑ„ํŒ… ๋ฆฌ์ŠคํŠธ๋กœ ๋‚˜์˜ค๋ฉด

GET /api/chat/rooms๊ฐ€ ์ตœ์‹  unreadCount=0์„ ๊ฐ€์ ธ์˜ค๋ฏ€๋กœ

๋ฑƒ์ง€๊ฐ€ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์‚ฌ๋ผ์ง„๋‹ค.


โœ… 6. ์™„์„ฑ๋œ UX ํ๋ฆ„

์ด์ œ ์˜จํ•์—์„œ๋Š” ์ด๋ ‡๊ฒŒ ๋™์ž‘ํ•œ๋‹ค.

  1. ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก โ†’ ๋นจ๊ฐ„ ๋ฑƒ์ง€๋กœ unreadCount ํ‘œ์‹œ
  2. ๋ฐฉ ์ง„์ž… โ†’ ์ž๋™์œผ๋กœ read ์ฒ˜๋ฆฌ
  3. ๋ชฉ๋ก์œผ๋กœ ๋Œ์•„์˜ค๋ฉด ๋ฑƒ์ง€๊ฐ€ ์‚ฌ๋ผ์ง
  4. ์ƒˆ๋กœ์šด ๋ฉ”์‹œ์ง€๊ฐ€ ์˜ค๋ฉด ๋‹ค์‹œ ๋ฑƒ์ง€๊ฐ€ ์ƒ๊น€

๋ฉ”์‹ ์ € ์•ฑ์ฒ˜๋Ÿผ ์ž์—ฐ์Šค๋Ÿฌ์šด ์‚ฌ์šฉ ๊ฒฝํ—˜์ด ๋งŒ๋“ค์–ด์กŒ๋‹ค.


๐ŸŽฏ 7. ์„ค๊ณ„ ์ด์ •๋ฆฌ

์ •๋ฆฌํ•˜๋ฉด ์ด๋ฒˆ ๊ธฐ๋Šฅ์€ ์ด๋Ÿฐ ๊ตฌ์กฐ๋กœ ์„ค๊ณ„ํ–ˆ๋‹ค.

  • messages.read_by ๋ฐฐ์—ด๋กœ ์ฝ์Œ ์—ฌ๋ถ€ ๊ด€๋ฆฌ
  • GET /api/chat/rooms โ†’ ์กฐํšŒ ์ „์šฉ
  • POST /api/chat/read โ†’ ์ƒํƒœ ๋ณ€๊ฒฝ(ํ–‰์œ„) ์ „์šฉ
  • ํ”„๋ก ํŠธ์—์„œ ๋ฐฉ ์ž…์žฅ ์‹œ read API ํ˜ธ์ถœ
  • ๋ชฉ๋ก ํ™”๋ฉด์€ GET API๋ฅผ ๋‹ค์‹œ ํ˜ธ์ถœํ•ด์„œ ์ตœ์‹  unreadCount ๋ฐ˜์˜

์ด ๊ตฌ์กฐ๋Š” ๋งค์šฐ ์ง๊ด€์ ์ด๊ณ , Next.js Route Handler ํŒจํ„ด๊ณผ๋„ ์ž˜ ๋งž์•„๋–จ์–ด์ง„๋‹ค.


๐Ÿ“ ๋งˆ๋ฌด๋ฆฌ

์ด๋ฒˆ์— ์ฝ์Œ ์ฒ˜๋ฆฌ ๊ธฐ๋Šฅ์„ ์ง์ ‘ ์„ค๊ณ„ํ•˜๋ฉด์„œ ๋‹ค์Œ์„ ๋ฐฐ์› ๋‹ค.

  • API๋Š” โ€œ์กฐํšŒ(GET)โ€์™€ โ€œํ–‰์œ„(POST/PATCH)โ€๋ฅผ ๋ถ„๋ฆฌํ•˜๋ฉด ์œ ์ง€๋ณด์ˆ˜์„ฑ์ด ์ข‹์•„์ง„๋‹ค
  • Supabase ๋ฐฐ์—ด ํƒ€์ž…(read_by)์„ ํ™œ์šฉํ•˜๋ฉด ์ฑ— ์‹œ์Šคํ…œ ํ™•์žฅ์„ฑ์ด ๋„“์–ด์ง„๋‹ค
  • Next.js Route Handler๋Š” RESTful ๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค๊ธฐ ๋งค์šฐ ์ข‹๋‹ค
  • ํ”„๋ก ํŠธ์—์„œ๋Š” ์ตœ์†Œํ•œ์˜ ํ˜ธ์ถœ๋งŒ์œผ๋กœ ์ž์—ฐ์Šค๋Ÿฌ์šด UX๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค
profile
์ฝ”๋ฆฐ์ด

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