JWT + OAuth2.0을 활용하여 로그인, 회원가입 구현하기 (React, Spring) (Front-End)

Lord·2024년 7월 23일
post-thumbnail

1,2,3번째 포스팅에서는 프로젝트에 대한 소개와 백엔드의 주요 프로세스에 대해 작성하였다. 이번 포스팅부터는 프론트엔드의 주요 프로세스에 대해 포스팅 할 예정이다. React를 사용하여 개발을 진행하였으며, TypeScript는 사용하지 않았다.

프론트엔드 주요 소스코드

홈화면 구성 (Home.js)

function Home({token, userInfo, onLogout}) {
    return (
        <CenteredContainer>
            <Title>{token ? `환영합니다 ${userInfo.nickname}` : '어서오세요. 로그인 또는 일반 회원가입을 진행해주세요.'}</Title>
            {!token && (
                <>
                    <LinkButton to="/login">로그인</LinkButton>
                    <LinkButton to="/register">회원가입</LinkButton>
                </>
            )}
            {token && (
                <>
                    <LinkButton to="/myinfo">내 정보</LinkButton>
                    <Button onClick={onLogout}>로그아웃</Button>
                </>
            )}
        </CenteredContainer>
    );
}

export default Home;
  • 컴포넌트 설명:
    • token이 존재하면 사용자가 로그인한 상태로 간주하고 환영 메시지와 함께 "내 정보" 및 "로그아웃" 버튼을 표시한다.
    • token이 존재하지 않으면 로그인과 회원가입 버튼을 표시한다.
    • userInfo 객체는 로그인한 사용자의 정보를 포함하며, 여기서는 nickname을 사용한다.
    • onLogout 함수는 로그아웃 버튼 클릭 시 호출된다.

React 로그인 컴포넌트 (Login.js)

const KakaoButton = styled.button`
    background: #ffffff;
    border: none;
    cursor: pointer;
    margin-bottom: 10px;
    width: 80%;
    height: 50px;
`;

function Login({ onLogin }) {
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [error, setError] = useState('');
    const navigate = useNavigate();
    const location = useLocation();

    useEffect(() => {
        const params = new URLSearchParams(location.search);
        const emailParam = params.get('email');
        const nicknameParam = params.get('nickname');
        const accessTokenParam = params.get('accessToken');
        const refreshTokenParam = params.get('refreshToken');

        if (emailParam && nicknameParam && accessTokenParam && refreshTokenParam) {
            const userInfo = { email: emailParam, nickname: nicknameParam };
            onLogin(accessTokenParam, refreshTokenParam, userInfo);
            navigate('/home');
        }
    }, [location, onLogin, navigate]);

    const handleSubmit = async (e) => {
        e.preventDefault();
        try {
            setError(null);
            const response = await api.post('/members/login', { email, password });
            const responseHeaders = response.headers;
            const accessToken = responseHeaders.get("Authorization");
            const refreshToken = responseHeaders.get("Authorization-RefreshToken");
            const userInfo = { email: response.data.email, nickname: response.data.nickname };
            onLogin(accessToken, refreshToken, userInfo);
            navigate('/home');
        } catch (error) {
            setError('이메일 또는 비밀번호가 잘못되었습니다. 다시 시도해주세요.');
        }
    };

    const handleKakaoLogin = () => {
        window.location.href = 'http://localhost:8080/oauth2/authorization/kakao';
    };

    return (
        <CenteredContainer>
            <FormContainer>
                <Form onSubmit={handleSubmit}>
                    <Title>로그인</Title>
                    <Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="이메일" required />
                    <Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="비밀번호" required />
                    <Button type="submit">로그인</Button>
                    {error && <Error>{error}</Error>}
                    <KakaoButton type="button" onClick={handleKakaoLogin}>
                        <img src={process.env.PUBLIC_URL + `/kakao_login_medium_narrow.png`} alt="카카오로 로그인하기" />
                    </KakaoButton>
                    <Button onClick={() => navigate('/register')}>회원가입</Button>
                    <Button onClick={() => navigate(-1)}>돌아가기</Button>
                </Form>
            </FormContainer>
        </CenteredContainer>
    );
}

export default Login;

이 컴포넌트는 사용자 로그인 페이지를 구현한 것이다. 사용자가 이메일과 비밀번호를 입력하여 로그인하거나, 카카오 소셜 로그인을 통해 로그인할 수 있도록 구성되어 있다.

  1. 상태 및 네비게이션 훅:

    • useState 훅을 사용하여 이메일, 비밀번호, 오류 메시지 상태를 관리한다.
    • useNavigate 훅을 사용하여 페이지 네비게이션을 처리한다.
    • useLocation 훅을 사용하여 현재 URL의 쿼리 파라미터를 가져온다.
  2. useEffect:

    • 컴포넌트가 마운트될 때 실행되며, URL의 쿼리 파라미터에서 이메일, 닉네임, 액세스 토큰, 리프레시 토큰을 추출한다.
    • 모든 파라미터가 존재할 경우, onLogin 함수를 호출하여 사용자를 로그인 상태로 만들고, 홈 페이지로 리디렉션한다.
  3. handleSubmit:

    • 폼이 제출될 때 호출되며, 이메일과 비밀번호를 API 요청을 통해 서버로 전송한다.
    • 요청이 성공하면, 응답 헤더에서 액세스 토큰과 리프레시 토큰을 추출하여 onLogin 함수를 호출하고 홈 페이지로 이동한다.
    • 요청이 실패하면, 오류 메시지를 상태에 저장하여 사용자에게 표시한다.
  4. handleKakaoLogin:

    • 카카오 로그인 버튼이 클릭될 때 호출되며, 카카오 로그인 페이지로 리디렉션한다.
  5. 렌더링:

    • 로그인 폼을 렌더링하며, 이메일 및 비밀번호 입력 필드, 로그인 버튼, 오류 메시지, 카카오 로그인 버튼, 회원가입 및 돌아가기 버튼을 포함한다.

React 내 정보 컴포넌트 (MyInfo.js)

function MyInfo({ token, userInfo, onLogout }) {
    const [info, setInfo] = useState(userInfo);
    const [showModal, setShowModal] = useState(false);
    const navigate = useNavigate();

    useEffect(() => {
        const fetchData = async () => {
            try {
                const response = await getMyInfo(token);
                setInfo(response.data);
            } catch (error) {
                console.error('Error fetching user info:', error);
            }
        };

        if (!info && token) {
            fetchData();
        }
    }, [token, info]);

    const handleDelete = async () => {
        try {
            await deleteUser(token);
            onLogout();
            navigate('/home');
        } catch (error) {
            console.error('오류가 발생했습니다.', error);
        }
    };

    if (!info) return <p>Loading...</p>;

    return (
        <Container>
            <Title>내 정보</Title>
            <p>이메일: {info.email}</p>
            <p>닉네임: {info.nickname}</p>
            <Button onClick={() => setShowModal(true)}>회원 탈퇴</Button>
            <Button onClick={() => navigate('/home')}>돌아가기</Button>
            {showModal && (
                <ModalContainer>
                    <Modal>
                        <p>정말로 회원 탈퇴를 하시겠습니까?</p>
                        <ModalButton onClick={handleDelete}></ModalButton>
                        <ModalButton onClick={() => setShowModal(false)}>아니오</ModalButton>
                    </Modal>
                </ModalContainer>
            )}
        </Container>
    );
}

export default MyInfo;

이 컴포넌트는 사용자의 개인 정보를 보여주고, 회원 탈퇴 기능을 제공한다.

  1. 상태 및 네비게이션 훅:

    • useState 훅을 사용하여 사용자 정보(info)와 모달 창 표시 여부(showModal)를 관리한다.
    • useNavigate 훅을 사용하여 페이지 네비게이션을 처리한다.
  2. useEffect:

    • 컴포넌트가 마운트될 때 실행되며, tokeninfo 상태가 변경될 때마다 호출된다.
    • getMyInfo API 호출을 통해 사용자 정보를 가져와 info 상태를 업데이트한다.
  3. handleDelete:

    • 회원 탈퇴 버튼이 클릭될 때 호출되며, deleteUser API 호출을 통해 회원 탈퇴 요청을 보낸다.
    • 요청이 성공하면, onLogout 함수를 호출하여 로그아웃 상태로 만들고, 홈 페이지로 리디렉션한다.
    • 요청이 실패하면, 콘솔에 오류 메시지를 출력한다.
  4. 렌더링:

    • 사용자 정보가 없을 경우 "Loading..." 메시지를 표시한다.
    • 사용자 정보가 있을 경우 이메일과 닉네임을 표시하고, 회원 탈퇴 및 돌아가기 버튼을 렌더링한다.
    • 회원 탈퇴 버튼을 클릭하면 모달 창이 표시되며, 모달 창에서 "예" 버튼을 클릭하면 handleDelete 함수가 호출되고, "아니오" 버튼을 클릭하면 모달 창이 닫힌다.

OAuth2 가입 컴포넌트 (Oauth2Join.js)

function Oauth2Join() {
    const [email, setEmail] = useState('');
    const [accessToken, setAccessToken] = useState('');
    const [nickname, setNickname] = useState('');
    const [message, setMessage] = useState('');
    const [error, setError] = useState('');
    const navigate = useNavigate();
    const location = useLocation();

    useEffect(() => {
        const params = new URLSearchParams(location.search);
        const emailParam = params.get('email');
        const tokenParam = params.get('accessToken');
        if (emailParam && tokenParam) {
            setEmail(emailParam);
            setAccessToken(tokenParam);
        }
    }, [location]);

    const validateNickname = (nickname) => {
        return nickname.length >= 2;
    };

    const handleSubmit = async (e) => {
        e.preventDefault();
        if (!validateNickname(nickname)) {
            setError('닉네임이 너무 짧습니다. 최소 2글자이어야 합니다.');
            return;
        }
        try {
            setError(null);
            const response = await api.put('/members/oauth2/update', { email, nickname }, {
                headers: {
                    'Authorization': `Bearer ${accessToken}`
                }
            });
            setMessage(`회원가입이 완료되었습니다! 환영합니다, ${response.data.nickname}`);
            setTimeout(() => navigate('/'), 2000);
        } catch (error) {
            if (error.response && error.response.status === 600) {
                setError('이미 가입된 이메일입니다.');
            } else if (error.response && error.response.status === 601) {
                setError('이미 존재하는 닉네임입니다.');
            } else {
                setError('회원가입에 실패했습니다. 다시 시도해주세요.');
            }
        }
    };

    return (
        <CenteredContainer>
            <FormContainer>
                <Form onSubmit={handleSubmit}>
                    <Title>카카오 회원가입</Title>
                    <Input type="email" value={email} placeholder="이메일" required disabled />
                    <Input type="text" value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder="닉네임" required />
                    <Button type="submit">회원가입</Button>
                    {message && <Message>{message}</Message>}
                    {error && <Error>{error}</Error>}
                </Form>
            </FormContainer>
        </CenteredContainer>
    );
}

export default Oauth2Join;

이 컴포넌트는 사용자가 카카오 OAuth2 로그인을 통해 회원가입을 완료할 수 있도록 하는 페이지를 구현한 것이다.

  1. 상태 및 네비게이션 훅:

    • useState 훅을 사용하여 이메일, 액세스 토큰, 닉네임, 메시지, 오류 메시지 상태를 관리한다.
    • useNavigate 훅을 사용하여 페이지 네비게이션을 처리한다.
    • useLocation 훅을 사용하여 현재 URL의 쿼리 파라미터를 가져온다.
  2. useEffect:

    • 컴포넌트가 마운트될 때 실행되며, URL의 쿼리 파라미터에서 이메일과 액세스 토큰을 추출하여 상태를 업데이트한다.
  3. validateNickname:

    • 닉네임이 최소 2글자인지 확인하는 유효성 검사 함수이다.
  4. handleSubmit:

    • 폼이 제출될 때 호출되며, 닉네임의 유효성을 검사한다.
    • 유효하지 않으면 오류 메시지를 상태에 설정하고, 유효하면 서버에 회원가입 요청을 보낸다.
    • 요청이 성공하면 환영 메시지를 상태에 설정하고, 2초 후에 홈 페이지로 리디렉션한다.
    • 요청이 실패하면 오류 상태 코드를 확인하여 적절한 오류 메시지를 설정한다.
  5. 렌더링:

    • 이메일 입력 필드는 서버에서 제공된 이메일을 표시하며 수정할 수 없도록 disabled 속성을 설정한다.
    • 닉네임 입력 필드는 사용자가 입력할 수 있도록 제공된다.
    • 폼 제출 버튼을 제공하며, 메시지 또는 오류 메시지가 있는 경우 이를 표시한다.

회원가입 컴포넌트 (Register.js)

function Register() {
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [nickname, setNickname] = useState('');
    const [message, setMessage] = useState('');
    const [error, setError] = useState('');
    const navigate = useNavigate();
    const location = useLocation();

    useEffect(() => {
        const params = new URLSearchParams(location.search);
        const emailParam = params.get('email');
        if (emailParam) {
            setEmail(emailParam);
        }
    }, [location]);

    const validateEmail = (email) => {
        const emailPattern = /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])+[.][a-zA-Z]{2,3}$/;
        return emailPattern.test(email);
    };

    const validatePassword = (password) => {
        const passwordPattern = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&?])[A-Za-z\d!@#$%^&?]{8,30}$/;
        return passwordPattern.test(password);
    };

    const validateNickname = (nickname) => {
        return nickname.length >= 2;
    };

    const handleSubmit = async (e) => {
        e.preventDefault();
        if (!validateEmail(email)) {
            setError('이메일 주소 양식을 확인해주세요');
            return;
        }
        if (!validatePassword(password)) {
            setError('비밀번호는 8자리 이상이면서 1개 이상의 알파벳, 숫자, 특수문자를 포함해야합니다.');
            return;
        }
        if (!validateNickname(nickname)) {
            setError('닉네임이 너무 짧습니다. 최소 2글자이어야 합니다.');
            return;
        }
        try {
            await register(email, password, nickname);
            setError(null);
            setMessage('회원가입이 완료되었습니다! 환영합니다!');
            setTimeout(() => navigate('/'), 1000);
        } catch (error) {
            if (error.response && error.response.data.errorCode === 600) {
                setError('이미 가입된 이메일입니다.');
            } else if (error.response && error.response.data.errorCode === 601) {
                setError('이미 존재하는 닉네임입니다.');
            } else {
                setError('회원가입에 실패했습니다. 다시 시도해주세요.');
            }
        }
    };

    return (
        <CenteredContainer>
            <FormContainer>
                <Form onSubmit={handleSubmit}>
                    <Title>회원가입</Title>
                    <Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="이메일" required />
                    <Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="비밀번호" required />
                    <Input type="text" value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder="닉네임" required />
                    <Button type="submit">회원가입</Button>
                    <Button onClick={() => navigate(-1)}>돌아가기</Button>
                    {message && <Message>{message}</Message>}
                    {error && <Error>{error}</Error>}
                </Form>
            </FormContainer>
        </CenteredContainer>
    );
}

export default Register;

이 컴포넌트는 사용자 회원가입을 처리하는 페이지를 구현한다. 사용자는 이메일, 비밀번호, 닉네임을 입력하여 회원가입을 완료할 수 있다.

  1. 상태 및 네비게이션 훅:

    • useState 훅을 사용하여 이메일, 비밀번호, 닉네임, 메시지, 오류 메시지 상태를 관리한다.
    • useNavigate 훅을 사용하여 페이지 네비게이션을 처리한다.
    • useLocation 훅을 사용하여 현재 URL의 쿼리 파라미터를 가져온다.
  2. useEffect:

    • 컴포넌트가 마운트될 때 실행되며, URL의 쿼리 파라미터에서 이메일을 추출하여 상태를 업데이트한다.
  3. validateEmail:

    • 이메일의 유효성을 검사하는 함수이다. 이메일 형식을 확인한다.
  4. validatePassword:

    • 비밀번호의 유효성을 검사하는 함수이다. 비밀번호는 최소 8자리 이상, 알파벳, 숫자, 특수문자를 포함해야 한다.
  5. validateNickname:

    • 닉네임의 유효성을 검사하는 함수이다. 닉네임은 최소 2글자 이상이어야 한다.
  6. handleSubmit:

    • 폼이 제출될 때 호출된다. 이메일, 비밀번호, 닉네임의 유효성을 검사한다.
    • 유효하지 않은 경우 오류 메시지를 상태에 설정하고, 유효한 경우 서버에 회원가입 요청을 보낸다.
    • 요청이 성공하면 환영 메시지를 상태에 설정하고, 1초 후에 홈 페이지로 리디렉션한다.
    • 요청이 실패하면 오류 상태 코드를 확인하여 적절한 오류 메시지를 설정한다.
  7. 렌더링:

    • 이메일, 비밀번호, 닉네임 입력 필드를 제공하며, 폼 제출 버튼과 돌아가기 버튼을 포함한다.
    • 메시지 또는 오류 메시지가 있는 경우 이를 표시한다.

API 모듈 (api.js)

import axios from 'axios';

const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080';

const api = axios.create({
    baseURL: API_BASE_URL,
    headers: {
        'Content-Type': 'application/json',
    },
});

api.interceptors.response.use(
    response => response,
    async error => {
        const originalRequest = error.config;
        if (error.response && error.response.status === 401 && !originalRequest._retry) {
            originalRequest._retry = true;
            const refreshToken = localStorage.getItem('refreshToken');
            if (refreshToken) {
                try {
                    originalRequest.headers['Authorization-refresh'] = `Bearer ${refreshToken}`;
                    const response = await api(originalRequest);
                    const responseHeaders = response.headers;
                    const accessToken = responseHeaders.get("Authorization");
                    const newRefreshToken = responseHeaders.get("Authorization-RefreshToken");
                    localStorage.setItem('accessToken', accessToken);
                    localStorage.setItem('refreshToken', newRefreshToken);
                    api.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
                    originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
                    return api(originalRequest);
                } catch (err) {
                    return Promise.reject(err);
                }
            }
        }
        return Promise.reject(error);
    }
);

export const register = (email, password, nickname) => {
    return api.post('/members/join', { email, password, nickname });
};

export const login = (email, password) => {
    return api.post('/members/login', { email, password });
};

export const getMyInfo = (token) => {
    return api.get('/members/getMyInfo', {
        headers: { Authorization: `Bearer ${token}` },
    });
};

export const updateUserInfo = (email, nickname, token) => {
    return api.put('/members/oauth2/update', { email, nickname }, {
        headers: {
            Authorization: `Bearer ${token}`
        }
    });
};

export const deleteUser = (token) => {
    return api.delete('/members', {
        headers: { Authorization: `Bearer ${token}` },
    });
};

export default api;

이 모듈은 axios를 사용하여 API 요청을 처리하는 기능을 제공한다. 인증 토큰을 자동으로 갱신하는 인터셉터를 포함하며, 회원가입, 로그인, 사용자 정보 조회 및 업데이트, 회원 탈퇴 등의 기능을 제공한다.

  1. API 기본 설정:

    • axios.create를 사용하여 기본 URL과 헤더를 설정한 api 인스턴스를 생성한다.
    • API_BASE_URL은 환경 변수에서 가져오거나 기본값으로 로컬 호스트를 사용한다.
  2. 인터셉터 설정:

    • 응답 인터셉터를 설정하여 401 오류(인증 실패)가 발생했을 때 토큰을 갱신한다.
    • originalRequest._retry를 사용하여 중복 요청을 방지한다.
    • localStorage에서 리프레시 토큰을 가져와 요청 헤더에 추가하고, 서버에 재요청하여 새로운 액세스 토큰과 리프레시 토큰을 받아온다.
    • 새로운 토큰을 localStorage에 저장하고, axios 기본 헤더와 요청 헤더에 설정한다.
  3. 회원가입 (register):

    • 이메일, 비밀번호, 닉네임을 받아 회원가입 요청을 보낸다.
  4. 로그인 (login):

    • 이메일과 비밀번호를 받아 로그인 요청을 보낸다.
  5. 내 정보 조회 (getMyInfo):

    • 토큰을 헤더에 포함하여 내 정보 조회 요청을 보낸다.
  6. 사용자 정보 업데이트 (updateUserInfo):

    • 이메일과 닉네임, 토큰을 받아 사용자 정보 업데이트 요청을 보낸다.
  7. 회원 탈퇴 (deleteUser):

    • 토큰을 헤더에 포함하여 회원 탈퇴 요청을 보낸다.

App 컴포넌트 (App.js)

function App() {
    const [token, setToken] = useState(localStorage.getItem('accessToken'));
    const [userInfo, setUserInfo] = useState(JSON.parse(localStorage.getItem('userInfo')));

    useEffect(() => {
        if (token) {
            api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
            const storedUserInfo = JSON.parse(localStorage.getItem('userInfo'));
            setUserInfo(storedUserInfo);
        }
    }, [token]);

    const handleLogin = (accessToken, refreshToken, userInfo) => {
        setToken(accessToken);
        localStorage.setItem('accessToken', accessToken);
        localStorage.setItem('refreshToken', refreshToken);
        localStorage.setItem('userInfo', JSON.stringify(userInfo));
        setUserInfo(userInfo);
    };

    const handleLogout = () => {
        setToken(null);
        localStorage.removeItem('accessToken');
        localStorage.removeItem('refreshToken');
        localStorage.removeItem('userInfo');
        setUserInfo(null);
    };

    return (
        <Router>
            <Container>
                <Routes>
                    <Route path="/" element={<Navigate to="/home" />} />
                    <Route path="/home" element={<Home token={token} userInfo={userInfo} onLogout={handleLogout} />} />
                    <Route path="/login" element={<Login onLogin={handleLogin} />} />
                    <Route path="/register" element={<Register />} />
                    <Route path="/myinfo" element={<MyInfo token={token} userInfo={userInfo} onLogout={handleLogout} />} />
                    <Route path="/members/oauth2/join" element={<Oauth2Join />} />
                </Routes>
            </Container>
        </Router>
    );
}

export default App;

이 컴포넌트는 애플리케이션의 루트 컴포넌트로, React Router를 사용하여 페이지를 라우팅하며, 사용자의 로그인 상태를 관리한다.

  1. 상태 및 초기화:

    • useState 훅을 사용하여 tokenuserInfo 상태를 관리한다.
    • localStorage에서 accessTokenuserInfo를 가져와 초기 상태를 설정한다.
  2. useEffect:

    • token 상태가 변경될 때마다 실행되며, token이 존재하면 api의 기본 헤더에 Authorization 헤더를 설정한다.
    • localStorage에서 userInfo를 가져와 상태를 업데이트한다.
  3. handleLogin:

    • 로그인 성공 시 호출되며, accessToken, refreshToken, userInfo를 받아 상태와 localStorage를 업데이트한다.
  4. handleLogout:

    • 로그아웃 시 호출되며, tokenuserInfo 상태를 초기화하고, localStorage에서 해당 항목들을 제거한다.
  5. 라우팅 설정:

    • RouterRoutes를 사용하여 라우트를 설정한다.
    • 기본 경로("/")에서 "/home"으로 리디렉션한다.
    • Home, Login, Register, MyInfo, Oauth2Join 컴포넌트로의 라우트를 설정한다.

이렇게 모든 프론트엔드 코드에 대한 설명이 끝났다. 주요 프로세스의 경우 백엔드 기반으로 작성된 코드라 백엔드 코드를 살펴보면 더 이해하기 쉽다고 생각한다. 마지막 포스팅에서는 직접 시현한 이미지와 함께 어떻게 구현되는지 설명할 예정이다.

profile
다재다능한 Backend 개발자에 도전하는 개발자

0개의 댓글