Post

Socket을 사용한 Read-Time 채팅 구현하기 - (3) 채팅 메시지 컴포넌트 구현하기

Socket을 사용한 Read-Time 채팅 구현하기 - (3) 채팅 메시지 컴포넌트 구현하기

📢 Socket을 사용한 Real-Time 채팅 구현하기 - (3) 채팅 메시지 컴포넌트 구현하기

이전 글에서 했던 작업

  • (1) Socket IO 초기 세팅
  • (2) 채팅 입력창 및 메시지 전송 API 구현

이번 글에서는 실제로 채팅 메시지들을 렌더링하는 컴포넌트를 구현한다.
메시지를 불러올 때 페이징을 적용하여 성능을 최적화하고,
새로운 메시지가 올 때 실시간으로 업데이트되는 기능도 추가할 것이다.


1️⃣ 채팅 메시지 API 구현

채팅에서 가장 중요한 부분 중 하나는 메시지 데이터를 효율적으로 불러오는 것이다.
이를 위해 페이징(pagination)과 무한 스크롤을 지원하는 메시지 API를 만들었다.

1-1. Messages API 생성 (GET 요청 처리)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// app/api/messages/route.ts

import { NextResponse } from "next/server";
import { currentProfile } from "@/lib/current-profile";
import { Message } from "@prisma/client";
import { db } from "@/lib/db";

// (1) 한 번에 로드할 메시지 개수 제한
const MESSAGES_BATCH = 10;

export async function GET(req: Request) {
  try {
    const profile = await currentProfile();
    const { searchParams } = new URL(req.url);

    const cursor = searchParams.get("cursor"); // 페이징 기준점
    const channelId = searchParams.get("channelId");

    if (!profile) {
      return new NextResponse("Unauthorized", { status: 401 });
    }

    if (!channelId) {
      return new NextResponse("Channel ID Missing", { status: 400 });
    }

    // (2) 메시지 조회 (페이징 적용)
    let messages: Message[] = [];

    if (cursor) {
      messages = await db.message.findMany({
        take: MESSAGES_BATCH, // 10개씩 불러오기
        skip: 1,
        cursor: { id: cursor },
        where: { channelId },
        include: {
          member: { include: { profile: true } }
        },
        orderBy: { createdAt: "desc" }
      });
    } else {
      messages = await db.message.findMany({
        take: MESSAGES_BATCH,
        where: { channelId },
        include: {
          member: { include: { profile: true } }
        },
        orderBy: { createdAt: "desc" }
      });
    }

    // (3) 다음 페이지의 cursor 설정
    let nextCursor = null;
    if (messages.length === MESSAGES_BATCH) {
      nextCursor = messages[MESSAGES_BATCH - 1].id;
    }

    return NextResponse.json({ items: messages, nextCursor });
  } catch (error) {
    console.log("[MESSAGES_GET]", error);
    return new NextResponse("Internal Error", { status: 500 });
  }
}

✅ API 동작 방식

  1. 한 번에 10개씩 메시지를 가져온다.
  2. cursor를 기반으로 페이징을 처리한다.
    • cursor가 없으면 최신 메시지를 가져온다.
    • cursor가 있으면, 해당 메시지 이후의 데이터를 가져온다.
  3. nextCursor 값을 반환하여 클라이언트에서 무한 스크롤을 적용할 수 있게 한다.

2️⃣ 채팅 메시지 UI 구현

이제 실제로 채팅 메시지들을 화면에 렌더링하는 컴포넌트를 구현한다.
메시지 불러오기, 무한 스크롤 적용, 실시간 업데이트 등의 기능이 포함된다.

2-1. 채팅 시작 화면 표시

  • 새로운 채널에 들어왔을 때 기본적으로 환영 메시지를 표시한다.
  • 채널인지, 개인 대화인지에 따라 UI를 다르게 구성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// components/chat/chat-welcome.tsx

import { Hash } from "lucide-react";

interface ChatWelcomeProps {
  name: string;
  type: "channel" | "conversation";
}

export const ChatWelcome = ({ name, type }: ChatWelcomeProps) => {
  return (
    <div className="space-y-2 px-4 mb-4">
      {type === "channel" && (
        <div className="h-[75px] w-[75px] rounded-full bg-zinc-500 dark:bg-zinc-700 flex items-center justify-center">
          <Hash className="h-12 w-12 text-white" />
        </div>
      )}
      <p className="text-xl md:text-3xl font-bold">
        {type === "channel" ? "Welcome to #" : ""}
        {name}
      </p>
      <p className="text-zinc-600 dark:text-zinc-400 text-sm">
        {type === "channel"
          ? `This is the start of the ${name} channel.`
          : `This is the start of your conversation with ${name}.`}
      </p>
    </div>
  );
};

2-2. 채팅 메시지 컴포넌트 구현

  • 무한 스크롤을 적용하여 이전 메시지를 불러올 수 있도록 한다.
  • 메시지를 실시간으로 업데이트할 수 있도록 useChatQuery를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// components/chat/chat-messages.tsx

"use client";

import { Member, Message, Profile } from "@prisma/client";
import { Loader2, ServerCrash } from "lucide-react";
import { useChatQuery } from "@/hooks/use-chat-query";
import { ChatWelcome } from "@/components/chat/chat-welcome";
import { Fragment } from "react";

type MessageWithMemberWithProfile = Message & {
  member: Member & {
    profile: Profile;
  };
};

interface ChatMessagesProps {
  name: string;
  member: Member;
  chatId: string;
  apiUrl: string;
  socketUrl: string;
  socketQuery: Record<string, string>;
  paramKey: "channelId" | "conversationId";
  paramValue: string;
  type: "channel" | "conversation";
}

export const ChatMessages = ({
  name,
  member,
  chatId,
  apiUrl,
  socketUrl,
  socketQuery,
  paramKey,
  paramValue,
  type
}: ChatMessagesProps) => {
  const queryKey = `chat:${chatId}`;

  // (1) useChatQuery 가져오기 (무한 스크롤 & 실시간 메시지 적용)
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
    useChatQuery({ queryKey, apiUrl, paramKey, paramValue });

  // (2) 로딩 & 에러 처리
  if (status === "loading") {
    return (
      <div className="flex flex-col flex-1 justify-center items-center ">
        <Loader2 className="h-7 w-7 text-zinc-500 animate-spin my-4 " />
        <p className="text-xs text-zinc-500 dark:text-zinc-400">
          Loading messages...
        </p>
      </div>
    );
  }

  if (status === "error") {
    return (
      <div className="flex flex-col flex-1 justify-center items-center ">
        <ServerCrash className="h-7 w-7 text-zinc-500 my-4 " />
        <p className="text-xs text-zinc-500 dark:text-zinc-400">
          Something went wrong!
        </p>
      </div>
    );
  }

  // (3) 메시지 렌더링
  return (
    <div className="flex-1 flex-col flex overflow-y-auto">
      <div className="flex-1" />
      <ChatWelcome name={name} type={type} />

      <div className="flex flex-col-reverse mt-auto">
        {data?.pages?.map((group, i) => (
          <Fragment key={i}>
            {group.items.map((message: MessageWithMemberWithProfile) => (
              <div key={message.id}>{message.content}</div>
            ))}
          </Fragment>
        ))}
      </div>
    </div>
  );
};

✅ 정리

  • 메시지 API를 만들어 페이징 처리 적용
  • 무한 스크롤과 실시간 메시지를 지원하는 채팅 컴포넌트 구현
  • 채팅 UI를 개선하여 환영 메시지메시지 리스트 추가
This post is licensed under CC BY 4.0 by the author.