현재 진행하고 있는 프로젝트에서는 컨트롤러단에 Custom Annotation이 선언 되어 있다.
컨트롤러 Annotation 선언부
@HoyasRegexpValid(returnEmptyClass = ProductSearchResult.class, checkFiledName = "searchWord")
Annotation 정의부
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface HoyasRegexpValid {
String regexp() default "([a-zA-zㄱ-ㅎ|ㅏ-ㅣ|가-힣0-9]+)";
String checkFiledName();
Class<?> returnEmptyClass();
}
해당 Annotation은 요청이 들어오면 해당 파라미터가 적절한지 판단 후 적절하지 않다면 (금칙어, 특수문자 등이 있으면) 이에 맞는 내용을 바로 리턴해주는 역할을 한다.
이러한 역할을 하기위해 해당 Annotation을 처리해주는 AOP가 정의 되어있는데, 해당 AOP는 각각의 컨트롤러단에서 정의한returnEmptyClass가 다르더라도 항상 똑같은 Class<?>만 내려주고 있었다.
AOP 선언부
@Around(value = "@annotation(com.*.HoyasRegexpValid) && @annotation(hoyasRegexpValid)")
public Object aroundSearch(ProceedingJoinPoint joinPoint, HoyasRegexpValid hoyasRegexpValid) throws Throwable {
Map<String, Object> param = new HashMap<>();
try {
// before
String regexp = hoyasRegexpValid.regexp();
String checkFiledName = hoyasRegexpValid.checkFiledName();
Object[] args = joinPoint.getArgs();
if (args.length > 0) {
param = mapper.convertValue(args[0], new TypeReference<Map<String, Object>>(){});
String value = HoyasConverter.convertString(param.get(checkFiledName));
boolean valid = this.isValid(value, Pattern.compile(regexp));
Object proceed;
if (valid) { // 파라미터의 검색어가 적절하다면
// process
proceed = joinPoint.proceed();
} else { // 파라미터의 검색어가 적절하지 않다면
EmptyResult emptyResult = new EmptyResult();
DefaultListResult<Object> result = new DefaultListResult<Object>(); // 항상 똑같은 리턴타입인 DefaultListResult를 내려주고 있다.
result.setEmptyResult(emptyResult);
proceed = new ResponseFormat(result);
}
return proceed;
} else {
return joinPoint.proceed();
}
} catch (Exception e) {
log.error("around search is failed. parameter: {}, e: ", param, e);
throw e;
}
}
이렇기 때문에, 각각의 컨트롤러 단에서 적절하지 않다는 내용을 추가해야 하는 필드들을 AOP에서 내려주고 있는 Class<?> (해당 코드에서는 DefaultListResult) 에 선언해야만 했다.
해당 어노테이션을 좀 더 사용성이 좋고 확장하기 편한 그런 어메이징한 기능을 하려면 어떻게 바꿔야 할까?
이 글을 읽고 있는 독자들이라면 어떻게 할것인가? 나는 자바의 reflection 기능을 떠올렸다.
개선 방안
1. Annotation 정의부에 검색 결과가 없을때마다 returnEmptyClass에 설정해줘야 하는 Class<?> 들을 선언하는 새로운 필드 setEmptyClass() 를 생성했다.
변경된 Annotation 정의부
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface HoyasRegexpValid {
String regexp() default "([a-zA-zㄱ-ㅎ|ㅏ-ㅣ|가-힣0-9]+)";
String checkFiledName();
Class<?>[] setEmptyClass() default {}; // 새로 추가된 필드
Class<?> returnEmptyClass();
}
기존 코드에 영향을 끼치지 않도록 default 로 {}를 설정해 주었다.
2. 컨트롤러에는 검색 결과가 없을때 설정해줘야 하는 필드들의 Class를 setEmptyClass에 넣어줬다.
변경된 컨트롤러 Annotation 선언부
@HoyasRegexpValid(returnEmptyClass = ProductSearchResult.class, setEmptyClass = {EmptyResult.class, SearchTabCountResult.class}, checkFiledName = "searchWord")
default로 빈 배열이 들어가있어 새롭게 설정해야만 하는 컨트롤러에만 setEmptyClass 를 선언해주면 된다.
3. AOP 구현 클래스에 setEmptyClass 배열의 class 들을 각각 reflection을 이용하여 빈 객체를 생성하게 하고 returnEmptyClass 내의 set + “class 이름”을 통해 설정해주었다.
어디서 많이 봤는데? 생각하면 맞다. ObjectMapper가 해당 로직과 비슷하게 동작하고 있다.
@Around(value = "@annotation(com.*.HoyasRegexpValid) && @annotation(hoyasRegexpValid)")
public Object aroundSearch(ProceedingJoinPoint joinPoint, HoyasRegexpValid hoyasRegexpValid) throws Throwable {
Map<String, Object> param = new HashMap<>();
try {
String regexp = hoyasRegexpValid.regexp();
String checkFiledName = hoyasRegexpValid.checkFiledName();
Object[] args = joinPoint.getArgs();
if (args.length > 0) {
param = mapper.convertValue(args[0], new TypeReference<Map<String, Object>>(){});
String value = HoyasConverter.convertString(param.get(checkFiledName));
boolean valid = this.isValid(value, Pattern.compile(regexp));
Object proceed;
if (valid) {
// process
proceed = joinPoint.proceed();
} else {
// 변경 된 코드 시작
if (hoyasRegexpValid.setEmptyClass().length > 0) {
Object object = hoyasRegexpValid.returnEmptyClass().newInstance(); // reflection을 이용한 빈 객체 생성
Class<?>[] classes = hoyasRegexpValid.setEmptyClass(); // setEmptyClass의 Class<?>[] 배열 가져옴
for (Class<?> clazz : classes) { // Class<?>[] 배열의 모든 Class<?>
Method method = object.getClass().getMethod("set" + clazz.getSimpleName(), clazz); // 해당 필드를 설정하는 set 메소드를 가져온다
method.invoke(object, clazz.newInstance()); // 해당 메소드 실행
}
proceed = new ResponseFormat(object);
// 변경 된 코드 끝
} else { // 기존 레거시 코드와의 연계성을 위해 남겨두었다.
EmptyResult emptyResult = new EmptyResult();
DefaultListResult<Object> result = new DefaultListResult<Object>();
result.setEmptyResult(emptyResult);
proceed = new ResponseFormat(result);
}
}
return proceed;
} else {
return joinPoint.proceed();
}
} catch (Exception e) {
log.error("around search is failed. parameter: {}, e: ", param, e);
throw e;
}
}
기대 효과
이를 통해 검색어에 대한 검증이 필요한 컨트롤러에서는
- 자신이 리턴하고 싶은 클래스 (returnEmptyClass)
- 해당 검증이 실패 했을때 설정해줘야 하는 클래스 (setEmptyClass)
위 두개만 명시해주면 모든 검증과 검증 실패시 값 설정을 어메이징하게 자동화 해줄것이다.
모든 개발자는 메소드명 하나 짜는거, 필드명 하나 짜는거 신중해야 한다.
또한, 내가 이 코드를 작성했을때 남들도 과연 알아볼 수 있는가?
확장성이 좋지 않아 비슷한 역할을 해도 다시 짜야하는가? 를 꼭 밥먹듯 생각하자.
추후에 스프링 AOP 관련과 자바의 Reflection에 대해 깊게 다뤄볼 예정이다.
'Spring' 카테고리의 다른 글
[Spring] Spring WebClient의 사용 (0) | 2022.06.04 |
---|---|
[Spring] 비동기 프로그래밍 TaskRejectedException (0) | 2022.04.20 |