브라우저에 www.google.com을 입력하면 무슨일이 일어나나요?

1. 사용자 입력 및 URL 분석

사용자가 www.google.com을 브라우저의 주소창에 입력 하면 브라우저는 사용자가 입력한 문자열을 분석해서 이 URL이 웹사이트 요청인지 검색어인지를 구분합니다.  만약 http://또는 https://가 생략되었다면 자동으로 추가됩니다.

2. DNS조회

브라우저, 운영체제 또는 로컬 네트워크에 www.google.com의 IP 주소가 캐시되어 있는지 확인합니다. 캐시가 없다면 ISP(인터넷 서비스 제공자)의 DNS서버에 도메인 이름을 요청합니다. DNS서버는 최종적으로 www.google.com에 해당하는 IP주소를 반환합니다. 

 

그래서 DNS가 뭔데 ..?

https://namu.wiki/w/DNS

 

DNS

Domain Name System 'DNS'는 시스템을 의미하고, 이 시스템을 운영하는 서버가 'DNS서버'다. 가

namu.wiki

3. TCP연결 

브라우저는 반환받은 IP주소와 통신하기 위해 TCP연결을 시작합니다. 클라이언트가 서버에 연결요청(SYN)을 보내고 서버는 요청을 승인하고 클라이언트는 연결확인을 보내고 연결을 완료합니다.


TCP는 또 뭔데.. ?

 

" TCP(Transmission Control Protocol)은 컴퓨터 간에 데이터를 안전하게 전송하기 위한 다중화 프로토콜이다. 한국어로는 전송제어 프로토콜이라고도 한다. TCP의 역할은 두 호스트를 연결하고 데이터 스트림을 교환하게 하고 한 컴퓨터에서 다른 컴퓨터로 전송되는 모든 데이터가 오류나 결함 없이 올바른 순서로 성공적으로 수신되도록 하게 한다. "

 

라고 구글 AI가 설명해 줍니다.

 

https://docs.tosspayments.com/resources/glossary/tcp

 

TCP(Transmission Control Protocol) | 토스페이먼츠 개발자센터

TCP(Transmission Control Protocol)는 애플리케이션 사이에서 안전하게 데이터를 통신하는 규약이에요.

docs.tosspayments.com

토스페이먼츠에서 설명해주는 정말 좋은 자료를 발견했답니다.

 

  

4. TLS/SSL 핸드셰이크 (HTTPS인 경우)

https://로 시작하면 TLS/SSL 암호화 과정이 추가됩니다. 클라이언트와 서버 간에 암호화 키를 교환하여 데이터를 안전하게 주고받을 수 있는 보안 연결을 수립합니다.

 

https://brunch.co.kr/@sangjinkang/38

 

HTTPS를 위한 SSL/TLS 핸드 셰이크 작동원리

안전한 웹을 위한 HTTPS | 인터넷에서 귀중한 내 정보를 어디론가 전달하거나, 열람하는 경우가 많습니다. 예를 들면 포털 사이트에 내 ID와 비밀번호를 입력하고 로그인을 하거나, 거래 은행 웹

brunch.co.kr

공부를 하다보니 정리가 잘된 글이 있어 가져와보았습니다. 

정처기 공부하면서 배웠던 RSA.. 비대칭.. 이런게 여기서 쓰이는 거였네요..

5. HTTP 요청

브라우저는 서버에 HTTP 요청을 보냅니다. 예를 들어 GET 메서드는 www.google.com의 메인 페이지를 요청합니다. 헤더에는 사용자 정보, 브라우저 정보, 쿠키 등이 포함됩니다.

6. 웹 서버 처리

구글의 웹 서버는 요청을 처리합니다. 서버는 요청된 리소스(HTML, CSS, JavaScript 파일 등)를 준비합니다.

데이터베이스 조회나 백엔드 로직 실행이 필요한 경우 서버에서 이를 처리합니다.

7. HTTP 응답

서버는 요청에 대한 응답을 브라우저로 보냅니다. 상태 코드에는 요청 성공 여부를 나타냅니다. 응답 데이터에는 HTML 문서, CSS, JavaScript, 이미지 파일 등이 포함됩니다.

8. 브라우저 렌더링

브라우저는 받은 HTML 파일을 기반으로 페이지를 렌더링합니다.

  1. HTML 파싱: DOM(Document Object Model) 트리를 생성합니다.
  2. CSS 파싱: CSSOM(CSS Object Model)을 생성하고 DOM과 결합하여 렌더 트리를 생성합니다.
  3. JavaScript 실행: 페이지의 동적 요소를 처리합니다.
  4. 레이아웃 및 페인팅: 화면에 요소를 배치하고 최종적으로 픽셀 단위로 렌더링합니다.

9. 추가 요청 처리

브라우저는 HTML에서 링크된 추가 리소스(CSS 파일, JavaScript 파일, 이미지 등)를 요청합니다.

이 과정은 여러 병렬 연결로 이루어져 페이지 로딩 속도를 높입니다.

10. 페이지 표시

모든 리소스를 다운로드하고 렌더링이 완료되면 사용자가 요청한 웹페이지가 화면에 표시됩니다.

 

 

여기까지 이루어지는 모든 과정은 보통 1초 이내에 완료됩니다. 인터넷의 핵심기술인 DNS, TCP/IP, HTTP, 브라우저 엔지 등이 긴밀하게 협력하여 사용자가 빠르게 웹사이트를 볼 수 있도록 돕습니다.

 

 

 

마지막의 내용은 pusher api를 활용해서 실시간으로 대화가 진행될 수 있는 기능을 만들겁니다. 

그렇다면 pusher api에 대해서 먼저 알아보겠습니다

PUSHER란 

pusher API는 실시간 통신 및 웹소켓 기반의 기술로 웹 및 모바일 애플리케이션에서 실시간 기능을 구현하는 데 널리 사용되는 서비스이다. 

pusherAPI의 주요 특징 

1. 실시간 통신 

- 클라이언트와 서버 간의 즉각적인 데이터 전송을 가능하게 한다.

- websocket 프로토콜을 기반으로 작동하며, 양방향 통신을 지원한다. 

 

2. 채널 기반 메시징 

- 특정 채널을 통해 메시지를 broadcast할 수 있다. 

- 공개, 비공개, 존재 채널 등 다양한 채널 타입을 제공한다. 

 

3. 다양한 플랫폼 지원 

- javascript, react, vue등 웹 프론트엔드 

- node.js, python, ruby, php등 백엔드 언어 

- ios, android등 모바일 플랫폼 지원

 

장점 

-websocket infrastrastructure구현이 불필요하다. 

- 확장성인 뛰어나다. 

- 다양한 프로그래밍 언어 및 프레임워크 지원이 가능하다. 

- 보안 및 인증 메커니즘을 제공한다. 

 

Pusher를 사용해보자 

pusher 공식 홈페이지에 들어가서 회원가입을 한후 api key를 발급받아야한다.  

https://pusher.com/

 

Pusher | Leader In Realtime Technologies

Simple, scalable and reliable. Hosted realtime APIs loved by developers and trusted by giants. Build live dashboards, notifications, geotracking, chat and more.

pusher.com

 

.env파일에 해당 내용들을 작성해준다.

NEXT_PUBLIC_PUSHER_APP_KEY=*******************
PUSHER_APP_ID = *******
PUSHER_SECRET = ********************

 

그리고 libs폴더에 pusher.ts파일을 만들어준다. 

import PusherServer from "pusher";
import PusherClient from "pusher-js";

export const pusherServer = new PusherServer({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_APP_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: "eu",
  useTLS: true,
});

export const pusherClient = new PusherClient(
  process.env.NEXT_PUBLIC_PUSHER_APP_KEY!,
  {
    channelAuthorization: {
      endpoint: "/api/pusher/auth",
      transport: "ajax",
    },
    cluster: "eu",
  }
);

여기서 만들어진 pusherClient와 pusherServer를 통해서 client와 server가 통신할 수 있게 해주는 역할을 한다. 

PusherServer

새로운 대화방 생성 api/conversations/route.ts

새로운 대화방을 생성한 뒤, 해당 대화방에 참여하는 모든 사용자에게 conversation:new이벤트를 전송한다.

newConversation.users.forEach((user) => {
  if (user.email) {
    pusherServer.trigger(user.email, "conversation:new", newConversation);
  }
});

1. 새로운 대화방이 생성되면 newConversation객체가 반환되고 

2. 각 사용자의 이메일 주소를 기반으로 pusher가 이벤트를 전송한다. 

3. 클라이언트는 conversation:new이벤트를 수신하고 uI를 업데이트한다. 

 

대화방이 생성되었음을 실시간으로 알림으로써 사용자간의 원활한 소통을 가능하게 한다. 

대화방 삭제 api/conversations/[conversationId]/route.ts

대화방이 삭제되면, 대화방에 참여했던 사용자들에게 conversation:remove이벤트를 전송한다. 

existingConversation.users.forEach((user) => {
  if (user.email) {
    pusherServer.trigger(user.email, "conversation:remove", existingConversation);
  }
});

1. 대화방 삭제 후 해당 대화방에 참여했던 사용자 목록을 가져온다. 

2. 각 사용자에게 conversation:remove이벤트를 트리거 한다. 

3. 클라이언트는 이벤트를 수신하여 UI에서 해당 대화방을 제거한다. 

 

삭제된 대화방이 클라이언트 UI에 남아있는 불필요한 상태를 방지한다. 

메시지 읽음 처리 api/conversations/[conversationId]/seen/route.ts

메시지를 읽을 때, Pusher를 사용하여 해당 상태를 다른 사용자에게 message:update이벤트로 알린다.

await pusherServer.trigger(conversationId!, "message:update", updatedMessage);

1. 사용자가 메시지를 읽으면 updateMessage에 읽음 상태가 업데이트 된다. 

2. 대화방 ID를 채널로 사용하여 message:update이벤트를 트리거한다. 

3. 클라이언트는 메시지의 읽음 상태를 실시간으로 반영한다. 

 

메시지 읽음 상태를 실시간으로 동기화하여, 다른 사용자가 해당 메시지가 읽혔음을 즉시 알 수 있도록 한다. 

 

대화 업데이트 알림

사용자의 이메일을 기반으로, 개인적인 대화 업데이트를 전달하기 위해 conversation: update이벤트를 사용한다. 

await pusherServer.trigger(currentUser.email, "conversation:update", {
  id: conversationId,
  messages: [updatedMessage],
});

1. 메시지 업데이트가 발생했을 때 이벤트를 트리거한다. 

2. 이메일 기반으로 특정 사용자에게 이벤트를 전송한다. 

3. 클라이언트는 이벤트를 수신해 메시지리스트를 업데이트한다. 

 

이는 클라이언트와 서버간의 동기화로 최신 대화 상태를 유지할 수 있다. 

 

새로운 메시지 전송 api/messages/route.ts

새 메시지가 생성되었을 때, 해당 메시지를 포함한 정보를 messages:new이벤트로 전송합니다.

await pusherServer.trigger(conversationId, "messages:new", newMessage);

1. conversationId는 메시지가 속한 대화를 식별한다. 

2. 클라이언트는 messages:new이벤트를 수신하여 실시간으로 새로운 메시지를 표시한다. 

 

대화상태 업데이트 

대화방의 마지막 메시지가 업데이트되었음을 대화방의 사용자들에게 알린다.

const lastMessage = updatedConversation.messages[updatedConversation.messages.length - 1];
updatedConversation.users.map((user) => {
  pusherServer.trigger(user.email!, "conversation:updata", {
    messages: [lastMessage],
  });
});

1. updatedConversation.users를 순회하며, 대화방에 참여중인 각 사용자의 email을 사용해 이벤트를 보낸다. 

2. 클라이언트는 conversation:update이벤트를 수신하여 대화 목록이나 알림을 갱신한다. 

 

PusherClient

Body

Pusher의 초기화 및 구독 

pusherClient.subscribe(conversationId);

- 사용자가 접속한 대화채널에 대해 pusher가 subscribe를 한다. 이 subscibe는 특정 대화방에서 발생하는 실시간 이벤트를 수신하기 위한 준비 단계이다. 

- conversationId는 현재 사용자가  참여중인 대화를 고유하게 식별한다. 예를들어 대화방의 ID가 123이라면 클라이언트는 123채널을 구독하게 된다. 

이벤트바인딩

새로운 메시지를 수신 

pusherClient.bind("messages:new", messageHandler);

- 이 코드는 messages:new이벤트를 구독하여 새 메시지가 서버에서 전송될 때마다 messageHandler를 실행한다. 

- 메시지 핸들러는 다음과 같이 동작한다. 

const messageHandler = (message: FullMessageType) => {
  axios.post(`/api/conversations/${conversationId}/seen`);
  setMessages((current) => {
    if (find(current, { id: message.id })) {
      return current;
    }
    return [...current, message];
  });
  bottomRef?.current?.scrollIntoView();
};

1. 서버로 읽음 상태를 전달: axios.post(...)를 호출하여 서버에 메시지가 읽혔음을 알린다. 

2. 중복 확인 후 메시지 추가 : find 함수로 메시지 목록에서 이미 존재하는 메시지인지 확인한 후, 없으면 새 메시지를 추가한다. 

3. 스크롤 이동: bottomRef. current. scrollIntroView()를 호출하여 스크롤을 최신메시지로 이동시킨다. 

 

메시지 업데이트

pusherClient.bind("message:update", updateMessageHandler);

- 기존 메시지가 업데이트 될 때 서버에서 message:update이벤트를 보내면 , 클라이언트는 updateMessageHandler를 실행하여 메시지 목록을 최신 상태로 유지한다. 

- updateMessageHandler는 다음과 같이 동작한다. 

const updateMessageHandler = (newMessage: FullMessageType) => {
  setMessages((current) =>
    current.map((currentMessage) => {
      if (currentMessage.id === newMessage.id) {
        return newMessage;
      }
      return currentMessage;
    })
  );
};

1. curren.map()을 사용하여 메시지 목록을 순회한다. 

2. 메시지 ID가 업데이트된 메시지와 동일하면 해당 메시지를 새 데이터로 교체한다. 

구독 해제 및 언 바인딩 

컴포넌트가 언마운트되거나 conversationId가 변경되면 이전 구독을 정리한다. 이를 통해 불필요한 이벤트 구독을 방지하고 메모리 누수를 막는다. 

return () => {
  pusherClient.unsubscribe(conversationId);
  pusherClient.unbind("messages:new", messageHandler);
  pusherClient.unbind("message:update", updateMessageHandler);
};

 

conversationalList

PusherClient.subscribe

pusherClient.subscribe(pusherKey);

여기서도 먼저 구독을 해야한다. 여기서 사용된 pusherKey는 사용자 이메일을 기반으로 생성고 개인화된 채널 역할을 한다. 이를 통해 자신과 관련된 실시간 이벤트를 받는다. 

Pusher 이벤트 핸들러 

pusher를 통해 서버에서 특정 이벤트를 클라이언트로 보낼때 그 이벤트를 처리하기 위한 핸들러를 설정한다. 

새로운 대화가 추가될때

const newHandler = (conversation: FullConversationType) => {
  setItems((current) => {
    if (find(current, { id: conversation.id })) {
      return current; // 이미 존재하는 대화면 그대로 유지
    }
    return [conversation, ...current]; // 새로운 대화를 맨 앞에 추가
  });
};

- 새로운 대화가 생성되었을 때 이를 items 상태에 추가한다. 

- find함수로 이미 동일한 conversation.id가 있는지 확인하고, 없다면 새로운 대화를 추가한다. 

기존 대화가 업데이트될 때

const updateHandler = (conversation: FullConversationType) => {
  setItems((current) =>
    current.map((currentConversation) => {
      if (currentConversation.id === conversation.id) {
        return {
          ...currentConversation,
          messages: conversation.messages, // 메시지 업데이트
        };
      }
      return currentConversation; // 다른 대화는 그대로 유지
    })
  );
};

- 대화의 메시지가 변경될 경우 이를 상태에 반영한다. 

- 해당 대화의 id를 기준으로 업데이트 된 내용을 상태에 적용한다. 

대화가 삭제될 때 

const removeHandler = (conversation: FullConversationType) => {
  setItems((current) => {
    return [...current.filter((convo) => convo.id !== conversation.id)]; // 삭제된 대화 제거
  });
  if (conversationId === conversation.id) {
    router.push("/conversations"); // 현재 보고 있는 대화라면 대화 목록 페이지로 이동
  }
};

- 대화가 삭제되었을 대 이를 items상태에서 제거한다. 

- 현재 열려있는 대화가 삭제된 대화와 동일하다면, 사용자를 대화 목록 페이지로 리다이렉트한다. 

pusherClient.bind

pusherClient.bind("conversation:new", newHandler);
pusherClient.bind("conversation:update", updateHandler);
pusherClient.bind("conversation:remove", removeHandler);

- pusherClient.bind를 사용하여 특정 이벤트와 이벤트 핸들러를 연결한다. 

- conversation:new, conversation:update, conversation:remove라는 이벤트가 서버에서 발생하면 ,각각 newHandler, updateHandler, removeHandler가 호출된다. 

 

정리

return () => {
  pusherClient.unsubscribe(pusherKey);
  pusherClient.unbind("conversation:new", newHandler);
  pusherClient.unbind("conversation:update", updateHandler);
  pusherClient.unbind("conversation:remove", removeHandler);
};

- 컴포넌트가 언마운트될때 호출되는 정리 함수이다. 

- 사용자가 pusherKey채널에서 구독을 해제하고 핸들러바인딩을 해제하여 메모리 누수를 방지한다. 

 

useActiveChannel

- pusher의 presence Channel은 사용자 상태를 실시간으로 추적할 수 있는 채널이다. 

- 사용자가 채널에 접속하거나 떠날 때 서버에서 member_added 및 member_removed이벤트를 발생시켜 다른 클라이언트에 이를 알린다. 

- pusher:subscription_succeeded 이벤트는 현재 채널에 접속한 사용자의 초기 목록을 제공한다. 

useActiveList훅

const { set, add, remove } = useActiveList();

- useActiveList는 외부에서 가져온 커스텀 훅으로 사용자 목록을 관리하는데 필요한 메서드를 제공한다. 

- set은 초기 사용자 목록을 설정한다. 

- add는 새로운 사용자를 추가한다. 

- remove는 사용자를 목록에서 제거한다. 

activeChannel

const [activeChannel, setActiveChannel] = useState<Channel | null>(null);

- activeChannel은 현재구독중인 Pusher Presence Channel을 저장한다. 

- 이 변수를 사용해 채널을 관리하고 채널이 이미 활성화 되어 있는 지 확인한다. 

 

presence Channel

if (!channel) {
  channel = pusherClient.subscribe("presence-messenger");
  setActiveChannel(channel);
}

- 채널이 아직 활성화되어 있지 않다면 pusherClient.subscrinbe를 통해 presence-messenger채널에 구독한다. 

- 구독이 완료되면 setActiveChannel로 channel을 상태에 저장한다. 

이벤트 핸들러

pusher:subscription_succeeded

channel.bind("pusher:subscription_succeeded", (members: Members) => {
  const initialMembers: string[] = [];
  members.each((member: Record<string, any>) =>
    initialMembers.push(member.id)
  );
  set(initialMembers);
});

- 채널 구독이 성공하면, 현재 접속중인 사용자의 목록을 가져온다. 

- members 객체는 현재 채널에 참여중인 사용자의 정보를 포함한다. 

- members.each를 사용해 사용자 ID를 하나씩 가져와 initialMembers배열에 추가한다. 

- 마지막으로 set을 호출하여 활성 사용자 목록을 초기화한다. 

pusher:member_added

channel.bind("pusher:member_added", (member: Record<string, any>) => {
  add(member.id);
});

- 새 사용자가 채널에 접속했을 때 호출된다. 

- member.id를 가져와 add(member.id)로 사용자 목록에 추가한다. 

pusher:member_removed

channel.bind("pusher:member_removed", (member: Record<string, any>) => {
  remove(member.id);
});

- 사용자가 채널을 떠났을 때 호출된다.

- member.id를 가져와 remove(member.id)로 사용자 목록에서 제거된다. 

 

정리자 

return () => {
  if (activeChannel) {
    pusherClient.unsubscribe("presence-messenger");
    setActiveChannel(null);
  }
};

- 컴포넌트가 언마운트되거나 activeChannel이 변경될 때 호출된다. 

- pusherClient.unsubscribe("presence-messenger")호출하여 presence channel구독을 해제하고 setActiveChannel로 상태를 초기화한다.

 

useActiveList

import { create } from "zustand";

interface ActiveListStore {
  members: string[];
  add: (id: string) => void;
  remove: (id: string) => void;
  set: (ids: string[]) => void;
}

const useActiveList = create<ActiveListStore>((set) => ({
  members: [],
  add: (id) => set((state) => ({ members: [...state.members, id] })),
  remove: (id) =>
    set((state) => ({
      members: state.members.filter((memberId) => memberId !== id),
    })),
  set: (ids) => set({ members: ids }),
}));
export default useActiveList;

 

이 훅은 상태관리하기 위해 사용되는 훅이다.! 

 

zustand 

zustand는 react에서 간단하고 가벼운 상태 관리를 제공하는 라이브러리이다. 독일어로 zustand가 상태를 의미하고 redux같은 상태관리 라이브러리의 대안으로 사용된다.

 

1. 가볍고 단순함 

- 복잡한 설정 없이 바로 사용할 수 있는 상태관리 라이브러리 

- 코드가 간결하고 직관적 

 

2. Flux패턴없이 동작 

- redux처럼 action, reducer, middleware가 필요하지 않다. 

- 대신 함수 기반으로 상태를 정의하고 업데이트 한다. 

 

3. react와 독립적 

- react와 함께 사용하도록 설계되어 있지만 react에 종속적이지는 않다. 

- react외부에서도 상태를 사용할 수 있다. 

 

4. 반응성 

- zustand는 사애가 변경되면 자동으로 필요한 컴포넌트를 업데이트한다. 

- 불필요한 컴포넌트 렌더링을 방지한다. 

 

5. 타입스크립트 지원 

 

 

 

GroupChatModal

일단 conversationList.tsx의 상단에 <GroupChatModal/>을 추가한다. 

이건 <MdOutlineGroupAdd>버튼을 누르면 모달이 동작하는데 그때 그룹을 설정하기 위한 모달이다.

 

 

react-hook-form

여기서도 지난번에 사용했었던 react-hook-form을 사용하여 폼 설정을 쉽게 하였다. 

register, handleSubmit, setValue, watch를 사용하였다. 

 

onSubmit함수 

폼이 제출되면 

axios.post를 통햇 /api/conversation로 폼데이터가 전송된다. 

성공시에는 router.refresh()로 데이터가 새로고침되고 onClose()를 호출해서 모달을 닫으면 된다. 

실패하면 react-hot-toast로 오류 메시지를 표시한다. 

마지막으로는 isLoading상태를 false로 설정하면 된다. 

 

Modal창

- modal컴포넌트를 사용하여 그룹 생성 폼을 감싼다. 

- isOpen상태로 모달의 표시 여부를 제어하고 onClose로 닫기 기능이 제공된다. 

 

Select컴포넌트 

이 컴포넌트는 사용자 목록에서 그룹 멤버를 선택하는 역할을 한다. 

props로 options, onChange, value값을 받는다. 

 

SELECT 컴포넌트 

이 코드는 Select라는 사용자 지정 select컴포넌트이다. react-select라이브러리를 기반으로 구성되었다. 주로 멀티 셀렉트 드롭다운을 구현하는데 사용된다. 

 

props

- label: 드롭다운상단에 표시될 레이블 텍스트이다. 

- value: 현재 선택된 값의 형식이고 react-select의 value로 전달된다. 

- onChange : 사용자가 옵션을 선택할 때 호출되는 콜백함수이다. 선택된 값을 부모 컴포넌트로 전달한다. 

- options: 드롭다운에 표시될 옵션 목록으로 각옵션은 객체 형태로 제공된다. 

- disabled: 드롭다운 활성화/ 비활성화를 제어하고 true일 경우 선택이 불가하다. react-select의 isDisabled로 전달된다.

 

ReactSelect

react-select는 React에서 드롭다운을 구현하기 위한 라이브러리 이다. 이 코드에서는 주요 기능으로 다음이 사용된다. 

1. isdisabled : 드롭다운을 비활성화 상태로 설정 , disabled prop에 따라 값이 결정된다. 

2. value와 onChange: 현재 선택된 옵션을 표시하고 변경시 onChange를 호출한다. 

3. isMulti: 멀티 셀렉트를 활성화하고 여러 옵션을 동시에 선택이 가능하다. 

4. options: 드롭다운의 옵션 목록을 정의한다. 

5. menuPortalTarget은 드롭다운의 메뉴가 특정 DOM요소에 렌더링 되도록 설정 , 이 경우 document.body를 대상으로 지정하낟. 

6. styles : menuPortal의 z-index를 직접 설정한다. 

7. classNames: react-select의 특정 스타일 클래스에 사용자 정의 클래스를 추가한다. 

 

layout

기존에 사용자 데이터가 추가되어 있지 않았던 conversation layout에 users를 추가해준다. 

const users = await getUsers();

 

AvatarGroup

대화박스가 그냥 개인이면 <Avatar>가 되지만 그룹채팅의 경우에는 <AvatarGroup>으로 표시되게 해야한다. 

AvatarGroup에는 대화 참여자의 프로필이 나열된다. 

 

conversationBox

{data.isGroup ? (
  <AvatarGroup users={data.users} />
) : (
  <Avatar user={otherUser} />

- 기존 Avatar컴포넌트가 있던 자리에 해당 조건문을 작성한다. 

 

users 속성 

- Users는 Users[]타입으로 prisma의 User객체 배열을 받는다. 각 객체는 사용자 정보를 포함한다. 

const AvatarGroup: React.FC<AvatarGroupProps> = ({ users = [] }) => {...}

slicedUsers

- users배열에서 최대 3명의 사용자만을 표시하기 위해 slice(0,3)을 사용한다. 

const slicedUsers = users.slice(0, 3);

예를 들어 users배열에 5명이 있어도 처음 3명만 표시한다. 

 

positionMap

- 각 사용자 아바타의 위치를 결정하는 객체이다. 

const positionMap = {
  0: "top-0 left-[12px]",
  1: "bottom-0",
  2: "bottom-0 right-0",
};

- index값에 따라 해당 아바타의 CSS위치가 결정된다. 

 

이미지 컴포넌트 

<Image
  alt="Avatar"
  fill
  src={user?.image || "/public/placeholder.jpg"}
/>

- image의 src는 사용자의 프로필 이미지 (user?.image)를 불러온다. 이미지가 없는 경우 placeholder를 사용한다. 

 

Header

{conversation.isGroup ? (
            <AvatarGroup users={conversation.users} />
          ) : (
            <Avatar user={otherUser} />
          )}

- header에도 다음 조건문을 추가해서 그룹일때와 개인일때를 구별해준다. 

 

Profile Drawer

프로필 드로워도 마찬가지 

<div className="mb-2">
  {data.isGroup ? (
    <AvatarGroup users={data.users} />
  ) : (
    <Avatar user={otherUser} />
  )}
</div>

- 뿐만 아니라 그룹일때는 statusText는 그룹의 총 멤버수로 설정되고 그룹이 아닐때는 Active로만 설정된다. 

- 이메일 목록에서는 그룹일때는 data.users배열의 모든 사용자 이메일이 표시되고 아닐때는 상대방 한명의 이메일 

- 그리고 Joined Date는 참여한 날짜가 표시되지 않고 그룹이 아닐때는 상대방의 가입날짜가 표시된다. 

 

ImageModal

컴포넌트는 이미지를 모달 형태로 표시하는 react컴포넌트이다. next.js의 Image컴포넌트와 사용자 정의 Modal컴포넌트를 활용하여 이미지를 표시하는 구조를 가지고 있다. 

 

이미지 유효성 확인

if (!src) {
  return null;
}

- src값이 없는 경우 null을 반환하여 아무것도 렌더링하지 않는다. 

 

모달 렌더링 

모달 컴포넌트를 사용해 모달을 열거나 닫을 수 있다. isOpen으로 열린 상태를 제어하고, onclose함수로 닫기 동작을 처리한다. 

 

이미지 표시 

<div className="w-80 h-80">
  <Image alt="Image" className="object-cover" fill src={src} />
</div>

- next.js의 Image컴포넌트를 사용하여 모달 안에 이미지를 표시한다. 이미지의 크기는 고정되어 있고 object-cover스타일을 적용해 이미지를 잘라서 표시한다. 

 

MessageBox에서 사용된 ImageModal

<ImageModal
  src={data.image}
  isOpen={imageModalOpen}
  onClose={() => setImageModalOpen(false)}
/>

- prop으로 src, isopen, onclose가 전달되고 대화창 내에서 이미지를 클릭하면 

모달이 열린다. 

 

Loading Modal

Loading Modal 컴포넌트는 사용자가 작업을 기다리는 동안 화면에 로딩 상태를 표시하기 위해 설계된 로딩 모달 컴포넌트이다. 이 컴포넌트는 Dialog와 Transition을 사용하여 모달의 애니메이션과 상태 관리를 구현하며, 로딩 스피너로 react-spinners의 ClipLoader를 활용한다. 

 

이 로딩 모달은 conversation과 user폴더에 위치 시켰다. 

 

 

지금쯤 내용이진행되니 전체적인 흐름이 이해가 되는것 같다. 대신 더 디테일한 이해들이 필요할 것으로 느껴지는 부분들이 있다고 느껴진다. 

 

 

Profile Drawer는 상단의 ... 을 클릭하면 슬라이드로 옆으로 표시되는 개인 정보 페이지 정도라고 생각하면 될거 같습니다. 

이런식으로 말이다! 

 

Headers

Header를 눌러야 저 drawer페이지가 나오게 되기 때문에 연결해준다. 

const [drawerOpen, setDrawerOpen] = useState(false);


<HiEllipsisHorizontal
          size={32}
          onClick={() => setDrawerOpen(true)}
          className="
            text-sky-500
            cursor-pointer
            hover:text-sky-600
            transition
        "
        />

이 drawerOpen을 통해 drawer의 상태를 바꿔 줄 수 있게한다. 

 

이 컴포넌트 아이콘을 클릭하게 되면 setDrawerOpen이 true가 되면서 ProfileDrawer컴포넌트가 열리게 된다. 

<ProfileDrawer
        data={conversation}
        isOpen={drawerOpen}
        onClose={() => setDrawerOpen(false)}
/>

 

 

ProfileDrawer

ProfileDrawer는 프로필 세부 정보를 표시하는 컴포넌트이다. 

- 사이드 패널은 오른쪽에서 슬라이드 형태로 열리고 닫힌다. 

- 대화 상대의 기본 정보, 가입일, 이메일 등의 세부 정보가 표시된다. 

- 삭제 버튼을 클릭하면 대화를 삭제할지 확인하는 confirmModal팝입된다. 

 

Props

interface ProfileDrawerProps {
  isOpen: boolean; // 드로어가 열려 있는지 여부
  onClose: () => void; // 드로어 닫기 함수
  data: Conversation & { users: User[] }; // 대화와 사용자 데이터
}

프롭은 다음과 같은 세가지 props를 받는다. 

 

State와 Hooks

const otherUser = useOtherUser(data); // 상대 사용자 정보 추출
const [confirmOpen, setConfirmOpen] = useState(false); // ConfirmModal 상태 관리

- useOtheruser는 현재 대화 상대를 기준으로 현재 사용자 이외의 유제 데이터를 추출하는 훅이다. 

- confirmOpen은 삭제 확인 및 모달의 상태를 관리한다. 초기값은 false이다. 

 

메모이제이션 

const joinedDate = useMemo(() => {
  return format(new Date(otherUser.createdAt), "PP");
}, [otherUser.createdAt]);

const title = useMemo(() => {
  return data.name || otherUser.name;
}, [data.name, otherUser.name]);

const statusText = useMemo(() => {
  if (data.isGroup) {
    return `${data.users.length} members`;
  }
  return "Active";
}, [data]);

- joinedDate: 상대 사용자의 가입일을 PP형식으로  포맷한다.

- title: 대화 제목을 결정합니다. 그룹대화일 경우 data.name, 개인 대화일 경우 상대방 이름(otherUser.name)을 표시한다.

- statusText: 대화 상태를 표시한다. 그룹이면 멤버수 개인이면 Active로 표시한다. 

 

삭제모달 

<ConfirmModal
  isOpen={confirmOpen}
  onClose={() => setConfirmOpen(false)}
/>

- confirmModal컴포넌트는 confirmOpen상태로 열리고 닫힌다. 

- 삭제 버튼 클릭시 setConfirmOpen(true)로 모달이 열린다. 

 

사이드 패널 UI

<Transition.Root show={isOpen} as={Fragment}>
  <Dialog as="div" className="relative z-50" onClose={onClose}>
    <Transition.Child
      as={Fragment}
      enter="ease-out duration-500"
      enterFrom="opacity-0"
      enterTo="opacity-100"
      leave="ease-in duration-500"
      leaveFrom="opacity-100"
      leaveTo="opacity-0"
    >
      <div className="fixed inset-0 bg-black bg-opacity-40"></div>
    </Transition.Child>

- Headless UI의 dialog와 Transition컴포넌트를 사용해 패널을 슬라이드 형태로 구현했다. 

- transition.root : show prop으로 컴포넌트를 표시하거나 숨긴다. 

- as={Fragment} 는 별도의 DOM요소를 추가하지 않고 자식 컴포넌트를 감싸는 역할을 한다. 

- Dialog : 접근성을 고려해 사용한다. 이 컴포넌트는 화면 리더리 자원이 기본 내장되어 있다. 

- 애니메이션으로 transition.child는 열리고 닫히는 애니메이션을 관리한다. ease-out duration-500 드로어가 나타나는 애니메이션 . ease-in duration-500: 드로어가 사라지는 애니메이션 

 

삭제버튼

<div
  onClick={() => setConfirmOpen(true)}
  className="
    flex flex-col items-center
    cursor-pointer hover:opacity-75
">
  <div
    className="
      w-10 h-10 bg-neutral-100
      rounded-full flex items-center justify-center
    "
  >
    <IoTrash size={20} />
  </div>
  <div className="text-sm font-light text-neutral-600">Delete</div>
</div>

- 쓰레기통 아이콘 클릭 시 setConfirmOpen를 호출하여 삭제 확인 모달을 연다.

- hover:opacity-75는 버튼에 호버 시 살짝 투명하게 변하게 하는 효과를 제공한다. 

 

ConfirmModal

confirm모달은 사용자가 대화를 삭제할때 요청하는 모달 창을 제공한다. 삭제 또는 취소 버튼이 제공된다. 삭제 작업이 완료되면 사용자를 /conversations페이지로 리다이렉트하고 대화 리스트를 새로 고친다.

 

Props

interface ConfirmModealProps {
  isOpen?: boolean; // 모달의 열림 상태를 나타냅니다.
  onClose: () => void; // 모달을 닫는 함수입니다.
}

- isOpen 모달이 열려있는지 여부를 결정한다. 

- onClose:사용자가 취소 버튼을 누르거나 작업을 완료 했을 때 호출되는 함수이다. 

 

삭제요청처리 

const onDelete = useCallback(() => {
  setIsLoading(true);
  axios
    .delete(`/api/conversations/${conversationId}`) // 서버로 DELETE 요청 전송
    .then(() => {
      onClose(); // 모달 닫기
      router.push("/conversations"); // 대화 목록 페이지로 이동
      router.refresh(); // 대화 목록 새로고침
    })
    .catch(() => toast.error("something went wrong!")) // 에러 메시지 표시
    .finally(() => setIsLoading(false)); // 로딩 상태 종료
}, [conversationId, router, onClose]);

 

- axios.delete: 서버 API를 호출하여 대화를 시작한다. 

- 성공하게되면 onClose를 통해 모달을 닫고 router.push로 conversations페이지로 이동한다. 그 이후에 router.refresh로 대화 목록을 새로고침하여 변경사항을 반영한다. 

- 실패시에는 toast.error를 통해 에러 메시지를 사용자에게 표시한다. 

- 작업 완료 후에는 setIsLoading(false) 로딩 상태를 종료한다. 

 

==> confirmModal은 사용자로 하여금 중요한 작업을 신중히 수행하도록 돕는 모달 컴포넌트이다. React상태관리, Next.js라우팅, axios를 통한 비동기 요청 등 다양한 기술 스택이 조화를 이루며 완성도 높은 UX를 제공한다. 

 

Modal 컴포넌트 

이 코드는 React와 headlessui/react를 사용하여 모달 창을 구현한 Modal컴포넌트이다. 이 컴포넌트는 재사용 가능한 모달창으로 children프로퍼티를 통해 원하는 내용을 동적으로 렌더링 할 수 있으며, 열기와 닫기 상태를 관리할 수 있도록 설계되어 있다. 

 

이 컴포넌트에서는 UI만 구성되어 있다.

 

이렇게 사용자를 삭제할 수 있는 모달을 만드는 것을 완성했다. 

 

SettingsModal

그리고 만든게 settingsmodal이다. 이건 개인의 프로필이나 이름 등을 교체할 수 있는 모달이다.

기본위치는 DesktopSidebar의 상단에 위치하고 있다. 

	  <SettingsModal
        currentUser={currentUser}
        isOpen={isOpen}
        onClose={() => setIsOpen}
      />
      
      <div
            onClick={() => setIsOpen(true)}
            className="
            cursor-pointer
            hover:opacity-75
            transition
          "
        >
            <Avatar user={currentUser} />
          </div>

 

props

interface SettingsMopdalProps {
  isOpen?: boolean;
  onClose: () => void;
  currentUser: User;
}

props로는 isOpen, onClose, currentUser가 사용되었다. 

 

사용된 hook

const router = useRouter();
  const [isLoading, setIsLoading] = useState(false);
  const {
    register,
    handleSubmit,
    setValue,
    watch,
    formState: { errors },
  } = useForm<FieldValues>({
    defaultValues: {
      name: currentUser?.name,
      image: currentUser?.image,
    },
  });

이 중에서 특히 useForm을 자세히 살펴보자 

- 이 훅은 폼을 관리하는데 사용된다. 

- defaultValue는 사용자의 기본값을 설정하는데 사용된다. 

- register는 입력 필드를 React hook form에 등록한다. 

- watch는 특정 필드 값을 감시하는데 여기서는 image값을 실시간으로 추적한다. 

- setValue는 폼 필드 값을 프로그래밍 적으로 업데이트 한다. 

 

handlUpload

const handleUpload = (result: any) => {
    setValue("image", result?.info?.secure_url, { shouldValidate: true });
};

- Cloudinary 업로드 버튼의 콜백으로 업로드 된 이미지의 URL을 폼 데이터의 image필드에 설정한다. 

 

onSubmit

const onSubmit: SubmitHandler<FieldValues> = (data) => {
    setIsLoading(true);
    axios.post("/api/settings", data)
        .then(() => {
            router.refresh();
            onClose();
        })
        .catch(() => toast.error("Something went wrong"))
        .finally(() => setIsLoading(false));
};

- 폼 제출시 호출되는 함수이다. 

- Axios를 사용해 /api/settings엔드포인트로 데이터를 POST요청한다. 

- 성공시 페이지를 새로고침하고 모달을 닫는다.

-실패시 오류메시지를 toast알림으로  출력한다. 

 

특정대화를 삭제하는 API

DELETE함수 

- HTTP DELETE요청을 처리하는 서버 핸들러이다. 

- 두개의 매개변수를 받는데 request는 요청객체이고 params는 요청 URL에서 전달된 파라미터이다. 여기서는 conversationId를 포함한다. 

 

 

주요 동작 

1. conversationId가져오기

const { conversationId } = params;

URL에서 conversationId추출 

 

2. 현재 사용자 확인 

const currentUser = await getCurrentUser();
if (!currentUser?.id) {
  return new NextResponse("Unauthorized", { status: 401 });
}

- 현재 로그인 된 사용자의 정보를 가져온다. 

- 사용자가 인증되지 않았다면 401상태 코드와 함께 unauthorized응답을 반환한다. 

 

3. 대화존재여부 확인 

const existingConversation = await prisma.conversation.findUnique({
  where: {
    id: conversationId,
  },
  include: {
    users: true,
  },
});

if (!existingConversation) {
  return new NextResponse("Invalid ID", { status: 400 });
}

- conversationId에 해당하는 대화를 prisma.conversation.findUnique를 사용해 검색한다. 

- 대화가 존재하지 않으면 400상태코드와 함께 invalid ID응답을 반환한다. 

 

4. 대화삭제 

const deletedConversation = await prisma.conversation.deleteMany({
  where: {
    id: conversationId,
    userIds: {
      hasSome: [currentUser.id],
    },
  },
});
return NextResponse.json(deletedConversation);

- prisma의 deleteMany메서드를 사용해 대화를 삭제한다. 

- 삭제조건으로는 conversationId가 일치하고 현재 사용자의 id가 대화 참여자 userIds에 포함되어야 한다. 

 

5. 오류처리 

catch (error: any) {
  console.log(error, "ERROR_CONVERSATION_DELETE");
  return new NextResponse("Internal Error", { status: 500 });
}

- 삭제 과정에서 발생하는 오류를 캐치하고 서버 내부 오류 메시지와 500 상태 코드를 반환한다. 

 

conversationId별로 대화페이지를 구성하고 메시지를 보낼 수 있는 그런 페이지를 만들어 볼 예정 

 

conversations안에 [conversationId]폴더를 만들고 그 안에는 components폴더와 page가 있다. 

이 [conversationId]의 존재를 이해하려면 지난 번에 만들었던 useConversation훅에 대해서 먼저 이해할 필요가 있다. 

 

useConversation.ts

이 훅은 next.js에서 동적 라우팅된 대화 ID를 추출하고 해당 대화가 활성 상태인지 확인하는 역할을 한다. 이를 통해 특정 대화와 관련된 상태나 데이터를 컴포넌트에서 쉽게 사용할 수 있다. 

 

conversationID

도데체 이 conversationId라는 녀석이 어디서 생성되는지에 대한 의문이 있었다. 

conversationID는 대화를 생성하는 과정에서 백엔드에서 conversation을 데이터베이스에 저장한다. 

그렇게 내부적으로 conversationId가 생성되는 듯했다. 다만 이 부분에 대한 이해도가 조금은 더 필요해 보이니 향후 공부해 봐야겠다 

 

URL파라미터에서 conversationId가져오기

useParams()를 사용해서 현재 라우트의 동적 세그먼트인 conversationId를 가져온다. 

예를 들어 URL이 /conversation/123인 경우 conversationId는 "123"이 된다. 

이를 통해 userconversation은 conversationId를 자동으로 추출하고 반환한다. 

getConversationById

getConversationById는 현재 로그인된 사용자의 정보를 가져와서 사용자 정보가 없는 경우 null을 반환하게 하는데 이는 인증되지 않은 사용자가 대화에 접근하지 못하도록 보호하는 로직이다.

 

대화데이터 데이터베이스에서 가져오기

const conversation = await prisma.conversation.findUnique({
  where: {
    id: conversationId,
  },
  include: {
    users: true,
  },
});

 findUnique는 conversationId로 특정 대화를 검색할 수 있는 prisma메서드이다. 

이것을 통해서 대화에 참여한 사용자 목록을 포함하여 가져온다. 예를 들어 이 users필드는 대화에 참여한 사용자들의 데이터를 제공한다. 

conversationId seen API

현재 사용자 확인 

const currentUser = await getCurrentUser();
const { conversationId } = params;

if (!currentUser?.id || !currentUser?.email) {
  return new NextResponse("Unauthorized", { status: 401 });
}

- getCurrentUser를 호출해서 현재 인증된 사용자의 정보를 가져온다. 

- 사용자가 인증되지 않았거나 id, email이 없으면 401에러가 반환된다. 

 

특정대화 조회 

const conversation = await prisma?.conversation.findUnique({
  where: {
    id: conversationId,
  },
  include: {
    messages: {
      include: {
        seen: true,
      },
    },
    users: true,
  },
});

if (!conversation) {
  return new NextResponse("Invalid ID", { status: 400 });
}

- conversationId를 사용해 데이터베이스에서 특정 대화를 조회한다. 

- messages의 모든 내용을 가져오는데 seen(해당 메시지를 읽은 사용자 목록)을 포함한다.

- 대화에 참여중인 사용자의 목록을 가져온다. 

 

마지막 메시지 확인 

const lastMessage = conversation.messages[conversation.messages.length - 1];

if (!lastMessage) {
  return NextResponse.json(conversation);
}

conversation.messages은 대화의 메시지 배열이고 배열에서 마지막 메시지인 lastMessage를 가져온다. 

대화에 메시지가 없는 경우, 대화 정보만 반환한다. 

 

마지막 메시지의 seen 상태 업데이트 

const updatedMessage = await prisma.message.update({
  where: {
    id: lastMessage.id,
  },
  include: {
    sender: true,
    seen: true,
  },
  data: {
    seen: {
      connect: {
        id: currentUser.id,
      },
    },
  },
});

- lastMessage.id를 기준으로 메시지 데이트를 업데이트한다. 

- data.seen.connect는 현재 사용자의 ID를 seen배열에 추가하고 이를 통해 사용자가 메시지를 읽었음을 기록한다. 

- 그리고 sender로 발신자 정보 seen으로 메시지를 읽은 사용자목록을 로드한다. 

 

Message API

const currentUser = await getCurrentUser();
const body = await request.json();
const { message, image, conversationId } = body;

if (!currentUser?.id || !currentUser?.email) {
  return new NextResponse("Unauthorized", { status: 401 });
}
  • **getCurrentUser()**를 통해 현재 로그인한 사용자의 정보를 가져옵니다.
  • 요청 본문에서 **message, image, conversationId**를 추출합니다.
  • 사용자 인증: currentUser?.id와 currentUser?.email이 없다면 인증되지 않은 요청으로 처리하고, 401 Unauthorized 응답을 반환합니다.

메시지 생성

const newMessage = await prisma.message.create({
  data: {
    body: message,
    image: image,
    conversation: {
      connect: {
        id: conversationId,
      },
    },
    sender: {
      connect: {
        id: currentUser.id,
      },
    },
    seen: {
      connect: {
        id: currentUser.id,
      },
    },
  },
  include: {
    seen: true,
    sender: true,
  },
});
  • prisma.message.create:
    • 새로운 메시지를 데이터베이스에 저장합니다.
    • body: 메시지 내용 (message).
    • image: 메시지에 첨부된 이미지 (선택적).
    • conversation: 메시지가 속할 대화 (conversationId로 연결).
    • sender: 메시지를 보낸 사용자 (currentUser.id로 연결).
    • seen: 메시지를 읽은 사용자 (현재 사용자를 seen 배열에 추가).
  • include 옵션을 통해:
    • seen: 메시지를 읽은 사용자 목록.
    • sender: 메시지를 보낸 사용자 정보.

대화 업데이트

const updatedConversation = await prisma.conversation.update({
  where: {
    id: conversationId,
  },
  data: {
    lastMessageAt: new Date(),
    messages: {
      connect: {
        id: newMessage.id,
      },
    },
  },
  include: {
    users: true,
    messages: {
      include: {
        seen: true,
      },
    },
  },
});
  • prisma.conversation.update:
    • **conversationId**를 사용하여 대화 정보(conversation)를 업데이트합니다.
    • lastMessageAt: 대화의 마지막 메시지 시간(new Date())을 현재 시간으로 설정하여 대화가 최근에 업데이트되었음을 표시.
    • messages: 새로운 메시지를 대화에 연결합니다.
  • include 옵션을 통해:
    • users: 대화에 참여한 사용자 목록.
    • messages: 대화의 모든 메시지와 각 메시지의 seen 상태를 포함하여 반환.

API에 대한 내용들을 통해서 데이터의 이동을 확인 해볼 수 있었다. 

conversationList의 conversationBox를 클릭하게 되면 대화창을 확인 할 수 있다. 

각각의 대화창은 header, Body, Form으로 이루어져 있다. 

useOtherUser

이 훅은 특정 대화에 참여한 사용자 중 현재 로그인한 사용자를 제외한 나머지 사용자를 반환하는 커스텀 훅이다. 

 

1. useOtherUser는 대화 객체를 매개변수로 받는다. 

2. useSession으로 현재 로그인한 사용자의 이메일을 가져온다. 

3. conversation.users에서 현재 사용자가 아닌 다른 사용자들을 필터링 한다. 

4. 필터링된 배열에서 첫 번째 사용자를 반환한다. 

 

const otherUser = useMemo(() => {
  const currentuserEmail = session?.data?.user?.email;
  const otherUser = conversation.users.filter(
    (user) => user.email !== currentuserEmail
  );
  return otherUser[0];
}, [session?.data?.user?.email, conversation.users]);

 

- 대화참여자 목록 (conversation.users) 또는 현재 사용자의 이메일 (session?.data?.eamil)이 변경될 때만 otherUser를 다시 계산한다. 

- 성능 최적화를 위해 useMemo를 사용해 불필요한 계산을 방지한다. 

- currentuserEmail은 현재 로그인한 사용자의 이메일을 세션에서 추출하는데 세션 데이터가 없거나 유효하지 않을 경우 undefined가 될 수 있다. 

- conversation.users배열에서 현재 사용자가 아닌 사용자를 필터링한다. 반환값은 currentuserEmail과 이메일이 다른 사용자들의 배열이다. 

- 필터링 된 사용자 배열의 첫번째 사용자를 반환한다.

 

Header.tsx

const otherUser = useOtherUser(conversation);

  const statusText = useMemo(() => {
    if (conversation.isGroup) {
      return `${conversation.users.length} members`;
    }

    return `Active`;
  }, [conversation]);

- otherUser는 현재 사용자를 제외한 상대방의 정보를 가져온다. 

- 상태 텍스트는 그룹대화인 경우에는 참여자 수를 반환하고 개인대화인 경우에는 Active를 반환한다. 

<Link
  className="
     lg:hidden
     block
     text-sky-500
     hover:text-sky-600
     transition
     cursor-pointer
   "
   href="/conversations"
>
 	<HiChevronLeft size={32} />
</Link>

- 저 위에 이미지를 보면 < 표시가 있는데 이걸 link컴포넌트로 감싸서 누르면 conversations로 이동할 수있도록 설정했다. 

<Avatar user={otherUser} />
        <div className="flex flex-col">
          <div>{conversation.name || otherUser.name}</div>
          <div
            className="
                text-sm
                font-light
                text-neutral-500

            "
          >
            {statusText}
          </div>
        </div>

- 제목은 conversation의 제목이나 상대방의 이름으로 설정할 수 있게 했다. 

 

Form.tsx

사용자 입력관리 

const { register, handleSubmit, setValue, formState: { errors } } = useForm<FieldValues>({
  defaultValues: { message: "" },
});

이 내용은 앞에 로그인 회원가입할때 공부했으니 이제는 이해하는게 어렵지 않다. 

 

메시지 전송 로직 

const onSubmit: SubmitHandler<FieldValues> = (data) => {
  setValue("message", "", { shouldValidate: true }); // 메시지 필드 초기화
  axios.post("/api/messages", { ...data, conversationId }); // 메시지 전송
};

- 입력된 메시지를 서버 messges api로 post요청하여 전송한다. 

-폼이 제출되면 onSubmit을 실행한다. 

 

이미지 업로드 로직 

const handleUpload = (result: any) => {
  axios.post("/api/messages", {
    image: result?.info?.secure_url,
    conversationId,
  });
};

이미지를 업로드한 결과에서 URL추출하고 이미지URL과 대화ID를 포함해 서버에 POST요청

<CldUploadButton>
  <HiPhoto size={30} className="text-sky-500" />
</CldUploadButton>

next-cloudinary의 CldUploadButton을 사용해 이미지를 업로드 

Body.tsx

useEffect(() => {
  axios.post(`/api/conversations/${conversationId}/seen`);
}, [conversationId]);

- conversationId가 변결될 때마다 실행 

- 서버에 POST요청을 보내 대화를 읽음 상태로 업데이트 

{messages.map((message, i) => (
  <MessageBox
    isLast={i === messages.length - 1}
    key={message.id}
    data={message}
  />
))}

- 메시지 배열을 map으로 순회해서 각메시지를 MessageBox로 렌더링 

- 마지막 메시지 여부를 isLast속성으로 전달

 

스크롤 기능 

ref : react에서 DOM요소나 React컴포넌트를 직접 참조할 수 있도록 제공되는 특별한 기능이다. ref는 비제어 컴포넌트에서 DOM요소의 상태를 관리하거나, 애니메이션, 포커스, 스크롤 같은 직접적인 DOM조작이 필요한 경우에 사용된다. 

 

이는 메시지 리스트 끝으로 스크롤 할때 유용하게 사용되는 스크롤 관리 기능이 있다.

const bottomRef = useRef<HTMLDivElement>(null);

<div ref={bottomRef} className="pt-24" />

 

MessageBox.tsx

이 컴포넌트는 메시지를 보여주는 역할을 하며 보낸 사람, 메시지 내용, 시간, 이미지 여부 등 다양한 정보를 포함한다. 메시지가 현재 사용자가 보낸 것인지에 따라 스타일링이 달라지며, 이미지 메시지와 텍스트 메시지도 다르게 렌더링 된다. 

 

interface 정의

interface MessageBoxProps {
  data: FullMessageType; // 메시지 관련 데이터 (보낸 사람, 본 사람, 내용 등)
  isLast?: boolean; // 이 메시지가 대화의 마지막 메시지인지 여부
}

- 메시지 박스는 data와 isLast라는 두개의 props를 받는데 data는 메시지의 주요 정보를 포함한 객체이고 isLast는 이메시지가 대화의 마지막인지 여부를 나타내는 선택적 boolean값이다. 

 

세션정보 확인 

const session = useSession();
const isOwn = session?.data?.user?.email === data?.sender?.email;

- useSession훅으로 현재 로그인 된 사용자의 세션 정보를 가져온다. 

- isOwn메시지의 보낸 사람 이메일 과 현재 사용자의 이메일을 비교해, 메시지가 현재 사용자가 보낸 것인지 판단한다. 

 

메시지 본 사람 리스트 생성 

const seenList = (data.seen || [])
  .filter((user) => user.email !== data?.sender?.email)
  .map((user) => user.name)
  .join(", ");

- data.seen배열에서 메시지를 본 사용자 정보를 가져온다. 

- 보낸 사람은 제외하고 나머지 사용자들의 이름을 콤마로 연결된 문자열로 변환한다. 

 

JSX 주요 부분 

#메시지 본문
<div className={body}>
  <div className="flex items-center gap-1">
    <div className="text-sm text-gray-500">{data.sender.name}</div>
  </div>
  <div className="text-xs text-gray-400">
    {format(new Date(data.createdAt), "p")}
  </div>
</div>

- 메시지 본문 영역에서 보낸사람의 이름은 data.sender.name이고 

- 메시지 보낸시간은 format함수로 포맷팅되어 p는 시간형식으로 나타낸다. 

#마지막 메시지 여부 
{isLast && isOwn && seenList.length > 0 && (
  <div className="text-xs font-light text-gray-500">
    {`Seen by ${seenList}`}
  </div>
)}

조건

- 이 메시지가 마지막 메시지인지 (isLast)

- 메시지가 자신이 보낸 메시지인지(isOwn)

- 다른 사용자가 메시지를 봤는지 (seenList.lengtu>0).

이 조건들을 만족하면 누가 메시지를 봤는지 표시한다. 

 

app/conversations/ 폴더에 page.tsx, layout.tsx, component폴더를 만든다. 

page.tsx에는 useConversation()훅을 통해서 isOpen을 전달받아서 페이지가 열였을 때와 닫혔을 때의 상황을 구현해준다.

"use client";

import clsx from "clsx";

import useConversation from "../hooks/useConversation";
import Emptystate from "../components/EmptyState";

const Home = () => {
  const { isOpen } = useConversation();
  return (
    <div
      className={clsx("lg:pl-80 h-full lg:block", isOpen ? "block" : "hidden")}
    >
      <Emptystate />
    </div>
  );
};
export default Home;

 그리고 lauout.tsx에 conversationlist컴포넌트를 Sidebar사이에 자리를 마련해놓는다 

<Sidebar>
	<div className="h-full">
        <ConversationList initialItems={[]} />
        {children}
    </div>
</Sidebar>

 

ConversationList

이 컴포넌트는 사용자의 대화 목록을 사이드 바 형식으로 렌더링한다. 현재 활성화 된 대화를 강조하며, 새로운 그룹을 생헝하는 버튼도 포함한다. prop으로 받는 initialtems는 초기 대화 데이터 배열로 이 데이터는 서버로부터 받아오는 대화 정보를 포함한다. 

 

대화 목록 관리

const [items, setItems] = useState(initialItems);

 

- 초기 대화 데이터를 items상태로 관리한다. 이후 새로운 대화가 추가되거나 삭제될 때 이 상태를 업데이트 할 수 있다. 

- initialItems는 FullConversationType[]으로 이건 대화의 ID, 이름, 메시지 등이 포함된 객체 배열이다. 

initialItems = [
  { id: "1", name: "Alice", messages: [...] },
  { id: "2", name: "Bob", messages: [...] }
];

 

useConversation훅을 활용한 대화 상태 관리 

const { conversationId, isOpen } = useConversation();

- useConversation()훅은 현재 활성화된 대화의 ID(conversationID)를 가져오고, 대화 창이 열려있는지 여부(isOpen)을 확인한다.

- isOpen이 true면 사이브가 숨겨지고 false이면 사이드바가 화면 전체 너비로 표시된다. 

 

대화목록 렌더링 부분

{items.map((item) => (
  <ConversationBox
    key={item.id}
    data={item}
    selected={conversationId === item.id}
  />
))}

- items.map을 통해 대화 데이터를 반복해서 ConversationBox컴포넌트를 렌더링한다. 

- conversationBox에 전달된 Props는 data는 각 대화에 대한 데이터이고 selected는 현재 대화가 선택된 대화인지 연부를 나타내는 것이다. 

 

컴포넌트의 전체 동작

- 초기 렌더링: initialItems를 기반으로 대화목록을 보여주고 현재 선택된 대화를 conversationId로 추적하여 스타일을 변경한다. 

- 대화 선택 : useConversation훅을 통해 URL에서 선택된 대화를 동적으로 가져온다. 선택된 대화는 강조 표시가 되고 대화 창이 열릴 경우 사이드 바는 숨김 처리가 된다. 

- 그룹 추가 버튼 : 버튼을 클릭하면 새로운 그룹 생성 모달을 띄울 수 있도록 확장이 가능하다. 

 

ConversationBox컴포넌트 

이 컴포넌트는 대화 목록의 각 항목을 렌더링 한다. 상대방 사용자의 정보, 마지막 메시지 내용, 읽음 여부를 표시한다.  클릭시 해당 대화 페이지로 이동한다. props으로는 data와 selected(현재 선택된 대화인지 여부를 파악)를 사용한다. 

 

useOtherUser훅

const otherUser = useOtherUser(data);

- 대화데이터에서 상대방의 정보를 추출한다. 

- 일반적으로 1:1대화라면 상대방은 data.users배열에서 로그인된 사용자가 아닌 유저로 판단한다. 

 

useSession과 useRouter

const session = useSession();
const router = useRouter();

- useSession: 현재 로그인된 사용자의 세션 정보를 가져온다. 이를 통해 로그인한 사용자의 이메일(use.email)을 확인한다. 

- useRouter: next.js의 라우팅 도구, 클릭시 해당 대화 페이지로 이동 할 수 있도록 라우터를 활용

 

클릭 이벤트 핸들러

const handleClick = useCallback(() => {
  router.push(`/conversations/${data.id}`);
}, [data.id, router]);

- 대화 박스를 클릭하면 해당 대화의 상세 페이지(/conversations/:id)로 이동할 수 있다.

- useCallback은 불필요한 재렌더링 하는 것을 방지하는 것이다.

 

마지막 메시지 정보 가져오기 

const lastMessage = useMemo(() => {
  const messages = data.messages || [];
  return messages[messages.length - 1];
}, [data.messages]);

- 대화데이터에서 가장 마지막 메시지를 추출한다. 메시지가 없으면 undefined를 반환한다. 

 

사용자 이메일 가져오기 

const userEmail = useMemo(() => {
  return session.data?.user?.email;
}, [session.data?.user?.email]);

- 현재 로그인된 사용자의 이메일을 세션 정보에서 가져온다. 

 

읽음 여부 판단 

const hasSeen = useMemo(() => {
  if (!lastMessage) return false;
  const seenArray = lastMessage.seen || [];
  if (!userEmail) return false;
  return seenArray.filter((user) => user.email === userEmail).length !== 0;
}, [userEmail, lastMessage]);

- 마지막 메시지의 seen 필드를 확인하여 사용자가 읽었는지 여부를 판단한다. 

- lastMessage.seen은 메시지를 읽은 사용자 목록을 의미한다. 

- 로그인된 사용자의 이메일이 해당 목록에 포함되어 있으면 true를 반환한다. 

 

마지막 메시지 텍스트 생성 

const lastMessageText = useMemo(() => {
  if (lastMessage?.image) {
    return "Sent an image";
  }
  if (lastMessage?.body) {
    return lastMessage.body;
  }
  return "Started a conversation";
}, [lastMessage]);

- 마지막 메시지의 내용에 따라 적절한 텍스트를 반환한다. 

- 이미지가 첨부된 메시지라면 "Sent an image"이다. 

- 메시지 본문이 존재하면 해당 텍스트를 반환한다. 

- 메시지가 없는 경우에는 "Started a conversation"

 

동적 스타일링 

className={clsx(
  `
  w-full
  relative
  flex
  items-center
  space-x-3
  hover:bg-neutral-100
  rounded-lg
  transition
  cursor-pointer
  p-3
  `,
  selected ? "bg-neutral-100" : "bg-white"
)}

- clsx는 조건에 따라 동적으로CSS클래스를  추가한다. 

- 선택된 대화(selected=true)는 배경색을 bg-neutral-100으로 설정한다. 

- 기본 상태에서는 bg-white가 적용된다. 

 

대화제목과 시간

<p className="text-md font-medium text-gray-900">
  {data.name || otherUser.name}
</p>
{lastMessage?.createdAt && (
  <p className="text-5x text-gray-400 font-light">
    {format(new Date(lastMessage.createdAt), "p")}
  </p>
)}

- 대화 제목: 대화이름(data.name)이 있으면 표시, 없으면 상대방의 이름(otherUser.name)을 표시한다.

- 마지막 메시지 시간: date-fns의 format함수를 사용하여 시간을 포맷팅한다. 

 

useConversation

이 훅은 특정 조건에 따라 새로운 대화를 생성하거나 기존 대화를 반환하는 역할을 한다. 현재 사용자 정보를 가져오는 getCurrentUser함수를 활용한다. 

 

전체 흐름

1. 현재 사용자 정보를 getCurrentUser를 호출하여 요청을 보낸 사용자의 정보를 가져온다. 

2. 요청에서 JSON데이터를 추출하여 userId,isGroup, members, name값을 가져온다.

3. 그룹대화가 true일 경우 데이터 베이스에서 새로운 그룹 대화를 생성한다. 이것은 대화의 참여자 목록에 멤버와 현재 사용자를 포함한다. 생성된 그룹 대화를 JSON형태로 반환한다. 

4. 1:1대화처리는 기존에 동일한 두 사용자가 포함된 대화가 있는지 확인 하고 이미 존재하면 해당 대화를 반환한다. 존재하지 않으면 새로운 1:1대화를 생성하고 반환한다. 

5. 처리도중 에러가 발생하면 500에러를 반환한다. 

 

현재 사용자 확인 

const currentUser = await getCurrentUser();
if (!currentUser?.id || !currentUser?.email) {
  return new NextResponse("Unauthorized", { status: 401 });
}

- 현재 사용자 정보가 없으면 권한이 없다는 의미로 401응답을 반환한다. 

- getCurrentUser는 사용자의 인증 정보를 확인하는 커스텀 함수이다. 

 

요청 본문 처리

const body = await request.json();
const { userId, isGroup, members, name } = body;

- 클라이언트에서 보낸 JSON데이터를 추출한다. 

- 주요 데이터로 userId, isGroup, members, name이다. 

 

그룹대화생성

if (isGroup && (!members || members.length < 2 || !name))
  return new NextResponse("Unauthorized", { status: 400 });

- 그룹 대화를 생성하기 위해서는 다음 조건을 만족해야한다. 

- 조건: members배열이 존재해야하고 members의 길이가 2명이상이어야하고 그룹이름이 있어야한다.

const newConversation = await prisma?.conversation.create({
  data: {
    name,
    isGroup,
    users: {
      connect: [
        ...members.map((member: { value: string }) => ({
          id: member.value,
        })),
        {
          id: currentUser.id,
        },
      ],
    },
  },
  include: {
    users: true,
  },
});
return NextResponse.json(newConversation);

- 프리즈마를 사용하여 데이터베이스에 새로운 그룹대화를 생성한다. 

- users필드는 Prisma의 관계설정으로 사용자를 연결한다. 

- 그룹멤버와 현재 사용자를 모두 연결한다. 

- 생성된 대화 데이터를 클라이언트로 반환한다. 

 

1:1 대화확인 및 생성 

const existingConversations = await prisma.conversation.findMany({
  where: {
    OR: [
      {
        userIds: {
          equals: [currentUser.id, userId],
        },
      },
      {
        userIds: {
          equals: [userId, currentUser.id],
        },
      },
    ],
  },
});
const singleConversations = existingConversations[0];
if (singleConversations) {
  return NextResponse.json(singleConversations);
}

- 기존에 현재 사용자와 상대방이 포함된 1:1대화가 있는지를 검색한다. 

- userIds필드는 사용자의 ID를 배열로 저장하고 있으며, 이를 확인한다. 

- 두사용자가 대화에 참여한 경우를 확인하기 위해 OR조건을 사용한다. 

- 기존 대화가 있으면 해당 대화를 반환한다. 

 

const newConversation = await prisma.conversation.create({
  data: {
    users: {
      connect: [
        {
          id: currentUser.id,
        },
        {
          id: userId,
        },
      ],
    },
  },
  include: {
    users: true,
  },
});
return NextResponse.json(newConversation);

- 기존 대화가 없으면 새로운 1:1대화를 생성한다. 

- 두 사용자를 연결하여 대화에 참여시킨다. 

 

getConversation

이 코드는 데이터베이스에서 현재 사용자가 참여하고 있는 대화목록을 가져오는 함수이다. prisma를 사용해 대화 데이터와 관련도니 사용자 및 메시지 정보를 포함하여 반환한다. getConversations함수는 대화 목록을 시간순으로 정렬하여 반환하며 에러가 발생하면 빈 배여을 반환한다. 

 

현재 사용자 정보 가져오기 

const currentUser = await getCurrentUser();
if (!currentUser?.id) {
  return [];
}

- getCurrentUser를 호풀하여 현재 사용자의 정보를 가져온다. 

- 현재 사용자의 ID가 없으면 빈 배열 [ ]을 반환하고 함수 실행을 종료한다. 

- 이는 인증되지 않은 사용자의 접근을 막기 위한 기본적인 방어 코드이다.

 

prisma데이터베이스 조회 

const conversations = await prisma.conversation.findMany({
  orderBy: {
    lastMessageAt: "desc",
  },
  where: {
    userIds: {
      has: currentUser.id,
    },
  },
  include: {
    users: true,
    messages: {
      include: {
        sender: true,
        seen: true,
      },
    },
  },
});

정렬조건

- lastmessageAt필드를 기준으로 내림차순으로 대화를 정렬한다. 

- 최신 메시지가 잇는 대화가 맨 위에 위치하도록 설정한다. 

대화필터링 

- userId는 대화에 참여한 사용자의 ID목록을 저장하는 필드이다. 

- 현재 사용자의 ID가 포함된 대화만 조회한다. 

포함된 데이터 설정 

- include를 사용해 대화와 관련되 추가 데이터를 함께 가져온다. 

- users: 대화에 참여한 사용자 정보 

- messages: 대화에 포함된 메시지 

그 동안 앞에 내용 복습하느라? 진도가 잘 안나갔는데 일단 빨리 빨리 진행을 해보고자 한다 

(사실 조금 늘어진 탓에 ) 

 

이번에 만들고 있는 내용은 사용자 페이지, 대화페이지 등이다. 

getUsers.ts

app/action/getUsers.ts파일을 생성한다. 

getUsers.ts는 주로 Next.js API라우트에서 호출되어 프론트엔드 애플리케이션에 사용자 데이터를 제공하는 데 사용된다.

그리고 현재 로그인한 사용자를 제외한 사용자 목록을 조회하므로, 주로 채팅, 메신저, 친구 추가 기능 등에서 활용할 수 있다. 

또한 관리자 대시보드에서도 사용할 수 있다. 

 

1. 세션 확인 

const session = await getSession();

if (!session?.user?.email) {
  return [];
}

- getSession()을 호출하여 현재 사용자의 세션 정보를 가져온다.

- 세션에 유효한 사용자 이메일이 없으면 빈 배열 []을 반환하고 함수를 종료한다.

 

2. 데이터베이스 사용자 조회 

const users = await prisma.user.findMany({
  orderBy: {
    createdAt: "desc",
  },
  where: {
    NOT: {
      email: session.user.email,
    },
  },
});

- prisma의 findMany메서드를 사용하여 사용자 테이블에서 데이터를 조회한다. 

- where조건 : 현재 로그인된 사용자의 이메일(session.user.email)과 일치하지 않는 사용자를 필터링한다.

- orderBy조건 : 사용자를 createdAt필드시준으로 내림차수 정렬한다. 

 

3. 오류처리 

} catch (error: any) {
  return [];
}

- 데이터 베이스 조회 중 오류가 발생할 경우 빈 배열을 반환

 

4. 결과반환 

 

이 함수의 사용목적은 

1. API 엔드포인트

  • getUsers는 주로 Next.js API 라우트에서 호출되어 프론트엔드 애플리케이션에 사용자 데이터를 제공하는 데 사용됩니다.
  • 예: /api/users 경로에서 사용자가 호출하면 해당 함수가 실행되어 필터링된 사용자 목록을 반환.

2. 채팅/메신저 기능

  • 현재 로그인한 사용자를 제외한 사용자 목록을 조회하므로, 주로 채팅, 메신저, 친구 추가 기능 등에서 활용됩니다.
  • 예: 로그인한 사용자가 새로운 대화를 시작하거나 친구를 검색할 때.

3. 관리자 대시보드

  • 특정 사용자 데이터를 관리하거나 분석하기 위해 관리자 패널에서 사용될 수 있습니다.
  • 예: 사용자 계정 목록을 날짜별로 정렬하여 표시.

getUser로 사용자를 정보를 받아왔다면 실제 UI를 만들어보자 

지난번에 만든 User폴더에서 component 폴더를 만든다. 

그리고 이 안에는 사용자 목록 컴포넌트를 구성할것이다.

 

그리고 본격적으로 UI를 구성해보면 다음과 같은 형태가 된다. 

 

먼저 user의 layout.tsx에 UserList컴포넌트를 넣는다.

이것은 item이라는 prop을 users로 받는다.

<Sidebar>
      <div className="h-full">
        <UserList items={users} />
        {children}
      </div>
    </Sidebar>

 

그럼 컴포넌트 내부를 둘러보자 

 

UserList

userlist는 여러 사용자를 포함하는 목록을 렌더링하며, UserBox를 반복적으로 사용해 각 사용자를 표시한다. 

interface UserListProps {
  items: User[];
}

- items를 User배열로 받는다. 이는 여러 사용자의 정보를 포함할 수 있기 때문이다. 

 

좀 더 구체적으로 이야기 하자면 UserList 컴포넌트는 사이드 바 형태로 여러 사용자를 나열하는 기능을 한다. 

이런 경우에는 사용자 정보를 개별적으로 처리하기 보다는, 한 번에 배열로 전달 받아 효율적으로 렌더링 하는 것이 일반적이다. 

<aside
  className="
    fixed
    inset-y-0
    pb-20
    lg:pb-0
    lg:left-20
    lg:w-80
    lg:block
    overflow-y-auto
    border-r
    border-gray-200
    block
    w-full
    left-0
"
>

- aside태그는 화면 왼쪽에 고정되어 표시되는 태그다. 

- 반응형 디자인: w-full로 전체 화면 너비를 사용하고, 큰 화면에서는 lg:w-80로 너비를 제한한다. 

- 스크롤 기능 : overflow-y-auto를 통해서 세로스크롤 기능이 활성화 된다. 

 

{items.map((item) => (
  <UserBox key={item.id} data={item} />
))}

- 여기서 items는 배열을 순회하면서 각 사용자에 대해 UserBox를 생성한다. 

- key값은 prisma모델에서 제공하는 고유 ID이다. 

 

 

UserBox

UserBox는 하나의 사용자를 나타내는 단위 컴포넌트이다. 

const handleClick = useCallback(() => {
  setIsLoading(true);
  axios
    .post("/api/conversations", {
      userId: data.id,
    })
    .then((data) => {
      router.push(`/conversations/${data.data.id}`);
    })
    .finally(() => setIsLoading(false));
}, [data, router]);

- 이 handleclick은 사용자가 특정 Userbox를 클릭했을 때 실행되는 클릭핸들러이다. 

이 함수는 클릭 이벤트에 반응하여 새로운 대화를 생성하고 해당 대화 화면으로 이동하는 역할을 한다. 

 

- axios.post(...) : 사용자가 Userbox를 클릭하면 해당 사용자의 ID를 포함한 POST요청을 서버 /api/conversations 엔드포인트로 보낸다. 이 요청의 목적은 선택된 사용자와의 새로운 대화를 생성하거나 이미 존재하는 대화를 가져오는 것이다. 

 

- 대화 화면으로 이동 data.data.id.를 사용하여 router.push를 호출한다. router.push는 Next.js의 클라이언트 측 라우팅 기능을 활용하여 생성된 대화의 상세 페이지로 이동한다. 

<Avatar user={data} />
      <div className="min-w-0 flex-1">
        <div className="focus:outline-none">
          <div
            className="
                flex
                justify-between
                items-center
                mb-1
            "
          >
            <p
              className="
                text-sm
                font-medium
                text-gray-900
            "
            >
              {data.name}
            </p>
          </div>
        </div>
      </div>

- 그리고 나서 사용자 정보를 기반으로 아바타 이미지를 표시하고 

사용자의 이름은 data.namd으로 표시한다. 

NextAuth.js

nextauth.js는 next.js애플리케이션에서 인증을 쉽게 구현할 수 있도록 해주는 오픈소스 라이브러리이다. OAuth, 이메일, 자격증명(credentials)등을 통해 사용자를 인증할 수 있으며, 인증세션을 관리하는 기능을 제공한다. Next.js의 API Routes와 긴밀하게 통합되어 서버리스 환경에서도 원활하게 작동한다. 

NextAuth.js의 주요 구성 요소

  1. Providers (인증 제공자)
    NextAuth.js는 다양한 인증 제공자(OAuth 제공자)를 지원한다.
    • 예: Google, GitHub, Facebook, Twitter 등
    • 또한, 자체 자격 증명(Credentials Provider)을 설정하여 커스텀 로그인 방식을 구현할 수도 있다.
  2. Session (세션 관리)
    사용자가 로그인하면 세션이 생성된다. 이 세션은 쿠키를 통해 클라이언트와 서버 간에 유지되며, 사용자의 로그인 상태를 확인하는 데 사용된다.
  3. Callbacks (콜백)
    로그인, 세션, JWT 등과 관련된 다양한 작업을 커스터마이징할 수 있도록 콜백 함수를 제공한다.
  4. Adapters
    사용자의 데이터를 데이터베이스에 저장하거나 읽어오는 작업을 처리한다. 예를 들어, MongoDB, PostgreSQL, MySQL 등을 지원한다.

NextAuth.js를 이용한 로그인 인증 방식

NextAuth 설정 파일 생성 ([...nextauth].ts)
pages/api/auth/[...nextauth].ts 또는 app/api/auth/[...nextauth]/route.ts 파일에서 인증 설정을 정의합니다.
주요 설정은 다음과 같습니다.

import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';

export default NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        token.accessToken = account.access_token;
      }
      return token;
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken;
      return session;
    },
  },
});

 

인증 과정 (OAuth 예제)

  • 1단계: 인증 제공자와 연결
    사용자가 로그인 버튼을 클릭하면 NextAuth는 설정된 OAuth 제공자(예: Google)의 로그인 페이지로 리다이렉트합니다.
  • 2단계: 인증 제공자의 인증 확인
    사용자가 이메일/비밀번호를 입력하거나, Google 계정을 승인하면 인증 제공자가 사용자 정보를 NextAuth.js로 반환합니다.
  • 3단계: JWT 토큰 생성
    NextAuth.js는 사용자 정보를 기반으로 JWT(Json Web Token)를 생성하여 사용자의 세션을 관리합니다.
  • 4단계: 세션 관리
    생성된 JWT는 쿠키에 저장되어 클라이언트와 서버 간의 통신에서 인증 상태를 유지합니다.

Custom Provider를 통한 자격 증명 인증
자격 증명 기반 인증을 구현하려면 CredentialsProvider를 설정합니다.

import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

export default NextAuth({
  providers: [
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        email: { label: "Email", type: "text" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        const user = await authenticateUser(credentials.email, credentials.password);
        if (user) {
          return user; // 사용자 정보 반환
        }
        return null; // 인증 실패
      },
    }),
  ],
});

 

인증 방식의 특징 및 동작

  1. 상태 기반 인증 (Session-based Authentication)
    세션 정보는 쿠키에 저장되며, 요청마다 클라이언트가 쿠키를 서버로 전달합니다.
    • 장점: 사용자의 인증 상태를 쉽게 유지할 수 있음.
    • 단점: 쿠키에 대한 보안 관리 필요.
  2. JWT 기반 인증 (Token-based Authentication)
    JWT를 사용하여 클라이언트와 서버 간의 인증 상태를 유지합니다.
    • 장점: 상태 비저장(Stateless) 방식으로 확장성이 높음.
    • 단점: 토큰 탈취 방지를 위한 보안 조치 필요.
  3. 콜백을 통한 데이터 처리
    인증 후 사용자 데이터를 가공하거나, 데이터베이스와 연동하는 등의 작업을 콜백에서 처리할 수 있습니다.
    예: 사용자 역할(Role) 추가, 접근 제어.

클라이언트에서 로그인/로그아웃 처리

로그인 버튼
next-auth/react의 signIn 함수 사용

import { signIn } from 'next-auth/react';

const LoginButton = () => (
  <button onClick={() => signIn('google')}>Login with Google</button>
);

 

로그아웃 버튼
signOut 함수 사용

import { signOut } from 'next-auth/react';

const LogoutButton = () => (
  <button onClick={() => signOut()}>Logout</button>
);

 

사용자 세션 확인
useSession 훅 사용

import { useSession } from 'next-auth/react';

const Profile = () => {
  const { data: session } = useSession();

  if (session) {
    return <p>Welcome, {session.user?.name}</p>;
  }

  return <p>Please log in.</p>;
};

 

 

NextAuth.js는 빠르게 인증 시스템을 구축하고자 할 때 매우 유용한 도구이며, 확장성과 커스터마이징 가능성도 뛰어납니다. 🛠️

+ Recent posts