이 시리즈에 나오는 모든 내용은 인프런 인터넷 강의 - 스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security - 에서 기반된 것입니다. 그리고 여기서 인용되는 PPT 이미지 또한 모두 해당 강의에서 가져왔음을 알립니다. 추가적으로 여기에 작성된 코드들 또한 해당 강의의 github 에 올라와 있는 코드를 참고해서 만든 겁니다.
이 글의 시작 코드는 아래와 같이 준비한다.
1. git clone https://github.com/onjsdnjs/corespringsecurity.git
2. git checkout ch05-01
3. application.properties 파일 내용 일부 수정spring.datasource.url=jdbc:postgresql://localhost:5432/springboot spring.datasource.username=postgres spring.datasource.password=root # 자기 것에 맞게 수정 spring.jpa.hibernate.ddl-auto=create # 일단 create 으로 수정 spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true spring.thymeleaf.cache=false spring.devtools.livereload.enabled=true spring.devtools.restart.enabled=true spring.main.allow-bean-definition-overriding=true
- SetupDataLoader 코드 일부 수정
@Component public class SetupDataLoader implements ApplicationListener<ContextRefreshedEvent> { // ... 일부 생략 ... private void setupSecurityResources() { Set<Role> roles = new HashSet<>(); Role adminRole = createRoleIfNotFound("ROLE_ADMIN", "관리자"); roles.add(adminRole); createResourceIfNotFound("/admin/**", "", roles, "url"); Account account = createUserIfNotFound("admin", "pass", "admin@gmail.com", 10, roles); Set<Role> roles1 = new HashSet<>(); Role managerRole = createRoleIfNotFound("ROLE_MANAGER", "매니저"); roles1.add(managerRole); createUserIfNotFound("manager", "pass", "manager@gmail.com", 20, roles1); Set<Role> roles3 = new HashSet<>(); Role childRole1 = createRoleIfNotFound("ROLE_USER", "회원"); roles3.add(childRole1); createResourceIfNotFound("/users/**", "", roles3, "url"); createUserIfNotFound("user", "pass", "user@gmail.com", 30, roles3); createResourceIfNotFound("/mypage", "", roles3, "url"); createResourceIfNotFound("/message", "", roles1, "url"); createResourceIfNotFound("/config", "", roles, "url"); } }
"두 번" 애플리케이션 실행, 처음은 에러 문구 좀 보이고, 두번째 다시 실행하면 OK
application.properties 에서
spring.jpa.hibernate.ddl-auto=validate로 수정이전 글에서 작성한 코드에 이어서 작성하고 싶지만, 강의 코드 자체가 부분적으로 아주 많이 바뀌었는데, 그걸 다 추적해서 새로 작성하는 건 힘들어서 그냥 clone, checkout 했다.
목표
==> 동적 권한 관리가 가능!antMathcers("/user").hasRole("USER")==> 권한 부여==> 권한 생성, 삭제==> 자원 생성, 삭제, 수정, 권한 매핑URL or Method(=aop 방식)권한과 관련된 시스템을 구축하기 위해서는 테이블과 그에 따른 도메인,
그리고 그 도메인과 연관이 있는 JPA Entity Class를 작성해야 한다.
우리가 필요로하는 모델은 아래와 같다.
package io.security.corespringsecurity.domain.entity;
import lombok.*;
import javax.persistence.*;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
@Entity
@Data
@ToString(exclude = {"userRoles"})
@Builder
@EqualsAndHashCode(of = "id")
@NoArgsConstructor
@AllArgsConstructor
public class Account implements Serializable {
@Id
@GeneratedValue
private Long id;
@Column
private String username;
@Column
private String email;
@Column
private int age;
@Column
private String password;
@ManyToMany(fetch = FetchType.LAZY, cascade={CascadeType.ALL})
@JoinTable(name = "account_roles",
joinColumns = { @JoinColumn(name = "account_id") },
inverseJoinColumns = {@JoinColumn(name = "role_id") })
private Set<Role> userRoles = new HashSet<>();
}
package io.security.corespringsecurity.domain.entity;
import lombok.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.*;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "RESOURCES")
@Data
@ToString(exclude = {"roleSet"})
@EntityListeners(value = { AuditingEntityListener.class })
@EqualsAndHashCode(of = "id")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Resources implements Serializable {
@Id
@GeneratedValue
@Column(name = "resource_id")
private Long id;
@Column(name = "resource_name")
private String resourceName;
@Column(name = "http_method")
private String httpMethod;
@Column(name = "order_num")
private int orderNum;
@Column(name = "resource_type")
private String resourceType;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "role_resources",
joinColumns = { @JoinColumn(name = "resource_id") },
inverseJoinColumns = { @JoinColumn(name = "role_id") })
private Set<Role> roleSet = new HashSet<>();
}
package io.security.corespringsecurity.domain.entity;
import lombok.*;
import javax.persistence.*;
import java.io.Serializable;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
@Entity
@Table(name = "ROLE")
@Getter
@Setter
@ToString(exclude = {"users","resourcesSet"})
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(of = "id")
public class Role implements Serializable {
@Id
@GeneratedValue
@Column(name = "role_id")
private Long id;
@Column(name = "role_name")
private String roleName;
@Column(name = "role_desc")
private String roleDesc;
@ManyToMany(fetch = FetchType.LAZY, mappedBy = "roleSet")
@OrderBy("ordernum desc")
private Set<Resources> resourcesSet = new LinkedHashSet<>();
@ManyToMany(fetch = FetchType.LAZY, mappedBy = "userRoles")
private Set<Account> accounts = new HashSet<>();
}
public interface ResourcesRepository extends JpaRepository<Resources, Long> {
Resources findByResourceNameAndHttpMethod(String resourceName, String httpMethod);
@Query("select r from Resources r join fetch r.roleSet " +
"where r.resourceType = 'url' order by r.orderNum desc")
List<Resources> findAllResources();
@Query("select r from Resources r join fetch r.roleSet " +
"where r.resourceType = 'method' order by r.orderNum desc")
List<Resources> findAllMethodResources();
@Query("select r from Resources r join fetch r.roleSet " +
"where r.resourceType = 'pointcut' order by r.orderNum desc")
List<Resources> findAllPointcutResources();
}
public interface RoleRepository extends JpaRepository<Role, Long> {
Role findByRoleName(String name);
@Override
void delete(Role role);
}
public interface UserRepository extends JpaRepository<Account, Long> {
Account findByUsername(String username);
int countByUsername(String username);
}

우리가 이전에 배운 시큐리티 인가 처리는 위와 같이 코드를 짰다.
그리고 여기서 알 수 있는 게, 시큐리티 필터가 인가 처리를 위해 사용되는 정보가
3가지(인증저보, 요청정보, 권한정보)라는 것이다.
그렇다면 스프링 시큐리티에서 저 정보들을 어떤 식으로 생성해두고 내부적으로 사용할까?
아래 그림을 보면서 설명해보겠다.

일단 처음에는 http.antMatchers("/user").access("hasRole('USER')"); 을
RequestMatcherhasRole("USER")) 정보를 담는이때 Map 을 만드는 주체는 ExpressionBasedFilterInvocationSecurityMetadataSource 클래스이다. 스프링 시큐리티가 초기화할 때 해당 클래스 내부에서 생성된다.
위 처럼 초기화된 후에 사용자 요청이 들어오면...
FilterSecurityInterceptor 필터는 요청정보를 FilterInvocation 클래스로 Wrap
FilterInvocation 정보를 자신의 부모클래스의 beforeInvocation 메소드의 인자로 주면서 호출
부모 클래스의 beforeInvocation 내에서 MetadataSource의 참조값을 사용해서 미리 만들었던 Map 정보를 조회
Map 의 Key 값인 RequestMatcher 와 FilterInvocation(현재 요청정보)을 비교
비교해서 같은 게 있다면 Map value("권한 목록 정보")를 return . (없으면 null 반환)
그 권한 목록이 위 그림에서는 List<ConfigAttribute> 이다.
SecurityInterceptor 는 반환받은 ("권한 목록 정보")와 List<ConfigAttribute>, Authentication 을 모두 모아서 AccessDecisionManager 에게 보내면서 인가 처리를 위임하게 된다
AccessDecisionManager은 전달 받은 3개의 인자들을 Voter 에게 주면서 인가처리를 위임한다.
여기서 가장 핵심은 자원의 권한 정보를 생성 및 내장하는 MetadataSource 계열의 클래스다.
만약 우리가 위처럼 http.antMatchers("/user").access("hasRole('USER')"); 를 사용하면 ExpressionBasedFilterInvocationSecurityMetadataSource 클래스를 통해서 Map을 만든다.
하지만 우리가 필요한 것은 저런 하드 코딩된 인가 처리가 아니라 DB 에 있는 자원-인가정보를 빼와서 해당 정보로 Map 을 생성해두는 것이다. 지금부터 Custom MetadataSource 클래스를 하나 작성해보자.
본격적으로 만들기에 앞서 ExpressionBasedFilterInvocationSecurityMetadataSource 클래스에 디버그 포인트를 잡고 조금 자세히 관찰해보자.
이후에 우리가 직접 만들 때 참고하기 위함이다.
일단 우리의 SecurityConfig 에는 권한 관련 정보를 아래처럼 하드코딩했다.
http
.authorizeRequests()
.antMatchers("/mypage").hasRole("USER")
.antMatchers("/messages").hasRole("MANAGER")
.antMatchers("config").hasRole("ADMIN")
.antMatchers("/**").permitAll()
.anyRequest().authenticate()
이러면 ExpressionBasedFilterInvocationSecurityMetadataSource 에서 아래와 같이 위에 작성된 url 에 따른 FitlerInvocation 객체를 사용하는 것을 확인할 수 있다. (PermitAll 은 이후에 사용될 것이니 신경쓰지 지금은 신경쓰지 말자.)

이 Map 정보는 부모 클래스인 DefaultFilterInvocationSecurityMetadataSource 내부의 requestMap 필드에 저장된다.
public class DefaultFilterInvocationSecurityMetadataSource implements
FilterInvocationSecurityMetadataSource {
private final Map<RequestMatcher, Collection<ConfigAttribute>> requestMap;
public DefaultFilterInvocationSecurityMetadataSource(
LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMap) {
this.requestMap = requestMap;
}
// ... 일부 생략 ...
}
아무튼 이 정보는 아래와 같이 FilterSecurityInterceptor 필터의 코드에서 사용하게 된다.
실제 인가 처리를 위해서 말이다. 아래 코드를 보자.
- AbstractSecurityInterceptor.java 일부
beforeInvocation 메소드 내부에서 해당 자원에 필요한 권한 정보를 추출하는 것을 확인AccessDecisionManager 에게 권한 심사를 받지 않고 바로 자원에 접근하게 되는 것이다.- DefaultFilterInvocationSecurityMetadataSource.java 일부
FilterInvocation 객체를 받고,이렇게 추출된 권한 정보는 AbstractSecurityInterceptor.beforeInvocation 후반부에 있는 this.accessDecisionManager.decide(authenticated, object, attributes); 메소드에서 사용된다.
첫번째 파라미터는 인증객체, 두번째 파라미터는 FilterInvocation(요청정보), 세번째 파라미터는 방금 설명한 자원접근에 필요한 권한 리스트 정보다.
참고로 FilterInvocation 은 FilterSecurityInterceptor 에서 생성하여, 자신의 부모 클래스인
AbstractSecurityInterceptor.beforeInvocation메소드의 인자로 준 것이다.
최종적으로 accessDecisionManager.decide 메소드에서 Voter 들에 의한 접근 여부를 묻게 된다.
참고: 스프링 시큐리티의 MetadataSource 클래스 및 인터페이스
![]()
작성에 앞서 우리가 만들 MetadataSource 클래스가 어디서 어떤 역할을 하는지 그림을 통해 가볍게 보고 바로 구현을 시작하자.

package io.security.corespringsecurity.security.metadatasource;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.security.web.util.matcher.RequestMatcher;
import javax.servlet.http.HttpServletRequest;
import java.util.*;
public class MyUrlFilterInvocationSecurityMetadatsSource implements FilterInvocationSecurityMetadataSource {
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap
= new LinkedHashMap<>();
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
HttpServletRequest request = ((FilterInvocation) object).getRequest();
if (requestMap != null) {
Set<Map.Entry<RequestMatcher, List<ConfigAttribute>>> entries = requestMap.entrySet();
for (Map.Entry<RequestMatcher, List<ConfigAttribute>> entry : entries) {
RequestMatcher matcher = entry.getKey();
if (matcher.matches(request)) {
return entry.getValue();
}
}
}
return null;
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
Set<ConfigAttribute> allAttributes = new HashSet<>();
for (Map.Entry<RequestMatcher, List<ConfigAttribute>> entry : requestMap
.entrySet()) {
allAttributes.addAll(entry.getValue());
}
return allAttributes;
}
@Override
public boolean supports(Class<?> clazz) {
return FilterInvocation.class.isAssignableFrom(clazz);
}
}
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// ... 생략 ....
// AcessDecisionManager 생성
private AccessDecisionManager affirmativeBased() {
AffirmativeBased affirmativeBased = new AffirmativeBased(getAccessDecistionVoters());
return affirmativeBased;
}
// AcessDecisionManager 내부에서 찬반 여부를 정하는 Voter 생성
private List<AccessDecisionVoter<?>> getAccessDecistionVoters() {
return Arrays.asList(new RoleVoter());
}
// 자원에 대한 권한 정보를 읽는 MetadataSource 생성
@Bean
public FilterInvocationSecurityMetadataSource myUrlFilterInvocationSecurityMetadataSource() {
return new MyUrlFilterInvocationSecurityMetadatsSource();
}
// MetadataSource 를 사용하는 권한 체크 필터,
// 즉 FilterSecurityInterceptor 를 직접 생성한다.
@Bean
public FilterSecurityInterceptor customFilterSecurityInterceptor() throws Exception {
FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
filterSecurityInterceptor
.setSecurityMetadataSource(myUrlFilterInvocationSecurityMetadataSource());
filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
// 권한체크 필터는 현재 사용자가 인증된 사용자인지 검사하기 때문에 인증 매니저가 필요하다.
filterSecurityInterceptor.setAuthenticationManager(authenticationManagerBean());
return filterSecurityInterceptor;
}
@Override
protected void configure(final HttpSecurity http) throws Exception {
http
.authorizeRequests()
// .antMatchers("/mypage").hasRole("USER")
// .antMatchers("/messages").hasRole("MANAGER")
// .antMatchers("/config").hasRole("ADMIN")
// .antMatchers("/**").permitAll()
.anyRequest().authenticated()
.and()
// ... 일부 생략 ...
.and()
.addFilterBefore(customFilterSecurityInterceptor(),
FilterSecurityInterceptor.class)
;
http.csrf().disable();
customConfigurer(http);
}
}
이렇게 하고 실행한 후에 FilterProxyChain 내부에 디버깅 포인트를 잡으면
우리가 생성한 FilterSecurityInterceptor 가 정상적으로 등록된 것을 확인할 수 있다.

참고: FilterSecurityInterceptor 가 2개 있는데, 권한 체크가 두 번 일어나는 거 아님?
대답은 No 이다. 아래 코드를 보면 이해가 될 것이다.
한 번
FilterSecurityInterceptor를 거치게 되면 다음FilterSecurityInterceptor는 요청을 바로 다음 Filter Chain 으로 위임한다.
한번 localhost:8080/mypage 로 바로 접근해보자.






MyUrlFilterInvocationSecurityMetadatsSource 에는 현재 Map<자원, 권한목록> 정보가 없어서 권한체크 프로세스가 일어나지 않는다.
이제 Map<자원, 권한목록> 를 DB 에서 정보를 얻어와서 생성하고,
실제 권한 체크가 일어나도록 해보자.

public class SecurityResourceService {
private final ResourcesRepository resourcesRepository;
public SecurityResourceService(ResourcesRepository resourcesRepository) {
this.resourcesRepository = resourcesRepository;
}
public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getResourceList() {
LinkedHashMap<RequestMatcher, List<ConfigAttribute>> result
= new LinkedHashMap<>();
List<Resources> resourcesList = resourcesRepository.findAllResources();
resourcesList.forEach(re -> {
List<ConfigAttribute> configAttributeList = new ArrayList<>();
re.getRoleSet().forEach(role -> {
configAttributeList.add(new SecurityConfig(role.getRoleName()));
result.put(new AntPathRequestMatcher(re.getResourceName()), configAttributeList);
});
});
return result;
}
}
public class UrlResourcesMapFactoryBean implements FactoryBean<LinkedHashMap<RequestMatcher, List<ConfigAttribute>>> {
private SecurityResourceService securityResourceService;
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourceMap;
public void setSecurityResourceService(SecurityResourceService securityResourceService) {
this.securityResourceService = securityResourceService;
}
@Override
public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getObject() throws Exception {
if (resourceMap == null) {
init();
}
return resourceMap;
}
private void init() {
resourceMap = securityResourceService.getResourceList();
}
@Override
public Class<?> getObjectType() {
return LinkedHashMap.class;
}
@Override
public boolean isSingleton() {
return FactoryBean.super.isSingleton();
}
}
public class MyUrlFilterInvocationSecurityMetadatsSource implements FilterInvocationSecurityMetadataSource {
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap
= new LinkedHashMap<>();
// DI 받는다.
public MyUrlFilterInvocationSecurityMetadatsSource(LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourcesMap) {
this.requestMap = resourcesMap;
}
// ... 생략 ...
}
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 생략...
@Autowired
private SecurityResourceService securityResourceService;
private UrlResourcesMapFactoryBean urlResourcesMapFactoryBean() {
UrlResourcesMapFactoryBean urlResourcesMapFactoryBean = new UrlResourcesMapFactoryBean();
urlResourcesMapFactoryBean.setSecurityResourceService(securityResourceService);
return urlResourcesMapFactoryBean;
}
@Bean
public FilterInvocationSecurityMetadataSource myUrlFilterInvocationSecurityMetadataSource() throws Exception {
return new MyUrlFilterInvocationSecurityMetadatsSource(urlResourcesMapFactoryBean().getObject());
}
}
public class MyUrlFilterInvocationSecurityMetadatsSource implements FilterInvocationSecurityMetadataSource {
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap;
private SecurityResourceService securityResourceService;
public MyUrlFilterInvocationSecurityMetadatsSource(LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourcesMap, SecurityResourceService securityResourceService) {
this.requestMap = resourcesMap;
this.securityResourceService = securityResourceService;
}
public void reload() {
LinkedHashMap<RequestMatcher, List<ConfigAttribute>> reloadedMap = securityResourceService.getResourceList();
Iterator<Map.Entry<RequestMatcher, List<ConfigAttribute>>> iterator = reloadedMap.entrySet().iterator();
requestMap.clear();
while (iterator.hasNext()) {
Map.Entry<RequestMatcher, List<ConfigAttribute>> entry = iterator.next();
requestMap.put(entry.getKey(), entry.getValue());
}
}
}
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SecurityResourceService securityResourceService;
@Bean
public MyUrlFilterInvocationSecurityMetadatsSource myUrlFilterInvocationSecurityMetadataSource() throws Exception {
return new MyUrlFilterInvocationSecurityMetadatsSource(urlResourcesMapFactoryBean().getObject(), securityResourceService);
}
// ... 생략 ...
}
@Controller
public class ResourcesController {
//.. 생략 ...
@Autowired
private MyUrlFilterInvocationSecurityMetadatsSource myUrlFilterInvocationSecurityMetadatsSource;
@PostMapping(value="/admin/resources")
public String createResources(ResourcesDto resourcesDto) throws Exception {
ModelMapper modelMapper = new ModelMapper();
Role role = roleRepository.findByRoleName(resourcesDto.getRoleName());
Set<Role> roles = new HashSet<>();
roles.add(role);
Resources resources = modelMapper.map(resourcesDto, Resources.class);
resources.setRoleSet(roles);
resourcesService.createResources(resources);
myUrlFilterInvocationSecurityMetadatsSource.reload();
return "redirect:/admin/resources";
}
@GetMapping(value="/admin/resources/delete/{id}")
public String removeResources(@PathVariable String id, Model model) throws Exception {
Resources resources = resourcesService.getResources(Long.valueOf(id));
resourcesService.deleteResources(Long.valueOf(id));
myUrlFilterInvocationSecurityMetadatsSource.reload();
return "redirect:/admin/resources";
}
}