
https://velog.io/@dkwktm45/Spring-AOP를-알고-사용-방법을-알자
https://engkimbs.tistory.com/entry/스프링AOP
서버에서 인증이 필요한 API들은 JwtFilter에서 인증을 진행하는데, 원래는 JwtFilter 내에서 클라이언트에서 보낸 토큰을 검증하고, 그 토큰에 담긴 이메일 정보로 데이터베이스에 회원 정보가 존재하는지 까지 확인을 하였다.
그런데 JwtFilter 내부에서는 토큰의 유효성만 검증하고, 회원 존재 여부에 대한 검증은 분리하는 것이 어떤지 팀원분이 제안해주셨다.
일단 단일 책임 원칙에도 어긋나고, 분리하는 편이 유지보수 및 재활용에도 좋을 것 같아서 분리하기로 하였다.
또한 분리하면 꼭 회원 존재 여부를 검사해야만 하는 경우에만 검사를 하여 성능에도 좋을 것 같다고 생각이 든다.
그래서 이러한 과정을 분리하는 데에 어노테이션이나 AOP를 사용할 수 있을 것 같은데, AOP를 거의 이름만 들어본 수준이라 이참에 학습을 해보려고 한다!
AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)는 관점을 기준으로 묶어 개발하는 방식을 의미
서비스의 비즈니스 로직의 목적에 해당하는 '핵심 기능', 나머지인 '부가 기능'을 서로 구분해 각각의 관점으로 보고, 각 관점을 모듈화 하는 것
부가 기능들은 여러 핵심 기능에서 공통적으로 재사용 되는 것을 볼 수 있는데, 이것을 '흩어진 관심사'라고 부르며 이러한 흩어진 관심사를 Aspect로 모듈화하고 핵심적인 비즈니스 로직에서 분리하여 재사용하겠다는 것이 AOP의 목적
Spring AOP는 프록시 패턴 기반의 AOP 구현체로, 프록시 객체를 쓰는 이유는 접근 제어 및 부가기능을 추가하기 위해서
스프링 빈에만 AOP를 적용 가능하며 모든 AOP 기능을 제공하는 것이 아닌 스프링 IoC와 연동하여 엔터프라이즈 애플리케이션에서 가장 흔한 문제(중복코드, 프록시 클래스 작성의 번거로움, 객체들 간 관계 복잡도 증가 ...)에 대한 해결책을 지원하는 것이 목적
@Before: 메소드 실행 전에 동작을 수행하는 Advice@After: 메서드 실행 후에 동작을 수행하는 Advice@AfterReturning: 메서드가 성공적으로 반환된 후에 동작을 수행하는 Advice@AfterThrowing: 메서드에서 에외가 발생한 후에 동작을 수행하는 Advice@Around: 메서드 실행 전후에 동작을 수행하며, 메서드 실행을 직접 제어하는 Advice이런 AOP를 사용해 아래와 같은 상황들에 적용해볼 수 있음
@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...");
}
}
@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() {
// 트랜잭션 롤백 진행..
}
}
@Aspect
@Component
public class SecurityAspect {
@Before("execution(* com.example.controller.AdminController.*(..))")
public void checkAdminPermission() {
// 인증 및 인가 과정 진행..
}
}
@Aspect
@Component
public class CachingAspect {
@AfterReturning(
pointcut = "execution(* com.example.service.CacheService.*(..))",
returning = "result"
)
public void cacheMethodResult(Object result) {
// 결과 캐싱 과정 진행..
}
}
@Aspect
@Component
public class ExceptionLogginAspect {
@AfterThrowing(
pointcut = "execution(* com.example.service.PaymentService.*(..))",
throwing = "exception"
)
public void logException(Exception exception) {
// 에러 로그 기록 과정 진행..
}
}
@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가 적용되는 방식은 다양한데, 아래와 같은 방식들이 있음
컴파일 타임(Compile-Time) 방식
컴파일 타임에 AOP 적용이 이루어지는 방식
AspectJ와 같은 AOP 프레임워크를 사용하여 코드를 컴파일 할 때 관점이 적용
가장 강력하고 정교환 AOP 구현을 제공
코드 변경과 컴파일이 필요한 점이 단점
로드 타임(Load-Time) 방식
로드 타임에 AOP 적용이 이루어지는 방식
클래스 로더가 클래스를 로드하는 시점에 바이트 코드를 수정하여 관점을 삽입
컴파일 이후에도 수정 없이 AOP를 적용할 수 있음
클래스 로더에 종속적일 수 있다는 점이 단점
런타임(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)
}
}
@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 정보로 넣음
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 메서드들에서 사용)
@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를 없앤 상태이다 ㅎ