
최신 트렌드 적용한
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)
댓글 남기기