사용 기술

프론트엔드는 Next.js / 백엔드는 스프링을 이용하여 채팅 기능을 구현해볼 것이다.

채팅 기능에만 집중해서 만들것이며 기본적인 CORS같은건 굳이 설명하지 않는다.

이 글에는 프론트엔드 사이드 내용만 적고 백엔드 쪽은 이 글로 분리하려고 한다.

sockjs-client는 npm에서 관리가 안된지 오래 돼서 타입스크립트 사용 시 타입도 따로 추가해야한다.

따라서 types/sockjs-client 라이브러리를 추가로 넣어주도록 한다.

  • Next.js 14 / App router
  • TypeScript
  • @types/sockjs-client, sockjs-client, stompjs

채팅 기능 구현

원래는 STOMP Client를 brokerUrl을 설정해서 만들어보려다가 포기하고 SockJS를 구현체로 사용했다.

이건 백엔드 도메인을 http로 받는듯.brokerUrl은 ws://localhost:8080/ws 의 형식이다.대략적인 순서는 아래와 같다.

( + 수정 - 백엔드 쪽에서 withSockJs 설정을 제거하고 프론트 쪽에서 Stomp Client 로 만들면 된다.)

  1. useEffect로 마운트 시 STOMP Client를 만들고 소켓 연결
  2. Client 생성 시 설정해둔 onConnect 콜백이 동작하여 설정해둔 topic 구독
  3. 구독 시 설정해둔 onMessageReceived 콜백이 동작하여 구독중인 topic에서 메시지를 수신
  4. 메시지 종류에 따라 switch 문으로 분기하여 원하는 동작 실행
    본인은 메시지 출력을 위해 messages 배열에 push 하도록 함
  5. 메시지를 전송하고 싶으면 client.publish 함수를 이용하여 백엔드 엔드포인트로 메시지 전송
    백엔드 코드에서 MessageMapping("url")에 해당.
    prefix(본인은 "/app"으로 설정) + url 형식으로 엔드포인트를 설정해주면 됨

 

참고로 한 페이지에 여러 채팅방을 연결해야할 경우 소켓 연결은 부모 컴포넌트에서 한 번만 해도 된다.

자식 컴포넌트에 STOMP Client 를 전달하여 자식 컴포넌트에서 topic을 구독하는 식으로 구현한다.

"use client";

import { Client } from "@stomp/stompjs";
import { useEffect, useState } from "react";
import SockJS from "sockjs-client";

const BASE_URL = "http://localhost:8080";

type Message = { content: string; sender: string; type: string };

type SocketUnitProps = {
  id: number;
};

export default function OldSocketUnit({ id }: SocketUnitProps) {
  const [client, setClient] = useState<Client | undefined>(undefined);
  const [connected, setConnected] = useState(false);
  const [messages, setMessages] = useState<string[]>([]);

  const onMessageReceived = (res: any) => {
    const data = JSON.parse(res.body);

    const { type, content, sender } = data;
    switch (type) {
      case "JOIN":
        messages.push(`${sender}님 입장`);
        break;
      case "CHAT":
        messages.push(content);
        break;
      case "LEAVE":
        messages.push(`${sender} 퇴장`);
        break;
    }
    setMessages([...messages]);
  };

  const sendMessage = () => {
    const chatMEssage = {
      sender: "mi",
      content: "내용",
      type: "CHAT",
    };
    if (client) {
      console.log(client);
      client.publish({
        destination: `/app/chat.sendMessage/${id}`, // 백엔드의 웹소켓 엔드포인트
        headers: {},
        body: JSON.stringify(chatMEssage),
      });
    }
  };

  const disconnect = () => {
    client?.deactivate();
  };

  useEffect(() => {
    const client = new Client({
      webSocketFactory: () => {
        return new SockJS("http://localhost:8080/ws");
      },
      onConnect: () => {
        setConnected(true);
        if (client) {
          client.subscribe(`/topic/${id}`, onMessageReceived); // url의 id = chatroomId
          client.publish({
            destination: `/app/chat.addUser/${id}`, // 백엔드의 웹소켓 엔드포인트
            headers: {},
            body: JSON.stringify({ sender: "name", type: "JOIN" }),
          });
        }
      },
      onStompError: () => {
        console.log("에러 발생");
      },
      debug: function (str) {
        console.log(str);
      },
      reconnectDelay: 5000,
      heartbeatIncoming: 4000,
      heartbeatOutgoing: 4000,
    });
    setClient(client);

    try {
      console.log("[웹소켓] 연결 시도");
      client.activate();
    } catch (e) {
      console.log(e);
    }
  }, []);

  return (
    <div>
      <div>connection: {connected ? "true" : "false"}</div>
      {messages.map((message, i) => (
        <div key={i}>{message}</div>
      ))}
      <div style={{ display: "flex", flexDirection: "column" }}>
        <button
          style={{ border: "1px solid green" }}
          onClick={sendMessage}
          type="button"
        >
          메시지 보내기
        </button>
        <button style={{ border: "1px solid red" }} onClick={disconnect}>
          채팅방 입장
        </button>
      </div>
    </div>
  );
}

문제 발생

Next.js의 페이지 라우터를 쓰고 있었고, 지금껏 개발할 때는 아무 문제가 없었다.

하지만 배포를 시작하면서 빌드를 해보니 그동안 없었던 문제가 생기기 시작했다.

에러 메시지는 아래와 같았다.

// 에러 메시지
Build optimization failed: found pages without a React Component as default export in
pages/my-shop/register/components/Input/types/types.ts
pages/signin/types/types.ts
pages/signup/types/types.ts
pages/home/hooks/userRequest.ts
pages/home/hooks/useUserQuery.ts

문제 해결

결론부터 말하자면 pages 폴더 내에 리액트 컴포넌트 이외의 파일을 만들어서 생긴 문제였다.

팀 프로젝트에서 폴더 구조 및 네이밍 컨벤션을 정할 때 아래처럼 네이밍 하기로 했다.

// 컴포넌트
/pages/{페이지 이름}/components/{컴포넌트 이름}/index.tsx  

// 타입
/pages/{페이지 이름}/types/types.ts    

 

Next.js에서는 pages 폴더 내의 파일을 페이지 컴포넌트로 읽는다.

따라서 타입 파일도 페이지 컴포넌트로 읽는데, 리액트 컴포넌트가 아니기 때문에 발생한 문제였다.

그래서 페이지 컴포넌트라는걸 명시해주기 위해 아래와 같은 설정을 추가해주고 페이지 파일의 이름을 변경했다.

// 페이지 파일 이름 변경
index.tsx -> index.page.tsx

next.config.js

const nextConfig = {
  ...
  pageExtensions: ["page.tsx", "page.ts", "page.jsx", "page.js"],
  ...
}
module.exports = nextConfig;

폴더 구조

백엔드든 프론트엔드든 프로젝트 폴더 구조를 설계하는건 참 어려운것 같다.

원티드에서 주워들은걸로는 FSD 라는 설계도 있고 프로젝트마다 제각각이라 참 어렵다.

우선은 멘토링 시간에 추천받은 구조로 가고자 한다.

폴더 구조 트리

페이지 라우터를 이용하는 기준이고  멘토링 시간에 추천받은 구조는 아래와 같다. 

각 페이지에서만 사용하는것들은 페이지 폴더에서 관리하고 공용으로 사용하면 밖으로 빼기로 했다.

root
├── pages // 페이지 라우터 구분
│   ├── 페이지1
│   │   ├── components
│   │   ├── styles
│   │   ├── utils
│   │   └── hooks
│   ├── 페이지2
│   │   ├── components
│   │   ├── styles
│   │   ├── utils
│   │   └── hooks
│   ├── 페이지3
│   │   ├── components
│   │   ├── styles
│   │   ├── utils
│   │   └── hooks
│   ├── _app.tsx
│   ├── _document.tsx
│   └── index.tsx
├── components  // 공용 컴포넌트
│   ├── Modal.tsx
│   ├── Header.tsx
│   ├── Footer.tsx
│   ├── Input.tsx
│   ├── Container.tsx
│   └── Card.tsx
├── hooks // 공용 커스텀 훅
├── lib   // 공용 유틸리티 함수, 상수, API 호출 함수 등
│   ├── apis
│   ├── utils
│   ├── constants
│   └── types
├── styles
│   └── global.ts
└── public

 

15주차 숙제

이번주 숙제는 만들어둔 페이지를 URL 쿼리 스트링을 이용해 리팩토링하기였다.

 

요구사항은 아래와 같다.

  • folder/shared 페이지에서 쿼리스트링을 이용하여 라우팅한다.
  • api 응답으로 받은 accessToken를 로컬스토리지에 저장한다.

결과

이번 주는 Next.js의 내부 구현에 대해 알 수 있는 시간이었다.

SSR 방식으로 돌아가는거라 CSR인 리액트와는 다른 양상을 보인단걸 느꼈다.

Next.js 별거 없다고 생각했는데 생각보다 상태 관리에 대해 신경써야할 부분이 많은것 같다.

hydration이 별거인가 싶었는데 생각대로 동작하지 않는 부분은 이 hydration 때문이었다.

Next.js를 제대로 쓰기 위해서는 서버 사이드와 클라이언트 사이드의 상태에 대해 공부해봐야겠다.

 

이번 주에 내가 부족했던 부분은 아래와 같다.

  • 기간 내에 제출 못함
  • Next.js에 대한 이해가 부족했음
  • 서버 사이드와 클라이언트 사이드에 상태 대한 이해가 부족했음
 

14주차 숙제

이번 주 숙제는 Next.js로 로그인, 회원가입 페이지 만들기였다.

그동안 그냥 모양만 form 태그에 넣고 form 형식을 만들었다면,

이번에는 react-hook-form을 사용하여 렌더링 최적화도 하고 rhf에서 제공하는 기능들을 사용하였다.

요구 사항은 아래와 같다.

  • Next.js를 이용한 로그인, 회원가입 페이지를 구현
  • react-hook-form 사용

결과

이번 주에 시간을 많이 잡아먹은건 react-hook-form을 이용해서 form-data를 보내는 부분이었다.

많은 기능을 제공해주고 최적화까지 해주는 편리한 라이브러리지만,

처음 써보는 라이브러리라서 적응에 시간이 필요했다.

인풋 태그를 rhf에서 제공하는 register 함수로 등록해야하는데, 이 부분을 이해하기 힘들었다.

등록한 각 인풋태그의 유효성 검사나 에러처리 후 에러 메시지 설정까지 할 수 있어서 신기했다.

백엔드도 라이브러리를 쓰긴 하지만 프론트엔드는 이렇게 다양한 라이브러리들에 익숙해져야 하는것 같다.

공식 문서를 잘 읽는 버릇을 들여야지.

 

이번 주에 내가 부족했던 부분은 아래와 같다.

  • 기간 내에 제출 못함
  • 재사용성 높은 컴포넌트를 만들기가 어려웠음

13주차 숙제

이번 주 숙제는 그동안 만들었던 React 앱을 Next.js로 마이그레이션하기였다.

언젠가 한번쯤 마이그레이션 해보고 싶었는데 마침 좋은 기회가 되었다.

그동안 리팩토링 해야지 해야지 하다가 못했던 부분도 할 수 있었다.

요구 사항은 아래와 같다.

  • folder 페이지 및 shared 페이지 Next.js로 마이그레이션
  • 타입스크립트 적용하기

결과

우선 타입스크립트를 적용하느라 시간을 많이 잡아먹었다.

JS로 할 때는 생각없이 해도 잘 되던게 TS에서는 제약이 너무 많이 걸려서 힘들었다.

카카오 SDK를 이용할 때 타입스크립트 컴파일러를 속여야하는데 처음 해봐서 많이 헤맸던것 같다.

scss를 적용하면서 스타일드 컴포넌트를 쓰면서 까먹었던 부분도 상기할 수 있었다.

팀 프로젝트를 하면서 익힌 지식들로 기존 코드를 이쁘게 바꿀 수 있을것 같았던 부분들도 고쳐보았다.

해야지 해야지 하던건데 안하고 있던 부분이라서 이런 식으로 강제성을 부여받아서 좋았다.

 

이번 주에 내가 부족했던 부분은 아래와 같다.

  • 기간 내에 제출 못함
  • 리액트 쿼리, 조타이 라이브러리는 추가 했지만 써보진 못함

+ Recent posts