최신 트렌드 적용한
React Router v6.4 , react-hook-form, axiosInstance, typescript, tailwind를 활용한 회원가입 폼 전송 처리 개발방법
소스 :
https://github.com/braverokmc79/springboot-restful-web
1. 리액트 프론트 엔드 개발
설치
1) vite 로 리액트 + typescript, 프로젝트를 생성한다.
2) tailwind 설정
3) react-router-dom,axio, react-hook-form 라이브러리 설치
npm i react-router-dom axio react-hook-form
package.json
{ "name": "macaronics-todo-app", "private": true, "version": "0.0.0", "type": "module", "scripts": { "ssr-dev": "node server", "dev": "vite --mode development & npx tailwindcss -i ./src/index.css -o ./output.css --watch", "build": "tsc && vite build --mode production", "linux-start": "nohup vite &", "win-start": "start /B vite", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, "dependencies": { "@heroicons/react": "^2.1.5", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-menubar": "^1.1.2", "@radix-ui/react-navigation-menu": "^1.2.1", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.59.3", "@tanstack/react-query-devtools": "^5.59.8", "@types/react-router-dom": "^5.3.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "express": "^4.21.1", "lucide-react": "^0.447.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", "react-hook-form": "^7.53.0", "react-router-dom": "^6.26.2", "react-spinners": "^0.14.1", "seamless-scroll-polyfill": "^2.3.4", "tailwind-merge": "^2.5.3", "tailwindcss-animate": "^1.0.7", "vike": "^0.4.199", "vite-plugin-ssr": "^0.4.142" }, "devDependencies": { "@hookform/devtools": "^4.3.1", "@types/node": "^22.7.4", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", "postcss": "^8.4.47", "tailwindcss": "^3.4.13", "typescript": "^5.2.2", "vite": "^5.4.9" } }
구조
라우트 설정
React Router와의 통합
React Router와 React Hook Form을 통합하여 폼 제출 시 라우트의 액션 함수를 호출하는 방법을 알아보겠습니다.
예제: React Router v6.4+에서 React Hook Form 사용
- 라우트 설정
import { createBrowserRouter , RouterProvider} from 'react-router-dom'; import ErrorPage from './pages/ErrorPage'; import RootLayout from './pages/RootLayout'; import LoginPage from './pages/login/LoginPage'; import TodoLayout from './components/todo/TodoLayout'; import TodoListPage from './pages/todo/TodoListPage'; import AuthProvider from './components/security/AuthContext'; import LogoutComponent from './components/logout/LogoutComponent'; import TodoDetailPage from './pages/todo/TodoDetailPage'; import { isAuthenticatedCheck } from './components/security/Auth'; import SignupPage from './pages/signup/SignupPage'; import { action as sinupAction } from '@/actions/sinupAction'; import { action as loginAction } from '@/actions/loginAction'; import LoginSuccessPage from './pages/login/LoginSuccessPage'; import TodoCreatePage from './pages/todo/TodoCreatePage'; import { todoCreateAction, todoUpdateAction } from './actions/todoAction'; import { todoDetailPageLoader } from './actions/loader/todoLoader'; import TodoUpdatePage from './pages/todo/TodoUpdatePage'; //라우트 정보를 담는 객체 배열 const router =createBrowserRouter( [ { path: '/', element: <RootLayout />, errorElement: <ErrorPage/>, //loader:, id:"root", children: [ { index: true, element: <TodoListPage /> }, { path: 'todo', loader: isAuthenticatedCheck, element: <TodoLayout />, children: [ { index: true, }, { path: 'create', element: <TodoCreatePage />, action:todoCreateAction }, { path: 'list', element: <TodoListPage /> }, { path: ':todoId', id: 'todo', // ID 설정 loader:todoDetailPageLoader, children: [ { path: 'detail', element: <TodoDetailPage />, index:true }, { path: 'update', element: <TodoUpdatePage />, action:todoUpdateAction, }, ] } ] }, { path: 'login', element: <LoginPage />, action: loginAction, } , { path: 'login-success', element: <LoginSuccessPage /> } , { path: 'signup', element: <SignupPage />, action:sinupAction, } , { path: 'logout', element: <LogoutComponent />, } ] }, ]); function App() { return( <AuthProvider> <RouterProvider router={router} /> </AuthProvider> ) } export default App
1. 공통 Axios 설정:
axiosInstance.ts 파일에서 API 요청을 위한 Axios 인스턴스를 설정합니다.
Axios 인스턴스 생성:
baseURL은 환경 변수에서 가져오고, 기본적으로 요청 헤더에 Content-Type을 application/json으로 설정합니다. 또한, localStorage에 저장된 토큰이 있으면 이를 자동으로 요청 헤더에 추가해줍니다.인터셉터:
- 요청 인터셉터: API 요청 전에 localStorage에서 토큰을 가져와 헤더에 추가하여 인증 처리를 자동화합니다.
- 응답 인터셉터: 응답 데이터를 처리하며, 만약 응답 코드가 401(Unauthorized)일 경우, 로그인 페이지로 리다이렉트하는 등의 처리를 할 수 있습니다.
axiosInstance.ts
import axios from "axios"; // Axios 인스턴스 생성 및 기본 설정 const axiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, headers: { "Content-Type": "application/json", }, }); // 요청 인터셉터: 요청에 토큰을 추가 axiosInstance.interceptors.request.use( (config) => { const accessToken = localStorage.getItem("accessToken"); const refreshToken = localStorage.getItem("refreshToken"); if (accessToken && refreshToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; }, (error) => { console.error("요청 인터셉터 에러:", error); return Promise.reject(error); } ); // 응답 인터셉터: 토큰 만료 시 재발급 및 요청 재시도 axiosInstance.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; if ( error.response?.data?.errorCode === "ACCESS_TOKEN_EXPIRED" && !originalRequest._retry ) { originalRequest._retry = true; try { const refreshToken = localStorage.getItem("refreshToken"); if (!refreshToken) throw new Error("No refresh token"); // 토큰 갱신 요청 const responseData = await axios.post("/users/refresh",{}, { headers: { refreshToken }, baseURL: import.meta.env.VITE_API_BASE_URL, } ); const data=responseData.data?.data; console.log( " 토큰 갱신하기 : ", data); if(data.token.accessToken&&data.token.refreshToken){ // 갱신된 토큰 저장 localStorage.setItem("accessToken", data.token.accessToken); localStorage.setItem("refreshToken", data.token.refreshToken); // 갱신된 토큰으로 원래 요청 재시도 originalRequest.headers.Authorization = `Bearer ${data.token.accessToken}`; return axiosInstance(originalRequest); } throw new Error("Failed to refresh access token"); } catch (refreshError) { console.error("토큰 갱신 실패:", refreshError); localStorage.clear(); window.location.href = "/logout"; return Promise.reject(refreshError); } } return Promise.reject(error); } ); export default axiosInstance;
설명:
- Axios 인스턴스 생성: baseURL과 Content-Type을 설정하고, 기본적으로 JSON 형태로 데이터를 전송합니다. 요청 및 응답 시 인터셉터를 사용해 토큰을 자동으로 추가하고 에러를 처리합니다.
- 토큰 자동 추가: 요청 전 localStorage에서 토큰을 확인해 자동으로 Authorization 헤더에 추가합니다.
- 401 에러 처리: 서버로부터 인증 실패(401)를 받을 경우, 로그인 리다이렉트 같은 처리를 할 수 있습니다.
2. 공통 API 요청 함수들:
다양한 API 요청(GET, POST, PUT, DELETE)을 처리하는 함수들이 있습니다. 각 함수는 Axios 인스턴스를 사용하여 서버와 데이터를 주고받으며, 응답 데이터를 apiData에 저장하고, 오류 발생 시 error 객체에 저장합니다.
- postApiData: 회원가입과 같은 POST 요청을 처리합니다.
- getApiData, putApiData, deleteApiData도 각각 GET, PUT, DELETE 요청을 처리하는 함수들입니다.
axiosActions.ts
import { ApiResponseDTO, ErrorData, ResponseDTO } from "@/dto/ResponseDTO"; import axiosInstance from "@/utils/axiosInstance"; import { AxiosError } from 'axios'; // 공통 에러 타입 정의 export type CustomError = AxiosError | Error | ResponseDTO | ErrorData | string | null; export interface actionDataType { alertMessage?: string; isLoading?: boolean; signUpResult?: boolean; fieldErrors?: Record<string, string>; errorField?: string; } // API 요청 함수 //1.GET 요청 함수 export async function getApiData<T>(url: string): Promise<ApiResponseDTO> { try { const response = await axiosInstance.get<T>(url); return { responseDTO: response.data as ResponseDTO, error: null }; } catch (err: unknown) { return handleApiError(err); } } //2.POST 요청 함수 export async function postApiData<T, U>(url: string, payload: T): Promise<ApiResponseDTO> { try { const response = await axiosInstance.post<U>(url, payload); return { responseDTO: response.data as ResponseDTO, error: null }; } catch (err: unknown) { return handleApiError(err); } } //3.PUT 요청 함수 export async function putApiData<T, U>(url: string, payload: T): Promise<ApiResponseDTO> { try { const response = await axiosInstance.put<U>(url, payload); return { responseDTO: response.data as ResponseDTO, error: null }; } catch (err: unknown) { return handleApiError(err); } } //4.DELETE 요청 함수 export async function deleteApiData<T>(url: string): Promise<ApiResponseDTO> { try { const response = await axiosInstance.delete<T>(url); return { responseDTO: response.data as ResponseDTO, error: null }; } catch (err: unknown) { return handleApiError(err); } } //5.공통 에러 처리 함수 function handleApiError(err: unknown): ApiResponseDTO { let error: CustomError = "Unexpected error"; if (err instanceof AxiosError) { error = err.response?.data as ResponseDTO || err.message || "Unknown error" ; if(error?.errorCode ==="INVALID_TOKEN"){ localStorage.removeItem("authenticated"); localStorage.clear(); window.location.href = "/logout"; } } else if (err instanceof Error) { error = err.message; } console.error("5.공통 에러 처리 함수 API 요청 에러:", error); return { responseDTO: null, error }; }
설명:
- API 요청 처리: axiosInstance를 통해 GET, POST 등의 요청을 보냅니다. 성공하면 데이터를 반환하고, 실패하면 에러를 반환합니다.
- 타입 안정성: TypeScript의 제네릭을 활용해 응답 데이터 타입을 명확하게 지정할 수 있습니다.
3-1. 공통 Axios 오류 처리:
handleAxiosError 함수는 서버로부터 받은 오류 응답을 처리합니다. 만약 서버에서 특정 필드에 대한 에러 메시지가 반환되면, 이를 처리해 클라이언트 측에서 에러 메시지를 표시할 수 있게 해줍니다.
handleAxiosError.ts
여기서 errorField 는 백엔드에서 설정한 변수인 errorField 를 받아 옵니다.
1)백엔드 : Express 경우
import { json } from "react-router-dom"; import { AxiosError } from "axios"; interface ErrorResponseData { message: string; errorField: string; } // AxiosError를 처리하는 유틸리티 함수 export function handleAxiosError(error: AxiosError) { const fieldErrors: Record<string, string> = {}; if (error?.response?.data as ErrorResponseData) { const errorData =error?.response?.data as ErrorResponseData; // 백엔드에서 전달된 에러 메시지 처리 if (errorData.message) { fieldErrors[errorData.errorField] = errorData.message; // 에러 응답을 json 형태로 반환 return json( { alertMessage: errorData.message, fieldErrors, errorField: errorData.errorField, isLoading: false, }, { status: 400 } ); } } // 기본 에러 메시지 처리 return json( { alertMessage: error.message, isLoading: false }, { status: 400 } ); }
2)백엔드 : springboot의 경우
springBootAxiosError.ts
import { json } from "react-router-dom"; import { ErrorData, ErrorList } from "@/dto/ResponseDTO"; export function springBootAxiosError(errorData: ErrorData) { console.log("오류 springBootAxiosError:", errorData); if (!errorData) { return json( { alertMessage: "No error data received.", isLoading: false, }, { status: 500 } ); } const fieldErrors: Record<string, string> = {}; const errorList = errorData.data as ErrorList[]; if (Array.isArray(errorList)) { errorList.forEach((validationError) => { console.log("** validationError:", validationError); fieldErrors[validationError.field] = validationError.defaultMessage; }); return json( { alertMessage: errorData.message, fieldErrors, isLoading: false, }, { status: errorData.status || 400 } ); } else if (errorData.message) { fieldErrors["all"] = errorData.message; return json( { alertMessage: errorData.message, fieldErrors, errorField: errorData.errorField, isLoading: false, }, { status: errorData.status || 400 } ); } return json( { alertMessage: "An unknown error occurred.", isLoading: false, }, { status: 400 } ); }
설명:
- 에러 메시지 처리: 서버로부터 받은 에러 응답을 처리하며, 발생한 에러를 특정 필드와 연결해 사용자에게 알릴 수 있습니다.
- 예를 들어, 잘못된 입력값이 있는 경우 해당 필드에 에러 메시지를 표시합니다
4. 회원가입 처리 로직 (action 함수):
회원가입 폼의 데이터를 서버에 전송하고, 그에 따른 응답을 처리하는 부분입니다.
- action 함수는 request.formData()를 통해 폼 데이터를 수집하고, 이를 postApiData 함수를 통해 /api/users/signup 엔드포인트로 전송합니다.
- 만약 에러가 발생하면, handleAxiosError 함수로 에러를 처리합니다.
- 성공 시, 메시지를 반환하거나 로그인 페이지로 리다이렉트할 수 있습니다.
sinupAction.ts
import { json } from "react-router-dom"; import { postApiData } from "./axiosActions"; import { springBootAxiosError } from "@/utils/srpingBootAxiosError"; import { ErrorData } from "@/dto/ResponseDTO"; // 회원 가입 action export async function action({ request }: { request: Request }) { const formData = await request.formData(); const signupData = Object.fromEntries(formData); // 여기에 실제 회원가입 처리 로직 추가 (API 호출 등) const {responseDTO, error}= await postApiData(`/users/signup`, signupData); console.log("1.responseDTO : ",responseDTO); console.log("2. error : ",error); if (error || responseDTO?.code !== 1) { const errorData=error as ErrorData; return springBootAxiosError(errorData); } // 회원가입 성공 시 처리 if(responseDTO &&responseDTO.code===1){ return json({ alertMessage: "회원가입을 축하합니다", isLoading:false, signUpResult:true }, { status: 200 }); } }
회원가입 처리: postApiData를 통해 서버에 회원가입 데이터를 전송하고, 성공 시 성공 메시지를 반환하거나 에러가 발생하면 handleAxiosError로 처리합니다.
3-2. 공통 타입스크립트 DTO 및 페이징처리
1)ResponseDTO.ts
import { PageMaker } from "./PageMaker"; import { TodoDTO } from "./TodoDTO"; import { LoginUserDTO } from "./UserDTO"; export interface ResponseDTO{ code: number; //1(성공), -1(실패) data: unknown | ApiResponseData | null | LoginUserDTO ; errorCode:string |null; message: string |null; errorField: string | null; } export interface ApiResponseDTO{ responseDTO: ResponseDTO | null; error: unknown | Error | ResponseDTO | string | null; } export interface todoResponseDTOes{ todoResponseDTOes:TodoDTO[] | null | undefined; } export interface ApiResponseData{ pageMaker?: PageMaker; todos: ApiResponseEmbedded; } export interface ApiResponseEmbedded{ _embedded?: todoResponseDTOes; // content: TodoDTO[] | null; link?:ResponseLink[]; } export interface ResponseLink{ href: string; rel: string; } export interface ErrorData{ code: number; data:ErrorList[], errorCode: string | null; errorField: string | null; message: string | null; status: number|null; } export interface ErrorList{ arguments:[]; bindingFailure:boolean; code:string[]; defaultMessage:string; field:string; objectName:string; rejectedValue:string; }
2)PageMaker.ts
// PageMaker 인터페이스 정의 export interface PageMaker { page: number; // 현재 페이지 pageSize: number; // 페이지당 항목 수 totalCount: number; // 전체 항목 수 startPage: number; // 시작 페이지 번호 endPage: number; // 끝 페이지 번호 prev: boolean; // 이전 버튼 표시 여부 next: boolean; // 다음 버튼 표시 여부 last: boolean; // 마지막 페이지 여부 displayPageNum: number; // 표시할 페이지 번호 수 tempEndPage: number; // 실제 끝 페이지 번호 searchKeyword?: string; // 검색 키워드 searchType?: string; // 검색 유형 } // PageMaker 유틸리티 함수 export const createPageMaker = ( totalCount: number, page: number = 1, pageSize: number = 10, displayPageNum: number = 10 ): PageMaker => { const tempEndPage = Math.ceil(totalCount / pageSize); const endPage = Math.min( Math.ceil(page / displayPageNum) * displayPageNum, tempEndPage ); const startPage = endPage - displayPageNum + 1 > 0 ? endPage - displayPageNum + 1 : 1; const prev = startPage > 1; const next = endPage < tempEndPage; const last = page >= tempEndPage; return { page, pageSize, totalCount, startPage, endPage, prev, next, last, displayPageNum, tempEndPage, }; };
3)UserDTO.ts
export interface LoginUserDTO { id: number | string; username: string; name: string; role: string | string[]; token: tokenDTO; refreshToken: string; expirationTime: string; accessTokenExpirationTime: string; refreshTokenExpirationTime: string; } interface tokenDTO { grantType: string; accessToken: string; refreshToken: string; accessTokenExpires: number; }
4)TodoDTO.ts
import { PageMaker } from "./PageMaker"; import { ResponseLink } from "./ResponseDTO"; export interface TodoDTO { id: number; title: string; description: string; done: boolean; targetDate: string | null; userId ?:number; username ?:string; num ?:number; link?:ResponseLink[]; } export interface TodoDTOListRes{ todos:TodoDTO[]; pageMaker:PageMaker|null; }
5)페이징 컴포넌트 Pagination.tsx
참고 : https://macaronics.net/index.php/m04/react/view/2268
import React from "react"; interface PaginationProps { currentPage: number; pageSize: number; totalItems: number; onPageChange: (currentPage: number) => void; } const Pagination: React.FC<PaginationProps> = ({ currentPage, pageSize, totalItems, onPageChange }) => { const totalPages = Math.ceil(totalItems / pageSize); const handlePageClick = (newPage: number) => { onPageChange(newPage); // 부모에서 상태 관리 }; return ( <section className="container mx-auto flex justify-center items-center my-8"> {currentPage > 1 && ( <button onClick={() => handlePageClick(currentPage - 1)} className="mr-2 px-2 py-1 border border-gray-300 rounded"> 이전 </button> )} {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( <button key={p} onClick={() => handlePageClick(p)} className={`mx-1 px-2 py-1 border rounded ${p === currentPage ? 'bg-blue-600 border-blue-600 text-white' : ' border-gray-300'}`} > {p} </button> ))} {currentPage < totalPages && ( <button onClick={() => handlePageClick(currentPage + 1)} className="ml-2 px-2 py-1 border border-gray-300 rounded"> 다음 </button> )} </section> ); }; export default Pagination;
5. 회원가입 컴포넌트 (SignupComponent):
react-hook-form 설정 및 사용방법 참조
1) 공식문서
https://www.react-hook-form.com/
2)
https://macaronics.net/m04/react/view/2301
SignupComponent는 실제로 사용자 입력을 받는 회원가입 폼을 구현한 부분입니다. 이 폼은 react-hook-form 라이브러리를 사용해 폼 상태를 관리하고 유효성 검사를 수행합니다.
폼 상태 관리 및 제출:
useForm 훅을 사용해 각 필드의 값과 에러를 관리합니다. handleSubmit을 통해 폼 데이터를 제출하며, 서버 응답을 받아 에러 메시지나 성공 메시지를 표시합니다.폼 유효성 검사:
각 입력 필드에 대해 필수 입력 여부와 같은 유효성 검사를 설정하고, 에러가 발생할 경우 Tailwind CSS로 스타일링된 에러 메시지를 보여줍니다.서버 응답 처리:
useActionData를 사용해 서버에서 반환된 데이터를 받아, 회원가입 성공 여부나 에러 메시지를 처리합니다. 예를 들어, 회원가입 성공 시에는 로그인 페이지로 리다이렉트되고, 에러가 있을 경우 해당 필드에 에러 메시지를 표시합니다.다이얼로그 컴포넌트:
서버에서 받은 메시지를 다이얼로그로 표시해 사용자에게 알려줍니다.
SignupComponent.tsx
import React, { useEffect, useState } from "react"; import { Form, useActionData, Link,useSubmit } from "react-router-dom"; import { FieldValues, useForm } from "react-hook-form"; import DialogConfirmComponent from "../common/dialog/DialogConfirmComponent"; import { actionDataType } from "@/actions/axiosActions"; import ErrorInputMessage from "../common/ErroInputMessage"; const SignupComponent: React.FC = () => { const [redirectURL, setRedirectURL] = useState(''); const actionData = useActionData() as actionDataType; const [isDialogOpen, setIsDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const { register, handleSubmit, formState: { errors }, watch, setError } = useForm(); const submit = useSubmit(); useEffect(() => { if (actionData?.alertMessage) { setIsDialogOpen(true); } if (!actionData?.isLoading) { setIsLoading(false); } if (actionData?.signUpResult) { setRedirectURL("/login"); } if (actionData?.fieldErrors) { Object.entries(actionData.fieldErrors).forEach(([field, message]) => { console.log(field, message); setError(field, { type: "server", message: String(message), }); }); } }, [actionData, setError]); const onSubmit = (data: FieldValues) => { //진행중 표시 setIsLoading(true); submit(data, {method: "post"}); }; return ( <> <Form method="post" onSubmit={handleSubmit(onSubmit)}> <div className="flex items-center justify-center py-20"> <div className="w-full max-w-2xl p-8 bg-white rounded-lg shadow-lg"> <h2 className="text-3xl font-bold text-center text-gray-800 mb-10"> 회원 가입 </h2> {isLoading && ( <div className="w-full text-blue-600 font-bold my-5 items-center text-center"> 회원 가입 중... </div> )} <div className="mb-4 flex flex-col"> <div className="flex flex-row"> <label className="w-3/12 block text-sm font-medium text-gray-700 mb-2"> 아이디 </label> <input type="text" {...register("username", { required: "아이디를 입력하세요." })} className={`w-9/12 px-4 py-2 border ${errors.username ? "border-red-500" : "border-gray-300"} rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} placeholder="아이디" /> </div> <ErrorInputMessage errors={errors} field="username" type={0} /> </div> <div className="mb-4 flex flex-col"> <div className="flex flex-row"> <label className="w-3/12 block text-sm font-medium text-gray-700 mb-2"> 이름 </label> <input type="text" {...register("name", { required: "이름을 입력하세요." })} className={`w-9/12 px-4 py-2 border ${errors.name ? "border-red-500" : "border-gray-300"} rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} placeholder="이름" /> </div> <ErrorInputMessage errors={errors} field="name" type={0} /> </div> <div className="mb-6 flex flex-col"> <div className="flex flex-row"> <label className="w-3/12 block text-sm font-medium text-gray-700 mb-2"> 비번 </label> <input type="password" {...register("password", { required: "비밀번호를 입력하세요." })} className={`w-9/12 px-4 py-2 border ${errors.password ? "border-red-500" : "border-gray-300"} rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} placeholder="비밀번호" /> </div> <ErrorInputMessage errors={errors} field="password" type={0} /> </div> <div className="mb-6 flex flex-col"> <div className="flex flex-row"> <label className="w-3/12 block text-sm font-medium text-gray-700 mb-2"> 비밀번호 확인 </label> <input type="password" {...register("confirmPassword", { required: "비밀번호 확인을 입력하세요.", validate: (value) => value === watch('password') || "비밀번호가 일치하지 않습니다." })} className={`w-9/12 px-4 py-2 border ${errors.confirmPassword ? "border-red-500" : "border-gray-300"} rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} placeholder="비밀번호 확인" /> </div> <ErrorInputMessage errors={errors} field="confirmPassword" type={0} /> </div> <div className="mb-6 flex flex-col"> <div className="flex flex-row"> <label className="w-3/12 block text-sm font-medium text-gray-700 mb-2"> 생일 </label> <input type="date" {...register("birthDate", { required: "생일을 입력하세요." })} className={`w-9/12 px-4 py-2 border ${errors.birthDate ? "border-red-500" : "border-gray-300"} rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} /> </div> <ErrorInputMessage errors={errors} field="birthDate" type={0} /> </div> <div className="mb-6 flex flex-col"> <div className="flex flex-row"> <label className="w-3/12 block text-sm font-medium text-gray-700 mb-2"> 이메일 </label> <input type="email" {...register("email", { required: "이메일을 입력하세요." })} className={`w-9/12 px-4 py-2 border ${errors.email ? "border-red-500" : "border-gray-300"} rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} placeholder="이메일" /> </div> <ErrorInputMessage errors={errors} field="email" type={0} /> </div> <div className="my-3"> <p> 이미 회원가입이 되어있습니까?{" "} <Link to={"/login"} className="text-indigo-700 hover:text-indigo-900" > 로그인 </Link> </p> </div> <div className="flex justify-center"> <button type="submit" className={`w-full py-2 px-4 ${isLoading ? "bg-gray-500" : "bg-blue-500"} text-white font-semibold rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2`} disabled={isLoading} > {isLoading ? "회원 가입중..." : "회원 가입"} </button> </div> </div> </div> </Form> {actionData?.alertMessage && ( <DialogConfirmComponent alertMessage={actionData.alertMessage} isDialogOpen={isDialogOpen} setIsDialogOpen={setIsDialogOpen} redirectURL={redirectURL} /> )} </> ); }; export default SignupComponent;
ErrorInputMessage.tsx
import React from 'react'; import { FieldErrors } from 'react-hook-form'; interface ErrorInputMessageProps { errors: FieldErrors; // react-hook-form에서 제공하는 타입 field: string; type:number } const ErrorInputMessage: React.FC<ErrorInputMessageProps> = ({ errors, field, type=0 }) => { if (errors[field]) { return ( <div className="flex flex-row w-full mt-1 mb-3"> {type===0 && <span className="w-3/12"></span>} <p className="text-red-500 text-sm">{errors[field]?.message as string}</p> </div> ); } return null; }; export default ErrorInputMessage;
설명:
- 폼 제출 및 유효성 검사: react-hook-form을 사용해 입력값에 대해 유효성 검사를 하고, 문제가 없을 경우 submit 함수로 데이터를 전송합니다.
- 에러 메시지 처리: 서버에서 반환된 에러는 해당 필드에 자동으로 표시됩니다.
- 성공 시 리다이렉트: 회원가입 성공 시 로그인 페이지로 리다이렉트하도록 설정합니다.
추가 알림 팝업 메시창 - shadcn 으로 팝업구현
DialogConfirmComponent.tsx
import { Dialog, DialogHeader, DialogFooter, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { useNavigate } from "react-router-dom"; // navigator 사용을 위해 추가 interface DialogConfirmComponentProps { isDialogOpen: boolean; setIsDialogOpen: React.Dispatch<React.SetStateAction<boolean>>; alertMessage: string; redirectURL?: string; } const DialogConfirmComponent: React.FC<DialogConfirmComponentProps> = ({ isDialogOpen, setIsDialogOpen, alertMessage, redirectURL }) => { const navigate = useNavigate(); const buttonConfirm = () => { setIsDialogOpen(false); if (redirectURL) { navigate(redirectURL); // 페이지 이동 } }; return ( <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <DialogContent> <DialogHeader> <DialogTitle className="text-lg font-bold text-rose-700">알림</DialogTitle> <DialogDescription className="mt-3 text-xl">{alertMessage}</DialogDescription> </DialogHeader> <DialogFooter className="mt-4"> <Button variant="ghost" onClick={buttonConfirm} className="bg-slate-500 text-white hover:bg-slate-700 hover:text-white" > 확인 </Button> </DialogFooter> </DialogContent> </Dialog> ); }; export default DialogConfirmComponent;
2. 스프링 부트 백엔드 개발
참고: 3.0 RESTful API 개발 기본설정
1) https://macaronics.net/m01/spring/view/2298
2) https://macaronics.net/index.php/m01/spring/view/2299
3) https://macaronics.net/index.php/m01/spring/view/2300
pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.1</version> <relativePath /> <!-- lookup parent from repository --> </parent> <groupId>net.macaronics.springboot.webapp</groupId> <artifactId>springboot-webapp</artifactId> <version>0.0.1-SNAPSHOT</version> <name>springboot-restful-web</name> <description>Demo project for Spring Boot</description> <url /> <licenses> <license /> </licenses> <developers> <developer /> </developers> <scm> <connection /> <developerConnection /> <tag /> <url /> </scm> <properties> <java.version>17</java.version> <querydsl.version>5.0.0</querydsl.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity6</artifactId> <version>3.1.2.RELEASE</version> </dependency> <dependency> <groupId>nz.net.ultraq.thymeleaf</groupId> <artifactId>thymeleaf-layout-dialect</artifactId> <version>3.3.0</version> </dependency> <dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-java8time</artifactId> <version>3.0.4.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.9.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>bootstrap</artifactId> <version>5.1.3</version> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>jquery</artifactId> <version>3.6.0</version> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>bootstrap-datepicker</artifactId> <version>1.9.0</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!--QueryDSL 의존성 추가--> <dependency> <groupId>com.querydsl</groupId> <artifactId>querydsl-core</artifactId> <version>${querydsl.version}</version> </dependency> <dependency> <groupId>com.querydsl</groupId> <artifactId>querydsl-jpa</artifactId> <version>${querydsl.version}</version> <classifier>jakarta</classifier> </dependency> <dependency> <groupId>com.querydsl</groupId> <artifactId>querydsl-sql</artifactId> <version>${querydsl.version}</version> </dependency> <dependency> <groupId>com.querydsl</groupId> <artifactId>querydsl-sql-spring</artifactId> <version>${querydsl.version}</version> </dependency> <dependency> <groupId>com.querydsl</groupId> <artifactId>querydsl-apt</artifactId> <version>${querydsl.version}</version> <classifier>jakarta</classifier> </dependency> <dependency> <groupId>org.modelmapper</groupId> <artifactId>modelmapper</artifactId> <version>3.2.1</version> </dependency> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-rest-webmvc</artifactId> <version>4.4.0</version> </dependency> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.7.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-rest</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-hateoas</artifactId> </dependency> <!-- <dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-xml</artifactId> </dependency>--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-rest-hal-explorer</artifactId> </dependency> <!-- MapStruct 의존성 추가 --> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <version>1.5.3.Final</version> </dependency> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>1.5.3.Final</version> <scope>provided</scope> </dependency> <dependency> <groupId>p6spy</groupId> <artifactId>p6spy</artifactId> <version>3.9.1</version> </dependency> <!-- Spring Boot Starter Data Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- JWT API --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>org.mariadb.jdbc</groupId> <artifactId>mariadb-java-client</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> <plugin> <groupId>com.mysema.maven</groupId> <artifactId>apt-maven-plugin</artifactId> <version>1.1.3</version> <executions> <execution> <goals> <goal>process</goal> </goals> <configuration> <outputDirectory>target/generated-sources/java</outputDirectory> <processor> com.querydsl.apt.jpa.JPAAnnotationProcessor</processor> </configuration> </execution> </executions> </plugin> <!-- MapStruct 애노테이션 프로세서 설정 --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.10.1</version> <configuration> <source>17</source> <target>17</target> <annotationProcessorPaths> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>1.5.3.Final</version> </path> </annotationProcessorPaths> </configuration> </plugin> </plugins> </build> </project>
1. 엔티티 , 공통반환 , 공통 에러 설정
1-1) User
package net.macaronics.springboot.webapp.entity; import java.time.LocalDate; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import net.macaronics.springboot.webapp.dto.user.UserUpdateFormDTO; import net.macaronics.springboot.webapp.repository.RoleRepository; @Entity @Getter @Setter @Table(name = "tbl_users") @Builder @AllArgsConstructor @NoArgsConstructor public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "user_id") private Long id; @Column(nullable = false, unique = true) private String username; private String password; @Column(unique = true, nullable = false) private String name; @Column(unique = true) private String email; private LocalDate birthDate; @ManyToMany(fetch = FetchType.EAGER) @JoinTable( name = "user_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id"), uniqueConstraints =@UniqueConstraint(columnNames = {"user_id", "role_id"}) // 복합 유니크 제약 조건 추가 ) private Set<Role> roles = new HashSet<>(); @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) // 삭제된 자식 엔티티 제거 private List<Todo> todos = new ArrayList<>(); @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List<Post> posts = new ArrayList<>(); public User(String username, LocalDate birthDate) { this.password = "$2a$10$8VKmqNwV0x/bQEN8Z54w7uUpLPTGeHgoJR73dyH2S6ZxoHkVkxGSm"; this.username = username; this.birthDate = birthDate; } // 더티 체킹 업데이트 public static User updateUser(User user, UserUpdateFormDTO updateFormDTO, RoleRepository roleRepository) { user.setPassword(updateFormDTO.getPassword()); user.setBirthDate(updateFormDTO.getBirthDate()); // Set<RoleType>을 Set<Role>로 변환하여 설정 Set<Role> roles = updateFormDTO.getRoles().stream().<Role>map(roleType -> roleRepository.findByName(roleType) .orElseThrow(() -> new IllegalArgumentException("Role not found: " + roleType))) .collect(Collectors.toSet()); user.setRoles(roles); return user; } // 더티 체킹 권한 추가 메서드 public void addRole(Role role) { if (!this.roles.contains(role)) { this.roles.add(role); } } //더티 체킹 권한 삭제 메서드 public void removeRole(Role role) { if (this.roles.contains(role)) { this.roles.remove(role); } } }
1-2)Role
package net.macaronics.springboot.webapp.entity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import net.macaronics.springboot.webapp.enums.RoleType; @Entity @Getter @Setter @NoArgsConstructor @Table(name = "tbl_roles") public class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "role_id") private Long id; @Enumerated(EnumType.STRING) @Column(nullable = false, unique = true) private RoleType name; public Role(RoleType name) { this.name=name; } }
1-3) Todo
package net.macaronics.springboot.webapp.entity; import java.time.LocalDate; import org.springframework.format.annotation.DateTimeFormat; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import lombok.ToString; @Entity @Data @Table(name = "tbl_todos") // 테이블 이름을 "todos"로 설정 @AllArgsConstructor @Builder @NoArgsConstructor @ToString(exclude = "user") // 순환 참조 방지 public class Todo { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) // ID 자동 생성 전략을 사용 (Auto Increment) @Column(name = "todo_id") private Long id; @ManyToOne(fetch = FetchType.LAZY) // CascadeType.ALL 제거 @JoinColumn(name = "user_id") // 외래키로 "user_id" 컬럼을 사용 private User user; private String title; private String description; // 할 일 설명 @DateTimeFormat(pattern = "yyyy-MM-dd") // 날짜 형식을 "yyyy-MM-dd"로 설정 private LocalDate targetDate; // 목표 날짜 private boolean done; // 완료 여부 }
2) ResponseDTO
package net.macaronics.springboot.webapp.dto; import org.springframework.hateoas.RepresentationModel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @AllArgsConstructor @NoArgsConstructor @Setter @Getter @Builder public class ResponseDTO<T> extends RepresentationModel<ResponseDTO<T>>{ private int code; //1(성공), -1(실패) private String message; private String errorCode; private String errorField; private T data; }
3)UserRegisterFormDTO
package net.macaronics.springboot.webapp.dto.user; import java.time.LocalDate; import java.util.HashSet; import java.util.Set; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Past; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import net.macaronics.springboot.webapp.entity.Role; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.enums.RoleType; import net.macaronics.springboot.webapp.service.RoleService; @Data @Builder @AllArgsConstructor @NoArgsConstructor public class UserRegisterFormDTO { @NotEmpty(message = "아이디는 필수 입력 항목입니다.") @Size(min = 2, max = 10, message = "아이디는 2자에서 10자 사이여야 합니다.") private String username; @NotEmpty(message = "비밀번호는 필수 입력 항목입니다.") @Size(min = 4, max = 15, message = "비밀번호는 4자에서 15자 사이여야 합니다.") private String password; @NotEmpty(message = "비밀번호 확인이 필요합니다.") private String confirmPassword; @NotEmpty(message = "이름은 필수 입력 항목입니다.") private String name; @Past(message = "생일은 과거 날짜로 입력해야 합니다.") @NotNull(message = "생일은 필수 입력 항목입니다.") private LocalDate birthDate; @Email @NotEmpty(message = "이메일은 필수 입력 항목입니다.") private String email; private RoleType role = RoleType.USER; // 기본값 설정 // 비밀번호와 비밀번호 확인이 일치하는지 확인 @AssertTrue(message = "비밀번호와 비밀번호 확인이 일치해야 합니다.") public boolean isPasswordConfirmed() { return password != null && password.equals(confirmPassword); } public static User toCreateUser(UserRegisterFormDTO dto, RoleService roleService) { Set<Role> roles=new HashSet<>(); RoleType roleType=dto.getRole()!=null ?dto.getRole() :RoleType.USER; roleService.findByName(roleType).ifPresentOrElse( role->{ roles.add(role); }, ()->{ roles.add(new Role(RoleType.USER)); } ); return User.builder() .username(dto.getUsername()) .email(dto.getEmail()) .name(dto.getName()) .password(dto.getPassword()) .birthDate(dto.getBirthDate()) .roles(roles) // Set<Role>로 역할을 설정 .build(); } }
4) APIGlobalExceptionHandler
package net.macaronics.springboot.webapp.exception; import java.io.IOException; import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.dto.ResponseDTO; /** * JSON 예외 처리 * * */ @ControllerAdvice @Slf4j public class APIGlobalExceptionHandler extends RuntimeException implements AuthenticationEntryPoint{ /** * 모든 예외 처리 * @param ex * @param request * @return */ @ExceptionHandler(Exception.class) public final ResponseEntity<?> handleAllExceptions(Exception ex, WebRequest request){ log.error("Unhandled exception occurred", ex); ErrorDetails errorDetails = new ErrorDetails(LocalDateTime.now(), ex.getMessage(), request.getDescription(false)); ResponseDTO<?> response = ResponseDTO.builder() .code(-1) .message("Internal Server Error") .data(errorDetails) .errorCode("INTERNAL_SERVER_ERROR") .build(); return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } /** * 공통 404 예외 처리: NotFoundException * @param ex * @param request * @return */ @ExceptionHandler(ResourceNotFoundException.class) public final ResponseEntity<?> handleNotFoundException(ResourceNotFoundException ex, WebRequest request){ log.warn("Resource not found exception: {}", ex.getMessage()); ErrorDetails errorDetails = new ErrorDetails(LocalDateTime.now(), ex.getMessage(), request.getDescription(false)); ResponseDTO<?> response = ResponseDTO.builder() .code(-1) .message("Resource Not Found") .data(errorDetails) .errorCode("NOT_FOUND") .build(); return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); } /** * bindingResult.hasErrors() 에러시 반환 처리한다 * 유효성 체크 에러 처리 * @param ex * @param request * @return */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<?> handleValidationExceptions(MethodArgumentNotValidException ex, WebRequest request){ List<String> errors = ex.getBindingResult() .getAllErrors() .stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) .collect(Collectors.toList()); ErrorDetails errorDetails = new ErrorDetails(LocalDateTime.now(), "Validation Failed", request.getDescription(false)); log.warn("Validation failed: {}", errorDetails.getMessage()); ResponseDTO<?> response = ResponseDTO.builder() .code(-1) .message(errors.get(0)) .data(ex.getFieldErrors()) .errorCode("VALIDATION_ERROR") .errorField(ex.getFieldError().getField()) .build(); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { // TODO Auto-generated method stub } // 기타 특정 예외 핸들러 추가 가능 }
2.컨트롤 및 서비스 및 Repository
1-1)ApiRestfullUserController
package net.macaronics.springboot.webapp.api.controller; import java.net.URI; import org.springframework.hateoas.CollectionModel; import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import jakarta.persistence.EntityNotFoundException; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.config.auth.dto.TokenDTO; import net.macaronics.springboot.webapp.config.auth.jwt.JwtTokenProviderService; import net.macaronics.springboot.webapp.dto.ResponseDTO; import net.macaronics.springboot.webapp.dto.user.UserDTO; import net.macaronics.springboot.webapp.dto.user.UserLoginFormDTO; import net.macaronics.springboot.webapp.dto.user.UserRegisterFormDTO; import net.macaronics.springboot.webapp.dto.user.UserResponse; import net.macaronics.springboot.webapp.dto.user.UserUpdateFormDTO; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.mapper.UserMapper; import net.macaronics.springboot.webapp.service.RoleService; import net.macaronics.springboot.webapp.service.UserService; @RestController @RequestMapping("/api/restfull/users") @RequiredArgsConstructor @Slf4j public class ApiRestfullUserController { private final UserService userService; private final UserMapper userMapper; private final JwtTokenProviderService tokenProvider; private final RoleService roleService; /** HATEOAS 링크 추가 메서드 */ private UserResponse addUserLinks(UserResponse userResponse) throws Exception{ userResponse.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiRestfullUserController.class).getUserById(userResponse.getId())).withSelfRel()); return userResponse; } /** GET => http://localhost:8080/api/users/{id} * 2. 개별 사용자 조회 메소드 * @param id * @return */ @GetMapping("/{id}") @Operation(summary = "개별 사용자 조회 메소드", description = "개별 사용자의 정보를 제공합니다.") public ResponseEntity<?> getUserById(@Parameter(description = "아이디", example = "1") @PathVariable Long id) throws Exception{ UserResponse user = addUserLinks(userService.getUserById(id)); return ResponseEntity.ok(user); } /** POST => http://localhost:8080/api/users/signup * 3. 사용자 생성 (회원가입) 메소드 * @param registerFormDTO * @param bindingResult * @return * @throws Exception */ @PostMapping("/signup") @Operation(summary = "사용자 생성", description = "새 사용자를 등록합니다.") public ResponseEntity<?> createUser(@Valid @RequestBody UserRegisterFormDTO registerFormDTO, BindingResult bindingResult) throws Exception{ log.info("회원 가입 처리 시작: {}", registerFormDTO); // 중복 사용자 확인 userService.findByUsername(registerFormDTO.getUsername()) .ifPresent(user -> { log.warn("중복 사용자 ID: {}", registerFormDTO.getUsername()); bindingResult.rejectValue("username", "error.username", "이미 사용 중인 아이디입니다."); }); // 이메일 중복 확인 if (userService.isEmailInUse(registerFormDTO.getEmail())) { log.warn("중복 이메일: {}", registerFormDTO.getEmail()); bindingResult.rejectValue("email", "error.email", "이미 사용 중인 이메일입니다."); } // 입력 검증 if (bindingResult.hasErrors()) { log.error("입력 검증 실패: {}", bindingResult.getAllErrors()); throw new MethodArgumentNotValidException(null, bindingResult); } // 사용자 매핑 및 저장 User user = userMapper.ofUser(registerFormDTO, roleService); User savedUser = userService.saveUser(user); // URI 생성 및 응답 반환 URI location = WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiRestfullUserController.class) .getUserById(savedUser.getId())).toUri(); UserResponse userResponse = addUserLinks(UserResponse.of(savedUser)); return ResponseEntity.created(location) .body(ResponseDTO.builder() .code(1) .message("success") .data(userResponse) .build()); } /** DELETE => http://localhost:8080/api/users/{id} * 4. 사용자 삭제 * @param id * @return */ @DeleteMapping("/{id}") @Operation(summary = "사용자 삭제", description = "특정 사용자를 삭제합니다.") public ResponseEntity<?> deleteUser(@Parameter(description = "아이디", example = "1") @PathVariable Long id) throws Exception{ // 사용자 삭제 userService.deleteUser(id); //삭제 후 가능한 다음 행동들에 대한 HATEOAS 링크 추가 CollectionModel<UserResponse> collectionModel = CollectionModel.empty(); ResponseDTO<?> response = ResponseDTO.builder() .code(1) .message("성공적으로삭제 처리 되었습니다.") .data(collectionModel) .build(); return ResponseEntity.ok() .body(response); } /** * PUT => http://localhost:8080/api/users/{id} 5.사용자 수정 * @param id * @param userUpdateFormDTO * @param bindingResult * @return * @throws Exception */ @PutMapping("/{id}") @Operation(summary = "사용자 수정", description = "특정 사용자를 수정합니다.") public ResponseEntity<?> updateUser(@Parameter(description = "아이디", example = "1") @PathVariable Long id, @Valid @RequestBody UserUpdateFormDTO userUpdateFormDTO ,BindingResult bindingResult) throws Exception{ // 입력 검증 오류 처리 if(bindingResult.hasErrors()) { throw new MethodArgumentNotValidException(null, bindingResult); } UserResponse response =userService.updateUser(id, userUpdateFormDTO); // HATEOAS 링크 추가 UserResponse userResponse = addUserLinks(response); return ResponseEntity.ok(ResponseDTO.builder() .code(1) .message("success") .data(userResponse) .build()); } /** * 로그인 처리 POST http://localhost:8080/api/users/login * @param userLoginFormDTO * @param bindingResult * @return * @throws Exception */ @PostMapping("/login") @Operation(summary = "사용자 로그인", description = "사용자 로그인을 합니다.") public ResponseEntity<?> login(@Valid @RequestBody UserLoginFormDTO userLoginFormDTO, BindingResult bindingResult) throws Exception { log.info("회원 로그인 처리 : {}", userLoginFormDTO.toString()); User user= userService.loginProcess(userLoginFormDTO); if (user == null) { log.info("회원 로그인 실패 : 아이디 또는 비밀번호가 일치하지 않습니다."); bindingResult.rejectValue("username", "error.username", "아이디 또는 비밀번호가 일치하지 않습니다."); } // 입력 검증 if (bindingResult.hasErrors()) { log.error("입력 검증 실패: {}", bindingResult.getAllErrors()); throw new MethodArgumentNotValidException(null, bindingResult); } final TokenDTO tokenDTO = tokenProvider.create(user); UserResponse response=UserResponse.of(user); response.setToken(tokenDTO); //1. HATEOAS 링크 추가 UserResponse userResponse = addUserLinks(response); return ResponseEntity.ok(ResponseDTO.builder() .code(1) .message("success") .data(userResponse) .build()); } /** * 갱신토큰 방행 처리 POST http://localhost:8080/api/users/refresh * @param userLoginFormDTO * @param bindingResult * @return * @throws Exception */ @PostMapping("/refresh") @Operation(summary = "갱신 토큰 발행", description = "갱신 토큰 발행 처리를 합니다.") public ResponseEntity<?> refreshToken(@RequestHeader(required = true) String refreshToken) { log.info("1.갱신 토큰 발행 처리 요청: {}", refreshToken); try { // 1. 토큰 재발급 처리 UserDTO userDTO = tokenProvider.reissue(refreshToken); if (userDTO == null) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ResponseDTO.builder() .code(0) .message("Invalid or expired refresh token.") .build()); } // 3. 유저 정보 가져오기 User user = userService.findByUsername(userDTO.getUsername()) .orElseThrow(() -> new EntityNotFoundException("User not found.")); // 4. 응답 데이터 생성 UserResponse response = UserResponse.of(user); UserResponse userResponseWithLinks = addUserLinks(response); response.setToken(userDTO.getToken()); // 토큰 재발급 처리 log.info("끝..토큰 재발급 처리: {}", response); return ResponseEntity.ok( ResponseDTO.builder() .code(1) .message("Token reissued successfully.") .data(userResponseWithLinks) .build() ); } catch (Exception e) { log.error("갱신 토큰 처리 중 오류 발생:", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ResponseDTO.builder() .code(-1) .message("An unexpected error occurred.") .build()); } } }
1-2)ApiRestfullTodoController
package net.macaronics.springboot.webapp.api.controller; import java.net.URI; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.hateoas.CollectionModel; import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.BindingResult; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.config.auth.PrincipalDetails; import net.macaronics.springboot.webapp.dto.ResponseDTO; import net.macaronics.springboot.webapp.dto.todo.TodoCreateDTO; import net.macaronics.springboot.webapp.dto.todo.TodoResponseDTO; import net.macaronics.springboot.webapp.dto.todo.TodoUpdateDTO; import net.macaronics.springboot.webapp.service.TodoService; import net.macaronics.springboot.webapp.utils.PageMaker; @RestController @RequestMapping("/api/restfull/todos") @RequiredArgsConstructor @Slf4j public class ApiRestfullTodoController { private final TodoService todoService; /** * HATEOAS 링크 추가 메서드 */ private TodoResponseDTO addTodoLinks(TodoResponseDTO todoResponse,PrincipalDetails principalDetails) { todoResponse.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiRestfullTodoController.class) .getTodoById(principalDetails, todoResponse.getId())) .withSelfRel()); return todoResponse; } /** * 1. GET /api/restfull/todos * 전체 Todo 목록 조회 */ @Operation(summary = "전체 Todo 목록 조회", description = "특정 사용자의 모든 Todo를 페이지네이션하여 조회합니다.") @GetMapping public ResponseEntity<?> getAllTodos( @AuthenticationPrincipal PrincipalDetails principalDetails, PageMaker pageMaker ) throws Exception{ log.info("1.pageMaker : {}", pageMaker.toString()); PageRequest pageable = PageRequest.of(pageMaker.getPageInt(), 10); Page<TodoResponseDTO> todoPage = todoService.todoSearchList(principalDetails.getUser(), pageMaker, pageable); pageMaker.setTotalCount(todoPage.getTotalElements()); List<TodoResponseDTO> todosWithLinks = todoPage.getContent().stream().map(todo -> addTodoLinks(todo, principalDetails)).collect(Collectors.toList()); CollectionModel<TodoResponseDTO> collectionModel = CollectionModel.of(todosWithLinks); collectionModel.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiRestfullTodoController.class).getAllTodos(principalDetails, pageMaker)).withSelfRel()); return ResponseEntity.ok() .body(ResponseDTO.builder() .code(1) .message("success") .data(Map.of( "todos", collectionModel, "pageMaker", pageMaker )) .build()); } /** // /api/restfull/todos/${todoId} * 2. GET /api/restfull/todos/{todoId} * 개별 Todo 조회 */ @Operation(summary = "개별 Todo 조회", description = "특정 사용자의 특정 Todo를 조회합니다.") @GetMapping("/{todoId}") public ResponseEntity<?> getTodoById(@AuthenticationPrincipal PrincipalDetails principalDetails , @PathVariable Long todoId) { log.info("개별 Todo 조회 {} :{}", principalDetails.getUser().getId(), todoId); TodoResponseDTO todo = todoService.getTodoById(principalDetails.getUser().getId(), todoId); addTodoLinks(todo, principalDetails); return ResponseEntity.ok(todo); } /** * 3. POST /api/restfull/todos * Todo 생성 */ @Operation(summary = "Todo 생성", description = "특정 사용자에게 새로운 Todo를 생성합니다.") @PostMapping public ResponseEntity<?> createTodo( @AuthenticationPrincipal PrincipalDetails principalDetails , @Valid @RequestBody TodoCreateDTO todoCreateDTO, BindingResult bindingResult) throws Exception { log.info("===============> Todo 생성 : {}",todoCreateDTO.toString()); if (bindingResult.hasErrors()) { throw new MethodArgumentNotValidException(null, bindingResult); } TodoResponseDTO createdTodo = todoService.createTodo(principalDetails.getUser().getId(), todoCreateDTO); addTodoLinks(createdTodo, principalDetails); URI location = WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiRestfullTodoController.class).getTodoById(principalDetails, createdTodo.getId())).toUri(); return ResponseEntity.created(location) .body(ResponseDTO.builder() .code(1) .message("Todo created successfully") .data(createdTodo) .build()); } /** * 4. PUT /api/restfull/todos/{todoId} * Todo 수정 */ @Operation(summary = "Todo 수정", description = "특정 사용자의 특정 Todo를 수정합니다.") @PutMapping("/{todoId}") public ResponseEntity<?> updateTodo( @AuthenticationPrincipal PrincipalDetails principalDetails , @PathVariable Long todoId, @Valid @RequestBody TodoUpdateDTO todoUpdateDTO, BindingResult bindingResult) throws Exception { if (bindingResult.hasErrors()) { throw new MethodArgumentNotValidException(null, bindingResult); } TodoResponseDTO updatedTodo = todoService.updateTodo(principalDetails.getUser().getId(), todoId, todoUpdateDTO); addTodoLinks(updatedTodo, principalDetails); return ResponseEntity.ok(ResponseDTO.builder() .code(1) .message("Todo updated successfully") .data(updatedTodo) .build()); } /** * 5. DELETE /api/restfull/todos/{todoId} * Todo 삭제 */ @Operation(summary = "Todo 삭제", description = "특정 사용자의 특정 Todo를 삭제합니다.") @DeleteMapping("/{todoId}") public ResponseEntity<?> deleteTodo(@AuthenticationPrincipal PrincipalDetails principalDetails ,@PathVariable Long todoId) throws Exception{ todoService.deleteTodo(principalDetails.getUser().getId(), todoId); CollectionModel<?> collectionModel = CollectionModel.empty(); collectionModel.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(ApiRestfullTodoController.class) .getAllTodos(principalDetails, new PageMaker())).withRel("all-todos")); ResponseDTO<?> response = ResponseDTO.builder() .code(1) .message("Todo deleted successfully") .data(collectionModel) .build(); return ResponseEntity.ok(response); } }
2)UserService
package net.macaronics.springboot.webapp.service; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.dto.role.DeleteRoleRequest; import net.macaronics.springboot.webapp.dto.user.UserDTO; import net.macaronics.springboot.webapp.dto.user.UserLoginFormDTO; import net.macaronics.springboot.webapp.dto.user.UserResponse; import net.macaronics.springboot.webapp.dto.user.UserUpdateFormDTO; import net.macaronics.springboot.webapp.entity.Role; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.enums.RoleType; import net.macaronics.springboot.webapp.exception.ResourceNotFoundException; import net.macaronics.springboot.webapp.repository.RoleRepository; import net.macaronics.springboot.webapp.repository.TodoRepository; import net.macaronics.springboot.webapp.repository.UserRepository; @Service @RequiredArgsConstructor @Transactional @Slf4j public class UserService { private final UserRepository userRepository; // User 정보를 관리하는 Repository private final PasswordEncoder passwordEncoder; // 비밀번호를 암호화하기 위한 PasswordEncoder private final TodoRepository todoRepository; private final RoleRepository roleRepository; /** * 사용자를 저장하는 메서드 * @param user * @return */ public User saveUser(User user) { // 비밀번호를 암호화하여 저장 user.setPassword(passwordEncoder.encode(user.getPassword())); User savedUser=userRepository.save(user); // 사용자 정보 저장 return savedUser; } /** * 사용자 이름으로 사용자 정보를 조회하는 메서드 * @param username * @return */ @Transactional(readOnly = true) public Optional<User> findByUsername(String username) { return userRepository.findByUsername(username); // 사용자 이름으로 조회 후 반환 } /** * 이메일 존재 여부 확인 * @param email * @return */ @Transactional(readOnly = true) public boolean existsByEmail(String email) { return userRepository.existsByEmail(email); } /** * 사유저 목록 불러오기 * @param pageable * @return */ @Transactional(readOnly = true) public Page<UserResponse> userListAll(PageRequest pageable) { Page<UserResponse> userList = userRepository.findAll(pageable).map(UserResponse::of); return userList; } /** * 사용자를 찾는 메서드 * @param id * @return */ @Transactional(readOnly = true) public UserResponse getUserById(Long id) { User user= userRepository.findById(id).orElseThrow(()->new ResourceNotFoundException(id+" 을 찾을 수 없습니다.")); return UserResponse.of(user); } /** * 사용자 삭제 * @param id */ @Transactional public void deleteUser(Long id) { User user= userRepository.findById(id).orElseThrow(()->new ResourceNotFoundException(id+" 을 사용자를 찾을 수 없습니다.")); todoRepository.deleteByUserId(user.getId()); userRepository.delete(user); } /** * 더티체킹 업데이트 * @param id * @param updateFormDTO * @return */ @Transactional public UserResponse updateUser(Long id, UserUpdateFormDTO updateFormDTO) { User user=userRepository.findById(id).orElseThrow(()->new ResourceNotFoundException(id+" 을 사용자를 찾을 수 없습니다.")); //더티 체킹 User updated=User.updateUser(user, updateFormDTO,roleRepository); return UserResponse.of(updated); } /** * 로그인 처리 * @param userLoginFormDTO * @return */ @Transactional(readOnly = true) public User loginProcess(@Valid UserLoginFormDTO userLoginFormDTO) { User user= userRepository.findByUsername(userLoginFormDTO.getUsername()).orElse(null); if(user!=null) { if(passwordEncoder.matches(userLoginFormDTO.getPassword(), user.getPassword())) { //비밀번호 일치 return user; } } return null; } /** * 권한 추가 * @param user * @param roleType */ public void addSupportRoleToUser(Long userId, RoleType roleType) { // RoleType.SUPPORT 권한을 RoleRepository에서 가져오기 Role roleData = roleRepository.findByName(roleType).orElseThrow(() -> new IllegalArgumentException("RoleType not found")); User user=userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("User not found")); //사용자가 이미 해당 권한을 가지고 있는지 확인 boolean hasRole = user.getRoles().stream().anyMatch(role -> role.getId().equals(roleData.getId())); /// 권한이 없을 경우에만 추가 if (!hasRole) { user.addRole(roleData); userRepository.save(user); } } /** * 권한 정보 불러오기 * @param userId * @return */ @Transactional(readOnly = true) public List<String> findByRoleOrderByNameDesc(Long userId) { User user=userRepository.findById(userId).orElseThrow(()->new IllegalArgumentException("User not found")); // 역할 이름만 추출하여 내림차순으로 정렬 후 List<String>으로 수집 List<String> sortedRoleNames = user.getRoles().stream().map(role->role.getName().toString()).sorted((name1, name2) -> name2.compareTo(name1)) .collect(Collectors.toList()); return sortedRoleNames; } /** * 권한 삭제 * @param request */ public void deleteRole(DeleteRoleRequest request) { Role roleData = roleRepository.findByName(request.getRole()).orElseThrow(() -> new IllegalArgumentException("RoleType not found")); User user=userRepository.findById(request.getUserId()).orElseThrow(() -> new IllegalArgumentException("User not found")); user.removeRole(roleData); userRepository.save(user); } /** * 유저 목록 * @param pageable * @return */ @Transactional(readOnly = true) public Page<UserDTO> listAllUsers(PageRequest pageable) { Page<User> userList =userRepository.findAll(pageable); return userList.map(UserDTO::of); } public boolean isEmailInUse(String email) { return userRepository.existsByEmail(email); } }
3)UserMapper
package net.macaronics.springboot.webapp.mapper; import java.util.HashSet; import java.util.Set; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import net.macaronics.springboot.webapp.dto.user.UserRegisterFormDTO; import net.macaronics.springboot.webapp.entity.Role; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.enums.RoleType; import net.macaronics.springboot.webapp.service.RoleService; @Mapper(componentModel = "spring", uses= {RoleService.class}) public interface UserMapper { /** * roles는 매퍼에서 직접 매핑하지 않음 * @param registerFormDTO * @param roleService * @return */ @Mapping(target="roles", expression = "java(mapRoles(registerFormDTO.getRole(),roleService))") User ofUser(UserRegisterFormDTO registerFormDTO, RoleService roleService); /** * roles를 별도로 설정하거나 필요한 경우 수동 처리 * @param roleType * @param roleService * @return */ default Set<Role> mapRoles(RoleType roleType, RoleService roleService){ //새로운 Role을 저장할 Set 생성 Set<Role> newRole =new HashSet<>(); //RoleType에 해당하는 Role을 가져와 추가 roleService.findByName(roleType).ifPresentOrElse( role->{ newRole.add(role); }, ()->{ newRole.add(new Role(RoleType.USER)); } ); return newRole; } }
4) RoleService
package net.macaronics.springboot.webapp.service; import java.util.List; import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.dto.role.RoleResponseDTO; import net.macaronics.springboot.webapp.entity.Role; import net.macaronics.springboot.webapp.enums.RoleType; import net.macaronics.springboot.webapp.repository.RoleRepository; @Service @Transactional @RequiredArgsConstructor @Slf4j public class RoleService { private final RoleRepository roleRepository; public Optional<Role> findByName(RoleType roleType){ return roleRepository.findByName(roleType); } public List<RoleResponseDTO> findRoleList() { List<Role> roles =roleRepository.findAllOrderByNameDesc(); return RoleResponseDTO.toRoleList(roles); } }
5) UserRepository
package net.macaronics.springboot.webapp.repository; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import net.macaronics.springboot.webapp.entity.User; public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByUsername(String username); boolean existsByEmail(String email); @Query("SELECT u FROM User u JOIN FETCH u.roles r WHERE u.id = :userId ORDER BY r.name DESC") Optional<User> findByRoleOrderByNameDesc(Long userId); }
6) 참고 :) JPA QueryDSL 사용한 Todo Repository 페이징처리 cusotm 설정
6-1)TodoRepository
package net.macaronics.springboot.webapp.repository; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.querydsl.QuerydslPredicateExecutor; import net.macaronics.springboot.webapp.entity.Todo; import net.macaronics.springboot.webapp.entity.User; public interface TodoRepository extends JpaRepository<Todo, Long>, QuerydslPredicateExecutor<Todo>, TodoRepositoryCustom { Todo findByIdAndUser(Long id, User user); void deleteByUserId(Long id); Page<Todo> findByUserIdOrderByIdDesc(Long userId, Pageable pageable); Optional<Todo> findByIdAndUserId(Long todoId, Long userId); }
6-2)TodoRepositoryCustom
package net.macaronics.springboot.webapp.repository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import net.macaronics.springboot.webapp.dto.todo.TodoResponseDTO; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.utils.PageMaker; public interface TodoRepositoryCustom { Page<TodoResponseDTO> todoSearchList(User user, PageMaker pageMaker, Pageable pageable); }
6-3)TodoRepositoryCustomImpl 검색처리
package net.macaronics.springboot.webapp.repository; import static net.macaronics.springboot.webapp.entity.QTodo.todo; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.support.PageableExecutionUtils; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.dto.todo.TodoResponseDTO; import net.macaronics.springboot.webapp.entity.QTodo; import net.macaronics.springboot.webapp.entity.QUser; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.utils.PageMaker; @RequiredArgsConstructor @Slf4j public class TodoRepositoryCustomImpl implements TodoRepositoryCustom { private final JPAQueryFactory queryFactory; /** * 할일 완료 여부 조건을 반환합니다. * @param done "true" 또는 "false" 문자열. null이면 조건 없음. * @return 완료 여부 조건 */ private BooleanExpression doneStatusEq(String done) { if (done == null || done.isBlank()) { return null; // null을 반환하면 QueryDSL에서 무시됨 } return done.equalsIgnoreCase("true") ? todo.done.eq(Boolean.TRUE) : done.equalsIgnoreCase("false") ? todo.done.eq(Boolean.FALSE) : null; } /** * 검색 조건을 반환합니다. * @param searchType 검색 유형 ("title", "description", "all") * @param searchKeyword 검색 키워드 * @return 검색 조건 */ private BooleanExpression searchByLike(String searchType, String searchKeyword) { if (searchType == null || searchType.isBlank() || searchKeyword == null || searchKeyword.isBlank()) { return null; } String trimmedKeyword = searchKeyword.trim(); return switch (searchType) { case "title" -> todo.title.like("%" + trimmedKeyword + "%"); // 제목 검색 case "description" -> todo.description.like("%" + trimmedKeyword + "%"); // 설명 검색 case "all" -> todo.title.like("%" + trimmedKeyword + "%") .or(todo.description.like("%" + trimmedKeyword + "%")); // 제목 또는 설명 검색 default -> null; // 유효하지 않은 검색 유형 }; } /** * 정렬 조건을 반환합니다. * @param pageMaker 페이지 정보를 포함한 객체 * @param todo QTodo 객체 * @return 정렬 조건 */ private OrderSpecifier<?> getOrderSpecifier(PageMaker pageMaker, QTodo todo) { if ("targetDate".equals(pageMaker.getOrderBy())) { return todo.targetDate.asc(); // 마감일 기준 내림차순 정렬 } return todo.id.desc(); // 기본 정렬: ID 내림차순 } /** * Todo 항목 검색 처리 */ @Override public Page<TodoResponseDTO> todoSearchList(User user, PageMaker pageMaker, Pageable pageable) { QTodo todo = QTodo.todo; QUser qUser = QUser.user; log.info(" TodoRepositoryCustomImpl - todoSearchList = {} : {}", pageable.getOffset(), pageable.getPageSize()); // 데이터 목록 조회 List<TodoResponseDTO> content = queryFactory.select(Projections.constructor( TodoResponseDTO.class, todo.id, qUser.id, qUser.username, todo.title, todo.description, todo.targetDate, todo.done, Expressions.constant(0L) // num 필드 초기값 )) .from(todo) .join(todo.user, qUser) // Todo와 User를 조인 .where( qUser.eq(user), // User 조건 searchByLike(pageMaker.getSearchType(), pageMaker.getSearchKeyword()), // 검색 조건 doneStatusEq(pageMaker.getDone()) // 완료 여부 조건 ) .orderBy(getOrderSpecifier(pageMaker, todo)) // 정렬 조건 .offset(pageable.getOffset()) // 페이징 시작 인덱스 .limit(pageable.getPageSize()) // 페이지 크기 제한 .fetch(); // 번호를 매기는 로직 long startIndex = pageable.getOffset() + 1; // 시작 인덱스 설정 for (int i = 0; i < content.size(); i++) { content.get(i).setNum(startIndex + i); // 각 항목에 번호 부여 } // 총 레코드 수 조회 JPAQuery<Long> countQuery = queryFactory.select(todo.count()) .from(todo) .join(todo.user, qUser) // Todo와 User 조인 .where( qUser.eq(user), // User 조건 searchByLike(pageMaker.getSearchType(), pageMaker.getSearchKeyword()), // 검색 조건 doneStatusEq(pageMaker.getDone()) // 완료 여부 조건 ); // PageableExecutionUtils로 페이지 객체 반환 return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } }
7) 페이징 Util PageMaker
package net.macaronics.springboot.webapp.utils; import lombok.Data; import lombok.ToString; import org.springframework.data.domain.Page; import org.springframework.util.StringUtils; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; //PageMaker @Data @ToString public class PageMaker { private Integer page; private int pageSize=10; private int pageStart; private long totalCount; //전체 개수 private int startPage; // 시작 페이지 private int endPage; // 끝페이지 private boolean prev; // 이전 여부 private boolean next; // 다음 여부 private boolean last; //마지막 페이지 여부 private int displayPageNum=5; //하단 페이징 << 1 2 3 4 5 6 7 8 9 10 >> private int tempEndPage; private String searchKeyword; private String searchType; private String done; private String orderBy; Page<?> pageObject; public int getPageInt(){ if(this.getPage() == null || this.getPage()<0) { return 0; } //JPA 페이지 0부터 시작 return this.getPage()-1; } private void calcData(){ endPage=(int)(Math.ceil(page / (double)displayPageNum)*displayPageNum); startPage=(endPage - displayPageNum) +1; if(endPage>=tempEndPage)endPage=tempEndPage; prev =startPage ==1 ? false :true; next =endPage *pageSize >=totalCount ? false :true; } /** * * * @param pageObject Page<?> 반환된 리스트값 * @param pageInt 현재 페이지 * @param pageSize 페이지사이즈 * @param displayPageNum 하단 페이징 기본 10설정 << 1 2 3 4 5 6 7 8 9 10 >> * @param pageUrl url 주소 * @param type ajax, href = 자바스크립트 , 링크 * @return */ public String pageObject(Page<?> pageObject, Integer pageInt , Integer pageSize, Integer displayPageNum , String pageUrl, String type) { this.pageObject = pageObject; this.page=pageInt==0? 1:pageInt+1; if(pageSize!=null){ this.pageSize=pageSize; } this.tempEndPage=pageObject.getTotalPages(); if(displayPageNum!=null){ this.displayPageNum=displayPageNum; }else this.displayPageNum=10; this.totalCount=Math.toIntExact(pageObject.getTotalElements()); calcData(); if(StringUtils.hasText(pageUrl)){ if(type.equalsIgnoreCase("JS")){ return paginationJs(pageUrl); }else if(type.equalsIgnoreCase("HREF")){ return paginationHref(pageUrl); }else if(type.equalsIgnoreCase("PATHVARIABLE")){ return paginationPathVariable(pageUrl); } }return null; } /** * javascript page 버튼 클릭 반환 * @param url * @return */ public String paginationJs(String url) { StringBuffer sBuffer = new StringBuffer(); sBuffer.append("<ul class='pagination justify-content-center'>"); if (prev) { sBuffer.append("<li class='page-item' ><a class='page-link' onclick='javascript:page(0)' >처음</a></li>"); } if (prev) { sBuffer.append("<li class='page-item'><a class='page-link' onclick='javascript:page("+ (startPage - 2)+")'; >«</a></li>"); } String active = ""; for (int i = startPage; i <= endPage; i++) { if (page==i) { active = "class='page-item active'"; } else { active = "class='page-item'"; } sBuffer.append("<li " + active + " >"); sBuffer.append("<a class='page-link' onclick='javascript:page("+ (i-1) +")'; >" + i + "</a></li>"); sBuffer.append("</li>"); } if (next && endPage > 0 && endPage <= tempEndPage) { sBuffer.append("<li class='page-item'><a class='page-link' onclick='javascript:page("+ (endPage) +")'; >»</a></li>"); } if (next && endPage > 0 && !isLast()) { sBuffer.append("<li class='page-item'> <a class='page-link' onclick='javascript:page("+ (tempEndPage-1) +")'; >마지막</a></li>"); } sBuffer.append("</ul>"); return sBuffer.toString(); } public String makeSearch(int page){ UriComponents uriComponents= UriComponentsBuilder.newInstance() .queryParam("searchKeyword", searchKeyword) .queryParam("page", page) .build(); return uriComponents.toUriString(); } /** * 링크 파리미터 반환 * @param url * @return */ public String paginationHref(String url){ StringBuffer sBuffer=new StringBuffer(); sBuffer.append("<ul class='pagination justify-content-center'>"); if(prev){ sBuffer.append("<li class='page-item'><a class='page-link' href='"+url+makeSearch(1)+"'>처음</a></li>"); } if(prev){ sBuffer.append("<li class='page-item'><a class='page-link' href='"+url+makeSearch(startPage-2)+"'>«</a></li>"); } String active=""; for(int i=startPage; i <=endPage; i++){ if (page==i) { active = "class='page-item active'"; sBuffer.append("<li " +active+" > "); sBuffer.append("<a class='page-link' href='javascript:void(0)'>"+i+"</a></li>"); sBuffer.append("</li>"); } else { active = "class='page-item'"; sBuffer.append("<li " +active+" > "); sBuffer.append("<a class='page-link' href='"+url+makeSearch(i-1)+"'>"+i+"</a></li>"); sBuffer.append("</li>"); } } if(next && endPage>0 && endPage <= tempEndPage){ sBuffer.append("<li class='page-item'><a class='page-link' href='"+url+makeSearch(endPage)+"'>»</a></li>"); } if (next && endPage > 0 && !isLast()) { sBuffer.append("<li class='page-item'><a class='page-link' href='"+url+makeSearch(tempEndPage-1)+"'>마지막</a></li>"); } sBuffer.append("</ul>"); return sBuffer.toString(); } public String paginationPathVariable(String url){ StringBuffer sBuffer=new StringBuffer(); sBuffer.append("<ul class='pagination justify-content-center'>"); if(prev){ sBuffer.append("<li class='page-item'><a class='page-link' href='"+url+(1)+"'>처음</a></li>"); } if(prev){ sBuffer.append("<li class='page-item'><a class='page-link' href='"+url+(startPage-2)+"'>«</a></li>"); } String active=""; for(int i=startPage; i <=endPage; i++){ if (page==i) { active = "class='page-item active'"; sBuffer.append("<li " +active+" > "); sBuffer.append("<a class='page-link' href='javascript:void(0)'>"+i+"</a></li>"); sBuffer.append("</li>"); } else { active = "class='page-item'"; sBuffer.append("<li " +active+" > "); sBuffer.append("<a class='page-link' href='"+url+(i-1)+"'>"+i+"</a></li>"); sBuffer.append("</li>"); } } if(next && endPage>0 && endPage <= tempEndPage){ sBuffer.append("<li class='page-item'><a class='page-link' href='"+url+(endPage)+"'>»</a></li>"); } if (next && endPage > 0 && !isLast()) { sBuffer.append("<li class='page-item'><a class='page-link' href='"+url+(tempEndPage-1)+"'>마지막</a></li>"); } sBuffer.append("</ul>"); return sBuffer.toString(); } }
3.JWT 토큰처리 및 시큐리티 처리
1)SecurityConfiguration
package net.macaronics.springboot.webapp.config; import java.util.List; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.config.auth.filter.JwtAuthenticationFilter; import net.macaronics.springboot.webapp.enums.RoleType; import net.macaronics.springboot.webapp.exception.CustomAuthenticationEntryPoint; @Configuration @Slf4j @RequiredArgsConstructor @EnableWebSecurity public class SecurityConfiguration { private final JwtAuthenticationFilter jwtAuthenticationFilter; @Value("${CORS_MAX_AGE_SECS}") private long CORS_MAX_AGE_SECS; @Bean public JPAQueryFactory queryFactory(EntityManager em){ return new JPAQueryFactory(em); } // 비밀번호 암호화를 위한 PasswordEncoder 설정 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of("http://localhost:3000")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); configuration.setMaxAge(CORS_MAX_AGE_SECS); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { //1.csrf 사용하지 않을 경우 다음과 같이 설정 http.csrf(AbstractHttpConfigurer::disable); http.cors(cors -> cors.configurationSource(corsConfigurationSource()) ) ; // CORS 설정 활성화 http.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)); //다른 도메인 간에 프레임(Frame)을 허용하려면 X-Frame-Options 헤더를 비활성화(disable) //http.headers((headers) -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)); //3.jwt token 만 인증 처리 할경우 basic 인증을 disable 한다. 그러나 이 프로젝트는 세션+jwt 이라 disable 설정은 하지 않는다. //httpSecurity.httpBasic(AbstractHttpConfigurer::disable); http.formLogin(login -> login.loginPage("/user/login").defaultSuccessUrl("/", true).usernameParameter("username") .failureUrl("/user/login?error=true")) .logout(logout -> logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout")).logoutSuccessUrl("/")) .exceptionHandling(exception -> exception.authenticationEntryPoint(new Http403ForbiddenEntryPoint())); // 세션방식 커스텀 403 에러 핸들러 설정 http.exceptionHandling(exception -> exception .accessDeniedHandler(customAccessDeniedHandler()) .authenticationEntryPoint((request, response, authException) -> { response.sendRedirect("/user/login?error=403"); }) ); http.authorizeHttpRequests(request -> request .requestMatchers("/webjars/**", "/css/**", "/js/**", "/img/**", "/images/**", "/favicon.ico", "/error/**").permitAll() //세션방식 .requestMatchers("/user/login", "/user/register", "/h2-console/**", "/logout").permitAll() //JWT 일반 접속 설정 .requestMatchers("/", "/api/users/**", "/api/users/signup", "/api/users/login").permitAll() .requestMatchers("/", "/api/restfull/users/**", "/api/restfull/users/signup", "/api/restfull/users/login").permitAll() .requestMatchers("/api/users/**").permitAll() .requestMatchers("/role/**").hasAnyAuthority(RoleType.ADMIN.toString(),RoleType.MANAGER.toString(), RoleType.SUPPORT.toString(),RoleType.USER.toString()) .requestMatchers("/menus/**").hasAnyAuthority(RoleType.ADMIN.toString(),RoleType.MANAGER.toString(), RoleType.SUPPORT.toString(),RoleType.USER.toString()) .requestMatchers( "/test/**", "/actuator/**", "/explorer/**", "/api-docs", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**").permitAll() .anyRequest().authenticated()); //api 페이지만 JWT 적용 - 필터 설정(jwtAuthenticationFilter 에서 shouldNotFilter 메서드로 세션 페이지는 필터를 제외 시켰다.) http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(exceptionConfig->exceptionConfig.authenticationEntryPoint(new CustomAuthenticationEntryPoint()) ); return http.build(); } @Bean public AccessDeniedHandler customAccessDeniedHandler() { return (request, response, accessDeniedException) -> { log.info("403 권한 오류 발생, 로그인 페이지로 리다이렉트"); response.sendRedirect("/user/login?error=403"); }; } }
2)PrincipalDetails
package net.macaronics.springboot.webapp.config.auth; import java.io.Serial; import java.util.Collection; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import lombok.Getter; import net.macaronics.springboot.webapp.entity.Role; import net.macaronics.springboot.webapp.entity.User; @Getter public class PrincipalDetails implements UserDetails { @Serial private static final long serialVersionUID = 1L; private final User user; private final Long id; private final String username; private final Set<Role> roles; public PrincipalDetails(User user) { this.user = user; this.id = user.getId(); this.username = user.getUsername(); this.roles = user.getRoles(); // Set<Role>을 가져옴 } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return roles.stream() .map(role -> new SimpleGrantedAuthority(role.getName().name())) // RoleType을 가져와서 GrantedAuthority로 변환 .collect(Collectors.toList()); } public List<String> getRoleNames() { return roles.stream() .map(role -> role.getName().name()) // Role 엔티티에서 이름을 가져오는 방법에 따라 다름 .collect(Collectors.toList()); } /** * 사용자를 인증하는 데 사용된 암호를 반환합니다. */ @Override public String getPassword() { return user.getPassword(); } /** * 사용자를 인증하는 데 사용된 사용자 이름을 반환합니다. null을 반환할 수 없습니다. */ @Override public String getUsername() { return user.getUsername(); } /** * 사용자의 계정이 만료되었는지 여부를 나타냅니다. 만료된 계정은 인증할 수 없습니다. */ @Override public boolean isAccountNonExpired() { return true; } /** * 사용자가 잠겨 있는지 또는 잠금 해제되어 있는지 나타냅니다. 잠긴 사용자는 인증할 수 없습니다. */ @Override public boolean isAccountNonLocked() { return true; } /** * 사용자의 자격 증명(암호)이 만료되었는지 여부를 나타냅니다. 만료된 자격 증명은 인증을 방지합니다. */ @Override public boolean isCredentialsNonExpired() { return true; } /** * 사용자가 활성화되었는지 비활성화되었는지 여부를 나타냅니다. 비활성화된 사용자는 인증할 수 없습니다. */ @Override public boolean isEnabled() { // 우리 사이트 1년동안 회원이 로그인을 안하면!! 휴먼 계정으로 하기로 함. // 현재시간-로긴시간=>1년을 초과하면 return false; return true; } }
3)PrincipalDetailsService
package net.macaronics.springboot.webapp.config.auth; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.repository.UserRepository; @RequiredArgsConstructor @Service public class PrincipalDetailsService implements UserDetailsService { private final UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username).orElseThrow(()->new IllegalArgumentException("아이디가 존재 하지 않습니다.") ); if (user == null) { throw new UsernameNotFoundException(username); } return new PrincipalDetails(user); } @Transactional(readOnly = true) public UserDetails loadUserById(String id) throws UsernameNotFoundException { User user = userRepository.findById(Long.valueOf(id)) .orElseThrow(() -> new UsernameNotFoundException("User not found with id: " + id)); return new PrincipalDetails(user); } }
4)TokenDTO
package net.macaronics.springboot.webapp.config.auth.dto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder @ToString public class TokenDTO { private String grantType; private String accessToken; private String refreshToken; private Long accessTokenExpires; public static TokenDTO of(String accessToken, String refreshToken , Long accessTokenExpires) { return TokenDTO.builder() .grantType("Bearer ") .accessToken(accessToken) .refreshToken(refreshToken) .accessTokenExpires(accessTokenExpires) .build(); } }
5)JwtAuthenticationFilter
package net.macaronics.springboot.webapp.config.auth.filter; import java.io.IOException; import java.io.PrintWriter; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.Claims; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.config.auth.PrincipalDetails; import net.macaronics.springboot.webapp.config.auth.PrincipalDetailsService; import net.macaronics.springboot.webapp.config.auth.jwt.JwtTokenProviderService; import net.macaronics.springboot.webapp.dto.ResponseDTO; import net.macaronics.springboot.webapp.exception.CustomAuthenticationException; import org.springframework.security.core.context.SecurityContext; @Component @RequiredArgsConstructor @Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { private final PrincipalDetailsService principalDetailsService; private final JwtTokenProviderService tokenProvider; // 필터링을 제외할 URL 패턴 정의 private static final String[] EXCLUDED_URL_PATTERNS = { "/", "/static/**", "/favicon.ico", "/css/**", "/js/**", "/images/**", "/main", "/members", "/members/**", "/admin", "/admin/**", "/thymeleaf/**", "/api/auth/signup", "/api/auth/signin", "/api/auth/reissue", "/api/auth/logout","/api/auth/memberInfo", "/api/users/signup", "/api/users/login", "/api/users/refresh", "/api/restfull/users/signup", "/api/restfull/users/login", "/api/restfull/users/refresh", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/todo/**", "/role/**" }; /** * 특정 URL이 필터링 제외 대상인지 확인합니다. */ @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { return exclusionPages(request); } public static boolean exclusionPages(HttpServletRequest request){ String requestUrl = request.getRequestURI(); for (String pattern : EXCLUDED_URL_PATTERNS) { if (!requestUrl.startsWith("/api")) { //api 시작 안되면 통과 //log.info("//api 시작되는 것은 통과 :{}", requestUrl); return true; }else if (pattern.contains("**")) { // URL 패턴에 **이 있으면, ** 앞부분만 비교하여 제외합니다. String patternBeforeDoubleStar = pattern.substring(0, pattern.indexOf("**")); if (requestUrl.startsWith(patternBeforeDoubleStar)) { return true; } } else { // URL 패턴에 **이 없으면 같음을 비교합니다. if (requestUrl.equals(pattern)) { return true; } } } return false; // 제외할 URL 패턴이 없는 경우 false를 반환합니다. } /** * 요청을 필터링하여 JWT 토큰을 검증하고 사용자 인증 정보를 설정합니다. */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { log.info("JWT 필터 실행: 요청 URI - {}", request.getRequestURI()); try { // 요청에서 JWT 토큰을 추출 String token = parseBearerToken(request); //토큰 검사하기 . JWT 이므로 인가 서버에 요청하지 않고도 검증 가능. userId 가져오기. 위조된 경우 예외 처리된다. if(token!=null && !token.equalsIgnoreCase("null")){ String userId =null; try{ userId = tokenProvider.validateAndGetUserId(token); }catch(Exception e){ if(e.getMessage().contains("expired")) { log.info("** validateAndGetUserId 접근 토큰시간이 만료되었습니다. : {}",e.getMessage()); throw new CustomAuthenticationException("접근 토큰시간이 만료되었습니다.","ACCESS_TOKEN_EXPIRED"); }else { log.info("** validateAndGetUserId 유효하지 않는 토큰 : {}",e.getMessage()); throw new CustomAuthenticationException("유효하지 않는 토큰.", "INVALID_TOKEN"); } } Claims claims=null; try { claims=tokenProvider.validateAndGetUserClaims(token); }catch (Exception e) { if(e.getMessage().contains("expired")) { log.info("** validateAndGetUserId 접근 토큰시간이 만료되었습니다. : {}",e.getMessage()); throw new CustomAuthenticationException("접근 토큰시간이 만료되었습니다.","ACCESS_TOKEN_EXPIRED"); }else { log.info("** validateAndGetUserId 유효하지 않는 토큰 : {}",e.getMessage()); throw new CustomAuthenticationException("유효하지 않는 토큰.", "INVALID_TOKEN"); } } //JWT 토큰로그인 인증에서는 API , oauth2 는 loadUserApiByUsername 커스텀으로 생성한 메서드로 id 값으로 인증처리 PrincipalDetails principalDetails = (PrincipalDetails) principalDetailsService.loadUserById(userId); if(principalDetails!=null){ log.info("필터 ===principalDetails {}", principalDetails.getUser().getRoles().toString()); setAuthentication( request, principalDetails); filterChain.doFilter(request, response); }else throw new CustomAuthenticationException("해당하는 유저가 없습니다.", "USER_NOT_FOUND"); }else throw new CustomAuthenticationException("유효하지 않은 토큰 입니다.","INVALID_TOKEN" ); } catch (CustomAuthenticationException e) { writeErrorResponse(response, e.getMessage(), e.getErrorCode()); } catch (Exception e) { log.info("JWT 기타 예외 처리 2. {}",e.getMessage()); writeErrorResponse(response, "기타 서버 에러", "INTERNAL_SERVER_ERROR"); } } /** * SecurityContext에 인증 정보를 설정합니다. */ private void setAuthentication(HttpServletRequest request, PrincipalDetails principalDetails) { // 인증된 사용자 정보를 토대로 토큰 생성 및 설정 AbstractAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( principalDetails, null, principalDetails.getAuthorities() ); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // SecurityContext에 인증 정보 저장 SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); securityContext.setAuthentication(authentication); SecurityContextHolder.setContext(securityContext); } /** * 요청 헤더에서 Bearer 토큰을 파싱합니다. */ public static String parseBearerToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); return (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) ? bearerToken.substring(7) : null; } /** * JSON 형식으로 에러 응답을 작성하여 클라이언트로 전송합니다. */ private void writeErrorResponse(HttpServletResponse response, String message, String errorCode) throws IOException { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.setCharacterEncoding("utf-8"); response.setContentType("application/json"); // 에러 응답을 JSON 형식으로 생성 ResponseDTO<Object> errorResponse = ResponseDTO.builder() .code(-1) .message(message) .errorCode(errorCode) .build(); ObjectMapper objectMapper = new ObjectMapper(); try (PrintWriter writer = response.getWriter()) { writer.print(objectMapper.writeValueAsString(errorResponse)); } } }
6)JwtTokenProviderService
package net.macaronics.springboot.webapp.config.auth.jwt; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Date; import java.util.Locale; import java.util.NoSuchElementException; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import io.jsonwebtoken.Claims; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.config.auth.PrincipalDetails; import net.macaronics.springboot.webapp.config.auth.dto.TokenDTO; import net.macaronics.springboot.webapp.config.auth.repository.LogoutAccessTokenRedisRepository; import net.macaronics.springboot.webapp.config.auth.repository.RefreshTokenRedisRepository; import net.macaronics.springboot.webapp.dto.user.UserDTO; import net.macaronics.springboot.webapp.entity.RefreshToken; import net.macaronics.springboot.webapp.entity.User; import net.macaronics.springboot.webapp.exception.CustomAuthenticationException; import net.macaronics.springboot.webapp.repository.UserRepository; /** * 추가된 라이브러리를 사용해서 JWT를 생성하고 검증하는 컴포넌트 */ @Slf4j @Service @RequiredArgsConstructor @Transactional public class JwtTokenProviderService { private final RefreshTokenRedisRepository refreshTokenRedisRepository; private final UserRepository userRepository; private final LogoutAccessTokenRedisRepository logoutAccessTokenRedisRepository; private final JwtTokenUtil jwtTokenUtil; @Value("${spring.jwt.token.access-expiration-time}") private long accessExpirationTime; @Value("${spring.jwt.token.refresh-expiration-time}") private long refreshExpirationTime; /** * 1. 로그인시 접근 토큰 생성 * @param user * @return */ public TokenDTO create(User user) { log.info("1.로그인시 접근토큰 생성: {}", user.getId()); long currentTimeMillis = System.currentTimeMillis(); // 현재 시간 Date expireDate = new Date(currentTimeMillis + accessExpirationTime); // 만료일 설정 long accessTokenExpires = currentTimeMillis + accessExpirationTime; // accessTokenExpires 값 계산 //1.접근 토큰 생성 //generateAccessToken ==>,doGenerateTokenDateInput String accessToken = jwtTokenUtil.doGenerateTokenDateInput(user, expireDate); //2.갱신토큰 생성후 redis 에 저장 RefreshToken refreshToken = saveRefreshToken(user); log.info("*********** 접근토큰 : {}" ,accessToken); log.info("*********** 갱신토큰 : {}",refreshToken); return TokenDTO.of(accessToken, refreshToken.getRefreshToken(), accessTokenExpires); } // Oauth2 를 위한 토큰 생성 public TokenDTO create(final Authentication authentication){ PrincipalDetails principal = (PrincipalDetails)authentication.getPrincipal(); return create(principal.getUser()); } /** * 갱신토큰 생성후 redis 에 저장 * @param userId * @return */ private RefreshToken saveRefreshToken(User user) { RefreshToken refreshToken = RefreshToken.createRefreshToken(user.getId(), jwtTokenUtil.generateRefreshToken(user, refreshExpirationTime), refreshExpirationTime); refreshTokenRedisRepository.save(refreshToken); return refreshToken; } /** * 2. access token + refresh token 재발급 처리 * @param userId * @return */ private TokenDTO reissueRefreshToken(User user) { long currentTimeMillis =System.currentTimeMillis(); Date expireDate = new Date(currentTimeMillis + accessExpirationTime); // 만료일 설정 long accessTokenExpires = currentTimeMillis + accessExpirationTime; // accessTokenExpires 값 계산 //access token + refresh token 재발급 //String accessToken = jwtTokenUtil.generateAccessToken(userId, accessExpirationTime); String accessToken = jwtTokenUtil.doGenerateTokenDateInput(user, expireDate); RefreshToken refreshToken = saveRefreshToken(user); return TokenDTO.of(accessToken, refreshToken.getRefreshToken(),accessTokenExpires); } /** * 3.토큰 재발행 * * * ★ * 현재 이 코드는 , 회원아이디 값을 키값을 설정 했기 때문에, 하나의 브라우저에서만 로그인 된다. * 정확이 말하면, 기존에 접속한 브라우저에서는 accessExpirationTime 만료시까지 유지 된다. * 즉, 새로운 브라우저로 로그인하면서 redis 에서 새로운 refresh token 값을 저장했기 때문이다. * * ★여러 브라우저에서 가능하도록 하려면, refresh token 을 키값을 설정하면 된다. * * * @return */ public UserDTO reissue(String refreshToken ) throws Exception{ //1.refreshToken 를 파싱해서 userId 값을 가져온다. String userId=validateAndGetUserId(refreshToken); log.info("1.refreshToken 를 파싱해서 userId 값을 가져온다. {}",userId); //2.Redis 저장된 토큰 정보를 가져온다. RefreshToken redisRefreshToken = refreshTokenRedisRepository.findById(Long.valueOf(userId)).orElseThrow(NoSuchElementException::new); log.info("2.Redis 저장된 토큰 정보를 가져온다. {}", redisRefreshToken.getRefreshToken()); //3.redis 에 저장된 토큰과 값과 파라미터의 갱신토크와 비교해서 같으면 갱신토큰 발급처리 if (refreshToken.equals(redisRefreshToken.getRefreshToken())) { User user = userRepository.findById(Long.parseLong(userId)).orElse(null); TokenDTO tokenDto = reissueRefreshToken(user); if(user!=null){ UserDTO userDTO = UserDTO.of(user); userDTO.setToken(tokenDto); return userDTO; }throw new CustomAuthenticationException("해당하는 유저가 없습니다.", "USER_NOT_FOUND"); } throw new CustomAuthenticationException("유효하지 않은 토큰 입니다.","INVALID_TOKEN" ); } /** * 토큰 확인 * @param token * @return */ public String validateAndGetUserId(String token) throws Exception{ return jwtTokenUtil.validateAndGetUserId(token); } public Claims validateAndGetUserClaims(String token) throws Exception{ return jwtTokenUtil.validateAndGetUserClaims(token); } public void logout(String refreshToken) throws Exception{ //1.refreshToken 를 파싱해서 userId 값을 가져온다. String userId=validateAndGetUserId(refreshToken); //2.redis 에서 삭제 처리 refreshTokenRedisRepository.deleteById(Long.valueOf(userId)); String logOutTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss(EEE)", Locale.ENGLISH)); //3.redis 에 로그아웃 날짜 저장 logoutAccessTokenRedisRepository.save(LogoutAccessToken.of(Long.valueOf(userId), refreshToken, System.currentTimeMillis(), logOutTime)); } /** * 접근 토큰으로 회원정보 가져오기 * @param accessToken * @return * @throws Exception */ public UserDTO getUser(String accessToken) throws Exception{ String userId=validateAndGetUserId(accessToken); User user= userRepository.findById(Long.valueOf(userId)).orElseThrow(EntityNotFoundException::new); return UserDTO.of(user); } }
7) JwtTokenUtil
package net.macaronics.springboot.webapp.config.auth.jwt; import java.util.Base64; import java.util.Date; import javax.crypto.SecretKey; import org.springframework.stereotype.Component; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; import net.macaronics.springboot.webapp.config.auth.PrincipalDetails; import net.macaronics.springboot.webapp.entity.User; @Component @Slf4j public class JwtTokenUtil { // 시크릿 키를 담는 변수 private SecretKey cachedSecretKey; /** //암호 생성 사이트 : https://ko. pw-gen.com/ $ echo '0-p7n#6s4l$ncdoui7+(^q(7^b^tt4@^i@6-q516f=aw-9%@fsdkj52423ksd9fs905235K$!$#49'|base64 => git bash 에서 base64 로 인코딩한 값 private static final String SECRET_KEY = "MC1wN24jNnM0bCRuY2RvdWk3KyhecSg3XmJedHQ0QF5pQDYtcTUxNmY9YXctOSVAZnNka2o1MjQyM2tzZDlmczkwNTIzNUskISQjNDkK"; */ private final String SECRET_KEY= "MC1wN24jNnM0bCRuY2RvdWk3KyhecSg3XmJedHQ0QF5pQDYtcTUxNmY9YXctOSVAZnNka2o1MjQyM2tzZDlmczkwNTIzNUskISQjNDkK"; //1.인증 키생성 public SecretKey getSecretKey() { String keyBase64Encoded = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes()); SecretKey secretKey1 = Keys.hmacShaKeyFor(keyBase64Encoded.getBytes()); if (cachedSecretKey == null) cachedSecretKey = secretKey1; return cachedSecretKey; } //2.토큰 정보 파싱 public Claims extractAllClaims(String token) { return Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); } //3.토큰을 이용하여 유저아이디값 가져오기 반환값 id public String getUsername(String token) { return String.valueOf(extractAllClaims(token).get("userId")); } /** * 4. 토큰 생성 * @param userId * @return */ private String doGenerateToken(User user, long expireTime) { Claims claims = Jwts.claims(); claims.put("userId", user.getId()); claims.put("roles", user.getRoles()); //기한 지금으로부터 1일로 설정 // Date expiryDate =Date.from(Instant.now().plus(1, ChronoUnit.DAYS)); //Date now = new Date(); now.getTime() 으로 하면 오류 Date expireDate = new Date(System.currentTimeMillis() + expireTime); //JWT Token 생성 return Jwts.builder() .setClaims(claims) .signWith(getSecretKey()) //토큰 서명 설정 //payload 에 들어갈 내용 .setSubject(String.valueOf(user.getId())) //sub .setIssuer("macaronics app") //iss .setIssuedAt(new Date()) //iat .setExpiration(expireDate) //exp .compact(); //문자열로 압축 } public String doGenerateTokenDateInput(User user, Date expireDate) { Claims claims = Jwts.claims(); claims.put("userId", user.getId()); claims.put("roles", user.getRoles()); //JWT Token 생성 return Jwts.builder() .setClaims(claims) .signWith(getSecretKey()) //토큰 서명 설정 //payload 에 들어갈 내용 .setSubject(String.valueOf(user.getId())) //sub .setIssuer("macaronics app") //iss .setIssuedAt(new Date()) //iat .setExpiration(expireDate) //exp .compact(); //문자열로 압축 } //5.접근 토큰 생성 public String generateAccessToken(User user, long expireTime) { return doGenerateToken(user, expireTime); } //6.갱신 토큰 public String generateRefreshToken(User user, long expireTime) { return doGenerateToken(user, expireTime); } //7.토큰 만료 여부 public Boolean isTokenExpired(String token) { Date expiration = extractAllClaims(token).getExpiration(); return expiration.before(new Date()); } //8.토큰 유효성 체크 public Boolean validateToken(String token, PrincipalDetails userDetails) { String userId = getUsername(token); return userId.equals(String.valueOf(userDetails.getId())) && !isTokenExpired(token); } //9.토큰 남은시간 public long getRemainMilliSeconds(String token) { Date expiration = extractAllClaims(token).getExpiration(); Date now = new Date(); return expiration.getTime() - now.getTime(); } /** * 토큰 확인 - JWT의 유효시간도 자동으로 체크한다 * parseClaimsJws 메시드 Base 64로 디코딩 및 파싱. * 즉, 헤더와 페이로드를 setSigningKey 로 넘어온 시크릿을 이용해 서명 후, token 의 서명과 비교. * 위조되지 않았다면 페이로드(Claims) 리턴, 위조라면 예외를 날림 , 그중 우리는 userId 가 필요하므로 getBody 를 부른다. * @param token * @return */ public String validateAndGetUserId(String token){ log.info(" 토큰 유효성 체크 validateAndGetUserId : {}",token); Claims claims=Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); // 토큰을 저장시 userId 를 저장했기에 getSubject 통해 userId 값을 가져온다. // .setSubject(String.valueOf(userId)) return claims.getSubject(); } public Claims validateAndGetUserClaims(String token){ return Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); } }
8) LogoutAccessToken
package net.macaronics.springboot.webapp.config.auth.jwt; import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.TimeToLive; import jakarta.persistence.Id; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @Getter @RedisHash("logoutInfo") @AllArgsConstructor @Builder public class LogoutAccessToken { @Id private Long id; private String refreshToken; //기본값 -1로 Redis 에 영구적으로 유지, 로그아웃 시간 Milliseconds 형식 @TimeToLive private Long logoutTimeMilliseconds; //로그아웃 시간 yyyy-MM-dd HH:mm:ss(EEE) 형식 private String logoutTime; public static LogoutAccessToken of(Long memberId, String refreshToken, Long logoutTimeMilliseconds, String logoutTime) { return LogoutAccessToken.builder() .id(memberId) .refreshToken(refreshToken) .logoutTimeMilliseconds(logoutTimeMilliseconds) .logoutTime(logoutTime) .build(); } }
9)RefreshToken
package net.macaronics.springboot.webapp.entity; import lombok.*; import org.springframework.data.annotation.Id; import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.TimeToLive; /** timeToLive는 유효시간을 값으로 초 단위를 의미합니다. 현재 14_400초 4시간으로 설정 , timeToLive = 14440 */ @Getter @RedisHash(value = "refreshTokenInfo") @AllArgsConstructor @Builder @ToString @NoArgsConstructor public class RefreshToken { //여기서 주의할 점은 @Id 어노테이션입니다. //java.persistence.id가 아닌 org.springframework.data.annotation.Id 를 import 해야 됩니다. //Refresh Token은 Redis에 저장하기 때문에 JPA 의존성이 필요하지 않습니다. (persistence로 하면 에러납니다.) // 또한 id 변수이름 그대로 사용해야지 CrudRepository 의 findById 를 사용할 수 있다. @Id private Long id; private String refreshToken; @TimeToLive //기본값 무한 private Long expiration; public static RefreshToken createRefreshToken(Long userId, String refreshToken, Long remainingMilliSeconds) { return RefreshToken.builder() .refreshToken(refreshToken) .id(userId) .expiration(remainingMilliSeconds / 1000) .build(); } }
댓글 ( 0)
댓글 남기기