선언적 권한 체크하기

·2025년 12월 19일

1. 문제 상황

서비스의 메서드마다 권한 체크 로직을 반복적으로 작성해야 한다.

기존 방식의 문제점

// controller
@PostMapping("/products")
public ResponseDto<ProductInfo> createProduct(
    @RequestHeader("X-Member-Role") String role,
    @RequestBody ProductRequest request) {

    return new ResponseDto<>(HttpStatus.CREATED, info, "상품이 등록되었습니다.");
}
// service
public class ProductService {
	
    public ProductInfo createProduct() {
    	// 매번 권한 체크 코드 반복
	    if (!role.equals("SELLER") && !role.equals("ADMIN")) {
        	throw new AccessDeniedException("접근 권한이 없습니다.");
    	}
        // 생략
    }
}

문제점:
1. 모든 메서드에 권한 체크 코드가 중복됨
2. 권한 체크 로직이 비즈니스 로직과 섞임
3. 새로운 권한 추가 시 모든 코드를 수정해야 함
4. 실수로 권한 체크를 누락할 가능성
5. 코드 가독성 저하


2. 해결 방법: AOP 기반 선언적 권한 체크

Spring AOP를 활용하여 권한 체크 로직을 횡단 관심사로 분리한다.

장점

  1. 선언적 접근: 어노테이션 하나로 권한 체크
  2. 중복 제거: 권한 체크 로직을 한 곳에서 관리
  3. 관심사 분리: 비즈니스 로직과 권한 체크 로직 분리
  4. 유지보수성: 권한 체크 로직 수정 시 한 곳만 변경
  5. 일관성: 모든 메서드에서 동일한 방식으로 권한 체크

전체 아키텍처

클라이언트
    ↓ (JWT 토큰 포함)
Gateway
    ↓ (JWT 검증 + 헤더 주입: X-Member-Id, X-Member-Email, X-Member-Role)
비즈니스 서비스 (Product, Order 등)
    ↓ (컨트롤러 메서드 호출)
AOP Aspect
    ↓ (@RequireRole 어노테이션 감지)
권한 체크
    ↓ (헤더의 Role 검증)
비즈니스 로직 실행 또는 예외 발생

3. 구현

(1) 커스텀 어노테이션 정의

// common/src/main/java/store/_0982/common/auth/RequireRole.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
    /**
     * 허용할 권한 목록 (예: Role.SELLER, Role.ADMIN)
     */
    Role[] value();
}

핵심 포인트:

  • @Target(ElementType.METHOD): 메서드에만 적용 가능
  • @Retention(RetentionPolicy.RUNTIME): 런타임에 어노테이션 정보 유지 (AOP가 감지하기 위해 필수)
  • value(): 허용할 권한 배열 (여러 권한 허용 가능)

(2) Role Enum 정의

// common/src/main/java/store/_0982/common/auth/Role.java
public enum Role {
    CONSUMER,    // 구매자
    SELLER,      // 판매자
    ADMIN        // 관리자
}

(3) AOP Aspect 구현

// common/src/main/java/store/_0982/common/auth/RoleCheckAspect.java
@Aspect
public class RoleCheckAspect {

    @Before("@annotation(requireRole)")
    public void checkRole(JoinPoint joinPoint, RequireRole requireRole) {
        // 1. HTTP 요청에서 헤더 정보 가져오기
        ServletRequestAttributes requestAttributes =
                (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
        HttpServletRequest request = requestAttributes.getRequest();

        // 2. Gateway가 주입한 Role 헤더 읽기
        String memberRoleHeader = request.getHeader(HeaderName.ROLE); // "X-Member-Role"

        // 3. 헤더가 없으면 인증되지 않은 사용자
        if (memberRoleHeader == null) {
            throw new CustomException(DefaultErrorCode.NO_ROLE_INFO);
        }

        // 4. 문자열을 Role Enum으로 변환
        Role memberRole;
        try {
            memberRole = Role.valueOf(memberRoleHeader.toUpperCase());
        } catch (IllegalArgumentException e) {
            throw new CustomException(DefaultErrorCode.NO_ROLE_INFO);
        }

        // 5. 사용자 권한이 허용 목록에 있는지 확인
        if (!Arrays.asList(requireRole.value()).contains(memberRole)) {
            throw new CustomException(DefaultErrorCode.ACCESS_DENIED);
        }
    }
}

동작 원리

  1. Pointcut 설정: @Before("@annotation(requireRole)")
    • @RequireRole 어노테이션이 붙은 메서드 실행 전에 Aspect 실행
    • requireRole 파라미터로 어노테이션 정보 접근 가능
  2. 헤더에서 권한 정보 추출:
    • Gateway가 JWT를 검증하고 주입한 X-Member-Role 헤더 읽기
  3. 권한 검증:
    • 헤더 값이 없으면 → NO_ROLE_INFO 예외
    • 허용된 권한 목록에 없으면 → ACCESS_DENIED 예외
    • 검증 통과 시 → 실제 메서드 실행

(4) Auto Configuration으로 Bean 자동 등록

// common/src/main/java/store/_0982/common/config/AuthAutoConfig.java
@AutoConfiguration
@ConditionalOnClass(Aspect.class)
@ConditionalOnProperty(
    prefix = "common.auth",
    name = "enabled",      
    havingValue = "true",
    matchIfMissing = true  // 설정이 없어도 기본적으로 활성화
)
public class AuthAutoConfig {
    @Bean
    public RoleCheckAspect roleCheckAspect() {
        return new RoleCheckAspect();
    }
}

핵심 포인트

  • @AutoConfiguration: Spring Boot Auto Configuration
  • @ConditionalOnClass(Aspect.class): AspectJ가 클래스패스에 있을 때만 활성화
  • @ConditionalOnProperty: 설정으로 활성화/비활성화 가능
  • matchIfMissing = true: 별도 설정 없이도 자동으로 활성화

이렇게 하면 common 모듈을 의존하는 모든 서비스에서 자동으로 RoleCheckAspect가 등록된다.

(5) Gateway에서 헤더 주입

// gateway/src/main/java/store/_0982/gateway/filter/JwtAuthGatewayFilterFactory.java
@Component
public class JwtAuthGatewayFilterFactory extends AbstractGatewayFilterFactory<Config> {

    private final GatewayJwtProvider jwtProvider;

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            // 1. 쿠키에서 JWT 토큰 추출
            HttpCookie accessTokenCookie = exchange.getRequest()
                    .getCookies()
                    .getFirst("accessToken");

            // 토큰이 없으면 헤더 추가 없이 통과 (public API)
            if (accessTokenCookie == null || accessTokenCookie.getValue().isBlank()) {
                return chain.filter(exchange);
            }

            String token = accessTokenCookie.getValue();

            // 2. JWT 토큰 검증 및 파싱
            AuthMember authMember;
            try {
                Claims claims = jwtProvider.parseToken(token);
                String memberId = claims.getSubject();
                String email = claims.get("email", String.class);
                String role = claims.get("role", String.class);
                authMember = new AuthMember(memberId, email, Role.valueOf(role));
            } catch (ExpiredJwtException e) {
                return ExceptionHandler.responseException(exchange, CustomErrorCode.EXPIRED);
            } catch (JwtException | IllegalArgumentException e) {
                return ExceptionHandler.responseException(exchange, CustomErrorCode.INVALID);
            }

            // 3. 검증된 사용자 정보를 헤더에 주입
            ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
                    .headers(headers -> {
                        // 보안: 클라이언트가 보낸 헤더 제거 (위조 방지)
                        headers.remove("X-Member-Id");
                        headers.remove("X-Member-Email");
                        headers.remove("X-Member-Role");

                        // Gateway가 검증한 정보로 덮어쓰기
                        headers.set("X-Member-Id", authMember.getId());
                        headers.set("X-Member-Email", authMember.getEmail());
                        headers.set("X-Member-Role", authMember.getRole().name());
                    })
                    .build();

            return chain.filter(exchange.mutate().request(mutatedRequest).build());
        };
    }
}

보안 포인트:

  • 클라이언트가 X-Member-Role 헤더를 위조해서 보내도 Gateway가 제거하고 재설정
  • JWT 검증 후에만 헤더 주입 → 신뢰할 수 있는 권한 정보

4. 실제 사용 예시

(1) SELLER, ADMIN만 접근 가능

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    @PostMapping
    @RequireRole({Role.SELLER, Role.ADMIN})  // 판매자 또는 관리자만 상품 등록 가능
    @ResponseStatus(HttpStatus.CREATED)
    public ResponseDto<ProductRegisterInfo> createProduct(
            @RequestHeader(HeaderName.ID) UUID memberId,
            @Valid @RequestBody ProductRegisterRequest request) {

        // 권한 체크 로직 없음! 어노테이션만으로 처리
        ProductRegisterInfo info = productService.createProduct(request.toCommand(memberId));
        return new ResponseDto<>(HttpStatus.CREATED, info, "상품이 등록되었습니다.");
    }

    // 권한 체크가 없는 public API
    @GetMapping("/{productId}")
    public ResponseDto<ProductDetailInfo> getProductInfo(@PathVariable UUID productId) {
        ProductDetailInfo response = productService.getProductInfo(productId);
        return new ResponseDto<>(HttpStatus.OK, response, "상품 조회 완료");
    }
}

(2) 다양한 권한 조합

// 모든 인증된 사용자 허용
@RequireRole({Role.CONSUMER, Role.SELLER, Role.ADMIN})
public ResponseDto<?> getAllUsers() { ... }

// ADMIN만 허용
@RequireRole({Role.ADMIN})
public ResponseDto<?> deleteUser() { ... }

// CONSUMER만 허용
@RequireRole({Role.CONSUMER})
public ResponseDto<?> purchaseProduct() { ... }

5. 동작 흐름

성공 시나리오 (SELLER가 상품 등록)

1. 클라이언트 → Gateway
   POST /api/products
   Cookie: accessToken=eyJhbGciOi...

2. Gateway: JWT 검증
   - 토큰 파싱: { sub: "uuid-123", email: "seller@example.com", role: "SELLER" }
   - 헤더 주입:
     X-Member-Id: uuid-123
     X-Member-Email: seller@example.com
     X-Member-Role: SELLER

3. Product Service → Controller
   createProduct() 메서드 호출

4. AOP Aspect: @RequireRole 감지
   - @RequireRole({SELLER, ADMIN}) 발견
   - 헤더에서 "SELLER" 읽기
   - 허용 목록에 SELLER 존재 ✓

5. 비즈니스 로직 실행
   상품 등록 성공

6. 응답
   201 Created

실패 시나리오 (CONSUMER가 상품 등록 시도)

1. 클라이언트 → Gateway
   POST /api/products
   Cookie: accessToken=eyJhbGciOi... (CONSUMER 토큰)

2. Gateway: JWT 검증
   - 헤더 주입:
     X-Member-Role: CONSUMER

3. Product Service → Controller
   createProduct() 메서드 호출

4. AOP Aspect: @RequireRole 감지
   - @RequireRole({SELLER, ADMIN}) 발견
   - 헤더에서 "CONSUMER" 읽기
   - 허용 목록에 CONSUMER 없음 ✗

5. 예외 발생
   CustomException(ACCESS_DENIED)

6. 응답
   403 Forbidden
   { "message": "접근 권한이 없습니다." }

인증되지 않은 사용자 시나리오

1. 클라이언트 → Gateway
   POST /api/products
   Cookie: (없음)

2. Gateway
   - 토큰 없음 → 헤더 주입하지 않고 통과

3. Product Service → Controller
   createProduct() 메서드 호출

4. AOP Aspect: @RequireRole 감지
   - X-Member-Role 헤더 없음
   - NO_ROLE_INFO 예외 발생

5. 응답
   401 Unauthorized
   { "message": "유저 역할 정보가 없습니다." }

6. 장점과 트레이드오프

장점

  1. 코드 간결성

    // 기존: 5줄
    if (!role.equals("SELLER") && !role.equals("ADMIN")) {
        throw new AccessDeniedException();
    }
    
    // AOP: 1줄
    @RequireRole({Role.SELLER, Role.ADMIN})
  2. 관심사 분리

    • 비즈니스 로직: 컨트롤러와 서비스
    • 권한 체크 로직: RoleCheckAspect
    • 각자의 책임에만 집중
  3. 일관성 보장

    • 모든 서비스에서 동일한 방식으로 권한 체크
    • 권한 체크 로직 변경 시 한 곳만 수정
  4. 재사용성

    • common 모듈에 구현 → 모든 서비스에서 사용
    • Member, Product, Order 서비스 모두 동일한 Aspect 사용
  5. 유지보수성

    • 새로운 권한 추가 시 Role enum만 수정
    • 권한 체크 로직 개선 시 RoleCheckAspect만 수정

트레이드오프

  1. 디버깅 복잡성

    • AOP는 런타임에 프록시로 동작 → 스택 트레이스가 길어짐
    • 어노테이션만 보고는 실제 로직을 파악하기 어려울 수 있음
    • 해결: 명확한 네이밍과 문서화
  2. 러닝 커브

    • AOP 개념을 이해해야 함 (Aspect, Pointcut, Advice 등)
    • 해결: 이미 구현된 것을 사용하는 것은 간단 (어노테이션만 추가)
  3. 성능 오버헤드

    • AOP 프록시 생성 및 메서드 인터셉션에 약간의 오버헤드
    • 해결: 권한 체크는 가벼운 작업이므로 무시할 수 있는 수준

7. 결론

Spring AOP를 활용한 선언적 권한 체크는 마이크로서비스 환경에서 간결하고 일관된 권한 관리를 가능하게 한다.

핵심 포인트:

  • @RequireRole 어노테이션으로 간단히 권한 체크
  • AOP Aspect로 횡단 관심사 분리
  • Gateway에서 JWT 검증 후 헤더 주입
  • common 모듈로 모든 서비스에서 재사용
  • 비즈니스 로직과 권한 체크 로직 완전 분리

이 방식을 통해 9개의 마이크로서비스에서 동일한 방식으로 권한을 관리하면서도, 각 서비스의 컨트롤러 코드는 비즈니스 로직에만 집중할 수 있었다.


(*) 이후

Spring AOP 기반의 선언적 권한 체크를 구현했으나,
서비스 내부에서 인가를 수행하는 방식은 요청이 이미 서비스에 도달한 이후라는 한계를 가짐을 인지했습니다.

이후 권한 검증을 Gateway 계층으로 이동하여, 인증·인가를 API 진입 단계에서 차단하도록 구조를 개선했습니다.
이를 통해 보안 강화, 불필요한 트래픽 감소, 책임 분리를 달성했습니다.


(1) 보안 관점 (가장 중요)

AOP 방식의 한계

  • 요청은 이미 서비스 내부까지 진입
  • 컨트롤러 → 프록시 → Aspect → 예외
  • 즉, 공격자가 서비스 endpoint를 직접 때릴 수 있음

Gateway 차단의 장점

  • 애초에 서비스로 요청이 전달되지 않음
  • 내부 서비스 endpoint 노출 면적 감소
  • Zero Trust 구조에 더 부합

-> “서비스 내부에서 막는다” ≠ “보안적으로 안전하다”

(2) 장애 전파 / 비용 관점

AOP 방식

  • 잘못된 요청
    • 네트워크 hop
    • Tomcat thread 점유
    • 프록시 / AOP 실행
  • 트래픽이 많아지면 불필요한 리소스 낭비

Gateway 방식

  • WebFlux + Non-blocking
  • early reject (403/401)
  • 서비스는 정상 트래픽만 처리

(3) 마이크로서비스 철학 관점

MSA에서 Gateway의 역할은 다음과 같다.

  • 인증(Authentication)
  • 인가(Authorization)
  • 라우팅
  • Rate limit

즉, “권한 체크는 비즈니스 로직이 아니다”

AOP는 “서비스 내부 규칙”에는 좋지만
API 접근 자체를 제어하는 역할에는 계층이 늦는다.




0개의 댓글