~☆~ 우하하!!~ 개발블로그

React diary - jwt 로그인하기 - React Context 본문

React

React diary - jwt 로그인하기 - React Context

iwoohaha 2024. 12. 10. 11:16
반응형

https://iwoohaha.tistory.com/366 에 이어서 Login RestAPI 와 연동하여 jwt 토큰으로 로그인하기 기능을 구현해 볼 예정이다.

우선 React 에서 로그인 상태를 전역적으로 관리하기 위해서 React Context 를 사용해보기로 하자.

일반적으로 데이터는 부모로부터 자식에게로 props 를 통해서 전달되지만, 컴포넌트 계층이 깊어질수록 이 과정이 번거로워질 수 밖에 없다. 흔히 Props Drilling 이라는 현상이 발생하게 되는데, 중간 계층의 컴포넌트들이 불필요하게 데이터 전달에 관여할 수 밖에 없는 현상을 의미한다. 이런 현상을 방지하기 위해서 사용하는 것이 Context 이다.

아래 코드는 인증 상태를 관리하기 위한 AuthContext 이다. src/context/AuthContext.js 파일로 만들어준다.

import React, { createContext, useContext, useState } from 'react';

// Context 생성
const AuthContext = createContext();

// Context 제공 컴포넌트
export const AuthProvider = ({ children }) => {
    const [isAuthenticated, setIsAuthenticated] = useState(false);
    
    const login = () => setIsAuthenticated(true);
    const logout = () => setIsAuthenticated(false);
    
    return (
    	<AuthContext.Provider value={{ isAuthenticated, login, logout }}>
            {children}
        </AuthContext.Provider>
    );
};

// Context 값 사용 Hook
export const useAuth = () => useContext(AuthContext);

App.js 에서 AuthProvider 를 사용하여 애플리케이션 전역에서 로그인 상태를 관리하도록 하기 위해서 App 을 감싸준다.

import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Login from './pages/Login';
import Home from './pages/Home';
import { AuthProvider } from "./context/AuthContext";// 추가

const App = () => {
    return (
        <AuthProvider>// 추가
        <BrowserRouter>
            <Routes>
                <Route path="/" element={<Login />} />
                <Route path="/home" element={<Home />} />
            </Routes>
        </BrowserRouter>
        </AuthProvider>// 추가
    )
}

export default App;

Login.js 에서 로그인 성공시 AuthContext 의 login 메소드를 호출해준다("// 추가" 라는 주석이 붙은 라인이 새롭게 추가된 라인이다).

import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';// 추가

const Login = () => {
    const navigate = useNavigate();
    const { login } = useAuth();// 추가

    const handleLogin = (e) => {
        e.preventDefault();

        // 로그인 검증 로직 (예: API 호출)
        const username = e.target.username.value;
        const password = e.target.password.value;

        if (username === 'user' && password === 'password') {
            alert('로그인 성공!');
            login();// 추가. 로그인 상태 업데이트
            navigate('/home'); // /home 경로로 이동
        } else {
            alert('로그인 실패: 올바른 사용자 이름과 비밀번호를 입력하세요.');
        }
    };

    return (
        <div style={{ textAlign: 'center', marginTop: '100px' }}>
            <h1>로그인</h1>
            <form onSubmit={handleLogin}>
                <div>
                    <label>
                        사용자 이름:
                        <input type="text" name="username" required />
                    </label>
                </div>
                <div>
                    <label>
                        비밀번호:
                        <input type="password" name="password" required />
                    </label>
                </div>
                <button type="submit">로그인</button>
            </form>
        </div>
    );
};

export default Login;

/home 화면에서는 로그인 상태를 확인하고, 인증되지 않은 상태일 경우 로그인 화면으로 리디렉션한다.

import React from 'react';
import { Navigate } from 'react-router-dom';// 추가. redirect 를 위해서.
import { useAuth } from '../context/AuthContext';// 추가

const Home = () => {
    const { isAuthenticated } = useAuth();// 추가
    
    // 로그인상태 확인 후 로그인상태가 아니면 / 로 리디렉션한다. // 추가
    if (!isAuthenticated) { // 추가
        return <Navigate to="/" replace /> // 추가
    } // 추가
    
    return (
        <div style={{ textAlign: 'center', marginTop: '100px' }}>
            <h1>홈 화면</h1>
            <p>로그인에 성공했습니다. 환영합니다!</p>
        </div>
    );
};

export default Home;

여기까지 진행한 상태에서 http://localhost:3000 으로 접속하게 되면 로그인 화면이 표시될 것이고, user / password 를 입력하여 로그인하면 /home 화면으로 이동하게 될 것이다.

그런데 /home 화면에서 페이지 reload 를 하면 다시 /login 화면으로 이동하게 되는 문제가 있다. 이 문제는 임시로 작성해 둔 로그인 상태로 설정하는 코드와 로그인된 상태인지 확인하는 코드를 실제 코드로 변환해보면서 해결해보기로 하겠다.

잠시 앞에서 작성한 코드들에 대한 설명을 덧붙여본다.

Context

앞에서도 잠시 밝혔듯이 컴포넌트 계층이 복잡한 상황에서 어떤 데이터를 상위에서 자식에게로 내리기 위해서 props 를 사용하게 되면 자칫 Props Drilling 현상이 발생하게 되는데, 이를 방지하기 위해서 Context 를 사용한다고 했다. 즉, Context 를 사용하게 되면 자식 컴포넌트 어디에서든지 특정 상태값을 즉시 구할 수 있게 됨에 따라 하위 컴포넌트로 props 를 전달할 필요가 없게 된다.

이 코드에서 상위 컴포넌트라 하면, App.js 에 새로 추가한 AuthProvider 이다.

...
        <AuthProvider>
        <BrowserRouter>
            <Routes>
                <Route path="/" element={<Login />} />
                <Route path="/home" element={<Home />} />
            </Routes>
        </BrowserRouter>
        </AuthProvider>
...

이 컴포넌트는 src/context/AuthContext.js 에서 정의하고 있다. 내부적으로 인증상태인지 여부(isAuthenticated)를 상태로 관리하고 있으며, 이 상태를 변경하기 위해서 별도로 login, logout 함수를 정의하고 있다.

export const AuthProvider = ({ children }) => {
    const [isAuthenticated, setIsAuthenticated] = useState(false);

    const login = () => setIsAuthenticated(true);
    const logout = () => setIsAuthenticated(false);

    return (
        <AuthContext.Provider value={{ isAuthenticated, login, logout }}>
            {children}
        </AuthContext.Provider>
    );
};

AuthProvider 하위의 컴포넌트인 Login 과 Home 에서는 import { useAuth } from '../context/AuthContext'; 로 임포트하고, const { login } = useAuth(); 또는 const { isAuthenticated } = useAuth(); 로 Context 에 직접 접근할 수 있게 된다.

...
import { useAuth } from '../context/AuthContext';// 추가

const Login = () => {
    const navigate = useNavigate();
    const { login } = useAuth();// 추가
...
        if (username === 'user' && password === 'password') {
            alert('로그인 성공!');
            login();// 추가. 로그인 상태 업데이트
            navigate('/home'); // /home 경로로 이동
        } else {
...
...
import { useAuth } from '../context/AuthContext';

const Home = () => {
    const { isAuthenticated } = useAuth();

    // 로그인상태 확인 후 로그인상태가 아니면 / 로 리디렉션한다. // 추가
    if (!isAuthenticated) { // 추가
        return <Navigate to="/" replace /> // 추가
    } // 추가
...

로그인 상태로 변경이 필요할 때에는 login(); 함수를 호출해주고, 로그인 상태인지를 읽을 때에는 isAuthenticated 를 읽으면 된다. login 함수와 isAuthenticated 상태는 AuthContext.js 에서 선언한 useAuth 라는 Hook 으로 정의되어 있는 상태이기 때문이다.

...
export const useAuth = () => useContext(AuthContext);

AuthContext.js 에서 정의된 AuthContext.Provider 는 isAuthenticated, login, logout 을 자식 컴포넌트들에게 제공하기 위한 선언이다.

...
    return (
        <AuthContext.Provider value={{ isAuthenticated, login, logout }}>
            {children}
        </AuthContext.Provider>
    );
...

 

이제 실제의 로그인 처리를 위해서 REST API 를 호출하고, 그 결과로 받은 jwt 토큰을 받아 사용하는 코드로 변환을 해보겠다.

https://iwoohaha.tistory.com/370 에서 SpringBoot diary 프로젝트에 대해서 외부에서 jwt 로그인하기 위한 Rest API 를 이미 구현해본 바 있다. 이번 포스트에서 사용하기 위해서 다시 한번 간략하게 정리하자면, 호출 URL은 /api/v1/login 이고 POST method 를 사용하여 아래 형식의 json 데이터를 전달하는 것으로 정의되어 있다.

{
  "email": "string",
  "password": "string"
}

SpringBoot diary 프로젝트를 로컬에서 실행시켜서 테스트해볼 것이므로 호출 URL 은 http://localhost:8080/api/v1/login 으로 하면 된다.

Login.js 에 구현했던 handleLogin 함수의 내용을 변경한 결과를 보자.

...
const Login = () => {
    const navigate = useNavigate();
    const { login } = useAuth();// 추가

    const handleLogin = async (e) => {
        e.preventDefault();

        // 로그인 검증 로직 (예: API 호출)
        const email = e.target.username.value;
        const password = e.target.password.value;

        try {
            // REST API 호출
            const response = await fetch('http://localhost:8080/api/v1/login', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ email, password }),
            });

            const responseData = await response.json();

            // 응답 데이터에서 필요한 값 추출
            const { body, errorCode, errorMessage } = responseData;

            // 응답 처리
            if (response.ok) {
                if (errorCode === 0) {
                    console.log("Login successful, token:", body);
                    // body (JWT 토큰)를 로컬 스토리지에 저장하거나 상태로 관리
                    localStorage.setItem('token', body);
                    login();// 추가. 로그인 상태 업데이트
                    navigate('/home'); // /home 경로로 이동
                } else {
                    console.error("Error:", errorMessage || "Unknown error");
                    alert(errorMessage || "An error occurred during login.");
                }
            } else {
                alert(errorMessage || "로그인 중 문제가 발생했습니다.");
            }
        } catch (error) {
            alert(error.message || '로그인 중 문제가 발생했습니다.');
        }
    };
...

우선 handleLogin 함수가 async 로 변경되었다. 그 이유는 내부에서 await 를 사용해야 했기 때문이다.

기존에 username 변수를 email 로 변경한 것은 /api/v1/login 에서 요구하는 json 데이터의 key 값이 email 과 password 이기 때문이다.

REST API /api/v1/login 의 응답값 구조를 따라 REST API 호출 결과에서 body, errorCode, errorMessage 등을 추출했다.

에러가 없이(errorCode === 0) 리턴값을 받은 경우 localStorage에 'token' Key 로 body 에 있는 jwt Token 값을 저장한다. 그리고 기존과 같이 login(); 으로 로그인 상태를 설정한 뒤에 /home 으로 리다이렉트한다.

localStorage 는 별도의 선언없이 사용할 수 있는 브라우저의 메모리 저장소이다.

이 상태로 diary 프로젝트를 실행시켜 둔 상태에서 React 프로젝트를 실행시켜보면 다음과 같이 에러가 발생할 것이다.

이는 CORS (Cross-Origin Resource Sharing) 정책에 의거 실행 주체가 되는 localhost:3000 에서 다른 도메인 localhost:8080 으로부터의 자원을 로딩하도록 허용되지 않은 상태이기 때문에 발생한 에러이다. 이를 해결하기 위한 방법은 여러가지가 있는데, 이 중에서 diary 프로젝트에서 수정하는 방법을 소개한다.

잠시 diary 프로젝트로 돌아가서 SecurityConfig 클래스에 아래와 같이 작성된 코드를 추가해주기로 하자.

package com.woohahaapps.study.diary.config;
...
@Configuration
@EnableWebSecurity
public class SecurityConfig {
...
    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.setAllowedOrigins(List.of("http://localhost:3000")); // 허용할 Origin
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); // 허용할 메서드
        configuration.setAllowedHeaders(List.of("Authorization", "Content-Type")); // 허용할 헤더
        configuration.setAllowCredentials(true); // 자격증명 허용
        configuration.setMaxAge(3600L); // preflight 요청 캐싱 시간

        source.registerCorsConfiguration("/api/**", configuration); // 특정 경로에 CORS 설정 적용
        return new CorsFilter(source);
    }
}

위 코드로 작성된 빈은 localhost:3000 으로부터의 요청을 허용하게 된다.

다시 localhost:3000 에서 로그인을 하면 문제없이 jwt 토큰이 구해지는 것을 확인할 수 있다.

localhost:8080/api/v1/login 으로부터 응답받은 jwt 토큰값을 localStorage 에 저장된 것 역시 아래처럼 확인할 수가 있다.

그러나 여전히 웹브라우저에서 refresh 를 하는 경우에는 다시 로그인폼 화면이 표시되고 있으므로 App.js 와 AuthContext.js 를 조금 더 수정해보기로 하겠다.

우선 메인 화면에서 로그인 상태를 확인해서 로그인 상태가 아닌 경우에는 Login 화면으로, 로그인 상태일 경우에는 Home 화면으로 이동되도록 다음과 같이 변경한다.

import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import Login from './pages/Login';
import Home from './pages/Home';

// 로그인 상태에 따른 보호된 라우트
const ProtectedRoute = ({ children }) => {
    const { isAuthenticated } = useAuth();

    if (!isAuthenticated) {
        // 로그인 상태가 아니면 로그인 페이지로 리디렉션
        return <Navigate to="/" replace />;
    }

    // 로그인 상태일 경우 요청한 컴포넌트를 렌더링
    return children;
};

// 로그인 상태일 경우 로그인 페이지로 접근 차단
const PublicRoute = ({ children }) => {
    const { isAuthenticated } = useAuth();

    if (isAuthenticated) {
        // 로그인 상태라면 /home으로 리디렉션
        return <Navigate to="/home" replace />;
    }

    // 로그인 상태가 아니라면 요청한 컴포넌트를 렌더링
    return children;
};

const App = () => {
    return (
        <AuthProvider>
            <BrowserRouter>
                <Routes>
                    {/* ProtectedRoute: 로그인 상태일 경우만 접근 가능 */}
                    <Route
                        path="/home"
                        element={
                            <ProtectedRoute>
                                <Home />
                            </ProtectedRoute>
                        }
                    />
                    {/* PublicRoute: 로그인 상태가 아닌 경우만 접근 가능 */}
                    <Route
                        path="/"
                        element={
                            <PublicRoute>
                                <Login />
                            </PublicRoute>
                        }
                    />
                </Routes>
            </BrowserRouter>
        </AuthProvider>
    );
};

export default App;

ProtectedRoute 와 PublicRoute 컴포넌트를 코딩하여 Home 컴포넌트와 Login 컴포넌트를 감싸주었다.

AppContext.js 에서는 최초 React 앱 로딩시에 localStorage 에 저장되어 있는(없을 경우 null) jwt 토큰값에 대해서 유효성을 체크하여 로그인 상태를 재설정하는 코드를 추가한다.

import React, { createContext, useContext, useState, useEffect } from 'react';

// Context 생성
const AuthContext = createContext();

// Context 제공 컴포넌트
export const AuthProvider = ({ children }) => {
    const [isAuthenticated, setIsAuthenticated] = useState(false);

    const login = () => setIsAuthenticated(true);
    const logout = () => {
        setIsAuthenticated(false);
        localStorage.removeItem('token');
    }

    // JWT 토큰 유효성 확인 함수
    const validateToken = async (token) => {
        try {
            const response = await fetch('http://localhost:8080/api/v1/validate', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    Authorization: `Bearer=${token}`, // 토큰을 헤더로 전달
                },
            });

            if (response.ok) {
                return true; // 유효한 토큰
            } else {
                return false; // 유효하지 않은 토큰
            }
        } catch (error) {
            console.error('토큰 검증 중 오류 발생:', error);
            return false;
        }
    };

    useEffect(() => {
        const initializeAuth = async () => {
            const token = localStorage.getItem('token');
            if (token) {
                const isValid = await validateToken(token);
                setIsAuthenticated(isValid);
            }
        };

        initializeAuth();
    }, []);

    return (
        <AuthContext.Provider value={{ isAuthenticated, login, logout }}>
            {children}
        </AuthContext.Provider>
    );
};

// Context 값 사용 Hook
export const useAuth = () => useContext(AuthContext);

validateToken 함수에서 호출하고 있는 REST API /api/v1/validate 는 diary 프로젝트에 다음과 같이 구현하면 된다(APILoginController.java).

package com.woohahaapps.study.diary.controller;
...
@RestController
@RequestMapping("/api/v1")
@Slf4j
public class APILoginController {
...
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginBody loginBody) {
...
    }
    
    @PostMapping("/validate")
    public ResponseEntity<?> validateToken(@RequestHeader("Authorization") String token) {
        try {
            String jwt = token.replace("Bearer=", "");
            System.out.println(jwt);
            boolean isValid = jwtUtil.validateToken(jwt);
            if (isValid) {
                System.out.println("valid token");
                return ResponseEntity.ok().body("Valid token");
            } else {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid token");
            }
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Token validation failed");
        }
    }
}

여기까지 구현하여 실행했을 때 이미 한번 로그인해서 http://localhost:3000 으로 접속하더라도 localStorage 에 저장되어 있는 jwt 토큰값에 대한 유효성을 판단하여 유효한 경우 로그인 상태로 설정하여 /home 경로로 이동된다.

약간 아름답게 수정할 부분이 있다면, 잠깐씩 로그인 폼이 표시되는 현상은 다음과 같이 로딩 중 화면이 표시되게 수정할 수 있다.

import React, { createContext, useContext, useState, useEffect } from 'react';
...
export const AuthProvider = ({ children }) => {
    const [isAuthenticated, setIsAuthenticated] = useState(false);
    const [isLoading, setIsLoading] = useState(true); // 초기화 상태 추가
...
    useEffect(() => {
        const initializeAuth = async () => {
            const token = localStorage.getItem('token');
            if (token) {
                const isValid = await validateToken(token);
                setIsAuthenticated(isValid);
            }
            setIsLoading(false); // 초기화 완료
        };

        initializeAuth();
    }, []);

    if (isLoading) {
        // 초기화 중 로딩 화면 표시
        return <div>로딩 중...</div>;
    }
...

 

반응형