백기선님이 강의하신 인프런 스프링 시큐리티 강좌를 학습하고 정리한 내용입니다. 소스코드는 Github를 참고해주세요.
Prerequisites
- Installing MySQL 5.7
Account Info
- Normal User : user / 123
- Admin User : admin / !@#
Password Encoder
비밀번호는 평문이 아닌 단방향 알고리즘으로 인코딩해서 저장해야 한다
- {id}encodePassword
PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
Password Encoder 종류
- BCryptPasswordEncoder
- NoOpPasswordEncoder
- Pbkdf2PasswordEncoder
- ScryptPasswordEncoder
- StandardPasswordEncoder
Spring Web Mock Mvc Test
@AutoConfigureMockMvc
를 사용하면 MockMvc 테스트를 진행할 수 있다
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class SpringBootTest {
}
Anonymous, User, Admin Test
Anonymous
@Test
@WithAnonymousUser
public void index_anonymous() throws Exception {
mockMvc.perform(get(INDEX_PAGE))
.andDo(print())
.andExpect(status().isOk());
}
User
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "user", roles="USER")
public @interface WithNormalUser {
}
@Test
@WithNormalUser
public void index_user() throws Exception {
mockMvc.perform(get(INDEX_PAGE))
.andDo(print())
.andExpect(status().isOk());
}
Admin
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "admin", roles="ADMIN")
public @interface WithAdminUser {
}
@Test
@WithAdminUser
public void admin_admin() throws Exception {
mockMvc.perform(get(ADMIN_PAGE))
.andDo(print())
.andExpect(status().isOk());
}
SecurityContextHolder와 Authentication
- SecurityContext 제공
- 하나의 Thread에서 Authentication 공유하기 위해서 ThreadLocal 사용
Authentication
는 Principal과 GrantAuthority 제공- Principal은 사용자에 대한 정보
- GrantAuthority는 권한 정보 (인가 및 권한 확인할 때 사용)
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 사용자 정보
Object principal = authentication.getPrincipal();
// 사용자 권한
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
// 인증 여부
boolean authenticated = authentication.isAuthenticated();
UserDetailsService 클래스는 DAO로 사용자 정보를 가져오는 작업을 수행한다. 실제 인증은 AuthenticationManager 인터페이스가
수행한다.
AuthenticationManager와 Authentication
- 스프링 시큐리티에서 인증은 AuthenticationManager가 수행
- SecurityContext는 인증 정보를 갖고 있음
- 대부분 AuthenticationManager 인터페이스를 구현한
ProviderManager
구현체 클래스를 사용한다
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
DaoAuthenticationProvider
- UsernamePasswordAuthenticationToken은 DaoAuthenticationProvider가 인증하는 작업을 처리
- UserDetailsService 인터페이스를 구현한 클래스의
loadUserByUsername
메서드를 호출 - AccountService 클래스의 loadUserByUsername 메서드는 User 객체를 반환
- User 클래스는 UserDetails 인터페이스를 구현한 구체 클래스
public class AccountService implements UserDetailsService {
@Autowired
AccountRepository accountRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Account account = accountRepository.findByUsername(username);
if (account == null) {
throw new UsernameNotFoundException(username);
}
return User.builder()
.username(account.getUsername())
.password(account.getPassword())
.roles(account.getRole())
.build();
}
}
ThreadLocal
java.lang
패키지에서 제공하는 쓰레드 범위 변수- 쓰레드 수준의 데이터 저장소
- 같은 쓰레드 내에서만 공유
- 같은 쓰레드라면 해당 데이터를 메서드의 매개변수로 넘겨줄 필요 없음
public class AccountContext {
private static final ThreadLocal<Account> ACCOUNT_THREAD_LOCAL
= new ThreadLocal<>();
public static void setAccount(Account account) {
ACCOUNT_THREAD_LOCAL.set(account);
}
public static Account getAccount() {
return ACCOUNT_THREAD_LOCAL.get();
}
}
SecurityContextHolder에 Authentication 정보를 제공하는 필터
-
UsernamePasswordAuthenticationFilter
AuthenticationManager
를 이용해서 사용자가 입력한 로그인 정보(이름, 비밀번호)를 인증- 인증에 성공하면
successfulAuthentication
메서드를 호출 - SecurityContextHolder의 SecurityContext에 인증 정보를 저장
-
SecurityContextPersistenceFilter
HttpSessionSecurityContextRepository
저장소를 통해 SecurityContext 정보를 가져온다- 기본 전략으로 Http 세션에 저장하고 복원한다
- Repository에서 가져온 SecurityContext 정보를 다시 SecurityContextHolder에 넣어 준다
스프링 시큐리티 Filter와 FilterChainProxy
- FilterChainProxy는 요청(HttpServletRequest)에 따라 적합한
SecurityFilterChain
을 사용 - 기본 전략으로
DefaultSecurityFilterChain
을 사용 DefaultSecurityFilterChain
는 Filter 리스트를 가지고 있다- SecurityFilterChain을 여러개 만들고 싶으면 SecurityConfig 클래스를 여러개 만든다
- 이 때 SecurityConfig가 상충할 수 있으니 Order 어노테이션을 통해 우선순위를 지정한다
- Filter 개수는 SecurityConfig 설정에 따라 달라진다
- FilterChainProxy는 필터를 호출하고 실행한다
- WebAsyncManagerIntergrationFilter
- SecurityContextPersistenceFilter
- HeaderWriterFilter
- CsrfFilter
- LogoutFilter
- UsernamePasswordAuthenticationFilter
- DefaultLoginPageGeneratingFilter
- DefaultLogoutPageGeneratingFilter
- BasicAuthenticationFilter
- RequestCacheAwareFilter
- SecurityContextHolderAwareReqeustFilter
- AnonymouseAuthenticationFilter
- SessionManagementFilter
- ExeptionTranslationFilter
- FilterSecurityInterceptor
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/", "/info").permitAll()
.mvcMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated();
http.formLogin();
http.httpBasic();
}
}
DelegatingFilterProxy
- 일반적인 서블릿 필터
- 서블릿 필터 처리를 스프링에 들어있는 빈으로 위임하고 싶을 때 사용하는 서블릿 필터
- 타겟 빈 이름을 설정
- 스프링 부트(자동 설정) 없이 스프링 시큐리티 설정할 때는
AbstractSecurityWebApplicationInitializer
를 사용해서 등록 - 스프링 부트를 사용할 때는 자동으로 등록 (
SecurityFilterAutoConfiguration
) FilterChainProxy
는 springSecurityFilterChain 이름으로 빈 등록
public abstract class AbstractSecurityWebApplicationInitializer
implements WebApplicationInitializer {
private static final String SERVLET_CONTEXT_PREFIX = "org.springframework.web.servlet.FrameworkServlet.CONTEXT.";
public static final String DEFAULT_FILTER_NAME = "springSecurityFilterChain";
...
}
AccessDecisionManager
Access Control 결정을 내리는 인터페이스, 구현체 3가지를 기본으로 제공한다
- AffirmativeBased : 여러 Voter 중에 한 명이라도 허용하면 인가 (기본 전략)
- ConsensusBased : 다수결
- UnanimousBased : 만장일치
public interface AccessDecisionManager {
void decide(Authentication authentication, Object object,
Collection<ConfigAttribute> configAttributes) throws AccessDeniedException,
InsufficientAuthenticationException;
boolean supports(ConfigAttribute attribute);
boolean supports(Class<?> clazz);
}
AccessDecisionVoter
- Authentication이 특정한 Object에 접근할 때 필요한 ConfigAttribute를 만족하는지 확인
- WebExpressionVoter : 웹 시큐리티에서 사용하는 기본 구현체, ROLE_XXX 일치하는지 확인
- RoleHierarchyVoter : 계층형 Role 지원
Custom AccessDecisionManager
RoleHierarchyImpl
객체에 Role 계층을 설정
public AccessDecisionManager accessDecisionManager() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
handler.setRoleHierarchy(roleHierarchy);
WebExpressionVoter webExpressionVoter = new WebExpressionVoter();
webExpressionVoter.setExpressionHandler(handler);
List<AccessDecisionVoter<? extends Object>> voters = Arrays.asList(webExpressionVoter);
return new AffirmativeBased(voters);
}
FilterSecurityInterceptor
- FilterChainProxy가 호출하는 시큐리티 필터 목록 중에 하나이며, 대부분 가장 마지막에 위치함
- 인증이 된 상태에서 특정 리소스에 접근할 수 있는지 Role을 확인함
AccessDecisionManager
를 사용해서 Access Control 또는 예외 처리하는 필터
AbstractSecurityInterceptor
- FilterSecurityInterceptor 클래스의 부모 클래스
ExceptionTranslationFilter
- 필터 체인에서 발생하는
AccessDeniedException
과AuthenticationException
을 처리하는 필터
AuthenticationException
- 인증에 실패할 때 발생하는 예외
- AbstractSecurityInterceptor 하위 클래스에서 발생하는 예외만 처리
AccessDeniedException
- 익명 사용자라면 AuthenticationEntryPoint 실행 (로그인 페이지로 이동)
- 익명 사용자가 아니라면 AccessDeniedHandler에게 위임
스프링 시큐리티 적용 무시하기 (ignoring)
인증이 필요없는 페이지를 접속할 때 favicon.ico와 같은 정적 자원을 요청하는 경우에 FilterChainProxy 리스트의 필터를 타게 된다. 아래 이미지에서 favicon.ico를 요청하면 DefaultLoginPageGeneratingFilter 필터가 인증을 위해서 login 요청을 다시 하게된다.
이러한 정적 자원을 필터에서 제외하기 위해서는 다음과 같이 WebSecurity에 ignoring
을 설정해야 한다.
CommonLocations은 5개의 자원에 대해 필터를 무시하도록 한다.
@Override
public void configure(WebSecurity web) {
web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
WebSecurityConfigurerAdapter
상속 받은 클래스에서 정적 자원을 무시하도록 설정하고, 다시 인증이 필요없는 페이지를 접속하게 되면 다음과 같이 스프링 필터를 적용하지 않고 바로 정적 자원을 전달한다.
WebAsyncManagerIntegrationFilter
스프링 MVC의 Async 기능을 사용할 때도 SecurityContext를 공유하도록 도와주는 필터
- PreProcess: SecurityContext를 설정한다.
- Callable: 비록 다른 쓰레드지만 그 안에서는 동일한 SecurityContext를 참조할 수 있다.
- PostProcess: SecurityContext를 정리(clean up)한다.
MVC 요청이 들어오는 쓰레드 작업을 완료하고 나서도 SecurityContextHolder
에서는 사용자 정보를 동일하게 얻을 수 있다. 그 역할을
WebAsyncManagerIntegrationFilter가 수행한다.
@Controller
public class SampleController {
@GetMapping("/async-handler")
@ResponseBody
public Callable<String> asyncHandler() {
// http-nio-8080-exec 쓰레드
SecurityLogger.log("MVC");
return () -> {
// task-1 쓰레드
SecurityLogger.log("Callable");
return "Async Handler";
};
}
}
SecurityContextCallableProcessingInterceptor
WebAsyncManagerIntegrationFilter는 SecurityContextCallableProcessingInterceptor
를 사용해서 SecurityContextHolder에 SecurityContext 정보를 저장한다.
@Async 서비스에서 SecurityContextHolder 공유
- SecurityContextHolder 기본 전략은
ThreadLocal
- @Async 서비스에서 SecurityContextHolder가 공유 되지 않는 문제가 발생함
- SecurityContextHolder 전략을 다음 코드와 같이 바꾸면 쓰레드 계층 사이에서도 SecurityContextHolder 정보가
공유된다
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
SecurityContextPersistenceFilter
SecurityContextRepository
를 사용해서 기존의 SecurityContext 정보를 읽어오거나 초기화한다
- 기본으로 사용하는 전략은 HTTP Session 사용 (HttpSessionSecurityContextRepository)
- Spring-Session과 연동하여 세션 클러스터를 구현할 수 있다
HeaderWriterFilter
응답 헤더에 시큐리티 관련 헤더를 추가해주는 필터
- XContentTypeOptionsHeaderWriter : 마임 타입 스니핑 방어.
- XXssProtectionHeaderWriter : 브라우저에 내장된 XSS 필터 적용.
- CacheControlHeadersWriter : 캐시 히스토리 취약점 방어.
- HstsHeaderWriter : HTTPS로만 소통하도록 강제.
- XFrameOptionsHeaderWriter : clickjacking 방어.
CsrfFilter
CSRF 어택 방지 필터
- 인증된 유저의 계정을 사용해서 악의적인 변경 요청을 만들어 보내는 기법
- 의도한 사용자만 리소스를 변경할 수 있도록 허용하는 필터
- CSRF 토큰을 사용하여 체크
CsrfFilter Token
form 형식에 hidden 타입으로 csrf 토큰 값이 포함되어 있다
Postman을 이용해서 /signup
POST 요청을 보내면, 401 Unauthorized 에러가 발생한다. 이유는 csrf 토큰 값이 없어서 폼 인증이 되지 않기
때문에 발생한다.
CsrfFilter Test
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class SignUpControllerTest {
@Autowired
MockMvc mockMvc;
// SignUp Get 요청
@Test
public void signUpForm() throws Exception {
mockMvc.perform(get("/signup"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("_csrf")));
}
// SignUp Post 요청, csrf 토큰을 포함
@Test
public void processSignUp() throws Exception {
mockMvc.perform(post("/signup")
.param("username", "jayden")
.param("password", "123")
.with(csrf()))
.andDo(print())
.andExpect(status().is3xxRedirection());
}
}
CsrfFilter 비활성화
http.csrf().disable();
LogoutFilter
여러 LogoutHanlder를 사용하여 로그아웃시 필요한 작업을 수행한다. 그리고 LogoutSuccessHandler
를 사용해서
로그아웃 후처리를 한다.
Default LogoutHanlder
- CsrfLogoutHandler
- SecurityContextLogoutHandler
Default LogoutSuccessHandler
- SimpleUrlLogoutSuccessHandler
UsernamePasswordAuthenticationFilter
폼 로그인을 처리하는 인증 필터
- 사용자가 폼에 입력한 정보를 토대로 Authentication 객체를 생성하고
AuthenticationManager
를 사용하여 인증을 시도한다 - AuthenticationManager(ProviderManager)는 여러
AuthenticationProvider
를 사용하여 인증을 시도하는데, 그 중DaoAuthenticationProvider
는 UserDetailsService를 사용하여 UserDetails 정보를 가져와서 사용자가 입력한 정보와 동일한지 비교한다
DefaultLoginPageGeneratingFilter
기본 로그인 페이지를 생성하는 필터
사용자 이름과 비밀번호 파라미터 이름 변경
http.formLogin()
.usernameParameter("app_username")
.passwordParameter("app_password");
커스텀 로그인 페이지
커스텀 로그인 페이지를 등록하면 FilterChainProxy에서 DefaultLoginPageGeneratingFilter
와 DefaultLogoutPageGeneratingFilter
두 필터가 제외됨
http.formLogin()
.loginPage("/login");
DefaultLogoutPageGeneratingFilter
기본 로그아웃 페이지를 생성하는 필터
로그인/로그아웃 폼 커스터마이징
로그인/로그아웃 폼 페이지를 커스터마이징 하기 위해서 LogInOutController
를 생성한다. 이 컨트롤러는 Get 요청으로 로그인/로그아웃 페이지를 반환한다.
@Controller
public class LogInOutController {
@GetMapping("/login")
public String loginForm() {
return "/login";
}
@GetMapping("/logout")
public String logoutForm() {
return "/logout";
}
}
SpirngSecurity
설정에서 로그인 폼 페이지 URL과 로그아웃 URL을 설정한다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/", "/info", "/signup").permitAll()
.mvcMatchers("/admin").hasRole("ADMIN")
.mvcMatchers("/user").hasRole("USER")
.anyRequest().authenticated()
.accessDecisionManager(accessDecisionManager());
http.httpBasic();
http.formLogin()
.loginPage("/login")
.permitAll();
http.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/");
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
BasicAuthenticationFilter
- Http Basic 인증을 지원하는 필터
- 요청 헤더에 아이디와 패스워드를 보내면 브라우저 또는 서버가 그 값을 읽어서 인증하는 방식
- 정보는 Base64 인코딩 되어 보내지고 읽을 때 다시 디코딩해서 값을 읽는다
- 스니핑하면 요청 정보를 쉽게 취득하는 위험이 있기 때문에 HTTPS를 사용할 것을 권장
http.httpBasic();
RequestCacheAwareFilter
현재 요청과 관련 있는 캐시된 요청이 있는지 찾아서 적용하는 필터
- 캐시된 요청이 없다면, 현재 요청 처리
- 캐시된 요청이 있다면, 캐시된 요청 처리
대시보드(로그인이 필요한 페이지) 페이지를 접속하려고 하면 로그인 페이지로 이동한다. 로그인 페이지에서 로그인 인증을 수행하고 나면,
RequestCacheAwareFilter
에서 캐시한 요청(대시보드 페이지로 이동하려는 요청)을 수행한다.
public class RequestCacheAwareFilter extends GenericFilterBean {
private RequestCache requestCache;
public RequestCacheAwareFilter() {
this(new HttpSessionRequestCache());
}
public RequestCacheAwareFilter(RequestCache requestCache) {
Assert.notNull(requestCache, "requestCache cannot be null");
this.requestCache = requestCache;
}
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest wrappedSavedRequest = requestCache.getMatchingRequest(
(HttpServletRequest) request, (HttpServletResponse) response);
// 캐시된 요청이 있는지 체크하고 현재 요청을 처리할지 캐시된 요청을 처리할지 결정
chain.doFilter(wrappedSavedRequest == null ? request : wrappedSavedRequest,
response);
}
}
SecurityContextHolderAwareRequestFilter
시큐리티 관련 서블릿 API를 구현해주는 필터
- HttpServletRequest#authenticate(HttpServletResponse)
- HttpServletRequest#login(String, String)
- HttpServletRequest#logout()
- AsyncContext#start(Runnable)
AnonymousAuthenticationFilter
SecurityContext에 Authentication이 null 값이면, 익명 Authentication을 생성해서 넣어준다. Authentication이 null 값이 아니면, 아무일도 하지 않는 필터이다. (null object pattern)
스프링 시큐리티는 별도의 설정이 없어도 AnonymousUser를 기본적으로 생성한다. Principal은 anonymousUser이고 권한은 ROLE_ANONYMOUS로 설정한다.
SessionManagementFilter
- 세션 변조 방지 전략 설정
- 세션 변조 방지 전략으로 changeSessionId로 설정
http.sessionManagement() .sessionFixation() .changeSessionId();
- 세션 변조 방지 전략으로 changeSessionId로 설정
- 유효하지 않은 세션을 리다이렉트 시킬 URL 설정
- 동시성 제어
- 세션 개수 제어
- 추가 로그인을 막을지 여부 (기본값은 false)
http.sessionManagement() .maximumSessions(1) .maxSessionsPreventsLogin(true);
- 세션 생성 전략
- ALWAYS
- NEVER
- IF_REQUIRED
- STATELESS
ExceptionTranslationFilter
try-catch
구문으로 감싸고FilterSecurityInterceptor
를 처리한다- FilterSecurityInterceptor는
AccessDecisionManager
를 이용해서 인가 처리를 함 - AuthenticationEntryPoint, AccessDeniedException 예외를 처리함
FilterSecurityInterceptor
- Http 리소스 시큐리티 처리를 담당하는 필터
AccessDecisionManager
를 사용하여 인가를 처리
http.authorizeRequests()
.mvcMatchers("/", "/info", "/signup").permitAll()
.mvcMatchers("/admin").hasRole("ADMIN")
.mvcMatchers("/user").hasRole("USER")
.anyRequest().authenticated()
.accessDecisionManager(accessDecisionManager());
RememberMeAuthenticationFilter
- 세션이 사라지거나 만료가 되더라도 쿠키 또는 DB를 사용하여 저장된 토큰 기반으로 인증을 지원하는 필터
RememberMe 설정
페이지에 접속하면 서버에서 세션이 생성되고 웹 브라우저 쿠키에 세션 아이디 정보가 담긴다. 로그인 하고 나면 서버는 해당 세션을 인증된 세션으로 취급한다.
사용자가 웹 브라우저 쿠키에서 세션 아이디를 삭제하게 되면, 인증된 세션이 아니기 때문에 서버는 다시 로그인 창으로 리다이렉트 된다.
세션 아이디를 삭제하면 SecurityContextHolder
에서 인증 정보를 가져올 수 없기 때문에 서버는 인증되지 않은 사용자로 판단하고 인증이 필요한 페이지의 접속을 막는다.
다음과 같이 rememberMe
설정을 하고 로그인할 때, remember-me 파라미터를 넘기면 remember-me 쿠키 정보가 생기게 된다. remember-me 쿠키에는 사용자 이름과 유효 기간 정보를 포함하고 있다.
http.rememberMe()
.userDetailsService(accountService)
.key("remember-me");
앞에서 한 것처럼 다시 세션 아이디를 삭제하고 나서 다시 인증이 필요한 페이지를 요청하면 로그인 페이지로 리다이렉트 하지 않는다. 필터 체인 목록에서RememberMeAuthenticationFilter
가 RememberMeAuthenticationToken 정보를 이용해서 인증하고, 인증된 정보를 다시SecurityContextHolder
에 넣어준다.
크롬 웹 브라우저에서 현재 접속한 페이지의 쿠키 정보를 쉽게 확인할 수 있는 플러그인으로 EditThisCookie를 설치해서 사용했다.
커스텀 필터 추가하기
Filter를 생성하는 것은 여러 방법이 있지만 이번에 추가하는 LoggingFilter는 GenericFilterBean
클래스를 상속 받아서 구현하도록 한다. GenericFilterBean
클래스에는 기본적인 설정이 되어 있기 때문에 상속 받은 클래스가 doFilter 메서드만 오버라이드 하면 된다.
public class LoggingFilter extends GenericFilterBean {
private Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
chain.doFilter(request, response);
stopWatch.stop();
logger.info(stopWatch.prettyPrint());
}
}
새로 생성한 LoggingFilter
필터를 필터 목록에서 원하는 위치로 설정할 수 있다.
WebAsyncManagerIntegrationFilter
필터는 필터 목록에서 가장 첫 번째에 위치하는 필터이다.
http.addFilterBefore(new LoggingFilter(), WebAsyncManagerIntegrationFilter.class);
'Spring' 카테고리의 다른 글
스프링 데이터 JPA와 Querydsl 인프런 강의 정리 (2) | 2020.02.03 |
---|---|
스프링 부트와 JPA 활용1,2 인프런 강의 정리 (0) | 2020.01.20 |
Spring 파일 업로드 구현 및 파일 크기 설정 (0) | 2019.07.26 |
ServletContextListener 이벤트 처리 (0) | 2019.07.26 |
스프링에서 Exception 핸들러 매핑하기 (0) | 2019.07.25 |
댓글