Search

Spring AOP로 로깅 및 Web hook으로 디스코드 알림 붙이기

태그
Java
Spring
분류
개발/구현
생성 일시
2023/09/17 23:30
프로젝트
Cabi

로깅과 알람을 적용하는 이유

Cabi 시스템에서는 Admin 페이지로 접속하면 사물함의 타입(개인 사물함, 공유 사물함) 변경이나 대여된 사물함의 반납 등 서비스에 치명적일 수 있는 동작들을 수행할 수 있다. 현재의 문제점은 Admin 계정을 42 서울의 데스크 분들도 같이 사용하고 있어, 어드민으로 누가 언제 어떤 동작을 수행했는지 기록이 남지 않는다는 것이다.
이 문제를 해결하기 위해 어드민 계정에 한해 GET 요청을 제외한 다른 요청들을 받으면, 받은 요청과 그 결과에 대해 로그를 남기고 디스코드에 알람을 던져 문제 발생 시 원인 파악과 대처를 쉽게 하는 것이 목적이다.
AOP를 통한 로깅과 Web hook으로 디스코드 알람을 구현하고, 그 과정을 정리하였다.

AOP

AOP(Aspect-Oriented Programming)란 관점(Aspect) 지향 프로그래밍으로, 관점을 기준으로 다양한 기능을 분리하여 모듈화하는 프로그래밍이다. 관점(Aspect)이란 부가 기능과 그 적용처를 정의하고 합쳐서 모듈로 만든 것이다.
AOP는 목적에 따라 클래스와 객체로 나뉘는 OOP에서 기능들을 어떤 식으로 바라보고 나누어 사용할 지에 대한 정의가 부족하다는 단점을 보완한다.
위의 3-티어 아키텍처 예시처럼 비즈니스 웹 어플리케이션은 핵심 비즈니스 로직이 있고 어플리케이션 전체를 관통하는 부가 기능 로직이 있고, 이를 횡단 관심사(cross-cutting concerns) 혹은 흩어진 관심사라고 한다. 이런 횡단 관심사의 코드를 핵심 비즈니스 로직과 분리하여, 코드의 가독성과 간결성을 높이고 유연함과 확장성을 높이는 것이 AOP의 목적이다.
AOP에서 각 관점으로 로직을 모듈화 한다는 것은 아래 그림처럼, 흩어진 관심사를 Aspect로 모듈화하고 핵심적인 비즈니스 로직에서 분리하여 재사용한다는 것이다.
AOP를 적용하는 방식에는 여러 가지가 있다.
컴파일 시점 적용
컴파일 시점 적용 방식은 AspectJ 컴파일러가 일반 java 파일을 컴파일할 때 부가기능을 넣어 class 파일로 컴파일 해주는 것을 의미한다. 이 동작을 Aspect와 실제 코드를 연결하는 위빙(weaving)이라 부른다.
클래스 로딩 시점 적용
JVM 내 클래스로더에 class 파일을 올리는 시점에 바이트 코드를 조작해 부가기능 로직을 추가하는 방식이다.
런타임 시점 적용
컴파일, 클래스 로딩, main 메서드 실행 이후 부가 기능을 적용하는 방식으로, 런타임 중에 코드를 조작하기 어려워 스프링, 컨테이너, DI, 빈 등 여러 개념과 기능을 동원하여 프록시를 통해 부가 기능을 적용하는 방식이다.
Spring AOP는 프록시 패턴 기반의 구현체로 런타임 시점에 적용하는 방식을 사용한다. 프록시 패턴 기반의 구현체를 사용하는 이유는 접근 제어 및 부가 기능을 추가하기 위해서이다. 스프링 빈에만 AOP를 적용 가능하고, 스프링 IoC와 연동하여 어플리케이션에서 Aspect 모듈화를 지원한다.
Aspect
어드바이스 + 포인트컷을 모듈화한 어플리케이션의 횡단 기능
Join Point
어플리케이션 실행 흐름에서의 특정 포인트(클래스 초기화, 호출, 예외 발생 등), AOP를 적용할 수 있는 지점을 의미
Advice
조인포인트에서 실행되는 부가기능으로, Aspect를 언제 핵심 코드에 적용할지 정의
Pointcut
조인포인트 중 어드바이스가 적용될 지점을 선별하는 기능으로, 주로 AspectJ 표현식으로 지정
Target
핵심 기능을 담은 모듈로, 어드바이스를 받는 객체이고 포인트컷으로 결정
Advisor
Spring AOP에서만 사용되는 용어로, 하나의 어드바이스와 하나의 포인트컷으로 구성된 Aspect를 지칭하는 말
Type
설명
Before
조인포인트 실행 전에 실행, 일반적으로 반환 타입 void
AfterReturning
조인포인트 완료 후 실행(예외 없이 동작 완료 시)
AfterThrowing
조인포인트의 메서드가 예외를 던지는 경우 실행
After
예외와 관련없이 조인포인트의 완료 후 실행
Around
메서드 호출 전후에 수행(조인포인트 실행 여부 선택, 반환값 반환, 예외 변환, try-catch 구문 처리 등)

AOP로 로깅 적용하기

현재 Cabi 시스템에는 어노테이션과 AOP를 통한 Oauth2 인증 방식이 도입 되어있다.
구현하고자 하는 로깅이 어드민 계정으로 받은 요청에만 적용되므로 PointCut은 아래처럼 authGuard 어노테이션이 적용되어 있고 Get 요청이 아닌 요청들이 된다.
"@annotation(authGuard) && !@annotation(org.springframework.web.bind.annotation.GetMapping))"
Java
복사
추가적으로 authGuard 어노테이션을 통해 받은 UserRole을 확인하여 어드민인 경우에만 수행하도록 로직을 추가하였다.
출력할 로그와 알람 메세지는 받은 요청의 Controller 클래스 명, 메서드 이름, 메서드 파라미터의 타입과 값, 그 결과(성공 시 반환 값 / 예외 발생 시 예외 메세지)를 담았다.
@AfterReturning을 통해 요청 성공 시 반환값이 없으면 void, 있으면 toString을 호출하여 저장하였고, @AfterThrowing을 통해 예외 발생 시에는 예외 이름과 메세지를 담았다.
// 요청 성공 시 String responseString = (ret == null) ? "void" : ret.toString(); // exception 발생 시 String responseString = exception.getClass().getName() + ":{" + exception.getMessage() + "}";
Java
복사
전체 코드
/** * 어드민 유저가 Get 요청을 제외한 API 호출 시 로그를 남기는 Aspect */ @Slf4j @Aspect @Component @Profile("prod") @RequiredArgsConstructor public class AdminApiLogAspect { private final static String ADMIN_CUD_POINTCUT = "@annotation(authGuard) && !@annotation(org.springframework.web.bind.annotation.GetMapping))"; private final ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer(); private final CookieManager cookieManager; private final TokenValidator tokenValidator; private final JwtProperties jwtProperties; private final LogParser logParser; private final DiscordWebHookMessenger discordWebHookMessenger; /** * 어드민 유저가 Get 요청을 제외한 API 호출 및 요청 정상 처리 시 로그를 남기는 메소드 * @param joinPoint joinPoint 객체 * @param authGuard 어드민 유저를 판단하기 위한 AuthGuard 어노테이션 * @param ret API 호출의 응답 객체 * @throws JsonProcessingException Discord WebHook 메시지를 JSON으로 변환하는 과정에서 발생할 수 있는 예외 */ @AfterReturning(pointcut = ADMIN_CUD_POINTCUT, returning = "ret", argNames = "joinPoint,authGuard,ret") public void adminApiSuccessLog(JoinPoint joinPoint, AuthGuard authGuard, Object ret) throws JsonProcessingException { AuthLevel level = authGuard.level(); if (level.equals(USER_ONLY) || level.equals(USER_OR_ADMIN)) { return; } String responseString = (ret == null) ? "void" : ret.toString(); sendLogMessage(joinPoint, responseString); } /** * 어드민 유저가 Get 요청을 제외한 API 호출 및 요청 실패(예외) 시 로그를 남기는 메소드 * @param joinPoint joinPoint 객체 * @param authGuard 어드민 유저를 판단하기 위한 AuthGuard 어노테이션 * @param exception API 호출의 예외 객체 * @throws JsonProcessingException Discord WebHook 메시지를 JSON으로 변환하는 과정에서 발생할 수 있는 예외 */ @AfterThrowing(pointcut = ADMIN_CUD_POINTCUT, throwing = "exception", argNames = "joinPoint,authGuard, exception") public void adminApiThrowingLog(JoinPoint joinPoint, AuthGuard authGuard, Exception exception) throws JsonProcessingException { AuthLevel level = authGuard.level(); if (level.equals(USER_ONLY) || level.equals(USER_OR_ADMIN)) { return; } String responseString = exception.getClass().getName() + ":{" + exception.getMessage() + "}"; sendLogMessage(joinPoint, responseString); } /** * joinPoint 객체와 HttpServletRequest로부터 로그 메시지를 생성해서, Discord WebHook으로 전송하고 로그를 남기는 메소드 * @param joinPoint joinPoint 객체 * @param responseString API 호출의 결과를 저장한 문자열 * @throws JsonProcessingException Discord WebHook 메시지를 JSON으로 변환하는 과정에서 발생할 수 있는 예외 */ private void sendLogMessage(JoinPoint joinPoint, String responseString) throws JsonProcessingException { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()) .getRequest(); String name = tokenValidator.getPayloadJson( cookieManager.getCookieValue(request, jwtProperties.getAdminTokenName())) .get("email").asText(); Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); String className = joinPoint.getTarget().getClass().getName(); String methodName = method.getName(); Object[] args = joinPoint.getArgs(); String[] parameterNames = discoverer.getParameterNames(method); StringBuilder sb = new StringBuilder(); // 어드민 사용자 sb.append(name).append("#"); // 메소드 sb.append(className).append("#") .append(request.getMethod()).append("#") .append(methodName).append("#"); // 파라미터 if (Objects.nonNull(parameterNames)) { for (int i = 0; i < args.length; i++) { sb.append("{").append(parameterNames[i]).append("="); if (args[i] != null) sb.append(args[i].toString()).append("}&"); } } if (args.length > 0) { sb.setLength(sb.length() - 1); sb.append("#"); } // 결과 String message = sb.append(responseString).toString(); DiscordAlarmMessage discordAlarmMessage = logParser.parseToDiscordAlarmMessage(message); discordWebHookMessenger.sendMessage(discordAlarmMessage); log.info(message); } }
Java
복사

Web hook

웹 훅(Web hook)은 웹 페이지나 웹 어플리케이션에서 발생하는 특정 이벤트들에 대해 Callback으로 처리하거나 지정된 특정 URL로 클라이언트를 호출할 수 있는 방식이다.
일반적인 API는 클라이언트가 서버를 호출하는 Polling 방식인데, 웹 훅의 경우 특정 이벤트가 발생했을 때 서버가 Callback URL을 통해 클라이언트를 호출하는 방식으로 역방향 API라고도 불린다.

Web hook으로 디스코드 알람 적용하기

위 사진과 같이 디스코드에서 알람을 받은 서버의 서버 설정에서 웹-연동-웹후크 만들기를 누르면,
이처럼 어느 채팅방에 어떤 이름으로 알람을 올릴 지와 그 웹 훅에 대한 URL을 받을 수 있다.
알람을 보낼 메세지를 Map에 “content” 키에 넣고, Map을 위의 웹 훅 URL로 보내면 디스코드에 원하는 메세지를 띄울 수 있다.

참고