상세 컨텐츠

본문 제목

NestJS, Fastify, Apollo GraphQL & NextJS를 사용해 채팅 앱 구축(클라이언트편)

Dev Type

by ai developer 2024. 5. 21. 21:48

본문

 

지난 NestJS, Fastify, Apollo GraphQL & NextJS를 사용해 채팅 앱 구축(서버편)에 이어서 클라이언트편을 이어갑니다.

 

서버 준비가 완료됐으니 이제 NextJS로 프론트엔드를 설정합니다.

 

터미널에서 다음 명령을 실행하여 새 NextJS 앱을 만듭니다.

npx create-next-app@latest

 

Tailwind CSS를 선택하는 것을 잊지 마세요. 개발 프로세스를 크게 가속하고 귀중한 시간을 절약해 줄 것입니다.

 

이제 필수 패키지를 설치합니다.

cd ui;
npm i -s @apollo/client graphql graphql-ws;

 

이제 프론트엔드를 구성해 볼텐데 파일 구조는 아래와 같습니다.

├── backend
├── ui
│     ├── src
│     │   ├── helpers
│     |   |   ├── gql.request.ts
│     |   |   ├── gql.setup.ts
│     │   ├── pages
│     |   |   ├── _app.tsx
│     |   |   ├── _document.tsx
│     |   |   ├── index.tsx
│     │   ├── styles
│     |   |   ├── globals.css

 

 

GraphQL 링크 설정

 

파일을 열고 helpers/gql.setup.ts 내용을 아래와 같이 변경하세요.

import { ApolloClient, InMemoryCache, HttpLink, split } from '@apollo/client'
import { getMainDefinition } from '@apollo/client/utilities'
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { createClient } from 'graphql-ws'

/**
 * @see https://www.apollographql.com/docs/react/data/subscriptions
 */

const httpGqlLink = new HttpLink({
  uri: 'http://localhost:3001/graphql',
})

const wsGqlLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:3001/graphql',
  })
)

// The split function takes three parameters:
//
// * A function that's called for each operation to execute
// * The Link to use for an operation if the function returns a "truthy" value
// * The Link to use for an operation if the function returns a "falsy" value
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query)
    
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    )
  },
  wsGqlLink,
  httpGqlLink
)

export const mainGqlClient = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
})

 

GraphQL 요청 등록

 

파일을 열고 helpers/gql.request.ts 내용을 아래와 같이 변경하세요.

import { useQuery, gql, useMutation, useSubscription } from '@apollo/client'
import { mainGqlClient } from './gql.setup'

export type Nullable<T> = T | null | undefined

export interface IMessage {
  id: string
  conversationId: string
  senderId: string
  content: string
  createdAt: Date
}

export interface IConversation {
  id: string
  name: string
  messages: IMessage[]
  lastMessage?: Nullable<IMessage>
  createdAt: Date
}

export interface IConversationWithoutMessages
  extends Omit<IConversation, 'messages'> {}

export const SEND_NEW_MESSAGE = gql`
  mutation SendNewMessage(
    $conversationId: ID!
    $senderId: ID!
    $content: String!
  ) {
    messageCreate(
      conversationId: $conversationId
      senderId: $senderId
      content: $content
    ) {
      id
      conversationId
      content
      createdAt
      senderId
    }
  }
`

export const GET_CONVERSATION_DETAILS = gql`
  query GetConversationDetails($id: ID!) {
    conversationById(id: $id) {
      id
      name
      createdAt
    }
  }
`

export const GET_CONVERSATION_MESSAGES = gql`
  query GetConversationMessages($conversationId: ID!) {
    conversationById(id: $conversationId) {
      messages {
        id
        content
        createdAt
        senderId
      }
    }
  }
`

export const GET_CONVERSATIONS_LIST = gql`
  query GetConversationsList {
    conversationsList {
      id
      name
      createdAt
      lastMessage {
        id
        senderId
        content
        createdAt
      }
    }
  }
`

const NEW_MESSAGE_SENT_SUBSCRIPTION = gql`
  subscription OnNewMessageSent($conversationId: ID!) {
    onNewMessageSent(conversationId: $conversationId) {
      content
      conversationId
      senderId
      createdAt
      id
    }
  }
`

export const getConversationDetails = (id: string) => {
  const { data, loading, error, refetch } = useQuery(GET_CONVERSATION_DETAILS, {
    variables: { id },
    fetchPolicy: 'no-cache',
  })

  return {
    conversation:
      data?.conversationById as Nullable<IConversationWithoutMessages>,
    loading,
    error,
    refetch,
  }
}

export const getConversationMessages = (conversationId: string) => {
  const { data, loading, error, refetch } = useQuery(
    GET_CONVERSATION_MESSAGES,
    {
      variables: { conversationId },
      fetchPolicy: 'no-cache',
    }
  )

  return {
    messages: data?.conversationById?.messages as IMessage[],
    loading,
    error,
    refetch,
  }
}

export const getConversationsList = () => {
  const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS_LIST, {
    fetchPolicy: 'no-cache',
  })

  return {
    conversations: data?.conversationsList as IConversationWithoutMessages[],
    loading,
    error,
    refetch,
  }
}

export const sendNewMessage = (
  conversationId: string,
  senderId: string,
  content: string
) => {
  const [createMessage, { data, loading, error }] = useMutation(
    SEND_NEW_MESSAGE,
    {
      variables: { conversationId, senderId, content },
      fetchPolicy: 'no-cache',
    }
  )

  return {
    createMessage,
    message: data?.messageCreate as IMessage,
    loading,
    error,
  }
}

export const subscribeToConversation = (conversationId: string) => {
  const { data, loading, error } = useSubscription(
    NEW_MESSAGE_SENT_SUBSCRIPTION,
    {
      variables: { conversationId },
      fetchPolicy: 'no-cache',
    }
  )

  return {
    message: data?.onNewMessageSent as IMessage,
    loading,
    error,
  }
}

export const sendNewMessageAsync = (
  conversationId: string,
  senderId: string,
  content: string
) =>
  mainGqlClient
    .mutate({
      mutation: SEND_NEW_MESSAGE,
      variables: {
        conversationId,
        senderId,
        content,
      },
      fetchPolicy: 'no-cache',
    })
    .then(({ data }) => {
      return data?.messageCreate as IMessage
    })

export const getConversationsListAsync = () =>
  mainGqlClient
    .query({
      query: GET_CONVERSATIONS_LIST,
      fetchPolicy: 'no-cache',
    })
    .then(({ data }) => {
      return data?.conversationsList as IConversationWithoutMessages[]
    })

export const getConversationDetailsAsync = (conversationId: string) =>
  mainGqlClient
    .query({
      query: GET_CONVERSATION_DETAILS,
      variables: {
        conversationId,
      },
      fetchPolicy: 'no-cache',
    })
    .then(({ data }) => {
      return data?.conversationById as IMessage[]
    })

export const getConversationMessagesAsync = (conversationId: string) =>
  mainGqlClient
    .query({
      query: GET_CONVERSATION_MESSAGES,
      variables: {
        conversationId,
      },
      fetchPolicy: 'no-cache',
    })
    .then(({ data }) => {
      console.log({ data })
      return data?.conversationById?.messages as IMessage[]
    })

 

그런 다음 ApolloProvider 를 NextJS 앱에 등록해야 합니다. pages/_app.tsx

import '@/styles/globals.css'
import { ApolloProvider } from '@apollo/client'
import type { AppProps } from 'next/app'
import Head from 'next/head'
import { mainGqlClient } from './../helpers/gql.setup'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={mainGqlClient}>
      <Component {...pageProps} />
      <Head>
        <title>
          Demo Chat App - Built on top of NestJS & GraphQL, and NextJS
        </title>
      </Head>
    </ApolloProvider>
  )
}

 

이제 Apollo Client 를 사용해 GraphQL기본 설정을 완료했습니다.

 

다음으로 사용자를 위해 UI를 업데이트하겠습니다.

 

파일을 열고 pages/index.tsx 내용을 아래 코드로 바꿉니다.

import { useEffect, useState } from 'react'
import {
  getConversationMessagesAsync,
  getConversationsList,
  IConversationWithoutMessages,
  IMessage,
  Nullable,
  sendNewMessageAsync,
  subscribeToConversation,
} from './helpers/gql.request'

const demoUserId = 'Qo952C9SB2RtXc0Qtrx'

export default function Home() {
  return (
    <div className="shadow-lg rounded-lg flex-1 relative flex flex-col">
      <div className="px-5 py-5 flex justify-between items-center bg-white border-b-2">
        <div className="font-bold text-2xl">Demo Chat App</div>
        <div className="w-1/2 text-center text-xl font-semibold">
          Built on top of NestJS & GraphQL, and NextJS
        </div>
        <div className="h-12 font-semibold flex items-center justify-center">
          <AvatarItem className="w-12 rounded-full mr-2" />
          <span className="whitespace-nowrap">Justin Phan</span>
        </div>
      </div>
      <ConversationsBox />
    </div>
  )
}

const ConversationsBox = () => {
  const { conversations, loading } = getConversationsList()

  const [current, setCurrent] =
    useState<Nullable<IConversationWithoutMessages>>(null)

  useEffect(() => {
    if (!loading) {
      setCurrent(conversations[0])
    }
  }, [conversations])

  return (
    <div className="flex-1 flex flex-row justify-between bg-white">
      <div className="flex flex-col w-2/5 border-r-2 overflow-y-auto">
        <ConversationsList
          conversations={conversations || []}
          currentConversationId={current?.id}
          onSelectItem={(item) => {
            setCurrent(item)
          }}
        />
      </div>
      {current && (
        <ConversationDetailsBox
          conversation={current}
          onNewMessageSent={(msg) => {
            setCurrent({
              ...current,
              lastMessage: msg,
            })
          }}
        />
      )}
    </div>
  )
}

const ConversationsList = (props: {
  conversations: IConversationWithoutMessages[]
  currentConversationId: Nullable<string>
  onSelectItem: (item: IConversationWithoutMessages) => void
}) => {
  return (
    <>
      {props.conversations.map((item) => (
        <div
          key={item.id}
          onClick={() => {
            props.onSelectItem(item)
          }}
          className={`flex flex-row cursor-pointer ease-in-out duration-300 hover:bg-gray-200 py-4 px-2 justify-center items-center border-b-2 border-l-4 ${
            props.currentConversationId === item.id
              ? 'border-blue-400 bg-gray-100'
              : ''
          }`}
        >
          <div className="w-1/4">
            <AvatarItem
              className="object-cover h-12 w-12 rounded-full"
              isMySelf={false}
            />
          </div>
          <div className="d-flex w-full overflow-hidden">
            <div className="text-lg font-semibold">{item.name}</div>
          </div>
        </div>
      ))}
    </>
  )
}

const ConversationDetailsBox = (props: {
  conversation: IConversationWithoutMessages
  onNewMessageSent: (newMessage: IMessage) => void
}) => {
  const [messages, setMessages] = useState<IMessage[]>([])

  const { message: newMessage } = subscribeToConversation(props.conversation.id)

  useEffect(() => {
    if (newMessage) {
      setMessages([...messages, newMessage])
    }
  }, [newMessage])

  useEffect(() => {
    fetchMessages()
  }, [props.conversation.id])

  function fetchMessages() {
    getConversationMessagesAsync(props.conversation.id).then((res) => {
      setMessages(res)
    })
  }

  return (
    <div className="w-full px-5 flex flex-col justify-between">
      <div className="pt-3 text text-xs">
        <span>
          <span>Chat ID: </span>
          <span className="text-red-500">{props.conversation.id}</span>
        </span>
        <br />
        <span>
          <span>Your user ID: </span>
          <span className="text-red-500">{demoUserId}</span>
        </span>
      </div>
      <div className="flex flex-col mt-5">
        {!!messages &&
          messages.map((msg) => (
            <MessageItem
              key={msg.id}
              isMySelf={`${msg.senderId}` === demoUserId}
              message={msg}
            />
          ))}
      </div>
      <div className="mt-auto pb-5 w-full">
        <form
          method="POST"
          onSubmit={(e) => {
            e.preventDefault()

            const formData = new FormData(e.currentTarget)

            const content = formData.get('content')?.toString()

            const input = document.getElementById(
              'input_new_message'
            ) as HTMLInputElement

            if (content) {
              sendNewMessageAsync(
                props.conversation.id,
                demoUserId,
                content
              ).then((res) => {
                // props.onNewMessageSent(res)
              })

              input.value = ''
            }
          }}
          className="flex-1 flex flex-row justify-between bg-white"
        >
          <input
            required
            name="content"
            id="input_new_message"
            className="w-full bg-gray-300 py-5 px-3 rounded-xl"
            type="text"
            placeholder="type your message here..."
            autoComplete="off"
          />
        </form>
      </div>
    </div>
  )
}

const MessageItem = ({
  isMySelf,
  message,
}: {
  isMySelf: boolean
  message: IMessage
}) => {
  if (!message) return

  return (
    <div className={`flex ${isMySelf ? 'justify-end' : 'justify-start'} mb-4`}>
      <div
        className={`${
          isMySelf
            ? 'mr-2 bg-blue-400 rounded-bl-3xl rounded-tl-3xl rounded-tr-xl'
            : 'order-1 ml-2 bg-gray-400 rounded-br-3xl rounded-tr-3xl rounded-tl-xl'
        } text-white py-3 px-4`}
      >
        {message.content}
      </div>
      <AvatarItem
        className="object-cover h-8 w-8 rounded-full"
        isMySelf={isMySelf}
      />
    </div>
  )
}

const AvatarItem = ({
  className,
  isMySelf = true,
}: {
  className: string
  isMySelf?: boolean
}) => {
  return (
    <img
      src={
        isMySelf
          ? 'https://i.pinimg.com/564x/28/64/a9/2864a9a9976a30753c3af93f4597d513.jpg'
          : 'https://i.pinimg.com/564x/a1/06/d9/a106d9a78d1190cb521d39ec938f4033.jpg'
      }
      className={className}
      alt=""
    />
  )
}

 

앱이 UI를 개선하기 위해 전역 스타일을 업데이트합니다.

// styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

#__next {
  min-width: 100vw;
  min-height: 100vh;
  display: flex;
  flex-flow: column nowrap;
}

 

이제 아래 명령을 실행하여 프론트엔드를 시작하겠습니다.

npm run dev;

> ui@0.1.0 dev
> next dev

- ready started server on 0.0.0.0:3000, url: http://localhost:3000
- event compiled client and server successfully in 103 ms (18 modules)
- wait compiling...
- event compiled client and server successfully in 72 ms (18 modules)
- wait compiling / (client and server)...
- event compiled client and server successfully in 715 ms (442 modules)
- wait compiling...
- event compiled client and server successfully in 108 ms (442 modules)

 

" http://localhost:3000 " 링크를 열면 아래 UI가 표시됩니다.

 

2개 이상의 서로 다른 브라우저 탭에서 채팅을 열 수 있으며, 보내는 모든 메시지는 모든 탭에 즉시 표시됩니다.

 

지난  NestJS, Fastify, Apollo GraphQL & NextJS를 사용해 채팅 앱 구축(서버편)에 이어서 2편  NestJS, Fastify, Apollo GraphQL & NextJS를 사용해 채팅 앱 구축(클라이언트편)으로 nestjs, fastify, apollo graphql, nextjs 기술을 활용해 간단하게 채팅앱 구현에 대해 알아봤습니다. 따라오느라 수고하셨습니다!

300x250

관련글 더보기

댓글 영역