기록하며 성장한다 - 개발, 회고

개발 회고록

[ 개발 회고록 ] 공통 인프라 로직인, 로그인 확인 기능을 AOP로 분리하기

전대홍 2024. 1. 24. 16:26

아, 로그인 체크 로직이.. 너무 중복되서 들어가는데.. 유지보수도 힘들고, 가독성도 안좋네.. 방법이 없나...?

서론

현재 배달과 관련 된 토이프로젝트를 진행하고 있다.

토이 프로젝트 링크 <<<

사실 이 내용은 조금 일찍 블로그에 포스팅했어야 하는 내용이다. 로그인 관련 기능은 이미 프로젝트 초반에 AOP 를 공부하며 개발을 끝냈기 때문이다.

그러나, 다른 내용들을 우선하여 정리하다보니 포스팅이 다소 늦게 되었다.

먼저, 이렇게 로그인 체크를 분리하게 된 이유는 다름아닌 공통 기능이기 때문이었다. 필자가 공부했던 내용들을 토대로 생각해보면, 이렇게 횡단 관심사이자 공통된 인프라 로직들은 AOP 를 활용하여 분리해 낼 수 있기 때문이다.

무엇보다 개발자 됨으로서 컨트롤러마다 중복된 코드를 넣는다는게 매우 불편했다^^;;

( 이제 중복된 코드는 어떻게든 묶어버리고 싶은 욕구가.. 강박 수준으로 와버렸다.. ( 장난이다 ㅎㅎ ) )

 

 


본론

1. 기존 코드

필자의 기본 코드는 아래와 같았다.

Member member = loginService.getCurrentMember();
if (member == null) {
    throw new HttpClientErrorException(HttpStatus.UNAUTHORIZED);
}

Owner 라면 본인 매장과 관련한 일을 할 경우, User 가 주문을 하는 경우 등등 로그인이 되어있는지 체크를 해야하는 경우가 매우 많았고, 로그인 정보가 세션에 없다면 예외를 던져주는 로직을 이렇게 만들었다.

해당 코드를 컨트롤러마다 적어주었고, 나중에는 그걸 LoginUtil 이라는 클래스를 만들어서 따로 빼준뒤, 메서드로 불러왔다. 그러나 그렇게 해도 결국 해당 메서드를 반복적으로 불러오게 되는 것이므로 기분이 좋지 않았다.

그래서 어노테이션을 활용해서 AOP로 분리를 해야겠다는 생각을 하였다.

 

2. 분리 방법

원래 AOP 를 사용하려면  @EnableAspectJAutoProxy 라는 어노테이션을 사용해야 한다.

그러나 우리 스프링부트 main 메서드가 있는 클래스에는, @SpringbootApplication 이라는 어노테이션이 기본으로 붙어있고,  그 안에 @EnableAutoConfiguration 이라는 어노테이션이 붙어있다. 이 어노테이션이 @Aspect 하는 어노테이션을 보고 프록시 방식으로 사용할 수 있게 자동으로 등록해주기 때문에, @EnableAspectJAutoProxy 는 생략이 가능하다.

 

이걸 확인하였고, 필자는 이후 커스텀 어노테이션을 만들었다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LoginCheck {
    MemberLevel memberLevel();

    enum MemberLevel {

        MEMBER, OWNER, RIDER;

        public static MemberLevel memberLevel(String level) {
            return Enum.valueOf(MemberLevel.class, level);
        }
    }
}

컨트롤러의 메서드에 적용할 것이기 때문에 Target 는 Method 로 설정하였고,

Spring AOP 는 RunTime 에 Weaving 을 해주기 때문에, Retention 은 Runtime 으로 지정하였다. ( AOP 의 방식은 여러가지가 있는데, RunTime Weaving 은 Spring AOP 의 특징이다 .)

( 사실 프록시 객체는 런타임에 생성되지만, 그 프록시 객체를 생성해야하는 타겟을 찾을 때 클래스 정보를 참고하므로, Retention 을  CLASS 로 지정해주어도 AOP는 정상적으로 작동한다. )

그리고 Enum 을 통해 열거형으로 MEMBER 와 OWNER 그리고 RIDER 를 정의하였다.

 

다음은 @Aspect 를 적용할 클래스를 만든다.

@Aspect
@Component
@RequiredArgsConstructor
public class LoginCheckAspect {

    private final LoginService loginService;

    @Before("@annotation(com.example.makedelivery.common.annotation.LoginCheck) && @annotation(target)")
    public void loginCheck(LoginCheck target) throws HttpClientErrorException {

        Member currentMember = getCurrentMember();

        switch (target.memberLevel()) {
            case MEMBER -> checkMemberLevel(currentMember, MemberLevel.MEMBER);
            case OWNER -> checkMemberLevel(currentMember, MemberLevel.OWNER);
            case RIDER -> checkMemberLevel(currentMember, MemberLevel.RIDER);
        }
    }

    private Member getCurrentMember() throws HttpClientErrorException {
        Member member = loginService.getCurrentMember();
        if (member == null) {
            throw new HttpClientErrorException(HttpStatus.UNAUTHORIZED);
        }
        return member;
    }

    private void checkMemberLevel(Member currentMember, MemberLevel requiredLevel) {
        if (!(currentMember.getMemberLevel() == requiredLevel)) {
            throw new HttpClientErrorException(HttpStatus.UNAUTHORIZED);
        }
    }
}

필자는 이렇게 @Before 어노테이션을 통해 @LoginCheck 가 달린 메서드가 시작하기 전 이러한 Advice 가 적용되도록 로직을 작성하였다. 해당 Advice 는 로그인 상태와, 해당 유저의 타입을 함께 체크 할 수 있도록 만들었다.

기본적으로 유저의 타입에 따라 접근 가능한 메서드를 나눠놓았기 때문이다. ( MEMBER 레벨인데 매장을 만들거나 하면 안되기 때문이다. )

 

이후에는 아래 코드처럼

@GetMapping("/profile")
@LoginCheck(memberLevel = MemberLevel.MEMBER)
public ResponseEntity<MemberProfileResponse> getMemberInfoPage(@CurrentMember Member member) {
    return ResponseEntity.status(HttpStatus.OK).body(MemberProfileResponse.toMemberProfileResponse(member));
}

@LoginCheck 어노테이션을 활용해주면 자동으로 로그인 체크가 가능해진다.

 

 


결론

공통 관심사, 특히 공통적으로 들어가야하는 인프라 로직은 이렇게 AOP 를 활용하여 빼주면 좋다는 사실을 알게되었다.

이 부분을 공부하였을 때는, 필자가 그 이전까지 개발해왔던 방식이 얼마나 바보 같았는지를 알 수 있었다.

예전에 다니던 회사에서는 이렇게 같은 코드를 반복해서 쓰는 경우가 많았기 때문이다.

지금이라도 이론적인 부분을 기반으로 이렇게 좋은 코드를 개발할 수 있다는 것에 감사하고, 앞으로도 꾸준한 공부를 통해 스스로를 갈고 닦아야겠다는 생각이 들었다.