코딩항해기

[Team/붕어빵원정대(최프)] AOP 작업 본문

Project

[Team/붕어빵원정대(최프)] AOP 작업

miniBcake 2024. 10. 21. 18:37

 

 

AOP 개념을 처음 배우다보니 설계도 우여곡절이 많았는데 관련 서적과 검색, 실무에 대해 아시는 분의 조언을 통해 최종 횡단 기능을 정리하고 AOP 작업을 진행했다.

 

설계

 

검증 로직과 반복되는 로그를 공통 기능으로 분리했다. 초기에는 url을 직접 수정해 유효하지 않는 요청을 하는 것을 막을 AOP도 가지고 있었는데, 해당 AOP는 Service에서 검증하는 것보단 Controller가 시작될 때 검증해야한다는 점에서 이상함을 느끼게 됐다. 

 

여러 검색과 조언을 통해 해당 로직은 View에서 페이지를 로드할 때 검증하는 것으로 로직을 수정할 수 있었다. Service를 감싸서 AOP를 작업하는 것도 가능은 하지만, 이미 페이지 로드가 들어간 다음에 검증이 진행된다는 점이 깔끔하지 않다고 생각했다.

 

AOP코드

설계를 기반으로 Pointcut을 모아두는 PointcutCommon 클래스를 생성했다.

기존 설계에서 변경된 부분을 주석으로 표기했다.

 

@Aspect
public class PointcutCommon {
    //로그인 확인, 작성자 일치확인, 권한 검증 등 AOP사용 포인트 컷
    @Pointcut("execution(boolean com.bungeabbang.app.biz..*Impl.*(..))")
    public void cudAllPointcut() {}

    //에러 발생 시 에러코드, service사용 로그 등 포인트 컷
    @Pointcut("execution(* com.bungeabbang.app.biz..*Impl.*(..))")
    public void allPointcut() {}

//    //존재하지 않는 데이터 접근 확인용 등 포인트 컷 -> View에서 js확인으로 변경
//    @Pointcut("execution(* com.bungeabbang.app.biz..*Impl.selectOne(..))")
//    public void selecOnePointcut() {}
}

 

Advice는 기능에 따라 ErrorAdvice, LogAdvice, UserCheckAdvice로 분리했으며, Error에는 에러가 발생했을 때 주석을 출력하는 기능을, Log에는 단순 로그 기능을, UserCheck에는 User정보를 검증하는 로직을 넣어 코드의 응집도를 높였다.

 

@Service
@Slf4j
@Aspect
public class ErrorAdvice {
    //발생한 에러 확인로그 출력
    @AfterThrowing(pointcut = "PointcutCommon.allPointcut()", throwing = "ex")
    public void afterThrowing(JoinPoint joinPoint, Throwable ex) {
        log.error("\u001B[33mAOP: errorThrowing : {}\n\terrorMsg : {}\n\tStackTrace : {}\u001B[0m",
                //에러가 난 메서드 시그니처, 에러 메세지, stackTrace (replace는 줄바꿈 처리, ExceptionUtils를 통해 stackTrace를 문자열로 반환)
                joinPoint.getSignature(), ex.getMessage(), ExceptionUtils.getStackTrace(ex).replace("\n", "\n\t\t"));
    }
}

 

@Service
@Slf4j
@Aspect
public class LogAdvice {
    //Service 사용 시작 로그
    @Before("PointcutCommon.allPointcut()")
    public void beforeLogger(JoinPoint joinPoint) throws Throwable {
        log.info("\u001B[33m---- AOP: start Service : {}\u001B[0m", joinPoint.getSignature());
    }
    //Service 사용 종료 로그
    @After("PointcutCommon.allPointcut()")
    public void afterLogger(JoinPoint joinPoint) throws Throwable {
        log.info("\u001B[33m---- AOP: end Service : {}\u001B[0m", joinPoint.getSignature());
    }
}

 

@Service
@Slf4j
@Aspect
public class UserCheckAdvice {
    @Autowired
    private HttpSession session;

    //session
    private final String MEMBER_PK = "userPK"; //세션에 저장된 memberPK

    //로그인 확인
    @Before("PointcutCommon.cudAllPointcut()")
    @Order(1) //첫번째로 실행
    public void loginCheck(JoinPoint joinPoint) {
        log.info("AOP: loginCheck start");
        //로그인 여부 확인 (member c :회원가입, product c는 검사하지 않음 : 크롤링...)
        if(!joinPoint.getSignature().toString().contains("MemberService.insert") && !joinPoint.getSignature().toString().contains("ProductService.insert") ) {
            if(session.getAttribute(MEMBER_PK) == null) {
                //로그인 되지 않은 상태라면 예외처리
                log.error("AOP try : {} / error not login", joinPoint.getSignature());
                throw new RuntimeException("try : " + joinPoint.getSignature() + " / error not login");
            }
        }
        log.info("AOP: loginCheck end");
    }

    //유효한 사용자인지 확인 (해당 데이터에 접근할 수 있는 사용자인지 검증
    @Before("PointcutCommon.cudAllPointcut()")
    public void userCheck(JoinPoint joinPoint) {
        log.info("AOP: userCheck start");
        Object MemberNum = null; //작성자 정보
        boolean flag = false;
        String signature = joinPoint.getSignature().toString(); //해당 메서드 경로 및 시그니처
        if(signature.contains("board")){
            log.info("AOP: userCheck board");
            //만약 board쪽 cud라면 작성자인지 확인
            for(Object o : joinPoint.getArgs()){ //파라미터에서 BoardDTO 찾기
                if(o instanceof BoardDTO){
                    MemberNum = ((BoardDTO) o).getMemberNum(); //작성자 정보 저장
                    flag = true;
                    break;
                }
            }
        }
        else if(signature.contains("reply")){
            log.info("AOP: userCheck reply");
            //만약 reply cud라면 작성자인지 확인
            for(Object o : joinPoint.getArgs()){ //파라미터에서 ReplyDTO 찾기
                if(o instanceof ReplyDTO){
                    MemberNum = ((ReplyDTO) o).getMemberNum(); //작성자 정보저장
                    flag = true;
                    break;
                }
            }
        }
        if(flag){
            log.info("AOP: MemberNum [{}]", MemberNum);
            if(session.getAttribute(MEMBER_PK) != MemberNum) { //작성자와 로그인한 사용자가 일치하지 않을 경우
                //예외처리
                log.error("AOP try : {} / error not writer", joinPoint.getSignature());
                throw new RuntimeException("try : " + joinPoint.getSignature() + " / error not writer");
            }
            log.info("AOP: userCheck end");
        }
    }
}

 

UserCheckAdvice에서는 모두 Session에 접근해 값의 존재를 확인하거나, 대조하는 기능을 수행하기 때문에 필드로 선언해 주입을 받는 방식으로 설계해 구현했다.

 

하지만 Session에 데이터를 등록하는 로직은 Member파트를 맡은 팀원이 진행하는데 아직 명확한 설계를 공유받지 못해 어떤 이름으로 들어갈지 확신이 없었다. 그래서 주석 커스텀 태그 기능을 통해 나만 확인할 수 있는 표시를 남겨 나중에 다시 확인할 수 있도록 표기 했다.

//session
//KS 세션 저장명 확인 필요
private final String MEMBER_PK = "memberPK"; //세션에 저장된 memberPK

*ks는 의미없이 그냥 편해서 사용하고 있다...

 

그리고 같은 시기의 AOP의 순서를 지정했는데, 만약 로그인 안된 상태라면 로그인 정보와 작성자 정보를 비교할 필요가 없기 때문이다. 그래서 로그인 검증 AOP에 Order(1)을 주어 가장 먼저 실행될 수 있도록 설계했다.

 

 

보완하고 싶은 점

AOP로 검증되는 부분은 사용자의 비정상적인 접근이기 때문에 (정상적인 접근일 경우 View의 검증로직을 통과할 수 없다) 에러를 일으켜 에러 페이지로 이동할 수 있도록 하고 있는데, 이 부분을 좀 더 보완할 수 있다면 좋을 것 같다.