๐Ÿ”ฅ์˜จํ•(on-fit) โ€“ ์ฑ„ํŒ…๋ฐฉ ๋ฆฌ๋ทฐ ์‹œ์Šคํ…œ ๊ตฌํ˜„๊ธฐ

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

์˜จํ•

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

โ€œ๋‚˜๋ฅผ ์ œ์™ธํ•œ ๋ฉค๋ฒ„ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ + ๋ชจ์ง‘ ๊ธฐ๊ฐ„ ๋งˆ๊ฐ ์‹œ ๋ฒ„ํŠผ ํ™œ์„ฑํ™”๊นŒ์ง€โ€

์ตœ๊ทผ์— ์˜จํ•(On-Fit) ํ”„๋กœ์ ํŠธ์—์„œ โ€œํ•จ๊ป˜ ์šด๋™ํ•œ ๋ฉค๋ฒ„์—๊ฒŒ ์นญ์ฐฌ ๋ฆฌ๋ทฐ ๋‚จ๊ธฐ๊ธฐโ€ ๊ธฐ๋Šฅ์„ ๊ฐœ๋ฐœํ•˜๊ฒŒ ๋๋‹ค.

๊ฐœ๋ฐœํ•˜๋ฉด์„œ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ Next.js App Router, Supabase, API ์„ค๊ณ„, ๊ทธ๋ฆฌ๊ณ  UX์ ์ธ ๊ณ ๋ฏผ๊นŒ์ง€ ์ด์–ด์กŒ๊ณ ,

๋‚ด๊ฐ€ ์–ด๋–ค ๊ณผ์ •์„ ๊ฑฐ์ณ ๊ธฐ๋Šฅ์„ ์™„์„ฑํ–ˆ๋Š”์ง€ ๊ธฐ๋กํ•ด๋ณด๊ณ ์ž ํ•œ๋‹ค.

์ด๋ฒˆ์— ๊ตฌํ˜„ํ•œ ํ•ต์‹ฌ ๊ธฐ๋Šฅ์€ ํฌ๊ฒŒ ๋‘ ๊ฐ€์ง€๋‹ค.

  1. ๋ฆฌ๋ทฐ ํŽ˜์ด์ง€์—์„œ โ€˜๋‚˜โ€™๋ฅผ ์ œ์™ธํ•œ ์ฐธ์—ฌ์ž ๋ชฉ๋ก๋งŒ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ
  2. ์ฑ„ํŒ…๋ฐฉ์—์„œ ๋ชจ์ง‘ ๊ธฐ๊ฐ„(= ์šด๋™ ์‹œ์ž‘ ์‹œ๊ฐ„)์ด ์ง€๋‚ฌ์„ ๋•Œ๋งŒ โ€˜๋ฆฌ๋ทฐํ•˜๊ธฐ / ๋‚˜๊ฐ€๊ธฐโ€™ ๋ฒ„ํŠผ ํ™œ์„ฑํ™”

์ด ๋‘ ๊ฐ€์ง€๊ฐ€ ํ•ฉ์ณ์ ธ์•ผ ์ „์ฒด ๋ฆฌ๋ทฐ ํ”Œ๋กœ์šฐ๊ฐ€ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์—ฐ๊ฒฐ๋œ๋‹ค.


1. ๋ฆฌ๋ทฐ ํŽ˜์ด์ง€: โ€œ๋‚˜๋ฅผ ์ œ์™ธํ•œ ์ฐธ์—ฌ์ž๋งŒ ๊ฐ€์ ธ์˜ค๊ธฐโ€

๋ฆฌ๋ทฐ๋Š” ๋‹ค๋ฅธ ์‚ฌ๋žŒ์—๊ฒŒ๋งŒ ๋‚จ๊ธธ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์—,

API์—์„œ ์ฐธ์—ฌ์ž ์ •๋ณด ๋ฐ›์„ ๋•Œ ๋‚ด ์ •๋ณด๋Š” ์ œ์™ธํ•ด์•ผ ํ•œ๋‹ค.

์›๋ž˜ ์ฐธ์—ฌ์ž ์กฐํšŒ๋Š” ์ด๋ ‡๊ฒŒ ๋˜์–ด ์žˆ์—ˆ๋‹ค.

const { data, error } = await supabase
  .from("participants")
  .select(`
    user_id,
    role,
    joined_at,
    profiles (
      id,
      nickname,
      profile_image
    )
  `)
  .eq("room_id", roomId);

๋ฌธ์ œ๋Š” ๋‚˜๊นŒ์ง€ ํฌํ•จ๋˜์–ด ์žˆ๋‹ค๋Š” ๊ฒƒ.

์ฒ˜์Œ์—” ํ”„๋ก ํŠธ์—์„œ ํ•„ํ„ฐ๋งํ• ๊นŒ ํ–ˆ์ง€๋งŒ,

API ๋‹จ์—์„œ ํ•„ํ„ฐ๋งํ•˜๋Š” ๊ฒŒ ๋” ๊น”๋”ํ–ˆ๊ณ , ํ˜‘์—… ์‹œ์—๋„ ๋ช…ํ™•ํ–ˆ๋‹ค.

๊ทธ๋ž˜์„œ API์— excludeSelf=true ์˜ต์…˜์„ ์ฃผ๋ฉด ๋‚˜๋ฅผ ๋นผ๋„๋ก ๊ฐœ์„ ํ–ˆ๋‹ค.

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

const { data, error } = await supabase
  .from("participants")
  .select("user_id, role, profiles(nickname, profile_image)")
  .eq("room_id", roomId)
  .neq("user_id", user.id);  // ๐ŸŽฏ ๋‚˜ ์ œ์™ธ

์ด์ œ ๋ฆฌ๋ทฐ ํŽ˜์ด์ง€์—์„œ๋Š” ์ด๋ ‡๊ฒŒ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ๋งŒ ํ•˜๋ฉด ๋œ๋‹ค.

api.get("/api/chat/participants", {
  params: { roomId, excludeSelf: true },
});

์ด๊ฑธ ์ ์šฉํ•˜๋‹ˆ ๋ฆฌ๋ทฐ ํŽ˜์ด์ง€์—๋Š” ์ •๋ง "๋‹ค๋ฅธ ๋ฉค๋ฒ„๋“ค๋งŒ" ๋œจ๊ฒŒ ๋˜์—ˆ๊ณ ,

UX์ ์œผ๋กœ๋„ ํ›จ์”ฌ ์ž์—ฐ์Šค๋Ÿฌ์›Œ์กŒ๋‹ค.


2. ๋ชจ์ง‘ ๊ธฐ๊ฐ„์ด ๋๋‚˜๋ฉด๋งŒ ๋ฒ„ํŠผ ํ™œ์„ฑํ™”

๋‹ค์Œ์œผ๋กœ ๊ณ ๋ฏผํ•œ ๊ฑด ์–ธ์ œ ๋ฆฌ๋ทฐ๋ฅผ ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•  ๊ฒƒ์ธ๊ฐ€์˜€๋‹ค.

์˜จํ•์—์„œ๋Š” post์˜ date_time์ด ์šด๋™ ์‹œ์ž‘ ์‹œ๊ฐ„์ด์ž ๋ชจ์ง‘ ๋งˆ๊ฐ ์‹œ๊ฐ„์ด๋‹ค.

  • ๋ชจ์ง‘ ์ „ โ†’ ๊ทธ๋ƒฅ ์ฑ„ํŒ…๋ฐฉ ๊ธฐ๋Šฅ๋งŒ ๊ฐ€๋Šฅ
  • ๋ชจ์ง‘ ํ›„ โ†’ ์ฐธ๊ฐ€์ž ์ƒํƒœ ํ™•์ • โ†’ ๋ฆฌ๋ทฐ, ๋ฐฉ ๋‚˜๊ฐ€๊ธฐ ๊ฐ€๋Šฅ

์ฆ‰, ์กฐ๊ฑด์€ ๊ฐ„๋‹จํ•˜๋‹ค.

posts.date_time < ํ˜„์žฌ ์‹œ๊ฐ(now) ์ผ ๋•Œ๋งŒ ๋ฒ„ํŠผ์„ ๋ณด์—ฌ์ฃผ์ž

๊ทธ๋Ÿฌ๊ธฐ ์œ„ํ•ด /api/chat/rooms ์‘๋‹ต์—

๊ฐ room์˜ ๋ชจ์ง‘ ๋งˆ๊ฐ ์‹œ๊ฐ„๊ณผ canReview ์—ฌ๋ถ€๋ฅผ ์ถ”๊ฐ€ํ–ˆ๋‹ค.

const now = new Date();

const recruitEndAt = post?.date_time ?? null;

const canReview =
  recruitEndAt !== null ? new Date(recruitEndAt) <= now : false;

rooms API์—์„œ ๋‚ด๋ ค์ฃผ๋Š” ์ตœ์ข… ๋ฐ์ดํ„ฐ๋Š” ์ด๋ ‡๊ฒŒ ๋ณ€ํ–ˆ๋‹ค.

{
  "roomId": "123",
  "title": "๊ฐ•๋‚จ ๋ฐฐ๋“œ๋ฏผํ„ด",
  "currentParticipants": 4,
  "lastMessage": "๋‚ด์ผ ๋ตˆ์–ด์š”!",
  "recruitEndAt": "2025-01-21T18:00:00Z",
  "canReview": true}

ํ”„๋ก ํŠธ์—์„œ๋Š” ๊ทธ๋ƒฅ ์ด ๊ฐ’๋งŒ ๋ณด๊ณ  ๋ Œ๋”๋งํ•˜๋ฉด ๋œ๋‹ค.


3. ChatRoomCard์—์„œ ์กฐ๊ฑด๋ถ€ ๋ Œ๋”๋ง

{canReview && (
  <div className="mt-3 flex justify-end gap-2">
    <Button variant="outline" size="sm" onClick={handleLeaveClick}>
      ๋‚˜๊ฐ€๊ธฐ
    </Button>
    <Button variant="default" size="sm" onClick={handleReviewClick}>
      ๋ฆฌ๋ทฐํ•˜๋Ÿฌ๊ฐ€๊ธฐ
    </Button>
  </div>
)}

canReview ๊ฒฐ๊ณผ๊ฐ€ true์ผ ๋•Œ๋งŒ ๋ฒ„ํŠผ์ด ๋ณด์ธ๋‹ค!

์ด์ œ ๋ชจ์ง‘์ด ๋๋‚˜๊ธฐ ์ „์—๋Š”

  • โ€œ๋ฆฌ๋ทฐํ•˜๊ธฐโ€ ๋ฒ„ํŠผ ์—†์Œ
  • โ€œ๋‚˜๊ฐ€๊ธฐโ€ ๋ฒ„ํŠผ ์—†์Œ

๋ชจ์ง‘์ด ๋๋‚œ ํ›„์—๋Š”

  • ๋‘ ๋ฒ„ํŠผ ๋ชจ๋‘ ๋‚˜ํƒ€๋‚จ

์•„์ฃผ ์ž์—ฐ์Šค๋Ÿฌ์šด UX ํ๋ฆ„.


4. ๋ฐฉ ๋‚˜๊ฐ€๊ธฐ ๊ธฐ๋Šฅ๊ณผ ์ƒˆ๋กœ๊ณ ์นจ ๋ฌธ์ œ ํ•ด๊ฒฐ

์ฒ˜์Œ์—๋Š” ๋‚˜๊ฐ€๊ธฐ ํ›„ router.refresh()๋ฅผ ์‚ฌ์šฉํ–ˆ๋Š”๋ฐ,

ChatRoomList๊ฐ€ ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ(useEffect + axios)๋ผ์„œ ์ƒˆ๋กœ๊ณ ์นจ์ฒ˜๋Ÿผ ๋ณด์ด์ง€ ์•Š์•˜๋‹ค.

๊ทธ๋ž˜์„œ ์•„๋ž˜์ฒ˜๋Ÿผ state์—์„œ ํ•ด๋‹น room์„ ์ง์ ‘ ์ œ๊ฑฐํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ํ•ด๊ฒฐํ–ˆ๋‹ค.

onLeave={(roomId) => {
  setRooms(prev => prev.filter(r => r.roomId !== roomId));
}}

๊ฒฐ๊ณผ์ ์œผ๋กœ ์ฆ‰์‹œ UI์—์„œ ์‚ฌ๋ผ์ง€๊ณ , ํ›จ์”ฌ ์ž์—ฐ์Šค๋Ÿฌ์šด UX๊ฐ€ ์™„์„ฑ๋˜์—ˆ๋‹ค.


โœจ ์ด๋ฒˆ ์ž‘์—…์—์„œ ์–ป์€ ์ธ์‚ฌ์ดํŠธ

1. ์„œ๋ฒ„์—์„œ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์€ ์„œ๋ฒ„์—์„œ ์ฒ˜๋ฆฌํ•˜์ž

  • โ€œ๋‚˜ ์ œ์™ธํ•˜๊ธฐโ€๋Š” API ๋‹จ์—์„œ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ๊ฐ€์žฅ ๊น”๋”ํ•˜๋‹ค.

2. ํด๋ผ์ด์–ธํŠธ ์ƒํƒœ ๊ด€๋ฆฌ ๋•Œ๋ฌธ์— router.refresh()๊ฐ€ ์•ˆ ๋จนํž ์ˆ˜ ์žˆ๋‹ค

  • App Router๋ผ๊ณ  ํ•ด๋„ ํด๋ผ ์ปดํฌ๋„ŒํŠธ์—์„œ axios ์“ฐ๋ฉด state ๊ธฐ๋ฐ˜์ด๊ธฐ ๋•Œ๋ฌธ์— ์„œ๋ฒ„ ์ƒˆ๋กœ๊ณ ์นจ์ด ์˜๋ฏธ ์—†์„ ๋•Œ๊ฐ€ ์žˆ๋‹ค.

3. ๋„๋ฉ”์ธ ๋กœ์ง์€ API์—์„œ ๊ณ„์‚ฐํ•ด์„œ ๋‚ด๋ ค์ฃผ๋Š” ๊ฒŒ ์ข‹๋‹ค

  • ๋ชจ์ง‘ ๋งˆ๊ฐ ์—ฌ๋ถ€(canReview)๋ฅผ ํ”„๋ก ํŠธ์—์„œ ๋งค๋ฒˆ ๊ณ„์‚ฐํ•˜๋Š” ๋Œ€์‹  API์—์„œ ์ฒ˜๋ฆฌํ•˜๋ฉด UI ์ฝ”๋“œ๊ฐ€ ํ›จ์”ฌ ๊ฐ„๊ฒฐํ•ด์ง„๋‹ค.

๐Ÿ”ฅ ๋งˆ๋ฌด๋ฆฌ

์ด๋ฒˆ ๊ธฐ๋Šฅ์€ ๋‹จ์ˆœํžˆ UI ํ•˜๋‚˜ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์˜€์ง€๋งŒ

โ€œ๋ฐ์ดํ„ฐ ํ๋ฆ„ โ†’ API ์„ค๊ณ„ โ†’ UI ์กฐ๊ฑด ๋ถ„๊ธฐ โ†’ UX ์ž์—ฐ์Šค๋Ÿฌ์›€โ€๊นŒ์ง€

์ „๋ฐ˜์ ์ธ ๋กœ์ง์„ ๋‹ค์‹œ ์ •๋น„ํ•˜๋Š” ๊ณ„๊ธฐ๊ฐ€ ๋˜์—ˆ๋‹ค.

ํŠนํžˆ:

  • ์ฐธ์—ฌ์ž ๋ถˆ๋Ÿฌ์˜ฌ ๋•Œ ๋‚˜๋ฅผ ์ œ์™ธํ•˜๋Š” ์ฒ˜๋ฆฌ
  • ๋ชจ์ง‘ ๋งˆ๊ฐ ์‹œ์  ๊ณ„์‚ฐํ•ด์„œ ๊ธฐ๋Šฅ ํ™œ์„ฑํ™”
  • ๋ฐฉ ๋‚˜๊ฐ€๊ธฐ ํ›„ ์ž์—ฐ์Šค๋Ÿฌ์šด ๋ฆฌ์ŠคํŠธ ๊ฐฑ์‹ 

์ด ์„ธ ๊ฐ€์ง€๊ฐ€ ์กฐํ™”๋กญ๊ฒŒ ๋™์ž‘ํ•˜๋„๋ก ๋งŒ๋“ค๋ฉด์„œ

๋„๋ฉ”์ธ ์ฃผ๋„์ ์ธ ์„ค๊ณ„์˜ ์ค‘์š”์„ฑ์„ ๋‹ค์‹œ ์ฒด๊ฐํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.

์•ž์œผ๋กœ๋Š” โ€œ๋ฆฌ๋ทฐ ์ ์ˆ˜ ์‹œ๊ฐํ™”โ€, โ€œ๋ฆฌ๋ทฐ ์•Œ๋ฆผโ€, โ€œ๋ฆฌ๋ทฐ ํ†ต๊ณ„โ€ ๊ฐ™์€ ๊ธฐ๋Šฅ๋„ ์ถ”๊ฐ€ํ•ด๋ณผ ๊ณ„ํš์ด๋‹ค.

profile
์ฝ”๋ฆฐ์ด

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