[BONBON] 로그인 파트 구현 - SpringBoot + JWT + Vue.js(2)

나무나무·2025년 8월 7일

프로젝트 BonBon

목록 보기
5/10

🖥️ 로그인 FE 구현

  • 백엔드 구현을 마쳤으니 이제는 FE 구현할 차례다..
  • 필자가 벌써부터 지친 것 같다면 기분탓이다...

Vue.js 초기 설정

main.js

  • npm run serve 실행 시 가장 먼저 실행되는 js 파일
  • App.vue를 준비함
  • appindex.html에 마운트해서 화면을 띄울 예정
    <div id="app"></div> 여기에 App.vue를 마운트 함
    index.html은 쉽게 말해 App.vue가 들어갈 껍데기에 해당

import { createApp } from 'vue'  // Vue 3 애플리케이션 생성 함수
import { createPinia } from 'pinia' // 뷰의 상태 관리 라이브러리
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js';

// 루트 컴포넌트인 App.vue 파일을 import.
import App from './App.vue'
// Vue Router 설정 파일을 import.
import router from './router'

// Vue 애플리케이션 인스턴스 생성 - App.vue 기반 초기화
const app = createApp(App)

app.use(createPinia())   // pinia 상태 관리 기능 연결
app.use(router)   // Vue Router 연결
app.mount('#app')    // index.html에 app 마운트 - 화면이 보이기 시작!

App.vue

  • 최상위 Component 로 렌더링 하는 역할
  • <RouterView></RouterView>
    • SPA(Single Page Application) 에서 주로 사용
    • 사용자가 Page를 이동할 때마다 서버에 직접 웹 페이지를 요청하는 방식이 아님.
    • 미리 page들을 받아둔 뒤 → page 이용 시 라우팅을 이용해 화면만 변경함
    • router/index.js 에 컴포넌트들의 라우팅 경로를 미리 등록해둠
<template>
  <RouterView></RouterView>
</template>

<script setup>
  import { useAuthStore } from './stores/auth';
  const authStore = useAuthStore();
  authStore.checkLogin();
</script>

전체적인 흐름
main.js가 먼저 실행 → App.vue 를 준비 → App.vueindex.html에 마운트 됨 → Router를 통해 URL 별 컴포넌트에 연결됨 → 페이지 접속


로그인

  • auth.js 안의 login 함수이다.
  • Post로 server에 로그인 요청을 보낸 뒤, Token을 받아오면 이걸 localstorage에 저장하는 방식으로 구현했다.
// 로그인 함수
    const login = async (loginData) => {
        // loginData -> LoginForm.vue에서 받아옴
        console.log(loginData);

        try {
            // 사용자 입력 loginData를 해당 엔드포인트로 POST 요청을 보냄
            const response = await apiClient.post('/bonbon/user/login', loginData);

            // 로그인에 성공하는 경우, 
            if(response.status === 200) {
                console.log(response);
                
                const userNameResponse = await apiClient.get(
                    '/bonbon/user',
                    {
                        headers: { 
                            'Authorization': `Bearer ${response.data.accessToken}` 
                        }
                    }
                );
                console.log(userNameResponse.data);

                const parseToken = parseJwt(response.data.accessToken);

                // 토큰들을 로컬 스토리지에 저장 + 사용자 정보도 로컬에 저장함
                localStorage.setItem('accessToken', response.data.accessToken);
                localStorage.setItem('refreshToken', response.data.refreshToken);
                localStorage.setItem('userInfo', JSON.stringify({
                    username: parseToken.username,
                    name: userNameResponse.data.name,
                    userImage: userNameResponse.data.userImage,
                    role: parseToken.role
                }));

                // userInfo 객체 업데이트
                Object.assign(userInfo, JSON.parse(localStorage.getItem('userInfo')));

                console.log(userInfo.data);

                // 로그인 상태 변경
                isLoggedIn.value = true;

                // 홈 페이지로 리다이렉트 함
                router.push({name: 'main'});
            }
        } catch (error) {
            // 로그인 실패 처리 -> 에러 핸들링
            if (error.response.data.code === 400) {
                alert(error.response.data.message);
            } else {
                // 401 이외의 오류 발생 시 일반적인 에러 메시지 표시
                alert(error.response.data.message);
                // alert('에러가 발생했습니다.');
            }
        }
    };

로그 아웃

  • isLoggedIn : 로그인 되어 있는지 먼저 확인
  • 로그인 되어 있으면 Token을 가져온다 → 토큰이 유효한 경우 server에 Post 방식으로 logout 요청을 보내고, 로그아웃이 제대로 되서 204 응답을 받으면 localstorage에 있는 사용자 정보를 지우고 isLoggedInfalse로 바꾼 뒤, router.push({name: 'login'});로 login 페이지로 이동시킨다.
// 사용자 로그아웃 시
    const logout = async () => {
        if(!isLoggedIn){
            return;
        }

        try {
            // localStorage에서 accessToken 먼저 가져오기
            const accessToken = localStorage.getItem('accessToken');
            console.log(accessToken);

            if (!accessToken || isInvalidAccessToken(accessToken)) {
                // accessToken이 유효하지 않는 경우, 토큰이 존재하지 않는 경우
                if(isLoggedIn.value){
                    alert('다시 로그인해 주세요.');
                    logoutUser();   
                }
                return;
            }

            // 토큰이 유효한 경우 -> 로그아웃 api 호출
            const response = await apiClient.post(
                '/bonbon/user/logout',
                null,
                {  
                     headers: {
                        'Authorization': `Bearer ${accessToken}`
                    },
                    _skipInterceptor: true
                }
              );

            // 로그아웃이 잘 된 경우,
            if (response.status === 204) {
                logoutUser();
            }

        } catch (error) {
            logoutUser();
        }
    };

Token Refresh

  • 사용자가 요청을 보낼 때 Header에 같이 담아 보내는 AccessToken이 만료되었을 때 자동으로 Refresh 해주는 로직이다.
  • server에서 401응답(토큰 만료)을 보낼 경우 응답 interceptor에서 서버에 refresh를 요청한다.
    • refreshToken도 만료된 경우 : 로그아웃
    • refreshToken 유효 : accessToken 갱신 + 기존의 요청 재전송 / 실행
  • refresh가 완료된 후 새롭게 발급된 accessToken은 로그인 때와 마찬가지로 localStorage에 저장된다.
// 서버에서 도착한 HTTP 응답(response) 인터셉터
apiClient.interceptors.response.use(
    (response) => {
        // 평범한 response가 온 경우, 그냥 response 그대로 반환
        return response;
    },
    // 비동기 함수
    async (error) => {

        // 이전 요청에 대한 config 객체
        const originalRequest = error.config;
        console.log(error);

        if (
            originalRequest.url === '/bonbon/user/refresh' // 이미 재시도 한 요청
          ) {
            const authStore = useAuthStore();
            authStore.logout();
            console.log("refreshToken도 만료 → 바로 로그아웃");
            return;
          }

        // 토큰이 만료되어 401 에러가 발생한 경우, retry 한 적이 없는 경우
        if (error.response.status === 401 && !originalRequest._retry) {

            // 무한 요청 재시도를 방지하기 위한 체크 변수
            originalRequest._retry = true;  // 객체에서 동적으로 추가된 변수 -> 응답 인터셉터 내에서 직접 추가됨
            try {
                // localStorage에서 refreshToken을 가져옴
                const refreshToken = localStorage.getItem('refreshToken');

                // refreshToken이 존재하지 않는 경우~~~~ -> 무한 루프 방지ㅎㅎ
                if (!refreshToken) {
                    // const authStore = useAuthStore();
                    // authStore.logout();
                    return Promise.reject(error);  // 리프레시 토큰이 없으면 바로 에러 반환
                }

                // 이 토큰을 Authorization 헤더에 Bearer ${refresh} 형태로 담아서 해당 엔드포인트에 POST 요청 전송
                // apiClient.post -> axios의 POST 메서드 호출 코드
                 
                // await : Promise가 해결될 때까지 기다리고, 값을 반환
                //  -> refreshToken을 이용해 새로운 AccessToken을 얻기 위해 서버에 비동기 요청을 보냄
                const response = await apiClient.post(
                    '/bonbon/user/refresh', // 해당 URL로 post 요청을 보낼거다  
                    null,   // 근데 Data는 없다
                    {   // config 설정은 이러하다. -> 헤더에 Bearer ${refreshToken} 형태로 토큰을 담아서 보낼 예정이다.
                        headers: {
                            'Authorization': `Bearer ${refreshToken}`
                        },
                        _skipInterceptor: true
                    }
                );

                // 새로운 accessToken 받기
                const accessToken = response.data.accessToken;
                // 새 액세스 토큰을 로컬 스토리지에 저장
                localStorage.setItem('accessToken', accessToken);
                const parsedToken = parseJwt(accessToken);

                const authStore = useAuthStore();
                authStore.isLoggedIn = true;
                authStore.userInfo.username = parsedToken.username;
                authStore.userInfo.role = parsedToken.role;
              
                // 원래 요청을 재시도
                return apiClient(originalRequest);
                
            } catch (error) {
                // 리프레시 토큰이 만료된 경우, 로그아웃 처리
                // const authStore = useAuthStore();

                const authStore = useAuthStore();
                authStore.logout();

                return Promise.reject(error);
            }
        }

        return Promise.reject(error);
    }
);

Vue.js와 Spring을 이용한 Jwt Token 방식 로그인 로직을 완성했다. 이제 화면을 구성해봐야겠지..

profile
백엔드 개발자 나무입니다

0개의 댓글