스프링 시큐리티와 연계하여 로그인에 실패했을 경우 로그인화면으로 리다이렉트 시키면서 이메일을 다시 입력폼에 유지하려고 여러가지 시도를 해봤다.

스프링 시큐리티에서 로그인 실패를 담당하는 인터페이스는 AuthenticationFailureHandler 이다.

public interface AuthenticationFailureHandler {
 
/**
 * Called when an authentication attempt fails.
 * @param request the request during which the authentication attempt occurred.
 * @param response the response.
 * @param exception the exception which was thrown to reject the authentication
 * request.
 */
void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception)
throws IOExceptionServletException;
}

위의 형태를 가지는 간단한 인터페이스로, 스프링 시큐리티 설정에서 formLogin() 에 설정하면 로그인이 실패했을 떄 호출되며 이에 대한 처리를 수행한다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .anyRequest().authenticated()
        // form 로그인 
        .and()
        .formLogin()
        .loginPage("/login").permitAll()
        .usernameParameter(USERNAME_PARAMETER)
        .passwordParameter(PASSWORD_PARAMETER)
        .successHandler(authenticationSuccessHandler()) (1)
        .failureHandler(authenticationFailureHandler()) (2)
}
 
// 생략 
@Bean
AuthenticationSuccessHandler authenticationSuccessHandler() {
    return new HoneymonUserAuthenticationSuccessHandler();
}
 
@Bean
AuthenticationFailureHandler authenticationFailureHandler() {
    return new HoneymonUserAuthenticationFailureHandler();
}
로그인 성공시 후속처리
로그인 실패시 후속처리

그리고 HoneymonUserAuthenticationFailureHandler가 호출되면 /login?error={message}&email={email}의 형식으로 리다이렉트되도록 해뒀다. 그런데 암만 로그인 실패가 나도 /login?error= 로 리다이렉트 되지를 않는 것이다. 이 문제 떄문에 한참 씨름을 했다.

public class HoneymonUserAuthenticationFailureHandler implements AuthenticationFailureHandler {
 
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOExceptionServletException {
        if(exception instanceof BadCredentialsException) {
            exception = new BadCredentialsException("security.exception.bad_credentials");
        }
        response.sendRedirect("/login?error=" + exception.getMessage() + "&email=" + request.getParameter(USERNAME_PARAMETER));
    }
}

옆에 누군가가 봐줬으면 참 좋았을텐데…​

그러다가 정말 우연찮게

'아, 내가 LoginWeb' 클래스를 지우고 ViewController 로 처리하도록 했지?

하는 생각이 미쳤다. 리다이렉트가 되지 않고 있는 url에 대해서는 다음과 같이 설정되어 있었다.

@Configuration
public class WebConfig  extends WebMvcConfigurerAdapter {
  @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/login").setViewName("front/login");  // 로그인 
        registry.addViewController("/signup").setViewName("front/signup");  // 회원가입 
    }
}

이 간단한 설정이 나를 괴롭혔던 주 원인이었다.

ViewController 에서 /login 요청에 대한 처리를 수행하기는 하지만 뒤에 전달되는 파라미터들을 모두 소거시킨다는 것을 이해하지 못한 것이다.

어떤 장애든지 기본적인 접근은 그 장애에 영향을 주는 설정부터 살펴보도록 하자. ㅡ_-);; 내가 허비한 시간 우짤꺼야!!

This is a shortcut for defining a ParameterizableViewController that immediately forwards to a view when invoked. Use it in static cases when there is no Java controller logic to execute before the view generates the response.



얼마 전에 JSON을 사용해서 AJAX 통신 API를 만드는 과정에서 삽질을 한 적이 있다. 기본만 제대로 살폈으면 쉽게 넘어갈 수 있는 일이었는데 나의 얼렁뚱땅 대충대충 ‘필요하면 그때그때 찾아서 쓰면 되지 뭐.’ 라는 안일함이 하루의 시간을 날려먹는 상황을 낳고 말았다. 방법은 정말 간단했다.

헤맸던 소스: Before Source

  • ● TestForm.java

    @Data
    @NoArgsConstructor
    @ToString
    public class TestForm {
      private String id;
      private String name;
      private List<TestTag> testTags;
    
      @Data
      @NoArgsConstructor
      @ToString
      public class TestTag {
          private String id;
          private String tag;
      }
    }
    
  • ● TestController.java

    @Controller
    public class TestController {
      private static final Logger logger = LoggerFactory.getLogger(TestController.class);
    
      @RequestMapping(value="/test", method=RequestMethod.GET)
      public void test(@RequestBody TestForm form, ModelMap map) {
          logger.debug("TestForm : {}", form);
      }
    }
    
  • ● Form JSON

      var form = {
          id: "123",
          name: "123",
          testTags: [{id: "1111", tag: "2222"}]
      };
    
      $.ajax({
          url: "http://localhost:8080/test",
          method: "get",
          type: "json",
          data: form,
          success: function(data) {
              console.log(data);
          }
      });
    

    여러가지 시도를 해봤지만, form의 데이터를 TestController의 test에서 제대로 받아들이지 못하는 문제로 골머리를 썩었다(지금 생각해보면 나의 무식함에 부끄럽지만).

해결 코드: After Source

  • ● TestForm.java

    @Data
    @NoArgsConstructor
    @ToString
    public class TestForm {
      private String id;
      private String name;
      private List<TestTag> testTags;
    
      @Data
      @NoArgsConstructor
      @ToString
      public static class TestTag {
          private String id;
          private String tag;
      }
    }
    
  • ● TestController.java

    @Controller
    public class TestController {
      private static final Logger logger = LoggerFactory.getLogger(TestController.class);
    
      @RequestMapping(value="/test", method=RequestMethod.POST)
      public void test(@RequestBody TestForm form, ModelMap map) {
          logger.debug("TestForm : {}", form);
      }
    }
    
  • ● Form JSON

      var form = {
          id: "123",
          name: "123",
          testTags: [{id: "1111", tag: "2222"}]
      };
    
      $.ajax({
          url: "http://localhost:8080/test",
          method: "post",
          type: "json",
          contentType: "application/json",
          data: JSON.stringify(form),
          success: function(data) {
              console.log(data);
          }
      });
    

Before Source와 After Source의 차이를 눈치챘는가? ㅡ_-)?
RequestMethod가 GET에서 POST로 변경되었다. 이에 대한 설명을 해본다. 토비의 스프링에 @RequestBody, @ResponseBody 를 살펴보기 바란다.

@RequestBody, @ResponseBody

최근 개발하고 있는 방식은 대부분이 프론트엔드와 백엔드를 분리하여 개발을 하고 있다. 프론트엔드의 AJAX요청은 대부분 JSON으로 되어 있고, 이에 맞춰 백엔드에서도 JSON 형태로 응답을 해주는 방식을 취하게 된다. 스프링에서는 이와 관련된 @MVC 관련 애노테이션과 설정을 통해 기능을 제공하고 있다.

  • ● @RequestBody

    이 애노테이션이 붙은 파라미터에는 HTTP 요청의 본문body 부분이 그대로 전달된다.
    AnnotationMethodHandlerAdapter에는 HttpMessageConverter 타입의 메시지 변환기message converter가 여러 개 등록되어 있다. @RequestBody가 붙은 파라미터가 있으면 HTTP 요청의 미디어 타입과 파라미터의 타입을 먼저 확인한다(servlet-context.xml 에서 <annotation-drvien> 태그 내에 선언하는 <message-converter> 에서 확인). 메시지 변환기 중에서 해당 미디어 타입과 파라미터 타입을 처리할 수 있다면, HTTP 요청의 본문 부분을 통째로 변환해서 지정된 메소드 파라미터로 전달해준다.

    내가 헤매던 부분이 바로 이부분이었다. ㅡ_-);;JSON 메시지 변환기에는 MappingJackson2HttpMessageConverter를 사용했다. @RequestBody 애노테이션은 요청에서 Body부분을 살펴 요청된 데이터를 추출하여 파라미터로 변환해주는데, ‘GET’ 메소드 요청의 경우에는 HTTP Body에 요청이 전달되는 것이 아니라, URL의 파라메터로 전달(ex: http://localhost:8080/test?id=123&name=123&testTag=…) 형식으로 전달되기 때문에 @RequestBody로 받으려고 해도 서로 다른 곳을 보며 데이터가 없다는 결과를 던질 수밖에 없다(이 부분도 로그에 대해서 상세하게 설정해서 살펴보면서 확인한 결과. 로그! 개발 중에 문제가 되는 요인들을 찾기 위해서 관심을 가지자).

  • ● @ResponseBody

    @ResponseBody는 @RequestBody와 비슷한 방식으로 동작한다. @ResponseBody가 메소드 레벨에서 부여되면 메소드가 리턴하는 오브젝트는 뷰를 통해 결과를 만들어내는 모델로 사용하는 대신, 메시지 컨버터를 통해 바로 HTTP 응답의 메시지 본문으로 변환된다.

    간단히 이야기 하자면, 요청한 형태에 맞춰서 메시지 변환기를 통해 결과값을 반환한다. ‘콩심은 데 콩나고 팥 심은데 팥난다.’ 랄까? ContentNegotiatingViewResolver 와는 동작방식이 좀 다르다. ContentNegotiatingViewResolver는 등록되어 있는 ViewResolver중에서 controller 메소드의 리턴값을 통해 등록된 ViewResolver 중에서 적합한 형태로 처리해서 반환하는 반면, @ResponseBody는 @RequestBody가 선택한 형식으로 결과값을 변환하여 반환한다고 보면 된다.

  • ● MessageConverter 메시지 변환기의 종류는 Spring API 문서를 참고하자.

정리

해당하는 애노테이션들이 어떻게 동작하는지 내가 했던 인터넷 설정들이 어떻게 반응하는지를 제대로 이해했다면, 별다른 삽질없이 조용히 넘어갈 수 있던 문제였는데, 쉬운 문제였다. 하아!!
요즘 들어서 부쩍 ‘기본을 탄탄히 갖춰야겠다.’라는 생각을 하게되는 일들이 많아지고 있다.

끊임없이 공부하고 공부하라!

  • ● 이와 관련된 내용들은 ‘[토비의 스프링]에서 [스프링 @MVC]’ 관련 내용을 상세하게 설명되어 있다.


+ Recent posts