본문 바로가기

2024-06-21 Supabase를 통한 회원 인증/인가 관리 기능 구현 회고

codeConnection 2024. 6. 22.

파일구조

src
  components
    SignUp.jsx
    Login.jsx
    Introduction.jsx
    signInWithKakako.js
    checkSignIn.js
  api
    api.js
    supabaseClient.js
  hooks
    usePlaces.js
    useLogout.js
  store
    store.js

구현모습

 

  • 상단 헤더에서 회원 인증/인가 상태에 따라 조건부 렌더링
    • 서버로부터 인증된 사용자는 로그인 버튼 X, 찜목록과 로그아웃 버튼이 렌더링 되도록 설정
    • 로컬에서 토큰을 활용한 인증 방식은 보안 취약 요소가 있을 것으로 생각되어 JWT 세션을 서버로부터 인증받아 사이트에서 서비스 사용을 조건부로 사용 가능하도록 모든 컴포넌트에서 일부 서비스 보호

  • 마우스 스크롤 액션 추적을 통한 Bumper Page 연결
    • 회원가입 버튼 클릭 시 곧바로 회원가입으로 넘어가지 않고, 사전에 준비된 광고 멘트가 렌더링 됨.
    • 이 페이지 또한 구현 의도에 따라 회원 중 대상을 선정하여 조건부로 렌더링 할 수 있을 것으로 생각 됨.

  • supabase에서 이용 가능한 소셜 로그인 중 국내 서비스인 카카오 소셜 로그인과 supabase 자체 authentication 기능인 이메일 가입을 통해 회원가입을 받을 수 있도록 구현하였음.
  • 참고로 모델 사진은 토이프로젝트여서 진짜 모델이 아님을 알림. 실제 서비스를 염두하고 제작해 본 퍼블리싱이며 이미지는 발표 이후 교체됨.

구현 순서

supabase 셋업

팀원들이 데이터를 가지고 작업을 하려면 적절한 테이블 구성과 더미 데이터를 미리 생성해두고 팀원들의 컴포넌트에서 fetch에 필요한 커스텀 훅을 미리 예측하고 작성해두어야 작업이 효율적이기 때문에 신속하게 세팅을 해야 할 필요가 있었다.

환경 변수 설정

// .env

VITE_SUPABASE_URL = "<나의 프로젝트 주소>.supabase.co"
VITE_SUPABASE_KEY = "<나의 퍼블릭 Key>"

 

민감한 정보인 API Key가 GitHub에 올라 가지 않게 하기 위해 환경 변수 처리를 한다.

그리고 이 정보는 팀원에게 공유해주어 팀원들도 같은 내용을 저장해두어야 한다.

Supabase 패키지 설치

npm install @supabase/supabase-js

 

Supabase는 기본적으로 RESTful한 API를 지원하기 때문에 fetch 또는 Axios 등을 활용하여 fetch를 하여도 되나, Supabase 패키지에서 지원하는 내장 함수가 함축적이고 편리하기 때문에 패키지를 이용하였다.

 

최초에는 Axios와 TanStack을 이용하여 학습한 내용을 심화하여 연습하고자 했으나, 이것은 단순히 RESTful한 API 요청을 보낼 때의 이야기이고, 구현을 할 수록 버킷에서 이미지 url을 꺼내온다든지 더 많은 로직이 필요했기 때문에 Supabase 패키지를 설치하여 사용하는 방향으로 전환하였다.

Supabase 기본 설정

// src/api/supabaseClient.js

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

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);

export default supabase;

Supabase 테이블 셋업

이 내용은 방대하기 때문에 이번 회고록에서 언급은 하지 않는다. 별도 게시글로 작성하였다.

 

팀의 기획 회의에 참여하며 필요하다고 생각하는 테이블은 아래와 같았다.

  • Data Table
    • Places : post 저장용 테이블. 행사 장소 추천 사이트를 기획하고 있었기에 테이블명을 Places로 정하고 세팅하였다.
    • Hearts : 사용자가 post를 찜하였을 때 마이페이지에서 찜한 목록을 보여주기 위해 세팅하였다.
      • 여기에는 places 테이블과 연결하여 heart_id, post_id, user_id를 세팅하였다.
      • heart_id는 row가 생성 됨에 따라 자동으로 고유한 uuid가 생성되도록 설정하였다.
      • post_id는 places에 저장된 각각의 post가 가진 id를 저장하도록 설계하였다.
      • user_id는 해당 user의 id를 서버로부터 세션을 받아와 저장하는 방법으로 설계하였다.
    • Users : 회원가입 시 회원에게 추가적인 데이터를 받기 위해 설계하였다. 실제 프로젝트에서 많은 유저 데이터를 활용하지 않아 적극적으로 구현하진 않았지만, 이 부분은 프로젝트 초기 세팅할 때부터 구상해두어야 한다. 그렇지 않으면 추후 Users 데이터 테이블을 만들게 됐을 때 기존 Authentication에 있는 User 데이터를 전부 동기화 시키는 것은 여간 복잡한 일이 아니기 때문이다.
  • bucket : 이미지를 저장하기 위한 용도이다.
    • public : assets의 의미로 세팅하였다. 그러나 supabase에서 assets는 이미 사용 중인 고유 키워드로, 설정할 수 없어 public으로 설정하였다. 이 bucket의 용도는 실제 서비스 기획 시 주기적으로 바뀌는 이미지들(예를 들어 광고 이미지 등)을 저장하고 필요할 때마다 프로젝트 assets의 업로드를 바꾸는 게 아닌, 백엔드에서 이미지 변경만 하면 페이지의 이미지가 교체되는 것이 편리하겠다고 생각하여 설계하였다.
    • default : 사용자가 게시글 사진을 등록하지 않았을 때 기본으로 렌더링 될 이미지, 사용자가 프로필 사진을 등록하지 않았을 때 기본으로 렌더링 될 이미지를 저장하고 불러올 수 있도록 설계하였다.
    • images : 사용자가 게시글 작성을 할 때 업로드하는 이미지를 저장하도록 설계하였다.

데이터 테이블 구조
Places 데이터 테이블
Hearts 데이터 테이블
Users 데이터 테이블
Storage Bucket 구조

회원가입 페이지 작성

작성 순서

  1. 필요한 라이브러리 및 hooks import
  2. 회원가입 폼 UI 작성
  3. styled-components를 이용한 스타일링
  4. 상태 및 이벤트 핸들러 연결
    1. 이메일, 비밀번호, 비밀번호 재확인, 유효성 검사 실패 시 실시간으로 렌더링 할 에러 값, 유효성 검사 통과 여부에 따른 버튼 활성화 여부(boolean)
    2. 위 상태를 만들고 이를 관리할 상태 변경 로직 작성
  5. 회원가입 폼 처리 로직 작성
    1. 사용자 입력 값 검증
    2. 에러 메시지 설정
    3. Supabase와 통신하여 회원가입 요청 전송
    4. 요청 성공 시 welcome message alert 출력 및 home으로 리디렉션
    5. 요청 실패 시 서버에서 반환되는 error 메시지 설정
  6. 폼 제출 시 회원가입 처리

컴포넌트 작동 순서

필요 라이브러리 및 모듈 import

// SignUp.jsx

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

 

  • import { useState, useEffect } from 'react';
    • 회원가입 페이지에서 상태가 필요하고, 특정 상태 변화에 따라 버튼이 활성화 되고 활성화 되지 않도록 하기 위해 useEffect 훅이 필요하다.
  • import { useNavigate } from 'react-router-dom';
    • 회원가입 후 홈으로 리디렉션 시키기 위해 react-router-dom 라이브러리의 useNavigate 훅이 필요하다.
  • import styled from 'styled-components';
    • 페이지의 스타일링 도구로 스타일드 컴포넌트 라이브러리를 사용한다.
  • import supabase from '../api/supabaseClient';
    • 회원가입 요청을 supabase에 보내기 위해 사전에 작성해 둔 project URL과 public key가 저장되어 있는 supabase라는 변수가 필요하다.
  • import signInWithKakao from './signInWithKako';
    • 소셜 로그인은 회원가입 / 로그인의 개념이 통합되어 있는 방식이기 때문에 로그인 페이지와 회원가입 페이지에서 동시에 필요하다. 따라서 재사용 할 것이기 때문에 별도의 유틸 함수로 작성하였다.

상태 관리

  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [error, setError] = useState('');
  const [isButtonDisabled, setIsButtonDisabled] = useState(true);

  const navigate = useNavigate();

 

  • email : 사용자가 입력한 email 값을 관리.
  • password : 상동 (password)
  • confirmPassword : 상동 (비밀번호 재입력 확인 값)
  • error : 실시간으로 오류를 렌더링 하기 위한 값
  • isButtonDisabled : 사용자의 유효성 검사 통과 에 따라 버튼을 조건부 렌더링 하기 위한 상태
  • useNavigate : 사용자의 회원가입 성공 여부에 따라 원하는 페이지로 리디렉션 시키기 위한 hook을 함수로 정의

UI 작성

return (
  <div>
    <div>
      <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"
      />
    </div>
    <form onSubmit={handleSignUp}>
      <h1>회원가입 페이지</h1>
      <div>
        <input type="email" placeholder="이메일" value={email} onChange={handleEmailChange} />
      </div>
      <div>
        <input type="password" placeholder="비밀번호" value={password} onChange={handlePasswordChange} />
      </div>
      <div>
        <input
          type="password"
          placeholder="비밀번호 재입력"
          value={confirmPassword}
          onChange={handleConfirmPasswordChange}
        />
      </div>
      {password !== confirmPassword && confirmPassword && (
        <div>비밀번호가 일치하지 않습니다</div>
      )}
      {error && <div>{error}</div>}
      <button type="submit" disabled={isButtonDisabled}>
        회원가입
      </button>
      <button onClick={signInWithKakao}>Kakao로 로그인</button>
    </form>
  </div>
);
};

 

Form 형태의 UI 렌더링의 핵심

 

  • <Form> 태그로 입력 필드를 감싸고, onSubmit 이벤트 핸들러로 회원가입 버튼에 연결할 handleSignUp 함수를 연결
  • 입력 필드에 필요한 속성을 부여
    • type : 서버에서 요구하는 양식을 부여.
    • placeholder : 어떤 입력 필드인지 남기거나 힌트를 남겨 사용자 경험을 증대.
    • value : 입력 필드를 어떤 상태와 연결할 것인지 설정.
    • onChange : 이 이벤트 핸들러에 연결되는 함수는 사용자가 값을 입력할 때마다 상태 변경 함수를 출력시킴.
const handlePasswordChange = (e) => {
  setPassword(e.target.value);
  setError('');
};

 

e.target.value로 입력 필드에 입력된 현재 값을 상태 변경 함수에 담아 상태를 실시간으로 변경하도록 전달함.

 

사용자가 값을 입력할 때 계속 에러 메시지가 출력되어 있으면 사용자 경험이 떨어질 수 있으므로, 에러 메시지를 입력하고 있을 때는 빈 값으로 바꾸기 위한 상태 변경 함수도 포함함.

 

제어 컴포넌트

 

input 태그에 value 속성을 통해 상태와 직접적으로 연결하여 상태와 입력 필드의 값이 항상 일치하도록 설정하였다.

상태를 정의할 때 초기 값으로 ('') 빈 문자열을 지정하였는데, 사용자가 처음 컴포넌트에 접속했을 때 초기값은 사용자의 의지와 상관 없이 빈 문자열이 된다. 상태에 의해 입력 필드가 완전히 제어되고 있기 때문에 이를 제어 컴포넌트라고 부른다.

 

반대의 개념은 비제어 컴포넌트인데, 이는 DOM 요소에 의해 입력 값이 관리되는 형태를 말한다.

만약 나의 제어 컴포넌트로 구현하게 되면 아래와 같이 변경할 수 있다.

import React, { useRef } from 'react';

const UncontrolledComponent = () => {
  const inputRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    alert(`입력된 비밀번호: ${inputRef.current.value}`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="password" placeholder="비밀번호" defaultValue="초기값" ref={inputRef} />
      <button type="submit">제출</button>
    </form>
  );
};

 

입력 필드의 초기값을 입력 필드 내부에서 defaultValue로 설정할 수 있다.

 

비제어 컴포넌트는 비교적 입력 값이 단순하고 입력 필드가 많을 경우 사용한다. 제어 컴포넌트는 사용자가 값을 입력할 때마다 상태가 변하기 때문에 수시로 컴포넌트가 리렌더링 된다. 그렇기 때문에 많은 입력 필드를 가진 곳에서는 상태와 입력 필드 둘 다 코드로 작성해야 해서 코드도 복잡해지고 성능 이슈가 발생할 수도 있다.

 

다만 지금 본인의 경우에는 사용자의 값의 입력 값에 따라 실시간으로 유효성 검사를 해서 에러를 렌더링 하고 있기 때문에 이런 경우에는 동적으로 입력 값을 관리할 수 있는 제어 컴포넌트가 더 적절하다.

 

다만 대규모 프로젝트 등 리액트가 아닌 다른 라이브러리와 함께 코드를 작성해야 할 때는 자바스크립트 코드만으로 작성된 비제어 컴포넌트를 사용해야 할 수도 있다.

 

유효성 검사 로직 작성

const validateEmail = (email) => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  };

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

  const handlePasswordChange = (e) => {
    setPassword(e.target.value);
    setError('');
    if (e.target.value.length < 6) {
      setError('비밀번호는 6자리 이상으로 설정해주세요');
    } else {
      setError('');
    }
  };

  const handleConfirmPasswordChange = (e) => {
    setConfirmPassword(e.target.value);
    setError('');
    if (e.target.value.length < 6) {
      setError('비밀번호는 6자리 이상으로 설정해주세요');
    } else if (password !== e.target.value) {
      setError('비밀번호가 일치하지 않습니다');
    } else {
      setError('');
    }
  };
  
    useEffect(() => {
    if (email && password && confirmPassword && !error && password === confirmPassword && validateEmail(email)) {
      setIsButtonDisabled(false);
    } else {
      setIsButtonDisabled(true);
    }
  }, [email, password, confirmPassword, error]);

회원가입 요청 함수 작성

  const handleSignUp = async (event) => {
    event.preventDefault();
    let errorMessage = '';

    if (!validateEmail(email)) {
      errorMessage = '이메일 형식을 확인해주세요';
    } else if (password.length < 6) {
      errorMessage = '비밀번호는 6자리 이상으로 설정해주세요';
    } else if (password !== confirmPassword) {
      errorMessage = '비밀번호 재입력이 일치하지 않습니다';
    }

    if (errorMessage) {
      setError(errorMessage);
      alert(errorMessage);
      return;
    }

    setError('');

    try {
      const { data, error } = await supabase.auth.signUp({
        email: email,
        password: password
      });

      if (error) {
        const signUpError = `회원가입 중 에러가 발생했습니다.: ${error.message}`;
        setError(signUpError);
        console.error(signUpError);
      } else {
        alert(`${data.user.email} 님 회원가입을 축하드립니다!`);
        navigate('/');
      }
    } catch (error) {
      const signUpError = `회원가입 중 에러가 발생했습니다.: ${error.message}`;
      setError(signUpError);
      console.error(signUpError);
    }
  };

 

동작 순서

 

  • e.preventDefault() 메서드로 Form 제출 기본 동작을 막는다. HTML이 아닌 자바스크립트로 통제하기 위해서다.
  • 유효성 검사
    • alert용 에러 메시지 초기화 : let errorMessage = '';
    • 이메일 유효성 검사 -> 비밀번호 길이 검사 -> 비밀번호 재확인 입력 값 일치 검사 -> 유효성 검사 형태에 따라 에러 메시지 alert  출력 후 함수 종료
    • 그러나 이는 유효성 검사 후 버튼을 조건부에 따라 숨기고 있기 때문에 중복 로직으로 생각되어 리팩토링을 하면서 삭제하였음.
  • 서버에 회원가입 요청

리팩토링

회원가입 로직 중복 개선

기존 코드

    try {
      const { data, error } = await supabase.auth.signUp({
        email: email,
        password: password
      });

      if (error) {
        const signUpError = `회원가입 중 에러가 발생했습니다.: ${error.message}`;
        setError(signUpError);
        console.error(signUpError);
      } else {
        alert(`${data.user.email} 님 회원가입을 축하드립니다!`);
        navigate('/');
      }
    } catch (error) {
      const signUpError = `회원가입 중 에러가 발생했습니다.: ${error.message}`;
      setError(signUpError);
      console.error(signUpError);
    }
  };

 

개선 코드

try {
  const { data } = await supabase.auth.signUp({
    email: email,
    password: password
  });

  alert(`${data.user.email} 님 회원가입을 축하드립니다!`);
  navigate('/');
} catch (error) {
  const signUpError = `회원가입 중 에러가 발생했습니다.: ${error.message}`;
  setError(signUpError);
  console.error(signUpError);
}

 

과제 제출 이후 리팩토링 과정 중 try...catch 문을 사용하고 있음에도 중복으로 에러 처리를 하고 있음을 발견했다.

트러블 슈팅과 개선 과제

supabase onAuthStateChange() 메서드 사용

getUser() 메서드로 인증 상태를 불러오는 과정에서 상태의 변화가 발생하고, 따라서 헤더에 리렌더링이 발생함에 따라 로그인 한 회원에게 보여주지 않으려 했던 로그인, 회원가입 버튼이 통신 과정에서 살짝 깜빡이는 현상이 발생했다.

 

리액트의 핵심인 상태를 이해하지 못했기 때문인데, onAuthStateChange() 메서드는 서버에서 auth 객체에 이벤트가 발생했을 때 서버에서 클라이언트에게 통신을 보내는 메서드이다. 따라서 이를 이용해서 getUser() 요청을 보내면 매번 인증 상태를 불러오는 것이 아니라 이벤트가 발생했을 때만 상태를 변경시킬 수 있을 것 같았는데 TanStack 쿼리 등을 이용해도 제대로 구현해내지 못했다.

 

프로텍티드 라우팅 설정에서도 마찬가지다. 일시적으로 로딩 상태 등이 노출이 되어 사용자 경험이 떨어진다.

 

트래픽 사용량 개선

이미지를 사용하는 프로젝트이다 보니, 트래픽 개선에 대한 니즈가 생겼다. 자바스크립트단에서 webp로 변환하여 버킷에 업로드하는 로직을 추천받았으나 실제로 구현해보지 못해 개선 과제로 남겨두었다.

댓글