[SpringMVC] 인터셉터(interceptor) 적용 - session
개요
프로젝트를 진행하던 중 개발 중인 가입된 유저만 접근이 가능하도록 제한하기 위한 접근 권한이 필요하였다. Spring에서는 Spring Security와 같이 애플리케이션의 보안을 담당하는 하위 프레임워크가 존재한다. 하지만 워낙 양과 자료가 방대하여 배울 내용이 많았기 때문에 적용하는데 어려움이 많았다. 그렇기 때문에 다른 차선책을 찾아야만 했다.
관련 키워드를 검색하던 중 Spring interceptor에 대하여 알게되었다. interceptor는 말 그대로 무언가를 가로채는 역할을 한다.
Interceptor 흐름도
interceptor는 위와 같은 흐름으로 Controller(Handler) 로 가기전에 요청을 가로채는 preHandle, controller 처리 후 postHandle, 마지막으로 전체 요청이 끝난 후 처리되는 afterCompletion 으로 이루어져 있다.
이러한 Spring Interceptor는 Handler의 역할을 하는 controller로 가기전에 가로채기 때문에 정식 명칭은 HandlerInterceptor이다.
HandlerInterceptor와 Session을 활용하여 간단한 로그인, 로그아웃 그리고 annotation을 활용한 권한 인증까지 진행해보려 한다.
프로젝트 생성
build.gradle
plugins {
id 'org.springframework.boot' version '2.4.5'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'me.hyeonic'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
test {
useJUnitPlatform()
}
spring boot의 버전은 2.4.5를 사용하였다. interceptor를 사용하기 위해서는 spring-boot-starter-web이 필요하다. 또한 view를 html로 활용하기 위해 thmeleaf 또한 추가하였다. 그밖에도 lombok 사용을 위한 dependencies를 추가하였다.
domain 및 repository 생성
간단한 예제를 위하여 User 클래스를 생성하였다.
User.java
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import me.hyeonic.springinterceptor.interceptor.Role;
@Getter
@Setter
@ToString
public class User {
private Long id;
private String email;
private String password;
private String name;
private Role role;
public User() {
}
public User(String email, String password, String name) {
this.email = email;
this.password = password;
this.name = name;
}
}
JPA를 사용하지 않고 간단한 예제를 구성하였기 때문에 @Entity와 같은 추가적인 annotation은 필요하지 않다. Role은 후에 등장하는 권한 설정을 위한 enum 클래스이다.
Role.java
public enum Role {
ADMIN, USER;
}
간단하게 ADMIN과 USER로 구분한다.
UserRepository.java
import me.hyeonic.springinterceptor.domain.User;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Repository
public class UserRepository {
private static Map<Long, User> userMap = new HashMap<>();
private static long count = 1L;
private UserRepository() {}
public Long save(User user) {
user.setId(count);
userMap.put(count, user);
count++;
return user.getId();
}
public Boolean isUser(String email, String password) {
return userMap.values().stream()
.anyMatch((user) -> user.getEmail().equals(email) && user.getPassword().equals(password));
}
public User findByEmail(String email) {
for (User user : userMap.values()) {
if (user.getEmail().equals(email)) {
return user;
}
}
return null;
}
public List<User> findAll() {
return new ArrayList<>(userMap.values());
}
}
user를 저장하고 조회하기 위한 repository이다. 따로 DB를 운영하지 않기 때문에 그 역할을 대신하는 userMap을 사용한다. UserRepository의 경우 @Repository로 인하여 Spring에서 bean으로 자동 관리된다.
save 메소드는 user 정보가 들어오면 id 값을 넣어주고 userMap에 저장한다. 그 다음 id 값을 유일하게 설정하기 위해 count값을 올려준다.
이제 간단한 테스트를 위하여 init data를 설정해주었다.
InitData.java
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.hyeonic.springinterceptor.domain.Posts;
import me.hyeonic.springinterceptor.interceptor.Role;
import me.hyeonic.springinterceptor.domain.User;
import me.hyeonic.springinterceptor.repository.PostsRepository;
import me.hyeonic.springinterceptor.repository.UserRepository;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.List;
@Component
@RequiredArgsConstructor
@Slf4j
public class InitData {
private final UserRepository userRepository;
private final PostsRepository postsRepository;
@PostConstruct
public void init() {
User admin = new User("admin@email.com", "1234", "관리자");
admin.setRole(Role.ADMIN);
userRepository.save(admin);
User hyeonic = new User("hyeonic@email.com", "1234", "hyeonic");
hyeonic.setRole(Role.USER);
userRepository.save(hyeonic);
List<User> users = userRepository.findAll();
for (User user : users) {
log.info(user.toString());
}
}
}
@Component로 인하여 자동으로 Bean에 등록된다. @PostConstruct로 InitData가 객체로 생성되는 시점에 초기화작업으로 한 번 실행된다. 서버가 실행되는 시점에 해당 메소드가 실행되어 두개의 user를 생성한다.
log를 활용하여 확인해보면 적절히 저장된 것을 확인할 수 있다.
Interceptor 설정
Interceptor 사용을 위해서는 Interceptor와 Interceptor를 등록하기 위한 Config가 필요하다.
AuthInterceptor.java
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
return false;
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
Interceptor 생성을 위해서는 Spring Web에서 제공하는 HandlerInterceptor Interface를 구현하는 방법과 추상 클래스인 HandlerInterceptorAdapter를 상속하는 방법이 있다.
하지만 HandlerInterceptorAdapter 사용에는 추가적인 고민이 필요하다.
HandlerInterceptorAdapter의 경우 @Deprecated로 되어 있기 때문에 HandlerInterceptor를 구현하는 것을 권장한다. 관련 내용은 밑의 docs를 참고하였다.
이제 Interceptor에 Override한 메소드 3가지를 살펴보면 이름이 매우 익숙한 것을 확인할 수 있다. 그렇다 위에서 본 그림에서 그 위치와 역할을 쉽게 확인할 수 있다.
이제 각각의 위치를 기억하며 역할을 간단하게 정리해보았다.
- preHandle: 실제 controller(handler)가 실행되기 전에 실행되며 boolean 타입의 값을 return 한다. true이면 요청한 handler를 처리하고 false이면 처리하지 않는다.
- postHandle: handler가 실행된 후에 실행된다.
- afterCompletion: 전체 요청이 끝나고 난 후 마지막에 실행된다.
권한을 검사하기 위해서는 주로 handler가 실행되기 전엔 preHandle에서 검사가 이루어진다. 나머지 메소드는 사용하지 않는다면 작성하지 않아도 된다. 이미 HandlerInterceptor에서 default 메소드로 선언되어 있기 때문이다.
WebConfig.java
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final HandlerInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns();
}
}
interceptor 등록을 위한 Config 파일이다. @Component로 Bean으로 등록된 Interceptor를 가져와 registry에 등록해준다.
addPathPatterns은 interceptor를 적용할 pattern에 대해 적는 공간이고, excludePathPatterns은 제외할 pattern을 적어두는 공간이다. 각 controller에 있는 메소드에 annotation으로 권한을 확인할 것이기 때문에 모든 pattern에 대해 열어두었다.
Annotation 설정 및 AuthInterceptor 수정
MySecured.java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MySecured {
Role role() default Role.USER;
}
handlerMethod에 annotation을 활용하여 권한을 확인하기 위해 추가한 annotation이다.
Spring Security에 비슷한 기능을 하는 @Secured annotation이 있다. 완전히 똑같은 기능은 아니지만 최대한 비슷한 역할을 할 수 있도록 작성하였다.
AuthInterceptor.java
import me.hyeonic.springinterceptor.domain.User;
import org.springframework.stereotype.Component;
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 javax.servlet.http.HttpSession;
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
// handler 종류 확인 => HandlerMethod 타입인지 체크
// HandlerMethod가 아니면 그대로 진행
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 형 변환 하기
HandlerMethod handlerMethod = (HandlerMethod) handler;
// @MySequred 받아오기
MySecured mySecured = handlerMethod.getMethodAnnotation(MySecured.class);
// method에 @MySequred가 없는 경우, 즉 인증이 필요 없는 요청
if (mySecured == null) {
return true;
}
// @MySequred가 있는 경우이므로, 세션이 있는지 체크
HttpSession session = request.getSession();
if (session == null) {
response.sendRedirect("/my-error");
return false;
}
// 세션이 존재하면 유효한 유저인지 확인
User user = (User) session.getAttribute("user");
if (user == null) {
response.sendRedirect("/my-error");
return false;
}
// admin일 경우
String role = mySecured.role().toString();
if(role != null) {
if ("ADMIN".equals(role)) {
if (user.getRole() != Role.ADMIN) {
response.sendRedirect("/my-error");
return false;
}
}
}
// 접근허가
return true;
}
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
annotation과 Session을 활용하여 적절한 user인지 검증한다. 적절하지 않으면 간단히 작성한 my-error로 redirect 한다.
Controller
IndexController.java
import lombok.RequiredArgsConstructor;
import me.hyeonic.springinterceptor.domain.User;
import me.hyeonic.springinterceptor.repository.UserRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpSession;
@Controller
@RequestMapping("/index")
@RequiredArgsConstructor
public class IndexController {
private final UserRepository userRepository;
@GetMapping
public String index() {
return "index";
}
@GetMapping("login")
public String login(Model model, HttpSession httpSession) {
if (httpSession.getAttribute("user") != null) {
User user = (User) httpSession.getAttribute("user");
model.addAttribute("user", user);
return "redirect:my-info";
}
return "login-form";
}
@PostMapping("login")
public String login(@RequestParam("email") String email,
@RequestParam("password") String password, HttpSession httpSession) {
if (!userRepository.isUser(email, password)) {
return "redirect:login";
}
httpSession.setAttribute("user", userRepository.findByEmail(email));
return "redirect:/index";
}
@MySecured(role = Role.USER)
@GetMapping("logout")
public String logout(HttpSession httpSession) {
httpSession.invalidate();
return "redirect:/index";
}
@GetMapping("my-info")
public String myInfo(Model model, HttpSession httpSession) {
if (httpSession.getAttribute("user") != null) {
User user = (User) httpSession.getAttribute("user");
model.addAttribute("user", user);
} else {
model.addAttribute("user", new User());
}
return "my-info";
}
}
AdminController.java
import me.hyeonic.springinterceptor.interceptor.Role;
import me.hyeonic.springinterceptor.interceptor.MySecured;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin")
public class AdminController {
@MySecured(role = Role.ADMIN)
@GetMapping
public String admin() {
return "admin page";
}
}
UserController.java
import me.hyeonic.springinterceptor.interceptor.MySecured;
import me.hyeonic.springinterceptor.interceptor.Role;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user")
public class UserController {
@MySecured(role = Role.USER)
@GetMapping
public String user() {
return "user page";
}
}
간단한 로직 확인을 위해 추가한 Controller이다. 직접 만든 annotation 확인을 위하여 @MySecured를 Admin와 User Controller에 구분하여 달아두었다. 로그인을 하고 난 후 해당 handlerMethod에 접근하려 할 때 my-error page로 적절히 redirect 되는지 확인한다.
view
index.html
index page이다. a태그로 원하는 기능의 실행을 확인할 수 있다.
login-form.html
login 시도를 위한 form이다.
my-info.html
로그인이 성공하면 자신의 정보를 확인할 수 있는 my-info이다.
비회원
비회원의 경우 index page와 로그인 시도 등 @MySecured가 없는 부분은 접근이 가능하다.
일반 user
hyeonic은 일반 user이다. 그렇기 때문에 admin page를 제외한 모든 page에 접근이 가능하다.
admin
admin은 모든 page에 접근이 가능하다.
정리
항상 안정성있는 웹 페이지 운영 방법에 대해 고민해왔다. 특히 로그인 부분은 실제 사용자의 정보를 다루는 부분이기 때문에 좀 더 안정적으로 데이터와 권한을 관리해야 한다고 생각했다. 하지만 Spring Security의 방대한 내용과 자료는 아직 이해하는 것이 어렵게 다가와서 대체할 수 있는 다른 방법을 찾아왔다. 그에 대한 해답이 Interceptor였다.
이번 정리를 통하여 Spring MVC의 구조에 대해서 다시 생각하게 되는 계기가 되었고 @MySecured annotation을 적절히 활용하여 controller는 controller의 역할에 집중할 수 있었고 권한에 관련된 부분은 AuthInterceptor로 분리하였기 때문에 좀 더 확실한 각자의 역할을 맡게 되었다.
지금은 session 기반의 인증이지만 다양한 front framework와 통신하기 위해서는 Json 기반의 토큰 인증 방식이 필요하다. 이것을 해결하기 위해서도 Interceptor는 큰 도움이 될 수 있을 것이라고 생각한다. 또한 후에 학습할 Spring Security의 구조를 이해하는데에도 큰 도움이 될 것이라고 생각한다.