728x90

1. 문제

Next.js 로 개발중인 Stellar Link를 Vercel 로 빌드 및 배포를 시도하였다.

그런데..... 분명 로컬 환경에서는 npm run build 를 통해 빌드가 제대로 되었는데, vecel 환경에서만 빌드가 도저히 되지 않았다.

발생했던 오류는 

[16:11:59.241] Running build in Washington, D.C., USA (East) – iad1
[16:11:59.754] Cloning github.com/heeyeon9578/Stellar-Link (Branch: main, Commit: ecadf59)
[16:12:00.080] Previous build cache not available
[16:12:01.648] Cloning completed: 1.894s
[16:12:01.981] Running "vercel build"
[16:12:02.406] Vercel CLI 39.3.0
[16:12:02.715] Installing dependencies...
[16:12:05.537] npm warn deprecated querystring@0.2.0: The querystring API is considered Legacy. new code should use the URLSearchParams API instead.
[16:12:08.829] npm warn deprecated three-mesh-bvh@0.7.8: Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.
[16:12:10.451] npm warn deprecated core-js@1.2.7: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
[16:12:20.619] 
[16:12:20.619] added 471 packages in 18s
[16:12:20.620] 
[16:12:20.620] 69 packages are looking for funding
[16:12:20.620]   run `npm fund` for details
[16:12:20.820] Detected Next.js version: 14.2.20
[16:12:20.824] Running "npm run build"
[16:12:21.041] 
[16:12:21.042] > stellar-link@0.1.0 build
[16:12:21.042] > next build
[16:12:21.042] 
[16:12:22.407] Attention: Next.js now collects completely anonymous telemetry regarding usage.
[16:12:22.408] This information is used to shape Next.js' roadmap and prioritize features.
[16:12:22.408] You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
[16:12:22.409] https://nextjs.org/telemetry
[16:12:22.409] 
[16:12:22.464]   ▲ Next.js 14.2.20
[16:12:22.464] 
[16:12:22.532]    Creating an optimized production build ...
[16:12:51.781]  ✓ Compiled successfully
[16:12:51.782]    Linting and checking validity of types ...
[16:13:03.716]    Collecting page data ...
[16:13:06.616]    Generating static pages (0/12) ...
[16:13:06.940]    Generating static pages (3/12) 
[16:13:17.843]    Generating static pages (6/12) 
[16:13:17.884]    Generating static pages (9/12) 
[16:13:17.952] ReferenceError: document is not defined
[16:13:17.956]     at createTag (/vercel/path0/.next/server/app/page.js:1:3621)
[16:13:17.956]     at /vercel/path0/.next/server/app/page.js:1:16509
[16:13:17.959]     at /vercel/path0/.next/server/app/page.js:1:16632
[16:13:17.959]     at /vercel/path0/.next/server/app/page.js:1:19457
[16:13:17.960]     at /vercel/path0/.next/server/app/page.js:1:3286
[16:13:17.960]     at 496 (/vercel/path0/.next/server/app/page.js:1:3290)
[16:13:17.960]     at t (/vercel/path0/.next/server/webpack-runtime.js:1:128)
[16:13:17.960]     at 7195 (/vercel/path0/.next/server/app/page.js:232:172578)
[16:13:17.960]     at Object.t [as require] (/vercel/path0/.next/server/webpack-runtime.js:1:128)
[16:13:17.960]     at require (/vercel/path0/node_modules/next/dist/compiled/next-server/app-page.runtime.prod.js:16:18678) {
[16:13:17.961]   digest: '362491951'
[16:13:17.961] }
[16:13:17.962] 
[16:13:17.962] Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
[16:13:17.962] 
[16:13:17.962] ReferenceError: document is not defined
[16:13:17.963]     at createTag (/vercel/path0/.next/server/app/page.js:1:3621)
[16:13:17.963]     at /vercel/path0/.next/server/app/page.js:1:16509
[16:13:17.963]     at /vercel/path0/.next/server/app/page.js:1:16632
[16:13:17.963]     at /vercel/path0/.next/server/app/page.js:1:19457
[16:13:17.963]     at /vercel/path0/.next/server/app/page.js:1:3286
[16:13:17.963]     at 496 (/vercel/path0/.next/server/app/page.js:1:3290)
[16:13:17.964]     at t (/vercel/path0/.next/server/webpack-runtime.js:1:128)
[16:13:17.964]     at 7195 (/vercel/path0/.next/server/app/page.js:232:172578)
[16:13:17.964]     at Object.t [as require] (/vercel/path0/.next/server/webpack-runtime.js:1:128)
[16:13:17.964]     at require (/vercel/path0/node_modules/next/dist/compiled/next-server/app-page.runtime.prod.js:16:18678)
[16:13:17.977]  ✓ Generating static pages (12/12)
[16:13:17.979] 
[16:13:17.982] > Export encountered errors on following paths:
[16:13:17.982] 	/page: /
[16:13:18.023] Error: Command "npm run build" exited with 1
[16:13:18.449]

 

로 결국 요점만 파악해보자면,


 

ReferenceError: document is not defined


 

인데.... document 를 사용한 코드를 다양한 방법으로 수정해봐도 도저히 고쳐지지 않았다. 

보면 알겠지만, document 를 사용한 스크립트에 'use client' 도 추가해보고, 

 

 

useEffect 에서 처음 화면 로딩시 (즉, 의존성 배열에 아무값도 안넣었을 때)

typeof document !== undefined

일때만 코드가 실행되도록 변경도 해봤다.

 

하지만.... 모두 소용없었고,,,, 진심 포기하고 싶을정도로 이유를 알기 어려웠다.

 

그러다가, 
https://bori-note.tistory.com/54

 

ReferenceError: document is not defined (feat. Next.js + lottie)

프로젝트 잘 진행하다가 dev실행했을 때 갑자기 오류가 났다.... 팀원이 lottie를 적용한 toastify를 수정 후 그 PR이 develop 브랜치에 머지 후에 갑자기 아래와 같은 오류가 발생했다.. 보니 lottie가 문

bori-note.tistory.com

해당 포스트를 접하게 되었다!

 

어?  나도 Next.js , lottie 애니메이션 사용하는데 같은 오류가 나셨잖아..?

바로 적용해보기로 했다!!!!!!

 

2. 문제 원인

바로....

import Lottie from 'lottie-react';

 

였다!!!!!

 

로티 애니메이션을 불러오는데 DOM 로드 전에 순서가 꼬인 것이다.

 

3. 해결 방법

import dynamic from 'next/dynamic';
const Lottie = dynamic(() => import('lottie-react'), { ssr: false });

 

로 변경해주었다.

이렇게 하면, 클라이언트 전용으로 설정되어 오류가 없어진다.

무사히 빌드 및 배포에 성공할 수 있었다!!!!!

 

 

728x90
728x90

 

Next.js의 router.push클라이언트 사이드 내비게이션을 수행하기 때문입니다.

클라이언트 사이드 내비게이션은 동일한 페이지 컴포넌트가 유지된 상태에서 URL만 업데이트하므로 해당 컴포넌트의 useEffect가 다시 실행되지 않습니다.

 

해결 방법

1. 강제 새로고침 사용

window.location.href를 사용하여 강제로 새로고침할 수 있습니다. 이 경우 useEffect는 실행됩니다.

const handleGoToBack = () => {
  window.location.href = '/chat';
};

 

2. 라우팅 시 상태 관리

router.push와 함께 특정 상태를 관리하거나 key를 변경하여 useEffect를 강제로 실행하도록 할 수 있습니다.

const handleGoToBack = () => {
  router.push('/chat');
  router.replace('/chat'); // `replace`를 사용해 URL을 다시 갱신
};

 

3. 라우터 이벤트 감지

Next.js의 useRouter에는 라우터 이벤트를 감지할 수 있는 방법이 있습니다. 이를 활용해 경로 변경 시 동작을 트리거할 수 있습니다.

import { useRouter } from 'next/router';

useEffect(() => {
  const handleRouteChange = (url) => {
    if (url === '/chat') {
      console.log(`
      
      
      
      
      /chat
      
      
      
      
      `);
    }
  };

  router.events.on('routeChangeComplete', handleRouteChange);

  return () => {
    router.events.off('routeChangeComplete', handleRouteChange);
  };
}, [router.events]);

 

4. 키 값으로 리렌더링 트리거

컴포넌트의 key를 변경해 강제로 재렌더링되도록 할 수도 있습니다.

const [key, setKey] = useState(0);

const handleGoToBack = () => {
  router.push('/chat');
  setKey((prev) => prev + 1); // 키 변경으로 컴포넌트 강제 리렌더링
};

// 적용
<ChatContent key={key} />;

 

 

728x90
728x90

1. Intl.DateTimeFormat 이란?

: ECMAScript Internationalization API의 일부로, 날짜와 시간을 로케일(지역) 및 특정 형식에 따라 포맷할 수 있는 기능을 제공합니다. 이를 사용하면 사용자가 선호하는 언어나 문화적 관습에 따라 날짜와 시간을 표시할 수 있습니다.

 

2. 주요 특징

1. 로케일 지원: Intl.DateTimeFormat은 다양한 로케일(ko-KR, en-US, fr-FR 등)을 지원하여 지역에 따라 맞춤화된 형식을 제공합니다.

2. 유연한 포맷 옵션: 날짜, 시간, 또는 둘 다 표시하도록 세부적으로 설정할 수 있습니다.

3. 자동화된 포맷: 복잡한 포맷팅 작업 없이 간단한 설정으로 날짜와 시간을 출력합니다.

 

3. 사용 방법

const formatter = new Intl.DateTimeFormat(locale, options);

 

- locale (옵션): 날짜 및 시간을 표현할 로케일 (예: 'en-US', 'ko-KR'). 생략 시 기본 로케일이 사용됩니다.

- options (옵션): 날짜 및 시간의 포맷을 지정하는 객체입니다.

 

4. 주요 옵션

options 객체에 포함되는 주요 속성은 다음과 같습니다.

 

날짜 관련 옵션

옵션 설명
year 'numeric', '2-digit' 연도 표시 (2025 또는 25)
month 'numeric', '2-digit', 'long', 'short', 'narrow' 월 표시 (1, 01, January, Jan, J)
day 'numeric', '2-digit' 일 표시 (6 또는 06)

 

시간 관련 옵션

옵션 설명
hour 'numeric', '2-digit' 시간 표시 (1 또는 01)
minute 'numeric', '2-digit' 분 표시 (5 또는 05)
second 'numeric', '2-digit' 초 표시 (9 또는 09)
hour12 true, false 12시간제(AM/PM) 또는 24시간제

 

기타 옵션

옵션 설명
weekday 'long', 'short', 'narrow' 요일 표시 (Monday, Mon, M)
timeZone 'UTC', 'Asia/Seoul' 등 특정 시간대를 지정
timeZoneName 'short', 'long' 시간대 이름 표시 (GMT, Greenwich Mean Time)

 

 

5. 실제 적용

- 적용전

 

- 적용후

 

728x90
728x90

1. socket.io 설치하기

npm install socket.io socket.io-client

 

2. Next.js API Route에 Socket.io 서버 설정

: pages/api/socket.ts를 생성하여 Socket.io 서버를 설정합니다.

import { Server as IOServer, Socket } from "socket.io";
import { NextApiRequest, NextApiResponse } from "next";
import { Server as HTTPServer } from "http";
import { connectDB } from "@/util/database";
import { getServerSession } from "next-auth";
import { authOptions } from "../api/auth/[...nextauth]";
import { ObjectId } from "mongodb";

type NextApiResponseWithSocket = NextApiResponse & {
  socket: {
    server: HTTPServer & {
      io?: IOServer;
    };
  };
};

interface ChatMessage {
  chatRoomId: string;
  sender: string;
  text: string;
}

let io: IOServer | undefined;

export default async function handler(req: NextApiRequest, res: NextApiResponseWithSocket) {
  if (!res.socket.server.io) {
    console.log("Socket.io server starting...");
    io = new IOServer(res.socket.server, {
      cors: {
        origin: "http://localhost:3000", // 클라이언트 URL
        methods: ["GET", "POST"],
      },
    });
    res.socket.server.io = io;

    io.on("connection", async (socket: Socket) => {
      console.log("A user connected:", socket.id);

      // 세션에서 사용자 정보 가져오기
      const session = await getServerSession(req, res, authOptions);

      if (!session) {
        console.log("Unauthorized user tried to connect");
        socket.disconnect(); // 세션 없는 사용자는 연결 차단
        return;
      }

      const client = await connectDB;
      const db = client.db("StellarLink");

      const requesterEmail = session.user?.email;
      const requester = await db.collection("user_cred").findOne({ email: requesterEmail });

      if (!requester) {
        console.log("User not found in database");
        socket.disconnect();
        return;
      }

      const requesterId = requester._id.toString(); // 사용자 ID를 문자열로 저장
      console.log(`User ${requesterId} (${requesterEmail}) connected`);

      // Join a room
      socket.on("join_room", (room: string) => {
        socket.join(room);
        console.log(`User ${requesterId} joined room ${room}`);
      });

      // Handle incoming messages
      socket.on("send_message", (data: ChatMessage) => {
        const message = {
          ...data,
          sender: requesterId, // 실제 사용자 ID를 추가
        };
        console.log("Message received:", message);
        io?.to(data.chatRoomId).emit("receive_message", message);
      });

      socket.on("disconnect", () => {
        console.log(`User ${requesterId} disconnected`);
      });
    });
  } else {
    console.log("Socket.io server already running.");
  }

  res.end();
}

- 소켓이 이미 열려있다면 재생성하지 않도록 합니다.

 

3. Socket.io 클라이언트 설정

'use client';
import { usePathname,useSearchParams } from 'next/navigation';
import Image from 'next/image';
import { useSession } from 'next-auth/react';
import { useEffect,useState } from 'react';
import { io, Socket } from "socket.io-client";

export default function Detail() {
  const { data: session, status } = useSession();
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const [chatRoomId, setChatRoomId] = useState<string | null>(null);
  const [socket, setSocket] = useState<Socket | null>(null);
  const [messages, setMessages] = useState<{ sender: string; text: string }[]>([]);
  const [input, setInput] = useState<string>("");

  // URL 파라미터에서 chatRoomId 가져오기
  useEffect(() => {
    const id = searchParams?.get("chatRoomId");
    console.log(`
      id
      `,id)
    if (id) {
      setChatRoomId(id);
    }
  }, [searchParams]);

  useEffect(() => {
    if (chatRoomId && !socket) {
      const newSocket = io("http://localhost:3000", {
        path: "/api/socket", // Next.js API Route와 연결
      });
      setSocket(newSocket);
  
      console.log("Socket initialized:", newSocket);
  
      newSocket.on("connect", () => {
        console.log("Connected to Socket.io server");
        newSocket.emit("join_room", chatRoomId);
      });
  
      newSocket.on("receive_message", (message) => {
        console.log("New message received:", message);
        setMessages((prev) => [...prev, message]);
      });
  
      return () => {
        newSocket.disconnect();
      };
    }
  }, [chatRoomId, socket]);

  const handleSendMessage = () => {
    if (socket && input.trim() && chatRoomId) {
      const message = {
        chatRoomId,
        text: input,
      };
  
      // 서버로 메시지 전송 (sender는 서버에서 처리)
      socket.emit("send_message", message);
      setMessages((prev) => [...prev, { sender: session?.user?.name|| "Me", text: input }]); // UI 업데이트
      setInput(""); // 입력 초기화
    }
  };

  return (
    <div className="w-full h-full flex items-center justify-center">
      
      {chatRoomId ?(
        <div className='text-black'>
          <div>
            <h1>Chat Room {chatRoomId}</h1>
            <div>
              {messages.map((msg, index) => (
                <div key={index}>
                  <strong>{msg.sender}</strong>: {msg.text || "No message"}
                </div>
              ))}
            </div>
            <input
              type="text"
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={(e) => e.key === "Enter" && handleSendMessage()}
            />
            <button onClick={handleSendMessage}>Send</button>
          </div>
        </div>
      ):(
        <Image
        src="/SVG/bigLogo.svg"
        alt="select"
        width={339}
        height={199}
        priority
        className=""
      />
      )}
      
    </div>
  );
}

- 우선, 채팅방 아이디가 존재하는지 검사합니다.

- 채팅방 아이디 혹은 소켓이 변경되면 소켓을 연결하고 메세지를 보낼 수 있도록 합니다.

- 이때,

const newSocket = io("http://localhost:3000", {
path: "/api/socket", // Next.js API Route와 연결
});

 

를 꼭 해줘야합니다. 

저희가 정의한 소켓 서버가 /api/socket 

에 존재하기 때문에, 꼭 path 설정을 해주세요!!! 그래야 소켓 연결가능합니다. 

(필자는 이 부분에서 한 시간 넘게 헤맸습니다 ㅜㅜㅜㅜㅜ 꼭 부탁드립니다)

 

4. 테스트하기

728x90
728x90

1. $ 연산자의 주요 분류

1.1 쿼리 연산자 ($로 시작)

: 데이터 검색에 사용됩니다. 조건을 정의하거나 특정 기준에 맞는 문서를 찾을 때 사용합니다.

연산자 설명
$eq 특정 값과 같은 문서를 찾음 (equal)
$ne 특정 값과 같지 않은 문서를 찾음 (not equal)
$gt, $gte 값이 특정 값보다 크거나 같음 (greater than, greater than or equal)
$lt, $lte 값이 특정 값보다 작거나 같음 (less than, less than or equal)
$in 배열에 포함된 값 중 하나와 일치하는 문서를 찾음
$nin 배열에 포함되지 않은 값을 가진 문서를 찾음
$exists 특정 필드의 존재 여부 확인
$regex 정규 표현식과 일치하는 문서를 찾음
$size 배열 필드의 크기가 특정 값과 같은 문서를 찾음
$all 배열에 모든 값이 포함된 문서를 찾음

예)

db.collection("users").find({ age: { $gte: 18 } });
// age가 18 이상인 사용자 검색

 

 

1.2 업데이트 연산자

  • 데이터 수정에 사용됩니다
연산자  설명
$set 필드 값을 설정하거나 추가
$unset 필드를 삭제
$inc 숫자 값을 증가/감소
$push 배열에 값을 추가
$pop 배열에서 첫 번째 또는 마지막 값을 제거
$pull 배열에서 특정 조건을 만족하는 값을 제거
$addToSet 배열에 중복되지 않게 값을 추가

 

예)

db.collection("users").updateOne({ _id: 1 }, { $set: { name: "Alice" } });
// _id가 1인 문서의 name 필드를 "Alice"로 설정

 

1.3 집계 연산자

  • 데이터 분석과 변환을 위한 연산자입니다.
연산자 설명
$sum 값을 합산
$avg 평균 값을 계산
$min, $max 최소값 또는 최대값을 계산
$group 그룹화
$match 조건에 맞는 문서를 필터링
$sort 문서를 정렬
$project 필드를 선택하거나 가공

 

예)

db.collection("sales").aggregate([
  { $group: { _id: "$category", totalSales: { $sum: "$amount" } } }
]);
// category별로 총 판매액 계산

 

1.4 플레이스홀더

  • 업데이트 또는 배열 작업에서 특정 위치를 나타내는 데 사용됩니다.
연산자 설명
$ 배열의 첫 번째 일치하는 요소를 참조
$[] 배열의 모든 요소를 참조
$[<identifier>] 배열의 특정 필터링된 요소를 참조 (Array Filters)

 

예)

db.collection("users").updateOne(
  { "tags.name": "mongodb" },
  { $set: { "tags.$.status": "updated" } }
);
// tags 배열에서 name이 'mongodb'인 첫 번째 요소의 status를 'updated'로 변경

 

728x90
728x90

1. Redux 환경 설정

1-1. Redux Toolkit 설치

npm install @reduxjs/toolkit react-redux


1-2. Redux Store 생성

: 프로젝트 루트에 store 폴더를 생성하고, store.ts 파일을 만듭니다.

// store/store.ts
import { configureStore } from "@reduxjs/toolkit";
import friendsReducer from "./friendsSlice"
import chatReducer from "./chatSlice"

const store = configureStore({ //Redux Toolkit에서 제공하는 스토어 생성 함수
  reducer: { //configureStore에 객체 형태로 전달하여, 스토어에서 사용할 리듀서를 등록합니다.
    friends: friendsReducer, 
    chat: chatReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>; //스토어의 전체 상태에 대한 타입 
//ReturnType<typeof store.getState>를 통해 스토어의 getState()가 반환하는 타입(전체 상태)을 추론하여 가져옵니다.
//컴포넌트에서 useSelector((state: RootState) => state.friends ...)처럼 사용 가능합니다.
export type AppDispatch = typeof store.dispatch;
//store.dispatch 함수의 타입입니다.
//비동기 Thunk 등을 작성할 때 타입 안정성을 높이기 위해 사용합니다.
export default store;
//생성한 스토어 인스턴스를 모듈의 기본값으로 내보냅니다.
//React에서 Provider 컴포넌트로 이 스토어를 감싸주면 전역에서 Redux 상태 사용이 가능해집니다.

 

 

1-3. friendsSlice.ts 생성

// store/friendsSlice.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
/**
 * createAsyncThunk 
 * 비동기 로직(예: API 요청)을 처리하기 위해 Redux Toolkit에서 제공하는 편의 함수입니다.
 * 첫 번째 인자로 액션 타입 문자열, 두 번째 인자로 비동기 함수를 받습니다.
 * 내부에서 fetchFriends.pending, fetchFriends.fulfilled, fetchFriends.rejected 형태의 액션이 자동으로 생성됩니다.
 */
interface Friend {
  friendId: string;
  email: string;
  name: string;
  profileImage?: string;
}

interface FriendRequest {
  fromUserEmail: string;
  fromUserName: string;
  fromUserProfileImage: string;
  status: string;
}

interface FriendsState {
  list: Friend[];
  receivedRequests: FriendRequest[];
  sentRequests: FriendRequest[];
  loading: boolean;
  error: string | null;
}

const initialState: FriendsState = {
  list: [],
  receivedRequests: [],
  sentRequests: [],
  loading: false,
  error: null,
};

// Async actions for API calls
// 실제 API 엔드포인트(/api/friends)를 호출해 친구 정보를 가져옵니다.
// 응답이 정상(ok)이 아니면 에러를 던집니다.
// 성공 시 JSON을 파싱해 Friend[] 형태로 반환합니다.
export const fetchFriends = createAsyncThunk("friends/fetchFriends", async () => {
  const response = await fetch("/api/friends");
  if (!response.ok) throw new Error("Failed to fetch friends");
  return (await response.json()) as Friend[];
});

export const fetchReceivedRequests = createAsyncThunk("friends/fetchReceivedRequests", async () => {
  const response = await fetch("/api/friends-requests");
  if (!response.ok) throw new Error("Failed to fetch received requests");
  return (await response.json()) as FriendRequest[];
});

export const fetchSentRequests = createAsyncThunk("friends/fetchSentRequests", async () => {
  const response = await fetch("/api/sent-friend-requests");
  if (!response.ok) throw new Error("Failed to fetch sent requests");
  return (await response.json()) as FriendRequest[];
});


/**
 * createSlice
 * Redux Toolkit에서 제공하는 슬라이스 생성 함수입니다.
	•	name: 슬라이스 이름. 예) "friends".
	•	initialState: 이 슬라이스가 관리할 상태의 초기 값.
	•	reducers: 동기 액션 리듀서를 정의할 수 있는 공간. 현재는 빈 객체 {}.
	•	extraReducers: createAsyncThunk를 사용한 비동기 액션에 대한 리듀서를 정의하는 공간.
 */
const friendsSlice = createSlice({
  name: "friends",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      // Fetch Friends
      .addCase(fetchFriends.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchFriends.fulfilled, (state, action) => {
        state.loading = false;
        state.list = action.payload;
      })
      .addCase(fetchFriends.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || "Failed to fetch friends";
      })

      // Fetch Received Requests
      .addCase(fetchReceivedRequests.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchReceivedRequests.fulfilled, (state, action) => {
        state.loading = false;
        state.receivedRequests = action.payload;
      })
      .addCase(fetchReceivedRequests.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || "Failed to fetch received requests";
      })

      // Fetch Sent Requests
      .addCase(fetchSentRequests.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchSentRequests.fulfilled, (state, action) => {
        state.loading = false;
        state.sentRequests = action.payload;
      })
      .addCase(fetchSentRequests.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || "Failed to fetch sent requests";
      });
  },
});

export default friendsSlice.reducer;

 

 

 

2. 전역 스토어 Provider 설정

components / ClientProvider.tsx

'use client';
import { Provider } from "react-redux";
import store from "../../../store/store";
import { SessionProvider } from 'next-auth/react';

export default function ClientProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return <SessionProvider> <Provider store={store}>{children} </Provider></SessionProvider>;
}

 

3. FriendsContent.tsx에서 Redux 훅 사용하기

핵심 변경 사항

1. useDispatch, useSelector (또는 useAppDispatch, useAppSelector)

React Redux에서 제공하는 훅으로,

useDispatch() : Redux 액션(Thunk 등)을 디스패치할 때 사용

useSelector() : Redux 스토어의 상태를 읽을 때 사용

2. 비동기 요청

기존에는 fetchData()를 통해 직접 API를 호출하고 useState에 담았지만,

이제는 fetchFriends, fetchReceivedRequests, fetchSentRequests Thunk 액션을 dispatch 한 뒤, Redux에서 관리하는 상태(friends.list, friends.receivedRequests, friends.sentRequests)를 useSelector로 가져오면 됩니다.

3. 에러 및 로딩 상태

마찬가지로 friends.loading, friends.error를 참조하면 됩니다.

import { useDispatch, useSelector } from 'react-redux';
import { fetchFriends, fetchReceivedRequests, fetchSentRequests } from '../../../../store/friendsSlice';
import { RootState } from '../../../../store/store';
import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
 
const dispatch = useAppDispatch();
// Redux store에서 필요한 상태를 꺼내옵니다.
const {
list: friends,
receivedRequests,
sentRequests,
loading,
error,
} = useSelector((state: RootState) => state.friends);

// 로컬 상태 (새로운 친구 추가용)
const [newFriendEmail, setNewFriendEmail] = useState<string>("");
useEffect(() => {
console.log("friends changed:", friends);
}, [friends]);
useEffect(() => {
// 컴포넌트가 마운트될 때, Thunk 액션을 디스패치해서 데이터 로드
dispatch(fetchFriends());
dispatch(fetchReceivedRequests());
dispatch(fetchSentRequests());

console.log(`friends`,friends)
}, [dispatch]);
728x90
728x90

1. 문제가 있던 코드 ( /api/auth/[...nextauth].js )

import { MongoDBAdapter } from "@next-auth/mongodb-adapter";
import NextAuth, { AuthOptions } from "next-auth";
import GithubProvider from "next-auth/providers/github";
import GoogleProvider from "next-auth/providers/google";
import DiscordProvider from "next-auth/providers/discord";
import CredentialsProvider from "next-auth/providers/credentials";
import { connectDB } from "@/util/database";
import { compare } from "bcryptjs";

export const authOptions: AuthOptions = {
  providers: [
    // GitHub 로그인
    GithubProvider({
      clientId: process.env.GITHUB_CLIENT_ID || "",
      clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
    }),
    // Google 로그인
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID || "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
    }),
    // Discord 로그인
    DiscordProvider({
      clientId: process.env.DISCORD_CLIENT_ID || "",
      clientSecret: process.env.DISCORD_CLIENT_SECRET || "",
    }),
    // Credentials 로그인
    CredentialsProvider({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "text" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials) {
          throw new Error("Missing credentials");
        }

        const db = (await connectDB).db("StellarLink");
        const user = await db.collection("user_cred").findOne({ email: credentials.email });

        if (!user) {
          console.log("해당 이메일은 없음");
          return null;
        }

        const isValidPassword = await compare(credentials.password, user.password);
        console.log(isValidPassword: , isValidPassword);

        if (!isValidPassword) {
          console.log("비밀번호 틀림");
          return null;
        }

        // 로그인 성공
        console.log("로그인 성공", user);
        return {
          id: user._id.toString(),
          name: user.name,
          email: user.email,
          profileImage: user.profileImage || "/default-profile.png", // 기본 프로필 이미지 설정
        };
      },
    }),
  ],
  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60, // 30일
  },
  callbacks: {
    
    jwt: async ({ token, user, account }) => {
      if (user && account) {
        token.user = {
          name: user.name,
          email: user.email,
          profileImage: user.profileImage,
          provider: account.provider, // provider 정보 추가
        };
      }
      return token;
    },
    session: async ({ session, token }) => {
      if (token.user) {
        session.user = token.user as {
          name: string;
          email: string;
          profileImage: string;
          provider: string; // provider 정보 추가
        };
      }
      return session;
    },
  },
  secret: process.env.NEXTAUTH_SECRET, //CSRF 공격 방지
  adapter: MongoDBAdapter(connectDB),
};

export default NextAuth(authOptions);

 

2. authOptions 코드에서 세션이 즉시 업데이트되지 않는 이유

callbacks.session이 클라이언트에서 호출될 때 token.user에 저장된 데이터를 사용하기 때문입니다.
그러나 데이터베이스에서 변경된 값을 가져오지 않으면 갱신된 정보를 반영할 수 없습니다.
세션 정보를 즉시 갱신하려면 callbacks.session 또는 callbacks.jwt 에서 데이터베이스와 동기화하도록 설정해야 합니다.

 

3. 수정된 코드

import { MongoDBAdapter } from "@next-auth/mongodb-adapter";
import NextAuth, { AuthOptions } from "next-auth";
import GithubProvider from "next-auth/providers/github";
import GoogleProvider from "next-auth/providers/google";
import DiscordProvider from "next-auth/providers/discord";
import CredentialsProvider from "next-auth/providers/credentials";
import { connectDB } from "@/util/database";
import { compare } from "bcryptjs";

interface UserToken {
  name: string;
  email: string;
  profileImage: string;
  provider: string;
}

export const authOptions: AuthOptions = {
  providers: [
    // GitHub 로그인
    GithubProvider({
      clientId: process.env.GITHUB_CLIENT_ID || "",
      clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
    }),
    // Google 로그인
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID || "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
    }),
    // Discord 로그인
    DiscordProvider({
      clientId: process.env.DISCORD_CLIENT_ID || "",
      clientSecret: process.env.DISCORD_CLIENT_SECRET || "",
    }),
    // Credentials 로그인
    CredentialsProvider({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "text" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials) {
          throw new Error("Missing credentials");
        }

        const db = (await connectDB).db("StellarLink");
        const user = await db.collection("user_cred").findOne({ email: credentials.email });

        if (!user) {
          console.log("해당 이메일은 없음");
          return null;
        }

        const isValidPassword = await compare(credentials.password, user.password);
        console.log(`isValidPassword: `, isValidPassword);

        if (!isValidPassword) {
          console.log("비밀번호 틀림");
          return null;
        }

        // 로그인 성공
        console.log("로그인 성공", user);
        return {
          id: user._id.toString(),
          name: user.name,
          email: user.email,
          profileImage: user.profileImage || "/default-profile.png", // 기본 프로필 이미지 설정
        };
      },
    }),
  ],
  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60, // 30일
  },
  callbacks: {
    jwt: async ({ token, user, account }) => {
      if (user && account) {
        token.user = {
          name: user.name,
          email: user.email,
          profileImage: user.profileImage,
          provider: account.provider,
        } as UserToken; // 타입 캐스팅
      }
      return token;
    },
    session: async ({ session, token }) => {
      if (token.user) {
        const db = (await connectDB).db("StellarLink");
        const user = await db.collection("user_cred").findOne({ email: (token.user as UserToken).email });
  
        if (user) {
          session.user = {
            name: user.name,
            email: user.email,
            profileImage: user.profileImage || "/default-profile.png", // 기본 프로필 이미지 설정
            provider: (token.user as UserToken).provider, // 기존 provider 유지
          };
        }
      }
      return session;
    },
  },
  secret: process.env.NEXTAUTH_SECRET, // CSRF 공격 방지
  adapter: MongoDBAdapter(connectDB),
};

export default NextAuth(authOptions);

 

실질적으로 수정된 코드

callbacks: {
    jwt: async ({ token, user, account }) => {
      if (user && account) {
        token.user = {
          name: user.name,
          email: user.email,
          profileImage: user.profileImage,
          provider: account.provider,
        } as UserToken; // 타입 캐스팅
      }
      return token;
    },
    session: async ({ session, token }) => {
      if (token.user) {
        const db = (await connectDB).db("StellarLink");
        const user = await db.collection("user_cred").findOne({ email: (token.user as UserToken).email });
  
        if (user) {
          session.user = {
            name: user.name,
            email: user.email,
            profileImage: user.profileImage || "/default-profile.png", // 기본 프로필 이미지 설정
            provider: (token.user as UserToken).provider, // 기존 provider 유지
          };
        }
      }
      return session;
    },
  },

- 세션 콜백에 데이터베이스에서 값을 받아오는 코드를 새로 작성

728x90
728x90

1. next-auth 라이브러리 다운로드 받고 데이터베이스 설정하기

1-1. next-auth 라이브러리 다운로드

우선 NextAuth 패키지를 설치해야 합니다. 터미널에서 아래 명령어를 실행하세요:

 
npm install next-auth

 

또는 yarn을 사용한다면:
yarn add next-auth

 

1-2. mongodb 데이터베이스 설치 및 설정하기

MongoDB와 NextAuth를 연결하려면, 아래 패키지를 추가로 설치해야 합니다:

npm install mongodb @next-auth/mongodb-adapter

 

또는 yarn을 사용한다면:

yarn add mongodb @next-auth/mongodb-adapter

 

1-3. mongodb 페이지에서 데이터베이스 생성 및 콜렉션 생성하기

 

- 제가 사용한 데이터베이스는 StellarLink 입니다. 

- 원하는 이름으로 생성하면 됩니다.

 

- Network Accecss 탭에서 사용할 주소를 입력하여 줍니다.

 

1-4. mongodb 연결 코드 작성하기

프로젝트 상단에  /util/database.ts 파일을 생성하고 MongoDB 연결 코드를 작성합니다.

import { MongoClient } from 'mongodb';

const url = process.env.DATABASE_KEY as string;

if (!url) {
  throw new Error('Please define the DATABASE_KEY environment variable in .env');
}

// TypeScript에서 글로벌 객체 정의
declare global {
  // 글로벌 변수 `_mongo`가 Node.js 전역에 존재하지 않는다면 선언
  // Node.js 환경에만 존재하도록 정의
  var _mongo: Promise<MongoClient> | undefined;
}

let connectDB: Promise<MongoClient>;

if (process.env.NODE_ENV === 'development') {
  // 개발 환경에서 전역 객체를 재사용
  if (!global._mongo) {
    global._mongo = new MongoClient(url).connect();
  }
  connectDB = global._mongo;
} else {
  // 프로덕션 환경에서는 새로 연결
  connectDB = new MongoClient(url).connect();
}

export { connectDB };

 

- process.env.DATABASE_KEY 는 .env 파일에 저장된 값입니다.

이때,
process.env. 어쩌구 

 

인 경우 환경변수 파일에 작성되어 있다는 뜻입니다. 

이는 보통 보호해야하는 값일 경우 사용됩니다. 추후에 배포할때도 신경써서 작성해야합니다.

 

프로젝트 상단에 .env 파일을 만들어서 

다음과 같이 적습니다. 

해당 키는 몽고 디비 사이트에서 얻을 수 있습니다.

- Overview 에서 ClustersConnect 버튼을 찾아 클릭합니다.

- Drivers 를 클릭합니다.

 

- 빨간 부분의 값을  .env 파일의

DATABASE_KEY =어쩌구

로 넣으시면 됩니다.

 

 

이때, admin:<db_password> 의 db_password부분을 제대로 바꿔서 넣습니다!

 

2. NextAuth 설정 파일 생성하기 (api 제작하기)

2-1. /pages/api/auth/[...nextauth].ts 파일을 생성하기

import { MongoDBAdapter } from "@next-auth/mongodb-adapter";
import NextAuth from "next-auth";
import GithubProvider from "next-auth/providers/github";
import {connectDB} from '@/util/database';
import CredentialsProvider from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google"; // GoogleProvider 추가
import DiscordProvider from "next-auth/providers/discord";
export const authOptions = {
  providers: [
    // GitHub 로그인
    GithubProvider({
      clientId: process.env.GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_CLIENT_SECRET,
     
    }),
     // Google 로그인
     GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),

    //Discord 로그인
    DiscordProvider({
      clientId: process.env.DISCORD_CLIENT_ID || "",
      clientSecret: process.env.DISCORD_CLIENT_SECRET || "",
    }),
    CredentialsProvider({
      //1. 로그인페이지 폼 자동생성해주는 코드 
      name: "credentials",
        credentials: {
          email: { label: "email", type: "text" },
          password: { label: "password", type: "password" },
      },
       //2. 로그인요청시 실행되는코드
      //직접 DB에서 아이디,비번 비교하고 
      //아이디,비번 맞으면 return 결과, 틀리면 return null 해야함
      async authorize(credentials) {
        let db = (await connectDB).db('StellarLink');
        let user = await db.collection('user_cred').findOne({email : credentials.email})
        if (!user) {
          console.log('해당 이메일은 없음');
          return null
        }
        const bcrypt = require('bcryptjs');
        const isValidPassword = await bcrypt.compare(credentials.password, user.password);
        console.log(`isValidPassword: `, isValidPassword)
        if (!isValidPassword) {
          console.log('비밀번호 틀림');
          return null;
        }
      
        // 로그인 성공
        console.log('로그인 성공', user);
        return user;
        
      }
    })

  ],
   //3. jwt 써놔야 잘됩니다 + jwt 만료일설정
   session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60 //30일
  },


  callbacks: {
    //4. jwt 만들 때 실행되는 코드 
    //user변수는 DB의 유저정보담겨있고 token.user에 뭐 저장하면 jwt에 들어갑니다.
    jwt: async ({ token, user }) => {
      if (user) {
        token.user = {};
        token.user.name = user.name
        token.user.email = user.email
      }
      return token;
    },
    //5. 유저 세션이 조회될 때 마다 실행되는 코드
    session: async ({ session, token }) => {
      session.user = token.user;  
      return session;
    },
  },
  secret : process.env.NEXTAUTH_SECRET,
  adapter:MongoDBAdapter(connectDB)
};
export default NextAuth(authOptions);

 

- import GoogleProvider from "next-auth/providers/google"; 를 통해 구글 로그인을 설정할 수 있습니다.

- import GithubProvider from "next-auth/providers/github"; 를 통해 github 로그인을 설정할 수 있습니다.

- import DiscordProvider from "next-auth/providers/discord"; 를 통해 디스코드 로그인을 설정할 수 있습니다.

- import CredentialsProvider from "next-auth/providers/credentials";를 통해 로컬 로그인을 구현할 수 있습니다.

 

2-1-1. 여기서 secret : process.env.NEXTAUTH_SECRET? 

1. JWT 토큰 서명

NextAuth는 기본적으로 JWT (JSON Web Token)을 사용하여 세션 정보를 저장합니다.
JWT에는 중요한 사용자 정보가 포함될 수 있으므로, 서명을 통해 토큰의 무결성을 보장해야 합니다.

NEXTAUTH_SECRET은 이러한 JWT의 서명을 위한 비밀 키로 사용됩니다.
이 키가 설정되지 않으면 NextAuth는 JWT 서명 및 검증이 불가능합니다.

 

2. CSRF 보호

NextAuth는 CSRF(Cross-Site Request Forgery) 공격을 방지하기 위해 여러 보안 메커니즘을 사용합니다.
NEXTAUTH_SECRET은 CSRF 방지를 위한 토큰 생성 및 검증에도 사용됩니다.

 

3. 암호화된 쿠키 데이터 보호

NextAuth는 쿠키를 통해 세션 정보를 전달합니다.
NEXTAUTH_SECRET은 쿠키에 저장된 데이터를 암호화하고 복호화하는 데 사용됩니다.

 

4. 개발 환경과 운영 환경

  • 개발 환경:
    NEXTAUTH_SECRET이 설정되지 않은 경우 NextAuth는 자동으로 무작위 키를 생성합니다.
    하지만 이는 개발 환경에서만 사용해야 하며, 운영 환경에서는 절대 사용하면 안 됩니다.
  • 운영 환경:
    반드시 .env 파일에 안전한 고유 문자열을 설정해야 합니다.
    예시:NEXTAUTH_SECRET는 강력한 임의 문자열이어야 하며, Random Key Generator와 같은 도구를 통해 생성할 수 있습니다.
  •  
    코드 복사
    NEXTAUTH_SECRET=your-secure-random-secret
  • env

5. 설정하지 않으면 생기는 문제

  • 토큰 서명 및 검증 실패로 인해 JWT 기반 인증이 동작하지 않을 수 있습니다.
  • CSRF 보호 및 쿠키 암호화가 제대로 이루어지지 않아 보안 취약점이 생깁니다.
  • NextAuth가 오류 메시지를 표시하며 시작되지 않을 수 있습니다.

 

각각의 키는 각 깃허브, 구글, 디스코드에서 받을 수 있습니다.

 

3. 깃허브, 구글, 디스코드 각각의 CLIENT_ID, CLIENT_SECRET 받기

3-1. 깃허브에서 CLIENT_ID, CLIENT_SECRET 받기

- settings 에 들어갑니다.

- 맨 아래로 내려보면 왼쪽 제일 하단에 Developer settings 가 있습니다.

- OAuth Apps 를 클릭하고 새로운 New OAuth App을 생성합니다.

- 자신의 홈페이지 URL를 적습니다. 이때, 로컬에서 개발중이라면 저와 같이, 아니라면 실제 도메인 포함된 URL를 적어주시면 됩니다.

- 그럼 General 탭에서 바로 Client ID 가 생성이 되는데요, (빨간부분입니다) 
- Client secrets 도 받기 위해서는 Generate a new client secret 버튼을 클릭하셔서 인증을 받아야 합니다.

- 저는 패스워드 인증을 선택했고 Confirm 을 클릭합니다.

- 그럼 Client secrets두번째 빨간 박스에 표시됩니다! 

- 두개를 모두 .env 파일에 잘 저장해주시면 됩니다.

 

3-2. 구글 CLIENT_ID, CLIENT_SECRET 받기

https://console.cloud.google.com/welcome?inv=1&invt=Abjq_A&project=stellar-link

 

Google 클라우드 플랫폼

로그인 Google 클라우드 플랫폼으로 이동

accounts.google.com

해당 사이트에서 진행합니다.

 

- 프로젝트 선택 창에서 새 프로젝트를 클릭합니다.

 

- 이름을 작성하고 만들기를 클릭합니다.

 

-생성된 새로운 프로젝트를 클릭하면 대시보드가 나타납니다.

- API 및 서비스에서 사용자 인증 정보를 클릭합니다.

 

- 사용자 인증 정보 만들기 버튼을 클릭하고, OAuth 클라이언트 ID 버튼을 클릭합니다.

 

- 동의 화면 구성을 클릭합니다.

 

- 새로운 버전이 나왔는지 곧 사라진다고 합니다.

- 새로운 환경으로 이동을 클릭합니다.

 

- 앱 정보, 대상, 연락처 정보, 동의 를 원하는대로 입력한 후 , 만들기를 클릭합니다.

 

- 클라이언트 탭에서 클라이언트 만들기를 클릭합니다.

- 이때, 시간이 좀 걸리는지 새로고침 해주면 동의화면 구성하라는 말 없이 잘됩니다.

 

- 빨간 사각형 부분을 입력 후 만들기를 클릭합니다.

- 그럼 이와 같이 클라이언트 ID 가 보입니다.

- 시크릿 ID 도 얻기위해서는 해당 앱을 클릭하여 들어갑니다.

 

- 들어가면 빨간 박스에 클라이언트와 시크릿 키 모두 잘 생성되었습니다.

- .env 파일에 잘 저장합니다.

 

3-3. 디스코드 CLIENT_ID, CLIENT_SECRET 받기 (비교적 간단함!)

https://discord.com/developers/applications

 

Discord Developer Portal — API Docs for Bots and Developers

Integrate your service with Discord — whether it's a bot or a game or whatever your wildest imagination can come up with.

discord.com

 

해당 사이트에서 진행합니다.

 

- New Application 을 해줍니다.

 

- 이름 잘 작성하고 Create 을 해줍니다.

 

- OAuth2 에서 Redirects 를 적어줍니다.

-로컬 개발일 경우, http://localhost:3000/api/auth/callback/discord띄어쓰기없이! 잘적어줍니다.

 

- 아래로 내려 identity, email 을 체크해줍니다. 

- 더 필요한게 있으시면 체크하시면 됩니다.

- 그리고 꼭! Save Changes 를 해줘야 합니다.

 

- 그럼 위에 Client ID 가 도출되어 있습니다. 

- Client Secret 을 얻기 위해 Reset Secret 을 해줍니다.

 

- 위험 하다고 하지만,,, Yest, do it! 합니다.

- 비밀번호로 인증합니다.

- 그럼 두 빨간 박스에 Client ID, Client secret ID 가 제대로 생성되었습니다!

-이를 마찬가지로 .env 파일에 잘 저장합니다!

- 다른 것들보다 비교적 간단하고 쉽습니다!!

 

4. 로그인 페이지 코드 작성하기

'use client';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation'; // 라우팅용
import { useTranslation } from 'react-i18next';
import '../../../i18n';
import 'animate.css';
import Rectangle from '../components/Rectangle';
import { signIn } from 'next-auth/react'; // next-auth signIn 함수
import Button from '../components/Button';

export default function LoginPage() {
const { t, i18n } = useTranslation('common');
const [isInitialized, setIsInitialized] = useState(false);
const [passwordVisible, setPasswordVisible] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isFormValid, setIsFormValid] = useState(false); // 폼 유효성 상태
const [emailError, setEmailError] = useState<string | null>(null); // 이메일 에러 상태
const [passwordError, setPasswordError] = useState<string | null>(null); // 비밀번호 에러 상태
const [touchedEmail, setTouchedEmail] = useState<boolean>(false);
const [touchedPasswd, setTouchedPasswd] = useState<boolean>(false);
const router = useRouter();

const validateForm = () => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // 이메일 정규식
const isEmailValid = emailRegex.test(email);
const isPasswordValid = password.length >= 6; // 비밀번호 최소 6자리
setIsFormValid(isEmailValid && isPasswordValid);
};

useEffect(() => {
validateForm();
}, [email, password]);

const handleLogin = async () => {
setLoading(true);
setError(null);
setEmailError(null);
setPasswordError(null);
 
try {
const result = await signIn('credentials', {
redirect: false,
email,
password,
});
 
if (!result?.ok) {
alert(t('InvalidEmailOrPassword')); // 에러 메시지 알림으로 표시

return;
}
 
// 성공 시 페이지 이동
router.push('/chat');
} catch (err: any) {
setError(err.message || 'An error occurred');
} finally {
setLoading(false);
}
};
useEffect(()=>{
if(!touchedEmail ){
setTouchedEmail(true)
}else{
if (email === '') {
setEmailError(t('EnterEmail')); // 번역 메시지 사용
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setEmailError(t('InvalidEmail'));
}else {
setEmailError(null);
}
}
 
},[email])

useEffect(()=>{
if(!touchedPasswd){
setTouchedPasswd(true);
}else{
if (password === '') {
setPasswordError(t('EnterPassword')); // 번역 메시지 사용
} else if (password.length < 6) {
setPasswordError(t('PasswordTooShort'));
}else {
setPasswordError(null);
}
}
 
},[password])

useEffect(() => {
if (i18n.isInitialized) {
setIsInitialized(true);
} else {
const handleInitialized = () => setIsInitialized(true);
i18n.on('initialized', handleInitialized);
return () => {
i18n.off('initialized', handleInitialized);
};
}
}, [i18n]);

if (!isInitialized) return null;

return (
<div
className="h-screen flex items-center justify-center animate__animated animate__fadeIn"
style={{ animationDuration: '2s' }}
>
<Rectangle
className="md:w-[620px] md:h-[90%] w-[80%] h-[70vh] md:rounded-[40px] rounded-[20px] bg-transparent border border-white border-2 md:border-4 opacity-100 flex flex-col"
classNameBg="md:rounded-[40px] rounded-[30px] opacity-0"
>

 
<div className="flex-1 flex flex-col justify-center items-center md:m-16 m-4">
 
<h2 className="text-2xl md:text-4xl font-bold mb-6 text-white mb-12">{t('Login')}</h2>

{/* 이메일 입력란 */}
<div className='w-full mb-4'>
<div className='text-sm mb-1'>{t('Email')}</div>
 
<input
type="email"
placeholder=""
value={email}
onChange={(e) => setEmail(e.target.value)} // 상태 업데이트
className="w-full md:px-4 px-2 md:py-3 py-2 text-sm bg-white border border-gray-300 rounded-lg text-customPurple focus:outline-none focus:ring-2 focus:ring-customLightPurple "
/>
{/* 이메일 입력란 아래 에러 메시지 */}
{emailError && <div className="text-xs text-red-500 mt-1">{emailError}</div>}
</div>
 
{/* 비밀번호 입력란 */}
<div className='w-full'>
<div className='text-sm mb-1'>{t('Password')}</div>
<div className="w-full relative mb-1">
<input
type={passwordVisible ? 'text' : 'password'}
placeholder=""
value={password}
onChange={(e) => setPassword(e.target.value)} // 상태 업데이트
className="w-full md:px-4 px-2 md:py-3 py-2 text-sm bg-white border border-gray-300 rounded-lg text-customPurple focus:outline-none focus:ring-2 focus:ring-customLightPurple"
/>
 

<button
onClick={() => setPasswordVisible(!passwordVisible)}
className={`absolute inset-y-0 right-4 flex items-center text-customPurple
text-xs md:text-sm
bg-transparent
hover:decoration-customPurple
hover:text-underline-offset-4
hover:!bg-gradient-to-r hover:!from-customPurple hover:!to-customLightPurple hover:bg-clip-text bg-clip-text hover:text-transparent
transition-all duration-300
 
`}
>
{passwordVisible ? <div>{t('Hide')}</div> : <div>{t('Show')}</div>}

</button>
 
</div>
{/* 비밀번호 입력란 아래 에러 메시지 */}
{passwordError && <div className="text-xs text-red-500 mt-1">{passwordError}</div>}
{/* 비밀번호를 잃어버렸나요 버튼 */}
<button
className="
mb-8 text-xs justify-start items-start text-white
bg-transparent text-white
hover:decoration-customPurple
hover:!bg-gradient-to-r hover:!from-customPurple hover:!to-customLightPurple hover:bg-clip-text bg-clip-text hover:text-transparent
transition-all duration-300
"
 
onClick={() =>{router.push('/forget-passwd')}}
>
{t('ForgetPassword')}
</button>
</div>

 
{/* 로그인 버튼 */}
<Button
onClick={handleLogin}
loading={loading}
disabled={!isFormValid || loading} // 폼이 유효하지 않거나 로딩 중일 때 비활성화
size='md'
className={`w-full justify-center hidden md:flex`}
>
{loading ? <div>{t('Signin')}</div> : <div>{t('Signin')}</div>}
</Button>

{/* 로그인 버튼 - 모바일*/}
<Button
onClick={handleLogin}
loading={loading}
disabled={!isFormValid || loading} // 폼이 유효하지 않거나 로딩 중일 때 비활성화
size='sm'
className={`w-full justify-center md:hidden`}
>
{loading ? <div>{t('Signin')}</div> : <div>{t('Signin')}</div>}
</Button>

<div className='md:m-8 m-4 text-xs'>{t('orcontinuewith')}</div>

{/* 소셜 로그인 버튼 */}
<div className="w-full flex flex-row space-x-4">
<button
className="w-full py-1.5 md:py-2.5 text-white bg-white rounded-lg text-sm font-medium flex justify-center items-center focus:outline-none focus:ring-2 focus:ring-customLightPurple"
onClick={() => signIn('google', { callbackUrl: '/chat' })}
>
<Image
src="/SVG/google.svg"
alt="Logo"
width={24}
height={24}
priority
className="cursor-pointer"
/>

</button>
<button
className="w-full py-1.5 md:py-2.5 text-white bg-white rounded-lg text-sm font-medium flex justify-center items-center focus:outline-none focus:ring-2 focus:ring-customLightPurple"
onClick={() => signIn('github', { callbackUrl: '/chat' })}
>
<Image
src="/SVG/github.svg"
alt="Logo"
width={24}
height={24}
priority
className="cursor-pointer"
/>
</button>
<button
className="w-full py-1.5 md:py-2.5 text-white bg-white rounded-lg text-sm font-medium flex justify-center items-center focus:outline-none focus:ring-2 focus:ring-customLightPurple"
onClick={() => signIn('discord', { callbackUrl: '/chat' })}
>
<div className='w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center'>
<Image
src="/SVG/DiscordMark.svg"
alt="Logo"
width={16}
height={16}
priority
className="cursor-pointer"
/>
</div>
</button>
</div>


<div className='md:m-8 m-4 text-xs flex items-center justify-center flex-wrap'>
<div>{t('Donthaveanaccountyet')}</div>

<div className='cursor-pointer font-bold ml-2 text-xs text-white
bg-transparent text-white
hover:decoration-customPurple
hover:!bg-gradient-to-r hover:!from-customPurple hover:!to-customLightPurple hover:bg-clip-text bg-clip-text hover:text-transparent
transition-all duration-300'
onClick={()=>{// 로그인 성공 시 리디렉트
router.push('/signup');
}}
>{t('Registerforfree')}</div>
</div>
</div>
</Rectangle>
</div>
);
}

- 저는 다음과 같이 적었습니다. 

- 소셜 로그인 일 경우, onClick={() => signIn('google', { callbackUrl: '/chat' })}

signIn 함수

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

를 통해 쉽게 처리할 수 있습니다.

- 로컬 로그인의 경우

const handleLogin = async () => {
setLoading(true);
setError(null);
setEmailError(null);
setPasswordError(null);
 
try {
const result = await signIn('credentials', {
redirect: false,
email,
password,
});
 
if (!result?.ok) {
alert(t('InvalidEmailOrPassword')); // 에러 메시지 알림으로 표시

return;
}
 
// 성공 시 페이지 이동
router.push('/chat');
} catch (err: any) {
setError(err.message || 'An error occurred');
} finally {
setLoading(false);
}
};

해당 함수를 통해 credentials

로 signIn 해줍니다. 

 

이렇게 모든 작업이 완료되었습니다!

                                          제 글이 조금이라도 도움이 되길 바라며 정성껏 작성하였습니다.

                                                                             공감부탁드려요!

 

728x90
728x90

 

0. Intro 프로젝트 계획 변동 

원래 계획은

 

였습니다. 

 

중앙에 Lottie 가 애니메이션으로 동작하고 주변에 3D 모델링된 별들이 고정된 위치에서 줌인 - 줌아웃 정도의 애니메이션만 동작할 예정이었죠..

 

하지만 실제 React-Three-Fiber 로 개발하다보니, 별들만 3D모델링을 하는게 쉽지 않았습니다.

 

같은 Canvas 에 모델들을 넣기 위해서 지구도 3D 모델 즉, Gltf 포맷으로 넣기로 계획을 수정하였습니다.

 

1. 지구와 별들 리소스 구하기 및 프로젝트에 추가하기

별 리소스 구하는 방법은

https://choi-hee-yeon.tistory.com/232

 

[Next.js] React-Three-fiber 을 사용하여 3D 모델 프로젝트에 추가 및 애니메이션 적용하기

1. 3D 모델 구하기 (gltf)우선 3D 모델을 구하기 위해서 저는 https://lumalabs.ai/genie?view=preview Luma AI - GenieA 3d generative foundation modellumalabs.ai를 사용했습니다!  1-1. 원하는 모델을 입력하기1-2. 원하는

choi-hee-yeon.tistory.com

에 자세히 나와있습니다.

해당 별을 하나만 삽입하는 것을 구현했었는데, 

이름이 다른 여러개의 별로 복제하여 프로젝트 public 폴더에 추가합니다.

=> 추후에 여러개의 별을 추가할 때, 같은 파일로 여러개를 추가하지 못합니다.... (경험입니다 ㅜㅜㅜㅜㅜ )

 

 

 

1-1. 지구 리소스 구하기 및 다운로드 받고 프로젝트에 추가하기

https://sketchfab.com/search?features=downloadable&q=earth&type=models

 

Sketchfab

 

sketchfab.com

지구는 이 사이트에서 구했습니다.

다양한 3D 모델들이 존재하며, 저작권 잘 확인 후, 추후에 프로젝트에 꼭 넣어줍니다.

 

꼭 gltf 포멧으로 다운로드 받습니다!!

 

 

 

다운로드 받은 파일은 .zip으로 저장됩니다.

압축을 해제해여 폴더 전체를 프로젝의 public 폴더 안에 옮겨 놓습니다.

 

절대 gltf 만 옮겨 놓으면 안됩니다!!!!

 

 

2. 코드를 통해 프로젝트에 삽입하기

제가 하고 싶은건 하나의 큰 지구가 중앙에서 자전하고, 3개의 별들이 자전 및 지구 주변을 공전하는 것입니다.

 

2-1. 별 하나 관련 코드

const Star: React.FC<{ orbitRadius: number; speed: number }> = ({ orbitRadius, speed }) => {
  const gltf = useGLTF('/models/star.glb');
  const ref = useRef<THREE.Group>(null);

  useFrame(({ clock }) => {
    if (ref.current) {
      const time = clock.getElapsedTime();
      const x = orbitRadius * Math.cos(time * speed);
      const z = orbitRadius * Math.sin(time * speed);
      ref.current.position.set(x, 0, z);

      ref.current.rotation.y = time * 0.5; // 느린 회전
      const scale = 0.5 + Math.abs(Math.sin(time * 2)) * 0.05; // 크기 변화 범위 0.5 ~ 0.55
      ref.current.scale.set(scale, scale, scale);
    }
  });

  return <primitive ref={ref} object={gltf.scene} />;
};

 

 

- gltf 변수에 저희가 삽입한 별 모델의 경로를 적어줍니다.

- useFrame 을 통해 별 애니메이션을 생성해줍니다.  x, z 축을 시간별로 설정하여 position을 변경해줍니다.

- 자전도 하도록

ref.current.rotation.y = time * 0.5; // 느린 회전

 

합니다.

y축으로 회전하는 것 입니다. 

 

현재 작성한 별의 코드를 3개를 작성해줍니다. 각기 다른 모델을 사용해야 합니다. 

const Star: React.FC<{ orbitRadius: number; speed: number }> = ({ orbitRadius, speed }) => {
const gltf = useGLTF('/models/star.glb');
const ref = useRef<THREE.Group>(null);

useFrame(({ clock }) => {
if (ref.current) {
const time = clock.getElapsedTime();
const x = orbitRadius * Math.cos(time * speed);
const z = orbitRadius * Math.sin(time * speed);
ref.current.position.set(x, 0, z);

ref.current.rotation.y = time * 0.5; // 느린 회전
const scale = 0.5 + Math.abs(Math.sin(time * 2)) * 0.05; // 크기 변화 범위 0.5 ~ 0.55
ref.current.scale.set(scale, scale, scale);
}
});

return <primitive ref={ref} object={gltf.scene} />;
};

const Star2: React.FC<{ orbitRadius: number; speed: number }> = ({ orbitRadius, speed }) => {
const gltf = useGLTF('/models/star2.glb');
const ref = useRef<THREE.Group>(null);

useFrame(({ clock }) => {
if (ref.current) {
const time = clock.getElapsedTime();
const x = orbitRadius * Math.cos(time * speed + Math.PI / 3);
const z = orbitRadius * Math.sin(time * speed + Math.PI / 3);
ref.current.position.set(x, 0, z);

ref.current.rotation.y = time * 0.5;
const scale = 0.5 + Math.abs(Math.sin(time * 2)) * 0.05;
ref.current.scale.set(scale, scale, scale);
}
});

return <primitive ref={ref} object={gltf.scene} />;
};

const Star3: React.FC<{ orbitRadius: number; speed: number }> = ({ orbitRadius, speed }) => {
const gltf = useGLTF('/models/star3.glb');
const ref = useRef<THREE.Group>(null);

useFrame(({ clock }) => {
if (ref.current) {
const time = clock.getElapsedTime();
const x = orbitRadius * Math.cos(time * speed + (2 * Math.PI) / 3);
const z = orbitRadius * Math.sin(time * speed + (2 * Math.PI) / 3);
ref.current.position.set(x, 0, z);

ref.current.rotation.y = time * 0.5;
const scale = 0.5 + Math.abs(Math.sin(time * 2)) * 0.05;
ref.current.scale.set(scale, scale, scale);
}
});

return <primitive ref={ref} object={gltf.scene} />;
};

 

 

const gltf = useGLTF('/models/star3.glb'); 

 

부분만 다릅니다. 주의해서 작성하도록 합니다.

 

2-2. 지구 코드 작성하기

const Earth: React.FC<{ position: [number, number, number] }> = ({ position }) => {
const gltf = useGLTF('/models/earth/scene.gltf');
const ref = useRef<THREE.Group>(null);

useFrame(() => {
if (ref.current) {
ref.current.rotation.y += 0.008; // 천천히 회전
}
});

return <primitive ref={ref} object={gltf.scene} position={position} scale={1.5} />;
};

- 별과 같이, gltf 에 알맞은 모델 경로를 작성하여 줍니다.

- useFrame 을 통해 자전하도록 애니메이션을 작성합니다.

 

2-3. 별과 지구를 같은 Canvas 에 배치하기

const GltfViewer: React.FC<GltfViewerProps> = ({
size = { width: '100%', height: '100%' },
}) => {
return (
<div style={{ width: size.width, height: size.height }}>
<Canvas>
{/* 조명 설정 */}
<ambientLight intensity={0.8} /> {/* 주변 조명 */}
<directionalLight position={[5, 5, 5]} intensity={0.5} /> {/* 방향 조명 */}

{/* GLTF 모델 컴포넌트 */}
<Earth position={[0, 0, 0]} />
<Star orbitRadius={3} speed={2} />
<Star2 orbitRadius={3} speed={2} />
<Star3 orbitRadius={3} speed={2} />

{/* 카메라 컨트롤 */}
<OrbitControls />
</Canvas>
</div>
);
};

 

- ambientLight를 통해 주변조명을 설정합니다.

- directionalLight 를 통해 방향 조명을 설정합니다.

- 지구를 0,0,0 에 위치시킵니다.

- 세 개의 별의 공전 지름을 같게 설정하고, 속도도 같게 설정했습니다. (변경 가능)

 

2-4. GltfViewer.tsx 의 전체코드

'use client';
import React, { useRef } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF } from '@react-three/drei';
import * as THREE from 'three';

interface GltfViewerProps {
size?: { width: string; height: string }; // Canvas 크기
}

const Star: React.FC<{ orbitRadius: number; speed: number }> = ({ orbitRadius, speed }) => {
const gltf = useGLTF('/models/star.glb');
const ref = useRef<THREE.Group>(null);

useFrame(({ clock }) => {
if (ref.current) {
const time = clock.getElapsedTime();
const x = orbitRadius * Math.cos(time * speed);
const z = orbitRadius * Math.sin(time * speed);
ref.current.position.set(x, 0, z);

ref.current.rotation.y = time * 0.5; // 느린 회전
const scale = 0.5 + Math.abs(Math.sin(time * 2)) * 0.05; // 크기 변화 범위 0.5 ~ 0.55
ref.current.scale.set(scale, scale, scale);
}
});

return <primitive ref={ref} object={gltf.scene} />;
};

const Star2: React.FC<{ orbitRadius: number; speed: number }> = ({ orbitRadius, speed }) => {
const gltf = useGLTF('/models/star2.glb');
const ref = useRef<THREE.Group>(null);

useFrame(({ clock }) => {
if (ref.current) {
const time = clock.getElapsedTime();
const x = orbitRadius * Math.cos(time * speed + Math.PI / 3);
const z = orbitRadius * Math.sin(time * speed + Math.PI / 3);
ref.current.position.set(x, 0, z);

ref.current.rotation.y = time * 0.5;
const scale = 0.5 + Math.abs(Math.sin(time * 2)) * 0.05;
ref.current.scale.set(scale, scale, scale);
}
});

return <primitive ref={ref} object={gltf.scene} />;
};

const Star3: React.FC<{ orbitRadius: number; speed: number }> = ({ orbitRadius, speed }) => {
const gltf = useGLTF('/models/star3.glb');
const ref = useRef<THREE.Group>(null);

useFrame(({ clock }) => {
if (ref.current) {
const time = clock.getElapsedTime();
const x = orbitRadius * Math.cos(time * speed + (2 * Math.PI) / 3);
const z = orbitRadius * Math.sin(time * speed + (2 * Math.PI) / 3);
ref.current.position.set(x, 0, z);

ref.current.rotation.y = time * 0.5;
const scale = 0.5 + Math.abs(Math.sin(time * 2)) * 0.05;
ref.current.scale.set(scale, scale, scale);
}
});

return <primitive ref={ref} object={gltf.scene} />;
};

const Earth: React.FC<{ position: [number, number, number] }> = ({ position }) => {
const gltf = useGLTF('/models/earth/scene.gltf');
const ref = useRef<THREE.Group>(null);

useFrame(() => {
if (ref.current) {
ref.current.rotation.y += 0.008; // 천천히 회전
}
});

return <primitive ref={ref} object={gltf.scene} position={position} scale={1.5} />;
};

const GltfViewer: React.FC<GltfViewerProps> = ({
size = { width: '100%', height: '100%' },
}) => {
return (
<div style={{ width: size.width, height: size.height }}>
<Canvas>
{/* 조명 설정 */}
<ambientLight intensity={0.8} /> {/* 주변 조명 */}
<directionalLight position={[5, 5, 5]} intensity={0.5} /> {/* 방향 조명 */}

{/* GLTF 모델 컴포넌트 */}
<Earth position={[0, 0, 0]} />
<Star orbitRadius={3} speed={2} />
<Star2 orbitRadius={3} speed={2} />
<Star3 orbitRadius={3} speed={2} />

{/* 카메라 컨트롤 */}
<OrbitControls />
</Canvas>
</div>
);
};

export default GltfViewer;

 

 

3. GltfViewer를 실제 page 에 사용하기

원하는 곳에 다음 코드를 작성하면 됩니다.

{/* GltfViewer: 절대 위치 */}
<div className="w-full h-full">
<GltfViewer/>
</div>

 

 

4. 결과 영상

 

 

728x90
728x90

1. 3D 모델 구하기 (gltf)

우선 3D 모델을 구하기 위해서 저는 

https://lumalabs.ai/genie?view=preview

 

Luma AI - Genie

A 3d generative foundation model

lumalabs.ai

를 사용했습니다! 

 

1-1. 원하는 모델을 입력하기

1-2. 원하는 모델을 선택하여 다운로드하기 

 

다운로드할때, gltf 형식으로 합니다. 웹에 제일 적절한 포맷입니다. 

 

GLTF와 기타 포맷 비교

포맷 크기 성능 최적화 호환성  주요 특징
GLTF 작음 매우 높음 웹 표준 지원 경량, 스트리밍 지원, PBR 렌더링 지원
OBJ 낮음 제한적 오래된 포맷, 텍스처 포함 불가
FBX 중간 제한적 애니메이션 지원, 폐쇄적 포맷
COLLADA 중간 제한적 XML 기반, 파일 크기 크고 느림

 

 

2. Next.js(React.js) 프로젝트에 추가 및 애니메이션 적용하기

2-1. React Three Fiber install 하기

npm install @react-three/fiber @react-three/drei three

 

2-2.GLTF 파일 렌더링하기

'use client';
import React from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, useGLTF } from '@react-three/drei';

const Model = () => {
  const gltf = useGLTF('/models/example.gltf'); // GLTF 파일 경로
  return <primitive object={gltf.scene} />;
};

const GltfViewer: React.FC = () => {
  return (
    <div className="w-full h-[500px]">
      <Canvas>
        <ambientLight />
        <directionalLight position={[1, 1, 1]} />
        <Model />
        <OrbitControls />
      </Canvas>
    </div>
  );
};

export default GltfViewer;

 

2-3. gltf 파일 불러오기

 

  • GLTF 파일을 public/models/example.gltf에 저장.
  • useGLTF('/models/example.gltf')를 통해 로드.

2-4. 애니메이션 적용하기 (회전과 Pulse 애니메이션)

'use client';
import React, { useRef } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF } from '@react-three/drei';

const AnimatedModel = () => {
  const gltf = useGLTF('/models/star.glb'); // GLTF 파일 경로
  const ref = useRef<THREE.Group>(null); // GLTF 모델의 참조

  // 애니메이션 추가
  useFrame(({ clock }) => {
    if (ref.current) {
      const time = clock.getElapsedTime(); // 경과 시간 가져오기

      // 회전 애니메이션
      ref.current.rotation.y = time; // Y축 회전 (1초에 한 바퀴)

      // Pulse 애니메이션
      const scale = 1 + Math.sin(time * 2) * 0.1; // 크기를 부드럽게 변화
      ref.current.scale.set(scale, scale, scale); // X, Y, Z 축의 크기 조정
    }
  });

  return <primitive ref={ref} object={gltf.scene} />;
};

const GltfViewer: React.FC = () => {
  return (
    <div className="w-full h-[500px]">
      <Canvas>
        <ambientLight intensity={0.5} />
        <directionalLight position={[1, 1, 1]} />
        <AnimatedModel />
        <OrbitControls />
      </Canvas>
    </div>
  );
};

export default GltfViewer;

useFrame 을 활용하여 다양한 애니메이션을 추가할 수 있습니다.

3.결과

 

 

 

728x90

+ Recent posts