일단 로그인 페이지에서 회원가입 페이지로 이동하기 위해 useRouter()를 사용하려했는데
이러한 에러가 떴다
그래서 확인해보니
App Router에선 기존 useRouter()에 대해
import { useRouter } from 'next/router'
여기서 가져오는게 아닌
import { useRouter } from 'next/navigation'
여기서 가져오는걸로 바뀌었다 하였다.
<a>
태그그래서 실제 동적 라우팅을 위해 useRouter를 사용하였고,
계속 타입에러가 뜨면서
회원가입 버튼을 누르면 새로고침이 반복되는 현상이 진행되었다.
그래서 e.preventDefault()를 이용해 새로고침을 막았고,
타입 에러가 떠서
e에 대한 타입을 React.MouseEvent<HTMLButtonElement>
를 사용하였고,
그러니까 이젠 onClick쪽에서 에러가 발생하였다.
그래서 버튼 구사하는 컴포넌트를 살펴보니
onClick쪽에 ()=>void 즉,
아무런 인자를 받지 않고, 반환값이 없는 함수로 세팅이 되어 있어서, 함수를 받기 위해
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
똑같이 받아올 수 있도록 처리하였더니
정상적으로 페이지 이동이 진행되었다.
그렇다면 페이지 라우팅은 해결했고,
그렇다면 Link가 아닌 useRouter를 사용함으로서
SEO를 어떻게 높일 수 있을까?
다양한 방식 중에 나는 메타 태그 SEO 향상을 택했는데
메타 태그의 역할
HTML의 태그는 웹 페이지에 대한 메타데이터를 제공하여 검색 엔진과 소셜미디어 플랫폼이 페이지를 인덱싱하고 표시할 때 중요한 역할
페이지의 제목, 설명, 키워드, 작성자 정보 등 포함 가능
<Head>
<title>Login - Share Life</title>
<meta
name="description"
content="Login to Share Life and start sharing your experiences with the world."
/>
</Head>
이 부분을 통해 메타태그를 넣어줬는데,
<title>Login - Share Life</title>
브라우저 탭에 표시되는 페이지 제목,
검색 엔진이 제목을 검색 결과에 표시
<meta
name="description"
content="Login to Share Life and start sharing your experiences with the world."
/>
description - 간단하게 어떤 페이지인지 설명하겠다는 의미
content(내용)이 너무 광범위한것 같고 제목도 부족해보여서
Login to Share Life - Secure Your Account
content="Log in to Share Life to access your account and connect with others."
이정도로 바꿨다
그러면 이제
그리고 찾아보니
SEO 향상을 위해
<meta name='rotobs' content'index, follow' />
를 추가하라는데
<link rel="canonical" href="https://yourdomain.com/login">
결국 실제 도메인을 넣어줘야함
그렇게 되면
가 가능하다
그리고 여기서 Head 컴포넌트는 페이지의 head 섹션을 정의하는 데 사용됨
여기서 페이지의 메타데이터와 관련된 HTML 태그 쉽게 관리함
이제 초안으로 구성은 완성
내용을 실제 인식시켜줘야함
이제 기본 세팅은 했으니 유저 정보 저장할 db를 연결할 차례
요즘 많이 사용하는 supabase를 사용하기로 하였다.
일단 차이를 설명하기 전
필수 기능
회원가입/로그인/CRUD/마이페이지/댓글/이미지업로드/검색
추가기능
채팅/알림
추가기능 2
검색 고도화/이모지채팅/테스트코드(컴포넌트 테스트)
이렇게 나눠봤다. 여기에 어울리는 db를 택해야하는 상황
이러한 부분을 총 정리해보면
일단 이정도 수준의 내용을 봐도 이해는 잘 안간다
몰랐던 용어들
SQL
데이터베이스 정보 저장하고 관리하는 방법 지시하는 언어
ex) '김철수'라는 모든 사용자 찾아주세요 하면 SQL로 데이터베이스에 질문할 수 있음
데이터 베이스가 거대한 도서관이면 SQL은 '이 책 찾아주세요'라고 말하는 방식PostgreSQL
SQL 사용하는 데이터 베이스 소프트웨어 중 하나
데이터 저장, 검색, 관리
잘 정돈된 책장에 책을 체계적으로 보관하는 도서관 같은 시스템NoSQL
문서, 키-값 쌍, 그래프 등 다양한 형태로 데이터 저장
데이터 유연하게 저장, 검색할 수 있도록 설계
데이터를 책장에서 일렬로 정리한게 아닌, 필요에 따라 자유롭게 파일 폴더에 넣어두는 방식,
덜 엄격한 데이터 구조트랜잭션
데이터베이스에서 이뤄지는 일련의 작업 묶어서 처리하는 개념
모든 작업이 성공적으로 완료, 그렇지 않으면 모든 작업 원래 상태로 돌아가도록 하는 것
은행에서 돈을 이체하는 것과 같음
완료되면 돈이 정확히 이동하고 중간에 문제 생기면 돈은 다시 원래 계좌로 돌아감S3 호환 스토리지
aws와 s3와 호환가능한 스토리지 서비스
인터넷에 파일을 저장할 수 있는 거대한 온라인 하드 드라이브
근데 가격이 firebase에 비해 supabase는 무료로 사용가능한 부분이 많음
그리고 SQL로 좀 더 데이터를 체계적으로 보관할 수 있고, 요즘 실제 회사에서도 채택한다는 더 인기있는 supabase 채택!!
일단 프로젝트는 supabase로 생성했으므로
공식문서에선 database 구축하라고 되어있음
Get started by building out your database
Start building your app by creating tables and inserting data. Our Table Editor makes Postgres as easy to use as a spreadsheet, but there's also our SQL Editor if you need something more.
일단 회원가입 진행할 땐, 이메일/성함/닉네임/비밀번호를 필요로 하고,
비밀번호 잘 입력했는지 이중 확인, 가입 날짜, 프로필 사진 정도만 더 추가해주면 좋을 것 같다
일단 Tabla Editor 클릭
생성
이게 맞는지 모르겠지만
id / email/ created_at / name / nickname 을 테이블에 세팅해주었고,
email/ name/nickname 셋다 is Nullable 비체크 즉 Null값이면 안된다로 세팅
그리고 email과 nickname은 is Unique 고유한 값이여야해서 설정해주었음
그리고 살펴보니
인증/저장공간/ 엣지함수? / 실시간 4가지 기능이 있으므로, 나중에 필요할때 써보자
pnpm add @supabase/supabase-js
그리고 pnpm으로 일단 프로젝트에 수파베이스 설치
그리고 API 연결도 진행해보자
NEXT_PUBLIC_SUPABASE_URL=https~~~
NEXT_PUBLIC_SUPABASE_ANON_KEY=ey~~~
.env.local에 해당 키 넣기
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
lib/supabaseClient.ts에 클라이언트 설정하기
이후 세팅하는건 커뮤니티를 참고하니
id관련해서 스키마를 auth / users / id
그리고 cascade, 만약 유저지우면 기록에서도 지우도록 세팅
그리고 프로젝트 세팅으로 가서
8자리 이상, 영어 대소문자, 숫자, 특수문자 조합 으로 세팅
그리고
Prevent use of leaked passwords (recommended)
Rejects the use of known or easy to guess passwords on sign up or password change. Powered by the HaveIBeenPwned.org Pwned Passwords API.
이건 비밀번호 보안에 대한 내용인데,
유출된 비밀번호 사용 방지/ 쉽게 추측가능한 비밀번호 등록 거부를 추천한다고 하니까 체크하고 싶었으나 돈을 들여야해서 스킵..!
그 아래도 스킵..! 여러 세션 못가지게 하는 법
마지막쪽엔
Enable Captcha protection
Protect authentication endpoints from bots and abuse.
인증 엔드포인트를 봇이나 악용으로부터 보호하기 위해 캡차 기능 활성화
CAPTCHA
사람이 컴퓨터인지 봇인지 구별하는 테스트
하면 좋을 것 같으니 체크!
근데 체크하니
hCaptcha 아니면 turnstile by cloudflare
둘 중에 선택하라는데
사용자 경험과 통합 중시한다면 Turnstile
검증된 보안 기능과 설정 옵션이 중요하다면 hCaptcha라는데
일단은 사용자 경험을 중시해볼 예정 Trunstile!
근데 실제 도메인 주소가 필요하네??
버셀 들어가서 Add New Project 에서 내 깃허브에 올라와 있는 레퍼지토리 클릭
이 후 루트 디렉토리가 src여서 해당 클릭
이후 환경변수 여기다 넣어주기
근데 next가 없다고 에러가 떴는데
"dependencies": {
"@supabase/supabase-js": "^2.45.1",
"add": "^2.0.6",
"button": "^1.1.1",
"init": "^0.1.2",
"next": "14.2.5",
정상적으로 next가 담겨있었음
근데 src 안에 package.json이 없어서 이런 문제가 있을 수 있어 root를 src가 아닌 완전 루트로 만들어서 재 배포해봄
그래도 안되서 개 헤매다가
아예 Root Directory를 비우니까 빌드 완성
하 힘들다 힘들어 ;;
라고 할뻔
지금 내가 세팅한 Prettier때문에 전부 에러가 뜨고 있는데 일단
1. "대신 '사용
이거 세팅한 기억이 있는데 "사용하는부분이 많아서 다 바꿔줘야함
근데 안바뀐다 이거 세팅 제거하자
이거 지워서 다시 빌드해봄
하 그래도 안되네 그러면 따옴표 다 찾아보자
근데 결국 import React from "react"
이런 것들 ''로 바꿔줘도 저장하니까 ""로 저절로 바뀌길래, 그냥 singleQuote를 false로 만들어줌
근데 그래도 에러뜸 ^_^
그래서 Eslintrc.json에서도
"rules": {
"prettier/prettier": ["error", { "singleQuoto": false }],
singleQuote 추가함
제발 되라ㅠㅠ
그래도 안되서
각 파일들 다 찾아가면서 import쪽 '로 되어있는것 "로 다 변경해줌
그래도 에러가 계속 떠서
prettier관련 기본세팅 제외 전부 삭제 시킴
{
"semi": false,
"trailingComma": "all",
"arrowParens": "always"
}
드디어! 다른 에러가 떴다
@typescript-eslint/no-unused-vars
이 에러때문에 빌드 에러가 뜨고 있었는데,
코드에서 정의된 변수들이 사용되지 않고 있다는 의미임
import React, { use, useState } from "react"
const { data, error } = await supabase.auth.signUp({
email,
password,
})
<input type="" id="login-name" placeholder="성명" />
<input type="" id="" placeholder="닉네임" />
현재 use 잘못 가져왔고, data 가져와서 사용안하고 있고, ''로 빈타입으로 냅둬서 에러가 뜨고 있었음.
이번엔 느낌이 좋은데 과연??
제발... 제발 되라....
이젠 button.tsx때문에 문제가 생기고 있었는데,
e에 대해 type선언해주었지만,
실제로 이 파일에서 사용을 안해서 에러가 떴다
근데 이건 받아만 와도 되는 코드여서 차라리
@typescript-eslint/no-unused-vars
이 eslint를 off를 하기로 하였다.
세팅할땐 오류를 줄일 수 있어서 좋다고 생각한 prettier와 eslint가 이렇게 발목을 잡을 줄이야....
prettier빼고 다 off처리!
보이나요 이 수많은 Error의 향연이....!!!
하......... 고생했다......
드디어 도메인 확보....
근데... 저 도메인때문에 지금까지 vercel 난리를 쳤는데
.com 형식이 아니면 넣어지지가 않네..? ㅎㅎ
빨리 가비아에서 싼거 사서 넣자
그래서 sharelife.shop 도메인을 turnstile에 넣어서 side 추가해서 key를 supabase와 프로젝트에 넣어주었고,
실제 코드를 루트인 layout.tsx에 넣고
실제 표현할 페이지인
register의 page.tsx에 넣었으나,
렌더링 방식이 다른 페이지라서 렌더링 차이가 나서 에러가 떴고,
그래서 script받아오는것도 동적으로 해당 페이지에서 받아왔으나,
수많은 에러에 부딪혔다.(약 3시간)
그래서 일단 이 부분은 스킵하고 기본1차기능부터 완성 시켜볼 예정이다..
"use client"
import { Button } from "@/components/ui/button"
import { supabase } from "@/lib/supabaseClient"
import { useRouter } from "next/navigation"
import React, { useEffect, useState } from "react"
const RegisterPage = () => {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
// const [turnstileToken, setTurnstileToken] = useState<string | null>(null)
const router = useRouter()
// // Turnstile 스크립트를 클라이언트에서만 로드
// useEffect(() => {
// const script = document.createElement("script")
// script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"
// script.async = true
// script.defer = true
// document.head.appendChild(script)
// // Turnstile 스크립트 로드 후 콜백을 등록
// script.onload = () => {
// ;(window as any).turnstile?.render(".cf-turnstile", {
// sitekey: "0x4AAAAAAAhAX4NKr_No5Ofa",
// callback: (token: string) => setTurnstileToken(token),
// })
// }
// return () => {
// document.head.removeChild(script)
// }
// }, [])
const signupHandler = async () => {
// if (!turnstileToken) {
// setError("Please complete the CAPTCHA verification.")
// return
// }
// const response = await fetch("/api/verify-turnstile", {
// method: "POST",
// headers: {
// "Content-Type": "application/json",
// },
// body: JSON.stringify({ token: turnstileToken }),
// })
// const data = await response.json()
// if (!data.success) {
// setError("CAPTCHA verification failed.")
// return
// }
const { data, error } = await supabase.auth.signInWithOtp({
email: 'example@email.com',
options: {
emailRedirectTo: 'https://example.com/welcome'
}
})
if (error) {
setError(error.message)
} else {
setSuccess("Signup successful! Please check your email for verification.")
}
}
const navigateToLogin = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
router.push("/login")
}
return (
<>
<div>Share Life</div>
<input
type="email"
id="login-email"
placeholder="이메일 주소"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input type="text" id="login-name" placeholder="성명" />
<input type="text" id="login-nickname" placeholder="닉네임" />
<input
type="password"
id="login-password"
placeholder="비밀번호"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{/* <div
className="cf-turnstile"
data-sitekey="0x4AAAAAAAhAX4NKr_No5Ofa"
></div> */}
<Button onClick={signupHandler}>가입</Button>
<Button onClick={navigateToLogin}>계정이 있으신가요? 로그인</Button>
{error && <p style={{ color: "red" }}>{error}</p>}
{success && <p style={{ color: "green" }}>{success}</p>}
</>
)
}
export default RegisterPage
당장 사용할진 모르겠지만 기본으로 Email 연동된건
이메일 인증 시간 1일에서 3600초로 줄여주기(1시간)
그리고 confirm email을 꺼놓으면 이메일 인증을 안해도되므로 일단 꺼놓기
"use client"
import { Button } from "@/components/ui/button"
import { useToast } from "@/components/ui/use-toast"
import {
signupHandler,
nicknameValidationHandler,
supabase,
} from "@/lib/supabase"
import { useRouter } from "next/navigation"
import React, { useRef, useState } from "react"
const RegisterPage = () => {
const { toast } = useToast()
const emailRef = useRef<HTMLInputElement | null>(null)
const passwordRef = useRef<HTMLInputElement | null>(null)
const repasswordRef = useRef<HTMLInputElement | null>(null)
const nicknameRef = useRef<HTMLInputElement | null>(null)
const [emailError, setEmailError] = useState("")
const [passwordError, setPasswordError] = useState("")
const [repasswordError, setRepasswordError] = useState("") // 비밀번호 재확인 에러 메시지 상태 추가
const [nicknameError, setNicknameError] = useState("") // 비밀번호 재확인 에러 메시지 상태 추가
const [isEmailValid, setIsEmailValid] = useState(false)
const [isPasswordValid, setIsPasswordValid] = useState(false)
const [isRepasswordValid, setIsRepasswordValid] = useState(false)
const [isNicknameValid, setIsNicknameValid] = useState(false)
const router = useRouter()
// 이메일 유효성 검사 함수
const validateEmail = (email: string): boolean => {
if (email.trim() === "") {
setEmailError("")
return false
}
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!regex.test(email)) {
setEmailError("유효하지 않은 이메일 형식입니다")
setIsEmailValid(false)
return false
}
setEmailError("")
setIsEmailValid(true)
return true
}
// 비밀번호 유효성 검사 함수
const validatePassword = (password: string) => {
const regex =
/^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*()_+{}:<>?])[A-Za-z\d!@#$%^&*()_+{}:<>?]{8,}$/
if (password === "") {
setPasswordError("")
} else if (!regex.test(password)) {
setPasswordError(
"비밀번호는 최소 8자 이상이며, 최소 하나의 문자와 하나의 숫자, 하나의 특수문자를 포함해야 합니다",
)
} else {
setPasswordError("유효한 비밀번호입니다") // 성공 메시지
setIsPasswordValid(true)
}
}
// 엔터 키 이벤트 핸들러
const handleKeyPress = (event: React.KeyboardEvent) => {
if (event.key === "Enter") {
SignUpHandler()
}
}
// 비밀번호 재확인 유효성 검사 함수
const validateRepassword = () => {
const password = passwordRef.current?.value || ""
const repassword = repasswordRef.current?.value || ""
if (repassword === "") {
setRepasswordError("")
} else if (password !== repassword) {
setRepasswordError("비밀번호가 다릅니다")
} else {
setRepasswordError("비밀번호가 일치합니다") // 성공 메시지
setIsRepasswordValid(true)
}
}
const checkEmail = async () => {
const email = emailRef.current?.value || ""
// 이메일 입력값이 비어있는지 확인
if (email.trim() === "") {
setEmailError("이메일을 입력해주세요")
return
}
// 이메일 형식 유효성 검사
if (!validateEmail(email)) {
return
}
// 이메일 중복 검사
const { data: emailData, error: emailError } = await supabase
.from("users")
.select("email")
.eq("email", email)
.maybeSingle()
// 오류 발생시
if (emailError) {
console.error("이메일 중복 검사 중 오류 발생:", emailError)
setEmailError(emailError.message)
return
}
// 중복된 이메일이 있는 경우
if (emailData) {
setEmailError("중복된 이메일입니다")
setIsEmailValid(false)
} else {
setEmailError("유효한 이메일입니다")
setIsEmailValid(true)
}
}
// 닉네임 유효성 검사 및 중복 검사
const checkNickName = async () => {
const nickname = nicknameRef.current?.value || ""
if (nickname.trim() === "") {
// 닉네임 입력을 확인
setNicknameError("닉네임을 입력해주세요")
return
}
// 닉네임 중복 검사
const result = await nicknameValidationHandler(nickname)
if (result.error) {
console.error("닉네임 중복 검사 중 오류 발생:", nicknameError)
setNicknameError("오류가 발생했습니다. 다시 시도해주세요.")
return
}
if (result.data) {
setNicknameError("중복된 닉네임입니다")
setIsNicknameValid(false)
} else {
setNicknameError("유효한 닉네임입니다")
setIsNicknameValid(true)
}
}
const SignUpHandler = async () => {
// 모든 유효성 검사가 통과되었는지 확인
if (
!isEmailValid ||
!isPasswordValid ||
!isRepasswordValid ||
!isNicknameValid
) {
alert("모든 입력란을 올바르게 채워주세요.")
return
}
try {
const result = await signupHandler(
emailRef.current?.value || "",
passwordRef.current?.value || "",
nicknameRef.current?.value || "",
)
if (result.error) {
console.error("회원가입 중 오류 발생:", result.error)
return
}
alert("회원가입 완료")
router.push("/login")
} catch (error) {
console.error("회원가입 중 오류 발생:", error)
}
}
// const signupHandler = async () => {
// const email = emailRef.current?.value
// if (!email || !password || !name || !nickname) {
// toast({
// title: "모든 필드를 입력해 주세요",
// description: "Please provide email,password. name and nickname.",
// })
// return
// }
// const { data, error } = await supabase.auth.signUp({
// email: email,
// password: password,
// options: {
// data: {
// name: name,
// nickname: nickname,
// },
// },
// })
// if (error) {
// toast({
// title: "Signup Failed",
// description: error.message,
// })
// return
// }
// if (data.user) {
// const { error: insertError } = await supabase
// .from("public.users")
// .insert({
// id: data.user.id,
// email,
// name,
// nickname,
// })
// if (insertError) {
// console.error("Insert Error:", insertError)
// toast({
// title: "회원가입 정보 저장 실패",
// description: insertError.message,
// })
// return
// }
// toast({
// title: "회원가입 성공",
// description: "Signup successful! Please check your email.",
// })
// router.push("/home")
// }
// }
const navigateToLogin = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
router.push("/login")
}
return (
<>
<div>Share Life</div>
<input
type="email"
id="email"
placeholder="이메일 주소"
ref={emailRef}
onChange={() => validateEmail(emailRef.curruent?.value || "")}
onKeyDown={handleKeyPress}
/>
<input
type="text"
id="name"
placeholder="성명"
ref={passwordRef}
onChange={() => validatePassword(passwordRef.current?.value || "")}
onKeyDown={handleKeyPress}
/>
{/* <input
type="text"
id="nickname"
placeholder="닉네임"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
/> */}
<input
type="password"
id="password"
placeholder="비밀번호"
// value={password}
ref={passwordRef}
// onChange={(e) => setPassword(e.target.value)}
onChange={() => validatePassword(passwordRef.current?.value || "")}
onKeyDown={handleKeyPress}
/>
{/* <div
className="cf-turnstile"
data-sitekey="0x4AAAAAAAhAX4NKr_No5Ofa"
></div> */}
<Button onClick={signupHandler}>가입</Button>
<Button onClick={navigateToLogin}>계정이 있으신가요? 로그인</Button>
</>
)
}
export default RegisterPage
이 거지같은 긴 로직은 당장 신경쓸건 아니고 엄청나게 많은 에러의 향연이였는데 구현 과정을 설명하자면
여기까진 스무스하게 진행되었음
이 문제를 해결하는데 꼬박 하루를 쓴 것 같다.
일단 1차 구현만 할 것이라
이메일 아이디, 이름, 닉네임, 비밀번호만 입력해서 회원가입 가능하게 구현하였고,
이 중에서 id만 옆에 버튼을 눌러서 실제 authentication의 users에 들어있는 id(uid)와 연동하였는데,
분명 커뮤니티 잘 따라해도 연동이 안되었다.
수십개를 찾아봐도 다 똑같이 하는데 나만 안되서
결국 하다하다 table editor를 삭제하고 다시 연결하니
잘 되네??
진짜 이런 억까도 이런 억까가 없다 ;;
그 다음에, 이제 회원가입 진행한 코드가
"use client"
import { Button } from "@/components/ui/button"
import { supabase } from "@/lib/supabase"
import { useRouter } from "next/navigation"
import React, { useState } from "react"
const RegisterPage: React.FC = () => {
const [email, setEmail] = useState<string>("")
const [password, setPassword] = useState<string>("")
const [name, setName] = useState<string>("")
const [nickname, setNickname] = useState<string>("")
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const route = useRouter()
const handleSignUp = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError(null)
setSuccess(null)
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
name,
nickname,
},
},
})
if (error) {
setError(error.message)
return
}
// const { error: insertError } = await supabase
// .from("users")
// .insert([{ email, name, nickname }])
// if (insertError) {
// setError(insertError.message)
// return
// }
setSuccess("회원가입 완료")
route.push("/home")
}
return (
<div>
<h1>회원가입</h1>
<form onSubmit={handleSignUp}>
<input
type="email"
placeholder="이메일"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="text"
placeholder="이름"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<input
type="text"
placeholder="닉네임"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
required
/>
<input
type="password"
placeholder="비밀번호"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Button type="submit">회원가입</Button>
</form>
{error && <p style={{ color: "red" }}>{error}</p>}
{success && <p style={{ color: "green" }}>{success}</p>}
</div>
)
}
export default RegisterPage
이렇게 되어있는데, supabase의 signup이 authentication의 users에 담기는 내용이였고, 거기엔 email만 input에서 적은게 입력되므로 email이랑, password만 넣고, 실제 users table editor에 담는 내용은 email, name, nickname이므로 (id는 이미 연동지어서 자동으로 authentication의 users에서 자동생성된 uid를 넣어주게 연동함) 따로
// const { error: insertError } = await supabase
// .from("users")
// .insert([{ email, name, nickname }])
// if (insertError) {
// setError(insertError.message)
// return
// }
이렇게 구분 지어서 코드를 세팅해줬는데,
authentication에는 email이 잘 저장되었는데,
users public table엔 아무런 정보도 담기지 않았다.
둘다 제대로 저장되기 위해선, authentication과 users table을 이어주는 과정이 필요했었는데,
그래서 찾아보니 코드로 구현하는방법 or SQL editor 활용해서 함수와 트리거 생성하는 방법이 있어서,
지금처럼 직접 코드로 넣지 않고, 함수, 트리거 생성하는걸로 구현해보았다.
supabase의 SQL editor에서
CREATE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
BEGIN
-- Ensure that the data is not null before inserting it
INSERT INTO public.users(id, email, name, nickname)
VALUES (NEW.id, NEW.email, NEW.raw_user_meta_data->>'name', NEW.raw_user_meta_data->>'nickname');
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
이러한 코드 작성하니 드디어 해결했는데,
handle_new_user 함수
on_auth_user_created 트리거
즉, auth.users 테이블에 새 사용자가 추가될 때마다, 해당 사용자의 데이터를 자동으로 public.users 테이블에 복사해주는 것
이 함수와 트리거 덕분에 auth.users와 public.users에 값이 잘 담길 수 있었다.
이걸 보려고 하루를 고생했네 하...
이후로 이제 ShadCN의 Toast기능으로 멋스럽게 알람 다시 넣어주는 리펙토링 진행하자
<Head>
<title>Login to Share Life - Secure Your Account</title>
<meta
name="description"
content="Log in to Share Life to access your account and connect with others."
/>
<meta name="robots" content="index, follow" />
{/* 추후 실제 도메인 넣어줘야함 */}
<link rel="canonical" href="https://www.sharelife.shop/"></link>
</Head>
이 부분이 로그인에 있던 내용이라 조금만 수정해서 넣어줌
<Head>
<title>Signup to Share Life - Make Your Account</title>
<meta
name="description"
content="Sing Up to Share Life to access your account and connect with others."
/>
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://www.sharelife.shop/"></link>
</Head>
크게 수정한 내용은 없고,
이제 기존 에러처리를
{error && <p style={{ color: "red" }}>{error}</p>}
{success && <p style={{ color: "green" }}>{success}</p>}
이렇게 성공하거나 실패하면 바로 글씨색깔만 바꿔서 띄웠다면
Toast로 구현해볼건데
이전에 ShadCN을 pnpm으로 잘 설치했었으나
실제 ShadCN 홈페이지의 Components에서 설치를 진행하니 진행되지 않았다.
그래서 임의로 설치를 다시 진행하니 잘 되었고,
설치된 기능은
여기에 잘 담기게 된다.
그래서 일단 npx 혹은 pnpm으로 설치 후
Layout에 import쪽 Toaster를 담아주고,
실제 사용할 페이지에서
이런식으로 사용이 가능하다.
회원가입 성공하면 저 로직을 , 실패하면 실패내용을 띄울 것이므로,
기존의 error, success useState 제거하고
toast로 구현 성공!
이메일 형식 또는 비밀번호에 대한 부분은 supabase의 authentication에서 세팅한게 딱히 코드 반영없이 알아서 인증을 진행해주므로 넘어가고,
추가적으로 대소문자,숫자,특수문자까지 넣은 8개 이상의 비밀번호로 보안강화를 supabase쪽에서 진행하였다.
이름,닉네임은 굳이 사용한 것에 대한 중복 검사를 진행할 필요는 없을 것 같아서 일단은 스킵!!
회원가입과 차이점은 로그인된 상태로 메인페이지에 접근해야함
supabase의 signInWithPassword를 사용해서 로그인 시킴
"use client"
import { Button } from "@/components/ui/button"
import { useToast } from "@/components/ui/use-toast"
import { supabase } from "@/lib/supabase"
import Head from "next/head"
import { useRouter } from "next/navigation"
import React, { useState } from "react"
const LoginPage = () => {
const route = useRouter()
const { toast } = useToast()
const [email, setEmail] = useState<string>("")
const [password, setPassword] = useState<string>("")
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
toast({
title: "로그인 실패",
description: error.message,
})
return
}
route.push("/home")
toast({
title: "로그인 성공",
description: "로그인에 성공하였습니다.",
})
}
const handleNavigateRegister = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
route.push("/register")
}
return (
<div>
<Head>
<title>Login to Share Life - Secure Your Account</title>
<meta
name="description"
content="Log in to Share Life to access your account and connect with others."
/>
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://www.sharelife.shop/"></link>
</Head>
<div>Share Life</div>
<form onSubmit={handleLogin}>
<input
type="email"
id="login-email"
placeholder="아이디(이메일)"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
id="login-password"
placeholder="비밀번호"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Button type="submit">로그인</Button>
<Button onClick={handleNavigateRegister}>회원가입</Button>
</form>
</div>
)
}
export default LoginPage
email, password useState
로 상태 관리
supabase.auth.signInWithPassword
인증 메서드 사용 이메일, 비밀번호로 로그인 시도
그리고 폼 제출시 required
를 적어 입력 필드 값을 반드시 입력하도록 지정
TypeScript-first schema validation with static type inference.
단순 문자열~ 복잡한 중첩객체까지 중복 타입 선언을 제거하는 것이 목표인 라이브러리
Zod이용해 validator 한번 선언하면 Zod가 정적 Typescipt 유형 자동으로 추론
스크마 인스턴스 생성, parse통해 검증, infer 통해 자동 타입 추론
import { z } from "zod";
// creating a schema for strings
const mySchema = z.string();
// parsing
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError
// "safe" parsing (doesn't throw error if validation fails)
mySchema.safeParse("tuna"); // => { success: true; data: "tuna" }
mySchema.safeParse(12); // => { success: false; error: ZodError }
복잡한 데이터 형식을 객체로 묶어 스키마 생성도 가능
import { z } from "zod";
const User = z.object({
username: z.string(),
});
User.parse({ username: "Ludwig" });
// extract the inferred type
type User = z.infer<typeof User>;
// { username: string }
Zod로 validation (인증) 진행시 편하고 간결하고 가독성 높아짐
typescript는 컴파일시에만 타입을 검사하여서, 데이터 요청 시 어떤 형식이 넘어올거다 명시해놓고 사용하지만, 실제 그 형식대로 넘어오지 않을 경우도 있음
런타입 타입 체킹하지 않아서
그래서 실제로 데이터 내용이 없어도 에러를 발생시키지 않음
//data.json
{}
// index.ts
import fs from 'fs'
interface Result {
results: {
id: number
name: string
job: string
}[]
}
const printJobs = (results: Result) =>{
results.results.forEach(({job})=>{
console.log(job)
});
}
const data: Result = JSON.parse(fs.readFileSync('data.json', 'utf-8'))
printJobs(data)
이때 ? 옵셔널 체이닝 이용해서 해결할 수 도 있긴함(해당 객체의 속성에 접근할때 속성이 존재하는지 안전하게 확인해줌)
pconst printJobs = (results:Result)=>{
results?.results?.forEach(({job})=>{
console.log(job)
})
}
이 때 Zod를 차용하면 런타임 체킹이 됨.
그 전에
런타임 컴파일
코드 실행 동시에 컴파일하여, 코드 변경 후 즉시 결과 확인
동적 생성된 코드, 변경된 코드 실행할 수 있어 더 유연한 프로그래밍 가능
컴파일
코드를 미리 컴파일하여 실행 파일을 생성, 이 실행 파일을 나중에 처리
pnpm add zod
Zod 스키마 정의
일단 회원가입, 로그인 나누어서 유효성 검사 함수를 따로 zod.ts에 선언하고 로그인,회원가입 페이지에 넣는 형식으로 구현
tsconfig.json strict 체크
{
"compilerOptions": {
"strict": true
}
}
기본적으로 되어있긴한데 zod 사용할땐 꼭 엄격모드 켜놓는걸 생각하기!
이제 코드에 대해서 분석해보면
z.object
: 객체 형태의 스키마 정의
z.string()
:문자열이여야 함 정의
생각보다 정말 구현자체는 쉬워서 사용할만 한 것 같다
이제 실제 사용할 컴포넌트 쪽을 보면
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
//Zod 유효성 검사
const result = LoginSchema.safeParse({ email, password })
if (!result.success) {
const errors = result.error.errors.map((err) => err.message)
toast({ title: "회원가입 실패", description: errors.join(", ") })
return
}
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
toast({
title: "로그인 실패",
description: error.message,
})
return
}
route.push("/home")
toast({
title: "로그인 성공",
description: "로그인에 성공하였습니다.",
})
}
safeParse
: 메서드임, 입력값의 유효성 검사시켜줌
나머지는 외워서 사용할 코드는 아닌 것 같고, 언제 어디에 사용할지만 잘 체크해놓으면 좋을 것 같다.
Form에 대해서 큰 고민을 해본적이 없었는데,
React Hook Form 이 있다고 해서 사용해보려 한다
React Hook Form
useForm으로 사용할 수 있는 라이브러리이자 커스텀 훅,
폼 관리를 쉽게 할 수 있도록 도와줌
폼의 상태를 관리하고, 유효성 검사를 수행하며, 제출 이벤트 처리하는 다양한 기능을 제공
결국 ShadCN으로는 UI,
React Hook Form으로 폼 상태 관리
(불필요한 리렌더링 방지/쉽게 폼 유효성 검사/ 유연성)
Zod로 Typescript와 함께 스키마 기반 유효성 검사
를 진행한다고 보면 되겠다
아까 Zod는 설치했으므로
pnpm add react-hook-form``pnpm add shadcn-ui form
두개를 추가 설치해준다
그 다음에 세개나 연동해야해서 커뮤니티를 참고했는데,
즉, 사용자가 Form 컴포넌트(shadcn/ui)에 제출한 데이터를 React-hook-form을 통해 상태추적, 유효성검증,제출 핸들링 등 관리하고, 이를 마지막으로 스키마(zod)에 따라 유효성검증 후 처리
이 순서로 진행한다고 하니 잘 따라해보자
zodResolver로 zod스키마와 react-hook-form을 연결해야한대서
pnpm add zod @hookform/resolvers
를 다운받았음에도 import가 안되서 설마하고 껐다 켰더니 또 된다...
아 진짜 컴퓨터 왜이래 ㅡㅡ 벌써 당연하게 되야하는데 안되는게 몇번째인지;;
이제 회원가입쪽 구현한 코드를 분석해보자면
"use client"
import { Button } from "@/components/ui/button"
import { Card, CardFooter, CardHeader } from "@/components/ui/card"
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { useToast } from "@/components/ui/use-toast"
import { supabase } from "@/lib/supabase"
import { RegisterSchema, userType } from "@/lib/zod"
import { zodResolver } from "@hookform/resolvers/zod"
import Head from "next/head"
import Image from "next/image"
import { useRouter } from "next/navigation"
import React, { useState } from "react"
import { useForm } from "react-hook-form"
const RegisterPage: React.FC = () => {
const [email, setEmail] = useState<string>("")
const [password, setPassword] = useState<string>("")
const [name, setName] = useState<string>("")
const [nickname, setNickname] = useState<string>("")
const route = useRouter()
const { toast } = useToast()
const form = useForm<userType>({
resolver: zodResolver(RegisterSchema),
})
const handleSignUp = async (data: userType) => {
const { email, password, name, nickname } = data
//Zod 유효성 검사
const result = RegisterSchema.safeParse({ email, password, name, nickname })
if (!result.success) {
const errors = result.error.errors.map((err) => err.message)
toast({ title: "회원가입 실패", description: errors.join(", ") })
return
}
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
name,
nickname,
},
},
})
//supabase signUp 오류 처리 로직
if (error) {
toast({
title: error.message,
description: "회원가입을 실패하였습니다.",
})
return
}
toast({
title: "회원가입 성공",
description: "회원가입이 성공적으로 완료되었습니다.",
})
route.push("/home")
}
return (
<div>
<Head>
<title>Signup to Share Life - Make Your Account</title>
<meta
name="description"
content="Sing Up to Share Life to access your account and connect with others."
/>
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://www.sharelife.shop/"></link>
</Head>
<Card className="w-[480px]">
<CardHeader>
<div className="flex justify-center">
<Image
width={300}
height={100}
src="/share life.png"
alt="Logo Image"
/>
</div>
</CardHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSignUp)}
className="space-y-8"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="이메일" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="password" placeholder="비밀번호" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="이름" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="닉네임" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<CardFooter className="flex justify-center">
<Button type="submit">가입하기</Button>
<Button type="submit">회원가입</Button>
</CardFooter>
</form>
</Form>
</Card>
<h1>회원가입</h1>
</div>
)
}
export default RegisterPage
const RegisterPage: React.FC = () => {
const [email, setEmail] = useState<string>("")
const [password, setPassword] = useState<string>("")
const [name, setName] = useState<string>("")
const [nickname, setNickname] = useState<string>("")
const route = useRouter()
const { toast } = useToast()
일단 입력값 email password name nickname은 useState로 설정
페이지 라우터 useRouter사용
알림은 useToast사용
const form = useForm<userType>({
resolver: zodResolver(RegisterSchema),
})
useForm(react-hook-form)사용해서 zodResolver를 이용해 zod의 설정해놓은 스키마와 연결해서 입력 값의 유효성 검증
const handleSignUp = async (data: userType) => {
const { email, password, name, nickname } = data
이전에는 ()안에 e:HTML 이런 값을 타입과 같이 넣어놨는데, 폼 처리 방식의 변화때문에 data를 넣어두었다.
이전 e를 인자로 받았을 때의 e는 폼 제출 시 발생하는 이벤트 객체
즉, e.preventDefault()를 호출하면 폼의 기본 제출 동작(페이지 새로고침)을 막고, JS로 비동기 요청을 처리했음
이번의 data는 react-hook-form에서 폼을 제출할때 자동으로 전달되는 입력 값들의 객체를 의미함
그렇다면 react-hook-form을 사용하면 폼 제출 처리하는 handleSubmit 함수가 기본적으로 e.preventDefault()를 수행함
그래서 새로고침 없이 JS로 비동기 처리할 수 있게 됨
handleSubmit은 form안에 들어있는 함수(onSubmit에서 사용함)
//Zod 유효성 검사
const result = RegisterSchema.safeParse({ email, password, name, nickname })
if (!result.success) {
const errors = result.error.errors.map((err) => err.message)
toast({ title: "회원가입 실패", description: errors.join(", ") })
return
}
이후 이전 zod 스키마에서 사용했던 유효성 검사
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
name,
nickname,
},
},
})
이후 수파베이스쪽의 signUp메서드를 사용해서 사용자 계정을 만들고 name, nickname은 users table에 담기도록 options-data안에 넣어줌(auth.users엔 email - users.table엔 email,name,nickname이 들어감)
//supabase signUp 오류 처리 로직
if (error) {
toast({
title: error.message,
description: "회원가입을 실패하였습니다.",
})
return
}
이건 백엔드 즉 수파베이스쪽의 오류
toast({
title: "회원가입 성공",
description: "회원가입이 성공적으로 완료되었습니다.",
})
route.push("/home")
}
최종적으로 오류가 다 안걸리면 toast로 알람 보내주고 home페이지로 이동시킴
아래쪽 form쪽은 커뮤니티를 참고함..!
이제 최종적으로 react-hook-form을 사용함으로써 email,password,name,nickname에 대한 useState상태관리가 필요 없게 되었는데
react-hook-form의 useForm을 통해 상태 관리가 가능해짐
그 후 db로 data 전달하는 함수 handleSignUp 같은 함수 내에서 data 매개변수로 전달된 값을 자체적으로 사용할 수 있음
참 좋은 기능!!