의존성 선택
Spring Security
Spring Security는 스프링 프레임워크를 기반으로 한 인증과 권한 부여를 제공하는 강력한 보안 프레임워크이다. 웹 애플리케이션 및 서비스의 보안을 손쉽게 설정하고 관리할 수 있도록 다양한 기능과 확장성을 제공한다.
스프링 시큐리티를 처음 설정하고 애플리케이션을 빌드했을 때 나타나는 이 메시지는, 기본 보안 설정에 따라 생성된 임시 비밀번호를 알려주는 것이다.
초기화면이 로그인 페이지가 뜨게 되며, 제공된 임시 비밀번호를 사용해 로그인 후 설정을 점검하고, 기능을 개발하면 된다.
환경설정
application.yml
spring:
application:
name: security
mvc:
pathmatch:
matching-strategy: ant_path_matcher
datasource:
url: jdbc:h2:tcp://localhost/~/testApi
driver-class-name: org.h2.Driver
username: sa
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
thymeleaf:
prefix: classpath:/templates/
suffix: .html
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
layout-dialect를 사용하기 위해서 build.gradle에 다음 코드 추가
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
static에 css, js 경로 생성 후 부트스트랩 파일 추가
<Back>
DAO
EzenMember.java
package spring.security.dao;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter @Setter
public class EzenMember {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String grade;
private String loginId;
private String name;
private String password;
}
UserRoll.java
package spring.security.dao;
import lombok.Getter;
@Getter
public enum UserRoll {
//열거형 상수
ADMIN("ROLE_ADMIN"),
USER("ROLE_USER");
UserRoll(String value){
this.value = value;
}
private String value;
}
DTO
MemberForm.java
package spring.security.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class MemberForm {
private Long id;
private String grade;
@NotEmpty(message = "아이디를 적어주세요.")
private String loginId;
@NotEmpty(message = "이름을 적어주세요.")
private String name;
@NotEmpty(message = "비밀번호를 적어주세요.")
private String password;
}
LoginForm.java
package spring.security.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class LoginForm {
private String loginId;
private String password;
}
Repository
EzenMemberRepository.java
package spring.security.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import spring.security.dao.EzenMember;
import java.util.Optional;
public interface EzenMemberRepository extends JpaRepository<EzenMember, Long> {
//사용자 아이디로 조회
Optional<EzenMember> findByLoginId(String loginId);
}
Service
MemberService.java
package spring.security.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import spring.security.dao.EzenMember;
import spring.security.repository.EzenMemberRepository;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class MemberService {
private final EzenMemberRepository ezenMemberRepository;
//회원 가입
public Long join(EzenMember ezenMember) {
validateDuplicateEzenMember(ezenMember);
this.ezenMemberRepository.save(ezenMember);
return ezenMember.getId();
}
//중복회원 검증
private void validateDuplicateEzenMember(EzenMember ezenMember){
Optional<EzenMember> findMember = ezenMemberRepository.findByLoginId(ezenMember.getLoginId());
if(findMember.isPresent()){
throw new IllegalStateException("이미 존재하는 회원입니다.");
}
}
//전체회원 조회
public List<EzenMember> findMember(){
return ezenMemberRepository.findAll();
}
//회원한명 조회
public Optional<EzenMember> findOne(Long id){
return ezenMemberRepository.findById(id);
}
//회원 수정
public Long update(EzenMember ezenMember) {
this.ezenMemberRepository.save(ezenMember);
return ezenMember.getId();
}
//회원 삭제
public void delete(EzenMember ezenMember) {
this.ezenMemberRepository.deleteById(ezenMember.getId());
}
}
LoginService.java
package spring.security.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
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 spring.security.dao.EzenMember;
import spring.security.dao.UserRoll;
import spring.security.repository.EzenMemberRepository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
@Slf4j
@RequiredArgsConstructor
public class LoginService implements UserDetailsService {
private final EzenMemberRepository ezenMemberRepository;
// Alt + Insert, 오버라이딩 클릭
@Override
public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
Optional<EzenMember> findLoginId = ezenMemberRepository.findByLoginId(loginId);
log.info("loginId는 뭐지? {}",loginId);
if(findLoginId.isEmpty()){
log.info("없어? {}", "사용자를 찾을 수 없음");
throw new UsernameNotFoundException("사용자를 찾을 수 없습니다.");
}
EzenMember findMember = findLoginId.get();
log.info("꺼낸 멤버 누구야? {}", findMember);
log.info("꺼낸 멤버의 권한? {}", findMember.getGrade());
//권한 부여
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
if("user".equals(findMember.getGrade())){
grantedAuthorities.add(new SimpleGrantedAuthority(UserRoll.USER.getValue()));
} else {
grantedAuthorities.add(new SimpleGrantedAuthority(UserRoll.ADMIN.getValue()));
}
log.info("찍어봐? {} {} {}", findMember.getLoginId(), findMember.getPassword(), grantedAuthorities);
return new User(findMember.getLoginId(), findMember.getPassword(), grantedAuthorities);
}
}
Controller
HomeController.java
package spring.security.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
@RequiredArgsConstructor
public class HomeController {
//사용자 인증 번호가 있으면 loginHome으로 이동, 그렇지 않으면 home으로 이동 (해당 사용자의 loginId 가져옴)
@GetMapping("/")
public String home(Model model){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); //사용자 인증 정보 가져오기
//만약 사용자 인증 정보가 null이 아니고 인증된 상태이며,
//유효한 인증 토큰을 가지고 있는지?
if(authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)){
String loginId = authentication.getName();
model.addAttribute("loginMember", loginId);
return "loginHome";
}
return "home";
}
}
//AnonymousAuthenticationToken: 로그인하지 않은 익명 사용자에게 자동으로 부여되는 토큰
MemberController.java
package spring.security.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import spring.security.SecurityConfig;
import spring.security.dao.EzenMember;
import spring.security.dto.MemberForm;
import spring.security.service.MemberService;
import java.util.List;
import java.util.Optional;
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
//회원 가입
@GetMapping("/add")
public String add(Model model){
model.addAttribute("memberForm", new MemberForm());
return "user/addMemberForm";
}
@PostMapping("/add")
public String create(@Valid @ModelAttribute("memberForm") MemberForm memberForm, BindingResult bindingResult){
if(bindingResult.hasErrors()){
return "user/addMemberForm";
}
EzenMember ezenMember = new EzenMember();
ezenMember.setName(memberForm.getName());
ezenMember.setLoginId(memberForm.getLoginId());
ezenMember.setPassword(passwordEncoder.encode(memberForm.getPassword()));
ezenMember.setGrade("user");
memberService.join(ezenMember);
return "redirect:/";
}
//회원 목록
//사용자 인증 정보가 있으면 해당 사용자의 loginId 가져옴
@GetMapping("/members")
public String memberList(Model model){
List<EzenMember> ezenMembers = memberService.findMember();
model.addAttribute("ezenMembers", ezenMembers);
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)){
String loginId = authentication.getName();
model.addAttribute("loginMember", loginId);
}
return "admin/memberList";
}
//회원 수정
//사용자 인증 정보가 있으면 해당 사용자의 loginId 가져옴
@GetMapping("/members/edit/{id}")
public String editMember(@PathVariable("id") Long id, Model model){
Optional<EzenMember> findOne = memberService.findOne(id);
EzenMember findMemberGet = findOne.get();
MemberForm memberForm = new MemberForm();
memberForm.setId(findMemberGet.getId());
memberForm.setLoginId(findMemberGet.getLoginId());
memberForm.setPassword(findMemberGet.getPassword());
memberForm.setName(findMemberGet.getName());
memberForm.setGrade(findMemberGet.getGrade());
model.addAttribute("memberForm", memberForm);
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)){
String loginId = authentication.getName();
model.addAttribute("loginMember", loginId);
}
return "admin/updateMemberForm";
}
@PostMapping("/members/edit/{id}")
public String updateMember(@Valid @ModelAttribute("memberForm") MemberForm memberForm, BindingResult bindingResult, Model model){
if(bindingResult.hasErrors()){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)){
String loginId = authentication.getName();
model.addAttribute("loginMember", loginId);
}
return "admin/updateMemberForm";
}
EzenMember ezenMember = new EzenMember();
ezenMember.setId(memberForm.getId());
ezenMember.setName(memberForm.getName());
ezenMember.setLoginId(memberForm.getLoginId());
ezenMember.setPassword(memberForm.getPassword());
ezenMember.setGrade(memberForm.getGrade());
memberService.update(ezenMember);
return "redirect:/members";
}
//회원 삭제
@GetMapping("members/delete/{id}")
public String deleteMember(@PathVariable("id") Long id){
Optional<EzenMember> findOne = memberService.findOne(id);
if(findOne.isPresent()){
memberService.delete(findOne.get());
}
return "redirect:/members";
}
}
LoginController.java
package spring.security.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
@RequiredArgsConstructor
public class LoginController {
//로그인
@GetMapping("/login")
public String loginForm(){
return "user/loginForm";
}
}
Security
SecurityConfig.java
package spring.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration //환경설정 파일임을 의미
@EnableWebSecurity
public class SecurityConfig {
@Bean //스프링에서 관리
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/images/**", "/favicon/**", "/*/icon-*").permitAll() //인가랑 상관없는 것들을 허용
.requestMatchers("/", "/add", "/error/**").permitAll()
.anyRequest().authenticated()) //이외의 것들은 인가 필요
.formLogin(form -> form
.loginPage("/login").permitAll()
.usernameParameter("loginId") //기본으로 설정된 매개변수 이름이 username이므로, 내가 원하는 이름을 사용하기 위해 파라미터 이름을 바꿀 수 있음
.defaultSuccessUrl("/")
.failureUrl("/login?error"))
.logout(logout -> logout
.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/")
.invalidateHttpSession(true)); //로그아웃시 생성된 사용자 세션 삭제
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
//기본적으로 bcrypt 암호화 알고리즘의 BcryptPasswordEncoder 객체를 생성하고 사용
}
//AuthenticationManager는 스프링 시큐리티의 인증을 처리
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
<View>
레이아웃
layout.html
<!DOCTYPE html>
<html lang="ko" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>시큐어 코딩</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link th:href="@{/css/bootstrap.css}" rel="stylesheet">
<link th:href="@{/css/style.css}" rel="stylesheet">
</head>
<body>
<!-- 네비게이션 바 -->
<nav th:replace="~{layout/navbar :: navbarFragment}"></nav>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
<!-- 푸터 -->
<footer th:replace="~{layout/footer :: footerFragment}"></footer>
<!-- 자바스크립트 -->
<script th:src="@{/js/bootstrap.bundle.js}"></script>
</body>
</html>
navbar.html
<nav th:fragment="navbarFragment" class="navbar navbar-expand-md bg-body-tertiary border-bottom border-2 border-secondary" xmlns:sec="http://www.w3.org/1999/xhtml">
<div class="container-fluid">
<a class="navbar-brand" th:href="@{/}">Home</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item">
<div th:if="${loginMember == null}">
<a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/login}"}>로그인</a>
<a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/logout}"}>로그아웃</a>
</div>
<div th:if="${loginMember}" class="mt-1">
<p th:text="${loginMember} + '님 환영합니다.'" class="m-0 fw-bold"></p>
</div>
</li>
<li class="nav-item">
<div th:if="${loginMember == null}">
<a class="nav-link" href="/add">회원 가입</a>
</div>
<div th:if="${loginMember}">
<form th:action="@{/logout}" method="post" class="ms-2">
<button type="submit" class="btn btn-secondary btn-sm">로그아웃</button>
</form>
</div>
</li>
</ul>
</div>
</div>
</div>
</nav>
footer.html
<footer class="bg-body-tertiary text-center py-2 mt-5 border-top border-2 border-secondary" th:fragment="footerFragment">
<p class="my-3">Copyright 2024. 이젠아카데미 Co. All rights reserved.</p>
</footer>
홈 화면
home.html
<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container my-5 max600" layout:fragment="content">
<div class="py-5 text-center">
<h2>홈 화면</h2>
</div>
<div class="row">
<div class="col">
<button type="button" th:onclick="|location.href='@{/add}'|" class="w-100 btn btn-secondary btn-lg">회원 가입</button>
</div>
<div class="col">
<button type="button" th:onclick="|location.href='@{/login}'|" class="w-100 btn btn-dark btn-lg">로그인</button>
</div>
</div>
</div>
</body>
</html>
loginHome.html
<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container my-5 max600" layout:fragment="content">
<div class="py-5 text-center">
<h2>홈 화면</h2>
</div>
<div class="py-3 row">
<p th:text="${loginMember} + '님, 환영합니다.'" class="mb-3 col-7 text-end"></p>
<form th:action="@{/logout}" method="post" class="col-5 text-start">
<button type="submit" class="btn btn-secondary btn-sm">로그아웃</button>
</form>
</div>
<div class="row">
<div class="col">
<button type="button" th:onclick="|location.href='@{/members}'|" class="w-100 btn btn-dark btn-lg">회원 목록</button>
</div>
</div>
</div>
</body>
</html>
회원 가입
addMemberForm.html
<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container my-5 max600" layout:fragment="content">
<div class="pb-3 text-center">
<h2>회원 가입</h2>
</div>
<form th:action th:Object="${memberForm}" method="post">
<div th:if="${#fields.hasErrors()}" class="alert alert-danger" role="alert">
<div th:each="err : ${#fields.allErrors()}" th:text="${err}">
전체 오류 메시지
</div>
</div>
<div class="mb-3">
<label th:for="name" class="form-label">이름</label>
<input type="text" class="form-control" th:field="*{name}" placeholder="이름을 입력하세요">
</div>
<div class="mb-3">
<label th:for="loginId" class="form-label">아이디</label>
<input type="text" class="form-control" th:field="*{loginId}" placeholder="아이디를 입력하세요">
</div>
<div class="mb-3 pb-3">
<label th:for="password" class="form-label">비밀번호</label>
<input type="password" class="form-control" th:field="*{password}" placeholder="비밀번호를 입력하세요">
</div>
<div class="row">
<div class="col">
<button type="submit" class="w-100 btn btn-primary btn-lg">회원 가입</button>
</div>
<div class="col">
<button type="button" th:onclick="|location.href='@{/}'|" class="w-100 btn btn-secondary btn-lg">취소</button>
</div>
</div>
</form>
</div>
</body>
</html>
회원 수정
updateMemberForm.html
<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container my-5 max600" layout:fragment="content">
<h1 class="pb-3">회원 수정</h1>
<form th:action th:Object="${memberForm}" method="post">
<div th:if="${#fields.hasErrors()}" class="alert alert-danger" role="alert">
<div th:each="err : ${#fields.allErrors()}" th:text="${err}">
전체 오류 메시지
</div>
</div>
<input type="hidden" th:field="*{id}">
<div class="mb-3">
<label th:for="name" class="form-label">이름</label>
<input type="text" class="form-control" th:field="*{name}" placeholder="이름을 입력하세요">
</div>
<div class="mb-3">
<label th:for="loginId" class="form-label">아이디</label>
<input type="text" class="form-control" th:field="*{loginId}" placeholder="아이디를 입력하세요">
</div>
<div class="mb-3">
<label th:for="password" class="form-label">비밀번호</label>
<input type="password" class="form-control" th:field="*{password}" th:placeholder="'기존 비밀번호는 ' + *{password} + ' 입니다.'">
</div>
<div class="mb-3 pb-3">
<label th:for="grade" class="form-label">등급</label>
<select class="form-select" th:field="*{grade}">
<option value="user" selected>user</option>
<option value="admin">admin</option>
</select>
</div>
<div class="row">
<div class="col">
<button type="submit" class="w-100 btn btn-primary btn-lg">수정</button>
</div>
<div class="col">
<button type="button" th:onclick="|location.href='@{/members}'|" class="w-100 btn btn-secondary btn-lg">취소</button>
</div>
</div>
</form>
</div>
</body>
</html>
로그인
loginForm.html
<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container my-5 max600" layout:fragment="content">
<div class="py-5 text-center">
<h2>로그인</h2>
</div>
<form th:action="@{/login}" method="post">
<div th:if="${param.error}">
<p class="alert alert-danger">로그인 ID와 패스워드를 확인하세요.</p>
</div>
<div class="mb-3">
<label for="loginId" class="form-label">아이디</label>
<input type="text" class="form-control" id="loginId" name="loginId" placeholder="아이디를 입력하세요">
</div>
<div class="mb-3 pb-3">
<label for="password" class="form-label">비밀번호</label>
<input type="password" class="form-control" id="password" name="password" placeholder="비밀번호를 입력하세요">
</div>
<div class="row">
<div class="col">
<button type="submit" class="w-100 btn btn-primary btn-lg">로그인</button>
</div>
<div class="col">
<button type="button" th:onclick="|location.href='@{/}'|" class="w-100 btn btn-secondary btn-lg">취소</button>
</div>
</div>
</form>
</div>
</body>
</html>
회원 목록
memberList.html
<html layout:decorate="~{layout/layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container my-5" layout:fragment="content">
<h1 class="pb-4">회원 목록</h1>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="min-width: 50px;">#</th>
<th scope="col" style="min-width: 100px;">아이디</th>
<th scope="col" style="min-width: 100px;">이름</th>
<th scope="col" style="min-width: 350px;">비밀번호</th>
<th scope="col" style="min-width: 80px;">등급</th>
<th scope="col" style="min-width: 80px;"></th>
<th scope="col" style="min-width: 80px;"></th>
</tr>
</thead>
<tbody>
<tr class="align-middle" th:each = "member : ${ezenMembers}">
<!-- 받은 변수를 각각 돌려 줄 지역변수 : ${Model로 받은 변수} -->
<th scope="row" th:text="${member.id}"></th>
<td th:text="${member.loginId}"></td>
<td th:text="${member.name}"></td>
<td th:text="${member.password}"></td>
<td th:text="${member.grade}"></td>
<td><a th:href="@{/members/edit/{id} (id=${member.id})}" class="btn btn-outline-primary" role="button">수정</a></td>
<!-- role : 웹브라우저에게 어떤 역할인지 알려줌 -->
<td><a th:href="@{/members/delete/{id} (id = ${member.id})}" class="btn btn-danger" role="button">삭제</a></td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
경로
'Framework > Spring' 카테고리의 다른 글
[Spring] 시큐리티 로그인 로직 (2) | 2024.12.18 |
---|---|
[Spring] 코드 프로젝트 (0) | 2024.12.05 |
[Spring] 문의 페이지 테스트 (0) | 2024.11.13 |
[Spring] 에러 페이지 예제 (0) | 2024.11.12 |
[Spring] 검색 기능 예제 (0) | 2024.11.11 |