테스터 계정 만들기 - 인터셉터에서 발생한 예외를 ExceptionHandler에서 잡을 수 있을까?

dondonee·2024년 7월 23일
0

테스터 계정 만들기

게시판 프로젝트를 외부에 공개했는데 방문자에 비해 회원가입까지 하는 사람들이 현저히 적어서, 회원가입 없이도 내부 기능을 둘러볼 수 있도록 테스터 계정을 생성했다. 테스터 계정은 공용이기 때문에 비밀번호 변경과 회원탈퇴 기능은 막아두어야 했다.

처음에는 간단하게 컨트롤러에서 테스터 계정을 체크하고 예외를 던졌다. 그런데 이러한 방식은 컨트롤러 로직과 관련성도 떨어지고, 동일한 코드가 반복되며, 확장성이 떨어지는 문제가 있었다.

테스터 계정 체크는 여러 컨트롤러의 공통 관심사이며, 컨트롤러 호출 이전에 처리되어야 하는 로직이기 때문에 인터셉터가 적합하다고 생각해서 리팩토링을 하기로 했다.


나는 인터셉터가 테스터 계정의 요청을 차단하기 위해 예외를 throw 하면 이것을 @ExceptionHandler에서 받아 처리하도록 하고 싶었다.

그런데, 둘 중에 무엇이 먼저 실행되는지 헷갈렸다. 인터셉터가 던진 예외를 @ExceptionHandler에서 받을 수 있는 것일까?

결론부터 설명하자면 preHandle()에서 발생한 에러는 처리 가능하고, postHandle()이나 afterCompletion()에서 발생한 에러는 처리할 수 없다.



인터셉터와 @ExceptionHandler

인터셉터는 Spring MVC의 한 부분으로, 디스패처 서블릿과 컨트롤러 사이에 위치한다. 인터셉터는 HTTP 요청이 컨트롤러에 도달하기 전에 실행될 수도 있고, View가 렌더링되기 전이나 후에 실행될 수도 있다.

스프링의 인터셉터 HandlerInterceptor세 가지 메소드를 가지고 있다. 인터셉터 로직을 처리하고 싶은 시점에 따라 선택하여 사용하면 된다 :

  • preHandle() : 요청이 컨트롤러로 전달되기 전 실행된다. true를 반환하면 다음 순서의 인터셉터를 실행한다. false를 반환하면 다음 과정이 실행되지 않으며, 응답 객체를 반환해야 한다. 이 메소드는 예외를 던질 수 있다.
  • postHandle() : 핸들러가 호출된 이후 디스패처 서블릿이 View를 렌더링하기 전 실행된다. ModleAndView에 값을 추가하고 싶을 때 사용할 수 있다.
  • afterCompletion() : 핸들러가 실행되고 View가 렌더링된 뒤, 한 번 실행된다.


직접 해보기

정말 preHandle()에서 예외를 던질 수 있는지, 다른 메소드에서는 처리되지 않는지 직접 실험해보았다.

커스텀 예외인 BusinessException.TEST_FORBIDDEN를 인터셉터의 각 메서드에서 던져보았다. 이 예외는 테스터 계정이 허용되지 않은 기능을 요청할 때 던져지며, public static final로 캐싱되어 있고, @RestControllerAdvice에서 핸들링한다.

그리고 아래와 같이 핸들링될 때 어떤 예외인지 알 수 있도록 예외 이름을 로그로 출력하도록 했다.

@Slf4j
@RestControllerAdvice
public class RestExceptionHandlerAdvice {

	@ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(final BusinessException e) {
        log.info("BusinessException : {}", e.getCode().name());
        return e.makeResponseEntity();
    }
}


INFO 3069 --- [nio-8084-exec-8] c.k.b.e.RestExceptionHandlerAdvice       : BusinessException : TEST_FORBIDDEN
WARN 3069 --- [nio-8084-exec-8] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [com.knou.board.exception.BusinessException]

preHandle()에서 예외를 던진 경우이다.

@ExceptionHandler 예외를 관할하는 ExceptionHandlerExceptionResolver에서 해당 예외를 처리한 것을 확인할 수 있다.


INFO 3051 --- [nio-8084-exec-8] c.k.b.e.RestExceptionHandlerAdvice       : BusinessException : INVALID_FORM
WARN 3051 --- [nio-8084-exec-8] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [com.knou.board.exception.BusinessException: 현재 비밀번호가 일치하지 않습니다.]

postHandle()에서 예외를 던진 경우이다.

인터셉터에서 던진 예외는 TEST_FORBIDDEN인데 이것은 무시되었다. 로그의 INVALID_FORM은 컨트롤러에서 유효성 검사에 실패해 발생한 예외이다.


 INFO 3060 --- [nio-8084-exec-7] c.k.b.e.RestExceptionHandlerAdvice       : BusinessException : INVALID_FORM
 WARN 3060 --- [nio-8084-exec-7] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [com.knou.board.exception.BusinessException: 현재 비밀번호가 일치하지 않습니다.]
ERROR 3060 --- [nio-8084-exec-7] o.s.web.servlet.HandlerExecutionChain    : HandlerInterceptor.afterCompletion threw exception

com.knou.board.exception.BusinessException: null

afterCompletion()에서 예외를 던진 경우이다.

컨트롤러가 실행되었고 유효성 검사에 실패해 INVALID_FORM 예외가 발생했다. 컨트롤러 동작이 끝난 후 인터셉터의 afterCompletion()에서 예외를 던졌지만 @ExceptionHandler에서 처리되지 않았다.


@ControllerAdvice는 컨트롤러 이전에 위치할 테니 preHandle()에서 던진 예외만 잡을 수 있는 것은 어찌 보면 당연할지도 모르겠다. 🤔



리팩토링

인터셉터에서 던진 예외를 @ControllerAdvice에서 처리할 수 있다는 것을 확인했으니, 리팩토링을 진행했다.


기존 코드

@Slf4j
@Controller
@RequiredArgsConstructor
public class MemberController {

   @PutMapping("/me/password")
   public ResponseEntity resetPassword(@Validated PasswordResetForm form, BindingResult bindingResult, @Login Member loginMember) {

	// 테스트 계정 체크
    Long userNo = loginMember.getUserNo();
        if (userNo == 4 && userNo == 5) {
            throw BusinessException.TEST_FORBIDDEN;
    
    // 사용자 입력 유효성 검증
    // 비밀번호 변경
    // 반환
	}
}

기존 코드에서는 위와 같이 컨트롤러 메서드마다 테스터 계정을 체크하고 예외를 던졌다.

  • 참고로 userNo로 테스터 계정을 체크한 이유는 세션에서 바로 체크 가능한 값이기 때문이다. DB 구조상 아이디로 테스터 계정을 체크하려면 DB 조회가 필요하다.


인터셉터 도입 - TesterLockInterceptor

@Slf4j
@Configuration
public class WebConfig implements WebMvcConfigurer {

	@Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 미인증 사용자 접근 제한
        registry.addInterceptor(new LoginCheckInterceptor())
                .order(1)
                .addPathPatterns("/*/new", "/*/*/edit", "/*/*/delete", "/settings/{*path}", "/api/**");

        // 테스터 기능 제한 - 비밀번호 변경, 회원탈퇴
        registry.addInterceptor(new TesterLockInterceptor())
                .order(2)
                .addPathPatterns("/api/v1/me/password", "/api/v1/me/withdrawal");
    }
}
@Slf4j
public class TesterLockInterceptor implements HandlerInterceptor {

    public static final Map<String, List<String>> targetMap = new HashMap<>();

    static {
        targetMap.put("PUT", List.of("/me/password"));
        targetMap.put("POST", List.of("/me/withdrawal"));
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {

        String method = request.getMethod();
        String apiURI = request.getRequestURI().replace("/api/v1", "");

        // 대상 요청인지 체크
        if (!targetMap.containsKey(method)) {
            return true;
        } else if (!targetMap.get(method).contains(apiURI)) {
            return true;
        }

        HttpSession session = request.getSession();
        Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
        Long userNo = loginMember.getUserNo();

        // 테스트 유저 사용 제한
        if (userNo == 4 || userNo == 5) {
            throw BusinessException.TEST_FORBIDDEN;
        }

        return true;
    }
}

먼저 LoginCheckInterceptor에서 요청 사용자가 로그인 된 사용자인지 체크한 뒤 TesterLockInterceptor로 넘어가기 때문에 loginMember의 NULL 체크는 생략했다.

테스터 계정을 차단하고 싶은 URI가 추가된다면 targetMap에 해당 URI를 추가하면 된다.



완료 화면

비밀번호 변경 불가


회원탈퇴 불가




🔗 Reference

0개의 댓글