Spring MVC에서 사용하는 ApplicationContext와 WebApplicationContext

보통 스프링 설정 파일이 최소한 두 개이상 있을 겁니다. xxx-servlet.xml 과 나머지로 나눌 수 있습니다. 그중에서 xxx-servlet.xml은 DispatcherServlet이 WebApplicationContext를 만들 때 사용하고, 나머지는  ContextLoaderListener 또는 ContextLoaderServlet이 일반적인 ApplicationContext를 만들 때 사용합니다.

이게 끝이 아닙니다. WebApplicationContext는 바로 이 ApplicationContext를 상속받아서 여러 서블릿들이 공통으로 사용하는 빈들을 사용할 수 있게 되는 겁니다. 따라서 만들어지는 순서도 중요한데, Listener가 아니라 ContextLoaderServlet을 사용했을 때는 load 머시기 설정 값에 1을 줘서 DispatcherServlet보다 먼저 만들게 해야 합니다. 그래서 WebApplicationContext를 만들 때 해당 ApplicationContext를 상속받아서 그 안에 있는 빈들을 사용할 수 있게 되겠죠.

이런 구조로 설계한 건, DispatcherServlet이 하나의 웹 애플리케이션에서 여러 개일 수 있기 때문입니다. 여러 개의 DispatcherServlet에서 공통으로 사용할 빈들을 상위에 있는 ApplicationContext에 선언해두고 공유할 수 있게 하는 거죠.

사용자 삽입 이미지
자. 그럼 여기서 문제

만약에 스프링 2.5 컨트롤러를 사용하고 있고 이 컨트롤러에 특정 @Aspect를 적용하고 싶을 때 해당 AOP 관련 설정(aop:autoproxy 머시기 엘리먼트 + @Aspect 빈 등록)은 xxx-servlet.xml과 applicationContext.xml 둘 중 어디에 둬야 할까요?

xxx-servlet.xml에는 컨트롤러 설정, 뷰 리졸버, 핸들러 맵퍼 등의 설정이 되어 있고, applicationContext.xml에는 서비스, DAO 등의 설정이 들어있습니다.
 
ㄱ. xxx-servlet.xml
ㄴ. applicationContext.xml
ㄷ. 어디에 두든지 상관없다.
ㄹ. 스프링 2.5 컨트롤러에는 Spring AOP를 사용할 수 없다.

정답은?? ㄱ 입니다. 왜냐면, applicationContext.xml을 사용해서 ApplicationContext를 만드는 순간에는 컨트롤러들이 미쳐 빈으로 등록되어 있지도 않기 때문에, 프록시를 만들 대상이 없습니다. 다시 말해, AOP 빈은 있지만, 이 AOP를 적용할 대상이 되는 빈이 없는겁니다. 따라서 컨트롤러들과 관련된 설정이 있는 xxx-servlet.xml에 해당 설정을 위치해야 합니다.

[수정]AbstractModelAndViewTests 사용하여 Controller 테스트하기

이전 글에서 내렸던 결론은 좀 더 생각해보니 확실히 틀렸습니다.

테스트를 할 단위가 컨트롤러 전체 범위라면 이전 글의 가정이 맞겠지만 하나의 테스트는 최소한 작은 부분을 테스트 하라고 했었습니다. 컨트롤러에는 다양한 종류의 lifecycle 메소드들이 있으며 그것들을 전부 handleRequest의 결과인 ModelAndView를 사용하여 테스트 한다는 것은 다소 위험한 발상인 것 같습니다.

Controller 테스트 != ModelAndView 테스트

따라서 handleRequest 전체가 아닌 onSubmit()이 테스트의 대상이 되어야 합니다. 하지만 이전 글에서는 handleRequest를 가지고 테스트를 했었습니다.

이것을 다음과 같이 수정할 수 있습니다.

public class CheckControllerTest extends AbstractModelAndViewTests{

    private CheckController controller;
    private MemberService mockMemberService;
    private String mail;
    private MemberCommand command;

    public void setUp() {
        mockMemberService = createMock(MemberService.class);
        controller = new CheckController();
        controller.setMemberService(mockMemberService);
        command = new MemberCommand();
    }

    public void testEmptyOrWhiteMail() throws Exception {
        mail = “”;
        expect(mockMemberService.findByMail(mail)).andReturn(null);
        replay(mockMemberService);
        command.setMail(mail);
        ModelAndView mav = controller.onSubmit(null, null, command, null);
        assertEquals(“redirect:join.html”, mav.getViewName());
        assertViewName(mav, “redirect:join.html”);
        verify(mockMemberService);
    }

    public void testExistMemberMail() throws Exception {
        Member member = new Member();
        mail = “keesun@mail.com”;
        member.setMail(mail);
        member.setName(“기선”);

        expect(mockMemberService.findByMail(mail)).andReturn(member);
        replay(mockMemberService);
        command.setMail(mail);
        ModelAndView mav = controller.onSubmit(null, null, command, null);
        assertViewName(mav, “confirm”);
        assertModelAttributeValue(mav, “member”, member);
    }
}

이전 테스트와의 가장 큰 차이점은 먼저 handleRequest 전부가 아닌 obSubmit 메소드만을 테스트 했다는 것입니다. 이래야 이전 보다 더 세부적인 부분을 테스트 했기 때문에 좀 더 단위 테스트라 부를 만 한 것 같습니다.

또하나 차이점은 MockHttpServletRequest와 MockHttpServletResponse의 필요가 없어졌습니다. Requet에 바인딩할 데이터를 주는 대신에 command 객체를 사용하여 이미 바인딩 된 상태라고 가정을 했습니다. 이전 테스트의 경우 binder를 사용하여 request에 넣어준 데이터를 바인딩하는 과정까지 테스트를 하는 것이였기 때문에 지금보다 훨씬 넓은 범위를 테스트 했다고 느껴집니다.

AbstractModelAndViewTests 사용하여 Controller 테스트하기

컨트롤러의 주된 목적은 handleRequest안에 Request와 Respnse 객체를 넣어서 결국 ModelAndView 객체를 반환하는 것입니다.

사용자 삽입 이미지따라서 다음과 같은 결론을 조심스래 내놓을 수 있습니다.

Controller 테스트 ==
ModelAndView
테스트

아직은 TDD에 익숙하지도 않고 테스트 클래스를 어떻게 작성해야 할지도 모르기 때문에;; 일단은 구현을 하고 그 것을 테스트 하는 방법을 익히는 식으로 공부하고 있습니다.

먼저 Controller 하나를 구현합니다.

public class CheckController extends SimpleFormController {

    private MemberService memberService;

    public CheckController() {
        setCommandClass(MemberCommand.class);
        setCommandName(“memberCommand”);
        setFormView(“check”);
        setSuccessView(“confirm”);
    }

    public void setMemberService(MemberService memberService) {
        this.memberService = memberService;
    }

    @Override
    protected ModelAndView onSubmit(HttpServletRequest request, HttpServletResponse response, Object command, BindException exception) throws Exception {
        MemberCommand memberCommand = (MemberCommand)command;
        Member member = memberService.findByMail(memberCommand.getMail());
        if(member != null)
            return new ModelAndView(getSuccessView())
                .addObject(“member”, member);
        else
            return new ModelAndView(“redirect:join.html”);
    }
}

이 컨트롤러는 입력 받은 mail 주소를 사용하여 DB에서 검색을 하여 있으면 confirm 페이지로 없으면 join 페이지로 리다이렉션하는 컨트롤러 입니다.

이것을 테스트 하려면 다음과 같이 if 조건문에 걸릴 경우(true)와 걸리지 않는 경우(false)를 모두 테스트 해봐야 합니다. 이 때 테스트 할 것은 위에서도 말했지만 결국은 ModelAndView입니다.

JUnit의 TestCase를 직접 사용해도 되지만 spring-mock.jar 에 Spring 2.0에서 추가된 AbstractModelAndViewTests 클래스를 사용하여 다음과 같이 테스트를 할 수 있습니다.

public class CheckControllerTest extends AbstractModelAndViewTests{

    private CheckController controller;
    private MockHttpServletRequest request;
    private MockHttpServletResponse response;
    private MemberService mockMemberService;
    private String mail;

    public void setUp() {
        request = new MockHttpServletRequest();
        response = new MockHttpServletResponse();
        mockMemberService = createMock(MemberService.class);
        controller = new CheckController();
        controller.setMemberService(mockMemberService);
    }

    public void testEmptyOrWhiteMail() throws Exception {
        mail = “”;
        expect(mockMemberService.findByMail(mail)).andReturn(null);
        replay(mockMemberService);
        request.addParameter(“mail”, mail);
        request.setMethod(“POST”);
        ModelAndView mav = controller.handleRequest(request, response);
        assertEquals(“redirect:join.html”, mav.getViewName());
        assertViewName(mav, “redirect:join.html”);
        verify(mockMemberService);
    }

    public void testExistMemberMail() throws Exception {
        Member member = new Member();
        mail = “keesun@mail.com”;
        member.setMail(mail);
        member.setName(“기선”);

        expect(mockMemberService.findByMail(mail)).andReturn(member);
        replay(mockMemberService);
        request.addParameter(“mail”, mail);
        request.setMethod(“POST”);
        ModelAndView mav = controller.handleRequest(request, response);
        assertViewName(mav, “confirm”);
        assertModelAttributeValue(mav, “member”, member);
    }
}

EasyMock을 사용하여 MemberSerive의 Mock 객체를 사용했으며 spring-mock.jar의 MockHttpServletRequest와 MockHttpServletReponse를 사용했습니다.

여기서 주목할 것은 테스트의 대상인 ModelAndView 객체를 받아 올 때 호출한 메소드가 handleRequest라는 것입니다. 이 것은 Spring 의 Controller 클래스의 Workflow 때문이죠. 결국은 모든 컨트롤러들이 handleRequest로 요청을 처리하기 시작하기 때문입니다.

AbstractController

상속 구조
사용자 삽입 이미지하는 일
모든 컨트롤러들의 기본이 되는 클래스로써 위 그림에 표시되어 있는 일들을 합니다.
여기서 한 가지 주목할 것은 Controller 인터페이스를 구현함에 따라 구현해야할 handleRequest 입니다.
이 메소드는 final로 다음과 같이 구현되어 있습니다.

    public final ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response)
            throws Exception {

        // Delegate to WebContentGenerator for checking and preparing.
        checkAndPrepare(request, response, this instanceof LastModified);

        // Execute handleRequestInternal in synchronized block if required.
        if (this.synchronizeOnSession) {
            HttpSession session = request.getSession(false);
            if (session != null) {
                Object mutex = WebUtils.getSessionMutex(session);
                synchronized (mutex) {
                    return handleRequestInternal(request, response);
                }
            }
        }
       
        return handleRequestInternal(request, response);
    }

이 메소드와 관련된 자세한 설명은 KSUG 제 2회 세미나에서 영회형이 설명해주셨지만 Spring MVC 118쪽에도 적혀있습니다. 워크 플로우와 메소드들의 라이프 사이클을 지키면서도 상속 받아 사용하는 녀석들에 때라 행동을 다르게 정의 할 수 있도록 Open-Closed Principle 을 구현한 사례에 해당합니다.

Workflow
1. DispatcherServelet에 의해 handleRequest() 호출됨.
2. 지원하는 요청의 타입을 조사(만약 지원하는 요청이 아닐 경우 ServletException 발생)
3. 세션이 필오하면 가져오려고 시도함.(없을 경우 ServletException 발생)
4. cacheSeconds 속성에 따라 필요하다면 캐슁 헤더를 설정함.
5. handleRequestInternal() 추상 메소드를 호출(이때 설정에 따라 HttpSession을 동기화 함)

Non-String DataBinding 테스트하기

참조 : Spring MVC

객체가 속성으로 String 타입이 아닌 속성을 가지고 있을 때 데이터 바인딩을 하려면 별도의 조취가 취해져야 합니다. request에 담고 있는 데이터는 모두 문자열이기 때문에 이전 글에서 살펴보았던 간단한 DataBinding일 경우에는 정말 간단하게 바인딩을 할 수 있었습니다. 하지만 이번 경우에는 PropertyEditor의 도움을 받아서 역시 간단하게 문자열을 특정 타입으로 변환하여 바인딩 해줍니다.

다음의 표는 ServletRequestDataBinder가 기본으로 사용하는 PropertyEditor들 입니다.
사용자 삽입 이미지따라서 위 표의 Result에 있는 타입들은 별도의 PropertyEditor를 바인더에 등록하지 않더라도 알아서 문자열을 해당 타입으로 변환하여 바인딩해줍니다.

간단한 클래스 작성
[#M_ more.. | less.. | public class DefaultPropertyClass {

    private int intProperty;
    private Integer integerProperty;
    private Class classProperty;
    private URL urlProperty;
    private String[] strings = new String[10];

    public Class getClassProperty() {
        return classProperty;
    }

    public void setClassProperty(Class classProperty) {
        this.classProperty = classProperty;
    }

    public Integer getIntegerProperty() {
        return integerProperty;
    }

    public void setIntegerProperty(Integer integerProperty) {
        this.integerProperty = integerProperty;
    }

    public int getIntProperty() {
        return intProperty;
    }

    public void setIntProperty(int intProperty) {
        this.intProperty = intProperty;
    }

    public URL getUrlProperty() {
        return urlProperty;
    }

    public void setUrlProperty(URL urlProperty) {
        this.urlProperty = urlProperty;
    }

    public String[] getStrings() {
        return strings;
    }

    public void setStrings(String[] strings) {
        this.strings = strings;
    }
}_M#]
테스트 클래스 작성
[#M_ more.. | less.. | public class DefaultPropertyClassTest {

    private DefaultPropertyClass propertyClass;

    private MockHttpServletRequest request;

    private ServletRequestDataBinder binder;

    @Before
    public void setUp() {
        propertyClass = new DefaultPropertyClass();
        request = new MockHttpServletRequest();
        binder = new ServletRequestDataBinder(propertyClass);
    }

    @Test
    public void testBinding() throws MalformedURLException {

        request.addParameter(“intProperty”, “34”);
        request.addParameter(“integerProperty”, “200”);
        request.addParameter(“classProperty”, “java.lang.String”);
        request.addParameter(“urlProperty”, “http://www.example.com/”);
        request.addParameter(“strings[1]”, “a, b, c”);
        binder.bind(request);
        assertEquals(34, propertyClass.getIntProperty());
        assertEquals(new Integer(200), propertyClass.getIntegerProperty());
        assertEquals(String.class, propertyClass.getClassProperty());
        assertEquals(new URL(“http://www.example.com/”), propertyClass.getUrlProperty());
        assertEquals(“a, b, c”, propertyClass.getStrings()[1]);

    }
}_M#]