서비스의 메서드마다 권한 체크 로직을 반복적으로 작성해야 한다.
// 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. 코드 가독성 저하
Spring AOP를 활용하여 권한 체크 로직을 횡단 관심사로 분리한다.
클라이언트
↓ (JWT 토큰 포함)
Gateway
↓ (JWT 검증 + 헤더 주입: X-Member-Id, X-Member-Email, X-Member-Role)
비즈니스 서비스 (Product, Order 등)
↓ (컨트롤러 메서드 호출)
AOP Aspect
↓ (@RequireRole 어노테이션 감지)
권한 체크
↓ (헤더의 Role 검증)
비즈니스 로직 실행 또는 예외 발생
// 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(): 허용할 권한 배열 (여러 권한 허용 가능)// common/src/main/java/store/_0982/common/auth/Role.java
public enum Role {
CONSUMER, // 구매자
SELLER, // 판매자
ADMIN // 관리자
}
// 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);
}
}
}
동작 원리
@Before("@annotation(requireRole)")@RequireRole 어노테이션이 붙은 메서드 실행 전에 Aspect 실행requireRole 파라미터로 어노테이션 정보 접근 가능X-Member-Role 헤더 읽기NO_ROLE_INFO 예외ACCESS_DENIED 예외// 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가 등록된다.
// 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가 제거하고 재설정@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, "상품 조회 완료");
}
}
// 모든 인증된 사용자 허용
@RequireRole({Role.CONSUMER, Role.SELLER, Role.ADMIN})
public ResponseDto<?> getAllUsers() { ... }
// ADMIN만 허용
@RequireRole({Role.ADMIN})
public ResponseDto<?> deleteUser() { ... }
// CONSUMER만 허용
@RequireRole({Role.CONSUMER})
public ResponseDto<?> purchaseProduct() { ... }
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
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": "유저 역할 정보가 없습니다." }
코드 간결성
// 기존: 5줄
if (!role.equals("SELLER") && !role.equals("ADMIN")) {
throw new AccessDeniedException();
}
// AOP: 1줄
@RequireRole({Role.SELLER, Role.ADMIN})
관심사 분리
일관성 보장
재사용성
유지보수성
디버깅 복잡성
러닝 커브
성능 오버헤드
Spring AOP를 활용한 선언적 권한 체크는 마이크로서비스 환경에서 간결하고 일관된 권한 관리를 가능하게 한다.
핵심 포인트:
@RequireRole 어노테이션으로 간단히 권한 체크이 방식을 통해 9개의 마이크로서비스에서 동일한 방식으로 권한을 관리하면서도, 각 서비스의 컨트롤러 코드는 비즈니스 로직에만 집중할 수 있었다.
Spring AOP 기반의 선언적 권한 체크를 구현했으나,
서비스 내부에서 인가를 수행하는 방식은 요청이 이미 서비스에 도달한 이후라는 한계를 가짐을 인지했습니다.
이후 권한 검증을 Gateway 계층으로 이동하여, 인증·인가를 API 진입 단계에서 차단하도록 구조를 개선했습니다.
이를 통해 보안 강화, 불필요한 트래픽 감소, 책임 분리를 달성했습니다.
-> “서비스 내부에서 막는다” ≠ “보안적으로 안전하다”
즉, “권한 체크는 비즈니스 로직이 아니다”
AOP는 “서비스 내부 규칙”에는 좋지만
API 접근 자체를 제어하는 역할에는 계층이 늦는다.