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' 도 추가해보고,
// 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>;
}
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;
},
},
프로젝트 상단에 /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 에서 Clusters 의 Connect 버튼을 찾아 클릭합니다.
- Drivers 를 클릭합니다.
- 빨간 부분의 값을 .env 파일의
DATABASE_KEY =어쩌구
로 넣으시면 됩니다.
이때, admin:<db_password> 의 db_password부분을 제대로 바꿔서 넣습니다!