반응형
필터와 인터셉터란?
- 인증과 보안이 필요한 사이트에서 해당 서비스를 사용할 수 없어야한다.
- 스프링에서는 인터셉터와 필터를 통하여 검증로직을 작성할 수 있다.
- 필터는 서블릿 자체의 기술이며 스프링이 서블릿 기반의 기술이기 때문에 필터를 사용할 수 있는것이다.
- 인터셉터는 스프링 자체의 기술이다.
- 즉 스프링은 서블릿 기반의 기술이므로 검증을 할 수 있는 수단이 2가지가 있는것이다.
필터와 인터셉터의 흐름
- 필터와 인터셉터의 전체적인 흐름은 아래와 같다.
HTTP요청 -> WAS -> Filter -> Servlet -> Intercepter -> Controller
- 필터는 서블릿에 도달되기전에 검증로직을 수행한다.
- 인터셉트는 컨트롤러에 도달하기전에 검증로직을 수행한다.
- 필터는 아래와 같은 인터페이스를 통해서 구현할 수 있다.
- 아래에서 부터는 순서대로 필터와 인터셉터에 대해서 설명하겠다.
필터의 인터페이스
public interface Filter {
public default void init(FilterConfig filterConfig) throws ServletException{}
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;
public default void destroy() {} }
- 필터의 인터페이스는 위와 같이 구현되었다. 자세히 살펴보자
init()
: 필터의 초기화 메서드이다. 서블릿 컨테이너가 생성될 때 호출된다.doFilter()
: 고객의 요청이 올 때 마다 해당 메서드가 호출된다. 여기에 필터의 로직을 구현하면 된다.destroy()
: 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.
서블릿 필터를 활용하여 최초 요청 로그를 남겨 보는 예제를 수행해보자
package hello.login.web.filter;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;
@Slf4j
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("log filter init");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("log filter doFilter");
//ServletRequest는 HttpServletRequest의 부모인데 ServletRequest의 기능은 너무 적기 때문에 HttpServletRequest로 캐스팅 해줘야한다.
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
String uuid = UUID.randomUUID().toString();
try{
log.info("REQUEST [{}][{}]", uuid, requestURI);
//chain은 filter과정이 더 남아있으면 더 남은 과정으로 이동하고 아닐경우 servlet과정을 진행한다. 현재 스프링을 사용하므로 정확히는 DispatchServlet으로 넘어가서 mapping과정을 진행한다.
chain.doFilter(request, response);
}catch (Exception e){
throw e;
}finally {
log.info("RESPONSE [{}][{}]", uuid, requestURI);
}
}
@Override
public void destroy() {
System.out.println("log filter destroy");
}
}
- 먼저 init() 메소드와 destroy에는 간단하게 출력문을 통하여 Server에 정상진입한것을 알려주었다.
- 이후 doFilter()에 핵심로직을 구현하였다.
- 요청된 URL(RequestURL)정보를 requestURI 변수에 저장한다.
- 이후 UUID메소드를 사용하여 로그번호를 생성한다.
- 이후 추가로 존재하는 Filter 로직을 수행하기 위해 chadin.doFilter()로 다음 필터 로직을 수행한다.
💡 여러 필터들이 추가된 것을 **필터 체인**이라고 한다.
- 이후 각 필터들의 수행을 마친 순서대로 finally 로직이 호출된다.
- 재귀함수 로직으로 생각하면 좀 더 이해하기 쉽다.
- 이후 필터가 소멸될 때 destroy() 로직이 수행된다.
package hello.login;
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
//모든 URL에서 작동하게 설정
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
- 마지막으로 필터에 대한 정보를 추가하기 위한 WebConfig를 작성하여 Bean에 추가해주자
- setFilter를 통하여 등록하고자 하는 필터를 설정한다.
- 이후 필터 체인이 존재할 경우를 대비하여 setOrder를 사용하여 필터의 순서를 지정해준다.
- 다음으로 addUrlPatterns를 사용하여 조건에 맞는 URL이 도착했을 경우에만 동작 하도록 지정해준다.(현재 예제 기준 모든 URL에서 동작할 수 있도록 와일드카드(*)로 설정했다)
추가로 로그인 세션관리를 필터도 추가해보자
package hello.login.web.filter;
import hello.login.web.session.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.PatternMatchUtils;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.regex.Pattern;
@Slf4j
public class LoginCheckFilter implements Filter {
private static final String[] whitelist = {"/", "/members/add", "/login", "/logout", "/css/*"};
//init,destroy에는 defalut가 선언되어있기 때문에 구현이 필수가 아니다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
HttpServletResponse httpResponse = (HttpServletResponse) response;
try{
log.info("인증 체크 필터 시작 {}", requestURI);
if(isLoginCheckPath(requestURI)){
log.info("인증 체크 로직 실행 {}", requestURI);
HttpSession session = httpRequest.getSession(false);
if(session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
log.info("미인증 사용자 요청 {}", requestURI);
httpResponse.sendRedirect("login?redirectURL=" + requestURI);
return; //여기서 중요, 미인증 사용자는 다음으로 진행하지 않고 진행 종료
}
}
//매우 중요, 더 진행할 필터가 없는지 검토 후 Servlet을 호출, 실질적으로 이거 빼면 DispatchServlet이 호출이 안되서 안돌아간다.
chain.doFilter(request, response);
}catch(Exception e){
throw e; //예외 로깅이 가능하지만, 톰캣까지 예외를 보내줘야 한다.
}finally{
log.info("인증 체크 필터 종료 {}", requestURI);
}
}
/**
* 화이트 리스트의 경우 인증 체크
*/
private boolean isLoginCheckPath(String requestURI){
//PatternMatchUtils를 활용하여 내부의 값이 있는지 없는지 판단 해주는 메소드. 현재 예제 기준 whitelist안에 requestURI가 있다면 참, 없으면 false
return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
}
- whitelist에 검증을 수행하지 않을 로직을 명시해주었다.
- 로그인 인증로직을 수행한 이후에 사용자가 사용하던 홈페이로 반환해주기 위해 rdiricetURL로 sendRedircet 해주었다.(Controller에서 별도의 로직 작성이 필요함)
LoginCheckFilter 추가하기
package hello.login;
import hello.login.web.argumentresolver.LoginMemberArgumentResolver;
import hello.login.web.filter.LogFilter;
import hello.login.web.filter.LoginCheckFilter;
import hello.login.web.interceptor.LogInterceptor;
import hello.login.web.interceptor.LoginCheckInterceptor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.servlet.Filter;
import javax.servlet.FilterRegistration;
import java.util.List;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean logFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
//모든 URL에서 작동하게 설정
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
//loginCheckFilter 함수를 추가해주었다. 동작 원리는 이전 logFilter 설명 참고
@Bean
public FilterRegistrationBean loginCheckFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
Controller
@PostMapping("/login")
public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,
@RequestParam(defaultValue = "/") String redirectURL, HttpServletRequest request){
if(bindingResult.hasErrors()){
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
log.info("login? {}", loginMember);
if(loginMember == null){
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않았습니다.");
return "login/loginForm";
}
//로그인 성공 처리
//세션이 있으면 있는 세션 반화, 없으면 신규 세션 생성
HttpSession session = request.getSession();
//세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
//redirectURL 적용
return "redirect:" + redirectURL;
}
인터셉터 인터페이스
package org.springframework.web.servlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.web.method.HandlerMethod;
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
return true;
}
default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable ModelAndView modelAndView) throws Exception {
}
default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable Exception ex) throws Exception {
}
- 인터셉터 또한 필드와 유사한 인터페이스를 가지고 있으나 몇 가지 차이점이 존재한다.
- 필터 같은 경우 수행 로직이 doFilter()만 있다면 인터셉터는 preHandle과 postHandle이 존재한다. 그림을 보면서 인터셉터의 로직을 이해해보자
- preHandler()은 컨트롤러 호출 전에 수행된다.
- 만약 응답값이 true라면 다음으로 진행하고 false라면 진행을 멈춘다.
- false인 경우 나머지 인터셉터, 핸들러 어뎁터도 호출하지 않고 바로 종료된다.
- PostHandle은 컨트롤러 호출 후에 호출된다.
- 컨트롤러에서 예외가 발생하면 호출되지 않는다.
- 이는 그림을 보면 이해하기 쉽다.
- 컨트롤러의 로직이 정상적으로 수행되지 않았기 때문에 수행할 필요가 없는것이다.
- 그렇기 때문에 Controller에서 수행로직이 진행된 이후에 로직 수행의 필요성을 느낀다면 PostHandle을 사용하자
- 컨트롤러에서 예외가 발생하면 호출되지 않는다.
- afterCompletion()은 뷰가 렌더링 된 이후에 호출한다. 서블릿의 destroy()와 비슷하니 이해가 어렵되면 destroy()를 생각해보자!
- afterCompletion()은 예외가 발생해도 항상 수행된다.
- 요청과 상관없이 항상 수행해야 된다면 afterCompletion()을 사용하자
스프링 인터셉터를 활용해 요청 로그를 만들어 보자
package hello.login.web.interceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;
@Slf4j
public class LogInterceptor implements HandlerInterceptor{
public static final String LOG_ID = "logId";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
String uuid = UUID.randomUUID().toString();
request.setAttribute(LOG_ID, uuid);
//@RequestMapping: HandlerMethod
//정적리소스: ResourceHttpRequestHandler
if(handler instanceof HandlerMethod){
HandlerMethod hm = (HandlerMethod) handler; //호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
}
log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
return true; //false 진행X
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
log.info("postHandle [{}]", modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
String requestURI = request.getRequestURI();
String logId = (String)request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}]", logId, requestURI);
if(ex != null){
log.error("afterCompletion error!", ex);
}
}
}
- Filter의 예제 코드와 마찬가지로 로그의 로그번호를 받기 위해 UUID를 사용하였다.
- 이후 request에 정보를 담아준다.
- Handler의 정보는 스프링에서는 일반적으로 @Controller, @RequestMapping을 사용해서 HandlerMethod로 정보가 넘어온다. 그래서 HandlerMethod로 형변환을 진행해주었다.
- 이후 return 값이 true라면 다음 인터셉터를 호출한다.
💡 인터셉터도 필터와 마찬가지로 연속적인 인터셉가 존재 가능하며 인터셉터 체인 이라는 용어가 존재한다!
- 필터와의 차이점이 여기서 드러나는데 복수의 인터셉터를 등록하기 위한 순서와 인터셉터를 적용할 URL을 Configuration 파일에 등록할 때 설정해준다.
package hello.login;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry){
//더할인터셉터, 순서, 응답패턴, 제외패턴순
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**")
.excludePathPatterns("/", "/members/add", "/login", "/logout",
"/css/**", "/*.ico", "/error");
}
// @Bean
public FilterRegistrationBean logFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
//모든 URL에서 작동하게 설정
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
//@Bean
public FilterRegistrationBean loginCheckFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
- addIntecrceptors()메서드를 확인해보면 order, addPathPatterns, excludePathPatterns 를 통하여 순서, 인터셉터를 적용시킬 URL 패턴, 인터셉터를 제외할 패턴을 등록할 수 있다.
이번에는 로그인 회원 세션관리를 인터셉터로 수행해보자.
package hello.login.web.interceptor;
import hello.login.web.session.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
log.info("인증 체크 인터럽트 실행 {}", requestURI);
HttpSession session = request.getSession();
if(session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
log.info("미인증 사용자 요청");
//로그인으로 redirect
response.sendRedirect("/login?redirectURL=" + requestURI);
return false;
}
return true;
}
}
- 서블릿 필터의 인증로직에 비해 코드가 매우 간결하다. 인증이라는 것은 컨트롤러 호출 전에만 호출되면 되기 때문이다.
- 코드를 보면 의문인게 PostHandle과 afterCompletion은 정의 안해줘도 되는가라는 질문이 생긴다
- 내부 HandlerInterceptor안에 default 선언으로 구현되어있기 때문에 기능을 사용 안 할 경우 정의를 해주지 않아도 된다.
- 그렇기 때문에 필터보다 좀 더 간결하고 깔끔하게 코드를 작성할 수 있다.
- 사실 기능적으로 필터보다도 인터셉터가 우수하므로 특별한 예외적인 이유가 없다면 인터셉터를 사용하자
ArgumentResolver를 활용하여 검증로직 구현하기
- 이전 코드들은 전부 별도의 로직을 추가로 해주지만 Controller단에서 검증을 할 때 불투명하게 보이는 점이 있다.
- 그래서 아래처럼 ArgumentResolver를 활용해서 @Login 어노테이션을 만들어 검증로직을 Controller 코드만 봐도 검증을 수행하고 있다는것을 보여주도록 하겠다.
@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model){
//세션에 회원 데이터가 없으면 home
if(loginMember == null){
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
- Login 어노테이션 생성하기
package hello.login.web.argumentresolver;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
//Target은 어노테이션이 사용될 수 있는 영역을 지정해주는것, 현재 예제 기준 파라미터에만 사용한다
@Target(ElementType.PARAMETER)
//리플렉션 등을 활용할 수 있도록 런타임까지 에노테이션 정보가 남아있는다.
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
- Login 어노테이션이 사용되었다면 검증로직을 수행할 수 있도록 HandlerMethodArgumentResolver를 구현해준다.
package hello.login.web.argumentresolver;
import hello.login.domain.member.Member;
import hello.login.web.session.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
//내부에 캐쉬가 있어서 최초 1회만 실행된다.
@Override
public boolean supportsParameter(MethodParameter parameter) {
log.info("supportsParameter 실행");
//Login 어노테이션이 붙어 있는지 확인
boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
//할당되어있는 Form형식이 Member.class면 참
boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());
return hasLoginAnnotation && hasMemberType;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
log.info("resolverArgument 실행");
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
HttpSession session = request.getSession(false);
if(session == null){
return null;
}
return session.getAttribute(SessionConst.LOGIN_MEMBER);
}
}
- supportParameter의 반환 자료형은 boolean형이다.
- true가 반환되면 resolveArgument 메서드가 수행되고 false일 경우 검증 로직이 수행되지 않는다.
- 그렇기 때문에 supportsParamet에서 Member클래스이고 Parameter의 데이터 폼이 일치하면 True 자료형을 반환하여 resolveArgument가 수행된다.
- resolveArgument에서 세션이 존재한다면 Controller의 어노테이션 옆에 있는 변수에 정보를 담아주게 된다.
- supportsParameter에서 검증로직에 실패한다면 supportsParameter에서 종료된다.
마지막으로 WebMvcConfiguer를 통해 인터셉터를 추가해주자
package hello.login;
import hello.login.web.argumentresolver.LoginMemberArgumentResolver;
import hello.login.web.filter.LogFilter;
import hello.login.web.filter.LoginCheckFilter;
import hello.login.web.interceptor.LogInterceptor;
import hello.login.web.interceptor.LoginCheckInterceptor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.servlet.Filter;
import javax.servlet.FilterRegistration;
import java.util.List;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
@Override
public void addInterceptors(InterceptorRegistry registry){
//더할인터셉터, 순서, 응답패턴, 제외패턴순
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**")
.excludePathPatterns("/", "/members/add", "/login", "/logout",
"/css/**", "/*.ico", "/error");
}
// @Bean
public FilterRegistrationBean logFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
//모든 URL에서 작동하게 설정
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
//@Bean
public FilterRegistrationBean loginCheckFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
예제의 모든 코드
https://github.com/NamHyeop/Spring_Boot_Study/tree/master/4.spring-MVC2/login
REFERENCE
0.https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
반응형
'Spring' 카테고리의 다른 글
Spring의 API 예외 처리 (0) | 2022.07.05 |
---|---|
Spring의 예외처리와 오류페이지 (0) | 2022.07.04 |
Spring의 쿠키,세션 (0) | 2022.06.29 |
Spring의 Bean Validation (0) | 2022.06.28 |
Spring 메시지, 국제화 (0) | 2022.06.27 |