https://codesmoothie.tistory.com/77
이전 포스팅에서 회원가입(SignUp) API 라우트를 구현했습니다. 이제 프론트엔드에서 이 API를 호출하도록 연동합니다. 폼 제출(submission) 시, '로그인' 모드가 아닌 '계정 생성' 모드일 때 백엔드에 사용자 생성을 요청해야 합니다.
1. 폼 제출 핸들러: submitHandler 구현
폼 제출 이벤트를 처리하기 위해 submitHandler 함수를 생성하고, 이를 <form> 요소의 onSubmit 속성에 연결합니다. 먼저 event.preventDefault()를 호출하여 폼 제출 시 발생하는 기본 동작(페이지 새로고침)을 방지합니다. submitHandler 내에서는 isLogin 상태값을 확인합니다.
- isLogin이 true (로그인 모드)이면: signIn 함수를 호출하여 로그인을 시도합니다.
- isLogin이 false (계정 생성 모드)이면: createUser 함수를 호출하여 사용자 생성을 위한 API 요청을 보냅니다.
async function submitHandler(event) {
event.preventDefault(); // 폼 기본 제출 동작 방지
// ref를 통해 입력값 가져오기
const enteredEmail = emailInputRef.current.value;
const enteredPassword = passwordInputRef.current.value;
// optional: Add validation (클라이언트 측 유효성 검사 - 선택 사항)
// 로그인 모드 확인
if (isLogin) {
// 로그인 모드일 때 (NextAuth의 signIn 함수 사용)
const result = await signIn('credentials', {
redirect: false,
email: enteredEmail,
password: enteredPassword,
});
if (!result.error) {
// 로그인 성공 시 프로필 페이지로 이동
router.replace('/profile');
}
} else {
// 계정 생성 모드일 때
try {
const result = await createUser(enteredEmail, enteredPassword);
console.log(result); // 성공 시 결과 콘솔 출력
} catch (error) {
console.log(error); // 오류 발생 시 오류 콘솔 출력
}
}
}
2. 폼 입력값 참조: useRef 사용
사용자가 입력한 이메일과 비밀번호를 가져오기 위해 useState 대신 useRef를 사용합니다. react에서 useRef를 임포트한 뒤, emailInputRef와 passwordInputRef를 생성합니다.
import { useState, useRef } from 'react';
// ...
function AuthForm() {
const emailInputRef = useRef();
const passwordInputRef = useRef();
// ...
이 Ref들을 각 <input> 요소의 ref 속성에 연결합니다.
<input type='email' id='email' required ref={emailInputRef} />
// ...
<input type='password' id='password' required ref={passwordInputRef} />
submitHandler 내에서 emailInputRef.current.value와 passwordInputRef.current.value를 통해 현재 입력된 값을 가져올 수 있습니다.
3. 클라이언트 측 유효성 검사 (보안)
submitHandler에서 입력값을 가져온 직후 프론트엔드 유효성 검사를 추가할 수 있습니다. 보안상 중요한 점은, 클라이언트 측(프론트엔드) 유효성 검사는 선택 사항이라는 것입니다. 이 검증 로직은 사용자에 의해 쉽게 조작되거나 우회될 수 있습니다. 필수적인 보안 조치는 백엔드 API 라우트(signup.js)에서 수행하는 서버 측 유효성 검사입니다. 이미 API 라우트에 유효성 검사(비밀번호 길이 등)가 구현되어 있으므로, 이 강의에서는 프론트엔드 유효성 검사를 생략하여 코드를 간결하게 유지합니다.
4. 사용자 생성 함수: createUser 구현
API 요청 로직을 컴포넌트 함수 밖에서 별도의 createUser 함수로 분리하여 관리합니다. 이 함수는 email과 password를 인자로 받습니다. createUser는 async 함수로 정의되며, fetch API를 사용해 /api/auth/signup 엔드포인트로 요청을 보냅니다.
// [auth-form.js] 컴포넌트 외부
async function createUser(email, password) {
const response = await fetch('/api/auth/signup', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (!response.ok) {
// 4xx, 5xx 상태 코드 응답 시 에러 발생
throw new Error(data.message || 'Something went wrong!');
}
return data;
}
요청 상세:
- Method: POST여야 합니다.
- Body: JSON.stringify를 사용해 이메일과 비밀번호 객체를 JSON 문자열로 변환하여 전송합니다.
- Headers: Content-Type: application/json 헤더를 추가하여 서버에 전송하는 데이터가 JSON 형식임을 명시합니다.
- 오류 처리: response.ok (HTTP 상태 코드가 200-299인지)를 확인하여, false일 경우 API가 보낸 오류 메시지(data.message)를 포함한 에러를 발생시킵니다.
5. API 라우트 보안 강화 : POST 메소드 확인
사용자 생성 API는 반드시 POST 요청만 받아야 합니다. signup.js API 핸들러 함수 상단에 req.method를 확인하는 로직을 추가하여, POST가 아닐 경우 즉시 함수를 종료하도록 합니다.
// [pages/api/auth/signup.js] 핸들러 함수 내부
async function handler(req, res) {
// POST 요청이 아니면 아무것도 하지 않고 반환
if (req.method !== 'POST') {
return;
}
// ... (POST일 경우에만 사용자 생성 로직 실행)
}
6. 테스트 및 치명적인 보안 버그 수정
- 테스트 1 (짧은 비밀번호): '계정 생성' 모드에서 짧은 비밀번호(예: 6자 미만)를 입력하고 제출하면, API 라우트의 서버 측 유효성 검사에 걸려 422 (Unprocessable Entity) 상태 코드와 오류 메시지가 반환됩니다. (정상 동작)
- 테스트 2 (유효한 데이터): 유효한 이메일과 긴 비밀번호(예: 7자 이상)를 입력하고 제출하면, 201 (Created) 응답과 "Created user!" 메시지를 받습니다.
- MongoDB 데이터 확인 (버그 발견): MongoDB Atlas에서 auth-demo 데이터베이스의 users 컬렉션을 확인해보면, 사용자는 생성되었으나 password 필드가 해시된 문자열이 아닌 [Object] (객체)로 저장된 것을 발견할 수 있습니다.
6-1 보안 버그 원인 및 수정
이 버그의 원인은 signup.js API 라우트에서 비밀번호 해싱 함수(hashPassword)를 호출할 때 await 키워드를 누락했기 때문입니다. hashPassword는 async 함수이므로 Promise를 반환합니다. await 없이 호출하면, 해시된 비밀번호 문자열이 아닌 Promise 객체 자체가 hashedPassword 변수에 할당되고, 이것이 그대로 데이터베이스에 저장된 것입니다.
[API 라우트 수정 전 - 버그]
const hashedPassword = hashPassword(password); // Promise 객체가 저장됨
[API 라우트 수정 후 - 정상]
hashPassword 호출 앞에 await를 추가하여 Promise가 완료되고 해시된 문자열이 반환될 때까지 기다리도록 수정해야 합니다.
const hashedPassword = await hashPassword(password); // await 추가
수정 확인
API 코드를 수정한 후, 잘못 저장된 사용자 데이터를 MongoDB에서 삭제합니다. 다시 동일한 사용자로 회원가입을 시도하고 MongoDB를 확인하면, password 필드에 해시된 긴 문자열이 정상적으로 저장되는 것을 볼 수 있습니다. 이제 사용자 생성 기능이 보안상 올바르게 작동합니다.
## 최종 코드 정리
[auth-form.js] (프론트엔드 컴포넌트)
import { useState, useRef } from 'react';
import { signIn } from 'next-auth/client';
import { useRouter } from 'next/router';
import classes from './auth-form.module.css';
async function createUser(email, password) {
const response = await fetch('/api/auth/signup', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Something went wrong!');
}
return data;
}
function AuthForm() {
const emailInputRef = useRef();
const passwordInputRef = useRef();
const [isLogin, setIsLogin] = useState(true);
const router = useRouter();
function switchAuthModeHandler() {
setIsLogin((prevState) => !prevState);
}
async function submitHandler(event) {
event.preventDefault();
const enteredEmail = emailInputRef.current.value;
const enteredPassword = passwordInputRef.current.value;
// optional: Add validation
if (isLogin) {
const result = await signIn('credentials', {
redirect: false,
email: enteredEmail,
password: enteredPassword,
});
if (!result.error) {
// set some auth state
router.replace('/profile');
}
} else {
try {
const result = await createUser(enteredEmail, enteredPassword);
console.log(result);
} catch (error) {
console.log(error);
}
}
}
return (
<section className={classes.auth}>
<h1>{isLogin ? 'Login' : 'Sign Up'}</h1>
<form onSubmit={submitHandler}>
<div className={classes.control}>
<label htmlFor='email'>Your Email</label>
<input type='email' id='email' required ref={emailInputRef} />
</div>
<div className={classes.control}>
<label htmlFor='password'>Your Password</label>
<input
type='password'
id='password'
required
ref={passwordInputRef}
/>
</div>
<div className={classes.actions}>
<button>{isLogin ? 'Login' : 'Create Account'}</button>
<button
type='button'
className={classes.toggle}
onClick={switchAuthModeHandler}
>
{isLogin ? 'Create new account' : 'Login with existing account'}
</button>
</div>
</form>
</section>
);
}
export default AuthForm;
[pages/api/auth/signup.js] (백엔드 API 라우트)
(참고: 이 코드는 이전 단계의 블로그 포스팅에서 다루었던 lib/auth.js 및 lib/db.js가 올바르게 설정되어 있다고 가정합니다.)
import { hashPassword } from '../../../lib/auth';
import { connectToDatabase } from '../../../lib/db';
async function handler(req, res) {
if (req.method !== 'POST') {
return;
}
const data = req.body;
const { email, password } = data;
if (
!email ||
!email.includes('@') ||
!password ||
password.trim().length < 7
) {
res.status(422).json({
message:
'Invalid input - password should also be at least 7 characters long.',
});
return;
}
const client = await connectToDatabase();
const db = client.db();
const existingUser = await db.collection('users').findOne({ email: email });
if (existingUser) {
res.status(422).json({ message: 'User exists already!' });
client.close();
return;
}
// 'await'를 추가하여 해싱이 완료될 때까지 기다리는 것이 핵심
const hashedPassword = await hashPassword(password);
const result = await db.collection('users').insertOne({
email: email,
password: hashedPassword,
});
res.status(201).json({ message: 'Created user!' });
client.close();
}
export default handler;
'NextJS > NextAuth' 카테고리의 다른 글
| [NextAuth] 클라이언트 사이드 페이지 가드 (라우트 보호) 추가하기 - 6 (0) | 2025.11.18 |
|---|---|
| [NextAuth] 클라이언트 컴포넌트 인증 : 로그인, 로그아웃 -5 (0) | 2025.11.17 |
| [NextAuth] 로그인 API 구현 [...nextauth].js - 4 (0) | 2025.11.16 |
| [NextAuth] 회원가입 API 구현하기 - 2 (1) | 2025.11.10 |
| [NextAuth] React/NextJS 인증(Authentication)의 원리와 적용 - 1 (0) | 2025.11.06 |