[SPRING] AOP... 맛만 볼까?

wannabeing·2025년 4월 18일
0

SPRING

목록 보기
8/12
post-thumbnail

AOP가 필요한 상황이 언제일까?

  • 모든 메서드의 호출 시간을 알고 싶다면?
  • 회원가입 시간, 회원조회 시간을 알고 싶다면?
  • 모든 메서드마다 시간측정 로직을 추가해야 될까?
  • @Transactional로 DB 작업을 트랜잭션 단위로 처리할 때?

AOP가 어떻게 도와줄까?

  • 시간측정 로직(공통관심사항)을 따로 모아서 내가 원하는 곳에 적용시키는 걸 도와준다.
  • 공통관심사항(cross-cutting-concern)
    핵심관심사항(core-concern)을 분리해준다.

✅ 과제를 통해 AOP를 알아보자!

@RestController
@RequiredArgsConstructor
public class UserAdminController {

    private final UserAdminService userAdminService;

    @PatchMapping("/admin/users/{userId}")
    public String changeUserRole(@PathVariable long userId, @RequestBody UserRoleChangeRequest userRoleChangeRequest) {
        userAdminService.changeUserRole(userId, userRoleChangeRequest);
        return "OK";
    }
}

관리자가 유저 권한을 수정하는 로직이다.
해당 메서드 실행 전/후에 AOP로 로깅을 해보자.


@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class AdminApiLoggingAop {

	private final ObjectMapper objectMapper;

	@Around("execution(* org.example.expert.domain.user.controller.UserAdminController.*(..))")
	public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
		...
  • @Slf4j: 로깅 어노테이션
  • @Aspect: 스프링에게 AOP 클래스라고 알려줌
  • @Component: 스프링 컨테이너에 Bean(객체)로 등록
  • @RequiredArgsConstructor: final 필드 생성자 어노테이션
  • @Around: 괄호에 들어가는 메서드의 실행 전/후로 제어하게 도와주는 어노테이션
  • "execution(* ..."): AOP를 적용할 범위를 지정함

사용자의 요청을 어디서든 알 수 있는 방법

Tomcat 웹서버는 HTTP 요청마다 하나의 쓰레드를 사용하고, 이 쓰레드가 전담하게 한다.
쓰레드에 관련된 정보들을 ThreadLocal(쓰레드로컬)에 저장해서 응답하기 전까지 어디서든 사용할 수 있도록 도와준다.

해당 ThreadLocal에 HTTP 요청에 대한 모든 정보를 담아둔 객체가 있는데
바로 RequestContextHolder이다.

❓ RequestContextHolder
스프링에서 전역으로 Request(요청)에 대한 정보를 가져올 때
사용하는 유틸 클래스이다. Request 객체를 참고하려 할 때 사용한다.

RequestContextHolder.getRequestAttributes();

위와 같은 방법으로 HTTP 요청에 대한 정보를 가져올 수 있다.

❗️하지만! RequestAttributes 자체는 인터페이스이기 때문에 구현체가 없다!

따라서 상속 받아 구현한 ServletRequestAttributes
다운캐스팅을 통해야만 Request(요청)에 대한 정보를 얻을 수 있다.

HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
							.getRequestAttributes())
                            .getRequest();

이렇게 되면 요청에 대한 많은 정보를 알 수 있다.
로그인 유저 id, jwt토큰, 요청의 헤더와 파라미터 등등..

이런 경고가 뜨는 이유는 다음과 같다.
보통의 경우 HTTP 요청이겠지만, 아닌 경우에 AOP를 쓰게 된다면 (비동기 등)
null이 반환될 수 있기 때문에 뜨는 경고이다. 아래와 같이 자동으로 변경해준다.

null일 경우 바로 NPE 예외가 터지므로,
어디서 예외처리를 해야되는지 명확히 알 수 있게 도와준다.

HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(
			RequestContextHolder.getRequestAttributes())).getRequest();

HttpServletRequest를 이용하여 값을 추출해보자!

String requestUrl = request.getRequestURI();
String requestTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
Long userId = (Long) request.getAttribute("userId");

log.info("사용자 ID: {}", userId);
log.info("요청 URL: {}", requestUrl);
log.info("요청 시간: {}", requestTime);

userId의 경우, jwtFilter 클래스에서 설정된 부분을 참고하여 추출하였다.


RequestBody 값을 직렬화하여 추출해보자!

// Controller
public String changeUserRole(@PathVariable long userId, @RequestBody UserRoleChangeRequest userRoleChangeRequest) {
        userAdminService.changeUserRole(userId, userRoleChangeRequest);
        return "OK";
}

우리의 ReuqestBody에 들어갈 값은 userId, userRoleChangeRequest 두 객체이다.

List<Object> validArgList = new ArrayList<>();
Object[] args = joinPoint.getArgs(); // AOP 에게 홀딩된 메서드의 모든 파라미터들을 배열로 갖고 있음
for (Object arg : args) {
	if (arg instanceof HttpServletRequest){
    	continue; // 실제 요청 정보가 아니고, 변환이 안되므로 continue
    }
	try {
		validArgList.add(arg);
    } catch (Exception e) {
		validArgList.add("[JSON 변환 실패]: " + arg.getClass().getSimpleName());
	}
}

String requestBody = objectMapper.writeValueAsString(validArgList); // 직렬화
log.info("요청 바디: {}", requestBody);
  • joinPoint.getArgs(): 현재 메서드의 파라미터 값들을 Object[]로 반환한다.
  • for문을 통해 유효한 값들만 validArgList에 추가해준다.
  • 만약 직렬화(JSON)이 어려운 파라미터의 경우, 이름을 추출하여 추가한다.
  • ObjectMapper 내장함수를 통해 유효한 파라미터들을 직렬화 시켜준다.
    아래와 같이 출력하게 된다.
요청 바디: [1, {"role":"ADMIN"}] // commentId, requestDto

Object result = joinPoint.proceed(); // 실제 메서드 실행해서 응답값 가져옴

String responseBody = objectMapper.writeValueAsString(result); // 직렬화
log.info("응답 바디: {}", responseBody);

return result;
  • joinPoint.proceed(): AOP에 걸린? 메서드를 실행하게 하고, 결과값을 Object로 가져온다.
  • ObjectMapper 내장함수를 통해 ResponseBody를 추출한다.
    나같은 경우의 "OK"를 반환한다.
  • 마지막으로 메서드 실행 결과값을 반환하면서, 해당 AOP를 종료시킨다.

❓ SecurityContextHolder와 RequestContextHolder는 무슨 차이점이 있을까?

같은 ~ContextHolder이길래 Security도 RequestContextHolder를 상속받는 구현체인가? RequestContextHolder의 아래인가? 라는 궁금증이 생겼다!

  • SecurityContextHolder는
    SpringSecurity 프레임워크를 사용하면서 현재 요청 및 인증과 관련된 정보를
    접근하려고 할 때 사용하는 클래스이다. ThreadLocal에 정보를 저장하고 있다.

  • RequestContextHolder는
    현재 HTTP Request에 대한 정보를 접근하려고 할 때 사용하는 클래스이다.
    ThreadLocal에 정보를 저장하고 있다.

💡 동작방식은 비슷하지만, 쓰임새가 전혀 다르다!!

RequestContextHolder의 경우, 비교적 무겁고 코드를 추가하기에도 부담스럽다.
따라서 상황에 따라 ThreadLocal을 다룰 수 있다면, CustomContextHolder를 사용할 수도 있다!


✅ AOP의 동작 방식을 맛만보자!

AOP를 적용하면, 스프링은 Controller → Service를 호출하지 않고,
프록시 객체(Proxy Service)를 통해 Service를 호출하도록 만든다.
Controller → Proxy Service → Service

이 프록시 객체는 joinPoint.proceed() 메서드 호출 시,
프록시 객체를 통해 진짜 Service를 가르킨다.

이 동작 방식 때문에 메서드 실행 전/후를 쉽게 조작할 수 있다.


✅ AOP의 동작 순서를 알아보자!

[클라이언트 요청]
        ↓
┌─────────────── AOP 시작 ───────────────┐
│   ✅ 요청 로그/시간/유저 ID 추출            
│   ✅ 요청 바디(JSON) 추출                 
│   ▶️ joinPoint.proceed() 호출            
│      └─ 실제 컨트롤러 메서드 실행 (예: changeUserRole(id, dto)) 
│   ✅ 응답 바디(JSON) 로깅               
└─────────────── AOP 종료 ───────────────┘
        ↓
[클라이언트로 응답 반환]

✅ 마무리

AOP를 다루는 건 굉장히 조심스럽게 다뤄야 한다고 한다.

  • 예외처리가 애매하다.
    @Around를 사용했을 때에, 메서드 실행 전에 예외가 발생하면 실제 비즈니스 접근도 못하고 예외를 처리를 해야 할 수도 있다. 굉장히 조심스럽다고 생각이 들었다.
  • 테스트 시 예상과 다른 동작이 될 수 있다.
    AOP는 프록시 객체를 이용하여 동작하므로, 단위테스트에 적합하지 않다.
    테스트 환경과 다르기 때문에 골치 아픈 상황이 만들어질 수도 있다 생각한다.

이번에 로깅 하는 방법으로 써봤는데, 맛만 봤다고 생각한다...
다음엔 더 깊이 음미하려고 노력해야겠다. 🥲


인프런 김영한 스프링 입문 강의
내배캠 튜터님들
내배캠 테스트 코드 작성 세션
Spring RequestContextHolder

profile
wannabe---ing

0개의 댓글