이전 좋아요 기능과 큰 차이가 없을 것 같은 팔로우 팔로잉을 구현하기 전에
각 유저 페이지에 대해 구현하기로 하였다.
기존에는 게시만 잘 진행되게 구현했는데, 해당 유저의 프로필 페이지로 이동시키기 위해선 게시글에 닉네임을 추가시켜줘야만 했다.(거기서 눌러서 이동할 수 있게)
이전 유저 테이블 구성할 때 유저에 대한 프로필사진을 따로 추가해놓지 않았는데, 유저 페이지를 만드는 만큼 게시글에 닉네임만 떡하니 적혀있으면 유저 페이지로 이동하는 닉네임인지 인지하기 아무리 ui로 티를 내도 크게 티가 안날 것 같아 드디어 생각만하고 방치해두었던 프로필이미지를 추가하기로하였다.
일단 수파베이스 users 테이블에서 profile_image를 VARCHAR형식으로 열을 추가시키고,
기존에 처리했던 방식은 storage에서 디폴트 프로필이미지를 넣어놔서 그걸 url로 가져와서 기존 유저들에게 집어넣을 생각이였는데,
이후에 어떻게 해야 기존에 회원가입 진행한 유저들의 닉네임, 이미지를 같이 가져올까 고민하기전 수 많은 에러를 겪고있다.
일단 기존에 null로 되어있는 이미 회원가입한 유저들의 이미지는 sql문을 통해 업데이트 시켜서 default image url을 집어넣어줬다.
근데 게시글에 대한 작성자 닉네임과 프로필 이미지는 users테이블에 있는 데이터지 posts에는 없는 데이터 값이다.
그래서 맨 처음엔 posts에 nickname과 profile_image열을 추가해서 users와 연동하려고 했다가 고유의 키가 없어서 테이블끼리 연동이 안되었는데,
그 과정에서 굳이 posts에 중복된 데이터를 담아줄 필요없이 join으로 users의 닉네임 프로필이미지를 가져올 수 있다는 사실은 알게되었다.
그 과정에서 users에 id에 auth의 users의 id를 연동한 사실을 알게되었고,
이 부분을 바로잡고자 user_id열을 추가해서 auth와 연동을 시도하였으나 또 고유한 키값 어쩌구하면서 에러가 발생하였다.
그러다보니 Failed to create error:Database error saving new user
가 떴고 코드에 문제가 있어서 수정을 시도하였으나, 애초에
supabase의 authentication에서조차 create user가 진행되지 않았다.
그래서 auth와 연결된 값들, sql문 다 삭제하고 auth아이디 생성을 진행해도 에러가 발생하였고,
그 과정에서 functions가 database에 남아있다는 사실을 알게 되었다.
이건 무슨짓을 해도 삭제가 안되고, 여기에서 에러가 발생이 되기 때문에
authentication에서 create user가 에러가 발생되고 있어서 결국 내 문제는 아니기 때문에 프로젝트를 새로 생성하는 결정을 하였다.
비록 해결을 한건 아니지만, 빠르게 다시 구축해보자
일단 users테이블 구조
user_id에 auth.users.id와 연동
posts테이블 구조
추후 게시글 작성자의 닉네임과 프로필 이미지를 넣어주려고
posts의 author_id와 users테이블의 user_id를 연동시켰더니
이런 에러가 떴고, 원인으로는 users테이블의 user_id필드에 unique나 primary key 제약조건이 없어서 그렇다고 한다
users테이블의 user_id에 is Unique 체크하고 시도해봄
unique한 값을 체크하니 연동 해결!
likes테이블엔 post_id와 posts의 id 연동 / user__id와 auth.users.id 연동
comments테이블엔 post_id와 posts의 id / user_id와 auth.users.id연동
이제 기본 테이블은 다 구축했고
for security purposes, you can only request this after 44 seconds 회원가입을 실패하셨습니다가 회원가입시 실패로 떴다
오래 켜놓은 dev모드를 껐다 키니 회원가입은 성공하였으나, auth에만 잘 추가되고 users테이블엔 사용자가 추가가안되고있다.
생각해보니 테이블만 세팅하고 rls정책 추가를 안해놨다
일단 딱히 업데이트나 삭제를 진행할 계획은없어서 select와 insert정책만 users 테이블에 추가
posts테이블의 select, insert, update, delete RLS정책 추가해줬는데,
update에서 email을 베이스로 둔 업데이트라는데 나의 posts엔 email열잉 없어서
좀 더 직관적인 author_id열로 수정한 값과 auth.uid로 email대신 변경해서 update RLS정책도 수정해주었다.
delete도 완성!
그러나 여전히 회원가입을 진행하면 RLS정책때문에 authentication에는 데이터가 생성되도 users table에는 값이 포함되지 않았는데,
찾아보니 insert에 대해서 신규 회원가입은 기존에 아무런 값도 없는 사람이 진행하는 회원가입인데 정책조건을 넣어주면 안되어서 SELECT 조건만 넣어주었다.
그래도 RLS정책 위반이여서
아예 insert는 SQL Editor에서 따로 건들여줘서 모든 사용자가 허용할 수 있도록 권한을 바꿨더니 회원가입은 성공하였지만,,,!
이번엔 이메일확인이 안되었다고 떴다.
그래도 회원가입성공까지 원상복구는 하였고, 그 과정에서
자꾸 이것저것 설정해보면서 로그인상태가 아니다 Email not confirmed 등등 다양한 에러가 떠서 이참에 미들웨어를 활용해서 세션관리를 진행해보려한다.
인증 및 세션 관리를 간편하게 구현할 수 있도록 도와주는 라이브러리
사용하면 다양한 인증방법과 이메일/비밀번호 기반 인증을 쉽게 설정, 로그인 상태 유지
pnpm add next-auth
로 설치근데 찾다보니 수파베이스와 연동된 nextauth가 있어서
pnpm add @supabase/supabase-js @auth/supabase-adapter
이걸로 설치!
역시나 공식문서만 보면서 구현하기엔 이것저것 끌어다 쓸게 많다..
일단 찾다보니 새로운 KEY값을 담아줘야해서
새롭게 SUPABASE_SERVICE_ROLE_KEY와 NEXTAUTH_SECRET 키를 추가해주었다.
근데 NEXTAUTH_SECRET키를 터미널에서 생성하는게 좀 신기했다
NEXTAUTH_SECRET
해당 키는 애플리케이션의 JWT 또는 암호화된 세션을 보호하기 위해 사용
근데 찾다보니 nextauth가 아닌 그냥 auth.js랑 연동된게 supabase여서 다시 처음부터.. 해보겠다...
일단 nextauth는 supabase의 service_role_key를 사용하라는데 나는
anonkey를 사용하고 있다
차이점은
브라우저에서 직접 호출되는 API 요청에 사용
클라이언트측에서 사용되기때문에 공개적으로 노출될 수 있어 민감한 작업 X
서버측에서 사용
완전한 권한을 가지고 있음, 인증되지 않은 사용자도 데이터베이스에 접근할 수 있음, 서버측에서만 사용됨
그래서 일단
supabaseClient.ts
import { createClient } from "@supabase/supabase-js"
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL as string
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
supabaseServer.ts
import { createClient } from "@supabase/supabase-js"
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL as string
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY as string
export const supabaseServer = createClient(supabaseUrl, supabaseServiceRoleKey)
이렇게 나눠서 사용했는데 supabaseClient.ts는 로그인 인증같이 클라이언트에서 사용할때 사용하는데 nextauth는 서버측에서 모든 데이터베이스 작업(사용자 인증,데이터생성,수정 및 삭제 등)을 안전하게 처리해서 authorize 메서드 내에 사용하면서 사용자가 로그인 시 인증 api를 호출하는 역할이라고 함
그 과정에서 NEXTAUTH_SECRET
값도 넣어줘야했는데,(아직 맞는지는 모르겠음)
NextAuth.js의 세션 및 JWT 암호화에 사용되는 중요한 환경번수라고 함!
openssl rand -base64 32
에서 생성된 문자열을 넣어줬음
그리고나서 수 많은 에러를 마주하였는데,
// pages/api/auth/[...nextauth].ts
import { supabaseServer } from "@/lib/supabaseServer"
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
export default NextAuth({
providers: [
CredentialsProvider({
name: "Supabase",
credentials: {
email: { label: "Email", type: "text", placeholder: "you@example.com" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const { email, password } = credentials!
const {
data: { user },
error,
} = await supabaseServer.auth.signInWithPassword({ email, password })
if (error || !user) {
throw new Error("Invalid credentials")
}
return { id: user.id, email: user.email } // Return user object
},
}),
],
pages: {
signIn: "/auth/signin", // 사용자 정의 로그인 페이지
},
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.email = user.email
}
return token
},
async session({ session, token }) {
if (token) {
session.user.id = token.id || ''
session.user.email = token.email || ''
}
return session
},
},
})
일단 [...nextAuth].ts에 만든 코드고,
session.user.id쪽 형식에 에러가 떠서 하나씩 건들여보면
일단 type/next-auth.d.ts에 타입 선언
import NextAuth from "next-auth"
import { DefaultSession } from "next-auth"
declare module "next-auth" {
interface Session {
user: {
id: string
email: string
// 다른 사용자 정의 속성 추가 가능
} & DefaultSession["user"]
}
interface JWT {
id?: string
email?: string
}
}
이후 tsconfig.json의 include배열에 types 폴더 추가하여 typescript가 정의된 타입 파일 인식하도록 함
(이 파일은 TypeScript에게 NextAuth.js의 Session과 JWT 타입을 확장하도록 지시함)
{
"compilerOptions": {
// 기존 설정
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "types"]
}
근데?????????????
하다보니까 지금까지 내가 구현하던 과정은 page Router에서의 nextAuth 사용법이라고 한다 하하하하하
어쩐지 page폴더를 구성하라고 하더라 캬악 퉤
그럼 다시 처음부터!!
진행 순서
1.nextauth 라이브러리 적용
2.supabase 키 nextAuth에 적용하기
3.middleware.ts 만들기(로그인하지 않은 사용자 보호된 페이지 접근시, 리디렉션)
NEXTAUTH_SECRET=your_secret_key
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SERVICE_ROLE_KEY 없이 기존 그대로 사용
src/lib/supabase.ts
에서 createClient하기
api/auth/[...nextauth]/route.ts에서 nextauth 관련 코드 생성
src/middleware.ts에서 로그인 세션 관리
이후 로그인에서 계속 실패가 떠서 원인을 찾던 중
새로 만든 supabase 프로젝트에서 authentication에서 이메일 인증을 사용안한다고 생각하였으나,
confirm email이 켜져있어서 계속 email confirmed에러가 떴었다.
그래도 어찌저찌 해결..!
'use client';
import { SessionProvider } from 'next-auth/react';
import { ReactNode } from 'react';
import { ToastProvider } from '@/components/ui/use-toast'; // 예시로 추가
interface LayoutProps {
children: ReactNode;
}
const Layout = ({ children }: LayoutProps) => {
return (
<SessionProvider>
<ToastProvider> {/* 다른 Provider들이 필요한 경우 추가 */}
{children}
</ToastProvider>
</SessionProvider>
);
};
export default Layout;
일단 nextAuth사용을 위해서 이런식으로 layout을 SessionProvider로 감싸줬었는데, 서버클라이언트여서 에러가 떴었다.
그래서 아예 SessionProvider.tsx라는 클라이언트 컴포넌트를 만들어줘서 그 컴포넌트를 layout.tsx를 감싸는데 import 해오면 서버 클라이언트에서 사용할 수 있도록 가능하였다.
src/components/auth/SessionProvider.tsx
"use client" // 클라이언트 컴포넌트로 지정
import { SessionProvider as NextAuthProvider } from "next-auth/react"
import { ReactNode } from "react"
interface SessionProviderProps {
children: ReactNode
}
const SessionProvider = ({ children }: SessionProviderProps) => {
return <NextAuthProvider>{children}</NextAuthProvider>
}
export default SessionProvider
여기서 원래 쓰려던 SessionProvider
를 가져옴 - NextAuth.js의 세션 관리 기능 제공하는 컴포넌트
ReactNode
는 React에서 사용할 수 있는 모든 자식 요소의 유형 정의
children
prop 타입으로 사용
그래서 SessionProvider컴포넌트에서 children prop을 받아서NextAuthProvider
로 감싸주면 NextAuthProvider가 자식 컴포넌트에 세션 정보를 제공하게 됨
src/app/api/auth/[...nextauth]/route.ts
import { supabase } from "@/lib/supabase"
import NextAuth, { AuthOptions } from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
export const authOptions: AuthOptions = {
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text", placeholder: "you@example.com" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials) {
console.error("Credentials are missing.")
return null
}
const { email, password } = credentials
//Supabase 사용하여 이메일 및 비밀번호로 로그인 시도
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
// Supabase에서 에러 발생 시 오류 처리
if (error) {
console.error("Supabase sign-in error:", error)
return null // null을 반환하여 인증 실패로 처리
}
// 사용자가 없는 경우
if (!data.user) {
console.error("No user found.")
return null // null을 반환하여 인증 실패로 처리
}
// 사용자 객체를 반환하여 세션에 저장
return { id: data.user.id, email: data.user.email }
},
}),
],
pages: {
signIn: "/login", // 사용자 정의 로그인 페이지
},
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.email = user.email
}
return token
},
async session({ session, token }) {
if (token) {
session.user = {
id: token.id as string,
email: token.email as string,
}
}
return session
},
},
secret: process.env.NEXTAUTH_SECRET,
}
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
CredentialsProvider
- 사용자 아이디와 비밀번호로 인증하는 제공자
authOptions
- NextAuth.js의 설정 - 이 객체는 인증제공자,세션관리,콜백 등 포함
provider
- 로그인하는 방법 정의(구글 등 다른 방식도 가능)
credentials
- 사용자에게 입력할 필드를 정의
authorize
- 입력한 자격 증명을 사용하여 인증하는 비동기 함수
credentials
- 없으면 오류를 기록하고 null 반환
pages
- 사용자 정의 로그인 페이지 지정
session
- 세션 관리 전략 - JWT사용하여 세션 관리
callbacks
- 인증 과정에서 특정 이벤트가 발생했을 때 호출되는 함수
jwt
- 사용자가 인증되면 호출되며, 사용자 정보를 jwt에 추가
session
- 세션이 생성될때 호출되며, jwt에서 사용자 정보를 세션에 추가
secret
- 비밀 키를 환경 변수에서 가져옴. jwt 서명 및 세션 보호에 사용
handler
- nextauth사용하여 설정된 인증 옵션을 기반으로 핸들러 생성
GET
및 POST
요청을 처리할 수 있도록 핸들러를 내보냄
src/middleware.ts
import { getToken } from "next-auth/jwt"
import { NextRequest, NextResponse } from "next/server"
const secret = process.env.NEXTAUTH_SECRET as string
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request, secret })
// 인증된 사용자만 접근할 수 있는 페이지 경로
const protectedPaths = ["/", "/home", "/profile"]
if (
protectedPaths.some((path) => request.nextUrl.pathname.startsWith(path))
) {
if (!token) {
// 로그인하지 않은 사용자는 로그인 페이지로 리디렉션
return NextResponse.redirect(new URL("/login", request.url))
}
}
return NextResponse.next()
}
export const config = {
matcher: ["/", "/home/:path*", "/profile/:path*"],
}
특정 경로에 대한 접근 제어를 위한 미들웨어
protectedPaths
- 로그인된 사용자만 접근할 수 있는 경로들을 정의
getToken
- next-auth에서 제공하는 함수, 요청에서 JWT를 추출하는데 사용, 인증된 사용자인지 확인하는데 사용
NextRequest
NextResponse
- Next.js의 서버 컴포넌트에서 요청과 응답 다루기 위한 객체
secret
- JWT 서명과 검증에 사용되는 시크릿 키
middleware
- NextRequert객체를 인자로 받아 요청 처리
protectedPaths.some
- 현재 요청의 경로가 보호된 경로 중 하나와 일치하는지 확인, 토큰이 없으면 '/login'로 리디렉션
config.matcher
- 미들웨어가 적용될 경로 설정
내 루트 페이지는 LoginPage 컴포넌트를 import하고 있어서 localhost:3000에서도 로그인페이지가 보여지고 있다.
근데, 인증되지 않은 사용자니까 바로 /login url로 이동시키고 싶었는데,
middleware에서 세팅한걸로는 이동이 되지 않았다.
그래서 처음엔 인증 관련된 세션을 루트 페이지에 담아줘서 처리하려 하였으나, 너무 코드도 길고 비효율적이여서 redirect 를 import해서 처리하였다.
이렇게 되면 새로고침 없이 사용자 입장에선 첫 페이지를 바로 /login url에서 볼 수 있게 된다
"use client"
import React from "react"
import LoginPage from "./login/page"
import { redirect } from "next/navigation"
const HomePage = () => {
redirect("/login")
// return <LoginPage />
}
export default HomePage
JWT(JSON Web Token)
웹 애플리케이션에서 정보의 전송과 인증을 위해 사용되는 JSON 기반의 토큰.
클라이언트와 서버 간에 정보를 안전하게 전달하는 데 사용
{"alg": "HS256", "typ": "JWT"}와 같은 형태
session
서버 측에서 사용자와의 상호작용 상태를 저장하고 관리하는 방식
토큰
사용자의 인증 정보를 클라이언트에 저장하는 방식, 서버에 상태 정보를 저장하지 않는 방식
새로 세팅하고 다시 만들다보니 짜잘한 오류가 발생되고 있는데 하나씩 바로잡아보겠다
storage에 이미지 저장 후 url로 받아와서 posts 테이블에 넣어줘야하는데 storage를 사용하지 않고 있어서 기존 이름과 똑같이 'images'로 만든 후,
모든 권한 허용해서 해결될 것 같음
게시글 작성 중 이런 에러가 뜨고 있는데,
모든 posts 테이블의 열을 채워줘야했는데, 넣는값과 실제 담는 곳이 일치하지 않아어 발생?했을 가능성도 있을 것 같아
const createPost = async (newPost: NewPost) => {
if (!currentUserId) {
toast({
title: "사용자가 인증되지 않았습니다.",
description: "게시글을 작성하기 전에 로그인하세요.",
})
return
}
const { title, content, user_id, image_url } = newPost
const { error } = await supabase
.from("posts")
.insert([{ title, content, user_id, image_url }])
if (error) {
console.log("게시글 작성 중 오류:", error.message)
toast({
title: "게시글 작성 중 오류가 발생하였습니다.",
description: error.message,
})
} else {
toast({ title: "게시글이 작성되었습니다." })
// 새로 작성된 게시글을 포함하여 데이터를 다시 불러옵니다.
queryClient.invalidateQueries({ queryKey: ["posts"] })
}
}
좀 더 직관적으로 title,content,user_d, image_url을 insert해줄거라고 적어놨었으나, .insert([{ title, content, user_id, image_url }])
객체형태로 담아주지 않아 저런 에러가 발생했었다.
이제 객체로 잘 담아주니 policy오류가 떠서 찾아보니 내가 게시글 작성하고 보여줄 때 users테이블에 담겨있는 nickname과 profile_image를 연동시켜주고 싶어서 posts의 user_id를 auth가 아닌 users 테이블의 user_id와 연동을 지어서 이런 문제가 발생한 것 같다
근데 그게 문제가 아닌 순수 policy 에러여서 이런 저런 정책을 다 건들여줘도 insert쪽에서 문제가 발생되서 모든 유저가 crud할 수 있게 전체 가능한 정책을 넣어줬다
CREATE POLICY "Enable insert for users based on user_id"
ON public.posts
FOR INSERT
WITH CHECK (auth.uid() = user_id);
아무리 고쳐도 안되서
-- 모든 사용자에게 SELECT, INSERT, UPDATE, DELETE 허용
CREATE POLICY "Allow all actions on posts"
ON public.posts
FOR ALL
USING (true);
이걸로 게시물 생성은 해결...!
인줄 알았지?
기존에는 auth랑만 연동을 해놨어서,
const fetchPosts = async (
pageParam: number = 1,
): Promise<FetchPostsResult> => {
const { data, error } = await supabase
.from("posts")
.select(`*, users(nickname, profile_image)`, { count: "exact" })
.order("created_at", { ascending: false })
.range((pageParam - 1) * ROWS_PER_PAGE, pageParam * ROWS_PER_PAGE - 1)
if (error) {
throw new Error(error.message)
}
return {
data: data || [],
nextPage: data?.length === ROWS_PER_PAGE ? pageParam + 1 : undefined,
}
}
여기서 select에서 users랑 연동하면 fetch 에러가 떴었는데,
user의 user_id랑도 연동하니까 해결되었다 어차피 auth든 posts든 users든 모두 user_id(uuid)값을 참조하므로 외래 키만 잘 연결해놓으면 상관없는 듯 하다
기존 수바베이스 select매서드를 활용해 posts와 외래키 user_id를 연동한 users 테이블에서 nickname, profile_image를 빼왔었는데,
{posts.map((post, index) => {
console.log("Posts data:", post)
return (
<div
key={post.id}
className="border p-4 mb-4"
ref={index === posts.length - 1 ? lastPostElementRef : null}
>
<span
onClick={() => handleProfileClick(post.user_id)}
className="cursor-pointer"
>
<div className="flex items-center mb-2">
<Image
src={post.users.profile_image} // 프로필 이미지
alt={`${post.users.nickname}'s profile`}
className="w-10 h-10 rounded-full mr-2"
/>
<span
onClick={() => handleProfileClick(post.user_id)}
className="cursor-pointer font-semibold"
>
{post.users.nickname} {/* 닉네임 */}
</span>
</div>
</span>
여기서 console을 찍어보니 post.profile_image / post.nickname으로 들어가져있는게 아닌, post.users 객체 안에 profile_image 이런식으로 담겨잇었다.
그래서 타입 선언을 다시해줬어야했는데,
이런식으로 users를 객체로 다시 타입을 넣어주니 드디어 사진과 닉네임을 불러올 수 있었다.
그 과정에서 img 태그가 아닌 Image - 넥스트 자체 이미지최적화시켜주는 컴포넌트를 불러와서 사용하니 에러가 떴는데,
Unhandled Runtime Error
Error: Image with src "https://mfovgoluhkgrvrsobpru.supabase.co/storage/v1/object/public/default_profile_image/profileImage.png?t=2024-09-01T11%3A46%3A01.209Z" is missing required "width" property.
Call Stack
getImgProps
../src/shared/lib/get-img-props.ts
props
../src/client/image-component.tsx
React
Image 태그를 이용한다면 width 같은 값을 직접 정의해줘야된다고해서
width={100}
height={100}
를 추가해줬더니
next/image 컴포넌트를 사용한다면 이미지의 호스트네임이 next.config.js파일에 명시되지 않은 문제가 발생하였다.
next.config.mjs 파일을 들어가니
이미지에 대헤 example.com으로 적용되어있던걸
mfovgoluhkgrvrsobpru.supabase.co
도메인을 넣어줬더니 해결할 수 있었다.
타입, config등 생각보다 강력하게 체크해줘야할게 많고 놓치기 쉬운 부분이라는걸 느껴버림..
근데 여기서 Image 컴포넌트가 이미지 최적화에 장점이 있다는건 알았는데,
따로 살펴보면
이제 드디어 follow.ts관련되서 다시 시작해보자 길었다 정말
useLike.ts의 좋아요 기능과 유사한 코드로 작성하려고 하였고,
follows 테이블에 follower_id(팔로우하는 사람) / following_id(팔로우 당하는 사람) 컬럼을 세팅해놨고, 팔로우 관계를 정리해야했다.
아직도 글로써도 살짝 헤깔리는데(인스타를 잘안함 ㅠ)
<함수>
수파베이스의 팔로우 팔로잉 불러오는 fetch함수 구현 중 수파베이스에서 받아오는 유저의 data가
following
:
{id: 19, nickname: '민규', profile_image: 'https://mfovgoluhkgrvrsobpru.supabase.co/storage/v…e/profileImage.png?t=2024-09-01T11%3A46%3A01.209Z'}
following_id
:
"0d9b2d9e-9708-4729-909d-3c1ea24acefa"
이런식으로 following쪽은 {id, nickname, profile_image}가 들어가 있고 following_id는 string으로 들어가있어서 Promise<User[]>로 딱 맞게 세팅을 해주었지만,
const fetchFollowers = async (userId: string): Promise<User[]> => {
if (!userId) return []
const { data, error } = await supabase
.from("follows")
.select(
`
follower_id,
follower: follower_id (id, nickname, profile_image)
`,
)
.eq("following_id", userId)
if (error) {
setError("Error fetching followers list")
console.error("Error fetching followers list:", error)
return []
} else {
return data ? data.map((follow) => follow.follower) : []
}
}
이 부분에서 자꾸 return문쪽 타입 에러가 발생하였다.
{following_id:any; following: {id:any; nickname:any; profile_image:any;}[];}[] 형식을 FollowData[]형식으로 변환한 작업은 실수일 수 있습니다. 두 형식이 서로 충분히 겹치지 않기 때문입니다. 의도적으로 변환한 경우에는 먼저 'unknown'으로 식을 변환합니
{following_id:any; following:{id:any; nickname:any; profile_image:any;}[]; } 형식을 FollowData 형식과 비교할 수 없습니다 following 속성의 형식이 호환되지 않습니다. {id:any; nickname:any; profile_image:any; }[] 형식에 User형식의 id, nickname, profile_image속성이 없습니다
이런 에러가 발생하고있어서 데이터 타입을 충분히 맞춰줬음에도 인식을 못하는 것 같아 Promise<User[]>를 삭제하니까 에러를 막을 수 있었다.
그러나 위쪽에서 해결을 했더니,
실제 useEffect로 함수를 실행하는 fetchInitialData함수에서
똑같은 문제가 발생하였다.
이 부분 전에 위쪽에서 먼저 에러가 뜨고 있었는데,
이런식으로 어떻게든 에러를 잡는 시도를 하긴했으나 근본적으로 data가 null이였다
즉 data가 안담기고 있다는 문제인데,
실제로 팔로우를 버튼을 눌러보면
could not embed because more than one relationship was found for 'follows' and 'users'
이런 에러가 발생하고 있는데,
내 수파베이스의 follows 테이블에서 follower_id, following_id 열이 있고 두 열 전부 users테이블의 user_id와 외래키로 연동하고 있었는데, 수파베이스 입장에서 그 부분에 있어서 혼동을 겪고 있어서 발생하는 문제였다.
그래서 멘토님의 도움을 받아서
users를 굳이 select에 넣지 않고 follower:follower_id(id, nickname,profile_image)
를 통해 users의 nickname, profile_image를 가져올 수 있었다.
그래서 data는 불러오기 성공하였으나, 그 데이터를 맵핑하는데 타입에러가 떴는데,
type User = {
id: string
nickname: string
profile_image?: string
}
type FollowCounts = {
followerCount: number
followingCount: number
}
interface FollowerData {
follower_id: string // follower_id 필드 추가
follower: User // follower를 User 타입으로 정의
}
어떤식으로 타입선언을 해도 데이터 타입 에러가 계속 떴다.
그래서 아싸리 수파베이스 api에서 데이터 선언을 시도해줬는데,
const { data, error } = await supabase
.from<FollowerData>("follows")
.select(`
follower:follower_id(id, nickname, profile_image),
following_id
`)
.eq("following_id", userId);
from()안에 인수가 2개가 필요하다면서 에러가 떴다.
그러다 찾다보니 공식문서에서 24년 9월부터 수파베이스 데이터 페칭 typescript 연동 변경이 되었다는 소식..! 이걸 몰라서 몇날 몇일을 고생했는데...
24년 9월 이후 typescript supabase 관련 공식문서
이 부분을 적용해서 타입 에러를 해결해보려한다.
그런데 <Database>
를 설치해야 적용할 부분이 있는데, pnpm을 패키지 매니저로 사용하는 내 프로젝트에 pnpm으로 npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > types/supabase.ts
이거 설치가 안된다..
그러다 npx로 supabase 명령어를 실행하는데도 엑세스 토큰이 없어서 문제가 발생하였다.
$ npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > types/supabase.ts
2024/09/10 18:08:40 Access token not provided. Supply an access token by running supabase login or setting the SUPABASE_ACCESS_TOKEN environment variable.
그래서 Supabase CLI를 사용하여 로그인해서 액세스 토큰을 받아오기로 하였다
npx supabase login
브라우저로 실행해서
토큰을 웹 브라우저로 받았고,
.env
파일에 SUPABASE_ACCESS_TOKEN=YOUR_ACCESS_TOKEN
를 추가해주었다.
이후 다시 npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > types/supabase.ts
를 실행해보니 Selected project: 가 뜨면서
types/supabase.ts 파일이 생성되었다...
근데?
ㅋㅋㅋㅋㅋㅋ 처음보는 형식의 코드가 있는데 무지하게 흘러 넘치는 에러들...
시도하다보니 $PROJECT_REF에 맨 처음 위쪽에서 Selected project: ~ 에서 ~를 넣어줘야 되는 거였다.
정상적으로 types/supabase.ts에 잘 타입이 담긴 모습
저 파일 내용은 Supabase에서 생성한 데이터베이스 스키마를 TypeScript 타입으로 변환한 파일임.
테이블, 열 데이터 타입 등에 대한 정보를 TypeScript의 타입으로 정의하여, TypeScript 코드에서 Supabase의 쿼리를 안전하게 사용할 수 있게 해줌.
Database
타입: Supabase에서 생성한 데이터베이스 스키마의 타입을 정의합니다. 예를 들어, 테이블의 Row
, Insert
, Update
타입 등을 포함
이후
기존 createClient에 새로 만든 Database 타입들을 넣어준 모습
그렇게 되면 타입스크립트는 쿼리에서 사용하는 테이블과 열의 정확한 타입을 알고, 잘못된 쿼리나 속성 접근을 컴파일 타임에 방지할 수 있음.
즉 이전에는, 서버에서만 에러를 반환했다면 이젠 타입스크립트만으로 사전에 에러를 방지할 수 있음.
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { data, error } = await supabase
.from('users') // 테이블 이름을 문자열로 지정
.select('*')
.eq('status', 'ONLINE');
if (error) {
return res.status(500).json({ error: error.message });
}
res.status(200).json(data);
};
이후 이러한 API 핸들러도 추가해줬는데,
req
와 res
는 각각 HTTP 요청과 응답, Next.js API 라우트에서 사용됨
테이블의 status열이 online인 모든 레코드를 필터링해서 쿼리 실행 중 에러가 발생하면 HTTP응답으로 에러메시지를 반환하고 쿼리가 성공적으로 완료되면 JSON형식으로 응답함
근데 내가 가지고 있는 테이블은 users뿐만 아니라 comments 등 다양하게 존재하는데 users만 넣어도 되는지 의문이 생겼다.
그러나 그 부분은 Database에 자동으로 삽입이 되어 있어서 그걸 활용만 잘하면 되었고,
찾아보니 req와 res객체를 직접 사용하여 요청과 응답을 처리하는 방식은 Express.js스타일이며 일반적인 Next.js API 라우트 방식인데,
Next.js13 기능 이후로는 HTTP 메서드별로 분리된 핸들러를 정의할 수 있다고 한다.
그리고 API 라우트여서 기존의 src/lib/supabase.ts가 아닌
src/app/api/users/route.ts 이쪽에서 선언해주었다.
근데 이 부분은 내가 해결하려고하는 부분에서 별로 중요하진 않아서 나중에 리펙토링 때 다뤄볼 예정이고
메인은 여전히 setFollowers쪽 type에러였는데,
내가 api 사용법이 미숙해서 그럴수도있지만, 어느 부분에서 타입정의를 해주냐도 중요해보였다.
드디어.... returns쪽에서 타입을 정의해주니까 해결되었는데,
아무래도 단일 쿼리가 아닌 복잡한 쿼리 즉 2개 이상의 테이블이 얽힌 구조이다보니 어디서 타입선언을 해줘도 그 데이터를 뽑아서 map할때 에러가 발생되었었는데,
returns쪽에서 타입을 명시해줘버리면, 중간 과정이 아닌 마지막에 data의 형식을 명확히 지정해주므로 데이터가 FollowQueryResult 즉,
type FollowingQueryResult = {
following: {
id: string
nickname: string
profile_image: string | null
}
follower_id: string
}
실제 데이터가 찍히는 key value에 대해 명확히 타입을 선언해줌으로써 데이터 구조와 타입간의 불일치를 방지할 수 있게 되었다. 참 위치하나 몰라서 타입 선언에 이렇게 애를 먹었다는게 화가나지만 마지막에 타입 체크가 이뤄져서 any타입으로 간주될 법한 일을 방지했다고 볼 수 있다.
갑자기 어디서부터 문제가 발생되었는지 모르겠는데 null한 값이 대한 타입을 지정하지 않아서 문제가 연속적으로 다 터졌다.
그래서 실제로 로그인하면 {nickname}님의 일상을 남겨보시요! 라는 문구가 있는데 undefined한 값이 들어가게 되서(언제부터인지도 감이 안옴)
그래서 후다닥 nickname가져오는 useAuth.ts에서
const [nickname, setNickname] = useState<string | null>("")
useEffect(() => {
if (status === "loading") return // 세션 로딩 중에는 아무 작업도 하지 않음
if (status === "unauthenticated") {
// 인증되지 않은 사용자는 로그인 페이지로 리다이렉트
toast({
title: "로그인 상태가 아닙니다.",
description: "로그인을 먼저 진행해주세요.",
})
route.push("/login")
} else if (status === "authenticated" && session?.user) {
setCurrentUserId(session.user.id)
// Supabase에서 사용자 데이터 가져오기
const fetchUserData = async () => {
const { data: userData, error } = await supabase
.from("users")
.select("nickname, profile_image")
.eq("user_id", session.user.id)
.single()
console.log("userdata(nickname):", userData)
if (error) {
console.error("사용자 데이터 가져오기 오류:", error.message)
return
}
if (userData) {
setNickname(userData.nickname)
console.log("설정된 닉네임:", userData.nickname) // 추가된 로그
setProfileImage(userData.profile_image || session.user.image || "")
}
}
갑자기 setNickname에 데이터 값을 담아주는 처리도 진행하지 않아서 후다닥 처리해줘서 그리고 useState에소 | null을 추가해서 당장은 해결하였다 이해가 안간다
이전에 잘만되던게 갑자기 안되었던게...;;
기존에 문제가 없던 코드인데 워낙 많은 부분 에러를 건들다보니 url은 게시글 작성한 유저의 id url로 이동은 되나 로그인한 사용자의 닉네임과 프로필페이지가 뜨고 있었다.
이 부분은 기존에 session관리할때의 fetch함수를 이용해서 가져오다보니 거기의 인수값은 로그인한 유저의 아이디여서 인수로 params값을 가져와서 그 값과 일치하는 nickname과 프로필이미지를 supabase에서 가져오는 방법을 채택했다.
아예 새로 게시글을 작성한 유저의 nickname과 id와 profileImage를 가져오는 함수를 생성해서 그 값을 postNickname, postProfileImage useState에 담아서 가져와서 해결하였다.
둘다 useEffect를 통해 함수 호출을 진행해야 수파베이스에있는 게시글 작성한 닉네임, 프로필이미지를 불러올 수 있었는데, useAuth에서 useEffect로 실행하니 에러가 발생했다.
그 이유는 useAuth에서 호출할땐 인수로 받아오는 id가 undefined일 수 있고, 아직 설정되지 않은 상태일 수 있어서이다.
그냥 안전하게 Profile에서 함수 호출해서 사용하자
이게 사실상 지금까지의 팔로우 팔로잉의 근본적인 에러였는데, 이 부분을 점점 알아내기 힘들었던 이유는 후반부갈수록 힘들다는 이유로 챗gpt에 의존하며 후에 공부해서 습득하자는 마인드가 강했던 것 같다. 너무 반성하고 일단 분석을 해보자면,
const fetchFollowCounts = async (userId: string) => {
try {
// 팔로워 수
const { count: followerCount, error: followerError } = await supabase
.from("follows")
.select("*", { count: "exact" })
.eq("follower_id", userId)
if (followerError) throw followerError
// 팔로잉 수
const { count: followingCount, error: followingError } = await supabase
.from("follows")
.select("*", { count: "exact" })
.eq("following_id", userId)
if (followingError) throw followingError
setFollowCounts({
followerCount: followerCount || 0,
followingCount: followingCount || 0,
})
} catch (err) {
setError("Error fetching follow counts")
console.error("Error fetching follow counts:", err)
}
}
실제로 팔로워는 follower_id와 eq로 userId를 맞춰야하고, 팔로잉은 following_id컬럼과 맞춰야했는데 그걸 반대로 해줬었다. 그래서 그부분을 바로잡아서 해결했고 fetch해주는 fetchFollower함수와 fetchFollowing함수도 똑같은 에러가 있어서 바로잡아주었다.
엄청 많은 힘듦이 있던 부분이지만 자체 과정 생략..!
const toggleFollow = async (targetUserId: string) => {
try {
if (isFollowing) {
await handleUnfollow(targetUserId)
} else {
await handleFollow(targetUserId)
}
// 팔로우 상태를 토글
setIsFollowing(!isFollowing)
// 팔로우 카운트 업데이트
await fetchFollowCounts(userId)
} catch (error) {
toast({
title: "팔로우 상태 변경 실패",
description: (error as Error).message,
})
console.error("팔로우 상태 변경 실패:", error)
}
}
const handleFollow = async (targetUserId: string) => {
try {
const { error } = await supabase
.from("follows")
.insert({ follower_id: currentUserId, following_id: targetUserId })
if (error) throw error
} catch (error) {
toast({
title: "팔로우 오류",
description: (error as Error).message,
})
console.error("팔로우 오류:", error)
}
}
const handleUnfollow = async (targetUserId: string) => {
try {
const { error } = await supabase
.from("follows")
.delete()
.match({ follower_id: currentUserId, following_id: targetUserId })
if (error) throw error
} catch (error) {
toast({
title: "언팔로우 오류",
description: (error as Error).message,
})
console.error("언팔로우 오류:", error)
}
}
match 매서드를 통해서 follower_id와 following_id컬럼을 동일하게 가지고 있는 데이터만 찾아서 삭제해주는 언팔로우 함수를 만드니까 드디어 언팔로우가 가능하게 되었다. 기존에는 eq로 그냥 follower_id컬럼이나 following_id컬럼을 찾아서 삭제만 해주어서 수파베이스 입장에선, 1개의 데이터만 삭제시켜야하는데 겹치는 follower_id와 following_id가 여럿 존재할 수 있기에 인지하기 힘들 수 밖에 없었다고 생각한다.
팔로우 중이라면 언팔로우버튼 팔로우 안했다면 팔로우 버튼이 보이게 구현했어야했는데, 계속 팔로잉이 1이 추가된다거나, 언팔로우 로직이 먹히지 않는 toggleFollow 함수가 원인이였다. 그래서 좋아요쪽 참고해서 구현하려다 팔로우함수, 언팔로우함수를 구분지어서 구현해주었는데,
isFollowing
상태가 true면 팔로우 중, false면 언팔로우로 체크를 해줬어야했는데,
기존에는
const isFollowingUser = (targetUserId: string) =>
following.some((user) => user.user_id === targetUserId)
단순히 following목록에서 user_id와 현재 보고 있는 인수로 받아온 유저프로필페이지의 targetUserId가 일치하면 true를 반환하고 없다면 false를 반환하는 이 함수를 page.tsx에 내려줘서 이 값을 통해 true/false를 판단했었다.
그러나, 이건 그냥 수행하는 함수이지, 실질적으로 isFollowing상태를 만들었기 때문에, 그 상태로 팔로우 상태 판단을 해주었고,
팔로우 언팔로우 함수가 잘 수행되기 때문에,
const toggleFollow = async (targetUserId: string) => {
try {
if (isFollowing) {
await handleUnfollow(targetUserId)
} else {
await handleFollow(targetUserId)
}
// 팔로우 상태를 토글
setIsFollowing(!isFollowing)
// 팔로우 카운트 업데이트
await fetchFollowCounts(userId)
} catch (error) {
toast({
title: "팔로우 상태 변경 실패",
description: (error as Error).message,
})
console.error("팔로우 상태 변경 실패:", error)
}
}
여기서 잘 토글 수행이 가능하게 되었다.
그리고 fetchFollowingCounts를 통해 바로바로 숫자 업데이트까지 진행해주었다.
unknown을 지켜주면서 추가로 error에 대해 세팅
이렇게 하면 이후 중복되는 error에 대한 코드도 줄여주고, instanceOf Error로 error 타입이 Error라는걸 알려줄 수 있다.