Supabase 회원가입 + 별도 데이터 테이블에 동시 기록하기
상황
Supabase에서 기본으로 제공하는 이메일 회원가입을 하면 Supabase의 authentication에 유저의 메타데이터가 저장된다.
그런데 데이터테이블의 관점으로 봤을 때 이 메타 데이터는 한 셀이 json 형태의 객체로 저장되어 있어 데이터를 가공하기가 복잡하다. 코드가 복잡해질 수 있다.
따라서 사용자에게 닉네임, 집 주소 등 받아야 하는 데이터가 많으면 많아질 수록 이 기본 기능만으로는 부족함을 느낄 수 있고, 찜하기, 댓글 기능 등 다른 데이터들과 관계형 데이터베이스 기반으로 외래 키를 설정하는 데 있어서도 부족함이 있다.
따라서 이런 경우 별도로 user 데이터를 만들어 관리하는 것이 더 편리하다.
전체 코드
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import supabase from '../api/supabaseClient';
import signInWithKakao from './signInWithKakao';
import SignUpConfirmModal from './SignUpConfirmModal';
import {
Container,
Content,
ImgWrapper,
Forms,
Form,
Title,
InputBox,
Icon,
Input,
Button,
ErrorMessage,
KakaoButton,
AgreementText
} from './auth.styled';
const ErrorMessages = ({ messages }) => {
return (
<>
{messages.map((message, index) => (
<ErrorMessage key={index}>{message}</ErrorMessage>
))}
</>
);
};
export const SignUp = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [errorMessages, setErrorMessages] = useState([]);
const [isButtonDisabled, setIsButtonDisabled] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const navigate = useNavigate();
const validateEmail = (email) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const handleEmailChange = (e) => {
setEmail(e.target.value);
const newErrorMessages = [...errorMessages];
if (!validateEmail(e.target.value)) {
newErrorMessages[0] = '이메일 형식을 확인해주세요.';
} else {
newErrorMessages[0] = '';
}
setErrorMessages(newErrorMessages);
};
const handlePasswordChange = (e) => {
setPassword(e.target.value);
const newErrorMessages = [...errorMessages];
if (e.target.value.length < 6) {
newErrorMessages[1] = '비밀번호는 6자리 이상으로 설정해주세요';
} else {
newErrorMessages[1] = '';
}
setErrorMessages(newErrorMessages);
};
const handleConfirmPasswordChange = (e) => {
setConfirmPassword(e.target.value);
const newErrorMessages = [...errorMessages];
if (password !== e.target.value) {
newErrorMessages[2] = '비밀번호가 일치하지 않습니다';
} else {
newErrorMessages[2] = '';
}
setErrorMessages(newErrorMessages);
};
useEffect(() => {
const isFormValid = () => {
const isPasswordsMatching = password === confirmPassword;
const isEmailValid = validateEmail(email);
const isFieldsFilled = email && password && confirmPassword;
return isFieldsFilled && isPasswordsMatching && isEmailValid && !errorMessages.some(message => message !== '');
};
setIsButtonDisabled(!isFormValid());
}, [email, password, confirmPassword, errorMessages]);
const handleSignUp = async (event) => {
event.preventDefault();
try {
const { data, error } = await supabase.auth.signUp({
email: email,
password: password
});
if (error) {
throw error;
}
const userId = data.user.id;
const userEmail = data.user.email;
const { data: insertData, error: insertError } = await supabase.from('Users').insert([{ user_id: userId, email: userEmail }]);
if (insertError) {
throw insertError;
}
alert(`${data.user.email} 님 회원가입을 축하드립니다!`);
navigate('/');
} catch (error) {
const signUpError = `회원가입 중 에러가 발생했습니다.: ${error.message}`;
setErrorMessages([signUpError]);
console.error(signUpError);
}
};
const openModal = () => {
setIsModalOpen(true);
};
const closeModal = (e) => {
e.preventDefault();
setIsModalOpen(false);
};
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={handleSignUp}>
<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>
<InputBox>
<Icon className="bx bx-lock"></Icon>
<Input
type="password"
placeholder="비밀번호 재입력"
value={confirmPassword}
onChange={handleConfirmPasswordChange}
/>
</InputBox>
<ErrorMessages messages={errorMessages.filter(message => message !== '')} />
<AgreementText onClick={openModal}>회원가입 약관 확인하기</AgreementText>
<Button type="submit" disabled={isButtonDisabled}>
회원가입
</Button>
<KakaoButton src="src/assets/kakao_login_medium_wide.png" onClick={signInWithKakao} />
</Form>
</Forms>
</Content>
<SignUpConfirmModal isOpen={isModalOpen} onClose={closeModal} />
</Container>
);
};
export default SignUp;
코드 설명
전체 로직
사용자 입력 값 유효성 검사 -> 폼 유효성 검사(유효성 검사 통과 여부에 따른 회원가입 버튼 활성화 및 비활성화 여부 결정) -> 회원가입 요청
사용자 입력 값 유효성 검사
이메일 유효성 검사
const validateEmail = (email) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
이메일 형식을 검증하기 위한 정규 표현식을 정의한다.
의미는
~ @ ~
.
~
의 형태로 작성되었는지 골뱅이와 마침표를 기준으로 검사한다.
이렇게 작성하면 a@a.a와 같이 없을 것 같은 이메일도 작성할 수 있다.
Supabase의 무료 요금제에서는 이메일 인증 기능이 시간당 3회만 발송 가능하기 때문에 이메일 인증 기능을 풀어 놓는 것도 좋고, 처음부터 허용할 도메인을 드롭다운 형태로 제공하는 것도 하나의 방법이겠다.
이 함수를 실행하게 되면 사용자가 이메일에 입력한 값이 정규 표현식과 일치하는지 여부를 검토하여 true 또는 false의 불리언 값을 return한다.
const handleEmailChange = (e) => {
setEmail(e.target.value);
const newErrorMessages = [...errorMessages];
if (!validateEmail(e.target.value)) {
newErrorMessages[0] = '이메일 형식을 확인해주세요.';
} else {
newErrorMessages[0] = '';
}
setErrorMessages(newErrorMessages);
};
실제로 입력 필드에 onChange 이벤트 핸들러에 연결할 함수이다.
사용자가 이메일 입력 필드에 입력한 값을 email이라는 상태에 계속해서 e.target.value를 통해 추적하고 상태 변경한다. 즉 제어 컴포넌트로 작성되어 있다.
const handleEmailChange = (e) => { ...
사용자가 이메일 입력 필드에 값을 입력할 때 작동할 함수를 정의한다. 이 함수는 이메일 입력 필드에 onChange 이벤트 핸들러로 연결한다.
setEmail(e.target.value);
제어 컴포넌트로 입력 필드를 관리하기 위해 email이라는 상태를 만들고 e.target.value 키워드를 통해 현재 사용자가 입력 필드에 입력한 값을 실시간으로 상태 변경 함수를 통해 연결한다.
const newErrorMessages = [...errorMessages];
newErrorMessages라는 배열을 새롭게 만든다. 그리고 이 배열 안에는 이메일 형식 불일치, 패스워드 자리수 기준 미충족, 패스워드 재확인 값 불일치 등의 에러를 인덱스로 하나씩 담을 것이다.
errorMessages라는 배열은 상태로 정의하고 있고 초기값은 빈 배열이다. 불변성을 유지하면서 newErrorMessages라는 새로운 배열을 만들어 낸다.
if (!validateEmail(e.target.value)) {}
이메일 입력 필드에 값이 입력되고 있을 때 현재 사용자가 입력한 값을 validateEmail 함수의 매개 변수로 전달해준다.
validateEmail이라는 함수는 입력된 이메일을 매개 변수로 받아 정규 표현식에 일치하는지 판단한 후 true, false로 값을 return 해준다.
만약 사용자가 입력한 값이 이메일 정규 표현식에 일치하지 않는다면,
newErrorMessages[0] = '이메일 형식을 확인해주세요.';
위와 같은 에러를 0번 인덱스에 할당한다
else { newErrorMessages[0] = ''; }
만약 이메일 정규 표현식에 충족한다면 newErrorMessages 배열의 0번 인덱스를 빈 값으로 할당한다.
setErrorMessages(newErrorMessages);
이 과정들이 끝나면 errorMesages라는 상태의 값을 에러 메시지 또는 빈 배열 중에 하나로 할당한다.
패스워드 유효성 검사
const handlePasswordChange = (e) => {
setPassword(e.target.value);
const newErrorMessages = [...errorMessages];
if (e.target.value.length < 6) {
newErrorMessages[1] = '비밀번호는 6자리 이상으로 설정해주세요';
} else {
newErrorMessages[1] = '';
}
setErrorMessages(newErrorMessages);
};
const handleConfirmPasswordChange = (e) => {
setConfirmPassword(e.target.value);
const newErrorMessages = [...errorMessages];
if (password !== e.target.value) {
newErrorMessages[2] = '비밀번호가 일치하지 않습니다';
} else {
newErrorMessages[2] = '';
}
setErrorMessages(newErrorMessages);
};
비밀번호에 대한 유효성 검사 로직도 동일하다.
첫번째 함수는 패스워드 입력 칸에 대한 유효성 검사이고, 두번째 함수는 패스워드 재입력 확인 칸에 대한 유효성 검사이다.
패스워드가 6자리 미만일 때는 newErrorMessages의 1번 인덱스에 에러 메시지를 저장하고 역시 이를 errorMessages라는 상태에 할당한다.
현재 패스워드 입력 필드에 사용자가 입력한 값의 길이를 측정해서 6자리 보다 짧으면 유효성 검사에 걸리게 설정되어 있다.
또한 비밀번호 재입력 칸은 현재 비밀번호 재입력 칸에 사용자가 입력한 값이 비밀번호 입력 칸과 다르다면 newErrorMessages의 2번 인덱스에 에러 메시지를 할당하고, errorMessages라는 상태의 값으로 전달한다.
// 에러 메시지를 렌더링 할 별도의 컴포넌트 정의
const ErrorMessages = ({ messages }) => {
return (
<>
{messages.map((message, index) => (
<ErrorMessage key={index}>{message}</ErrorMessage>
))}
</>
);
};
// 실제로 jsx에서 return 하는 영역
<ErrorMessages messages={errorMessages.filter(message => message !== '')} />
위에서 만들어진 errorMessages라는 배열을 이 컴포넌트에 매개 변수로 전달해줌으로써 에러 메시지를 출력해주게 된다.
그런데 에러가 실제로 발생한 인덱스만 존재하고 나머지는 빈 값이기 때문에 실제로 발생한 에러 메시지만 출력해주게 된다.
이렇게 구현한 이유는, 지금 에러 메시지로 띄우는 3가지를 한 배열이 아니라 따로 관리하게 되면 렌더링 할 때 에러 메시지가 중복되어 출력되기도 한다. 예를 들어 비밀번호가 6자리도 아니면서 서로 비밀번호와 비밀번호 확인 칸이 일치하지 않을 때, 두 가지 에러 메시지가 중복으로 출력되게 되는데 어차피 처리해야 될 우선 순위가 있는 것이라면 하나만 출력되도록 할 때 이런 방법이 좋다.
회원가입 요청 함수
const handleSignUp = async (event) => {
event.preventDefault();
try {
const { data, error } = await supabase.auth.signUp({
email: email,
password: password
});
if (error) {
throw error;
}
const userId = data.user.id;
const userEmail = data.user.email;
const { data: insertData, error: insertError } = await supabase.from('Users').insert([{ user_id: userId, email: userEmail }]);
if (insertError) {
throw insertError;
}
alert(`${data.user.email} 님 회원가입을 축하드립니다!`);
navigate('/');
} catch (error) {
const signUpError = `회원가입 중 에러가 발생했습니다.: ${error.message}`;
setErrorMessages([signUpError]);
console.error(signUpError);
}
};
여기에는 두 가지 로직이 포함된다.
auth에 signup 메서드로 회원가입을 요청하는 함수를 요청하면 서버는 성공하면 유저 객체가 담긴 data를, 실패하면 error를 반환한다. 따라서 에러가 있다면
if (error) {
throw error;
}
이 구문에 의해서 에러가 반환되고 함수는 종료되고,
에러가 없다면 다음 코드로 진행된다. 다음 코드는 Users 테이블에 데이터를 저장하는 것이다.
그런데 여기서 모두 필요한 것은 아니고, 유저의 아이디와 이메일만 테이블에 insert 하도록 설정하였다.
const userId = data.user.id;
const userEmail = data.user.email;
const { data: insertData, error: insertError } = await supabase.from('Users').insert([{ user_id: userId, email: userEmail }]);
Supabase 데이터 테이블에서 user_id라는 컬럼이 있고 email이라는 컬럼이 있는데 여기에다가 어떤 값을 넣어줄 지 변수로 명시해주었다.
반환된 data에서 user 정보를 꺼내오는 것이다.
Supabase에서는 각 함수의 반환 값을 친절히 명시해주고 있다. 아래 공식문서에서도 확인 가능하다.
https://supabase.com/docs/reference/javascript/auth-signup
// Some fields may be null if "confirm email" is enabled.
{
"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": "",
"last_sign_in_at": "2024-01-01T00:00:00Z",
"app_metadata": {
"provider": "email",
"providers": [
"email"
]
},
"user_metadata": {},
"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"
},
"session": {
"access_token": "<ACCESS_TOKEN>",
"token_type": "bearer",
"expires_in": 3600,
"expires_at": 1700000000,
"refresh_token": "<REFRESH_TOKEN>",
"user": {
"id": "11111111-1111-1111-1111-111111111111",
"aud": "authenticated",
"role": "authenticated",
"email": "example@email.com",
"email_confirmed_at": "2024-01-01T00:00:00Z",
"phone": "",
"last_sign_in_at": "2024-01-01T00:00:00Z",
"app_metadata": {
"provider": "email",
"providers": [
"email"
]
},
"user_metadata": {},
"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"
}
}
},
"error": null
}
번외) 로그인 컴포넌트 전체 코드
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import supabase from '../api/supabaseClient';
import signInWithKakao from './signInWithKakao';
import {
Button,
Container,
Content,
ErrorMessage,
Form,
Forms,
Icon,
ImgWrapper,
Input,
InputBox,
Title,
KakaoButton
} from './auth.styled';
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isButtonDisabled, setIsButtonDisabled] = useState(true);
const navigate = useNavigate();
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('');
}
};
useEffect(() => {
if (email && password && !error && validateEmail(email)) {
setIsButtonDisabled(false);
} else {
setIsButtonDisabled(true);
}
}, [email, password, error]);
const handleLogin = async (event) => {
event.preventDefault();
try {
const { data, error } = await supabase.auth.signInWithPassword({
email: email,
password: password
});
alert(`${data.user.email} 님의 방문을 환영합니다!`);
navigate('/');
} catch (error) {
const loginError = `로그인 중 에러가 발생했습니다.: ${error.message}`;
setError(loginError);
console.error(loginError);
}
};
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>
<KakaoButton src="src/assets/kakao_login_medium_wide.png" onClick={signInWithKakao} />
</Form>
</Forms>
</Content>
</Container>
);
};
export default Login;
로그인 컴포넌트는 signUpWithPassword 메서드를 사용한다는 점 빼고 나머지 로직은 비슷하다.
'Programing > Server' 카테고리의 다른 글
Supabase 데이터 전처리 :: 텍스트 컬럼을 구분자를 기준으로 여러 컬럼으로 분리하기 (0) | 2024.06.28 |
---|---|
Supabase 데이터 전처리 :: 여러 컬럼을 하나의 jsonb로 병합하기 (1) | 2024.06.28 |
supabase CRUD - 이미지 파일 선택 후 미리보기로 렌더링하기 (0) | 2024.06.20 |
supabase 카카오 소셜 로그인 기능 구현하기 (0) | 2024.06.20 |
supabase 유저 이메일 말고 유저 아이디가 일치하면 업데이트 가능하도록 정책 설정하기 (0) | 2024.06.19 |
댓글