지난 포스팅에서 Interceptor와 Argument Resolver에 대해서 공부했었다!
만약 WebConfig 설정 파일을 통해서 MVC를 커스터마이징하는 것이 낯설다면 이 포스팅을 참고하면 될 것 같다!
오늘 포스팅에서는 지난 포스팅에서 소개했던 Interceptor와 Argument Resolver를 코드에 적용해볼 것이다.
Interceptor를 만들어서 공통적인 인증과 관련된 코드를 빼내보자!
💋 리팩터링 전 코드
먼저 컨트롤러와 서비스의 코드를 보자! (지저분함 주의)
[컨트롤러]
/cart로 오는 모든 요청을 잡고 있는 컨트롤러이다.
@RestController
@RequestMapping("/carts")
public class CartController {
private final CartService cartService;
public CartController(final CartService cartService) {
this.cartService = cartService;
}
@GetMapping("/all")
public ResponseEntity<List<Product>> findItems(@RequestHeader("Authorization") String authorization) {
if (authorization.isBlank()) { // 반복적인 코드
return ResponseEntity.badRequest().build();
}
final List<Product> products = cartService.findCartItems(authorization);
return ResponseEntity.ok().body(products);
}
@PostMapping("/{productId}")
public ResponseEntity<CartItem> addItem(@PathVariable("productId") long productId, @RequestHeader("Authorization") String authorization) {
if (authorization.isBlank()) { // 반복이 거슬림
return ResponseEntity.badRequest().build();
}
final CartItem addedItem = cartService.addItem(authorization, productId);
return ResponseEntity.ok().body(addedItem);
}
@DeleteMapping("/{productId}")
public ResponseEntity<Void> deleteItem(@PathVariable("productId") long productId, @RequestHeader("Authorization") String authorization) {
if (authorization.isBlank()) { // 또또 반복 어차피 다 해야하는거잖아!
return ResponseEntity.badRequest().build();
}
cartService.deleteItem(authorization, productId);
return ResponseEntity.noContent().build();
}
}
[서비스]
/carts URL 하위의 모든 URL에 대한 처리를 하는 메서드들이다.
이 코드들에서는 모두 다 Basic Authentication이 필요하다.
현재 코드에서는 validateAuth()라는 메서드를 반복하고 있고, 이 부분이 굉장히 반복적이고 거슬린다.
@Service
public class CartService {
public static final String BASIC_TYPE_PREFIX = "Basic";
private static final String DELIMITER = ":";
private final CartItemDao cartItemDao;
private final MemberDao memberDao;
private final ProductDao productDao;
public CartService(final CartItemDao cartItemDao, final MemberDao memberDao, final ProductDao productDao) {
this.cartItemDao = cartItemDao;
this.memberDao = memberDao;
this.productDao = productDao;
}
public CartItem addItem(final String authorization, final Long productId) {
final Member authenticatedMember = validateAuth(authorization); // 거슬림
if (authenticatedMember == null) {
return null;
}
final CartItem cartItem = new CartItem(authenticatedMember.getId(), productId);
return cartItemDao.save(cartItem);
}
public List<Product> findCartItems(final String authorization) {
final Member authenticatedMember = validateAuth(authorization); // 거슬림
final List<CartItem> cartItems = cartItemDao.findByMemberId(authenticatedMember.getId());
return cartItems.stream()
.map(CartItem::getProductId)
.map(productDao::findById)
.collect(Collectors.toList());
}
public void deleteItem(final String authorization, final long productId) {
final Member member = validateAuth(authorization); // 거슬림
cartItemDao.delete(member.getId(), productId);
}
private Member validateAuth(final String authorization) {
// 매번 반복해야 하는 코드 (한줄한줄 이해할 필요 X)
final boolean isBasicAuthentication = authorization.toLowerCase().startsWith(BASIC_TYPE_PREFIX.toLowerCase());
if (!isBasicAuthentication) {
return null;
}
String authHeaderValue = authorization.substring(BASIC_TYPE_PREFIX.length()).trim();
byte[] decodedBytes = Base64.decodeBase64(authHeaderValue);
String decodedString = new String(decodedBytes);
String[] credentials = decodedString.split(DELIMITER);
String email = credentials[0];
String password = credentials[1];
return memberDao.findByEmailAndPassword(email, password).get();
}
}
특징을 보자면 /carts 로 들어오는 요청들에 대해서 빠짐없이 인증을 거쳐야 한다.
이 부분을 Interceptor로 빼보자!
💋 Interceptor를 사용해 리팩터링하기
먼저, interceptor 패키지 아래에 BasicAuthInterceptor를 만들었다.
@Component
public class BasicAuthInterceptor implements HandlerInterceptor {
private static final String BASIC_TYPE_PREFIX = "Basic";
private static final String DELIMITER = ":";
private final MemberDao memberDao;
public BasicAuthInterceptor(MemberDao memberDao) {
this.memberDao = memberDao;
}
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) throws Exception {
final String authorization = request.getHeader("Authorization"); // Basic Ym94c3RlckB3b290Z...
final boolean isBasicAuthentication = authorization != null && authorization.toLowerCase().startsWith(BASIC_TYPE_PREFIX.toLowerCase());
if (!isBasicAuthentication) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
String authHeaderValue = authorization.substring(BASIC_TYPE_PREFIX.length()).trim();
byte[] decodedBytes = Base64.decodeBase64(authHeaderValue);
String decodedString = new String(decodedBytes);
String[] credentials = decodedString.split(DELIMITER);
String email = credentials[0];
String password = credentials[1];
final Member authenticatedMember = memberDao.findByEmailAndPassword(email, password).orElse(null);
if (authenticatedMember == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
// 컨트롤러에서 사용하기 위한 request 파라미터 추가
request.setAttribute("authenticatedMember", authenticatedMember);
return true;
}
}
이제 변화한 Service 코드를 보자!
@Service
public class CartService {
private final CartItemDao cartItemDao;
private final ProductDao productDao;
public CartService(final CartItemDao cartItemDao, final ProductDao productDao) {
this.cartItemDao = cartItemDao;
this.productDao = productDao;
}
public CartItem addItem(final long memberId, final long productId) {
final CartItem cartItem = new CartItem(memberId, productId);
return cartItemDao.save(cartItem);
}
public List<Product> findCartItems(final long memberId) {
final List<CartItem> cartItems = cartItemDao.findByMemberId(memberId);
return cartItems.stream()
.map(CartItem::getProductId)
.map(productDao::findById)
.collect(Collectors.toList());
}
public void deleteItem(final long memberId, final long productId) {
cartItemDao.delete(memberId, productId);
}
}
이제 컨트롤러를 리팩터링해보자!
@RestController
@RequestMapping("/carts")
public class CartController {
private final CartService cartService;
public CartController(final CartService cartService) {
this.cartService = cartService;
}
@GetMapping("/all")
public ResponseEntity<List<Product>> findItems(HttpServletRequest request) {
final Member authenticatedMember = (Member) request.getAttribute("authenticatedMember");
final List<Product> products = cartService.findCartItems(authenticatedMember.getId());
return ResponseEntity.ok().body(products);
}
@PostMapping("/{productId}")
public ResponseEntity<CartItem> addItem(@PathVariable("productId") long productId, HttpServletRequest request) {
final Member authenticatedMember = (Member) request.getAttribute("authenticatedMember");
final CartItem addedItem = cartService.addItem(authenticatedMember.getId(), productId);
return ResponseEntity.ok().body(addedItem);
}
@DeleteMapping("/{productId}")
public ResponseEntity<Void> deleteItem(@PathVariable("productId") long productId, HttpServletRequest request) {
final Member authenticatedMember = (Member) request.getAttribute("authenticatedMember");
cartService.deleteItem(authenticatedMember.getId(), productId);
return ResponseEntity.noContent().build();
}
}
✔ 아쉬운 점
내 코드는 현재 Interceptor를 통해서
1. 가입된 사용자가 맞는지
2. 가입된 사용자를 확인해 Member로 변환
두 가지를 모두 하고 있다.
하지만 Interceptor의 preHandle() 메서드는 컨트롤러로 넘어갈 지 여부를 결정하는 boolean만을 반환하기 때문에, 인증이 끝난 후 찾아낸 authenticatedMember를 Controller에 전달하기 위해서 request에 addAttribute을 사용했다.
하지만 리뷰를 통해 받은 내용처럼, HTTP 요청을 임의로 조작하는 것은 부자연스럽다!
추천받은 HandlerMethodArgumentResolver를 사용해서 리팩터링 해보자!!!
💋 ArgumentResolver를 사용해 리팩터링하기
어려웠던 점에 대해서 작성했는데, 궁금한 사람은 접힌 글을 열어서 확인하면 좋을 것 같다!
- Argument Resolver를 통해서 request 헤더의 Authentication 내에 들어있는 인증과 관련된 부분을 처리했다.
- 로그인이 불가능한 경우에, 401 상태코드를 곧바로 반환하고 싶어서, 아래와 같이 코드를 썼었다. (주석만 슬쩍 보면 됨)
@Component
public class AuthenticatedMemberArgumentResolver implements HandlerMethodArgumentResolver {
private static final String BASIC_TYPE_PREFIX = "Basic";
private static final String DELIMITER = ":";
private final MemberDao memberDao;
public AuthenticatedMemberArgumentResolver(final MemberDao memberDao) {
this.memberDao = memberDao;
}
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticatedMember.class)
&& Member.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(
final MethodParameter parameter,
final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest,
final WebDataBinderFactory binderFactory) throws Exception {
final HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
final String authorization = request.getHeader("Authorization");
final boolean isBasicAuthentication = authorization != null && authorization.toLowerCase().startsWith(BASIC_TYPE_PREFIX.toLowerCase());
if (!isBasicAuthentication) {
return new ResponseEntity<>("Unauthorized", HttpStatus.UNAUTHORIZED); // 곧바로 401 상태코드를 포함한 채로 반환
}
final Member authenticatedMember = findMemberFromAuthentication(authorization);
if (authenticatedMember == null) {
return new ResponseEntity<>("Unauthorized", HttpStatus.UNAUTHORIZED); // 곧바로 401 상태코드를 포함한 채로 반환
}
return authenticatedMember;
}
private Member findMemberFromAuthentication(final String authorization) {
String authHeaderValue = authorization.substring(BASIC_TYPE_PREFIX.length()).trim();
byte[] decodedBytes = Base64.decodeBase64(authHeaderValue);
String decodedString = new String(decodedBytes);
String[] credentials = decodedString.split(DELIMITER);
String email = credentials[0];
String password = credentials[1];
return memberDao.findByEmailAndPassword(email, password).orElse(null);
}
}
그리고 곧바로 접근 권한이 없는데 물건을 지우겠다거나 한다면 401 상태코드를 반환한다는 테스트 코드를 짰다.
@Test
public void deleteItemWhenNotLoggedInTest() {
RestAssured.given()
.log().all()
.when()
.delete("/carts/1")
.then()
.log().all()
.statusCode(HttpStatus.UNAUTHORIZED.value());
}
근데 이게 에러가 뜨는 것...!
- 이유는... ArgumentResolver에서 반환한 값이 그대로 파라미터로 @AuthenticatedMember Member 를 만나서 바인딩이 되어버리는데, ResponseEntity 형태는 Member 객체로 바인딩할 수 없어서 나타나는 (ClassCastException 비슷한) 것이었다...!
- 해결 방법은... ArgumentResolver에서 예외를 던지고, 그것을 ControllerAdvice에서 받아서 처리하는 식으로 해결했다...
@Component
public class AuthenticatedMemberArgumentResolver implements HandlerMethodArgumentResolver {
// ...
private final MemberDao memberDao;
public AuthenticatedMemberArgumentResolver(final MemberDao memberDao) {
this.memberDao = memberDao;
}
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticatedMember.class)
&& Member.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(
final MethodParameter parameter,
final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest,
final WebDataBinderFactory binderFactory) throws Exception {
final HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
final String authorization = request.getHeader("Authorization");
final boolean isBasicAuthentication = authorization != null && authorization.toLowerCase().startsWith(BASIC_TYPE_PREFIX.toLowerCase());
if (!isBasicAuthentication) {
throw new UnAuthenticatedMemberException("로그인 해 주세요!"); // 커스텀 예외 만들어서 던짐
}
final Member authenticatedMember = findMemberFromAuthentication(authorization);
if (authenticatedMember == null) {
throw new UnAuthenticatedMemberException("로그인 해 주세요!"); // 커스텀 예외 만들어서 던짐
}
return authenticatedMember;
}
private Member findMemberFromAuthentication(final String authorization) {
String authHeaderValue = authorization.substring(BASIC_TYPE_PREFIX.length()).trim();
byte[] decodedBytes = Base64.decodeBase64(authHeaderValue);
String decodedString = new String(decodedBytes);
String[] credentials = decodedString.split(DELIMITER);
String email = credentials[0];
String password = credentials[1];
return memberDao.findByEmailAndPassword(email, password).orElse(null);
}
}
그리고 이 커스텀 예외를 처리하는 코드
@ExceptionHandler(UnAuthenticatedMemberException.class)
public ResponseEntity<String> handleUnAuthenticatedMemberException(UnAuthenticatedMemberException e, HttpServletResponse response) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Unauthorized");
}
아래와 같이 ArgumentResolver를 작성하자.
@Component
public class AuthenticatedMemberArgumentResolver implements HandlerMethodArgumentResolver {
private static final String BASIC_TYPE_PREFIX = "Basic";
private static final String DELIMITER = ":";
private final MemberDao memberDao;
public AuthenticatedMemberArgumentResolver(final MemberDao memberDao) {
this.memberDao = memberDao;
}
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticatedMember.class)
&& Member.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(
final MethodParameter parameter,
final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest,
final WebDataBinderFactory binderFactory) throws Exception {
final HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
final String authorization = request.getHeader("Authorization");
final boolean isBasicAuthentication = authorization != null && authorization.toLowerCase().startsWith(BASIC_TYPE_PREFIX.toLowerCase());
if (!isBasicAuthentication) {
throw new UnAuthenticatedMemberException("로그인 해 주세요!");
}
final Member authenticatedMember = findMemberFromAuthentication(authorization);
if (authenticatedMember == null) {
throw new UnAuthenticatedMemberException("로그인 해 주세요!");
}
return authenticatedMember;
}
private Member findMemberFromAuthentication(final String authorization) {
String authHeaderValue = authorization.substring(BASIC_TYPE_PREFIX.length()).trim();
byte[] decodedBytes = Base64.decodeBase64(authHeaderValue);
String decodedString = new String(decodedBytes);
String[] credentials = decodedString.split(DELIMITER);
String email = credentials[0];
String password = credentials[1];
return memberDao.findByEmailAndPassword(email, password).orElse(null);
}
}
Controller에서 바인딩된 객체를 받을 수 있도록 인터페이스를 설정하고
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthenticatedMember {
}
컨트롤러에서 파라미터를 통해서 받아낸다.
@RestController
@RequestMapping("/carts")
public class CartController {
private final CartItemAddService addService;
private final CartItemFindService findService;
private final CartItemDeleteService deleteService;
public CartController(final CartItemAddService addService, final CartItemFindService findService, final CartItemDeleteService deleteService) {
this.addService = addService;
this.findService = findService;
this.deleteService = deleteService;
}
@GetMapping("/all")
public ResponseEntity<List<ProductResponse>> findItems(@AuthenticatedMember Member authenticatedMember) {
final List<ProductResponse> products = findService.findCartItems(authenticatedMember.getId()).stream()
.map(ProductResponse::from)
.collect(Collectors.toList());
return ResponseEntity.ok().body(products);
}
@PostMapping("/{productId}")
public ResponseEntity<CartItemResponse> addItem(@PathVariable("productId") long productId, @AuthenticatedMember Member authenticatedMember) {
final CartItem addedItem = addService.addItem(authenticatedMember.getId(), productId);
final CartItemResponse cartItem = CartItemResponse.from(addedItem);
return ResponseEntity.ok().body(cartItem);
}
@DeleteMapping("/{productId}")
public ResponseEntity<Void> deleteItem(@PathVariable("productId") long productId, @AuthenticatedMember Member authenticatedMember) {
deleteService.deleteItem(authenticatedMember.getId(), productId);
return ResponseEntity.noContent().build();
}
}
ArgumentResolver를 통해 반환되는 값은 설정한 형태(Member)로 바인딩되기 때문에,
접근할 수 없는 사용자에 대해 401 Unauthorized를 보내주려면, ResponseEntity를 반환하는 대신 ArgumentResolver에서 예외를 일으킨 후에 ControllerAdvice에서 예외를 처리해 주어야 한다.
package cart.entity.member;
public class Member {
private final Long id;
private final String email;
private final String password;
public Member(final Long id, final String email, final String password) {
this.id = id;
this.email = email;
this.password = password;
}
public Member(final String email, final String password) {
this(null, email, password);
}
public long getId() {
return id;
}
public String getEmail() {
return email;
}
public String getPassword() {
return password;
}
}
마지막으로, ArgumentResolver를 설정 파일에 등록한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final HandlerMethodArgumentResolver argumentResolver;
public WebConfig(final HandlerMethodArgumentResolver argumentResolver) {
this.argumentResolver = argumentResolver;
}
@Override
public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(argumentResolver);
}
}
💋 조금 더 욕심 내서 Extractor로 분리해보기
Request의 Authorization 내용을 분리했다.
@Component
public class BasicAuthenticationExtractor {
private static final String BASIC_TYPE_PREFIX = "Basic";
private static final String DELIMITER = ":";
public MemberAuthenticationDto extract(final String authentication) {
validateAuthentication(authentication);
String authHeaderValue = authentication.substring(BASIC_TYPE_PREFIX.length()).trim();
byte[] decodedBytes = Base64.decodeBase64(authHeaderValue);
String decodedString = new String(decodedBytes);
String[] credentials = decodedString.split(DELIMITER);
String email = credentials[0];
String password = credentials[1];
return new MemberAuthenticationDto(email, password);
}
private static void validateAuthentication(final String authorization) {
if (authorization == null) {
throw new UnAuthenticatedMemberException("로그인 해 주세요!");
}
if (!authorization.toLowerCase().startsWith(BASIC_TYPE_PREFIX.toLowerCase())) {
throw new UnAuthenticatedMemberException("로그인 해 주세요!");
}
}
}
그리고 쪼오오끔 더 깔끔해진 Argument Resolver로 남겨두면 진짜 끗!
@Component
public class AuthenticatedMemberArgumentResolver implements HandlerMethodArgumentResolver {
private final MemberDao memberDao;
private final BasicAuthenticationExtractor extractor;
public AuthenticatedMemberArgumentResolver(final MemberDao memberDao, final BasicAuthenticationExtractor extractor) {
this.memberDao = memberDao;
this.extractor = extractor;
}
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticatedMember.class)
&& Member.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) throws Exception {
final String authorization = webRequest.getHeader("Authorization");
final Member authenticatedMember = findMemberFromAuthentication(authorization);
if (authenticatedMember == null) {
throw new UnAuthenticatedMemberException("로그인 해 주세요!");
}
return authenticatedMember;
}
private Member findMemberFromAuthentication(final String authorization) {
final MemberAuthenticationDto memberAuthenticationDto = extractor.extract(authorization);
final String email = memberAuthenticationDto.getEmail();
final String password = memberAuthenticationDto.getPassword();
return memberDao.findByEmailAndPassword(email, password).orElse(null);
}
}
편-안
끗