반응형
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 메뉴 제어까지 확장 가능
- 유지보수 비용 감소
를 동시에 만족한다.
반응형
'백엔드 개발' 카테고리의 다른 글
| 서킷 브레이커(Circuit Breaker) 완벽 정리 + Spring Boot 적용 (Resilience4j) (2) | 2026.04.15 |
|---|---|
| Spring Boot Querydsl 설정부터 실무 사용까지 (동적쿼리, 페이징 예제) (0) | 2026.04.14 |
| JPA MyBatis 집계 한계와 Spring Batch로 해결하는 대용량 집계 처리 (0) | 2026.04.09 |
| Spring Snowflake ID 생성기 실무 적용기 (Auto Increment 한계와 해결 방법) (0) | 2026.04.08 |
| Spring에서 @Autowired를 지양해야 하는 이유와No qualifying bean 오류가 발생하는 근본 원인 (0) | 2026.04.07 |