Spring AOP

송준섭 Junseop Song·2024년 8월 5일
post-thumbnail

참고

https://velog.io/@dkwktm45/Spring-AOP를-알고-사용-방법을-알자
https://engkimbs.tistory.com/entry/스프링AOP


개요

서버에서 인증이 필요한 API들은 JwtFilter에서 인증을 진행하는데, 원래는 JwtFilter 내에서 클라이언트에서 보낸 토큰을 검증하고, 그 토큰에 담긴 이메일 정보로 데이터베이스에 회원 정보가 존재하는지 까지 확인을 하였다.
그런데 JwtFilter 내부에서는 토큰의 유효성만 검증하고, 회원 존재 여부에 대한 검증은 분리하는 것이 어떤지 팀원분이 제안해주셨다.
일단 단일 책임 원칙에도 어긋나고, 분리하는 편이 유지보수 및 재활용에도 좋을 것 같아서 분리하기로 하였다.
또한 분리하면 꼭 회원 존재 여부를 검사해야만 하는 경우에만 검사를 하여 성능에도 좋을 것 같다고 생각이 든다.
그래서 이러한 과정을 분리하는 데에 어노테이션이나 AOP를 사용할 수 있을 것 같은데, AOP를 거의 이름만 들어본 수준이라 이참에 학습을 해보려고 한다!

AOP

AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)는 관점을 기준으로 묶어 개발하는 방식을 의미
서비스의 비즈니스 로직의 목적에 해당하는 '핵심 기능', 나머지인 '부가 기능'을 서로 구분해 각각의 관점으로 보고, 각 관점을 모듈화 하는 것
부가 기능들은 여러 핵심 기능에서 공통적으로 재사용 되는 것을 볼 수 있는데, 이것을 '흩어진 관심사'라고 부르며 이러한 흩어진 관심사를 Aspect로 모듈화하고 핵심적인 비즈니스 로직에서 분리하여 재사용하겠다는 것이 AOP의 목적

Spring AOP는 프록시 패턴 기반의 AOP 구현체로, 프록시 객체를 쓰는 이유는 접근 제어 및 부가기능을 추가하기 위해서
스프링 빈에만 AOP를 적용 가능하며 모든 AOP 기능을 제공하는 것이 아닌 스프링 IoC와 연동하여 엔터프라이즈 애플리케이션에서 가장 흔한 문제(중복코드, 프록시 클래스 작성의 번거로움, 객체들 간 관계 복잡도 증가 ...)에 대한 해결책을 지원하는 것이 목적

주요 개념

  • Aspect: 흩어진 관심사를 모듈화한 것으로, 주로 부가 기능을 모듈화한 것
  • Target: Aspect를 적용하는 곳 (클래스, 메서드, ,,)
  • Advice: 어떤 일을 해야할 지에 대한 것으로, 부가 기능을 담은 실질적인 구현체
    • @Before: 메소드 실행 전에 동작을 수행하는 Advice
    • @After: 메서드 실행 후에 동작을 수행하는 Advice
    • @AfterReturning: 메서드가 성공적으로 반환된 후에 동작을 수행하는 Advice
    • @AfterThrowing: 메서드에서 에외가 발생한 후에 동작을 수행하는 Advice
    • @Around: 메서드 실행 전후에 동작을 수행하며, 메서드 실행을 직접 제어하는 Advice
  • JoinPoint: Advice가 적용될 위치, 끼어들 수 있는 지점으로, 메서드 진입 지점, 생성자 호출 시점, 필드에서 값을 꺼내올 때 등 다양한 시점에 적용 가능
  • PointCut: JoinPoint의 상세한 스펙을 정의한 것으로, 'A란 메서드의 진입 시점에 호출할 것'과 같이 Advice가 어느 지점에서 실행될 지 정할 수 있음, Target

이런 AOP를 사용해 아래와 같은 상황들에 적용해볼 수 있음

적용 예시

  1. 로깅
    메서드의 호출 및 반환 값을 로깅하는 작업에 사용 가능
@Aspect
@Component
public class LogginAspect {
	// execution(* com.example.sevice.UserService.getUser(..))의 뜻은 다음과 같음
    // 맨 처음 *: 모든 리턴 타입에 대해 적용
    // com.~~.getUser: UserService의 getUser 메서드
    // (..): 모든 파라미터 타입과 개수
    @Before("execution(* com.example.sevice.UserService.getUser(..))")
    public void logBeforeUserGet() {
    	System.out.println("Getting user...");
    }
}
  1. 트랜잭션 관리
    데이터베이스 트랜잭션 관리에 AOP를 사용하여 트랜잭션 시작, 커밋, 롤백 등을 처리할 수 있음
@Aspect
@Component
public class TransactionAspect {
	// com.~~.ProductService.*: ProductService의 모든 메서드에 적용
    @AfterReturning("execution(* com.example.service.ProductService.*(..))")
    public void commitTransaction() {
    	// 트랜잭션 커밋 진행..
    }
    
    @AfterThrowing("execution(* com.example.service.ProductService.*(..))")
    public void rollbackTransaction() {
    	// 트랜잭션 롤백 진행..
    }
}
  1. 보안
    인증 및 인가에 대한 작업
@Aspect
@Component
public class SecurityAspect {
	
    @Before("execution(* com.example.controller.AdminController.*(..))")
    public void checkAdminPermission() {
    	// 인증 및 인가 과정 진행..
    }
}
  1. 캐싱
    메서드 결과를 캐싱하여 성능을 향상시키는 작업
    메서드 결과를 캐싱하고 캐시 유효성을 관리하는 코드 분리
@Aspect
@Component
public class CachingAspect {
	
    @AfterReturning(
    	pointcut = "execution(* com.example.service.CacheService.*(..))",
        returning = "result"
    )
    public void cacheMethodResult(Object result) {
    	// 결과 캐싱 과정 진행..
    }
}
  1. 예외 처리
    특정 예외가 발생하면 처리하는 작업
    물론 예외 처리 과정에는 다른 간편한 사례들도 많지만 AOP를 사용할 수도 있음
@Aspect
@Component
public class ExceptionLogginAspect {
	
    @AfterThrowing(
    	pointcut = "execution(* com.example.service.PaymentService.*(..))",
        throwing = "exception"
    )
    public void logException(Exception exception) {
    	// 에러 로그 기록 과정 진행..
    }
}
  1. 성능 모니터링
    메서드 실행 시간 측정 및 리소스 사용량 추적 등의 작업
@Aspect
@Component
public class PerformanceMonitoringAspect {
	
    @Around("execution(* com.example.service.Analytics.Service.*(..))")
    public Object mesureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
    	long startTime = System.currentTimeMillis();
        Object result = joinPoint.proceed();
        long endTime = System.currentTimeMillis();
        System.out.println("Method execution time: " + (endTime - startTime) + "ms");
        return result;
    }
}

이 외에도 물론 여러 상황에 AOP를 사용할 수 있을 것
부가적으로 위에서는 경로를 통해 AOP를 적용할 특정 메서드를 명시했지만, Bean에 이름이나 특정 어노테이션을 명시할 수도 있음

@Before("bean(Bean 이름)")  // ex) @Before("bean(memberService)")
@Before("@annotation(어노테이션 경로)")  // ex) @Before("@annotation(com.package.MyAnnotation)")

이렇게 핵심 기능과 부가 기능을 분리할 수 있기 때문에 각각의 코드를 따로 관리하여 유지보수에 용이

AOP가 적용되는 방식

AOP가 적용되는 방식은 다양한데, 아래와 같은 방식들이 있음

  1. 컴파일 타임(Compile-Time) 방식
    컴파일 타임에 AOP 적용이 이루어지는 방식
    AspectJ와 같은 AOP 프레임워크를 사용하여 코드를 컴파일 할 때 관점이 적용
    가장 강력하고 정교환 AOP 구현을 제공
    코드 변경과 컴파일이 필요한 점이 단점

  2. 로드 타임(Load-Time) 방식
    로드 타임에 AOP 적용이 이루어지는 방식
    클래스 로더가 클래스를 로드하는 시점에 바이트 코드를 수정하여 관점을 삽입
    컴파일 이후에도 수정 없이 AOP를 적용할 수 있음
    클래스 로더에 종속적일 수 있다는 점이 단점

  3. 런타임(Runtime) 방식
    런타임에서 AOP 적용이 이루어지는 방식
    Spring AOP와 같은 프록시 기반 AOP 프레임워크를 사용하여 런타임 중에 프로시 객체를 생성하여 관점을 적용
    가장 유연하며 동적인 AOP 구현 제공
    프록시 생성에 오버헤드가 발생할 수 있다는 점이 단점

결론 및 코드 적용

결국 우리 서비스에서는 분리하고 싶은 부가 기능은 회원 이메일로 회원 데이터베이스에 접근해 회원이 존재하지 않으면 에러를 발생시키는 기능
이 부가 기능이 필요한 다른 핵심 기능들이 실행되기 전에(@Before) 검사가 진행되면 됨

과거 코드

원래 JwtFilter에서 모든 검사가 이루어 졌음

@Component
class JwtFilter(
    private val jwtService: JwtService,
    private val memberRepository: MemberRepository
): OncePerRequestFilter() {

    override fun shouldNotFilter(request: HttpServletRequest): Boolean {
        val requestUri = request.requestURI
        return Uri.passUris.any { passUri ->
            if (passUri.endsWith("/**")) {
                requestUri.startsWith(passUri.removeSuffix("/**"))
            } else {
                requestUri == passUri
            }
        }
    }
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val accessToken = request.getHeader("Authorization")?.substringAfter("Bearer ")
            ?: throw SoonganException(StatusCode.MISSING_JWT)

        val payload = jwtService.getPayload(accessToken, TokenType.ACCESS)

        val email = payload["sub"] as String
        // 회원 정보 조회 (분리할 부분)
        val member = memberRepository.findByEmail(email)
            ?: throw SoonganException(StatusCode.FORBIDDEN, "유효하지 않은 토큰입니다.")  
        
        val memberDetail = member.toMemberDetails()
    	val auth = UsernamePasswordAuthenticationToken(memberDetail, null, memberDetail.memberAuthorities)
        SecurityContextHolder.getContext().authentication = auth
        filterChain.doFilter(request, response)
    }
}

변경 코드

JwtFilter

@Component
class JwtFilter(
    private val jwtService: JwtService,
): OncePerRequestFilter() {

    override fun shouldNotFilter(request: HttpServletRequest): Boolean {
        val requestUri = request.requestURI

        return Uri.passUris.any { passUri ->
            if (passUri.endsWith("/**")) {
                requestUri.startsWith(passUri.removeSuffix("/**"))
            } else {
                requestUri == passUri
            }
        }
    }

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val accessToken = request.getHeader("Authorization")?.substringAfter("Bearer ")
            ?: throw SoonganException(StatusCode.MISSING_JWT)

        val payload = jwtService.getPayload(accessToken, TokenType.ACCESS)

        val email = payload["sub"] as String
        val authorities = payload["authorities"] as List<String>

        val memberDetail = MemberDetail(
            email = email,
            memberAuthorities = authorities.map { SimpleGrantedAuthority(it) }
        )

        val auth = UsernamePasswordAuthenticationToken(memberDetail, null, memberDetail.memberAuthorities)
        SecurityContextHolder.getContext().authentication = auth
        filterChain.doFilter(request, response)
    }
}

회원 데이터베이스 조회 과정을 없애고 그냥 memberDetail을 authentication 정보로 넣음

SecurityAspect

package com.soongan.soonganbackend.aspects

import com.soongan.soonganbackend.persistence.member.MemberAdapter
import com.soongan.soonganbackend.persistence.member.MemberEntity
import com.soongan.soonganbackend.util.common.dto.MemberDetail
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Before
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.context.request.RequestAttributes
import org.springframework.web.context.request.RequestContextHolder
import kotlin.annotation.AnnotationRetention.*
import kotlin.annotation.AnnotationTarget.*

@Aspect
@Component
class SecurityAspect(
    private val memberAdapter: MemberAdapter
) {

    @Before("@annotation(CheckMember)")
    fun checkMember() {
        val memberDetail = SecurityContextHolder.getContext().authentication.principal
        if (memberDetail is MemberDetail) {
            val member = memberAdapter.getByEmail(memberDetail.email)
                ?: throw RuntimeException("Member not found")
            val request = RequestContextHolder.currentRequestAttributes()
            request.setAttribute("member", member, RequestAttributes.SCOPE_REQUEST)
        }
    }
}

@Target(FUNCTION)
@Retention(RUNTIME)
annotation class CheckMember

fun getMemberFromRequest(): MemberEntity {
    val request = RequestContextHolder.currentRequestAttributes()
    return request.getAttribute("member", RequestAttributes.SCOPE_REQUEST) as MemberEntity
}

@CheckMember 어노테이션이 붙은 메서드들에 대하여 작동
memberDetail에 저장된 email로 회원 데이터베이스를 조회하여 request context holder에 "member"라는 이름으로 회원 정보 저장
이는 이후 Service 메서드들에서 조회한 member 정보를 사용하기 위함
getMemberFromRequest() 함수는 request context holder에서 member 데이터를 추출하는 함수 (Service 메서드들에서 사용)

예시 Service 메서드

@CheckMember
fun withdraw() {
    val member = getMemberFromRequest()
    val softDeletedMember = member.copy(withdrawalAt = LocalDateTime.now())
    memberAdapter.save(softDeletedMember)
    jwtService.deleteToken(member.email)
}

@CheckMember 어노테이션을 붙여주면 메서드 로직 실행 전에 회원 존재 여부 검증
원래는 메서드 내에서 회원 데이터베이스에서 회원을 조회하여 존재하지 않는다면 에러를 내보냈음
위처럼 getMemberFromRequest() 함수로 회원 정보를 가져와 이후 로직에 사용하도록 함

=> 사실 지금 우리 프로젝트에서는 저 로직에 굳이 AOP를 쓸 필요 없이 ArgumentResolver를 쓰면 될 것 같아서 AOP를 없앤 상태이다 ㅎ

0개의 댓글