@LoginUser + HandlerMethodArgumentResolver로 반복되는 유저 조회 로직 제거하기
서비스 메서드마다 반복적으로 등장하는 아래와 같은 유저 조회 로직이 있다.
User user = userRepository.findByUsername(userDetails.getUsername()).orElseThrow(NoUserFoundException::new);
service 메서드마다 요청한 유저의 엔티티를 찾는 로직이 계속 반복되기 때문에 수정을 진행했다.
public class VideoService {
public VideoResponse uploadVideo(VideoUploadRequest videoUploadRequest, UserDetails userDetails) {
User user = userRepository.findByUsername(userDetails.getUsername()).orElseThrow(NoUserFoundException::new);
// .. logic
}
public VideoResponse changeVideoStatus(Long videoId, VideoStatusChangeRequest videoStatusChangeRequest, UserDetails userDetails) {
User user = userRepository.findByUsername(userDetails.getUsername()).orElseThrow(NoUserFoundException::new);
// .. logic
}
public void changeVideosStatus(VideosStatusChangeRequest videosStatusChangeRequest,
UserDetails userDetails) {
User user = userRepository.findByUsername(userDetails.getUsername()).orElseThrow(NoUserFoundException::new);
// .. logic
}
public VideoResponse updateVideoInfo(Long videoId, VideoUpdateRequest videoUpdateRequestDto, UserDetails userDetails) {
User user = userRepository.findByUsername(userDetails.getUsername()).orElseThrow(NoUserFoundException::new);
// .. logic
}
}
위에서도 설명했지만 메서드마다 유저 조회하는 로직이 계속해서 반복된다. 줄일 수 없을까...? 개선할 수 있다. 스프링의 HandlerMethodArgumentResolver
를 사용하면 된다. 구현부를 살펴보기 전에 어떻게 개선되었는지 결과부터 살펴보자.
public class VideoService {
public VideoResponse uploadVideo(VideoUploadRequest videoUploadRequest, User user) {
// .. logic
}
public VideoResponse changeVideoStatus(Long videoId, VideoStatusChangeRequest videoStatusChangeRequest, User user) {
// .. logic
}
public void changeVideosStatus(VideosStatusChangeRequest videosStatusChangeRequest,
User user) {
// .. logic
}
public VideoResponse updateVideoInfo(Long videoId, VideoUpdateRequest videoUpdateRequestDto, User user) {
// .. logic
}
}
서비스 메서드들이 파라미터로 User 엔티티를 그대로 받도록 변경되었다. 유저가 null 만 아니라면 로직이 잘 동작한다. 그럼 이런 의문이 든다. 서비스에서 유저를 찾는게 아니라 컨트롤러 단에서 유저를 찾고 서비스로 유저 엔티티를 넘겨주는거 아님? 컨트롤러를 살펴보자.
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/videos")
@PreAuthorize("hasRole('USER')")
public class VideoPermittedController {
private final VideoService videoService;
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<VideoResponse> uploadVideo(@ModelAttribute @Valid VideoUploadRequest videoUploadRequest,
@LoginUser User user) {
return ResponseEntity.status(HttpStatus.CREATED).body(videoService.uploadVideo(videoUploadRequest, user));
}
@PatchMapping("/{videoId}/info")
public ResponseEntity<VideoResponse> updateVideo(@PathVariable Long videoId,
@Valid @RequestBody VideoUpdateRequest videoUpdateRequestDto,
@LoginUser User user) {
return ResponseEntity.ok(videoService.updateVideoInfo(videoId, videoUpdateRequestDto, user));
}
@PatchMapping("/{videoId}/status")
public ResponseEntity<VideoResponse> changeVideoStatus(@PathVariable Long videoId,
@Valid @RequestBody VideoStatusChangeRequest videoStatusChangeRequest,
@LoginUser User user) {
return ResponseEntity.ok(videoService.changeVideoStatus(videoId, videoStatusChangeRequest, user));
}
@PatchMapping("/status")
public ResponseEntity<Void> changeVideosStatus(@RequestBody VideosStatusChangeRequest videosStatusChangeRequest,
@LoginUser User user) {
videoService.changeVideosStatus(videosStatusChangeRequest, user);
return ResponseEntity.ok().build();
}
}
컨트롤러에서도 별다른 처리 없이 User를 바로 파라미터로 입력받고 이를 서비스로 전달하고있다. @LoginUser 어노테이션이 눈에 띄는데 살펴보자.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
엥? 이게 다야?
그렇다. @LoginUser 의 내용은 이게 전부다. 별다른 로직이 없다.
@LoginUser
는 마커 역할만 수행하고, 실제 인증된 유저를 찾아서 주입하는 역할은 LoginUserArgumentResolver
가 담당한다. LoginUserArgumentResolver
는 HandlerMethodArgumentResolver
를 구현하는데 이 인터페이스가 중요한 부분이다. Spring MVC는 컨트롤러 메서드의 파라미터를 커스터마이징할 수 있도록 HandlerMethodArgumentResolver
기능을 제공한다. 즉, 애노테이션은 신호만 보내고, 실제 동작은 Resolver가 처리하는 구조다. 해당 인터페이스까지 구현이 된다면 반복되는 사용자 조회 코드를 제거할 수 있다. 살펴보자.
@Component
@RequiredArgsConstructor
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
private final UserRepository userRepository;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterAnnotation(LoginUser.class) != null && parameter.getParameterType().equals(User.class);
// 처리하려는 파라미터가 LoginUser 에너테이션을 갖고 있고 && 에너테이션이 수식하는 파라미터가 User 클래스라면
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 시큐리티 컨텍스트에서 인증 정보 획득
if (authentication == null || !(authentication.getPrincipal() instanceof UserDetails userDetails)) {
// 인증 정보가 없거나 위조됐다면 Spring Security의 ExceptionTranslationFilter가 401 응답으로 처리
throw new AuthenticationCredentialsNotFoundException("No Authentication"); // 401 처리
}
return userRepository.findByUsername(userDetails.getUsername()).orElseThrow(NoUserFoundException::new);
// 인증 정보대로 유저를 찾는데 해당하는 유저가 없다면 예외처리
// 유저를 찾았다면 유저를 응답
}
}
@LoginUser 에너테이션이 수식하는 파라미터가 있을 경우 위 리졸버가 동작하여 User를 찾고 주입해준다. 엄청나다! @LoginUser 에너테이션이 동작하기 위해서는 설정이 하나 더 필요하다.
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginUserArgumentResolver);
}
}
애플리케이션에서 동작하는 리졸버 리스트에 제작한 LoginUserArgumentResolver
를 등록해줘야한다.
구성 요소 | 역할 |
---|---|
@LoginUser | 컨트롤러 메서드에서 유저를 파라미터로 받는 마커 역할 |
LoginUserArgumentResolver | 인증된 User를 조회하여 메서드 파라미터에 주입 |
WebConfig | ArgumentResolver를 Spring MVC에 등록 |