본문 바로가기

Supabase 로그인 기능 구현하기

codeConnection 2024. 6. 18.

주의

본 포스트에 소개된 내용 중 마지막 부분에 해당하는 조건부 렌더링은 리액트의 특성 상 제대로 작동하지 않는다.

Next.js 등을 이용하여 서버 사이드 렌더링을 하지 않는 이상 서버와 통신하여 인증 상태를 받아 오는 방식으로 조건부 렌더링을 하는 것은 리액트의 특성 상 불가능에 가까운 영역이므로 글쓴이와 같이 이틀 간 안 되는 것으로 씨름하다 탈모를 촉진시키지 마시고, 학습용으로 참고만 하길 바란다.

 

간단한 상황에서 리액트만으로 해결한 방법이 있는데 이는 별도로 React 카테고리에서 별도로 포스팅 하였다.

상황

  • 이메일 가입 회원
  • 회원가입이 되어 authentication 테이블에 유저 정보가 있는 경우를 가정

로그인 함수

supabase에서는 내장 메서드를 제공한다.

이 내장 메서드를 사용하면 로그인과 동시에 accessToken이 로컬 스토리지에 저장된다.

로컬 스토리지에 토큰이 저장되면 인증 상태가 새로고침을 하더라도 풀리지 않는다. 내장 메서드만으로 이 기능을 제공하니 매우 편리하다.

그리고 로그아웃 메서드를 사용하면 토큰도 로컬 스토리지에서 삭제된다.

const { data, error } = await supabase.auth.signInWithPassword({
  email: 'example@email.com',
  password: 'example-password',
})

당연히 위 함수를 그대로 사용하면 안 되고, 사용자에게 이메일(아이디), 비밀번호를 입력받은 뒤 위 자리에 상태로 넣어 주어야 하고, 로그인 실패에 대한 에러 처리도 해야 한다.

 

아래 코드는 스타일드 컴포넌트는 제외했다.

import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import supabase from '../api/supabaseClient';

const Login = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isButtonDisabled, setIsButtonDisabled] = useState(true);
  const navigate = useNavigate();

  // 이메일 형식 정규식으로 설정 (a@a.a 만 입력 되어도 통과됨)
  // 진짜 이메일만 받고 싶으면 골뱅이와 마침표 앞 뒤로 서식을 잘 정해보길 바람
  const validateEmail = (email) => {
    return /\S+@\S+\.\S+/.test(email);
  };

  // 로그인 함수
  const handleLogin = async (event) => {
    event.preventDefault(); // 폼 제출 시 페이지 리로드 방지
    let errorMessage = '';
  
    // 유효성 검사
    if (!validateEmail(email)) {
      errorMessage = '이메일 형식을 확인해주세요';
    } else if (password.length < 6) {
      errorMessage = '비밀번호는 6자리 이상으로 설정해주세요';
    }
  
    if (errorMessage) {
      setError(errorMessage);
      return;
    }
  
    setError('');
  
    try {
      const { data, error } = await supabase.auth.signInWithPassword({
        email: email, // 이 부분을 이메일과 비밀번호로 변경
        password: password,
      });
  
      if (error) {
        const signUpError = `회원가입 중 에러가 발생했습니다.: ${error.message}`;
        setError(signUpError);
        console.error(signUpError);
      } else {
        alert(`${data.user.email} 님의 방문을 환영합니다!`);
        console.log('로그인 완료:', data);
        navigate('/');
      }
    } catch (error) {
      const loginError = `회원가입 중 에러가 발생했습니다.: ${error.message}`;
      setError(loginError);
      console.error(loginError);
    }
  };

  const handleEmailChange = (e) => {
    setEmail(e.target.value);
    if (!validateEmail(e.target.value)) {
      setError('이메일 형식을 확인해주세요');
    } else {
      setError('');
    }
  };

  const handlePasswordChange = (e) => {
    setPassword(e.target.value);
    setError('');  // 에러 메시지 초기화
  };

  useEffect(() => {
    // 버튼 활성화/비활성화 상태 업데이트
    if (email && password && !error && validateEmail(email)) {
      setIsButtonDisabled(false);
    } else {
      setIsButtonDisabled(true);
    }
  }, [email, password, error]);

  return (
    <Container>
      <Content>
        <ImgWrapper>
          <img src="https://kmfvncclriiektxphias.supabase.co/storage/v1/object/public/images/public/Festiall_Model.png?t=2024-06-18T03%3A56%3A18.517Z" alt="user login" />
        </ImgWrapper>
        <Forms>
          <Form className="block" id="login-up" onSubmit={handleLogin}>
            <Title>로그인 페이지</Title>
            <InputBox>
              <Icon className='bx bx-at'></Icon>
              <Input
                type="email"
                placeholder="이메일"
                value={email}
                onChange={handleEmailChange}
              />
            </InputBox>
            <InputBox>
              <Icon className='bx bx-lock'></Icon>
              <Input
                type="password"
                placeholder="비밀번호"
                value={password}
                onChange={handlePasswordChange}
              />
            </InputBox>
            {error && (
              <ErrorMessage>
                {error}
              </ErrorMessage>
            )}
            <Button type="submit" disabled={isButtonDisabled}>
              로그인
            </Button>
          </Form>
        </Forms>
      </Content>
    </Container>
  );
};

export default Login;

  // const handleLogout = () => {
  //   clearAccessToken();
  //   alert('로그아웃 성공');
  // };

  // // handleLogout을 다른 컴포넌트에서 사용할 수 있도록 전역 상태에 저장
  // useAuthStore.setState({ handleLogout });
  • 폼을 제출하는 함수인 로그인 함수(회원가입도 마찬가지) 첫 시작은 e.preventDefault()로 유효성 검사 통과 못할 시 양식이 제출되는 동작을 막아야 함.
  • form 태그에 로그인 함수를 onSumbit 이벤트 핸들러에 연결.
  • 양식 제출하는 button의 type 속성은 sumbit으로 설정.

로그아웃 함수

supabase에서 제공하는 내장 함수는 아래와 같다.

const { error } = await supabase.auth.signOut()

매우 짧고 간결하다. 저대로 쓰는 것이 아니라 이것도 JWT 인증 세션을 서버에서 만료 시키는 서버와의 통신 과정이 들어가기 때문에 에러 처리 등을 해주면 좋다.

 

또한 위 내장 함수는, 로컬 스토리지에 저장 되어 있는, 유저의 인증여부(토큰) 또한 삭제 시켜주는 로직도 포함되어 있어 편리하다.

 

본인은 로그아웃 버튼이 헤더나 외부에 있을 것이기 때문에 커스텀 훅으로 생성하겠다.

// hooks/useLogout.js

import { useNavigate } from "react-router-dom";
import supabase from "../components/api/supabaseClient";

const useLogout = () => {
  const navigate = useNavigate();

  const handleLogout = async () => {
    try {
      const { error } = await supabase.auth.signOut();
      if (error) throw error;
    } catch (error) {
      alert('로그아웃 중 오류가 발생했습니다. :' + error.message);
      return;
    }

    alert('로그아웃 되었습니다. 또 만나요!');
    navigate('/');
  };

  return handleLogout;
};

export default useLogout;

 

위 커스텀 훅을 헤더 등 다른 컴포넌트에서 재사용 하려면 import 후 사용하면 된다.

// 커스텀 훅 import
import useLogout from '../../hooks/useLogout';

// 사용할 함수 이름으로 할당하기
const handleLogout = useLogout();

// 버튼 태그나 컴포넌트에 onClick 이벤트 핸들러로 연결하기
<Button type="button" onClick={handleLogout}>
	로그아웃
</Button>

로그인 상태 전역으로 공유하기(Zustand)

로컬 스토리지에 토큰이 저장되어 있긴 하지만, 이걸로 로그인 상태를 알기엔 매번 세션을 불러오는 메서드를 필요한 컴포넌트에서 매번 실행해야 하기 때문에 번거롭다.

 

사용자 로그인 상태에 따라 보여줘야 할 버튼이나 컴포넌트, 페이지가 따로 정해져 있다면 전역으로 사용자 인증 상태를 공유해서 사용해야 한다.

패키지 설치

yarn add zustand

Zustand 스토어 설정

// src/store/store.js

import { create } from 'zustand';

const useAuthStore = create((set) => ({
  isSignedIn: false,
  setSignIn: (status) => set({ isSignedIn: status }),
}));

export default useAuthStore;
  • import { create } from 'zustand';
    • zustand에서 상태 스토어를 생성하는 함수임. 이 함수를 사용하면 우리가 알고 있는 state와 setState를 정의할 수 있는 함수를 반환해 줌.
  • const useAuthStore = create( (set) => ( {} ) );
    • useAuthStore라는 커스텀 훅을 zustand 스토어를 만들겠다는 선언임.
    • 매개 변수로 오는 set은 상태 변경을 하기 위해 필요한 함수임. 이 스토어를 끌어다 쓰는 곳에서 함수가 실행되면 새로운 상태를 인수로 받아서 상태를 업데이트 해 줌.
    • 중괄호 내부에 상태, 상태 변경 함수를 객체 형태로 정의하면 됨.
  • { isSignedIn: false }
    • isSingedIn이라는 상태를 만든 것이고, 초기값을 false로 설정한 것임.
  • { setSignIn: (status) => set( { isSignedIn: status} ) }
    • 상태를 변경하는 함수임.
    • status를 인수로 받아서 isSignedIn이라는 상태(바로 위에서 정의한 것)의 값을 status로 바꿔 줌.
    • 활용하기 나름인데 본인은 checkSignIn() 이라는 함수를 만들어서, 함수를 실행하면 supabase에서 true, false를 받아오게 할 것임.

로그인 상태 체크 함수 작성

// checkSignIn.js

import { supabase } from './supabaseClient';
import useAuthStore from './useAuthStore';

async function checkSignIn() {
    const { data: { user } } = await supabase.auth.getUser();
    
    const setSignIn = useAuthStore.getState().setSignIn;
    setSignIn(!!user);

    return !!user;
}

export default checkSignIn;
  • import { supabase } from './supabaseClient';
    • supabase 라이브러리 설치해야 됨. 그래야 supabase에서 정해 둔 축약된 메서드를 사용할 수 있음. 이 코드의 경우 ~.auth.getUser() 같은 함수를 말함.
  • import useAuthStore from './useAuthStore';
    • zustand 스토어에서 만든 커스텀 훅인 useAuthStore을 가져옴.
    • 이걸 가져와서 supabase에서 가져온 로그인 상태 정보를 여기 상태 변경 함수에 매개 변수로 전달하여 전역 상태로 설정한 isSignedIn의 상태를 바꿀 것임.
  • async function checkSignIn() {...};
    • 이 함수는 크게 supabase에서 user 정보를 가져오는 내장 함수임.
  • const { data: { user } } = await supabase.auth.getUser();
    • supabase에서 현재 로그인된 사용자의 정보를 가져옴.
    • awiat 키워드를 사용해서 getUser() 함수가 종료될 때가지 기다림.
    • const { data: { user } } : 응답 결과로 반환해준 값에서 user 객체만 추출함. 여기에는 로그인된 사용자의 정보가 담겨있음.
  • const setSignIn = useAuthStore.getState().setSignIn;
    • zustand 스토어에서 setSignIn 함수를 가져옴. 이 함수에 매개 변수로 방금 supabase에서 받은 데이터를 넘겨 주기 위함.
  • setSignIn(!!user);
    • supabase에서 받아온 user 객체를 매개 변수로 전달해서 user 객체가 존재하면 true, 존재하지 않으면 false라는 불리언 값으로 반환된다. 그냥 쓰면 객체가 들어 가 버린다.
    • 자바스크립트에서 !! NOT 논리 연산자를 두 번 사용하면 그 값이 truthy한 값이면 true를, 반대는 false라는 불리언 값으로 반환해준다.
  • return !!user;
    • 그렇게 생성된 불리언 값을 return한다.

 

* 참고 supabase getUser(); 응답값 예시

{
  "data": {
    "user": {
      "id": "11111111-1111-1111-1111-111111111111",
      "aud": "authenticated",
      "role": "authenticated",
      "email": "example@email.com",
      "email_confirmed_at": "2024-01-01T00:00:00Z",
      "phone": "",
      "confirmed_at": "2024-01-01T00:00:00Z",
      "last_sign_in_at": "2024-01-01T00:00:00Z",
      "app_metadata": {
        "provider": "email",
        "providers": [
          "email"
        ]
      },
      "user_metadata": {
        "email": "example@email.com",
        "email_verified": false,
        "phone_verified": false,
        "sub": "11111111-1111-1111-1111-111111111111"
      },
      "identities": [
        {
          "identity_id": "22222222-2222-2222-2222-222222222222",
          "id": "11111111-1111-1111-1111-111111111111",
          "user_id": "11111111-1111-1111-1111-111111111111",
          "identity_data": {
            "email": "example@email.com",
            "email_verified": false,
            "phone_verified": false,
            "sub": "11111111-1111-1111-1111-111111111111"
          },
          "provider": "email",
          "last_sign_in_at": "2024-01-01T00:00:00Z",
          "created_at": "2024-01-01T00:00:00Z",
          "updated_at": "2024-01-01T00:00:00Z",
          "email": "example@email.com"
        }
      ],
      "created_at": "2024-01-01T00:00:00Z",
      "updated_at": "2024-01-01T00:00:00Z",
      "is_anonymous": false
    }
  },
  "error": null
}

필요한 컴포넌트에서 사용

// 아무 컴포넌트

import useAuthStore from './store';
import checkSignIn from './checkSignIn';

function App() {
  
  const isSignedIn = useAuthStore( (state) => state.isSignedIn );
  
  useEffect( () => {
    checkSignIn();
  }, []);

  return ();
}

export default App;
  • import useAuthStore from './store';
    • zustand에서 만든 커스텀 훅이다. 이 훅이 있어야 전역 상태에 접근할 수 있다.
  • import checkSignIn from './checkSignIn';
    • supabase에서 유저 정보를 받아오는 함수를 작성했던 내용이다.
    • 이 함수를 실행시켜서 supabase에서 현재 로그인 한 유저의 객체를 받아오면 그 객체가 스토어에서 불리언 값으로 저장되는 로직이 이미 짰었다.
  • const isSignedIn = useAuthStore( (state) => state.isSignedIn );
    • 하나의 패턴이다. useAuthStore 커스텀 훅을 실행시켜서 state를 불러오는데 그 중에서도 isSignedIn이라는 상태의 값을 불러오겠다는 의미이다. 아무것도 안 하면 기본값은 false다.
  • useEffect(()=>{ checkSignIn() ,[] );
    • supabase에서 유저 정보를 받아 오는 역할을 하고, 컴포넌트 마운트 시 최초 1회 실행된다.
    • 이 객체 정보로 zustand 스토어에 정의한 함수에 불리언 값으로 넣는 것는 것을 실행하면 그 값이 const isSignedIn 변수에 저장된다.
    • 그러면 이제 이 isSignedIn으로 로그인 중인지 아닌지 그 컴포넌트에서 판단할 수 있다.

그런데 여기서 끝나면 너무 좋겠지만, logout() 함수를 제작해서 실행했을 때 이 로직이 한 번 더 반복돼서 인증 상태를 다시 false로 돌려주어야 한다.

// 기존 로그아웃 버튼 이벤트 핸들러
<Button bgColor={"red"} onClick={handleLogout()}>로그아웃</Button>

// 변경된 로그아웃 버튼 이벤트 핸들러
<Button bgColor={"red"} onClick={handleLogoutAndCheckSignIn}>로그아웃</Button>

 

supabase에서 제공하는 logout만 했던 함수를 한 번 더 감싸서 같이 묶어 위 로직을 한 번 더 실행해줘야 한다.

 

supabase에서 user 객체를 가져오는 checkSignIn() 함수만 실행해주면, zustand 스토어의 상태가 변경되기 때문에, 리렌더링이 자동으로 발생한다.

const handleLogoutAndCheckSignIn = async () => {
  await handleLogout();
  checkSignIn();
};

유저 인증 상태(전역 상태)에 따라 로그인, 로그아웃 버튼 조건부 렌더링

로그인을 했든, 로그아웃을 했든 전역 상태로 그 여부가 관리되고는 있지만 조건부 렌더링을 하지 않아 똑같은 버튼이 렌더링 된다.

{isSignedIn ? (
  <button onClick={로그아웃함수}>로그아웃</Button>
) : (
  <>
    <Button onClick={() => navigate("/signup")}>회원가입</Button>
    <Button onClick={() => navigate("/login")}>로그인</Button>
  </>
)}

구조를 보면 삼항 연산자다.

 

isSignedIn ?  로그아웃 버튼 : 회원가입, 로그인 버튼

 

형티인데, 유저가 로그인 한 상태(true)라면 로그아웃 버튼을 보여주고, false라면 회원가입과 로그인 버튼을 fragment로 묶어서 렌더링 하는 방식으로 구현했다.

댓글