React

 

 

최신 트렌드 적용한 

React Router v6.4 , react-hook-form, axiosInstance, typescript, tailwind를 활용한 회원가입 폼 전송 처리 개발방법

 

 

소스 :

https://github.com/braverokmc79/springboot-restful-web

 

 

 

1. 리액트 프론트 엔드 개발

 

설치

1) vite 로 리액트 + typescript,  프로젝트를 생성한다.

https://ko.vitejs.dev/guide/

 

2) tailwind  설정

https://tailwindcss.com/

 

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 사용

  1. 라우트 설정
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 으로 팝업구현

 

https://ui.shadcn.com/

 

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)+")';  >&laquo;</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) +")';  >&raquo;</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)+"'>&laquo;</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)+"'>&raquo;</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)+"'>&laquo;</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)+"'>&raquo;</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();
    }

}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

지렁이도 밟으면 꿈틀한다 , 아무리 보잘것없고 약한 사람이라도 너무 업신여김을 당하면 반항한다는 말.

댓글 ( 0)

댓글 남기기

작성