사이드 프로젝트 중에 jwt 토큰에서 ID와 authority를 꺼내어 사용하는 일이 생겼는데, token을 발급해주는 클래스에서 처리했었다.
그런데, 최근 오브젝트라는 책을 보고 객체가 가져야 할 책임에 대해 고민하게 되었고 프로젝트에 적용해보려 한다.
코드를 잘 작성한 줄 알았는데, 역시나.. 문제가 있었다.
문제 인식
기존 코드에서는 Authorization 헤더의 값을 컨트롤러에서 받아 서비스로 전달했다.
@DeleteMapping(value = "/tutorings/{tutoringId}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> cancelTutoring(
@RequestHeader(name = "Authorization") String authValue, @PathVariable long tutoringId) {
commandExecutor.cancelTutoring(tutoringId, authValue);
return ResponseEntity.ok("튜터링이 취소되었습니다.");
}
@Transactional
public boolean cancelTutoring(long tutoringId, String authValue) {
String accountId = tokenProvider.getAccountId(authValue);
String authority = tokenProvider.getAuthority(authValue);
Tutoring tutoring = findByTutoringIdWithAuth(tutoringId, accountId, authority);
tutoring.cancelTutoring();
tutoringRepository.save(tutoring);
return true;
}
여기에 몇 가지 문제가 있었는데 다음과 같다.
- 새로운 서비스에서 토큰 정보를 얻고 싶다면, token provider를 주입받아야 하기 때문에 의존이 생긴다.
- token provider가 점점 유틸 클래스처럼 변해가는 것 같았다.
TokenProvider의 역할은 어디까지인가?
나의 경우 token 발급 주체는 암, 복호화에 필요한 키를 가지고 있기 때문에, 암, 복호화만 해도 충분하다 생각한다.
따라서 TokenProvider에 포함된 ID, authority를 추출하는 코드를 제거하고, 이를 ArgumentResolver로 처리하는 것을 대안으로 삼았다.
왜 ArgumentResolver인가?
토큰에서 값을 추출하는 작업은 매번 반복되는 작업이고 변경 포인트가 많을 것 같았다. 따라서 해당 로직이 여기저기에 적용되어 있는걸 하나로 합칠 필요를 느꼈고 ArgumentResolver에서 관리하는게 낫다 생각했다.
ArgumentResolver는 어떤 동작을 하는가?
API 엔드포인트로부터 들어온 파라미터를 가공하여 필요한 데이터만 뽑는 등의 로직이 필요한 경우 사용할 수 있다.
HandlerArgumentResolver를 상속하여 새로운 Resolver를 만들고, 애플리케이션 실행 시 Resolver 리스트에 추가하여 적용시킬 수 있다.
동작 과정은 다음과 같다.
- 사용자가 웹 브라우저를 통해 요청하면 DispatcherServlet이 받음
- DispatcherServlet은 해당 요청에 맞는 URI를 HandlerMapping에서 검색
- 이 때, RequestMapping으로 구현한 API를 찾게 되는데, 이들은 RequestMappingHandlerAdapter가 모두 가지고 있음.
- 원하는 Mapping을 찾으면, 첫 번째로 Interceptor를 처리
- Argument Resolver 처리
- Message Converter 처리
- Controller Method Invoke
사용 방법
메서드의 파라미터로 받아올 거기 때문에 @Target은 ElementType.PARAMETER로 지정한다.
프로그램 수행 중에 사용할 애너테이션이므로 @Retention 은 RetentionPolicy.RUNTIME으로 지정한다.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface TokenAccount{
}
토큰에서 추출한 값을 저장할 TokenAccountInfo 클래스
public class TokenAccountInfo {
private String accountId;
private String authority;
}
CustomMethodArgumentResolver 클래스 구현
HandlerMethodArgumentResolver 클래스를 상속하여 supportsParameter 메소드와 resolveArgument 메소드를 오버라이딩(overriding)한다.
@Component
@RequiredArgsConstructor
public class CustomMethodArgumentResolver implements HandlerMethodArgumentResolver {
private final TokenProvider tokenProvider;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterAnnotation(TokenAccount.class) != null
&& parameter.getParameterType().equals(TokenAccountInfo.class);
}
@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory)
throws RuntimeException {
String authorizationHeader = webRequest.getHeader("Authorization");
if (authorizationHeader == null) {
throw new MediateNotFoundToken("인증 토큰 값이 없습니다.");
}
Map<String, Object> decodedToken = tokenProvider.decode(authorizationHeader.substring(7));
String accountId = String.valueOf(decodedToken.getOrDefault("sub", ""));
String authority = String.valueOf(decodedToken.getOrDefault("auth", ""));
return new TokenAccountInfo(accountId, authority);
}
}
- supportsParameter 메소드를 통해 해당 파라미터에 대한 처리를 수행할지 말지 여부를 체크한다.
- resolveArgument 메소드를 통해 토큰에서 필요한 데이터를 추출하여 TokenAccountInfo 객체에 SETTING 후 이를 반환한다.
- 반환된 객체는 요청 Parameter로써 Controller에게 전달된다.
실행결과
@DeleteMapping(value = "/tutorings/{tutoringId}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> cancelTutoring(
@TokenAccount TokenAccountInfo token, @PathVariable long tutoringId) {
commandExecutor.cancelTutoring(tutoringId, token);
return ResponseEntity.ok("튜터링이 취소되었습니다.");
}
@Transactional
public boolean cancelTutoring(long tutoringId, TokenAccountInfo token) {
Tutoring tutoring = findByTutoringIdWithAuth(tutoringId, token.getAccountId(), token.getAuthority()));
tutoring.cancelTutoring();
tutoringRepository.save(tutoring);
return true;
}
개선된 점
- 새로운 서비스를 만들 때마다 TokenProvider를 주입받지 않아도 된다.
- 비즈니스 로직에서 토큰에서 값을 추출하는 관심사를 분리하여 응집도를 개선했다.
- TokenProvider는 토큰 암, 복호화의 책임만 수행하도록 했다.
참고
https://junhyunny.github.io/spring-boot/handler-method-argument-resolver/
'Spring' 카테고리의 다른 글
스프링초짜의 도메인 이벤트 찍먹 도전기 (1) (0) | 2022.02.15 |
---|---|
[Spring Boot + Security] @PreAuthorize로 토큰 확인하기 (0) | 2022.01.28 |
SpringBoot 중요한 설정 숨기기 (0) | 2022.01.12 |
SpringSecurity + CustomAuthenticationProvider 만들기 (0) | 2022.01.11 |
Filter, Interceptor, AOP 차이에 대한 정리 (0) | 2021.11.22 |