[Cinemagram] 공통기능은 묶어보자 AOP, 마지막 확인 - (16)

2023. 2. 13. 00:49Project/시네마그램

이번 포스팅은 Cinemagram의 마지막포스팅입니다.

물론, 계속해서 리팩터링 하거나 기능을 추가할 예정이지만 우선 이렇게 막을 내리고 추후에 작업을 해서 올리도록 하겠습니다. 

AOP란?

Aspect Oriented Programming의 약자로 관점 지향 프로그래밍입니다. 

그렇다고 객체지향프로그래밍은 버리겠다는 것이 아니라 추가로 적용 가능합니다.

 

예를 들어보겠습니다. 

로그인 기능, 회원가입 기능을 구현하고자 할 때 로직을 기술해 보면 다음과 같습니다.

핵심기능
로그인 로직 회원가입 로직
1. username, password입력 1. username, password, email, name입력
2. DB에 SELECT쿼리 보냄 2. DB에 INSERT함
3. 로그인(세션)  

 

두 기능의 핵심기능은 다르겠지만 공통적으로 확인하는 기능도 있습니다.

공통기능
유효성 검사
예외처리
로그남기기 ..etc

 

핵심기능의 코드는 생각보다 간단합니다. 비즈니스로직을 Service에서 처리할 수 있게 파라미터로 적절한 객체들을 넘겨주면 됩니다. 반면, 핵심 기능이 아닌 유효성 검사나 예외처리의 코드 더 긴 경우가 있습니다. 

 

뿐만 아니라 여러 기능들에 동일하게 들아가 가독성도 떨어지고 추후 유지보수할 때 불편함을 초래하게 됩니다. 

 

따라서 기능을 구현할 때 핵심기능과 공통기능을 분리하고자 하는 것이 AOP의 핵심입니다. 

 

생각보다 필터링하는 것이 쉽습니다, 저도 최대한 이해하기 쉽게 기술해 보겠습니다. 

라이브러리 추가

implementation 'org.springframework.boot:spring-boot-starter-aop:2.6.1'

 

 

이번 포스팅에서는 유효성 검사 공통기능을 뺄 것이므로 클래스명을 

ValidationAdvice으로 지었습니다. (Advice : 공통기능)

@Component 
@Aspect 
public class ValidationAdvice {

    @Around("execution(* com.photo.web.api.*Controller.*(..))")
    public Object apiAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        return proceedingJoinPoint.proceed();
    }

    @Around("execution(* com.photo.web.*Controller.*(..))")
    public Object advice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        return proceedingJoinPoint.proceed();
    }
}

1. @Aspect

AspectJ 프로젝트에서 제공하는 어노테이션으로 AOP를 처리할 수 있는 핸들러가 됩니다. 

 

2. @Component

이 클래스 또한 메모리에 띄워야 합니다. @Service, @Controller 이외 어떤 어노테이션을 사용해야 할지 모르겠다면 @Component을 사용하면 됩니다.

 

왜냐하면 Service 이런 모든 것들은 Component를 상속해서 만든 구현체이기 때문입니다. 

 

3. 메서드 위에 붙이는 어노테이션

@Before() : 특정 함수 전에 실행
@After() : 특정 함수 후에 실행
@Around() : 특정 함수 전, 후 모두 실행

우리는 그중 @Around()를 사용할 것입니다. 여기서 괄호 안에 주소를 적어야 하는데 순서가 중요합니다. 

@Around("executioon([접근제어자][패키지명].web.모든컨트롤러.모든메서드(모든파라미터))")

 

...

 

 

이렇게 적으면 잘 이해가 안 갑니다. 제가 직접 사용한 주소를 보시면 이해하시기 편하실 겁니다.

@Around("execution(* com.photo.web.api.*Controller.*(..))")

com.photo.web.api안에있는 모든 컨트롤러에서

모든 메서드

그리고 그 메서들에 어떤 파라미터가 있더라도 전부를 가져올 수 있습니다. 

 

4. ProceedingJoinPoint proceedingJoinPoint

해당 Controller의 특정 메서드가 실행될 때

메서드 내부의 모든 정보에 접근할 수 있는 파라미터입니다.

 

5. return proceedingJoinPoint.proceed();

'그 함수로 다시 돌아가라'라는 의미로 

 

ApiController(또는 Controller)의  특정 함수가 실행되면 이 함수가 실행되는 것이 아닌

함수의 모든 정보를 proceedingJoinPoint에 담고 apiAdvice(또는 advice) 함수가 먼저 실행됩니다.

여기서 공통기능을 처리한 후

return 하면 특정 함수가 실행되는 구조입니다. 

 

이렇게 뼈대를 갖췄습니다. 

그럼 특정 함수의 파라미터에 접근하는 코드를 짜봐야겠죠.

@Around("execution(* com.photo.web.api.*Controller.*(..))")
public Object apiAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
    Object[] params = proceedingJoinPoint.getArgs();
    for(Object param : params) {
        if(param instanceof BindingResult) { 
            BindingResult bindingResult = (BindingResult) param;
            ...
        }
    }
    return proceedingJoinPoint.proceed();
}

1. Object[] params = proceedingJoinPoint.getArgs();

특정 함수의 매개변수에 접근해서 매개변수를 전부 뽑아 params에 넣습니다.

 

2. for문을 돌면서 BindingResult 타입이 있으면 (예) 댓글, 회원가입 기능) 유효성 검사를 합니다.

 

이제 if문 안에 여러 메서드에 퍼져있는 유효성 검사(Validation) 코드를 가져오면 끝이 납니다.

 

ApiController에 있는 유효성 검사 코드는 다음과 같습니다.

if(bindingResult.hasErrors()) {
    Map<String, String> errors = new HashMap<>();
    for (FieldError error : bindingResult.getFieldErrors()) {
        errors.put(error.getField(), error.getDefaultMessage());
    }
    throw new CustomValidationApiException("유효성 검사에 실패하였습니다.", errors);
}

 

완성코드

@Around("execution(* com.photo.web.api.*Controller.*(..))")
public Object apiAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
    Object[] params = proceedingJoinPoint.getArgs();
    for(Object param : params) {
        if(param instanceof BindingResult) { 
            BindingResult bindingResult = (BindingResult) param;

            if(bindingResult.hasErrors()) {
                Map<String, String> errors = new HashMap<>();
                for (FieldError error : bindingResult.getFieldErrors()) {
                    errors.put(error.getField(), error.getDefaultMessage());
                }
                throw new CustomValidationApiException("유효성 검사에 실패하였습니다.", errors);
            }
        }
    }
    return proceedingJoinPoint.proceed();
}

 

리팩토링

@Around("execution(* com.photo.web.api.*Controller.*(..))")
public Object apiAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
    Object[] params = proceedingJoinPoint.getArgs();
    for (Object param : params) {
        if (param instanceof BindingResult) {
            BindingResult bindingResult = (BindingResult) param;
            if (bindingResult.hasErrors()) {
                Map<String, String> errors = bindingResult.getFieldErrors().stream()
                    .collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
                throw new CustomValidationApiException("유효성 검사에 실패하였습니다.", errors);
            }
            break; // BindingResult가 발견되면 추가적인 탐색은 필요하지 않습니다.
        }
    }
    return proceedingJoinPoint.proceed();
}
  1. 성능 향상: 이중 for문 대신, 단일 for문을 사용하여 필요한 파라미터만 체크합니다
  2. Java 8 스트림 사용: FieldError 객체들을 Map으로 변환하기 위해 Java 8의 스트림 API를 사용하여 코드를 간결하게 수정했습니다.
  3. 예외 처리: 유효성 검사에서 오류가 발견되면 CustomValidationApiException을 던집니다. 이를 통해  API에서 유효성 검사 오류를 효과적으로 관리하고 사용자에게 적절한 피드백을 제공합니다.
  4. 코드 간결성: break 문을 사용하여 BindingResult 인스턴스가 발견되면 더 이상의 탐색을 중단하여 불필요한 연산을 줄였습니다.

 

Controller 유효성 검사도 위와 같이 처리합니다.

 

그 후 해당 컨트롤러에서 유효성 검사 if문은 다 삭제합니다. 

 

ApiController

  • CommentApiContrller > 댓글 등록
  • UserApiContoller > 유저 정보 업데이트

 

Controller

  • AuthContoller >  회원가입

이젠 AOP가 잘 작동하는지 확인해 보겠습니다. 

댓글을 적지 않고 게시를 눌러보면 "공백일 수 없습니다." alert가 뜹니다. 

(아! 그전에 프런트에서 막는 부분은 주석 처리 하셔야 합니다)

 

 

이번엔 회원가입 시 username을 빈칸으로 두고 가입하기 버튼을 눌러보겠습니다. 

 

따로 빼서 작성해도 잘 동작하는 것을 확인했습니다. 

 

[정리] 이렇게 공통기능 AOP 만들면 앞으로 유효성 검사가 필요한 곳에는
1. DTO 만들고
2. @NotBlank, @Size 등등 유효성 검사 어노테이션을 걸어주고
3. 파라미터 받을 때 @Valid, @BindingResult만 걸어주면 끝이 납니다.

 


 

제가 처음에 기획했던 기능들은 다 구현했습니다. 

마지막으로 확인 테스트를 하고 push 하도록 하겠습니다. 

구현한 기능

  • 회원가입
    • 일반, 소셜(네이버, 구글)
    • 네이버의 경우 현재 프로젝트는 개발 중 상태이고 검수 요청을 하지 않은 상태입니다. 현재는 등록한 하나의 아이디로만 로그인 가능
    • 필드들을 하나라도 채우지 않으면 유효성 검사, 프론트단에서 막음
  • 로그인
    • 소셜로 이미 가입되었으면 그 후로 ' ~로 로그인하기'를 클릭 시 Insert가 안되어야 함
    • 소셜로 가입 시 provider, providerId 필드가 채워짐
  • 이미지 추가
    • 추가 후 밑에 출력됨
    • 로그인 유저는 이미지 추가 버튼, 이외 유저 페이지 이동시 팔로우 버튼 보임
  • 프로필 사진 변경
    • 다른 유저 프로필 사진 변경 못함
  • 회원정보 변경
  • 로그아웃
  • 팔로우
    • 내가 팔로우한 유저가 올린 이미지가 내 Feed에 떠야 함
    • 팔로잉 버튼을 클릭해 뜨는 모달에서도 내가 팔로우한 유저확인 및 언팔로우 가능
    • 다른 유저의 팔로잉 모달 내에서도 팔로우, 언팔로우 가능
  • 좋아요
    • 좋아요, 좋아요 취소
  • 댓글
    • 댓글 등록, 최신 댓글이 가장 상단에 위치
    • 댓글 내용 없이 게시하면 유효성 검사, 프런트단 alert로 막음
    • 삭제
    • 새로고침(F5)을 눌러서 계속 남아있는지 확인
    • 다른 유저의 댓글 삭제 못함
  • Popular페이지
    • 좋아요 많이 받은 순서대로 이미지 출력
      • 내가 팔로우한 유저만 나오는 것이 아닌 모든 유저를 대상으로 함
      • 따라서 모든 유저의 Popular페이지가 같음
    • 이미지 클릭 시 해당 이미지를 업로드한 유저 Profile 페이지로 이동
    • 이미지 마우스 오버 시 좋아요를 몇 개 받았는지 확인 가능
  • Feed페이지의 페이징
    • 내가 팔로우하는 유저들의 이미지들이 뜸
    • 스크롤을 내리면 계속해서 볼 수 있음

추가로 이번 프로젝트 때 구현 해본 것들

  • 일반 시큐리티 로그인
  • OAuth2.0 로그인 
    • 네이버
    • 구글
  • 엔티티 연관관계 분석 및 무한 순환 참조 문제 해결
    • @JsonIgnoreProperties({" "})
  • AOP
    • 유효성 검사
  • 예외 처리
    • ExceptionHandler 사용
      • API 관련 예외
      • 공통 예외
      • 유효성 검사 + API 관련 예외
      • 유효성 검사 + 공통 예외
  • 파라미터 엔티티 사용 금지, DTO 생성

 

 

 

 

 

 

여기까지 해서 Validation AOP 적용으로 push했습니다.