Custom Annotation 만들기

김성재·2024년 3월 10일
0

코인 개발에 본격적으로 참여하기 전에 코인 마이그레이션에 적용된 커스텀 어노테이션의 작용 방식을 분석하라는 과제를 받았다. 다음은 커스텀 어노테이션을 어떻게 적용하는 지와 그것을 분석하는 내용이다.

대부분의 API에 필요한 공통적인 로직이 있다면 어떻게 하는 것이 좋을까?

공통적인 로직이 있다면 스프링에서 interceptor, filter, aop등의 다양한 방법으로 처리할 수 있다. 하지만 공통적인 로직을 처리해서 어떤 결과 값을 컨트롤러에 넘겨줘야 한다면 어떻게 하는 것이 좋을까

예를 들어, 클라이언트 측에서 header에 토큰을 담아서 request를 보내는 경우가 있다고 가정하자. 그러면 아래 거의 모든 api에서 해당 요청을 보낸 사용자가 누구인지 사용자 정보가 필요하다

@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {

    @GetMapping("/test1")
    public void test1(@RequestHeader String token) {
        // 이 API에서는 사용자 정보가 필요하다
    }

    @PostMapping("/test2")
    public void test2(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요하다
    }

    @GetMapping("/test3")
    public void test3(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요하다
    }

    @GetMapping("/test4")
    public void test4(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요하다
    }
}

이 경우 중복되는 로직을 메소드를 만들면 해결될까?

public User getLoginUser(String token) {
    return userRepository.findByEmail(token).orElseThrow();
}
package com.feelcoding.argumentresolverdemo.controller;

import com.feelcoding.argumentresolverdemo.User;
import com.feelcoding.argumentresolverdemo.dto.SignUpRequestDto;
import com.feelcoding.argumentresolverdemo.dto.SignUpResponseDto;
import com.feelcoding.argumentresolverdemo.service.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {

    private final AuthService authService;

    @GetMapping("/test1")
    public void test1(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @PostMapping("/test2")
    public void test2(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @GetMapping("/test3")
    public void test3(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @GetMapping("/test4")
    public void test4(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

}

하지만 여전히 반복되는 코드가 들어가 있다.
이런 경우에 @Auth어노테이션만 달면 토큰을 읽어서 사용자의 아이디를 반환 할 수 있으면 얼마나 좋을까? 이런식으로 말이다

@GetMapping("/test1")
public void test1(@Auth Long userId) {
    // 어쩌구 저쩌구
}

@PostMapping("/test2")
public void test2(@Auth Long userId) {
    // 어쩌구 저쩌구
}

@GetMapping("/test3")
public void test3(@Auth Long userId) {
    // 어쩌구 저쩌구
}

@GetMapping("/test4")
public void test4(@Auth Long userId) {
    // 어쩌구 저쩌구
}

이러한 이유로 커스텀 어노테이션을 만드는 것이다.

Custom Annotation 만들기

먼저 Auth 어노테이션을 만들자
어노테이션을 만들 때 interface 앞에 @를 붙이면 된다.
밑과 같은 방식으로 말이다.

@Target(PARAMETER)
@Retention(RUNTIME)
public @interface Auth {

    UserType[] permit() default {};

    boolean anonymous() default false;
}

UserType은 enum 클래스로 사장님과 학생 두가지 값을 갖고 있다. anonymous는 로그인 하지 않고 사용하는 유저를 위한 값이다.

@Target 과 @Retention

우선 @Retention은 어노테이션의 지속 시간을 정한다. 우리는 실행중에 사용할 것이니 RUNTIME을 적용했다.

@Target(PARAMETER)는 이 어노테이션을 파라미터에 적용할 것이란 뜻이다.
아래와 같은 방식으로 말이다.

@GetMapping("/articles/{id}")
    public ResponseEntity<ArticleResponse> getArticle(
        @Auth(permit = {STUDENT}, anonymous = true) Long userId,
        @PathVariable("id") Long articleId,
        @IpAddress String ipAddress
    ) {
        //  내용
    }

HandlerMethodArgumentResolver

다음은 HandlerMethodArgumentResolver를 상속 받아 커스텀 HandlerMethodArgumentResolver클래스를 만들면 된다.

HandlerMethodHandlerMethodArgumentResolver는 컨트롤러 메서드에서 특정한 조건에 맞는 파라미터가 있을 때 원하는 값을 바인딩 해주는 인터페이스라고 한다.

@Component
@RequiredArgsConstructor
public class AuthArgumentResolver implements HandlerMethodArgumentResolver {

    private final UserRepository userRepository;
    private final AuthContext authContext;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(Auth.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {

        Auth authAt = parameter.getParameterAnnotation(Auth.class);
        requireNonNull(authAt);
        List<UserType> permitStatus = Arrays.asList(authAt.permit());
        Long userId = authContext.getUserId();
        if (isAnonymous(userId, authAt)) {
            return null;
        }
        User user = userRepository.getById(userId);
        if (permitStatus.contains(user.getUserType())) {
            return user.getId();
        }
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        throw AuthException.withDetail("header: " + request);
    }

    private static boolean isAnonymous(Long userId, Auth authAt) {
        if (userId == null) {
            if (authAt.anonymous()) {
                return true;
            }
            throw AuthException.withDetail("userId is null");
        }
        return false;
    }
}

인터페이스 HandlerMethodArgumentResolver를 상속 받으면 supportsParameter와 resolveArgument 두 메서드를 구현해야 한다
이 클래스를 살펴보자

supportsParameter

@Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(Auth.class);
    }

우리가 만든 커스텀 어노테이션인 Auth가 있는지 확인하고 없을 경우 false를 반환한다

resolveArgument

@Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {

        Auth authAt = parameter.getParameterAnnotation(Auth.class);
        requireNonNull(authAt);
        List<UserType> permitStatus = Arrays.asList(authAt.permit());
        Long userId = authContext.getUserId();
        if (isAnonymous(userId, authAt)) {
            return null;
        }
        User user = userRepository.getById(userId);
        if (permitStatus.contains(user.getUserType())) {
            return user.getId();
        }
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        throw AuthException.withDetail("header: " + request);
    }
  1. supportsParameter에서 true가 반환되면 실행된다.
  2. parameter를 통해 auth 어노테이션을 추출한다. auth 어노테이션에는 허용된 사용자 타입이 정의 되어 있어(enum클래스 userType으로) null 체크를 한 다음 permitStauts 리스트에 저장한다.
  3. authContext.getUserId()를 통해 현재 요청을 보낸 사용자의 ID를 얻는다. 만약 isAnonymous 메서드가 사용자가 익명이라고 판단하면 메서드는 여기서 null을 반환하여 요청 처리를 중단한다.
  4. 데이터베이스에서 userId에 해당하는 User 객체를 가져온 다음, 사용자의 타입이 permitStatus 리스트에 있는지 확인합니다. 허용된 사용자 타입에 속하면 사용자의 ID를 반환한다.
  5. 만약 사용자가 허용된 사용자 타입에 속하지 않는 경우, HttpServletRequest 객체를 통해 요청 정보를 가져와 AuthException 예외를 던진다.

webConfig 파일에 등록

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AuthArgumentResolver authArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(authArgumentResolver);
    }
}

이렇게 생성하면 이제 authArgumentResolver가 스프링에서 인식될 수 있다!
HandlerMethodArgumentResolver는 항상 addArgumentResolvers를 거쳐서 추가해야한다.

참고자료 출처 :
+https://velog.io/@sin8282/Custom-annotation-parameterMap-%EB%A7%8C%EB%93%A4%EA%B8%B0
+https://breakcoding.tistory.com/402

0개의 댓글