[Spring/TIL] 커스텀 어노테이션(@AuthUser) 구현

resilient

·

2023. 5. 31. 21:40

728x90
반응형

유저를 기본적으로 가지고 있는 서비스 개발을 하다 보면 대부분의 API에서 유저의 정보를 필요로 합니다.

 

이런 상황에서 제일 많이 쓰는 방법이 Custom 어노테이션을 만들어서 API 요청을 할 때, parameter로 넘겨서 사용하는 방법이 있습니다. Custom 어노테이션은 Jwt 등 유저의 정보를 담고 있는 무언가(대부분 토큰이나 IAM 토큰을 사용합니다.)에서 유저의 정보를 가져와서 넘겨주는 역할을 하게 되죠.

 

이번 게시물에서는 유저의 정보를 들고 다닐 수 있는 Custom 어노테이션을 만들어서 사용하기 위해 구현한 과정들을 정리해보려고 합니다.

 

0. Custom 어노테이션이란?

 

먼저 어노테이션은 애플리케이션이 실행될 때 추가적인 정보를 제공해주는 메타 데이터입니다. 여기서 메타 데이터란 어플리케이션이 처리해야 할 데이터가 아니라 컴파일 과정과 런타임에서 코드를 어떻게 컴파일하고 처리할 것인지에 대한 정보를 담은 데이터죠.

 

어노테이션은 옵션에 따라 컴파일 전까지만 유효하도록 처리될 수도 있고, 컴파일 시기에 처리(컴파일러가 클래스를 참조할 때까지)될 수도 있고, 런타임 시기에 처리될 수도 있습니다.

 

필요한 기능을 직접 만들어서 사용하는 어노테이션을 Custom 어노테이션이라고 합니다. 이번 게시물에서 다룰 내용 처럼 유저의 정보를 header에 담아서 들고 다녀야 한다던지, 아니면 어떠한 공통처리를 위한 기능을 구현할 경우 사용하곤 합니다. Custom 어노테이션 이야기를 할 때 자주 함께 등장하는 내용 중에 AOP가 있는데요. 이 부분은 나중에 따로 다뤄보도록 하겠습니다.

 

1. Custom 어노테이션의 구성

 

Custom 어노테이션은 아래 예시와 같이 메타 어노테이션을 사용해서 커스텀 어노테이션을 구성할 때 시점, 위치등을 지정할 수 있습니다.

@Target({ElementType.[적용대상]})
@Retention(RetentionPolicy.[정보유지되는 대상])
public @interface [어노테이션명]{
	public 타입 elementName() [default 값]
    ...
}

 

제가 AuthUser라는 Custom 어노테이션을 만들어서 사용하기 위해 작성한 코드는 다음과 같습니다.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Parameter(hidden = true)
public @interface AuthMember {
}

 

Target, 적용대상은 PARAMETER옵션을 적용해서 전달인자로 넘긴다라는 의미이고,

Retention, 정보가 유지되는 시점은  RUNTIME옵션을 적용해서 컴파일 이후 런타임 시기에도 JVM에 의해 참조가 가능하도록 구현 했습니다.

 

@Parameter은 Swagger와 관련된 어노테이션으로 API 문서에 표시하고 싶지 않은 Parameter라는 의미를 담은 어노테이션입니다.

 

2. HandlerMethodArgumentResolver

 

먼저 HandlerMethodArgumentResolver가 무엇인지 먼저 살펴보겠습니다.

 

spring 공식 문서

 

해석을 해보면 주어진 요청을 처리할 때, 메서드 파라미터를 인자값들에 주입해주는 전략 인터페이스라고 하네요. 여기서 봐야 할 단어들은 주어진 요청, 파라미터, 주입입니다.

 

일반적으로 아래와 같은 상황에서 사용하는 인터페이스 입니다.

  • parameter로 받는 값이 여러 개가 존재하고(혹은 객체의 필드들이 여러 개가 존재), 그것을 처리하는 코드들의 중복이 발생할 때
  • Controller에 공통으로 입력되는 parameter들을 추가하거나 수정하는 등의 여러 공통적인 작업들을 한 번에 처리하고 싶을 때

 

AuthUser 어노테이션을 Controller에서 parameter로 넘길 때 어떤 작업을 수행할지를 지정해 주는 역할을 수행한다고 생각하면 됩니다.

 

HandlerMethodArgumentResolver를 구현한 AuthUserResolver라는 클래스를 아래와 같이 만들어줍니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class AuthUserResolver implements HandlerMethodArgumentResolver {

    private final MemberRepository memberRepository;
    private final JwtUtil jwtUtil;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasAnnotation = parameter.hasParameterAnnotation(AuthUser.class);
        boolean isMemberType = Member.class.isAssignableFrom(parameter.getParameterType());

        return hasAnnotation && isMemberType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        String jwt = webRequest.getHeader("Authorization");

        return memberRepository.findById(jwtUtil.getUserId(jwt)).orElseThrow(
                () -> new Exception("인증되지 않은 사용자입니다.")
        );
    }
}

 

2-1. supportsParameter

 

HandlerMethodArgumentResolver을 구현하려면 오버라이딩 메서드들이 있는데 그중에 supportsParamter라는 메서드가 있습니다.

 

Parameter가 해당 Resolver에 의해 수행되는 Type인지 체크하여 boolean을 return을 해서 true로 return 될 경우 resolveArgument method를 실행해 줍니다.

 

이때 Type 체크를 위해 Class를 Annotation을 새로 생성하여 체크할 수도 있죠. 저는 AuthUser 어노테이션을 만들어서 해당 클래스의 타입체크를 해줬습니다.

 

2-2. resloveArgument

 

실제 Parameter와 Binding 하여 return 할Object를 생성하는 method입니다. NativeWebRequest Object에 접근하여 Client Request의 Parameter를 Controller 보다 우선적으로 받아 작업할 수 있죠.

 

해당 Handler method안 Parameter에서 Binding을 원하는 객체 Type에 맞게 return 해주면 됩니다.

 

저는 AuthUser에서 인증된 사용자인지를 확인하고, 인증된 사용자일 경우 해당 사용자의 userId로 사용자의 데이터를 return 하도록 구현해줬습니다.

 

3. JwtUtil

 

AuthUserResolver 클래스를 보면 JwtUtil이란 클래스를 주입받아서 사용하고 있습니다. 해당 JwtUtil 클래스는 아래와 같습니다.

@Component
public class JwtUtil {
    //todo: 추후 properties 관리
    public static final String SECRET_KEY = "-";
    public static final long EXPIRATION_SECONDS = 1_800_000;
    private final MemberRepository memberRepository;

    public JwtUtil(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public Long getUserId(String authToken) {
        Jws<Claims> parse = parse(authToken);
        Claims body = parse.getBody();
        String email = body.get("email", String.class);
        return memberRepository.findByEmail(email).orElseThrow(
                IllegalArgumentException::new
        ).getId();
    }

    public Jws<Claims> parse(String authToken) {
        String token = authToken.replace("Bearer", "").trim();
        try {
            JwtParser parser = Jwts.parserBuilder().setSigningKey(SECRET_KEY).build();
            return parser.parseClaimsJws(token);
        } catch (SignatureException e) {
            throw new AccessDeniedException(e.getMessage());
        }
    }
}

 

위와 같이 작업을 마무리해주면 AuthUser 어노테이션을 사용하면 jwt에 있는 유저의 정보를 긁어서 DB를 조회하고 얻은 유저 정보를 return 해주기 때문에 모든 메서드에서 사용가능해집니다.

 

4. 403 Forbidden 에러 발생

 

구현 도중에 잘 구현을 했는데도 불구하고 Postman으로 테스트를 해보면 Controller 레이어에도 도달하지 못하고 바로 403을 뱉는 에러가 발생한 적이 있습니다.

403 Forbidden

해당 에러를 해결하기 위해 많은 시간을 쏟았었는데요. 디버깅을 통해 해결했습니다.

 

수정하기 전의 JwtUtil 클래스를 살펴보겠습니다.

 

@Component
public class JwtUtil {
    //todo: 추후 properties 관리
    public static final String SECRET_KEY = "-";
    public static final long EXPIRATION_SECONDS = 1_800_000;
    private final MemberRepository memberRepository;

    public JwtUtil(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public Long getUserId(String authToken) {
        String email = parse(authToken).getBody().getSubject();
        return memberRepository.findByEmail(email).orElseThrow(
                IllegalArgumentException::new
        ).getId();
    }

    public Jws<Claims> parse(String authToken) {
        final SecretKey signingKey = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
        String token = authToken.replace("Bearer", "").trim();
        try {
            return Jwts.parserBuilder().setSigningKey(signingKey).build().parseClaimsJws(token);
        } catch (SignatureException e) {
            throw new AccessDeniedException(e.getMessage());
        }
    }
}

 

parse라는 메서드를 보면 String 타입의 SECRET_KEY를 가져와서 String을 Byte로 바꿔서 SecretKey 타입의 signingKey에 담아서 사용했는데 여기서 에러가 발생해서 catch로 AccessDeniedException이 발생해서 403이 떴던 것이죠.

 

디버깅의 중요성을 다시 한번 느끼게 된 계기였습니다.

 

정리

 

이번 시간에는 Custom 어노테이션을 어떤 경우에 사용하고 사용하게 될 경우 어떻게 만드는지 방법을 알아봤습니다.

 

구현을 하면서 에러 로그가 남지 않으니 403 에러가 발생했을 때 굉장히 많은 시간을 써서 해결을 했는데요. 저번달에 올렸던 게시물에서도 한번 언급을 한적이 있지만 디버깅의 중요성을 다시 한 번 깨닫게 되었습니다.

 

다음 포스팅에서는 제가 생각했을 때 디버깅을 할 때는 어떻게 접근하는 것이 좋을지와 디버깅의 중요성에 대해 다뤄보겠습니다.

 

감사합니다.

 

반응형