권한 검증을 위한 커스텀 AOP 는 wecam 프로젝트를 진행하면서 만들게 되었습니다!! (프로젝트 기능 개발)
백엔드 개발을 하다 보면 컨트롤러 메서드마다 반복되는 검증 로직들이 있습니다.
"이 유저가 학생인가?", "헤더에 학생회 ID가 있는가?", "그 ID가 현재 로그인한 상태와 유효한가?"
이런 if 문들이 비즈니스 로직과 섞이면 코드가 지저분해지고 가독성이 떨어집니다. 오늘은 Spring AOP와 Redis를 활용해, 어노테이션 하나만 붙이면 이 모든 검증을 자동으로 수행하고 컨텍스트까지 주입해 주는 설계 패턴을 기록합니다,

1. 반복되는 검증 로직
학생회 관리 서비스를 만들었을 때, 특정 기능을 수행하려면 다음 과정이 필수적입니다.
- 로그인한 유저인지 확인한다.
- 유저의 Role이
STUDENT인지 확인한다. - 요청 헤더(
X-Council-Id)에 학생회 ID가 있는지 확인한다. - [중요] 해당 유저가 현재 선택한 학생회(Redis)와 헤더의 ID가 일치하는지 검증한다.
이걸 모든 컨트롤러에 복사-붙여넣기 한다면? 유지보수 지옥이 시작됩니다.
2. 해결책: 커스텀 어노테이션 + AOP
저는 컨트롤러를 이렇게 깔끔하게 만들고 싶었습니당.
// After: 어노테이션 하나로 검증 끝!
@IsCouncil
@PostMapping("/council/todo")
public ApiResponse<?> createTodo(@CurrentUser UserDetailsImpl user, @RequestBody TodoReq req) {
// 이미 검증 통과 + ID 주입 완료
Long councilId = CouncilContextHolder.getCouncilId();
service.createTodo(councilId, user.getId(), req);
return ApiResponse.ok();
}
설계 구조는 다음과 같습니다.
- Request 도착: 컨트롤러 진입 전 AOP가 가로챕니다.
- Role 검사:
@IsStudent,@IsUnauth등을 체크합니다. - 상태 검사:
@IsCouncil인 경우 Redis와 헤더를 대조합니다. - Context 주입: 검증이 통과되면
ThreadLocal에 ID를 저장해 하위 로직에서 쓰게 합니다.
3. 구현 상세 (Step-by-Step)
Step 1: 커스텀 어노테이션 정의
먼저 필요한 검증 타입별로 어노테이션을 만듭니다.
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface IsCouncil {
// 학생회 소속이며, 현재 학생회 모드로 접속 중인지 검증
}
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface IsStudent {
// 일반 학생 권한 검증
}
Step 2: 상태를 저장할 ContextHolder (ThreadLocal)
검증된 councilId를 컨트롤러나 서비스 계층까지 파라미터 없이 전달하기 위해 ThreadLocal을 사용합니다.
public class CouncilContextHolder {
private static final ThreadLocal<Long> contextHolder = new ThreadLocal<>();
public static void setCouncilId(Long councilId) {
contextHolder.set(councilId);
}
public static Long getCouncilId() {
return contextHolder.get();
}
public static void clear() {
contextHolder.remove();
}
}
Step 3: 핵심 AOP 로직 (RoleCheckAspect)
가장 중요한 부분입니다. Redis를 조회하여 "유저가 지금 이 학생회로 작업하겠다고 선언했는지"를 더블 체크합니다.
@Aspect
@Component
@RequiredArgsConstructor
@Order(1) // 비즈니스 로직보다 먼저 실행되어야 함
public class RoleCheckAspect {
private final RedisTemplate<String, String> redisTemplate;
private final HttpServletRequest request;
@Before("@within(org.ecampus.wecam.annotation.IsCouncil) || @annotation(org.ecampus.wecam.annotation.IsCouncil)")
public void checkCouncilContext(JoinPoint joinPoint) {
// 1. 헤더 검증
String header = request.getHeader("X-Council-Id");
if (!StringUtils.hasText(header)) {
throw new BaseException(BaseResponseStatus.MISSING_COUNCIL_ID_HEADER);
}
// 2. 현재 로그인한 유저 정보 가져오기
UserDetailsImpl user = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// 3. Redis 검증 (핵심!)
// Key 패턴: currentCouncil:{userId}
String key = "currentCouncil:" + user.getId();
String storedCouncilId = redisTemplate.opsForValue().get(key);
if (storedCouncilId == null) {
// Redis에 데이터가 없다 = 학생회를 선택하지 않았거나 세션 만료
throw new BaseException(BaseResponseStatus.COUNCIL_SESSION_EXPIRED);
}
// 4. 불일치 검증 (헤더 조작 방지)
if (!storedCouncilId.equals(header)) {
throw new BaseException(BaseResponseStatus.COUNCIL_MISMATCH);
}
// 5. Context 설정 (통과!)
CouncilContextHolder.setCouncilId(Long.valueOf(storedCouncilId));
}
// @IsStudent, @IsUnauth 처리 메서드도 유사하게 구현...
}

4. 기술적 고려사항 및 트러블슈팅
이 구조를 도입하면서 고민했던 점과 해결책!
1) Redis를 사용하는 이유? (Stateful Check)
단순히 DB 조회만 하지 않고 Redis를 쓰는 이유는 '현재성' 때문입니다. 내가 지금 하고있는게 맞는지....
사용자는 여러 학생회에 소속될 수 있습니다. 하지만 API 요청 시점에는 "지금 내가 A 학생회로서 글을 쓴다"는 명확한 상태가 필요했습니다. 로그인 후 학생회를 선택(Select)하는 순간 Redis에 상태를 저장하고, 요청마다 이를 검증하여 보안성을 높였습니다.
정말로 Redis 사용이 좋은지는.... 잘 모르겠어요; - ;
2) ThreadLocal 메모리 누수 (Context Leak)
ThreadLocal은 스레드 풀 환경에서 값이 남아있을 경우, 다른 사용자의 요청에 영향을 줄 수 있는 치명적인 단점이 있습니다.
반드시 요청이 끝날 때 비워줘야 합니다.
- 해결책:
HandlerInterceptor의afterCompletion혹은 서블릿 필터에서CouncilContextHolder.clear()를 강제 호출하도록 안전장치를 마련했습니다.
3) Spring Security vs Custom AOP
@PreAuthorize를 사용할 수도 있지만, Redis 값 비교나 헤더 검사 같은 복잡한 비즈니스 로직이 섞인 검증은 Custom AOP가 훨씬 직관적이고 디버깅하기 편함!
단순 Role 체크는 Security에 맡기고, 도메인 특화 검증은 AOP로 분리하여 책임(Responsibility)을 나눴습니다.
5. 마무리
이렇게 AOP를 도입함으로써 얻은 이점은,,,
- 코드 재사용성 : 컨트롤러에서 검증 로직이 싹 사라졌습니다.
- 안전성 : 실수로 검증을 누락할 일이 없습니다. (
@IsCouncil만 붙이면 됨) - 비즈니스 집중 : 개발자는 오직 기능 구현에만 집중할 수 있습니다.
Custom Annotation과 AOP 도입은 참 좋은거 같다 느꼈습니다..
다만 Redis 에 넣는게 좋은 선택인지는 아직도 잘 모르겠습니다. 해당 내용은 가능하면 새로운 게시물로 써보려 합니당.
#SpringBoot #AOP #Redis #Java #백엔드 #디자인패턴 #ThreadLocal
'ServerDev > SpringBoot' 카테고리의 다른 글
| [JPA] 동시성 제어 : LockModeType.PESSIMISTIC_WRITE (0) | 2025.12.07 |
|---|---|
| [SpringBoot&JPA] QR 코드 기반 게스트 계정 자동 생성 및 로그인 구현 (0) | 2025.12.07 |
| [SpringBoot - SSR] 파일 업로드 에러: "Failed to convert String to MultipartFile" 원인과 해결 (0) | 2025.12.06 |
| [Spring Boot] 수정 기능 구현 시, 멀쩡한 데이터가 NULL 로 덮어씌워지는 문제 (0) | 2025.12.06 |
| [ Web Stack] 스프링 부트 Web Stack 종류 - Server MVC & WebFlux (0) | 2025.12.06 |