@Transactional
로 DB 작업을 트랜잭션 단위로 처리할 때?공통관심사항(cross-cutting-concern)
과핵심관심사항(core-concern)
을 분리해준다.@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[]
로 반환한다.validArgList
에 추가해준다.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"
를 반환한다.같은 ~ContextHolder이길래 Security도 RequestContextHolder를 상속받는 구현체인가? RequestContextHolder의 아래인가? 라는 궁금증이 생겼다!
✅ SecurityContextHolder는
SpringSecurity 프레임워크를 사용하면서 현재 요청 및 인증과 관련된 정보를
접근하려고 할 때 사용하는 클래스이다. ThreadLocal
에 정보를 저장하고 있다.
✅ RequestContextHolder는
현재 HTTP Request에 대한 정보를 접근하려고 할 때 사용하는 클래스이다.
ThreadLocal
에 정보를 저장하고 있다.
RequestContextHolder의 경우, 비교적 무겁고 코드를 추가하기에도 부담스럽다.
따라서 상황에 따라 ThreadLocal
을 다룰 수 있다면, CustomContextHolder를 사용할 수도 있다!
AOP를 적용하면, 스프링은 Controller → Service
를 호출하지 않고,
프록시 객체(Proxy Service)를 통해 Service를 호출하도록 만든다.
Controller → Proxy Service → Service
이 프록시 객체는 joinPoint.proceed() 메서드 호출 시,
프록시 객체를 통해 진짜 Service를 가르킨다.
이 동작 방식 때문에 메서드 실행 전/후를 쉽게 조작할 수 있다.
[클라이언트 요청]
↓
┌─────────────── AOP 시작 ───────────────┐
│ ✅ 요청 로그/시간/유저 ID 추출
│ ✅ 요청 바디(JSON) 추출
│ ▶️ joinPoint.proceed() 호출
│ └─ 실제 컨트롤러 메서드 실행 (예: changeUserRole(id, dto))
│ ✅ 응답 바디(JSON) 로깅
└─────────────── AOP 종료 ───────────────┘
↓
[클라이언트로 응답 반환]
AOP를 다루는 건 굉장히 조심스럽게 다뤄야 한다고 한다.
@Around
를 사용했을 때에, 메서드 실행 전에 예외가 발생하면 실제 비즈니스 접근도 못하고 예외를 처리를 해야 할 수도 있다. 굉장히 조심스럽다고 생각이 들었다.이번에 로깅 하는 방법으로 써봤는데, 맛만 봤다고 생각한다...
다음엔 더 깊이 음미하려고 노력해야겠다. 🥲
인프런 김영한 스프링 입문 강의
내배캠 튜터님들
내배캠 테스트 코드 작성 세션
Spring RequestContextHolder