Spring Security 권한 부여(Authorization) 완벽 가이드: RoleHierarchy 계층 권한과 @PreAuthorize 실무 적용

Spring Security 권한 부여(Authorization) 완벽 가이드: RoleHierarchy 계층 권한과 @PreAuthorize 실무 적용

1. 서론

지난 포스팅들을 통해 우리는 사용자의 신원을 증명하는 인증(Authentication) 과정을 마스터했습니다. 세션과 JWT 중 아키텍처를 선택하고, 필터 체인을 커스텀하여 토큰을 검증하는 단계까지 구축했습니다. 이제 우리는 “이 사용자가 누구인가?”라는 질문을 넘어, “이 사용자가 무엇을 할 수 있는가?“라는 인가(Authorization, 권한 부여)의 영역으로 진입해야 합니다.

많은 초급 개발자들이 인증과 인가를 혼동하거나, 단순히 hasRole('ADMIN') 정도의 간단한 설정으로 보안을 마무리하곤 합니다. 하지만 실무 비즈니스 로직은 그리 단순하지 않습니다. 예를 들어, “관리자(ADMIN)는 매니저(MANAGER)와 사용자(USER)가 할 수 있는 모든 일을 할 수 있어야 한다”는 요구사항이 있다고 가정해 봅시다. 만약 단순한 역할 검사만 사용한다면, 관리자 계정에 ROLE_ADMINROLE_MANAGERROLE_USER를 모두 넣어줘야 하는 데이터 중복과 관리의 비효율이 발생합니다. 또한, URL 패턴만으로는 “작성자 본인만 게시글을 수정할 수 있다”와 같은 정교한 데이터 레벨의 권한 제어를 구현하기 어렵습니다.

오늘은 Spring Security의 인가 아키텍처 내부를 들여다보고, 계층형 권한을 구현하는 RoleHierarchy와 메서드 수준에서 정교한 보안을 적용하는 @PreAuthorize의 활용법에 대해 심층 분석해 보겠습니다. 이를 통해 여러분의 애플리케이션 보안을 한 차원 더 높은 수준으로 끌어올릴 수 있습니다.


2. 본론

1. Spring Security 인가(Authorization) 아키텍처의 진화

Spring Security 5.x 버전까지는 AccessDecisionManager와 AccessDecisionVoter가 투표를 통해 권한 승인 여부를 결정하는 구조였습니다. 하지만 최신 Spring Security 6.x 버전부터는 이 구조가 AuthorizationManager 인터페이스로 통합 및 간소화되었습니다.

1. AuthorizationFilter의 역할

인증 필터를 통과한 요청은 필터 체인의 가장 마지막에 위치한 AuthorizationFilter(과거 FilterSecurityInterceptor의 대체)에 도달합니다. 이 필터는 SecurityContextHolder에 저장된 Authentication 객체(사용자 정보 및 권한 목록)와 요청 정보를 꺼내어 AuthorizationManager에게 판단을 위임합니다.

2. GrantedAuthority와 권한의 본질

사용자가 로그인할 때 우리는 ROLE_USER와 같은 문자열을 GrantedAuthority 객체로 감싸서 넣어줍니다. 인가 과정의 핵심은 결국 “사용자가 가진 GrantedAuthority 목록에, 현재 리소스가 요구하는 권한이 포함되어 있는가?“를 검사하는 것입니다. 단순한 문자열 매칭 같지만, 여기에 ‘계층 구조’라는 논리가 더해지면 훨씬 강력한 권한 관리가 가능해집니다.

2. RoleHierarchy를 이용한 계층형 권한 관리

앞서 언급했듯이, 상위 권한을 가진 사용자가 하위 권한의 기능도 수행할 수 있게 하려면 어떻게 해야 할까요? DB에 모든 권한을 매핑하는 것은 하책입니다. Spring Security는 RoleHierarchy라는 기능을 통해 “A 권한은 B 권한을 포함한다(Imply)”는 규칙을 정의할 수 있게 해줍니다.

1. 계층 구조의 정의

우리가 원하는 구조가 다음과 같다고 가정해 봅시다.

  • ROLE_ADMIN (최상위) > ROLE_MANAGER > ROLE_USER (최하위)

이 경우 ADMIN 권한을 가진 사용자는 별도로 USER 권한을 부여받지 않아도, 시스템 내부적으로 USER 권한이 필요한 모든 로직을 통과할 수 있어야 합니다.

2. RoleHierarchy 설정 구현 (Spring Security 6 기준)

이를 구현하기 위해 RoleHierarchy 빈을 등록해야 합니다.

Java

@Configuration
@EnableMethodSecurity // 메서드 보안 활성화 (추후 설명)
public class SecurityConfig {

    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        // 계층 구조 정의: ADMIN은 MANAGER를 포함하고, MANAGER는 USER를 포함한다.
        String hierarchy = "ROLE_ADMIN > ROLE_MANAGER \n ROLE_MANAGER > ROLE_USER";
        roleHierarchy.setHierarchy(hierarchy);
        return roleHierarchy;
    }

    // 정의한 계층 구조를 메서드 보안 표현식 핸들러에 등록
    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy);
        return expressionHandler;
    }
}

이렇게 설정해 두면, hasRole('USER')가 걸려있는 API에 ROLE_ADMIN 권한만 가진 사용자가 접근하더라도, Spring Security가 계층 트리를 타고 내려가 “아, ADMIN은 USER를 포함하니까 통과!”라고 판정하게 됩니다. 이는 권한 관리 테이블을 획기적으로 단순화시켜 줍니다.

3. URL 보안을 넘어, @PreAuthorize와 메서드 시큐리티

SecurityConfig에서 requestMatchers("/admin/**").hasRole("ADMIN")과 같이 URL 패턴으로 권한을 제어하는 것은 숲을 보는 방식입니다. 하지만 우리는 때때로 나무를 봐야 합니다. 특정 서비스 메서드나 컨트롤러 메서드 단위로 정교한 제어가 필요할 때 사용하는 것이 메서드 시큐리티(Method Security)이며, 그 중심에 @PreAuthorize 어노테이션이 있습니다.

과거에는 @Secured 어노테이션도 사용했지만, 표현식(SpEL)을 사용할 수 없다는 단점 때문에 최근에는 거의 @PreAuthorize로 대체되었습니다. 이를 사용하려면 설정 클래스에 @EnableMethodSecurity를 붙여야 합니다.

1. SpEL(Spring Expression Language)의 강력함

@PreAuthorize의 진가는 SpEL을 사용할 때 발휘됩니다. 단순히 역할을 검사하는 것을 넘어, 메서드의 파라미터나 반환값, 심지어 스프링 빈의 메서드 실행 결과까지 참조하여 동적으로 권한을 판단할 수 있습니다.

2. 실무 활용 패턴

  • 기본 권한 검사 (계층 구조 적용됨)Java// RoleHierarchy에 의해 ADMIN 권한자도 호출 가능 @PreAuthorize("hasRole('USER')") @GetMapping("/dashboard") public String dashboard() { ... }
  • 동적 데이터 소유권 검사 (가장 중요한 패턴)”본인이 작성한 글만 수정할 수 있다”는 로직은 URL 설정으로는 불가능합니다. 컨트롤러 내부에서 if 문으로 검사할 수도 있지만, 이는 비즈니스 로직과 보안 로직이 섞이는 결과를 낳습니다. @PreAuthorize를 사용하면 이를 우아하게 분리할 수 있습니다.Java@Service public class PostService { // #dto.writer는 파라미터로 넘어온 PostDto의 writer 필드를 의미 // authentication.name은 현재 로그인한 사용자의 ID @PreAuthorize("#dto.writer == authentication.name") public void updatePost(PostDto dto) { // 비즈니스 로직만 존재 (보안 로직 분리됨) postRepository.update(dto); } }
  • 복합 조건 검사Java// 관리자이거나, 혹은 작성자 본인인 경우에만 삭제 가능 @PreAuthorize("hasRole('ADMIN') or #writer == authentication.name") public void deletePost(Long postId, String writer) { ... }

4. 커스텀 PermissionEvaluator 구현

SpEL만으로는 로직이 너무 복잡해질 때가 있습니다. 예를 들어, “해당 프로젝트의 팀원이면서 동시에 PM 역할인 경우”와 같이 DB 조회가 필요한 복잡한 조건일 경우입니다. 이때는 SpEL 표현식 안에 자바 로직을 넣기보다 PermissionEvaluator를 커스텀하여 구현하는 것이 깔끔합니다.

Java

@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
    @Override
    public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
        // DB 조회 등 복잡한 인가 로직 구현
        // 예: targetDomainObject(프로젝트ID)에 대해 authentication(사용자)가 permission(수정권한)이 있는지 체크
        return projectMemberRepository.existsByProjectIdAndUserId(...);
    }
    // ...
}

이렇게 등록하면 @PreAuthorize("hasPermission(#projectId, 'project', 'edit')")와 같이 매우 직관적인 형태로 사용할 수 있습니다.


3. 결론

Spring Security의 권한 부여 시스템은 단순히 “막는다/푼다”의 이분법적인 구조가 아닙니다. AuthorizationManager를 필두로 한 유연한 아키텍처 위에서, RoleHierarchy를 통해 수직적인 권한 체계를 효율적으로 관리하고, @PreAuthorize를 통해 수평적이고 미세한 비즈니스 규칙을 제어하는 입체적인 보안 시스템입니다.

오늘 소개한 계층형 권한 관리와 메서드 시큐리티를 적용한다면, 여러분의 코드는 더 이상 컨트롤러마다 덕지덕지 붙은 if (user.getRole() == ADMIN)과 같은 조건문으로 더럽혀지지 않을 것입니다. 보안 로직은 보안 설정에 맡기고, 비즈니스 로직은 본연의 업무에 집중하게 하는 것, 그것이 바로 스프링 시큐리티가 추구하는 AOP 보안의 핵심 철학입니다.

지금 바로 여러분의 프로젝트에서 하드 코딩된 권한 검사 로직을 걷어내고, 계층형 구조와 어노테이션 기반의 우아한 보안 전략을 적용해 보시기 바랍니다.


이제 백엔드 보안은 탄탄해졌습니다. 하지만 요즘 대세인 MSA 환경이나 외부 인증(구글, 카카오 로그인)을 구현하려면 OAuth2에 대한 이해가 필수적입니다. 다음 포스팅으로 “Spring Security OAuth2 Client 완벽 가이드: 카카오/구글 소셜 로그인 구현부터 JWT 발급까지의 전체 흐름 분석” 을 포스팅하여, 복잡한 OAuth2 프로토콜을 아주 쉽게 풀어드립니다.

댓글 남기기