ArgumentResolver를 사용한 중복로직제거 (feat. 필터, 인터셉터)

Jeonghwa·2024년 3월 27일
1

서론

ModooSpace프로젝트는 세션 인증을 구현하였으며, 표현 계층(Controller)에서 로그인한 유저의 정보를 얻어올 때 아래와 같은 로직을 계속해서 반복하였습니다.

@GetMapping("/{reservationId}")
public ResponseEntity<ReservationResponse> find(@PathVariable Long reservationId, HttpSession session) {

	// 세션에서 로그인한 유저의 정보 얻어오기
	String loginEmail = (String) httpSession.getAttribute("member");
	if(loginEmail == null) {
		throw new UnAuthenticatedException();
	}
                                                    
	ReservationResponse reservation = reservationService.findReservation(reservationId, loginEmail);
    return ResponseEntity.ok().body(reservation);
}

그리고 어플리케이션 계층(Service)에서도 비지니스 로직을 수행하기 위해 유저의 정보를 이용하여 유저 엔티티를 조회하는 작업을 반복하였습니다.

public ReservationResponse findReservation(Long reservationId, String loginEmail) {
       	
	// 얻어온 유저 정보로 유저 조회하기
	Member loginMember =  memberService.findMemberByEmail(loginEmail);
	... 
}

과연 이는 올바른 형태일까요? 향후 로그인 관련 로직이 변경된다면 전부 수정해줘야 할 것입니다.
ex) session 저장값 변경, JWT 토큰으로 인증방법 변경

이렇게 공통으로 관심있는 것을 공통 관심사(cross-cutting concern)이라고 하며 유지보수하기 좋은 코드를 작성하기 위해서는 해당 관심들을 한곳에 모아줘야합니다.

공통 관심사는 스프링 AOP로도 해결할 수 있지만, 이는 웹과 관련된 공통 관심사이기 때문에 HTTP의 헤더의 정보(Session)이 필요하므로 HttpServletRequest를 제공해주는 서블릿 필터, 스프링 인터셉터, ArgumentResolver등을 이용하는 것이 좋습니다.

필터 vs 인터셉터 vs ArgumentResolver

서블릿 필터는 서블릿에서 제공하는 기술로 Dispatcher Servelet이 호출되기 전 호출됩니다. 아래 인터페이스를 구현한 후 FilterRegistrationBean에서 Url패턴으로 구현한 필터를 적용할 수 있습니다.

public interface Filter {
    default void init(FilterConfig filterConfig) throws ServletException {
    }

    void doFilter(ServletRequest var1, ServletResponse var2, FilterChain var3) throws IOException, ServletException;

    default void destroy() {
    }
}

스프링 인터셉터는 스프링 MVC가 제공하는 기술로 Dispatcher Servelet과 컨트롤러 사이에서 호출됩니다. 아래 인터페이스를 구현한 후 WebMvcConfigurer 설정 클래스를 상속받아 더 정밀한 Url패턴으로 인터셉터를 설정할 수 있습니다.

public interface HandlerInterceptor {
    default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        return true;
    }

    default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
    }

    default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
    }
}

인터셉터는 필터보다 더 다양한 기능을 지원합니다.
필터의 경우 단순 request, response 정보를 사용하여 오직 doFilter 한 단계만 구현할 수 있습니다. (initdestroy는 서블릿 컨테이너 생성, 종료 시 호출되므로 로직 구현하기는 어려움)
반면 인터셉터는 preHandle, postHandle, afterCompletion와 같이 세분화된 단계를 구현할 수 있으며 request, response 뿐아니라 어떤 컨트롤러를 호출(handler)했는지, 어떤 정보가 반환(modelAndView)되는지까지 받을 수 있습니다.

마지막으로 ArgumentResolver는 Dispatcher Servelet에서 컨트롤러로 요청이 전달될 때, 컨트롤러에서 필요로 하는 다양한 파라미터의 값(객체)를 생성하며 파라미터 값이 모두 준비되면 컨트롤러를 호출하면서 값을 넘겨줍니다.

아래 인터페이스를 구현한 후,WebMvcConfigurer 설정 클래스를 상속받아 ArgumentResolver를 추가해주면됩니다.

public interface HandlerMethodArgumentResolver {
    boolean supportsParameter(MethodParameter parameter);

    @Nullable
    Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;
}

저는 3가지 방법 중 ArgumentResolver를 선택하였습니다. 이유는 Session에서 유저의 정보를 찾아 유저 엔티티를 조회하여 반환하는 로직을 하나의 관심사로 분리해야하는데, 필요한 객체를 만들어 컨트롤러의 파라미터로 넘겨주는 ArgumentResolver가 적합하다 생각하였습니다.

물론 필터와 인터셉터로도 충분히 구현가능하지만, 이들은 주로 request과 response를 수정하거나 요청을 처리하기 전 특정 조건에 따라 로직을 수행하는데 사용됩니다. 반면 ArgumentResolver는 파라미터의 해석과 주입에 특화되어있습니다.

참고자료

ArgumentResolver로 주입받을 수 있는 파라미터(스프링 기본제공)

리팩토링

지금부터 위 코드를 리팩토링해보겠습니다.

1. 객체를 주입받을 Parameter Annotation 생성

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginMember {

}

현재 로그인되어있는 유저 엔티티를 주입받을 것이기 때문에 LoginMember라는 이름으로 어노테이션을 생성해주었습니다.

  • @Target(ElementType.PARAMETER) : 파라미터에만 사용
  • @Retention(RetentionPolicy.RUNTIME) : 런타임까지 애노테이션 정보가 남음

2. HandlerMethodArgumentResolver 구현체 생성

@RequiredArgsConstructor
@Component
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

    private final MemberService memberService;

    /**
     * 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단
     */
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(LoginMember.class) && parameter.getParameterType().equals(Member.class);
    }


    /**
     * 파라미터에 전달할 객체 생성
     */
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        try {
            return memberService.findMemberByEmail(getLoginEmail(webRequest));
        } catch (RuntimeException e) {
            throw new UnAuthenticatedException();
        }
    }

    private String getLoginEmail(NativeWebRequest webRequest) {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        return (String) session.getAttribute("member");
    }
}

LoginMember 어노테이션이 있으면서, Member 타입이면 ArgumentResolver는 Session에 존재하는 Member의 Email을 찾아 Member를 직접조회한 후 컨트롤러 메서드를 호출하면서 파라미터에 Member를 주입해줍니다.

3. WebConfig 설정

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final LoginMemberArgumentResolver loginMemberArgumentResolver;

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

    ...
}

LoginMemberArgumentResolver를 등록해줍니다.

4. 어플리케이션 코드 리팩토링

컨트롤러

@GetMapping("/{reservationId}")
public ResponseEntity<ReservationResponse> find(@PathVariable Long reservationId,
                                                @LoginMember Member loginMember) {
	ReservationResponse reservation = reservationService
    	.findReservation(reservationId, loginMember);
	return ResponseEntity.ok().body(reservation);
}

서비스

public ReservationResponse findReservation(Long reservationId, Member loginMember) {
	Reservation reservation = findReservationById(reservationId);
    reservation.verifyReservationAccess(loginMember);

	return ReservationResponse.of(reservation);
}

로그인 관련 로직이 사라지면서, MemberService에 대한 의존성 또한 제거할 수 있습니다.

결론

코드 내 중복되는 로직은 관심사를 분리함으로써 유지보수성을 향상시킬 수 있습니다.

특히 웹 관련 객체를 컨트롤러에 주입해야 하는 상황에서는 ArgumentResolver를 활용하여 더 깔끔하고 효율적인 코드를 작성할 수 있습니다.

profile
backend-developer🔥

0개의 댓글

관련 채용 정보