기록

PWA 푸시알림 구현하기(Next.js + PWA + Push Notification + Supabase) - 2 본문

TIL*

PWA 푸시알림 구현하기(Next.js + PWA + Push Notification + Supabase) - 2

mnmhbbb 2024. 8. 21. 23:53

4. Next.js + Supabase를 사용하여 로그인 기능 추가하기

이전 포스팅에 구독과 푸시 알림 테스트는 마쳤으니, 이제 본격적으로 사용자별 알림 기능을 만들기 위해 로그인을 붙인다.

1) email, password 인풋 2개로 간단하게 만들고, 
2) 로그인 또는 회원가입 버튼을 선택할 수 있게 한다.

supabase 공식문서 설명을 따라 진행하였다.

 

4.1 테이블 세팅

supabase에서 제공하는 방식(ex. supabase.auth.signUp() 등)로 회원가입을 할 경우,
유저의 정보가 auth.users에 저장되는데, 그것을 내가 만든 public.user에서도 관리할 수 있다. 

1) 외래키 설정
유저 정보를 담는 users 테이블을 public에 새로 만들고, users의 id를 supabase에서 제공하는 auth 스키마 users 테이블의 id와 연결하여 foreign key로 만든다.
즉, public.users.id -> auth.users.id

auth.users.email이 varchar라서 public.users.email도 varchar로 설정하였다.
구독정보 객체를 통으로 subscription_data에 저장할 예정인데, 타입을 json과 jsonb 중에 고민하다가 jsonb로 하였다.
(chatGPT: jsonb는 이진 형식으로 데이터를 저장하며, 더 나은 쿼리 성능을 제공합니다. 데이터가 저장될 때 파싱되기 때문에 저장 속도는 다소 느릴 수 있지만, 쿼리 성능이 훨씬 우수하고, 내부적으로 인덱스를 사용할 수 있습니다.)

그 외, Project Settings > Authentication에 들어가면 다양한 설정을 추가할 수 있다.

나의 경우는 이메일 컨펌을 추가로 받지 않을 거라서,
Authentication > Providers > Email에서 Enable Email provider 빼고 전부 스위치 오프하였다.

 

2) 트리거 설정
auth.users에 데이터가 저장될 때 내 user 테이블에도 저장하려면 트리거가 필요하다.

공식 문서에 나와있는 예시는 이러하다:
raw_user_meta_data 이 부분은, supabase에서 기본으로 제공하는 프로퍼티 이외에 옵셔널로 입력 받는 유저 정보에 대한 처리이다.

-- inserts a row into public.profiles
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, first_name, last_name)
  values (new.id, new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data ->> 'last_name');
  return new;
end;
$$;

-- trigger the function every time a user is created
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

나의 경우는 최초 회원가입 시에는 email만 auth.users에서 가져올 것이므로 다음과 같이 수정한다:

-- inserts a row into public.users
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.users (id, email)
  values (new.id, new.email);
  return new;
end;
$$;

-- trigger the function every time a user is created
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

SQL Editor에서 Run 하면 끝.

 

+) 이때 만약, 이전에 위 쿼리를 실행한 적이 있는데 수정 후 다시 실행하려고 한다면,

DROP FUNCTION public.handle_new_user() CASCADE

먼저 삭제한 다음에 다시 실행해야 중복되지 않고 정상적으로 run 된다.
(참조: https://github.com/supabase/supabase/issues/2310)

 

3) public.users에 RLS 설정하기

DB 데이터에 대한 권한을 구분할 수 있다. 예를 들어 자신의 데이터만 조회할 수 있게 하는 방법 등이 있는데,
현재 프로젝트의 경우 로그인한 경우(인증된 사용자인 경우) 모든 데이터를 접근 할 수 있게 하려고 한다.
Add RLS Policy - Target Roles: Authenticated - template 중 all를 선택(CRUD 전체 가능)
하면 다음 내용이 자동으로 입력된다.

create policy "Enable delete for users based on user_id"
on "public"."users"
as PERMISSIVE
for ALL
to authenticated
using (
  (select auth.uid()) = id
);

save policy

추후 권한을 수정해 볼 예정.

 

4.2 화면 만들기

이제 테이블 세팅은 마쳤고, 회원가입/로그인 화면을 만들 차례
Next.js Guide를 참조하여 이전 포스팅에서 안 하고 넘어갔던 미들웨어 추가를 해보자.

(4. Hook up Middleware)를 보면,

Since Server Components can't write cookies, you need middleware to refresh expired Auth tokens and store them. 
(서버 컴포넌트에서는 쿠키를 작성할 수 없기 때문에, 만료된 인증 토큰을 레프레쉬하고 저장하기 위해 미들웨어가 필요하다.)

그래서 미들웨어를 만들자.

/middleware.ts:
모든 요청에 대해 미들웨어가 실행되어 supabase 세션을 갱신하도록 한다.
정적파일을 제외한 모든 경로의 파일에 미들웨어가 적용되도록 설정하고 있다.

import { updateSession } from "@/utils/supabase/middleware";
import { type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  // update user's auth session
  return await updateSession(request);
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * Feel free to modify this pattern to include more paths.
     */
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

/utils/supabase/middleware.ts:

  • supabase.auth.getUser()를 호출하면사용자의 정보를 가져오고 토큰을 갱신할 수 있다. 내부적으로 다음 동작을 한다고 한다.
    • 제공된 액세스 토큰을 검증한다.
    • 액세스 토큰이 만료된 경우, 저장된 리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰 쌍을 발급받는다.
    • 새로 발급받은 토큰으로 사용자 데이터를 반환한다.
  • 또한 미들웨어는 세션 관련 쿠키를 적절히 관리하여(request.cookies.set과 response.cookie.set) 클라이언트와 서버 간의 인증 상태를 동기화한다.
  • 최종적으로 응답 객체를 반환한다.
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
          supabaseResponse = NextResponse.next({
            request,
          });
          cookiesToSet.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options));
        },
      },
    },
  );

  // refreshing the auth token
  await supabase.auth.getUser();

  return supabaseResponse;
}
더보기

미들웨어의 역할:

  1. 인증 토큰 새로고침 (리프레시 토큰 기능 내장)
    • 미들웨어는 supabase.auth.getUser() 를 호출하여 사용자의 인증 토큰을 새로고칩니다.
    • 토큰이 만료되었거나 유효하지 않은 경우, Supabase는 새로운 토큰을 발급한다.
    • 이를 통해 사용자가 항상 유효한 토큰을 가지고 있음을 보장할 수 있다.
  2. 서버 컴포넌트에 새로고친 토큰 전달
    • 미들웨어는 request.cookies.set()을 사용하여 새로고친 인증 토큰을 쿠키에 저장합니다.
    • 이후 서버 컴포넌트에서 이 쿠키를 읽어 사용자 세션 정보에 접근할 수 있습니다.
    • 이렇게 함으로써 서버 컴포넌트에서 중복으로 토큰을 새로고치는 작업을 방지할 수 있습니다.
  3. 브라우저에 새로고친 토큰 전달
    • 미들웨어는 response.cookies.set()을 사용하여 새로고친 인증 토큰을 브라우저에 전달합니다.
    • 이를 통해 브라우저에 저장된 이전 토큰을 새로운 유효한 토큰으로 대체할 수 있다.
    • 클라이언트 측에서도 항상 유효한 토큰을 사용하여 인증 문제를 방지할 수 있다.

이렇게 미들웨어를 생성하면, 리프레시 토큰 로직을 직접 구현하지 않아도 된다.

 

그 다음 절차는 아주 간단하다. supabase에서 제공하는 회원가입/로그인 함수를 호출하여 기능을 붙이면 된다.
(이메일 인증 기능은 생략)

/app/login/page.tsx:

더보기
"use client";

import React, { useState, FormEvent } from "react";
import { login, signup } from "./action";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";

const SignInPage = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [showPassword, setShowPassword] = useState(false);

  const handleSubmit = async (event: FormEvent<HTMLFormElement>, action: (formData: FormData) => Promise<void>) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    try {
      await action(formData);
    } catch (error) {
      console.error("Error:", error);
    }
  };

  const togglePasswordVisibility = () => {
    setShowPassword(!showPassword);
  };

  return (
    <div className="flex justify-center items-center min-h-screen bg-gray-100">
      <form className="bg-white p-8 rounded-lg shadow-md w-96" onSubmit={(e) => handleSubmit(e, login)}>
        <div className="mb-4">
          <label htmlFor="email" className="block text-gray-700 text-sm font-bold mb-2">
            Email:
          </label>
          <input
            id="email"
            name="email"
            type="email"
            required
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full px-3 py-2 border text-black border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>
        <div className="mb-6">
          <label htmlFor="password" className="block text-gray-700 text-sm font-bold mb-2">
            Password:
          </label>
          <div className="relative">
            <input
              id="password"
              name="password"
              type={showPassword ? "text" : "password"}
              required
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              className="w-full px-3 py-2 border text-black border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
            <button
              type="button"
              onClick={togglePasswordVisibility}
              className="absolute inset-y-0 right-0 pr-3 flex items-center"
            >
              {showPassword ? (
                <EyeSlashIcon className="h-5 w-5 text-gray-500" />
              ) : (
                <EyeIcon className="h-5 w-5 text-gray-500" />
              )}
            </button>
          </div>
        </div>
        <div className="flex flex-col space-y-4">
          <button
            type="submit"
            className="w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
          >
            로그인
          </button>
          <button
            type="button"
            onClick={(e) => {
              e.preventDefault();
              const form = e.currentTarget.form;
              if (form) handleSubmit(new Event("submit") as any as FormEvent<HTMLFormElement>, signup);
            }}
            className="w-full bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
          >
            회원가입
          </button>
        </div>
      </form>
    </div>
  );
};

export default SignInPage;

비밀번호 표시 아이콘을 사용하기 위해 heroicons 설치(tailwindcss와 함께 사용하기 편하다고 하여)

pnpm add @heroicons/react

 

/app/login/actions.ts:
로그인이 완료되면 /private로 리다이렉트 시켜서 로그인 한 유저 이메일을 띄우려고 한다.

더보기
"use client";

import React, { useState, FormEvent } from "react";
import { login, signup } from "./actions";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";

const LoginPage = () => {
  const [showPassword, setShowPassword] = useState(false);

  const togglePasswordVisibility = () => {
    setShowPassword(!showPassword);
  };

  return (
    <div className="flex justify-center min-h-screen bg-gray-100">
      <form className="bg-white p-8 rounded-lg shadow-md w-96">
        <div className="mb-4">
          <label htmlFor="email" className="block text-gray-700 text-sm font-bold mb-2">
            Email:
          </label>
          <input
            id="email"
            name="email"
            type="email"
            required
            className="w-full px-3 py-2 border text-black border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>
        <div className="mb-6">
          <label htmlFor="password" className="block text-gray-700 text-sm font-bold mb-2">
            Password:
          </label>
          <div className="relative">
            <input
              id="password"
              name="password"
              type={showPassword ? "text" : "password"}
              required
              className="w-full px-3 py-2 border text-black border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
            <button
              type="button"
              onClick={togglePasswordVisibility}
              className="absolute inset-y-0 right-0 pr-3 flex items-center"
            >
              {showPassword ? (
                <EyeSlashIcon className="h-5 w-5 text-gray-500" />
              ) : (
                <EyeIcon className="h-5 w-5 text-gray-500" />
              )}
            </button>
          </div>
        </div>
        <div className="flex flex-col space-y-4">
          <button
            formAction={login}
            className="w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
          >
            로그인
          </button>
          <button
            formAction={signup}
            className="w-full bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
          >
            회원가입
          </button>
        </div>
      </form>
    </div>
  );
};

export default LoginPage;

app/error/page.tsx:

export default function ErrorPage() {
  return <p>Sorry, something went wrong</p>;
}

 

로그인이 완료되면, 로그인 사용자만 접근할 수 있는 /private 페이지를 만들고, 유저 정보를 가져온다.
서버 컴포넌트는 쿠키를 읽을 수 있기 때문이다.

import { redirect } from 'next/navigation'

import { createClient } from '@/utils/supabase/server'

export default async function PrivatePage() {
  const supabase = createClient()

  const { data, error } = await supabase.auth.getUser()
  if (error || !data?.user) {
    redirect('/login')
  }

  return <p>Hello {data.user.email}</p>
}

여기까지 잘 실행되고, auth.users에 저장된 유저 정보가 public.users에도 잘 저장되는 것도 확인되었다.
로그인이 성공하면 cookie에 데이터가 정상적으로 들어오는 것도 확인된다.
이 쿠키로 supabase가 getUser를 할 수 있는 것

 

 

TODO: supabase에서 제공하는 소셜 로그인 기능 추가하기:

Authentication > Providers > 원하는 소셜 서비스 선택

 

 

5. 보완

기존 푸시 알림 기능에 zustatnd, react-query 도입하고 로딩 스피너, 토스트 메시지 등을 추가하여 보완하기.

 

5.1 상태 관리 라이브러리 추가하기: zustand, react-query

그동안 리덕스 툴킷만 사용해 봐서, 평소 궁금했던 두 라이브러리를 사용하려고 한다.
react-query는 fetch 관련 부분에 집중하고, zustand는 프로젝트 전반적인 상태 관리를 하기 위해 설치하였다.

파일도 가볍지만, 리덕스 툴킷과 달리 초기 설정할 것이 거의 없다는 장점이 있다.

pnpm add zustand @tanstack/react-query

추가로, react-query에서 추천하는 eslint 플러그인도 설치해보자.

pnpm add -D @tanstack/eslint-plugin-query

필요한 세팅을 한다.

react-query를 전역에서 적용하기 위해 layout.tsx에 QueryClientProvider로 children을 감싸야 하는데,
useState를 사용하기 위해서 hooks에 훅 파일로 만들어서 사용하려고 한다.
SSR 프레임워크인 Next.js에서는 다음과 같이 useState를 사용한다는 것이 특징이다.
공식 문서

// useReactQuery.tsx
"use client";

import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

export default function ReactQueryClientProviders({ children }: React.PropsWithChildren) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // With SSR, we usually want to set some default staleTime
            // above 0 to avoid refetching immediately on the client
            staleTime: 60 * 1000,
          },
        },
      }),
  );

  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

왜냐하면, useState와 함수 초기화를 사용하면 컴포넌트가 마운트될 때 QueryClient가 한 번만 생성되고, 이후 렌더링에서는 동일한 인스턴스가 사용되기 때문. 매렌더링마다 불필요하게 재생성되는 것을 방지한다.

 

최종적으로 layout.tsx에 다음과 같이 useReactQuery를 사용하였다.
이제 전역에서 react-query를 사용할 수 있게 된다.

import ReactQueryClientProvider from "@/hooks/useReactQuery";
...
  return (
    <html lang="ko_KR">
      <body className={sans.className}>
        <ReactQueryClientProvider>
          {children}
          <Pwa />
        </ReactQueryClientProvider>
      </body>
    </html>
  );

 

react-query eslint 플러그인 설정도 공식문서를 참조하여 .eslintrc.js를 다음과 같이 설정:

{
  "plugins": ["@tanstack/query"],
  "extends": ["next/core-web-vitals", "plugin:@tanstack/eslint-plugin-query/recommended"]
}

 

zustand는 별도로 해야 할 설정은 없고,
만약 dev tool을 사용하려면, redux dev tool을 사용하면 되고, 다음과 같이 steatCreator를 사용하여 devtools로 감싸주기만 하면 된다.

import { create, StateCreator } from "zustand";
import { devtools } from "zustand/middleware";

const storeCreator: StateCreator<UserState> = (set) => ({
  isLoggedIn: false,
  user: null,
  isPushSubscribed: false,
  setUser: (user) => set({ user, isLoggedIn: !!user }),
  setPushSubscription: (isSubscribed) => set({ isPushSubscribed: isSubscribed }),
  logout: () => set({ user: null, isLoggedIn: false }),
});

export const useUserStore = create<UserState>()(devtools(storeCreator, { name: "UserStore" }));

zustand/middleware/immer를 사용할 수도 있다. 추후에 도입해 볼 것.

 

 

이제 로그인 유저 정보(로그인 여부 포함)를 저장하는 로직을 작성해보자.

/store/userStore를 만들고, 

import { create } from "zustand";

interface User {
  id: string;
  email: string;
}

interface UserState {
  isLoggedIn: boolean;
  userInfo: User | null;
  isPushSubscribed: boolean;
  setUser: (user: User | null) => void;
  setPushSubscription: (isSubscribed: boolean) => void;
  logout: () => void;
}

export const useUserStore = create<UserState>((set) => ({
  isLoggedIn: false,
  userInfo: null,
  isPushSubscribed: false,
  setUser: (user) => set({ userInfo: user, isLoggedIn: !!user }),
  setPushSubscription: (isSubscribed) => set({ isPushSubscribed: isSubscribed }),
  logout: () => set({ userInfo: null, isLoggedIn: false }),
}));

구독 정보 객체 자체를 저장하는 것은 보안이나 저장 공간 측면에서 좋지 않다고 생각되어
푸시 알림 구독 여부 정도만 저장하려고 한다.

 

기존 코드에 적용한다.

최초 접근 시에 항상 로그인 여부를 체크하여 store에 저장해야 한다.
현재 supabase로 로그인이 완료되면 refresh cookie에 토큰이 알아서 저장되고 있다.

매 페이지에서 접근 권한을 체크하기 위해서 layout과 같은 곳에서 매번 제일 처음 해야 하는 것은 유저 정보(권한) 체크

이전에 next-auth로 구현했던 로직 복기:
next-auth의 useSession으로 만료여부 체크함

만료되지 않았으면 그 session에서 accessToken, refreshToken, idToken 을 가져와서 api 로컬 클래스에 저장함.
기존에 클래스에 저장되어있던 token과 이번에 session에서 가져온 accessToken이 같으면 다음 절차를 생략. 다르다면 클래스의 token에 accessToken을 저장시킴
토큰이 있는 것이 확인되면 유저정보 가져오는 api도 요청을 하고 이 정보를 store에 저장시킨다.

이때 사용하면 좋은 것이 바로, supabase-js 패키지이다. 처음 설치하고 아직 사용을 안했는데, 
그동안 supabase 세팅에 사용했던 @supabase/ssr은 말 그대로 서버 사이드 렌더링(SSR) 환경에서 Supabase를 사용하기 위해 설계된 패키지라면 @supabase/supabase-js는 다음 역할을 한다고 docs에서 소개하고 있다.

This reference documents every object and method available in Supabase's isomorphic JavaScript library, supabase-js. You can use supabase-js to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.

이 참조 문서는 Supabase의 범용(클라이언트와 서버 양쪽에서 모두 사용 가능한) JavaScript 라이브러리인 supabase-js에서 사용 가능한 모든 객체와 메소드를 설명합니다. supabase-js를 사용하여 Postgres 데이터베이스와 상호 작용하고, 데이터베이스 변경 사항을 수신하며, Deno Edge Functions를 호출하고, 로그인 및 사용자 관리 기능을 구축하며, 대용량 파일을 관리할 수 있습니다.

 

/utils/supabase/supabase-js.ts 파일을 만들고, 다음과 같이 설정 파일을 만든다:
(이 코드는 이후에 수정됨)

import { createClient } from "@supabase/supabase-js";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

export default function createSupabaseJs() {
  return createClient(supabaseUrl, supabaseAnonKey, {
    auth: {
      persistSession: false,
    },
  });
}

autoRefreshToken라는 설정이 있는데, 이는 기본값 true이다. 이 설정을 하면 supabase에서 액세스 토큰이 만료되기 전에 자동으로 새로운 토큰을 요청하며 토큰 관리를 알아서 해주기 때문에 나는 이 설정을 활성화했다.
persistSession이라는 설정을 하면 사용자 세션을 localStorage에 저장하는데, 나는 false로 했다. 그렇게 하면 메모리에만 저장되기 때문에 새로고침을 했을 때 세션 정보가 사라진다.

(단, supabase는 persistSession 설정과 관계없이 쿠키에 세션 정보를 저장한다.)

 

이제 useAuth를 만들자. 인증 관련 로직은 이 곳에서 한 번에 관리하기 위함이다.(단일 책임 원칙)
/hooks/useAuth.ts는 supabase-js 공식문서를 참조하여 각 상황을 감지하여 store에 데이터를 업데이트하도록 짰다.

hooks/useAuth.ts:

더보기
import { User, useUserStore } from "@/store/userStore";
import supabase from "@/utils/supabase/client";
import supabaseJs from "@/utils/supabase/supabase-js";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

export const useAuth = () => {
  const { user, setUser } = useUserStore();
  const router = useRouter();

  const checkUser = async () => {
    const {
      data: { user },
    } = await supabase.auth.getUser();
    updateUser(user as User);
  };

  useEffect(() => {
    const { data: authListener } = supabaseJs.auth.onAuthStateChange(
      (event: string, session: any) => {
        updateUser(session?.user as User);
        if (event === "SIGNED_IN") {
          console.log(event, "user is signed in");
          router.push("/private");
        }
        if (event === "SIGNED_OUT") {
          console.log(event, "Signing out");
          router.push("/");
        }
      },
    );

    checkUser();

    return () => {
      authListener.subscription.unsubscribe();
    };
  }, []);

  const updateUser = (authUser: User | null) => {
    if (authUser) {
      setUser({ id: authUser.id, email: authUser.email! });
    } else {
      setUser(null);
    }
  };

  const login = async (formData: FormData) => {
    const data = {
      email: formData.get("email") as string,
      password: formData.get("password") as string,
    };

    try {
      const { error } = await supabase.auth.signInWithPassword(data);
      if (error) throw error;
    } catch (error: any) {
      console.error("Error logging in:", error.message);
      throw error;
    } finally {
    }
  };

  const signup = async (formData: FormData) => {
    const data = {
      email: formData.get("email") as string,
      password: formData.get("password") as string,
    };
    try {
      const { error } = await supabase.auth.signUp(data);
      if (error) throw error;
    } catch (error: any) {
      console.error("Error signing up:", error.message);
      throw error;
    } finally {
    }
  };

  const logout = async () => {
    try {
      const { error } = await supabase.auth.signOut();
      if (error) throw error;
    } catch (error: any) {
      console.error("Error logging out:", error.message);
      throw error;
    } finally {
    }
  };

  return { user, login, signup, logout };
};

 

useAuth.ts를 만들고 나니 supabase/client.ts와 겹쳐서 다음 경고창이 떴다.

Multiple GoTrueClient instances detected in the same browser context. It is not an error, but this should be avoided as it may produce undefined behavior when used concurrently under the same storage key.

동일한 브라우저에서 GoTrueClient 라는 supabase 인증 클라이언트를 여러 개 생성하였다.

supabase-js에서 만든 설정 파일과, supabase/client.ts의 supabase 인증 클라이언트를 모두 사용하기 때문이다.

각 설정 파일을 다음과 같이 수정하여 단일 인스턴스를 사용하도록 하여 경고 메시지를 없앴다.
또한, 매 요청마다 새 인스턴스를 생성하는 단점도 개선하였다.

// supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";

// Create a supabase client on the browser with project's credentials
const createClient = () =>
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  );

const supabase = createClient();

export default supabase;
// /supabase/supabase-js.ts
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

const createSupabaseJs = () =>
  createClient(supabaseUrl, supabaseAnonKey, {
    auth: {
      persistSession: false,
    },
  });

const supabaseJs = createSupabaseJs();

export default supabaseJs;

 

이제 다시 useAuth.ts를 다듬는다.

import { useUserStore } from "@/store/userStore";
import { useLoadingStore } from "@/store/loadingStore";
import supabase from "@/utils/supabase/client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useCallback, useState } from "react";

export const useAuth = () => {
  const { user, setUser, logout: storeLogout } = useUserStore();
  const { setLoading, isLoading } = useLoadingStore();
  const router = useRouter();
  const pathname = usePathname();

  const updateUserState = useCallback(
    (session: any | null) => {
      if (session) {
        const { id, email } = session.user;
        setUser({ id, email });
        if (pathname === "/login") {
          router.push("/private");
        }
      } else {
        if (pathname !== "/login") {
          router.push("/login");
        }
        storeLogout();
      }
    },
    [pathname, router, setUser, storeLogout],
  );

  const checkUser = useCallback(async () => {
    setLoading(true);
    try {
      const {
        data: { session },
      } = await supabase.auth.getSession();
      updateUserState(session);
    } catch (error) {
      console.error("Error checking user session:", error);
    } finally {
      setLoading(false);
    }
  }, [updateUserState, setLoading]);

  useEffect(() => {
    checkUser();

    const { data: authListener } = supabase.auth.onAuthStateChange(async (event, session) => {
      console.log("Auth state changed:", event);
      updateUserState(session);
    });

    return () => {
      authListener.subscription.unsubscribe();
    };
  }, [checkUser, updateUserState]);

  const handleAuthAction = useCallback(
    async (
      action: "signInWithPassword" | "signUp" | "signOut",
      data?: { email: string; password: string },
    ) => {
      setLoading(true);
      try {
        const { error } = await supabase.auth[action](data!);
        if (error) throw error;
      } catch (error: any) {
        console.error(`Error during ${action}:`, error.message);
        throw error;
      } finally {
        setLoading(false);
      }
    },
    [setLoading],
  );

  const login = (formData: FormData) =>
    handleAuthAction("signInWithPassword", {
      email: formData.get("email") as string,
      password: formData.get("password") as string,
    });

  const signup = (formData: FormData) =>
    handleAuthAction("signUp", {
      email: formData.get("email") as string,
      password: formData.get("password") as string,
    });

  const logout = () => handleAuthAction("signOut");

  return {
    user,
    isLoggedIn: !!user,
    isLoading,
    checkUser,
    login,
    signup,
    logout,
  };
};

 

이 훅을 바탕으로, 클라이언트 사이드에서도 인증 관련 미들웨어 역할을 하는 컴포넌트를 만들고자 한다.
위에서 만든 middleware.ts는 서버 사이드에서 매 요청마다 토큰을 점검하고, 갱신하는 동작을 했다면, 
클라이언트 사이드에서는 서버에서 작업한 내용을 바탕으로 userStore에 로그인 여부, 유저 정보 등 상태를 저장하려고 한다.

/components/ClientSideMiddleware.tsx를 만들어서 layout.tsx에서 렌더링 시킨다.
middleware.ts로 SSR은 물론이고, 이 컴포넌트로 CSR에서 라우트를 이동할 경우까지 대응할 수 있다.
(/app 경로 방식을 사용하는 경우는 next/navigation를 통해 라우트 변경을 간단하게 감지할 수 있게 되었다.)

"use client";

import { useEffect } from "react";
import { usePathname } from "next/navigation"; // next/navigation에서 useRouter를 가져옵니다
import { useAuth } from "@/hooks/useAuth";

const AuthStateHandler = () => {
  const pathname = usePathname();
  const { checkUser } = useAuth();

  useEffect(() => {
    // 페이지 이동(경로 변경) 시마다 로그인 상태 확인
    const handleRouteChange = () => {
      checkUser(); // 로그인 상태 갱신
    };

    handleRouteChange();
  }, [pathname, checkUser]);

  return null;
};

export default AuthStateHandler;
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko_KR">
      <body className={sans.className}>
        <ReactQueryClientProvider>
          <Header />
          {children}
          <AuthStateHandler />
          <Pwa />
        </ReactQueryClientProvider>
      </body>
    </html>
  );
}

이제 서버 사이드/클라이언트 사이드 각각 필요한 세션 체크를 통해 각 페이지의 권한, 상태 저장을 할 수 있게 됐다.

 

 

5.2 로그아웃 기능

useAuth에서 만든 logout 가져와서 실행시킴. 간단.

 

5.3 로딩 스피너

/store/loadingStore.ts 생성하는데, 원래는 로딩키를 만들어서 각각의 상황을 구분하여 로딩 상태를 표시하려고 했으나,
우선은 다음과 같이 간단하게 만들고 이후에 발전시키려고 한다.

import { create, StateCreator } from "zustand";
import { devtools } from "zustand/middleware";

interface LoadingState {
  isLoading: boolean;
  setLoading: (isLoading: boolean) => void;
}

const storeCreator: StateCreator<LoadingState> = (set) => ({
  isLoading: false,
  setLoading: (isLoading) => set({ isLoading }),
});

export const useLoadingStore = create<LoadingState>()(
  devtools(storeCreator, { name: "LoadingStore" }),
);

 

GlobalLoadingSpinner 컴포넌트를 생성하여, 전역에서 로딩 표시를 할 수 있고, /app/layout.tsx에 적용하였다.

"use client";

import { useLoadingStore } from "@/store/loadingStore";
import React from "react";

const GlobalLoadingSpinner = () => {
  const { isLoading } = useLoadingStore();

  return isLoading ? (
    <div className="fixed inset-0 flex justify-center items-center bg-gray-800 bg-opacity-50 z-50">
      <div className="w-12 h-12 border-4 border-dashed rounded-full animate-spin border-blue-500"></div>
    </div>
  ) : null;
};

export default GlobalLoadingSpinner;

 

/app/layout.tsx:

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko_KR">
      <body className={sans.className}>
        <ReactQueryClientProvider>
          <GlobalLoadingSpinner />
          <Header />
          {children}
          <AuthStateHandler />
          <Pwa />
        </ReactQueryClientProvider>
      </body>
    </html>
  );
}

 

 

 

5.5 토스트 메시지

pnpm add react-hot-toast

회원가입 관련, 각종 통신 에러 등 표시하기 위함

 

TODO: 비밀번호 변경

 

 

6. 커스텀 푸시 알림 기능

알림 폼 받기.

  • 타이틀
  • 내용

기존에 임시로 설정했던 부분들 실제 유저 정보로 적용하기:

user 테이블 subscription_data에 값이 있으면, userStore의 isPushSubscription이 true가 됨

이때 인상적인 것은, supabase에서 제공하는 기능인지, jsonb 타입의 subscription 구독객체 데이터가 parse를 하지 않아도 알아서 자바스크립트 객체가 된다.
이 부분을 몰라서 처음엔 에러가 발생했는데, JSON.parse 로직을 제외하니 잘 되었다.

export async function POST(req: NextRequest) {
  try {
    const { id, title, body } = await req.json();
    const supabase = createClient();

    const { data, error } = await supabase.from("users").select("subscription_data").eq("id", id);

    if (error) {
      console.error("Supabase error:", error);
      return NextResponse.json({ message: error.message }, { status: 400 });
    } else {
      const notificationPayload = {
        title,
        body,
        icon: "/assets/icons/icon-192x192.png",
        badge: "/assets/icons/icon-192x192.png",
      };

      const rawPushSubscription = data?.[0].subscription_data;

 

title, description 폼 받기
react.js에서 form 받는 것이 편해져서 그 방법으로 빠르게 구현 완료.

 

 

6.1 잔 버그 고치기

화면 깜빡임 개선이 필요하다.
현재는 useAuth에서 세션을 체크하여 라우트를 이동시키고 있기 때문에, 일단 이동하고(화면 렌더링 후) 세션 확인하고 리다이렉트 되는 과정에서 깜빡임이 발생한다. 현재 supabase 공식문서를 따라 만든 middleware는 모든 요청에 대한 토큰을 관리하고 세션 정보를 리턴하는 역할만 하고 있기 때문이다.?
경로에 접근하기 전에, 요청에 따라(유저 권한에 따라) 리다이렉트하는 로직이 필요하다.

/supbase/middleware.ts에서 세션을 체크하고, 갱신하기 때문에 여기에서 유저 유무에 따라 리다이렉트 시키는 로직을 추가하였다.
추가로, 루트 경로 "/"는 권한 관계없이 접근 가능하도록 수정하였다.

import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next();

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) => {
            request.cookies.set(name, value);
          });
          supabaseResponse = NextResponse.next();
          cookiesToSet.forEach(({ name, value }) => {
            supabaseResponse.cookies.set(name, value);
          });
        },
      },
    },
  );

  // 세션 갱신을 위한 토큰 확인 (토큰이 만료되었을 경우 갱신)
  const {
    data: { user },
  } = await supabase.auth.getUser();

  // 유저가 없으면 로그인 페이지로 리다이렉트
  if (!user) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // 세션을 갱신한 후 페이지를 계속해서 처리
  return supabaseResponse;
}

 

/middleware.ts:

import { updateSession } from "@/utils/supabase/middleware";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  if (request.nextUrl.pathname === "/") {
    return NextResponse.next();
  }

  // 세션을 갱신하고 인증 체크
  return await updateSession(request);
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * Feel free to modify this pattern to include more paths.
     */
     // / 경로 예외처리:
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$|^/$).*)",
  ],
};

 

 

6.2 브라우저 구독 여부 + DB 구독 여부 동시 체크

현재 브라우저 서비스워커에 구독 기능을 승인한 상태이며 + 서비스워커로부터 받은 구독객체를 db에 저장하고 있는가?(푸시알림 보낼 때 이거 필요하기 때문)

 

6.3 axios 도입 + 요청/응답 마다 로딩 적용

axios interceptor 를 사용하여 모든 요청과 응답에 대해 로딩 스피너, 에러 처리 등 일관적인 로직을 적용하고자 한다.

 

6.4 react-query 적용

 

 

7 커스텀 푸시 알림 api 만들기

node schedule 사용해서 유저가 입력한 일시에 푸시 알림 트리거

pnpm install node-schedule @types/node-schedule

input type="datetime-local"로 입력 받아서, 해당 하는 시간으로 계산해서 그 시간에 webPush 보내기

 

/api/send-notification.ts에 로직을 추가하면 된다.

      const dateObj = new Date(dateTime);
      const utcDate = new Date(dateObj.getTime() - 9 * 60 * 60 * 1000);
      schedule.scheduleJob(utcDate, async function () {
        try {
          await webPush.sendNotification(pushSubscription, JSON.stringify(notificationPayload));
        } catch (pushError) {
          console.error("푸시 알림 전송 중 오류 발생:", pushError);
        }
      });

      return NextResponse.json(
        {
          message: `푸시 알림이 성공적으로 예약되었습니다.\n${dateObj.toLocaleString()}에 전송될 예정입니다.`,
        },
        { status: 200 },
      );
    }

하지만, 여기에서 내가 간과한 사실이 있었다. 
내가 배포하고 있는 Vercel은 서버리스 환경이기 때문에 계속 실행 상태가 아니라는 것이다. Vercel에서 제공하는 cron jobs를 사용할 수 있으나 이는 내가 설정한 주기대로 실행하는 것이기 때문에 유동적인 유저의 datetime을 적용하기가 곤란하다.(1분 주기로 실행시키면 가능은 하겠으나, 서버 부하가 심할 것이다.)

따라서 이 부분은 우선은 주석처리하고, ec2 환경에서 배포하여 적용할 계획이다. ec2는 항상 실행 중인 서버이기 때문.

 

 

+)

if (window.matchMedia('(display-mode: standalone)').matches) {
  console.log('This is a PWA!');
} else {
  console.log('This is not a PWA.');
}

이것을 이용해서, pwa에서만 실행되도록 하기.
음.. 브라우저에서 하고 싶으면? 개발모드에서만 가능하게 하기.. 최대한 웹앱 사용을 이끌어야 하기 때문

개발모드/운영모드 구분하려면? next.js에서?

if (process.env.NODE_ENV === 'development') {
  console.log('개발 모드');
} else if (process.env.NODE_ENV === 'production') {
  console.log('운영 모드');
}

그냥 npm run dev / npm run build npm run start를 하면 nextjs에서 알아서 위와 같이 구분해줌.

 

참조: 

https://velog.io/@39busy/supabase-auth

 

[supabase] Supabase 이메일 로그인/회원가입 with Next.js

Supabase + Next.js로 이메일 로그인/회원가입을 구현한다. 데이터베이스에 사용자 정보를 자동으로 등록하는 것까지!

velog.io

 

https://www.vigorously.xyz/docs/supabase/supabase-doc-auth-setting-up-server-side-auth-for-nextjs/

 

Next.js 서버 사이드 Auth 설정

Next.js 서버 사이드 Auth 설정 생성일: 2024-04-26 수정일: 2024-04-26 Next.js에는 App Router 와 Pages Router 두 가지 버전이 있다. 둘 다 서버 사이드 Auth를 설정할 수 있다. 한 애플리케이션에서 두 가지 전략을

www.vigorously.xyz

 

Comments