[개인 프로젝트 ShareLife] SNS 만들기 팔로우 팔로잉 구현

규갓 God Gyu·2024년 10월 1일
0

프로젝트

목록 보기
68/81
post-thumbnail

이전 좋아요 기능과 큰 차이가 없을 것 같은 팔로우 팔로잉을 구현하기 전에
각 유저 페이지에 대해 구현하기로 하였다.

유저 페이지

게시글에 닉네임 추가

기존에는 게시만 잘 진행되게 구현했는데, 해당 유저의 프로필 페이지로 이동시키기 위해선 게시글에 닉네임을 추가시켜줘야만 했다.(거기서 눌러서 이동할 수 있게)

users 테이블에 프로필이미지 추가하는 과정에서 회원가입 에러

Failed to create error:Database error saving new user

이전 유저 테이블 구성할 때 유저에 대한 프로필사진을 따로 추가해놓지 않았는데, 유저 페이지를 만드는 만큼 게시글에 닉네임만 떡하니 적혀있으면 유저 페이지로 이동하는 닉네임인지 인지하기 아무리 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 등등 다양한 에러가 떠서 이참에 미들웨어를 활용해서 세션관리를 진행해보려한다.

next auth로 세션관리

인증 및 세션 관리를 간편하게 구현할 수 있도록 도와주는 라이브러리
사용하면 다양한 인증방법과 이메일/비밀번호 기반 인증을 쉽게 설정, 로그인 상태 유지

  • 간편한 설정
  • 보안성(CSRF 방지, 쿠키 기반 인증, JWT(JSON Web Tokens)와 같은 보안 기능을 기본적으로 지원)
  • 자동 세션 관리
    • 사용자의 로그인 상태를 자동으로 유지하고, 세션 만료 시 자동 로그아웃 기능을 제공하여 사용자의 세션을 효과적으로 관리
    • 서버 사이드 렌더링(SSR)에서도 세션을 쉽게 사용할 수 있습니다.
  • Typescript 지원
  • 커스터마이징 가능
    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를 사용하고 있다
차이점은

supabase anon key(NEXT_PUBLIC_SUPABASE_ANON_KEY)

브라우저에서 직접 호출되는 API 요청에 사용
클라이언트측에서 사용되기때문에 공개적으로 노출될 수 있어 민감한 작업 X

supabase service role key(SUPABASE_SERVICE_ROLE_KEY)

서버측에서 사용
완전한 권한을 가지고 있음, 인증되지 않은 사용자도 데이터베이스에 접근할 수 있음, 서버측에서만 사용됨

그래서 일단
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 에서 생성된 문자열을 넣어줬음

그리고나서 수 많은 에러를 마주하였는데,

session 타입 불일치

// 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쪽 형식에 에러가 떠서 하나씩 건들여보면

nextAuth 세션 타입 에러

일단 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에러가 떴었다.

그래도 어찌저찌 해결..!

nextAuth / middleware / supabase 혼합 코드 정리

'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 렌더링 에러

그래서 아예 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가 자식 컴포넌트에 세션 정보를 제공하게 됨

nextAuth

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사용하여 설정된 인증 옵션을 기반으로 핸들러 생성
GETPOST 요청을 처리할 수 있도록 핸들러를 내보냄

middleware

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 - 미들웨어가 적용될 경로 설정

첫 페이지에서 /login 으로 리디렉션 에러

내 루트 페이지는 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 /session/ token

JWT(JSON Web Token)
웹 애플리케이션에서 정보의 전송과 인증을 위해 사용되는 JSON 기반의 토큰.
클라이언트와 서버 간에 정보를 안전하게 전달하는 데 사용

  • 헤더
    • 토큰의 유형과 서명 알고리즘을 정의 {"alg": "HS256", "typ": "JWT"}와 같은 형태
  • 페이로드
    • 토큰에 담길 실제 데이터(사용자 정보, 권한, 만료 시간) - 일반적으로 비공식적
  • 서명
    • 토큰의 무결성을 보장, 헤더와 페이로드를 합친 후, 비밀 키를 사용하여 해시 값 생성

작동방식

  1. 사용자가 로그인하면 서버는 JWT를 생성하고 클라이언트에 전달
  2. 클라이언트는 이 JWT를 저장하고, 후속 요청 시 HTTP 헤더에 JWT를 포함하여 서버에 요청을 보냄
  3. 서버는 JWT 를 검증하고 , 유효한 경우 요청 처리

장점

  1. Stateless : 서버는 클라이언트 상태를 저장할 필요가 없음
  2. 스케일링 용이 : 서버간에 상태를 공유할 필요가 없으므로 쉽게 확장
  3. 저체 포함: 자체적으로 필요한 정보를 포함하므로 추가적인 데이터 베이스 조회가 필요 없음

단점

  1. 보안 위험: 클라이언트 측에서 노출될 위험
  2. 리프레시 어려움: 만료 시간 지나면 새로운 토큰 발급받아야 하며, 이 과정에서 리프레시 토큰과 같은 추가적인 메커니즘 필요

session
서버 측에서 사용자와의 상호작용 상태를 저장하고 관리하는 방식

  1. 사용자 로그인
  • 서버는 해당 사용자에 대한 세션을 생성하고 고유한 세션 ID를 발급
  • 이 세션 ID는 클라이언트에 쿠키로 저장
  1. 세션 저장
  • 서버는 세션 데이터를 메모리, 데이터베이스 또는 세션 스토리지와 같은 저장소에 저장.(사용자의 상태, 인증 정보, 권한 등이 포함)
  1. 후속 요청
  • 클라이언트가 서버에 후속 요청을 보낼 때, 클라이언트는 요청 헤더에 세션 ID를 포함(일반적으로 쿠키를 통해 전송)
  • 서버는 일정 시간이 지나면 만료될 수 있음. 만료된 세션 ID는 더 이상 유효하지 않으며, 사용자는 다시 로그인해야함.

장점

  • 서버 측 관리: 클라이언트가 세션 데이터 조작 불가
  • 보안
  • 로그아웃 기능: 서버에서 세션 제거하면 클라이언트는 즉시 로그아웃

단점

  • 서버 메모리 사용
  • 확장성 문제 : 서버 여러대로 확장하면, 세션 상태를 공유해야 하므로 추가적인 설정 필요

토큰
사용자의 인증 정보를 클라이언트에 저장하는 방식, 서버에 상태 정보를 저장하지 않는 방식

게시글 작성 오류(새로 프로젝트 세팅하면서 이미 겪었던 오류들을 또 겪음)

새로 세팅하고 다시 만들다보니 짜잘한 오류가 발생되고 있는데 하나씩 바로잡아보겠다

이미지 추가 오류

storage에 이미지 저장 후 url로 받아와서 posts 테이블에 넣어줘야하는데 storage를 사용하지 않고 있어서 기존 이름과 똑같이 'images'로 만든 후,

모든 권한 허용해서 해결될 것 같음

Could not find the '0' column of 'posts' in the schema cache

게시글 작성 중 이런 에러가 뜨고 있는데,

모든 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 }]) 객체형태로 담아주지 않아 저런 에러가 발생했었다.

new row violates row-level security policy for table "posts"

이제 객체로 잘 담아주니 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);

이걸로 게시물 생성은 해결...!
인줄 알았지?

게시글에 users 테이블에 있는 nickname, profile_image를 불러와서 넣는데 fetch 에러

기존에는 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)값을 참조하므로 외래 키만 잘 연결해놓으면 상관없는 듯 하다

post에서 users profile_image undefined

기존 수바베이스 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를 객체로 다시 타입을 넣어주니 드디어 사진과 닉네임을 불러올 수 있었다.

Image 태그 관련 에러

그 과정에서 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 컴포넌트가 이미지 최적화에 장점이 있다는건 알았는데,

따로 살펴보면

Image 컴포넌트

장점

  • 자동 이미지 최적화
  • 반응형 이미지 - srcSet 속성을 자동으로 생성(다양한 화면 크기에서 다양한 크기로 로드)
  • 레이지 로딩 지원
  • 자동 스크롤 위치 보존
    =이미지 비동기 로딩할 때 스크롤 위치 유지

단점

  • 정적 경로 요구(외부 API로부터 가져온 이미지 제약 있을 수 있음)
  • 설정 복잡성
  • 레이아웃 제약 - 부모 컨테이너 크기 고정되어 있지 않다면 , 예상치 못한 레이아웃 문제 발생할 수 있음

이제 드디어 follow.ts관련되서 다시 시작해보자 길었다 정말

useLike.ts의 좋아요 기능과 유사한 코드로 작성하려고 하였고,

follows 테이블에 follower_id(팔로우하는 사람) / following_id(팔로우 당하는 사람) 컬럼을 세팅해놨고, 팔로우 관계를 정리해야했다.

아직도 글로써도 살짝 헤깔리는데(인스타를 잘안함 ㅠ)

  • followingUsers - 내가(유저가) 팔로우한 유저 목록
  • followers - 나를 팔로우한 유저 목록
  • followCounts - 팔로워 및 팔로잉 수를 카운트하는 상태

<함수>

  • 팔로우 / 팔로잉 데이터 가져오기
  • 팔로우 토글 기능

팔로우 팔로잉 타입 에러

수파베이스의 팔로우 팔로잉 불러오는 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 핸들러도 추가해줬는데,

reqres는 각각 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에러였는데,

원인은 follows테이블엔 nickname과 profile_image열이 없고 users에서 가져오는건데 그 부분을 코드로 알려주는데 한계가 있었기 때문이다.

내가 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을 추가해서 당장은 해결하였다 이해가 안간다

이전에 잘만되던게 갑자기 안되었던게...;;

cannot read properties of undefined(reading'id')

게시글을 작성한 유저가 아닌 로그인한 유저의 프로필 페이지로 이동하는 문제

기존에 문제가 없던 코드인데 워낙 많은 부분 에러를 건들다보니 url은 게시글 작성한 유저의 id url로 이동은 되나 로그인한 사용자의 닉네임과 프로필페이지가 뜨고 있었다.

이 부분은 기존에 session관리할때의 fetch함수를 이용해서 가져오다보니 거기의 인수값은 로그인한 유저의 아이디여서 인수로 params값을 가져와서 그 값과 일치하는 nickname과 프로필이미지를 supabase에서 가져오는 방법을 채택했다.

아예 새로 게시글을 작성한 유저의 nickname과 id와 profileImage를 가져오는 함수를 생성해서 그 값을 postNickname, postProfileImage useState에 담아서 가져와서 해결하였다.

useAuth에서 useEffect로 fetchPostUserData함수 호출하는것과 Profile 컴포넌트에서 useEffect로 함수 호출하는 차이

둘다 useEffect를 통해 함수 호출을 진행해야 수파베이스에있는 게시글 작성한 닉네임, 프로필이미지를 불러올 수 있었는데, useAuth에서 useEffect로 실행하니 에러가 발생했다.

그 이유는 useAuth에서 호출할땐 인수로 받아오는 id가 undefined일 수 있고, 아직 설정되지 않은 상태일 수 있어서이다.

그냥 안전하게 Profile에서 함수 호출해서 사용하자

로그인한 유저 기준 A유저의 유저페이지에서 팔로우버튼을 누르면 팔로잉쪽이 추가되는게 아닌, 팔로워쪽에 1이 추가되는 오류

이게 사실상 지금까지의 팔로우 팔로잉의 근본적인 에러였는데, 이 부분을 점점 알아내기 힘들었던 이유는 후반부갈수록 힘들다는 이유로 챗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를 통해 바로바로 숫자 업데이트까지 진행해주었다.

try catch쪽 error 타입 오류


unknown을 지켜주면서 추가로 error에 대해 세팅

이렇게 하면 이후 중복되는 error에 대한 코드도 줄여주고, instanceOf Error로 error 타입이 Error라는걸 알려줄 수 있다.

말도 많고 탈도 많던 팔로우 페이지 ..

다신 보지말자

profile
웹 개발자 되고 시포용

0개의 댓글