일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- JDBC
- JavaScript
- 문서
- Python
- Spring Boot
- MSSQL
- Tomcat
- Docker
- SpringBoot
- git
- 오픈소스
- 설정
- myBatis
- Thymeleaf
- STS
- PostgreSQL
- oracle
- Exception
- Eclipse
- error
- Source
- MySQL
- jpa
- IntelliJ
- spring
- maven
- Open Source
- ubuntu
- AJAX
- Core Java
- Today
- Total
헤르메스 LIFE
[Spring Boot] Spring Security #02 본문
개발환경
1. STS 버전 : 4.13.1
2. JDK 버전 : OpenJDK 11.0.14_9_x64
3. Tomcat 버전 : 9.0.56
4. Maven 버전 : 3.8.4
5. Spring 버전 : Spring Boot 2.6.3
6. Database : Docker 에 DB 설치
- primary - PostgreSQL 13.3
7. Spring Security : 5.6.1
8. lombok
목표
1. Spring Boot 환경에서 Spring Security 기능을 추가하여 로그인 기능을 완성
2. 인프런의 최주호 강사님 스프링부트 시큐리티 & JWT 강의를 듣고, 그 내용을 구현
https://hermeslog.tistory.com/583?category=1078420
User.java -> Member 테이블을 생성하게 됩니다.
Postgresql은 User 테이블이 시스템 테이블인 듯 합니다.
package com.study.springboot.system.entity;
import java.sql.Timestamp;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import org.hibernate.annotations.CreationTimestamp;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity(name="member")
@NoArgsConstructor
@Getter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private String email;
private String role; // ROLE_USER, ROLE_ADMIN
@CreationTimestamp
private Timestamp createDate;
/**
* @param username
* @param password
*/
public User(String username, String password) {
this.username = username;
this.password = password;
}
/**
* @param username
* @param password
*/
public User(String username, String password, String email) {
this.username = username;
this.password = password;
this.email = email;
}
/**
* @param username
* @param password
*/
public User(String username, String password, String email, String role) {
this.username = username;
this.password = password;
this.email = email;
this.role = role;
}
public void setRole( String role ) {
this.role = role;
}
}
PrincipalDetails.java
package com.study.springboot.config.auth;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.study.springboot.system.entity.User;
/**
* Spring Security가 /login.do 요청이 들어오면
* 로그인이 완료되면 Security Session 을 생성한다. ( SecurityHolder )
* Object Type => Authentication 타입 객체
* Authentication 안에 User 정보가 있어야 함.
* User Object Type => UserDetails Type 객체
*
* Security Session -> Authentication -> UserDetails(PrincipalDetails)
*/
@SuppressWarnings("serial")
public class PrincipalDetails implements UserDetails {
private User user; // 콤포지션
// 생성자 만들기
public PrincipalDetails(User user) {
this.user = user;
}
@Override // 사용자의 비밀번호를 알고 싶으면 호출
public String getPassword() {
return user.getPassword();
}
@Override // 사용자의 유저네임를 알고 싶으면 호출
public String getUsername() {
return user.getUsername();
}
@Override // 사용자가 만료된 지를 알고 싶으면 호출
public boolean isAccountNonExpired() { // 만료안됐니?
// 접속시간확인하여 true false 리턴
return true;
}
@Override
public boolean isAccountNonLocked() { // 락 안걸렸니?
return true;
}
@Override
public boolean isCredentialsNonExpired() {
// TODO Auto-generated method stub
return true;
}
@Override
public boolean isEnabled() { // 계정활성화 되어있니?
return true;
}
// Arrays.asList(new SimpleGrantedAuthority(user.getRole()));
@Override // 어떤 권한을 가졌니?
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authList = new ArrayList<>();
authList.add(new SimpleGrantedAuthority(user.getRole()));
return authList;
}
}
WebMvcConfig.java
package com.study.springboot.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import com.study.springboot.config.interceptor.AccessInterceptor;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
/**
* <pre>
* Interceptor 설정
* excludePathPatterns 설정이 변경되면, SecurityConfig Class도 변경되어야 한다.
* </pre>
*/
@Override
public void addInterceptors( InterceptorRegistry registry ) {
registry.addInterceptor(new AccessInterceptor()).addPathPatterns("/**") // 해당 경로에 접근하기 전에 인터셉터가 가로챈다.
.excludePathPatterns("/css/**") // 해당 경로는 인터셉터가 가로채지 않는다.
.excludePathPatterns("/fonts/**") // 해당 경로는 인터셉터가 가로채지 않는다.
.excludePathPatterns("/images/**") // 해당 경로는 인터셉터가 가로채지 않는다.
.excludePathPatterns("/js/**") // 해당 경로는 인터셉터가 가로채지 않는다.
.excludePathPatterns("/modules/**") // 해당 경로는 인터셉터가 가로채지 않는다.
.excludePathPatterns("/error") // 해당 경로는 인터셉터가 가로채지 않는다.
.excludePathPatterns("/login.do") // 해당 경로는 인터셉터가 가로채지 않는다.
.excludePathPatterns("/logout.do") // 해당 경로는 인터셉터가 가로채지 않는다.
.excludePathPatterns("/upload.do"); // 해당 경로는 인터셉터가 가로채지 않는다.
}
}
SecurityConfig.java
package com.study.springboot.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import com.study.springboot.config.auth.CustomAuthenticationSuccessHandler;
@Configuration
@EnableWebSecurity // 스프링 Security Filter가 Spring Fileter Chain에 등록된다.
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final Logger logger = LoggerFactory.getLogger(SecurityConfig.class);
@Bean
public BCryptPasswordEncoder encodePwd() {
return new BCryptPasswordEncoder();
}
/**
* <pre>
* Security 무시 설정
* excludePathPatterns 설정이 변경되면, WebMvcConfig Class도 변경되어야 한다.
* </pre>
*/
@Override
public void configure( WebSecurity web ) {
web.ignoring().antMatchers("/css/**", "/fonts/**", "/images/**", "/js/**", "/modules/**")
.antMatchers("/h2-console/**", "/swagger-ui/**");
}
@Override
protected void configure( HttpSecurity http ) throws Exception {
// 해당 기능을 사용하기 위해서는 프론트단에서 csrf토큰값 보내줘야함
// <input type="hidden" name="${_csrf.paremeterName }" value="${_csrf.token }"/>
http.csrf().disable(); // Spring Security의 SCRF를 막음. Post가 안될 경우가 존재하면 막는 경우도 있음.
http.authorizeHttpRequests() //
.antMatchers("/login.do", "/logout.do", "/swagger-ui/**", "/swagger-ui").permitAll() // 로그인 하지 않고 모두 권한을
// 가짐.
.anyRequest().authenticated() // 그 외 모든 요청은 인증된 사용자만 접근 가능
// .anyRequest().permitAll() // 로그인 하지 않고 모두 권한을 가짐.
;
http.requiresChannel()
// .antMatchers("/**").requiresSecure() // https 로 리다이렉스 시킴
.antMatchers("/**").requiresInsecure() // http 로 리다이렉스 시킴
;
http.formLogin() // 로그인 페이지와 기타 로그인 처리 및 성공 실패 처리를 사용하겠다는 의미 입니다.
.loginPage("/login.do") // Login 화면
.loginProcessingUrl("/loginProcess.do") // Login 프로세스
// .defaultSuccessUrl("/main.do", true)
.successHandler(new CustomAuthenticationSuccessHandler("/main.do")) // 인증에 성공하면 Main 페이지로 Redirect
// // .failureHandler(new CustomAuthenticationFailureHandler("/login-fail")) // 커스텀 핸들러를 생성하여 등록하면 인증실패
// 후
.failureUrl("/login.do?fail=true") // 인증이 실패 했을 경우 이동하는 페이지를 설정합니다.
.usernameParameter("userId") // Login ID 명칭지정 - MemberRepository 의 id와 매칭됨.
.passwordParameter("password") // Login PW 명칭지정
;
http.logout() //
.logoutRequestMatcher(new AntPathRequestMatcher("/logout.do")) // 로그아웃
.logoutSuccessUrl("/login.do") // 로그아웃에 성공하면 페이지 Redirect
.invalidateHttpSession(true) // Session 초기화
;
}
}
CustomAuthenticationProvider.java
package com.study.springboot.config.auth;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
private Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
private PrincipalDetailsService principalDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate( Authentication authentication ) throws AuthenticationException {
logger.info("[CustomAuthenticationProvider] 시작 ---------------------------------");
logger.info("authentication.getName() :: {}", authentication.getName().toString());
logger.info("---------------------------------");
PrincipalDetails principalDetails = (PrincipalDetails) principalDetailsService
.loadUserByUsername(authentication.getName().toString());
logger.info("principalDetails.getUsername :: {}", principalDetails.getUsername());
logger.info("principalDetails.getPassword :: {}", principalDetails.getPassword());
logger.info("principalDetails.getAuthorities :: {}", principalDetails.getAuthorities());
logger.info("authentication.getCredentials() :: {}", authentication.getCredentials().toString());
String reqPassword = authentication.getCredentials().toString();
if ( !passwordEncoder.matches(reqPassword, principalDetails.getPassword()) )
throw new BadCredentialsException("Not Found User");
logger.info("[CustomAuthenticationProvider] 끝 ---------------------------------");
return new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
}
@Override
public boolean supports( Class<?> authentication ) {
return true;
}
}
PrincipalDetailsService.java
package com.study.springboot.config.auth;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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 com.study.springboot.system.entity.User;
import com.study.springboot.system.repository.MemberRepository;
// UserDetailsService는 IoC로 찾음
// /loginProcess.do 가 찾아오는 클래스임.
@Service // UserDetailsService타입으로 메모리에 뜬다 (덮어씌워짐)
public class PrincipalDetailsService implements UserDetailsService {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
private MemberRepository userRepository;
@Override
public UserDetails loadUserByUsername( String userId ) throws UsernameNotFoundException {
// 어썬티케이션 매니저가 낚아챔
// JPA는 기본적인 CRUD만 있어서 다른걸 쓰려면 만들어줘야함
logger.info("[PrincipalDetailsService] userId :: {}", userId);
User user = userRepository.findById(Long.parseLong(userId));
logger.info("[PrincipalDetailsService] user :: {}", user);
if ( user != null ) {
List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
String roles[] = user.getRole().split(",");
for ( int i = 0; i < roles.length; i++ ) {
authorities.add(new SimpleGrantedAuthority(roles[i]));
}
return new PrincipalDetails(user); // SecurityContext의 Authertication에 등록되어 인증정보를 가진다.
}
return null;
}
}
MemberRepository.java
package com.study.springboot.system.repository;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.study.springboot.system.entity.User;
@Repository
public interface MemberRepository extends CrudRepository<User, Long> {
// Jpa Naming 전략
// SELECT * FROM member WHERE id = ?
// Insert into member ( password, username, createdate, email, role) values ( '$2a$10$C4BZPH4raAlKGrvy9dtyyufBp56af2W6fge0hD1wLctWvNEjrK.AG', '홍길동', now(), 'hermeswing@test.com', 'ROLE_ADMIN')
// id:1, pw:1234
User findById( long userId ); // JPA Query Method
}
CustomAuthenticationSuccessHandler.java
package com.study.springboot.config.auth;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.WebAttributes;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private String redirectUri;
public CustomAuthenticationSuccessHandler(String redirectUri) {
this.redirectUri = redirectUri;
}
@Override
public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response,
Authentication authentication ) throws IOException, ServletException {
logger.info("[CustomAuthenticationSuccessHandler] 인증성공 ==============================================================");
logger.info("1. 사용자 Session 생성");
HttpSession session = request.getSession(true);
session.setAttribute("UserDetail", authentication.getCredentials());
response.sendRedirect(redirectUri);
// 에러 세션 지우기
clearAuthenticationAttributes(request);
logger.info("session 정보 :: {}", session.getId());
logger.info("UserDetail :: {}", session.getAttribute("UserDetail"));
logger.info("[CustomAuthenticationSuccessHandler] 끝 ==============================================================");
}
// 남아있는 에러세션이 있다면 지워준다.
protected void clearAuthenticationAttributes( HttpServletRequest request ) {
HttpSession session = request.getSession(false);
if ( session == null ) return;
session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
}
}
AccessInterceptor.java
package com.study.springboot.config.interceptor;
import java.net.URI;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.util.UriComponentsBuilder;
public class AccessInterceptor implements HandlerInterceptor {
private Logger logger = LoggerFactory.getLogger(this.getClass());
/**
* 컨트롤러에 진입하기 전에 실행됩니다.
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
logger.info("[AccessInterceptor] 시작 ==============================================================");
String url = request.getRequestURI();
URI uri = UriComponentsBuilder.fromUriString(request.getRequestURI()).query(request.getQueryString()).build()
.toUri();
int httpStatus = response.getStatus();
logger.info("AccessInterceptor preHandle >> url :: {} >> {}", url, httpStatus);
logger.info("AccessInterceptor preHandle >> uri :: {} >> {}", uri, httpStatus);
logger.info("1. 접근 URL 체크");
// HttpSession session = request.getSession();
// UserVO loginVO = (UserVO) session.getAttribute("loginUser");
//
// if (ObjectUtils.isEmpty(loginVO)) {
// response.sendRedirect("/moveLogin.go");
// return false;
// } else {
// session.setMaxInactiveInterval(30 * 60);
// return true;
// }
logger.info("[AccessInterceptor] 끝 ==============================================================");
return true;
}
/**
* 컨트롤러 진입 후 View가 랜더링 되기 전에 수행됩니다.
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
logger.info("AccessInterceptor postHandle");
}
/**
* 컨트롤러 진입 후 view가 랜더링 된 후에 실행되는 메소드입니다.
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {
logger.info("AccessInterceptor afterCompletion");
}
}
Email Address -> User ID 로 변경 했습니다.
로그인 하는 동안의 Log
23:00:03.097 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationProvider - [CustomAuthenticationProvider] 시작 ---------------------------------
23:00:03.097 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationProvider - authentication.getName() :: 2511
23:00:03.097 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationProvider - ---------------------------------
23:00:03.097 [http-nio-9090-exec-8] INFO c.s.s.c.auth.PrincipalDetailsService - [PrincipalDetailsService] userId :: 2511
23:00:03.099 [http-nio-9090-exec-8] DEBUG org.hibernate.SQL - select user0_.id as id1_1_0_, user0_.createDate as createda2_1_0_, user0_.email as email3_1_0_, user0_.password as password4_1_0_, user0_.role as role5_1_0_, user0_.username as username6_1_0_ from member user0_ where user0_.id=?
23:00:03.100 [http-nio-9090-exec-8] TRACE o.h.type.descriptor.sql.BasicBinder - binding parameter [1] as [BIGINT] - [2511]
23:00:03.101 [http-nio-9090-exec-8] TRACE o.h.t.descriptor.sql.BasicExtractor - extracted value ([createda2_1_0_] : [TIMESTAMP]) - [2022-03-01 14:01:26.729303]
23:00:03.101 [http-nio-9090-exec-8] TRACE o.h.t.descriptor.sql.BasicExtractor - extracted value ([email3_1_0_] : [VARCHAR]) - [hermeswing@test.com]
23:00:03.101 [http-nio-9090-exec-8] TRACE o.h.t.descriptor.sql.BasicExtractor - extracted value ([password4_1_0_] : [VARCHAR]) - [$2a$10$C4BZPH4raAlKGrvy9dtyyufBp56af2W6fge0hD1wLctWvNEjrK.AG]
23:00:03.101 [http-nio-9090-exec-8] TRACE o.h.t.descriptor.sql.BasicExtractor - extracted value ([role5_1_0_] : [VARCHAR]) - [ROLE_ADMIN]
23:00:03.101 [http-nio-9090-exec-8] TRACE o.h.t.descriptor.sql.BasicExtractor - extracted value ([username6_1_0_] : [VARCHAR]) - [홍길동]
23:00:03.102 [http-nio-9090-exec-8] INFO c.s.s.c.auth.PrincipalDetailsService - [PrincipalDetailsService] user :: com.study.springboot.system.entity.User@26f4d238
23:00:03.102 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationProvider - principalDetails.getUsername :: 홍길동
23:00:03.102 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationProvider - principalDetails.getPassword :: $2a$10$C4BZPH4raAlKGrvy9dtyyufBp56af2W6fge0hD1wLctWvNEjrK.AG
23:00:03.102 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationProvider - principalDetails.getAuthorities :: [ROLE_ADMIN]
23:00:03.102 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationProvider - authentication.getCredentials() :: 1234
23:00:03.163 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationProvider - [CustomAuthenticationProvider] 끝 ---------------------------------
23:00:03.163 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationSuccessHandler - [CustomAuthenticationSuccessHandler] 인증성공 ==============================================================
23:00:03.163 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationSuccessHandler - 1. 사용자 Session 생성
23:00:03.163 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationSuccessHandler - session 정보 :: 8CD462D80262C18C529B571036062F60
23:00:03.164 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationSuccessHandler - UserDetail :: null
23:00:03.164 [http-nio-9090-exec-8] INFO c.s.s.c.a.CustomAuthenticationSuccessHandler - [CustomAuthenticationSuccessHandler] 끝 ==============================================================
23:00:03.168 [http-nio-9090-exec-1] INFO c.s.s.c.i.AccessInterceptor - [AccessInterceptor] 시작 ==============================================================
23:00:03.168 [http-nio-9090-exec-1] INFO c.s.s.c.i.AccessInterceptor - AccessInterceptor preHandle >> url :: /main.do >> 200
23:00:03.168 [http-nio-9090-exec-1] INFO c.s.s.c.i.AccessInterceptor - AccessInterceptor preHandle >> uri :: /main.do >> 200
23:00:03.168 [http-nio-9090-exec-1] INFO c.s.s.c.i.AccessInterceptor - 1. 접근 URL 체크
23:00:03.168 [http-nio-9090-exec-1] INFO c.s.s.c.i.AccessInterceptor - [AccessInterceptor] 끝 ==============================================================
23:00:03.169 [http-nio-9090-exec-1] INFO c.s.s.c.i.AccessInterceptor - AccessInterceptor postHandle
23:00:03.171 [http-nio-9090-exec-1] INFO c.s.s.c.i.AccessInterceptor - AccessInterceptor afterCompletion
로그인 성공한 모습 -> 실패하면 로그인 페이지로 이동합니다.
인프런의 최주호 강사님 스프링부트 시큐리티 & JWT 강의 에서 발췌했습니다.
참고
인프런의 최주호 강사님 스프링부트 시큐리티 & JWT 강의
https://jhhan009.tistory.com/31
https://kimchanjung.github.io/programming/2020/07/02/spring-security-02/
'Spring Boot Framework' 카테고리의 다른 글
[Spring Boot] Spring Boot + Thymeleaf (0) | 2022.03.09 |
---|---|
[Spring Boot] Spring Security 에서 antMatchers 를 Array 로 처리 (0) | 2022.03.09 |
[Spring Boot] Spring Security #01 (0) | 2022.03.01 |
[Spring Boot] 정적 페이지 로딩 (0) | 2022.02.27 |
[Spring Boot] Multi FileUpload Test (0) | 2022.02.27 |