mini project - 영화 페이지 만들기(4-1단계) - 백엔드 연결 전 input창 구성하기 로그인 / 회원가입 구현
부캠에서 제시해준 구현단계 네번째 최종 구현 단계는 다음과 같습니다.
Supabase(백엔드)를 연결하기 전에, 우선 프론트만으로 로직을 구성해봅니다.
1. 회원가입/로그인 페이지 구현하기
기존 navBar에, 로그인 / 회원가입 칸은 따로 구성 해두었으니 공통 Input 컴포넌트를 만든 뒤 login 페이지와 Join 페이지
그 안을 채워줍니다.
유효성 검사? 프론트에서 구현할 수 있는 것 맞아 ? ↓
유효성 검사는 입력값의 형식이 적절한지를 판단하는 거야.
✔️ 유효성 검사 (Validation)
백엔드 없이도 가능 (프론트엔드에서 처리)
- 비밀번호가 8자리 이상인가?
- 이메일 형식이 맞는가? (@, .com 등)
- 공백 없이 입력했는가?
- 특수문자를 포함했는가?
if (password.length < 8) {
// 유효성 검사 실패: "비밀번호는 8자리 이상이어야 해요"
}
✔️ 인증 (Authentication)
백엔드와의 통신 필요
- 이 이메일/비밀번호 조합이 진짜 사용자 계정에 있는지?
- 비밀번호가 정확한지?
// 백엔드에 보낸 후
if (서버응답 === "비밀번호 틀림") {
// "비밀번호가 일치하지 않습니다" 표시
}
유효성 검사는 "형식이 맞는지" 보는 거고,
인증은 "실제로 맞는지" 보는 거야.
"이 형식은 맞는 입력인가?" → 프론트에서 유효성 검사로 가능해!
유효성 검사 코드 예시↓
정규식 문법은 암기 X , 외우는 게 아니라 필요할 때 꺼내 쓰는 도구
전문가들도 다 외우고 쓰는 게 아니라, 상황에 맞게 구글링하거나 참고해서 조합
- 자주 쓰는 조건은 패턴을 저장해놓기!
- GitHub, Notion, 메모앱 등 정리해두면 나중에 금방 꺼내 쓸 수 있어.
- 예: "비밀번호 8자리 + 숫자 포함" → 정리해두면 계속 재활용 가능!
- 코드 작성할 때는 정규식 테스트 사이트 활용하기
- regexr.com
- regex101.com
→ 조건 넣어보고 실시간으로 테스트 가능해서 진짜 편해.
비밀번호 조건에 따라 여러 가지 정규식이 있어.
기본: 8자리 이상
/^.{8,}$/
- 설명: 문자 8개 이상이면 통과
- 예: abc12345 → ✅ / abc123 → ❌
영어+숫자 포함, 8자리 이상
/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/
- 설명: 영문자 + 숫자 필수, 8자 이상
- 예: password1 → ✅ / password → ❌ / 12345678 → ❌
영어 대소문자 + 숫자 + 특수문자 포함, 8자리 이상
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
- 설명:
- 소문자 1개 이상
- 대문자 1개 이상
- 숫자 1개 이상
- 특수문자 1개 이상
- 총 8자리 이상
- 예: Abcdef1! → ✅ / abcdefg1 → ❌
예시 코드 (JavaScript)
const password = "Abcdef1!";
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
if (regex.test(password)) {
console.log("유효한 비밀번호입니다!");
} else {
console.log("비밀번호 조건에 맞지 않습니다.");
}
내가 생각한 로직 ? ↓
일단 나는 유효성 검사를 두번 해야 한다고 생각했다. (구현 조건엔 없음)
1. 실시간 검사 2. 최종 검사
로그인 로직 흐름 요약
- 입력 실시간 유효성 검사 (프론트엔드)
- 사용자가 아이디/비밀번호 입력할 때
- 입력값의 형식 체크 (예: 이메일 형식인지, 비밀번호 길이는 적절한지 등)
- 즉각적으로 에러 메시지 출력 (ex. "비밀번호는 8자 이상이어야 해요")
- 로그인 버튼 클릭 시, 최종 유효성 검사 (프론트엔드)
- 사용자가 입력을 다 마치고 버튼 클릭 시
- 한 번 더 전체적으로 조건 검토 (입력 안 된 필드가 없는지 등)
- 유효하지 않으면 서버로 요청 보내지 않음
- 서버 요청 (백엔드)
- 유효하면 서버로 로그인 정보 전송 (API 요청)
- 백엔드는 실제 사용자 정보(DB)와 비교하여 로그인 성공 여부 판단
- 로그인 성공 → 토큰 발급, 유저 정보 전달 등
- 로그인 실패 → 오류 메시지 전달 (예: "비밀번호가 틀렸습니다")
- 프론트에서 서버 응답 기반 렌더링
- 성공 시: 메인 페이지로 이동
- 실패 시: 서버로부터 받은 메시지를 사용자에게 보여줌
* 같이 공부하시는 비스킷 동기님의 조언 : 유효성 검사 쪽 로그인 / 회원가입 창에서 직접 해야 함.
( 직접 안하면, 검사 후 예를 들어 틀렸어도, 로그인할 때 그냥 넘어간다고 함!) ->
이 부분은 백엔드쪽이랑도 관리가 있어서?(모르겠음) 아직 내 머리로는 생각이 어려움
유효성 검사 로직을 로그인 페이지, 회원가입 페이지, 프로필 수정 페이지 등 각기 다른 페이지에서 매번 새로 다 구현하는 건 비효율적이야. 그래서 보통은 검사 함수(또는 유틸)를 공통으로 만들어서 재사용해.
방법 1. 공통 유효성 검사 함수 만들기 (모듈화)
예를 들어 validation.js 같은 파일에 함수들을 만들어두고, 로그인, 회원가입 페이지에서 필요할 때 불러다 써.
// validation.js
export function validateEmail(email) {
if (!email) return "이메일을 입력해주세요.";
if (!email.includes("@")) return "이메일 형식이 올바르지 않습니다.";
return "";
}
export function validatePassword(password) {
if (!password) return "비밀번호를 입력해주세요.";
if (password.length < 6) return "비밀번호는 6자리 이상이어야 합니다.";
return "";
}
각 페이지에서
import { validateEmail, validatePassword } from "./validation";
function handleSubmit() {
const emailError = validateEmail(email);
const passwordError = validatePassword(password);
// 에러 있으면 처리...
}
방법 2. 커스텀 훅으로 만들기 (React 전용)
React에서 자주 쓰는 패턴인데, useValidation 같은 훅을 만들어서 관리하면 편해요.
function useValidation() {
function validateEmail(email) { /*...*/ }
function validatePassword(password) { /*...*/ }
return { validateEmail, validatePassword };
}
그리고 각 컴포넌트에서 훅을 불러서 씁니다.
서버와 통신하는 검사
서버 통신이 필요한 중복 검사 같은 건
- 프론트에서 공통 함수로 API 호출하는 부분만 만들거나
- 각 페이지에서 별도로 API 호출하고, 결과 받아서 처리해도 괜찮아
다만 API 호출 로직도 api.js 같은 모듈에 정리해두는 게 유지보수에 좋아요.
요약
- 기본 형식 검사(유효성 검사)는 공통 함수 또는 훅으로 만들어 재사용
- 서버와 통신하는 중복 검사 등은 페이지별로 상황에 맞게 구현해도 되지만, API 호출 함수는 공통 관리 추천
- 이렇게 하면 코드 중복 줄이고, 관리가 훨씬 쉬워짐
supabase 연동 전 로그인 전체 코드
// Login.jsx
import React, { useState } from "react";
import Input from "../components/Input";
import { Navigate, useNavigate } from "react-router-dom";
function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");
const [passwordError, setPasswordError] = useState("");
const navigate = useNavigate();
// 이메일 검사 함수
const validateEmail = (email) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// 비밀번호 검사 함수
const validatePassword = (password) => {
return password.length >= 6;
};
// 이메일 입력할 때마다 검사
const handleEmailChange = (e) => {
const value = e.target.value;
setEmail(value);
if (!validateEmail(value)) {
setEmailError("올바른 이메일 형식이 아니에요.");
} else {
setEmailError("");
}
};
// 비밀번호 입력할 때마다 검사
const handlePasswordChange = (e) => {
const value = e.target.value;
setPassword(value);
if (!validatePassword(value)) {
setPasswordError("비밀번호는 6자 이상이어야 해요.");
} else {
setPasswordError("");
}
};
const handleLogin = () => {
if (email === "test@test.com" && password === "123456") {
alert("로그인 성공");
navigate("/");
} else {
alert("아이디 또는 비밀번호가 잘못되었습니다.");
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (!emailError && !passwordError && email && password) {
handleLogin();
} else {
alert("입력한 값을 다시 확인해주세요.");
}
};
return (
<div style={{ maxWidth: "400px", margin: "2rem auto" }}>
<h2>로그인</h2>
<form onSubmit={handleSubmit}>
<Input
label="이메일"
type="email"
value={email}
onChange={handleEmailChange}
error={emailError}
/>
<Input
label="비밀번호"
type="password"
value={password}
onChange={handlePasswordChange}
error={passwordError}
/>
<button
type="submit"
style={{
padding: "0.5rem 1rem",
backgroundColor: "#333",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer"
}}
>
로그인
</button>
</form>
</div>
);
}
export default Login;
supabase 연동 전 회원 가입 전체 코드
//Join.jsx
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import Input from "../components/Input";
function Join() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [emailError, setEmailError] = useState("");
const [passwordError, setPasswordError] = useState("");
const [nameError, setNameError] = useState("");
const [confirmPasswordError, setConfirmPasswordError] = useState("");
const navigate = useNavigate();
// 이메일 검사 함수
const validateEmail = (email) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// 이름 검사 함수
const validateName = (name) => {
// 2~8자 사이, 숫자, 한글, 영어만 허용
const nameRegex = /^[가-힣a-zA-Z0-9]{2,8}$/;
return nameRegex.test(name);
};
// 비밀번호 검사 함수
const validatePassword = (password) => {
return password.length >= 6;
};
//비밀번호 확인 검사 함수
const validatePasswordConfirm = (passwordConfirm) => {
return passwordConfirm === password;
};
// 이메일 입력할 때마다 검사
const handleEmailChange = (e) => {
const value = e.target.value;
setEmail(value);
if (!validateEmail(value)) {
setEmailError("올바른 이메일 형식이 아니에요.");
} else {
setEmailError("");
}
};
// 이름 입력할 때마다 검사
const handleNameChange = (e) => {
const value = e.target.value;
setName(value);
if (!validateName(value)) {
setNameError("올바른 이름 형식이 아니에요.");
} else {
setNameError("");
}
};
// 비밀번호 입력할 때마다 검사
const handlePasswordChange = (e) => {
const value = e.target.value;
setPassword(value);
if (!validatePassword(value)) {
setPasswordError("비밀번호는 6자 이상이어야 해요.");
} else {
setPasswordError("");
}
};
// 비밀번호 확인 부분 입력할 때마다 검사
const handleConfirmPasswordChange = (e) => {
const value = e.target.value;
setConfirmPassword(value);
if (!validatePasswordConfirm(value)) {
setConfirmPasswordError("비밀번호가 일치하지 않습니다.");
} else {
setConfirmPasswordError("");
}
};
const handleJoin = () => {
if (!emailError && !passwordError && !nameError && email && password && name && confirmPassword && !confirmPasswordError) {
alert("회원가입 성공, 로그인 창으로 넘어갑니다.");
navigate("/login");
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (!emailError && !passwordError && !nameError && email && password && name && confirmPassword && !confirmPasswordError) {
handleJoin();
} else {
alert("입력한 값을 다시 확인해주세요.");
}
};
return (
<div style={{ maxWidth: "400px", margin: "2rem auto" }}>
<h2>회원가입</h2>
<form onSubmit={handleSubmit}>
<Input
label="이메일"
type="email"
value={email}
onChange={handleEmailChange}
error={emailError}
/>
<Input
label="이름"
type="name"
value={name}
onChange={handleNameChange}
error={nameError}
/>
<Input
label="비밀번호"
type="password"
value={password}
onChange={handlePasswordChange}
error={passwordError}
/>
<Input
label="비밀번호 확인"
type="password"
value={confirmPassword}
onChange={handleConfirmPasswordChange}
error={confirmPasswordError}
/>
<button
type="submit"
style={{
padding: "0.5rem 1rem",
backgroundColor: "#333",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer"
}}
>
회원가입
</button>
</form>
</div>
);
}
export default Join;
2. 앞선 login 페이지에서, 우선 백엔드 없이 로그인, 회원가입을 구현할 수 있는 코드를 작성했고,
이제 로그인 된 뒤 NavBar의 UI를 바꿔 봐야 합니다.
로그인이 되었다는 걸 기억할 로그인유무 상태가 필요하고,
그 상태 따라 조건부 렌더링 해준다.
자 그런데 이제 또 상태는 어느 컴포넌트 관리해야하고 로그인에 따른, NavBar 렌더링은 어디서 해야하지?
렌더링은 네브바에서 할테니까 네브바에서 렌더링 코드 짜면되고..
상태는..
지금 단계에서 "상태를 어디서 관리하고, 렌더링은 어디서 할지" 정확히 짚고 넘어가는 건 아주 좋아!
상태는 어디서 관리해야 할까?
💡 로그인 상태 (isLoggedIn)는 여러 컴포넌트에서 필요하니까 → _상위 컴포넌트_에서 관리하는 게 좋아.
- 예: App.js 혹은 Layout, Root 같은 상위 컴포넌트
- 그래야 Navbar 같은 하위 컴포넌트로 props로 전달해서 쓸 수 있어
구조 예시
App.js (or Layout.js)
import React, { useState } from "react";
import Navbar from "./components/Navbar";
import { Routes, Route } from "react-router-dom";
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
return (
<>
<Navbar isLoggedIn={isLoggedIn} setIsLoggedIn={setIsLoggedIn} />
<Routes>
{/* 로그인 시 setIsLoggedIn(true) 해줄 수 있게 prop 전달 */}
<Route path="/login" element={<Login setIsLoggedIn={setIsLoggedIn} />} />
{/* 기타 라우트 */}
</Routes>
</>
);
}
export default App;
Navbar.js
function Navbar({ isLoggedIn, setIsLoggedIn }) {
return (
<nav>
<Logo />
<SearchInput />
{isLoggedIn ? (
<ThumbnailMenu setIsLoggedIn={setIsLoggedIn} />
) : (
<>
<Link to="/login">로그인</Link>
<Link to="/join">회원가입</Link>
</>
)}
</nav>
);
}
Login.js (로그인 성공 후)
function Login({ setIsLoggedIn }) {
const navigate = useNavigate();
const handleLogin = () => {
// 로그인 검증 로직 (생략)
setIsLoggedIn(true); // 로그인 성공 시 상태 업데이트
navigate("/"); // 홈으로 이동
};
return (
// 로그인 폼 ...
<button onClick={handleLogin}>로그인</button>
);
}
ThumbnailMenu.jsx(로그인 후, 썸네일 컴포넌트. 마이페이지와, 로그아웃 버튼)
import React from "react";
function ThumbnailMenu({ setIsLoggedIn }) {
const handleLogout = () => {
setIsLoggedIn(false); // 로그아웃 시 상태 변경
alert("로그아웃되었습니다.");
};
return (
<div style={{ position: "relative" }}>
<img
src="/user-thumbnail.jpg"
alt="User"
style={{ width: "40px", borderRadius: "50%", cursor: "pointer" }}
/>
<div
style={{
position: "absolute",
top: "50px",
right: 0,
background: "white",
border: "1px solid #ddd",
borderRadius: "6px",
padding: "10px"
}}
>
<button onClick={() => alert("마이페이지로 이동")}>마이페이지</button>
<button onClick={handleLogout}>로그아웃</button>
</div>
</div>
);
}
export default ThumbnailMenu;
상태를 공유해야하는데 context API나 리덕스를 아직 쓰지 않으면 최상단에서
App
└── Layout (props 받아서)
└── Navbar (또 props 받아서)
└── ThumbnailMenu (또또 props 받아서)
이런식으로 내려줘도 된다.
작은 프로젝트에서는 괜찮음! (드릴링...)
혼자 해 본 심화 공부
유효성 검사 파트 중에,
특정 사이트에서는 사용자가 회원가입시 아이디를 입력할 때, 실시간으로 데이터를 받아오는건지.. 에러메시지에
"이미 사용중인 아이디 입니다." 이런식으로 뜰 때가 있다.
이런 경우.. 프론트로의 유효성 검사만으로는 안될 것 같다는 생각을 해봤음.
지금으로써는 많이 무리일 것 같고, 나중에 언젠가 이 기능도 넣어보고 싶다 !
그게 바로 입력할 때마다 서버에 중복 검사 요청을 보내는 실시간 중복 검사야.
어떻게 동작하냐면?
- 사용자가 아이디를 입력할 때마다 (예: onChange 또는 onBlur 이벤트 때)
- 그 입력값을 서버에 API 요청으로 보내서
- 서버가 DB에서 중복 여부를 검사한 뒤
- 결과를 받아서 바로 화면에 “중복 아이디입니다” 또는 “사용 가능한 아이디입니다” 라고 보여주는 거야
예시 코드 (간단하게)
function SignUpForm() {
const [username, setUsername] = React.useState("");
const [error, setError] = React.useState("");
async function checkUsername(name) {
if (!name) {
setError("");
return;
}
const response = await fetch("/api/check-username", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: name }),
});
const data = await response.json();
if (data.exists) {
setError("중복 아이디입니다.");
} else {
setError("");
}
}
function handleUsernameChange(e) {
const value = e.target.value;
setUsername(value);
checkUsername(value); // 입력할 때마다 서버 중복 검사 호출
}
return (
<div>
<input value={username} onChange={handleUsernameChange} />
{error && <div style={{color: "red"}}>{error}</div>}
{/* 가입 버튼 등 */}
</div>
);
}
단점도 있어!
- 입력할 때마다 서버에 요청 보내니까 너무 잦으면 서버 부담 커짐
- 그래서 **"디바운스(Debounce)"**라는 기법을 사용해서
- 사용자가 입력을 멈추고 잠시 후에만 검사 요청 보내도록 조절하기도 함
비스킷 동기님이 언급하신 거랑 비슷하게, 예를 들어 회원가입이나 로그인하기 버튼을 눌렀을 때
백엔드 부분과 연결되어 회원가입이나 로그인을 시도한 특정 회원의 DB에 따라 또 다르게 렌더링 해줘야 할 것 같았다.
이것도 나중에 꼭 로직으로 넣어줘야 할 부분이겠지 ?
- 실시간 검사(프론트에서 바로 검사)
- 최종 검사(서버에서 검사)
여기서 최종 검사는 보통 이렇게 진행해요:
1. 프론트에서는 기본적인 형식 검사만 한다
예를 들어, 이메일 형식, 비밀번호 길이, 필수 입력 여부 같은 건 프론트에서 바로 검사해서 유저에게 빠르게 알려줌.
2. 최종 검사 (서버 검사)
폼 제출 버튼 눌렀을 때, 프론트에서 서버(API)로 데이터 보냄 → 서버에서 DB나 비즈니스 로직 기반으로 중복 여부, 실제 가입 가능한지, 권한 체크 등 더 엄격한 검사를 함
3. 서버 응답 결과에 따라 에러 메시지 보여주기
- 서버에서 이상 없으면 가입 성공 처리
- 서버에서 에러(예: 이메일 중복, 유효하지 않은 데이터) 반환하면, 프론트에서 조건부 렌더링으로 해당 에러 메시지 보여줌
요약
- 실시간 검사는 프론트에서 빠르게 UI 반응
- 최종 검사는 서버 쪽에서 꼭 해줘야 하는 중요한 검증
- 서버에서 받은 결과에 따라 프론트에서 에러 메시지를 보여주는 방식이 일반적이에요
따로 설정하지 않았는데, email Input창에서
이렇게 뜨는데, 이건 ↓
그 "@"를 포함하라고 뜨는 경고 메시지"는 사실 우리가 따로 설정한 게 아니고,
브라우저(크롬, 사파리 등)가 자동으로 해주는 기본 기능이야.
우리가 Input.jsx에서 <input type="email" /> 이렇게 썼잖아?
<input
type={type} // 여기에 email이 들어가 있음
...
/>
- type="email" 은 HTML에서 기본으로 제공하는 "유효성 검사" 기능이 있어.
- 그래서 이메일 주소에 @ 없으면 브라우저가 자동으로 경고를 띄우는 거야.
- 이건 자바스크립트 코드랑은 상관없이 브라우저가 제공하는 기능이야.
그럼 이걸 끄고 싶으면?
<form> 태그에 noValidate라는 속성을 넣어주면 돼!
<form onSubmit={handleSubmit} noValidate>
이렇게 하면 브라우저가 자동으로 검사하는 걸 꺼버리고,
우리가 만든 validateEmail() 함수만 작동하게 돼.
썸네일 부분 메뉴 호버로
그럼 마우스를 썸네일에 올렸을 때만 "마이페이지 / 로그아웃" 메뉴가 뜨는
ThumbnailMenu 컴포넌트를 만들어보자!
해결 방법
썸네일 이미지 + 메뉴 전체를 감싸는 영역에 onMouseEnter, onMouseLeave를 줘야
메뉴 안에 마우스를 내려도 안 사라져!
수정 버전
import React, { useState } from "react";
function ThumbnailMenu({ setIsLoggedIn }) {
const [isOpen, setIsOpen] = useState(false);
const handleLogout = () => {
setIsLoggedIn(false);
alert("로그아웃 되었습니다.");
};
return (
<div
style={{ position: "relative", display: "inline-block" }}
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
{/* 썸네일 이미지 */}
<img
src="/thumbnail.png"
alt="User Thumbnail"
style={{
width: "40px",
height: "40px",
borderRadius: "50%",
cursor: "pointer",
}}
/>
{/* 썸네일과 메뉴 전체를 포함한 영역이 hover 상태일 때만 메뉴 표시 */}
{isOpen && (
<div
style={{
position: "absolute",
top: "50px",
right: 0,
backgroundColor: "#fff",
border: "1px solid #ccc",
borderRadius: "8px",
boxShadow: "0 2px 6px rgba(0,0,0,0.15)",
padding: "0.5rem",
zIndex: 100,
width: "120px",
}}
>
<button
style={{
display: "block",
padding: "0.5rem 1rem",
background: "black",
color: "white",
border: "none",
width: "100%",
textAlign: "left",
cursor: "pointer",
}}
onClick={() => alert("마이페이지로 이동")}
>
마이페이지
</button>
<button
style={{
display: "block",
padding: "0.5rem 1rem",
background: "black",
color: "white",
border: "none",
width: "100%",
textAlign: "left",
cursor: "pointer",
}}
onClick={handleLogout}
>
로그아웃
</button>
</div>
)}
</div>
);
}
export default ThumbnailMenu;