[spring] reflection API와 AOP를 활용한 공통 헤더 값 입력하기

KIM Jongwan·2024년 1월 24일
0

SPRING

목록 보기
5/5
post-thumbnail

이전 포스트에서 AOP를 활용한 성능 측정 방법에 대해 알아보았습니다.

AOP의 주요한 목적이 핵심 기능과 부가 기능을 분리하여 프로그래머로 하여금 비지니스 로직에 집중할 수 있게 하는데 있다는 것을 알았습니다.

이제 AOP를 이용하여 실무에서 자주 겪을 수 있는 부가 기능 중 공통 헤더 처리 기능을 분리해봅시다.

공통 헤더

사용자가 있는 어플리케이션에는 현재 사용자가 위치한 페이지에 대한 정보가 담겨있는 공통 헤더 부분이 존재하게 됩니다.

이미지 입력

해당 view 파일에 공용 헤더 부분을 직접 수정하는 방식으로 다양한 페이지의 공통 헤더 처리가 가능하지만 오탈자의 위험, view가 많아졌을 때 하나하나 직접 수정하는 데 한계가 존재합니다.
이런 경우 Model 객체를 활용하여 Back단에서 값을 입력하여 처리하는 방식을 많이 사용하고 있을겁니다.

@Controller
public class HelloController(){

	@GetMapping("/hello/index")
	public String indexPage(Model model){

		model.addAttribute("depth1", "헬로우 서비스");
		model.addAttribute("depth2", "인덱스");

		//...do something

		return "index";
	}

	@GetMapping("/hello/list")
	public String listPage(Model model){

		model.addAttribute("depth1", "헬로우 서비스");
		model.addAttribute("depth2", "목록");

		//...do something

		return "list";
	}

	...

}

HelloController Model객체를 사용한 attribute setting

<!DOCTYPE html>
<html lang="ko">

	<head>
	    <meta charset="utf-8">
	    <meta http-equiv="X-UA-Compatible" content="IE=edge">
	    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
	    <meta name="description" content="">
	    <meta name="author" content="">
	    <script src="/vendor/jquery-easing/jquery.easing.min.js"></script>
	</head>
	<body>
		<div class="common-heaer">
			<span class="header-item">{{depth1}}</span>
			<span class="header-item">{{depth2}}</span>
		</div>


		.... render something
	</body>
</html>

common header 가 포함된 mustache 파일에서 model 값 사용

하지만 이러한 처리 방식은 html에 직접 작성하던 방식을 back단의 Controller부분으로 위임한 것이지 방식을 개선되었다고 할 수 없습니다. 오히려 Controller에는 공통 헤더 값을 입력해주는 부가 기능이 추가된 모습을 볼 수 있습니다.

공통되는 부가 기능을 확인하였으니 AOP를 활용하여 이를 핵심 기능과 분리해보도록 합시다.


목표

최종적인 목표는 간단합니다. Controller에서 공통 헤더 값을 셋팅해주는 로직을 별도의 Aspect로 떼어네어, 페이지 이동을 담당하는 Controller의 메소드가 실행 될 때 필요한 값들을 model attribute로 알아서 입력해주는 것입니다.

@CommonHeader custom annotation

우선 custom annotation class를 하나 작성해보도록 하겠습니다.

@CommonHeader@Column(name = “someThing”)과 같이 특정 메소드에서 사용될 수 있는 value값을 지정해주는 역할을 합니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CommonHeader {
	
	String depth1 default "depth1";
	String depth2 default "depth2";

}
  • @Target(ElementType.METHOD)
    • CommonHeader annotation은 메소드 레벨에서 사용 가능
  • @Retention(RetentionPolicy.RUNTIME)
    • CommonHeader annotation의 라이프사이클이 Runtime임을 명시

이제 앞서 작성했던 HelloController에 @CommonHeader를 적용해보도록 하겠습니다.

@Controller
public class HelloController(){

	@CommonHeader(depth1 = "헬로우 서비스", depth2 = "인덱스")
	@GetMapping("/hello/index")
	public String indexPage(Model model){

		//...do something

		return "index";
	}

	@CommonHeader(depth1 = "헬로우 서비스", depth2 = "목록")
	@GetMapping("/hello/list")
	public String listPage(Model model){

		//...do something

		return "list";
	}
	...
}

이전에 작성했던 로직보다 한결 간결해졌음은 물론 각 메소드는 비지니스 로직에 집중할 수 있는 구조가 되었습니다.

CommonHeaderAspect.class

이제 부가 기능 수행 역할을 하게 될 CommonHeaderAspect.class를 작성해보겠습니다.

  • 페이지 이동(router)역할을 하는 class를 타겟으로 실행됩니다.
  • 부가 기능은 핵심 기능이 모두 실행 된 뒤 한 번 실행된다.
  • 사용자의 Request URI를 확인하여 실행 될 Controller 내부 method를 찾는다.
  • 해당 method의 @CommonHeader Annotation을 확인하여 depth1과 depth2의 값을 가져온다
  • 전달받은 model 객체에 각각 depth1, depth2라는 key로 등록한다.

CommonHeaderAspect가 실행 될 때 일어나는 일들을 정리해 보았습니다. 이를 토대로 코드를 작성해보도록 하겠습니다.

우선 페이지 이동(Router.class)과 API(Controller.class) 역할을 하는 클래스를 패키지 수준에서 분리하도록 하겠습니다.

├── main
│   └── com
│        ├── router
│        └── controller
└── resources
    └── template

@Aspect

@Aspect // (1)
public class CommonHeaderAspect {

	@After("execution(public String com.router..*Router.*(..)) && @annotation(org.springframework.web.bind.annotation.GetMapping))") // (2)
	public void commonHeaderMethod(JoinPoint joinPoint) {
		String depth1 = null;
		String depth2 = null;

		HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); // (3)

		String requestUri = request.getRequestURI();
		
		Class targetClass = joinPoint.getTarget().getClass(); // (4)
		
		Method[] methods = targetClass.getDeclaredMethods(); 
		for(Method m : methods){
			String tempUri = m.getAnnotation(GetMapping.class).value()[0];
			if(requestUri.equals(tempUri)){
				depth1 = m.getAnnotation(CommonHeader.class).depth1();
				depth2 = m.getAnnotation(CommonHeader.class).depth2();
			}
		}

		Object[] args = joinPoint.getArgs(); // (5)
		for(Object arg : args){
			if(arg instanceof Model){
				Model model = (Model)arg;
				model.addAttribute("depth1", depth1);
				model.addAttribute("depth2", depth2);
			}
		}
	}
}
  1. 해당 클래스가 여러 클래스에 공통으로 적용되는 Aspect임을 명시해줍니다.
  2. AOP에서 공통 기능 적용 시점을 Advice라고 합니다.
    스프링에서는 @Before, @After, @Around, @AfterRetuning, @AfterThrowing을 사용 가능합니다.
    사용된 @After의 경우는 대상이되는 객체의 method 실행 후 공통 기능을 실행함을 명시해주었습니다.
  • execution(public String com.router..*Router.*(..): com.router 패키지에 포함되어있는 클래스중 Router로 끝나는 클래스의 method를 대상으로 실행됩니다. (접근제한자는 public, Return type은 String인 method가 실행 대상이 됩니다.)
  • @annotation(org.springframework.web.bind.annotation.GetMapping)): 화면 이동 역할의 method를 대상으로 실행하기 때문에 GetMapping annotation이 붙어있는 method를 대상으로 이전 조건과 and연산을 하였습니다.
  1. RequestContextHolder: 전역에서 Request에 대한 정보를 사용할 수 있도록 Spring에서 제공하는 클래스입니다. 요청된 Request의 정보를 받아와 사용자 Request Uri를 초기화합니다.
  2. 공통 기능이 호출된 Class의 정보를 조회합니다. Class에 선언되어있는 method 목록을 가져와 Request uri에 호출될 method를 특정하여 annotation값을 초기화합니다.
  3. 전달된 model 인자에 초기화한 값을 추가합니다.

문제점과 개선 사항

이와 같이 처리할 경우 /book/save, /user/users, /item/delete 와 같은 주소로의 요청은 간단하게 처리가 가능하지만, /book/1, /user/update/3, /item/delete?parameter=true 와 같이 Parameter가 uri에 포함되어 전달되는 경우의 처리가 어려울 수 있습니다.

이런 경우 @Aspect의 공통 기능 로직 내에서 파라미터 포함 여부를 확인하여 파라미터를 제외한 값들로 값 처리를 진행하면 가능하겠지만, 로직이 복잡하고 직관적이지 않게 될 가능성이 있습니다.

지금까지의 학습 내용을 바탕으로 작성한 Aspect는 현 상태가 최선이지만 추후 새로 알게 된 사실이 생긴다면 이와 같은 문제점도 처리할 수 있도록 업데이트 할 예정입니다.

profile
2년차 백앤드 개발자입니다.

0개의 댓글