Next.js - 채팅 어플리케이션 구현 3편

김종민·2023년 4월 12일
0

이번에는 참가자목록 및 이미지 업로드 기능을 추가했습니다.

참가자목록

채팅방에 유저가 접속하면, 서버에서 해당 유저를 Map에 저장하여 클라이언트측으로 배열 형태로 넘겨주는 방식으로 구현했습니다.

//server 코드
//app.ts
const chatUserListMap: Map<string, string> = new Map();

io.on("connection", (socket: Socket) => {
  socket.on("userJoin", ({ userName }: { userName: string }) => {
    chatUserListMap.set(socket.id, userName);
    const chatUserListArray = Array.from(chatUserListMap, (entrty) => {
      return { userId: entrty[0], userName: entrty[1] };
    });
    io.emit("userList", chatUserListArray);
  });
}
  1. 유저의 socket.id를 key, 닉네임을 value형태로 Map에 저장
  2. 클라이언트측으로 넘겨주기 위해 배열로 변환 후 chatUserListArray에 저장
  3. io.emit("userList")를 통해 클라이언트측에 전달

서버에서 유저 리스트를 받은 후, 모달 형식으로 화면에 구현했습니다.

//Client 코드
//ChatMenu.tsx
    <aside onClick={onMenuClose}>
      <div>
        <span>참가자 목록</span>
        {chatUserList.length > 0 && (
          <>
            <li className={styles.myName}>{`${userName} (나)`}</li>
            {chatUserList
              .filter((item: ChatUserList) => item.userName !== userName)
              .map((item: ChatUserList) => {
                return <li key={item.userId}>{item.userName}</li>;
              })}
          </>
        )}
      </div>
    </aside>
  1. 서버에서 받은 chatUserList중, 본인의 이름을 filter메서드를 활용하여 제거해줍니다.
  2. 본인의 이름은 참가자 리스트 최상단에 표시해줍니다.

이미지 업로드

전 이번 프로젝트에서는 db를 사용하지않기 때문에 이미지 업로드 과정은 다음과 같이 구성했습니다.

  1. 클라이언트에서 이미지를 서버로 전송
  2. 서버에서 multer라이브러리의 diskStorage기능을 활용하여 이미지를 폴더에 저장
  3. 서버에 이미지가 저장된 경우, io.emit()을 활용하여 이미지 경로를 클라이언트에 전송
//server 코드
//app.ts
const upload = multer({
  storage: multer.diskStorage({
    destination: function (req, file, cb) {
      cb(null, __dirname + `/upload/chat`);
    },
    filename: function (req, file, cb) {
      const ext = path.extname(file.originalname);
      cb(null, path.basename(file.originalname, ext) + "-" + Date.now() + ext);
    },
  }),
});

app.post("/upload", upload.single("chatImage"), (req: Request, res: Response) => {
  const imagePath: string = req.file ? `http://localhost:8080/upload/chat/${req.file?.filename}` : "";
  const { userName } = req.body;

  io.emit("message", { userName, imagePath });

  res.status(200).send({
    status: 200,
    message: "ok",
  });
});
  1. multer라이브러리를 통해 "/upload/chat"경로로 이미지를 저장합니다.
  2. 클라이언트에서 "/upload"경로로 post요청이 들어오면 이미지를 저장하고 io.emit("message")을 통해 이미지 경로를 다시 전달합니다.
//Client 코드
//index.ts
  const onImgSubmit = async (event: ChangeEvent<HTMLInputElement>) => {
    try {
      if (event.currentTarget.files) {
        const form = new FormData();
        form.append("chatImage", event.currentTarget.files[0]);
        form.append("userName", userName);

        const res = await axios.post("http://localhost:8080/upload", form);

        if (res.status !== 200) {
          throw new Error("이미지 전송 오류");
        }
      }
    } catch (error) {
      console.log(error);
    }
  };
//ChatInput.tsx
export default function ChatInputBox({ onImgSubmit, message, setMessage, messageEnterKeyDown }: Props) {
  return (
    <div className={styles.chatInputBox}>
      <label>
        <input type="file" accept="image/*" onChange={onImgSubmit} />
        <Image width={20} height={20} alt="파일첨부 아이콘" src={"/images/plus.png"} />
      </label>
      <input
        className={styles.chatInput}
        type="text"
        placeholder="메세지를 입력해주세요"
        value={message}
        onChange={(e) => setMessage(e.currentTarget.value)}
        onKeyDown={messageEnterKeyDown}
      />
    </div>
  );
}
  1. index.ts의 onImgSubmit함수를 생성하여 ChatInput.tsx에 Props으로 전달해줍니다.
//ChatContent.tsx
export default function ChatContent({ chatMessage, onChatLeaveClick, scrollRef, onMenuOpen, userName }: Props) {
  return (
    <main className={styles.chatMessageBox}>
      <header>
        <Image width={20} height={20} alt="뒤로가기 아이콘" src={"/images/back.png"} onClick={onChatLeaveClick} />
        <Image width={20} height={20} alt="메뉴 아이콘" src={"/images/more.png"} onClick={onMenuOpen} />
      </header>
      {chatMessage.map((item: ChatMessage, index: number) => {
        return (
          <li key={index} className={item.userName === userName ? styles.myMessage : styles.otherMessage}>
            <span>{item.userName}</span>
            {item.message && <p>{item.message}</p>}
            {item.imagePath && <Image width={200} height={200} alt="채팅 이미지" src={item.imagePath || ""} />}
          </li>
        );
      })}
      <div ref={scrollRef} />
    </main>
  );
}
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  images: {
    domains: ["localhost"],
  },
};

module.exports = nextConfig;
  1. io.on("message")를 통해 전달받은 이미지를 ChatContent.tsx의 next/image 컴포넌트를 활용하여 보여줍니다.
  2. next/image에 외부 이미지를 활용하기 위해선, next.config.js에서 외부 이미지 경로를 설정해줘야 합니다.
  3. 따라서 domains:["localhost"]와 같이 외부에서 받아오는 이미지 경로를 저장합니다.

onKeyDown오류 수정

한글의 경우 onKeyDown를 통해 서버로 메세지를 전송하면,
마지막 글자가 한번 더 전송되는 오류를 발견했습니다.

구글링을 통해 확인한 결과,
한글 입력 시 글자 아래 검은 밑줄이 있는 상황에서 키보드 이벤트가 발생하면,
이벤트헨들러가 두번 호출되어 발생되는 문제라 판단되었습니다.

해당 오류를 해결하기 위해선 키보드 이벤트의 isComposing메서드를 활용할 수 있습니다.
MDN URL

isComposing가 false인 경우는 이벤트세션이 종료되는것으로 판단되므로,
false인 경우에만 메세지를 전송하면 해당 오류를 해결 가능합니다.

const messageEnterKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter" && !event.shiftKey && event.nativeEvent.isComposing === false) {
      event.preventDefault();
      onMessageSubmit();
    }
  };

전체소스코드는 아래의 저장소에서 확인 가능합니다.
깃허브 저장소 이동

profile
개발을 합시다 :)

0개의 댓글