build.gradle에 다음 코드 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.google.code.gson:gson:2.11.0'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
application.yml 수정
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
org.springframework.security.web: trace
config
Security에서 재설정 하기 위해 CustomServletConfig에서 CORS 메서드 삭제
CustomSecurityConfig.java
package code.code_api.config;
import code.code_api.security.filter.JWTCheckFilter;
import code.code_api.security.handler.APILoginFailHandler;
import code.code_api.security.handler.APILoginSuccessHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
@Slf4j
@RequiredArgsConstructor
public class CustomSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
log.info("......security config");
//CORS 정책사용
http.cors(httpSecurityCorsConfigurer -> {
httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource());
});
//CSRF 사용하지 않음
http.csrf(httpSecurityCsrfConfigurer -> httpSecurityCsrfConfigurer.disable());
//뷰가 없으므로 시큐리티가 제공하는 LoginForm을 사용한다.
http.formLogin(config -> {
config.loginPage("/api/member/login");
config.successHandler(new APILoginSuccessHandler());
config.failureHandler(new APILoginFailHandler());
});
//사용자 아이다와 패스워드를 검증하는 필터전에 우리가 만든 필터를 동작시킨다.
http.addFilterBefore(new JWTCheckFilter(), UsernamePasswordAuthenticationFilter.class); //JWT 체크
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
//모든 Origin 허용
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
//허용할 Http 메서드 설정
configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE"));
//허용할 Http 요청 헤더를 설정
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type"));
//클라이언트가 자격 증명(쿠키, 인증 정보 등)을 포함한 요청을 보낼 수 있도록 허용
//true로 설정되면, 서버는 자격 증명이 있는 요청을 신뢰
configuration.setAllowCredentials(true);
//URL 패턴에 따라 CorsConfiguration을 매핑할 수 있는 객체를 생성
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// "/**"로 지정하여 모든 URL 경로에 대해 이 CORS 정책을 적용
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
domain
MemberRole.java
package code.code_api.domain;
public enum MemberRole {
USER, MANAGER, ADMIN
}
CodeMember.java
package code.code_api.domain;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = "memberRoleList")
public class CodeMember {
@Id
private String email;
private String pw;
private String nickname;
//소셜 로그인 : true, 일반 로그인 : false
private boolean social;
//memberRoleList가 실제로 사용될 때 데이터 로드
@ElementCollection(fetch = FetchType.LAZY)
@Builder.Default
private List<MemberRole> memberRoleList = new ArrayList<>();
//권한 부여
public void addRole(MemberRole memberRole){
memberRoleList.add(memberRole);
}
//권한 삭제
public void clearRole(){
memberRoleList.clear();
}
//nickname, pw, social은 변경 가능하도록 Setter 생성
public void setPw(String pw) {
this.pw = pw;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public void setSocial(boolean social) {
this.social = social;
}
}
Repository
CodeMemberRepository.java
package code.code_api.repository;
import code.code_api.domain.CodeMember;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface CodeMemberRepository extends JpaRepository<CodeMember, String> {
@EntityGraph(attributePaths = {"memberRoleList"})
@Query("select m from CodeMember m where m.email = :email")
CodeMember getWithRoles(@Param("email") String email);
}
Test
CodeMemberRepositoryTest.java
package code.code_api.repository;
import code.code_api.domain.CodeMember;
import code.code_api.domain.MemberRole;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Slf4j
class CodeMemberRepositoryTest {
@Autowired
private CodeMemberRepository codeMemberRepository;
@Autowired
private PasswordEncoder passwordEncoder;
//회원 10명 등록, 5번 이상은 MANAGER 등급 부여, 9번 이상은 ADMIN 등급 부여
@Test
public void 회원10명등록() {
for(int i = 1; i <= 10; i++) {
String password = "password" + i;
String encodedPassword = passwordEncoder.encode(password);
CodeMember codeMember = CodeMember.builder()
.email("user" + i + "@email.com")
.pw(encodedPassword)
.nickname("user" + i)
.build();
codeMember.addRole(MemberRole.USER);
if(i>=5) codeMember.addRole(MemberRole.MANAGER);
if(i>=8) codeMember.addRole(MemberRole.ADMIN);
codeMemberRepository.save(codeMember);
}
}
@Test
public void 회원조회9번() {
String email = "user9@email.com";
log.info("9번회원 {}", codeMemberRepository.getWithRoles(email));
log.info("9번회원권한 {}", codeMemberRepository.getWithRoles(email).getMemberRoleList());
}
}
DTO
MemberDTO.java
package code.code_api.dto;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.*;
import java.util.stream.Collectors;
@Getter @Setter
@ToString
public class MemberDTO extends User {
private String email, pw, nickname;
private boolean social;
private List<String> roleNames = new ArrayList<>();
//우리는 문자로 권한을 받으면 되는데 시큐리티는 객체로 받아야 함. 그래서 new SimpleGrantedAuthority("ROLE_" + str) 문자를 객체로 생성해 준다.
public MemberDTO(String email, String pw, String nickName, boolean social, List<String> roleNames) {
super(email, pw, roleNames.stream().map(str -> new SimpleGrantedAuthority("ROLE_" + str)).collect(Collectors.toList()));
this.email = email;
this.pw = pw;
this.nickname = nickName;
this.social = social;
this.roleNames = roleNames;
}
//JWT 문자열을 만들어서 데이터를 주고 받는다.
//JWT 문자열의 내용물을 클레임스(Claims)라고 한다.
public Map<String, Object> getClaims() {
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("email", email);
dataMap.put("pw", pw);
dataMap.put("nickName", nickname);
dataMap.put("social", social);
dataMap.put("roleNames", roleNames);
return dataMap;
}
}
Security
CustomUserDetailsService.java
package code.code_api.security;
import code.code_api.domain.CodeMember;
import code.code_api.dto.MemberDTO;
import code.code_api.repository.CodeMemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 java.util.stream.Collectors;
//스프링 시큐리티 사용자의 인증 처리를 위해서 UserDetailsService라는 인터페이스 구현체를 활용한다.
@Service
@Slf4j
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final CodeMemberRepository codeMemberRepository;
//loadUserByUsername()에서 사용자 정보를 조회하고 해당 사용자의 인증과 권한을 처리하게 된다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("시큐리티가 사용자 정보 조회를 처리하는가? {}", username);
CodeMember member = codeMemberRepository.getWithRoles(username);
if(member == null){
throw new UsernameNotFoundException("사용자를 찾을 수 없습니다.");
}
MemberDTO memberDTO = new MemberDTO(
member.getEmail(),
member.getPw(),
member.getNickname(),
member.isSocial(),
member.getMemberRoleList()
.stream()
.map(memberRole -> memberRole.name()).collect(Collectors.toList()));
log.info("로그인한 멤버 {}", memberDTO);
return memberDTO;
}
}
Handler
APILoginSuccessHandler.java
package code.code_api.security.handler;
import code.code_api.dto.MemberDTO;
import code.code_api.util.JWTUtil;
import com.google.gson.Gson;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;
@Slf4j
public class APILoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("로그인 성공 후 인증정보 : {}", authentication);
//인증된 사용자의 정보(authentication.getPrincipal())를 반환
MemberDTO memberDTO = (MemberDTO) authentication.getPrincipal();
//사용자의 클레임 데이터를 가져온다
Map<String, Object> claims = memberDTO.getClaims();
//JWTUtil을 이용해서 Access Token과 Refresh Token을 생성한다.
String accessToken = JWTUtil.generateToken(claims, 10);
String refreshToken = JWTUtil.generateToken(claims, 60 * 24);
claims.put("accessToken", accessToken);
claims.put("refreshToken", refreshToken);
//JSON 응답 생성을 위해, Gson 라이브러리를 사용해 클레임 데이터를 JSON 형식으로 변환
Gson gson = new Gson();
String jsonStr = gson.toJson(claims);
//HTTP 응답으로 반환
response.setContentType("application/json; charset=UTF-8");
PrintWriter printWriter = response.getWriter();
printWriter.println(jsonStr);
printWriter.close();
}
}
APILoginFailureHandler.java
package code.code_api.security.handler;
import com.google.gson.Gson;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;
@Slf4j
public class APILoginFailHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
log.info("로그인 실패 : ", exception);
Gson gson = new Gson();
String jsonStr = gson.toJson(Map.of("error", "ERROR_LOGIN"));
response.setContentType("application/json");
PrintWriter printWriter = response.getWriter();
printWriter.println(jsonStr);
printWriter.close();
}
}
Util
CustomJWTException.java
package code.code_api.util;
public class CustomJWTException extends RuntimeException{
public CustomJWTException(String msg) {
super(msg);
}
}
JWTUtil.java
package code.code_api.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import javax.crypto.SecretKey;
import java.io.UnsupportedEncodingException;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.Map;
@Slf4j
public class JWTUtil {
//JWT의 서명을 생성할 때 사용하는 비밀 키, 최소 256비트(32자 이상)가 필요
//HMAC-SHA 알고리즘을 사용
private static String key = "1234567890123456789012345678901234567890";
//JWT 문자열 생성을 위한 generateToken() 메서드
//입력으로 전달된 valueMap과 유효 시간(min)을 바탕으로 JWT를 생성
//valueMap은 JWT의 클레임(Claims)입니다. 예를 들어, 사용자 정보 같은 데이터를 담을 수 있습니다.
public static String generateToken(Map<String, Object> valueMap, int min) {
SecretKey key = null;
//hmacShaKeyFor, 비밀키를 HMAC-SHA 알고리즘용 키로 변환
try {
key = Keys.hmacShaKeyFor(JWTUtil.key.getBytes("UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
String jwtStr = Jwts.builder()
.setHeader(Map.of("typ", "JWT")) //JWT의 헤더를 설정
.setClaims(valueMap) //클레임 설정
.setIssuedAt(Date.from(ZonedDateTime.now().toInstant())) //발급시간
.setExpiration(Date.from(ZonedDateTime.now().plusMinutes(min).toInstant())) //만료시간
.signWith(key) //HMAC-SHA 알고리즘으로 서명을 만듦
.compact();//JWT를 직렬화하여 최종 문자열로 변환
return jwtStr;
}
//검증을 위한 validateToken() 메서드
//입력값 token은 검증대상의 JWT문자열
public static Map<String, Object> validateToken(String token){
//토큰의 클레임 데이터를 저장할 변수, 클레임은 토큰에 담긴 정보(key-value 쌍)으로 이루어짐
Map<String, Object> claim = null;
//비밀키
SecretKey key = null;
//JWT 생성 시 사용한 키와 동일해야 함
try {
key = Keys.hmacShaKeyFor(JWTUtil.key.getBytes("UTF-8"));
claim = Jwts.parserBuilder() //JWT 문자열을 파싱하는 객체를 빌드
.setSigningKey(key) //JWT의 서명을 검증하기 위해 사용할 비밀 키를 설정
.build()
.parseClaimsJws(token) //입력받은 JWT 문자열을 파싱하여 유효성을 확인, 서명이 유효한지, 토큰이 만료되지 않았는지 확인
.getBody(); //검증이 성공하면 토큰의 페이로드(Payload) 부분에 포함된 클레임 데이터를 반환
} catch (MalformedJwtException malformedJwtException) {
throw new CustomJWTException("MalFormed"); //토큰이 잘못된 형식으로 작성된 경우
} catch (ExpiredJwtException expiredJwtException) {
throw new CustomJWTException("Expired"); //토큰이 만료되거나, 만료 시간이 잘못된 경우
} catch (InvalidClaimException invalidClaimException) {
throw new CustomJWTException("Invalid"); //JWT 처리 중, 클레임 값이 특정 검증 조건을 충족하지 않을 때
} catch (JwtException jwtException) {
throw new CustomJWTException("JWTError"); //JWT 생성, 검증, 또는 파싱 과정에서 발생할 수 있는 다양한 문제
} catch (Exception e) {
throw new CustomJWTException("Error");
}
return claim;
}
}
Filter
JWTCheckFilter.java
package code.code_api.security.filter;
import code.code_api.dto.MemberDTO;
import code.code_api.util.JWTUtil;
import com.google.gson.Gson;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.Map;
@Slf4j
public class JWTCheckFilter extends OncePerRequestFilter {
//검증(필터)에서 제외하고 싶은 url
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
//먼저 서버에 OPTIONS 메서드로 사전 확인(preflight) 요청 => 서버는 이 요청에 응답하며 클라이언트가 실제 요청(GET, POST 등)을 보낼 수 있도록 허용할지 확인
if(request.getMethod().equals("OPTIONS")){
return true;
}
//return true : 체크 안함, false : 체크 한다는 뜻
String path = request.getRequestURI();
if(path.startsWith("/api/member/")){
return true; //체크하지 않음
}
//이미지 조회 경로는 체크하지 않는다
if(path.startsWith("/api/products/view")){
return true;
}
log.info("체크 url {}", path);
return false; //체크함
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("doFilterInternal : 검증중^^");
//일단 끄집어 낸다
String authHeaderStr = request.getHeader("Authorization");
//Authorization : Basic 토큰값~~
//Bearer 토큰값 ~~ => 이렇게 됨. Bearer 여기까지 7글자(공백포함)
try {
String accessToken = authHeaderStr.substring(7); //앞의 7개는 짤라냄
Map<String, Object> claims = JWTUtil.validateToken(accessToken);
log.info("JWT claims", claims);
//성공하면 다음 목적지를 부른다.
//filterChain.doFilter(request, response); //통과
String email = (String) claims.get("email");
String pw = (String) claims.get("pw");
String nickName = (String) claims.get("nickName");
Boolean social = (Boolean) claims.get("social");
List<String> roleNames = (List<String>) claims.get("roleNames");
MemberDTO memberDTO = new MemberDTO(email, pw, nickName, social.booleanValue(), roleNames);
log.info("멤버? {}", memberDTO);
log.info("멤버 권한? {}", memberDTO.getAuthorities());
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberDTO, pw, memberDTO.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
} catch (Exception e) {
log.info("에러 {}", e.getMessage());
Gson gson = new Gson();
String msg = gson.toJson(Map.of("error", "ERROR_ACCESS_TOKEN"));
response.setContentType("application/json");
PrintWriter printWriter = response.getWriter();
printWriter.println(msg);
printWriter.close();
}
}
}
//OncePerRequestFilter : 모든 요청에 대하여 체크하겠다.
//doFilterInternal : 요청과 응답을 실제로 구현함.
Handler
CustomAccessDeniedHandler.java
package code.code_api.security.handler;
import com.google.gson.Gson;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
Gson gson = new Gson();
String jsonStr = gson.toJson(Map.of("error", "ERROR_ACCESSDENIED"));
response.setContentType("application/json");
response.setStatus(HttpStatus.FORBIDDEN.value());
PrintWriter printWriter = response.getWriter();
printWriter.println(jsonStr);
printWriter.close();
}
}
Controller
ProductController에 권한 설정 코드 추가
/상품목록 조회
@PreAuthorize("hasAnyRole('ROLE_ADMIN')") //임시로 권한 설정
@GetMapping("/list")
public PageResponseDTO<ProductDTO> list(PageRequestDTO pageRequestDTO) {
return productService.getList(pageRequestDTO);
}
APIRefreshController.java
package code.code_api.controller;
import code.code_api.util.CustomJWTException;
import code.code_api.util.JWTUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.propertyeditors.CustomNumberEditor;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@Slf4j
public class APIRefreshController {
@RequestMapping("/api/member/refresh")
public Map<String, Object> refresh(@RequestHeader("Authorization") String authHeader, @RequestParam("refreshToken") String refreshToken){
//refreshToken이 없음
if(refreshToken == null) {
throw new CustomJWTException("NULL_REFRESH");
}
//authHeader가 없거나 Bearer가 7자가 안됨
if(authHeader == null || authHeader.length() < 7) {
throw new CustomJWTException("INVALID_STRING"); //잘못된 문자
}
String accessToken = authHeader.substring(7);
//Access 토큰이 만료되지 않았다면, 그대로 사용
if(checkExpiredToken(accessToken) == false) {
return Map.of("accessToken", accessToken, "refreshToken", refreshToken);
}
//Refresh 토큰 검증
Map<String, Object> claims = JWTUtil.validateToken(refreshToken);
log.info("refresh ... claims {}", claims);
//새로운 accessToken 발행
String newAccessToken = JWTUtil.generateToken(claims, 10);
//refreshToken의 시간을 검사해서 다시 발행할지 ,아닐지를 결정
String newRefreshToken = checkTime((Integer)claims.get("exp")) == true ? JWTUtil.generateToken(claims, 60 * 24) : refreshToken;
return Map.of("accessToken", newAccessToken, "refreshToken", newRefreshToken);
}
//1시간 미만으로 남았는지 체크, true 반환시 1시간도 안남은 것
private boolean checkTime(Integer exp) {
//JWT (JSON Web Token)에서 가져온 exp 클레임은 Unix 타임스탬프로 표현됨
//JUnix 타임스탬프는 초단위로 시간을 나타냄
//자바의 Date는 System.currentTimeMillis()로 밀리초 단위의 시간을 다룬다
Date expDate = new Date((long) exp * (1000));
//현재 시간과의 차이 계산
long gap = expDate.getTime() - System.currentTimeMillis();
//분단위 계산
long leftMin = gap / (1000 * 60);
//1시간도 안남았는지..
return leftMin < 60;
}
//만료되었는지 검사, true면 만료, false면 만료되지 않음
private boolean checkExpiredToken(String accessToken) {
try {
JWTUtil.validateToken(accessToken);
} catch (CustomJWTException ex) {
if(ex.getMessage().equals("Expired")) {
return true;
}
}
return false;
}
}'Framework > Spring' 카테고리의 다른 글
| [Spring] 카카오 로그인 기능 추가하기 (0) | 2024.12.23 |
|---|---|
| [Spring] 코드 프로젝트 (0) | 2024.12.05 |
| [Spring] 시큐어 코딩 예제 (0) | 2024.11.22 |
| [Spring] 문의 페이지 테스트 (0) | 2024.11.13 |
| [Spring] 에러 페이지 예제 (0) | 2024.11.12 |