본문 바로가기
백엔드 개발

Spring Boot Security 4.x 인증/인가 구현 (Resource · Menu · Action 기반 권한 설계)

by collenkim 2026. 4. 13.
반응형

1. 개요

실무에서는 단순 Role 기반 권한(RBAC)으로는 부족한 경우가 많다.

  • API 단위 접근 제어
  • 행위별 권한 분리 (조회 / 생성 / 수정 / 삭제)
  • 메뉴 노출 제어

이 글에서는 Resource · Action · Menu 기반 권한 모델 + Interceptor 기반 인가 처리를 구현한다.


2. 권한 모델

User → Role → Permission
                    ├─ Resource (API)
                    ├─ Action (행위)
                    └─ Menu (UI)

3. 엔티티 설계

Resource (API 단위)

@Entity
@Table(name = "resources")
@Getter
@NoArgsConstructor
public class Resource {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String url; // /api/posts

    private String description;
}

 

Action (행위)

@Entity
@Table(name = "actions")
@Getter
@NoArgsConstructor
public class Action {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private ActionType name;
}

public enum ActionType {
    READ, CREATE, UPDATE, DELETE
}

 

Menu (UI 제어)

@Entity
@Table(name = "menus")
@Getter
@NoArgsConstructor
public class Menu {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String path;
}

 

Permission (핵심)

@Entity
@Table(name = "permissions")
@Getter
@NoArgsConstructor
public class Permission {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Resource resource;

    @ManyToOne(fetch = FetchType.LAZY)
    private Action action;

    @ManyToOne(fetch = FetchType.LAZY)
    private Menu menu;
}

 

Role / User

@Entity
@Table(name = "roles")
@Getter
public class Role {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToMany
    private List<Permission> permissions;
}
@Entity
@Table(name = "users")
@Getter
public class User {

    @Id @GeneratedValue
    private Long id;

    private String username;
    private String password;

    @ManyToMany
    private List<Role> roles;
}

4. 인증 (JWT 기반)

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/**").permitAll()
            .anyRequest().authenticated()
        )
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
        .build();
}

5. 인가 처리 (Interceptor)

PermissionInterceptor

@Component 
@RequiredArgsConstructor 
public class PermissionInterceptor implements HandlerInterceptor { 
	private final PermissionService permissionService; 
    private final AntPathMatcher pathMatcher = new AntPathMatcher(); 
    
    @Override 
    public boolean preHandle(HttpServletRequest request, 
    		HttpServletResponse response, 
    		Object handler) { 
    	String requestUri = request.getRequestURI(); 
        String method = request.getMethod(); 
        ActionType action = mapToAction(method); 
        boolean allowed = permissionService.hasPermission(requestUri, action, pathMatcher); 
        
        if (!allowed) { 
        	throw new AccessDeniedException("권한이 없습니다."); 
        } 
            return true; 
 	} 
        
    private ActionType mapToAction(String method) { 
    	return switch (method) { 
        	case "GET" -> ActionType.READ; 
            case "POST" -> ActionType.CREATE; 
            case "PUT", "PATCH" -> ActionType.UPDATE; 
            case "DELETE" -> ActionType.DELETE; 
            default -> throw new IllegalArgumentException("Unsupported Method"); 
        }; 
    } 
}

 

Interceptor 등록

@Configuration 
@RequiredArgsConstructor 
public class WebConfig implements WebMvcConfigurer { 
	private final PermissionInterceptor permissionInterceptor; 
    
    @Override 
    public void addInterceptors(InterceptorRegistry registry) { 
    	registry.addInterceptor(permissionInterceptor) 
        		.addPathPatterns("/api/**") 
                .excludePathPatterns("/api/auth/**"); 
    } 
}

 


6. PermissionService

@Service 
@RequiredArgsConstructor 
public class PermissionService { 
	public boolean hasPermission(String requestUri, ActionType action, AntPathMatcher matcher) { 
    	Authentication auth = SecurityContextHolder.getContext().getAuthentication(); 
        
        if (auth == null || !(auth.getPrincipal() instanceof User user)) { 
        	return false; 
        } 
        
        return user.getRoles().stream() 
        	.flatMap(role -> role.getPermissions().stream()) 
            .anyMatch(p -> matcher.match(p.getResource().getUrl(), requestUri) && 
            p.getAction().getName() == action ); 
    } 
}

7. Menu 권한 조회

public List<Menu> getAccessibleMenus(User user) { 
	return user.getRoles().stream() 
    	.flatMap(role -> role.getPermissions().stream()) 
        .map(Permission::getMenu) 
        .filter(Objects::nonNull) 
        .distinct() .toList(); 
}

8. 초기 데이터 예시

INSERT INTO resources (url) VALUES ('/api/posts/**'); 
INSERT INTO actions (name) VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE');

9. 성능 최적화 (Local Cache vs Redis)

권한 체크는 모든 API 요청마다 수행된다.
DB를 매번 조회하면 트래픽 조금만 올라가도 바로 병목이 생긴다.

그래서 캐싱은 필수다.


1. Local Cache (Caffeine)

단일 서버 거나 트래픽이 크지 않다면 Local Cache로 충분하다.

@Bean
public CacheManager cacheManager() {
    CaffeineCacheManager cacheManager = new CaffeineCacheManager("userPermissions");
    cacheManager.setCaffeine(
        Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
    );
    return cacheManager;
}
@Cacheable(value = "userPermissions", key = "#userId")
public List<Permission> getPermissions(Long userId) {
    return permissionRepository.findAllByUserId(userId);
}

 

특징

  • 속도 빠름 (메모리)
  • 설정 간단
  • 단일 인스턴스에 적합

2. Redis Cache

서버가 여러 대거나 권한 변경이 잦으면 Redis를 사용한다.

@Cacheable(value = "userPermissions", key = "#userId")
public List<Permission> getPermissions(Long userId) {
    return permissionRepository.findAllByUserId(userId);
}
@CacheEvict(value = "userPermissions", key = "#userId")
public void updateUserRole(Long userId) {
    // 권한 변경 시 캐시 무효화
}

 

특징

  • 여러 서버 간 캐시 공유
  • 권한 변경 즉시 반영 가능
  • 운영 환경에서 안정적

10. 정리

이 구조의 핵심은 세 가지다.

1. 인증과 인가를 분리

  • 인증 → JWT Filter
  • 인가 → Interceptor

2. 권한을 코드가 아닌 데이터로 관리

  • Resource (API)
  • Action (행위)
  • Menu (UI)

→ 요구사항 변경 시 코드 수정 없이 대응 가능

3. 인가 로직을 중앙 집중화

  • Controller에서 권한 제거
  • Interceptor에서 일괄 처리

결과적으로 이 구조는

  • API 단위 권한 제어 가능
  • UI 메뉴 제어까지 확장 가능
  • 유지보수 비용 감소

를 동시에 만족한다.

 

반응형