웹 관련 기술을 테스트하는 것은 어렵다. 컨테이너에 올리고 수동테스트 하는 건 어렵지 않지만 컨테이너 밖에서 자동화된 단위/통합 테스트를 구성하는 것은 쉽지 않다. 하지만 불가능한 것은 아니다.

스프링의 웹 기술은 DispatcherServlet과 그 안에서 생성되는 WebApplicationContext가 기반이 된다. SpringMVC/@MVC를 쓰든 아답터를 이용해서 다른 웹 프레임워크가 연동되든 마찬가지다.

@MVC는 @RequestMapping을 이용해서 URL과 핸들러-메소드를 연결한다. Controller처럼 인터페이스가 정해져 있는 것은 아니고 메소드 시그니처와 파라미터의 조합을 통해서 매우 유연하게 웹 요청을 핸들링 하는 메소드를 작성할 수 있다. 파라미터, 리턴타입, 애노테이션의 조합에 따라 매우 다양한 방식으로 동작하기 때문에 그 경우를 모두 따져서 테스트 해보면서 익히지 않으면 제한적으로 밖에 사용할 수 없다. 문제는 이를 컨테이너에서 테스트 하는 것은 매우 번거롭다는 것이고 자동화 하기 힘들다는 것이다.

대신 컨테이너 밖에서 웹에서 동작하는 것처럼 테스트를 만들 수 있다면 다양한 테스트를 만들어서 @MVC가 동작하는 방식을 학습할 수 있다. request, session 스코프의 경우도 마찬가지이다. 이 스코프들이 동작하는 방식을 테스트 하는 것은 단순한 @MVC 매핑이나 MAV 기능을 테스트 하는 것보다 훨씬 더 복잡하다. 여러번의 요청과 여러 세션을 바꿔가면서 테스트를 해야 한다. 이것은 수동 테스트로도 매우 귀찮은 작업이다. 역시 자동화된 컨테이너 밖에서 동작하는 테스트를 만들 수 있다면 좋을 것이다.

HttpServletRequset와 Response를 이용해서 웹에서 동작하는 SpringMVC를 테스트 하려면 DispatcherServlet을 이용해야 한다. DispatcherServlet은 configClass 정보를 이용해서 자동으로 웹 컨텍스트를 만들고 지정된 설정파일을 읽어서 초기화 한다. 설정파일을 따로 만들어두고 DispatcherServlet을 직접 DI해주고 스프링이 제공하는 웹용 Mock 오브젝트를 사용하면 이런 스프링 웹 테스트를 만드는 것이 어렵지 않다.

하지만 오늘 도전해볼 것은 이전에 해본 것처럼 XML없이 스태틱 클래스와 애노테이션 설정만을 가지고 웹 테스트를 만드는 것이다.

DispatcherServlet의 빈 설정을 코드로 제어하고 싶다면 DispatcherServlet의 createWebApplicationContext() 메소드를 오버라이드 해서 직접 컨테스트를 만들게 하면 된다. 여기서 만들어지는 컨텍스트는 WebApplicationContext류여야 한다. 코드에 의해서 빈을 등록하게 할 수 있는 WAC는 StaticWebApplicationContext가 있다.

문제는 SWAC는 registerBeanDefinition()을 이용해서 빈 클래스를 등록하게 할 수는 있지만 기본적으로 애노테이션 설정을 적용하기 위해서 필요한 다양한 후처리기들이 내장되어 있지 않다. 따라서 @Autowired니 @Scope같은 것들이 적용되지 않는다. 5개에 달하는 <context:annotation-config/>에 의해서 등록되는 애노테이션 설정을 위한 후처리기를 수동으로 등록해주지 않으면 안된다. 매우 번거로운 일이다.

다음으로 고려해볼 WAC는 AnnotationConfigApplicationContext의 웹 버전이라고 할 수 있는 AnnotationConfigWebApplicationContext이다. 문제는 ACAC와 달리 ACWAC는 등록할 클래스를 직접 넣어주는 방법을 제공하지 않고 빈 스캐닝을 위한 폴더만 지정할 수 있다. ACAC와 ACWAC가 다른 사람이 개발한게 아닌가 하는 의심이 들정도로 제공하는 기능이 다르다. 물론 ACAC는 코드에 의해서 테스트 하는데 사용되는 것이 주 용도이고, ACWAC는 실전용이니 그럴 수도 있겠다 싶지만.

그래서 ACWAC를 사용하면서 XML없이, 스태틱 클래스로 만든 테스트용 빈을 추가할 방법은 없다. 스캐닝용 폴더를 따로 만들고 그 안에 빈 클래스를 넣지 않으면 안된다. 귀찮은 일이다.

그래서 ACWAC를 대신해서 AbstractRefreshableWebApplicationContext를 상속해서 애노테이션 리더를 이용해서 빈 클래스를 직접 등록할 수 있도록 만들어줄 필요가 있다. 이를 DispatcherServlet의 기본 컨텍스트 생성 메소드를 오버라이드해서 넣는다면 애노테이션 방식의 클래스를 바로 지정해서 빈으로 등록하고 DispatcherServlet을 테스트 할 수 있는 테스트용 DispatcherServlet을 만들 수 있다.

이렇게 만든 게 다음 클래스이다.

public class AnnotationConfigDispatcherServlet extends DispatcherServlet {
    private Class<?>[] classes;
    public AnnotationConfigDispatcherServlet(Class<?> …classes) {
        super();
        this.classes = classes;
    }
    protected WebApplicationContext createWebApplicationContext(ApplicationContext parent) {
        AbstractRefreshableWebApplicationContext wac = new AbstractRefreshableWebApplicationContext() {
            protected void loadBeanDefinitions(DefaultListableBeanFactory beanFactory)
                    throws BeansException, IOException {
                AnnotatedBeanDefinitionReader reader = new AnnotatedBeanDefinitionReader(beanFactory);
                reader.register(classes);
            }
        };

        wac.setServletContext(getServletContext());
        wac.setServletConfig(getServletConfig());
        wac.refresh();
        return wac;
    }
}

그리고 다음은 이를 이용해서 request scope빈이 매 request마다 하나씩만 만들어져서 사용되는 것을 테스트 해보려고 만든 코드이다. 내부에서 스코프가 어떻게 처리되는지 알 필요 없이 웹 기반의 스코프를 테스트 하는 코드를 이렇게 간단히 만들 수 있으니 매우 만족스럽다. session scope를 테스트 해보는 것도 어렵지 않다. Mock request의 session을 유지 또는 변경해가면서 scoped 빈의 변화를 살펴보면 된다. 여기서는 Provider를 사용했는데 Proxyed Scope를 이용하는 코드를 테스트 하는 것도 간단하다. @Scope의 proxyMode를 default에서 interface나 targetClass로 바꿔주고 Provider로 DL하는 대신 proxyed scope로 DI하게 바꾸면 된다. @RequestMapping의 테스트도 쉽게 만들 수 있다. 심심할 때 해보자.

MockHttpServletResponse response = new MockHttpServletResponse();
@Test
public void requestScope() throws ServletException, IOException {
    MockServletConfig ctx = new MockServletConfig();
    DispatcherServlet ds = new AnnotationConfigDispatcherServlet(HelloController.class, HelloService.class, RequestBean.class, BeanCounter.class);
    ds.init(new MockServletConfig());
    BeanCounter counter = ds.getWebApplicationContext().getBean(BeanCounter.class);
    ds.service(new MockHttpServletRequest("GET", "/hello"), this.response);
    assertThat(counter.addCounter, is(2));
    assertThat(counter.size(), is(1));
    ds.service(new MockHttpServletRequest("GET", "/hello"), this.response);
    assertThat(counter.addCounter, is(4));
    assertThat(counter.size(), is(2));
}
@RequestMapping("/") static class HelloController {
    @Autowired HelloService helloService;
    @Autowired Provider<RequestBean> requestBeanProvider;
    @Autowired BeanCounter beanCounter;
    @RequestMapping("hello") public String hello() {
        beanCounter.addCounter++;
        beanCounter.add(requestBeanProvider.get());
        helloService.hello();
        return "";
    }
}
static class HelloService {
    @Autowired Provider<RequestBean> requestBeanProvider;
    @Autowired BeanCounter beanCounter;
    public void hello() {
        beanCounter.addCounter++;
        beanCounter.add(requestBeanProvider.get());
    }
}
@Scope("request") static class RequestBean {}
static class BeanCounter extends HashSet { int addCounter = 0; };

스프링의 빈 스캐너는 애노테이션이나 클래스 이름패턴 필더를 이용해서 조건을 만족하는 모든 클래스를 빈으로 자동 등록해주는 기능을 가지고 있다. 아.. 사실 스캐너는 등록까지는 아니고 스캔을 통해서 후보 빈 메타정보를 생성하는 것 까지가 책임이긴 하다. 아무튼 편하다. XML선언 없이도 간단히 빈을 등록할 수 있으니 말이다.

보통 @Component라는 애노테이션을 빈 후보 선정용 필터로 사용하는데 이게 필수는 아니다. 원한다면 애노테이션 없이도 특정 패키지 아래의 모든 클래스를 모두 빈으로 등록하게 할 수도 있다.

그런데 빈 스캐닝 방식에 대해서 보통 성능상의 손해가 있을 것이라고 생각한다. 그런 생각은 스캐닝이라는 작업이 주는 일종의 선입관이 아닌가 싶다. XML문서를 파싱하고 이를 해석하는 것보다 특정 패키지 아래의 모든 클래스를 뒤져서 애노테이션을 확인하는 것이 더 느린 작업이라는 전제가 깔려있다. 아마도 그런 판단은 클래스의 정보를 읽는 리플렉션 API가 사용됐을 것이라는 추측에서 비릇됐을 것이다. 리플렉션 API는 그 성능에 대해 좋지 않은 이미지를 가지고 있다. 마치 JDK프록시처럼 말이다. 물론 두가지 다 JDK버전이 업그레이드 되면서 상당히 성능이 개선되었고, 이제는 왠만한 툴과 프레임워크에서 리플렉션 API를 사용해서 일종의 메타프로그래밍을 시도하는 일이 많아졌을만큼 충분히 좋아졌다.

리플렉션API가 보편화 되는 과정에서 애노테이션의 등장도 한 몫했다. 대부분의 애노테이션은 런타임 시에 그 정보를 소화한다. 애노테이션은 타겟의 타입에 변화를 주지 못한다. 그래서 조쉬 블록은 애트리뷰트가 없는 마커 애노테이션을 쓸바엔 마커 인터페이스를 쓰라고 했다. 인터페이스는 타입의 변화를 주고 그래서 검증이 쉽다. 반면에 애노테이션은 일일히 리플렉션 API를 이용해서 확인해야 한다.

아무튼 그래서 스프링의 빈 스캐닝은 특정 패키지 내의 모든 클래스의 애노테이션을 일일히 리플렉션을 통해서 검사해야 하므로 느릴 것이라는 나름의 추정을 하고 있는 사람들이 많은지 모르겠다. 여기에 한가지 더 하자면 리플렉션을 적용하려면 클래스를 일단 메모리로 로딩해야 한다. 굳이 당장에 스프링의 빈으로 등록될 필요가 없는, 어쩌면 애플리케이션 개발에서 만들기는 했으나 정작 사용하지 않는 클래스까지 빈 스캐너가 리플렉션으로 애노테이션을 검사하게 하기 위해서 모두 메모리로 로딩한다는 것은 매우 비효율적이라고 밖에 느껴지지 않을 것이다.

개발중이라면 테스트 클래스도 같은 클래스 패스의 패키지에 존재한다. 따라서 테스트 중에 빈 스캐닝을 적용한 컨텍스트 설정을 해버리면 모든 패키지내의 테스트도 몽땅 메모리로 로딩된다. 이 얼마나 어이없는 일인가.

이렇게 성능상의 손해를 보더라도 빈 스캐너의 편리함 때문에 그냥 사용해야 하는 것일까?

 

아니다.

성능을 떨어뜨리고 초기 기동시간을 많이 잡아먹으니 사용하지 말라는 얘기가 아니다. 위에서 추정한 식의 성능상의 심각한 손해는 없다는 말이다. 스캐닝 과정에서 모든 대상 클래스를 로딩하고 리플렉션을 사용해서 일일히 애노테이션을 확인하는 일 따윈 발생하지 않고, 그래서 성능상의 영향이 별로 없다는 얘기다.

그렇다면 스프링의 빈 스캐너는 클래스를 로딩하지도 않고, 리플렉션을 사용하지도 않은채로 클래스 안에 어떤 컴포넌트가 있는지를 확인할 수 있다는 말인가? 그렇다.

스프링의 빈 스캐너는 클래스를 일절 로딩하지 않고 리플렉션API를 쓰지 않고도 클래스 정보를 알아내고 애노테이션을 분석한다. SimpleMetadataReaderFactory가 만들어주는 SimpleMetatdataReader가 그런 일을 가능하게 해준다.

com.mycomponent라는 패키지와 그 하위 패키지의 아래의 모든 클래스의 이름과 클래스 레벨의 애노테이션을 출력하는 프로그램을 만들어보자. 를래스를 로딩도 하지 않고 리플렉션도 사용하지 않도록 할 것이다.

먼저 패키지 아래 모든 클래스 파일을 리소스 형태의 목록으로 만든다.

PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource[] rs = resolver.getResources("com/mycompany/**/*.class”);

리소스 로더가 지원하는 앤트 방식의 패턴매칭을 사용했다. 리소스는 그 자체로 파일을 읽어들인 것은 아니다. 단지 리소스 형태로 포인팅만 하고 있다.

이제 클래스 파일에 대한 리소스 정보를 가지고 클래스이름과 애노테이션을 읽고 출력해보겠다.

for(Resource r : rs) {
    MetadataReader mr = new SimpleMetadataReaderFactory().getMetadataReader(r);
    System.out.println(mr.getClassMetadata().getClassName());
    for(String an : mr.getAnnotationMetadata().getAnnotationTypes()) {
        System.out.println(" – " + an);
    }
}

먼저 리소스에 대해서 메타정보리더를 만들어 둔다.  그리고 그 안의 클래스 메타데이터와 애노테이션 메타데이터 정보를 가져와서 사용하면 된다.

그렇다면 이 MetadataReader는 어떻게 클래스 정보와 애노테이션 정보를 읽어들이는 것일까? 그것은 클래스 바이트코드를 직접 분석해서 필요한 메타정보만 가져오는 방법을 사용한 것이다. 정식으로 클래스 바이너리를 JVM에 로딩하는 과정없이 바로 바이트코드에 접근하는 방법을 쓴 것이다. 이 방법을 사용하면 클래스, 메소드, 애노테이션 등에 대한 메타정보를 로딩이나 리플렉션API사용 없이도 알아낼 수있다.

사실 뒤에서 이 일을 해주는 것은 자바의 바이트코드 조작을 가능하게 해주는 ASM라이브러리이다. 스프링은 이 ASM의 각종 visitor를 사용해서 메타정보를 읽어낸다. 내부적으로는 스프링의 ClassMetadataReadingVisitor와 MethodMetadataReadingVisitor, AnnoationMetadataReadingVisitor 등이 ASM라이브러리의 도움을 받아서 메타정보 읽기 작업을 수행한다.

결국 빈 스캐너는 불필요한 클래스를 메모리에 로딩하는 위험성을 피하고, 리플렉션API와 같은 상대적으로 성능이 떨어지는 API의 사용을 안하고 직접 빠르게 바이트코드 파싱을 통해서 필요한 정보를 가져오기 때문에 성능에 그다지 영향이 없다.

 

그런데 정말 클래스가 로딩됐는지 아닌지를 어떻게 확인할 수 있을까? 내가 알기로는 간단한 방법은 없다.

굳이 방법을 찾아보면, ClassFileTransfomer를 지원하는 클래스로더를 이용하거나 JVM agent를 등록해서 Instrumentation을 가져와 로딩된 클래스를 확인해보는 것이다. 스프링의 InstrumentationSavingAgent를 사용하면 아주 간단히 Instrumentation 오브젝트에 접근 할 수 있다. 좀 더 해보고 싶다면 LoadTimeWeaver에서 사용할 ClassFileTransformer를 만들어 클래스가 뭐가 로딩되는지 확인하는 기능을 사용해봐도 좋을 것이다.

 

오늘의 결론은 "잘 모르면서 쓸데없는 걱정이나 하지말고 빈 스캐너를 열심히 쓰 자"는 것.

이 정도는 스프링을 제대로 공부하고 쓰는 사람이면 상식이어야겠다고 생각하고 있지만… 욕심이겠지.

정리해보자.

스프링은 WAR 독립 웹 모듈(애플리케이션)으로 주로 개발된다. 서블릿 컨테이너에 올라가는 웹 애플리케이션이다.

독립 웹 모듈로 만들어진 하나의 스프링 애플리케이션에 애플리케이션 컨텍스트는 몇 개가, 어떻게 만들어질까? 스프링 웹을 제대로 공부했다면 보통 두 개가 만들어진다는 것을 알 수 있다.

하나는 ContextLoaderListener에 의해서 만들어지는 Root WebApplicationContext이고 다른 하나는 DispatcherServlet에 의해서 만들어지는  WebApplicationContext이다. 전자는 보통 서비스계층과 DAO를 포함한, 웹 환경에 독립적인 빈 들을 담아두고 후자는 DispatcherServlet이 직접 사용하는 컨트롤러를 포함한 웹 관련 빈을 등록하는 데 사용한다. 그리고 이 둘이 parent-child ApplicationContext 관계로 맺어진다. 그래서 ContextLoaderListener로 만들어지는 컨텍스트를 Root WAC라고 부르는 것이다.

이론적으로 DispatcherServlet는 여러 개 등록될 수 있다. 왜 그래야 하는지는.. 생각해보면 많은 이유가 있겠지만, 아무튼 기술적으로 가능하고 그런 의도를 가지고 설계되었다. 그리고 각각 DispatcherServlet은 독자적인 WAC를 가지고 있고 모두 동일한 Root WAC를 공유한다. 스프링의 AC는 ClassLoader와 비슷하게 자신과 상위 AC에서만 빈을 찾는다. 따라서 같은 레벨의 형제 AC의 빈에는 접근하지 못하고 독립적이다.

이는 마치 JavaEE의 엔터프라이즈 애플리케이션 축소판을 보는 듯 하다. 공유되는 EJB나 JAR와 이를 사용하는 여러 개의 Web모듈이 존재하는 것과 비슷하다. JavaEE의 모듈개념을 AC단위로 웹 모듈 하나 안에 넣은 것이라고 봐도 좋다. 물론 EE앱처럼 유연한 리로딩이 보장된 건 아니지만, 서블릿 단위로 생각해보면 핫-디플로이도 된다니까 불가능 할 것도 없다.

 

그런데 Root WAC는 사실 없어도 된다. 당연히 스프링 앱에는 Root WAS를 등록하는 ContextLoaderListener가 필요한 것 같지만 실제론 강제되지 않는다. 대신 서블릿 레벨의 WAC에 웹용 빈들은 물론이고 서비스, DAO 계층의 빈들도 모두 넣으면 된다. 실제로 여러 개의 DispatcherServlet이 만들어질 것이 아니라면 Root WAC를 따로 등록하나 아니나 그게 그거다.

DispatcherServlet 단위의 WAC는 보통 서블릿이름-servlet.xml이라는 이름규칙을 가지고 설정파일을 찾는다. 하지만 이는 얼마든지 바꿀 수 있다. 서블릿 이름과 상관없이 직접 지정한 여러 개의 설정파일을 읽도록 만들어도 그만이다.

아래는 내가 스프링 웹 기능 테스트용으로 만든 애플리케이션의 web.xml 설정의 일부다. init-param이 없다면 action-servlet.xml 만을 읽겠지만, 직접 지정해주면 지정된 설정파일을 사용한다.

<servlet>
    <servlet-name>action</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>
            /WEB-INF/applicationContext.xml
            /WEB-INF/action-servlet.xml
        </param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
</servlet>

물론 ContextLoaderListener는 필요없다. 결국 하나의 WAC만 만들어진다. 이 경우는 DispatcherServlet이 만든 WAS가 설정파일 계층구조에서 Root위치를 차지한다. 결국 Root WAC가 되는 것이다.

 

나는 특별한 이유가 아니라면 후자의 방법을 사용할 생각이다. 사실 WAC가 두 개 만들어지기 때문에 발생하는 매우 미묘한 문제가 많다는 것을 그동안 KSUG에 올라온 질문을 통해서 많이 확인했다. 두 개의 parent-child 구조의 WAC이기 때문에 AOP의 적용부터, 상호참조를 비롯해서 만은 부분에서 혼란을 줄 수 있다. 그럴 바에는 차라리 WAC를 하나만 만드는 게 속편하지 않을까? 빈 스캐닝도 excludes 같은 옵션 없이 한방에 해도 된다. 물론 개발과 테스트에서는 원하는 XML파일의 조합을 가져가게 해도 되니까 SoC와 의존관계 관리에서는 문제가 없다.

 

이렇게 DispatcherServlet 단독 WAC 방식을 사용했을 때 한가지 문제가 발생한다. 그것은 바로

WebApplicationContextUtils.getWebApplicationContext()을 그냥 사용하지 못한다는 점이다.

WebApplicationContextUtils.getWebApplicationContext는 매우 유용한 유틸리티 메소드이다. 스프링 빈이 아닌 뷰(JSP…)나 스프링 빈이 아니고 AC에 바로 접근 불가능한 Struts같은 코드에서 스프링의 빈을 사용하고 싶을 때 ServletContext만 있다면 파라미터로 제공해서 바로 WAC를 가져올 수 있고, 이를 이용해서 getBean()해주면 스프링 빈에 접근 가능하다.

그런데 이 WebApplicationContextUtils.getWebApplicationContext()가 돌려주는 사실 WAC는 듀얼 WAC 방식이라면 ContextLoaderListener가 등록해주는 Root WAC뿐이다. 즉, DispatcherServlet이 등록한 WAC를 돌려주지 않는다. 게다가 계층구조의 ROOT이므로 하위 WAC에 접근하지도 못한다. 내가 아는 한 방법이 없다.

첫 번째 문제는 스프링 밖의 코드에서 WebApplicationContextUtils.getWebApplicationContext()를 통해서 DispatcherServlet의 WAC에 접근하는 것이 가능한가 이다.

두 번째도 사실 비슷한데 듀얼 WAC방식이 아니라 DispatcherServlet에 등록된 WAC만 생성하는 경우에 이 WAC를 찾는 일이다. 이 때는 DS의 WAC가 Root WAC라고 했으니 당연히 WebApplicationContextUtils.getWebApplicationContext()로 가져올 수 있을 것 같다. 하지만 해보면 못가져온다. null만 리턴될 뿐이다. WebApplicationContextUtils.getWebApplicationContext()는 결국 ContextListenerLoader가 등록한 WAS밖에 못돌려준다. 설령 Root WAC로 등록되도 말이다.

따라서 WebApplicationContextUtils.getWebApplicationContext()가 Root WAC를 돌려준다는 것은 반만 맞는 얘기다.

 

이 문제를 해결하려면 WAC가 어떻게 ServletContext를 통해서 찾아지는지 그 원리를 알아야 한다. 스프링이 만드는 WAC는 모두 ServletContext의 attribute로 등록된다. ContextLoaderListener가 만드는 것은 웹 애플리케이션 당 하나뿐이다. 따라서 항상 일정하게 찾을 수 있다.

반면에 DS가 만드는 WAC는 한 개 이상이 등록가능하다. 따라서 다른 방식으로 저장되기 때문에 WebApplicationContextUtils.getWebApplicationContext가 바로 찾지 못하는 것이다. 이 WAC를 찾으려면 그에 맞는 attribute name을 파라미터에 전달해줘야 한다.

WebApplicationContextUtils.getWebApplicationContext(ServletContext) 메소드는 이미 그 Attr. name을 알고 있다. 따라서 그 이름을 신경쓰지 않는다. 바뀔 염려도 없다.

DS의 WAS는 다른 이름으로 등록된다. 따라서 attr name을 지정해줘야 한다.

이 이름을 찾는 방법은 다음과 같이 작성 하면 된다.

FrameworkServlet.SERVLET_CONTEXT_PREFIX + 서블릿이름

DS의 WAC는 FrameworkServlet.SERVLET_CONTEXT_PREFIX에 지정된 문자열에 서블릿이름을 추가해서 저장한다. 그래서 여러 개의 DS가 등록되도 서로 충돌없이 찾을 수 있다. 서블릿이름이 action이라면 다음과 같이 하면 WAC를 찾을 수 있다.

String attr = FrameworkServlet.SERVLET_CONTEXT_PREFIX + "action";
WebApplicationContext ac = WebApplicationContextUtils.getWebApplicationContext(sc, attr);

저 FrameworkServlet은 참 친근한 이름이다. OS도 만든다는 유명한 땡땡사의 네모네모 프레임워크를 역컴파일 한 소스를 Y군이 검토해달라고 보여준 적이 있는데, 그 안에보니 당당하게도 스프링의 FrameworkServlet을 비롯한 핵심 클래스를 그대로 배껴서 사용하고 있는 것을 확인했다. 과연 아파치 라이선스를 제대로 지켜서 코드의 사용여부를 밝히고 제품을 팔아먹고 있는지 그것이 궁금했지만, 애정이 없는 관계로 사실확인까지는 못해봤다. 단, 기억나는 것은 기술영업사원들이나 엔지니어들이 "스프링과 같은 오픈소스는 불안해서 못쓴다. 세계적인 기술력을 가진 우리 제품을 쓰라"고 얘기하고 다녔다는 것. 그런데 몰래 오픈소스를 배껴쓰나. 라이선스도 허용되는데 당당히 가져다 쓰지 패키지이름은 싹 바꾸고 자기들이 만든 것처럼 넣었다는 데서 살짝 짜증이 나기도 했다. 아무튼 그때 발견했던 대표적인 스프링 클래스가 바로 저 FrameworkServlet이다. 그 안에 attr. name에 쓸 문자열이 담겨있다는 것. 왜 저 클래스의 이름을 쓰냐 하면 바로 DispatcherServlet의 상위 클래스이기 때문이다.

아무튼 문제 해결.

 

보너스로 하나 더.

Spring 3.0에선 XML이 전혀 없는 웹 애플리케이션을 간단히 만들 수 있다. 물론 2.5부터 가능했던 것이지만 더 간편해졌다. @Configuration을 이용한 코드에 의한 빈 등록이 가능하기 때문이다. 스프링이 주류 기술이 된 이후로 스프링을 공격하는 많은 안티들이 생겼다. 그들의 주장의 99%쯤은 근거에 없는 비방이거나(스프링은 단일 DB밖에 못쓴다는 둥, JCA는 안된다는 둥, 단지 MVC 프레임워크라는 둥) 아니면 2004년에 나온 스프링 1.0에나 해당되는 철지난 기능에 대한 비난이다. 마치 자바는 느려서 실전에서는 못쓴다는 얘기나 하이버네이트는 네이티브 SQL을 쓸 수 없어서 쓰면 안된다는 것과 비슷한 수준이다.

이들 비난의 대표선수들은 "스프링은 XML 설정밖에 안되서 XML hell" 이라는 것과 "스프링은 Stateless 방식 밖에 안된다"는 것이다.

간단히 반박하자면 스프링은 초기부터 지금까지 단 한번도 XML설정으로 설정방식이 제한된 적이 없다. 애초부터 설정정보는 설정포맷에 독립적으로 설계되었고 리더만 만들어주면 어떤 방식으로든 빈 설정을 만들 수 있었다. 비공식적으로는 1.2시절부터(스캐닝 방식은 2.0 시절부터 있었고), 공식적으로는 2.1이 만들어지는 시점부터 애노테이션을 통한 설정을 지원했고, 이미 그 이전에 로드 존슨이 직접 시작한 JavaConfig을 통해서 자바코드에 의한 설정도 지원했다. 이미 최신 스프링 앱에서는 XML의 사용이 극소화 되었고, 스프링의 설정방식에 대한 확장기능을 통해서 점점 더 단순해지고 있다.

또한 스프링은 2.0부터 Scope개념을 지원해서 원하는 단위와 방식의 conversation과 stateful bean을 가지는 애플리케이션을 만들 수 있도록 지원해오고 있다. 벌써 4년이 넘었다. Scope를 가지고 stateful app용인 SWF도 만들고, 데이터그리드로 확장해서 Coherence-Spring도 만들고, 심지어 Seam은 스스로 스프링에서 동작하도록 하는 기능도 만들었다. Stateful app에서 사용할 수 있도록 JPA의 extended EntityManager도 사용하도록 오래전부터 지원하고 있다. 그런데도 아직도 스프링은 statelesss 밖에 안된다고 떠들고 다니는 사람은 사기꾼 아니면 자신이 비판하는 대상에 대한 최소한의 확인의지 조차없는 나태한 사람일 뿐이다.

얘기가 샜는데.. 아무튼 3.0의 비 XML 설정방식을 적극 사용하겠다고 하면 정말이지 모든 설정에서 XML을 완벽하게 제거할 수 있다. 이미 컴포넌트 스캔이나 @Autowired는 잘 알려진 기법이다. 하지만 DataSource 같은 서비스 빈의 등록은 설정자체가 중요하기 때문에 보통 XML로 남겨둔다. 애노테이션을 극대화 해서 쓴다는 다른 기술도 사실은 그런 환경과 관련된 설정을 위해서 이런 저런 XML을 잔뜩 사용하기 일수다. 그나마 서비스나 인프라스트럭처 빈도 대부분 namespace와 전용 tag를 제공하고 있기 때문에 <bean>과 같은 식으로 등록할 일도 거의 없다.

하지만 원하면 그것도 XML을 사용하지 않도록 할 수 있다. @Configuration을 이용한 자바코드에 의한 설정은 사실 그런 용도로 유용하다. 코드에 의한 설정이므로 얼마든지 원하는 방식으로 3-rd party 빈을 정의할 수 있다.

결국 그런 방식까지 쓴다면 아예 웹 애플리케이션에서 WAC가 필요로 하는 XML파일의 등록을 없앨 수 있다.

원래 DS나 ContextLoader가 만드는 WAC는 XmlWebApplicationContext이다. 물론 디폴트일 뿐이다. 스프링은 언제나 그렇듯이 간단히 확장하면 된다. ListenerContextLoader나 DispatcherServlet이 사용할 contextClass 값을 AnnotationConfigWebApplicationContext으로 바꿔주기만 하면 된다. 그러면 XML은 아예 생략할 수 있다. 결국 web.xml을 제외한 모든 스프링 XML을 웹 애플리케이션에서 제거하는 것이 가능하다. 이 WAC는 JSR-330으로 표준화된 @Inject도 지원한다.

 

블로그를 너무 오래 방치해 두는 것 같아서 시작한 끄적거리기는 여기서 끝.

사실은 요즘 마무리 하고 있는 스프링 3.0 책의 AC설정 부분에 나오는 내용을 조금 배낀 것이다. 책에는 이보다 훨씬 친절하게 AC 등록방법, 설정방법과 선택에 관한 설명이 나올 것이다.

스프링AOP의 핵심 동작원리는 BeanPostProcessor를 이용한 자동 프록시 생성방법이다. JDK다이나믹 프록시(내지는 CGLib프록시)를 적용하기 위해서는 프록시 생성 대상이 필요하고 이를 선정하는 작업이 BPP를 통해서 일어난다.

Pointcut은 ProxyFactoryBean을 사용해서 직접 타겟(또는 타겟소스)을 지정하지 않는 이프 록시 자동생성의 대상을 선정하는 작업에도 포인트컷터로서 참여한다. 

Pointcut의 생김새는 다음과 같다.

public interface Pointcut {

    ClassFilter getClassFilter();

    MethodMatcher getMethodMatcher();

}

클래스 필터 하나 메소드 매처 하나로 이루어져있다. 메소드 매처는 다시 파라미터를 확인하는지 여부에 따라 스태틱과 다이나믹으로 구분된다.

어쨌든 이것만 보면 자동프록시생성에 참여하는 것은 포인트컷의 ClassFilter일 겄으로 생각하기 쉽다.

하지만 아니다.

중요한 것은 최종 Advice를 적용할 대상 메소드가 존재하는지 여부이기 때문이다. 따라서 자동프록시 생성기들도 클래스필터를 통과했다고 해도 다시 메소드 단위까지 MethodMatcher를 적용해보고 최소한 하나이상의 메소드가 match가 되야 프록시 생성 대상으로 삼는다. AOPUtil의 canApply() 메소드를 살펴보면 이런 작업과정을 파악할 수 있다.

그런데 2.0이전의 유일한 방식이었던 스프링 어드바이저를 이용한 오토프록싱 방식에서는 이 클래스필터가 제법 필터로서 역할을 했다. 사실 클래스필터에서 걸러지지 않으면 메소드매처는 시도조차 할 필요가 없다. 빈 이름을 필터로 사용하는 BeanNameAutoProxyCreator나 커스톰 어드바이저의 클래스필터를 적용하는 DefaultAdvisorAutoProxyCreator등은 당연히 ClassFilter가 중요한 역할을 차지한다.

 

하지만 AspectJ Pointcut Expression을 사용하기 시작한 2.0부터는 조금 다르다. 포인트컷팅을 AspectJ PE에 의지하기 때문이다. 물론 AspectJExpressionPointcut을 이용하기 때문에 역시 Pointcut 인터페이스를 구현한 것은 마찬가지이다. 따라서 ClassFilter가 분명 존재한다. 하지만 AspectJ PE의 경우는 ClassFilter가 우리가 예상할 수 있는 클래스 필터와 다르게 동작한다.

이 ClassFilter는 내부적으로 PointcutExpression의 couldMatchJoinPointsInType()이라는 메소드를 한번 확인할 뿐이기 때문이다. 이 메소드는 단지 이 타입에 조인포인트를 매치해볼 수 있느냐는 확인밖에 안해준다. 스프링AOP는 메소드 실행이 유일한 조인포인트이다. 따라서 메소드가 존재하면 무조건 이 테스트는 통과한다.

따라서 포인트컷 식이 “execution(* a.b.c.XYZ.*(..))"라고 되어있다고 할지라도 XYZ클래스가 아닌 타겟에 대해서도 ClassFilter의 match()는 true를 리턴한다. 결국 포인트컷 표현식가지고 메소드 단위로 적용해보는 MethodMatcher에서야 클래스의 비교까지도 이뤄진다는 점이다.

스프링의 전통적인 Pointcut과 AspectJ EL의 차이점 때문에 발생하는 문제이다. 하지만 ApsectJ EL을 사용해서 스프링의 자동프록시생성 방식을 절묘하게 결합시키는 것을 가능하게했다는 것은 재밌는 일이다.

Pointcut, Advice, Advisor, AutoProxyCreator등의 잘 짜여진 추상화된 모델의 뛰어난 확장성 덕분인듯 싶다.

@Aspect를 사용한 경우라도 내부적으로는 스프링AOP의 자동프록시생성방식을 따르는 AnnotationAwareAspectJAutoProxyCreator에 의해서 프록시가 만들어진다는 것. 이 AutoProxyCreator는 시드니대 88학번인 로드존슨이 이미 2003년에 만든 것이다. AspectJ가 한참 적용되고 발전한 지금까지도 6년째 이 설계는 변하지 않고 잘 적용되고 있다.

 

스프링AOP의 구조와 동작방식을 이해하려면 Profession Spring Programming을 보는 것이 좋다. 내용은 가장 어렵지만 알고나면 짜릿하다. 1.x 때의 내용이지만 2.0이후 지금까지의 스프링AOP의 모든 발전과정을 이해하는데도 아무런 지장이 없을만큼 그 동작원리는 여전하게 유지되고 있다. 나에게 OO설계의 참 맛을 느끼게 해준 대표적인 모델이다.

어제 글에 이어서.

KSUG 메일링리스트에 나머지 두가지 방법을 한번 찾아보라고 글을 올렸는데, 스프링 고수 두 분이 답을 주셨다. 대단한 분들이다.

 

@Configuration을 적용하는 방법을 여러가지로 시도해보려고 한 이유는 두가지인데, 하나는 스프링 책에 들어갈 예제에 써먹기 위해서이고(XML 없이 자바코드로만 스프링을 사용하는 예제가 필요했다), 다른 하나는 이전 글에서 이야기 한 것처럼 XML에 annotation-config 설정만으로 이 기능이 적용되는지가 궁금했기 때문이다.

 

레퍼런스에 나온 방법은 XML에 annotation-config 태그를 다는 것이고, @Configuration에 대한 스프링 내부의 테스트 코드에는 두번째로 이야기한 DefaultListableBeanFactory와 ConfigurationClassPostProcessor를 이용하고 있다.

 

ApplicationContext

세번째로 해볼 것은 이 BeanFactory를 이용한 방법을 ApplicationContext를 이용하는 것으로 바꾸는 것이다. BeanFactory는 스프링의 핵심이긴 하지만, 실전에서 직접 사용되는 경우가 거의 없다. 왜냐하면 이를 확장한 ApplicationContext를 쓰면 되기 때문이다.

두가지를 비교해보면 ApplicationContext는 인터페이스이고, 이는 BeanFactory인터페이스를 상속한 것이다. 즉 ApplicationContext는 BeanFactory이다. List가 Collection인것과 마찬가지이다.

그래서 쉽게 생각하기를 ApplicationContext를 구현한 클래스는 마찬가지로 BeanFactory를 구현한 클래스를 상속해서 기능을 확장해서 쓸 것이라고 생각하기 쉽다.

스프링의 IoC기능을 담당하는 BeanFactory를 가장 완벽하게 구현한 클래스는 DefaultListableBeanFactory이다. BeanFactory가 독자적으로 XML파일을 읽고 초기화 될 수있도록 이를 한번 더 확장한 XmlListableBeanFactory가 있기는 하지만, 이건 거의 테스트용도 외에는 사용되지 않는다.

그럼 상식적으로 생각했을 때 BeanFactory의 기능을 모두 포함하며 기능을 더 확장한 ApplicationContext의 구현 클래스들은 바로 이 DefaultListableBeanFactory를 상속해서 만들 것이라고 생각할 수 있다. 실제로 ApplicationContext는 BeanFactory인터페이스를 바로 상속하는 것이 아니고, 그 확장 판인 ListableBeanFactory인터페이스를 확장한다. 그 말은 ListableBeanFactory인터페이스 구현의 결정판인 ListableBeanFactory의 기능이 ApplicationContext에서 모두 요구된다는 것이다.

재밌는 것은 이 ApplicationContext를 바로 구현한 클래스는 없다는 점이다. 그것을 다시 확장한 ConfigurableApplicationContext 인터페이가 있고 이 것을 구현한 AbstractApplicationContext가 있을 뿐이다.

 

이를 BeanFactory쪽과 비교해서 보면 이해하기 쉬운데,

ApplicationContext 는 ListableBeanFactory를 상속했다.

ApplicationContext의 서브 인터페이스인 ConfigurableApplicationContext 는 ListableBeanFactory의 서브 인터페이스인 ConfigurableApplicationContext를 상속했다. 이 ConfigurableApplicationContext를 구현하지 않는 ApplicationContext는 존재하지 않는다. 따라서 실제로 ApplicationContext라고 불리는 모든 것들은 사실 ConfigurableApplicationContext라고 불러도 그만이다.

ConfigurableApplicationContext를 BeanFactory계층과 연결해보자. ApplicationContext는 최소한 ListableBeanFactory를 구현해야 한다. ConfigurableApplicationContext도 인터페이스의 상속구조만 보자면 ListableBeanFactory의 하위 인터페이스인 ConfigurableListableBeanFactory을 구현하도록 되어있어야 하는데 실제 정의를 보면 그렇지 않다. 이름은 Configurable~인데 Configurable한 BeanFactory 인터페이스를 상속하지 않는다? 뭔가 이상해보인다.

 

하지만 여기서 매우 중요한 설계적인 결정사항을 발견할 수 있다. 그것은 인터페이스의 상속구조가 아니라 메소드의 정의에서 ConfigurableListableBeanFactory이 등장한다는 점이다.

ConfigurableApplicationContext에는 다음과 같이 정의된 메소가 보인다.

ConfigurableListableBeanFactory getBeanFactory() throws IllegalStateException;

결국 무슨 얘기냐 하면 ApplicationContext가 BeanFactory(정확히는 ListableBeanFactory)인터페이스의 계층구조와 병렬적으로 상속구조를 가지고 발전하는게 아니라, 사실은 위임구조로 변신한다는 것이다. ConfigurableListableBeanFactory, 즉 사실상 스프링의 BeanFactory가 실질적으로 구현해야 하는 핵심인터페이스의 구현 부분은 ApplicationContext 인터페이스의 계층구조에서 나타나지 않고, 이를 내부에 참조할 수 있는 구조로 바꾸었다는 것이다. 바로 저 메소드가 그 증거이다. ConfigurableApplicationContext는 이제 직접 ConfigurableListableBeanFactory을 구현하지 않고, 내부에 ConfigurableListableBeanFactory을 구현한 객체를 프로퍼티로 가지고 있다가 이를 반환할 수 있기만 하면 되도록 했기 때문이다. 그 말은 또 이미 상속구조를 가지는 ListableBeanFactory의 인터페이스의 메소드들은 모두 내부의 ConfigurableListableBeanFactory 구현 객체에 위임(delegate)하는 구조로 되어있어야 한다는 것이다.

 

결론적으로 ApplicationContext와 BeanFactory의 관계는 상속(is-a) 관계라기보다는 위임(delegate) 관계이다. 모든 ApplicationContext는 BeanFactory 정확히는 ConfigurableListableBeanFactory를 구현한 객체를 내부에 가지고 있다는 것을 기억하자. 싫으면 말고.

모든 ApplicationContext구현 클래스의 수퍼클래스는 AbstractApplicationContext이고 추상클래스이므로 실전에서 쓰일 수 있도록 이를 상속한 클래스는 AbstractRefreshableApplicationContext(이것도 추상이네)와 GenericApplicationContext뿐이다. 둘다 내부에 BeanFactory의 대표주자인 DefaultListableBeanFactory(ConfigurableListableBeanFactory을 구현한)을 가지고 있는데, 차이는 전자는 refresh(!)가 가능하고, 후자는 final로 고정이라는 것이다. 그런데 스프링의 모든 Generic~이 붙는 클래스는 사실상 실전용이 아니다. 대부분 테스트나 특별한 경우에 내부적으로 사용하도록 만든 별볼일 없는 것들이다. 대체로 무시해도 좋다. 결국 남은 것은 AbstractRefreshableApplicationContext이고 여기서 쭉 이어져 내려가서 ClasspathXmlApplicationContext도 나오고 XmlWebApplicationContext도 나오고 하는 것이다.

 

아무튼 이 스프링의 핵심인 BeanFactory-ApplicationContext의 구조는 등장하는 인터페이스와 클래스만 한 100개는 되는 것 같은데, 들여다보면 참 재밌다. 그림을 하나 그려보고 싶긴한데, 귀찮아서.

 

서론이 길었는데, 그래서 앞에서 DefalutListableBeanFactory로 했던 @Configuration적용을 ApplicationContext를 전면에 등장하는 것으로 바꾸어보자.

먼저 GenericApplicationContext의 오브젝트를 만든다. Generic~이 붙은 건 바로 이럴 때 써먹는 것이다. ClasspathXml~ 이런걸 쓰려면 XML을 뭔가 만들어야 하니까. 실전에서는 당연히 그렇겠지만, 이렇게 간단하게 테스트 해볼 때는 Generic~, Static~이런 것들을 쓰면 된다.

GenericApplicationContext ctx = new GenericApplicationContext();

그리고 @Configuration이 붙은 설정용 팩토리 클래스를 등록해주는데, 이것은 원래 DefaultListableBeanFactory가 구현한 BeanDefinitionRegistry의 메소드이다. 따라서 ApplicationContext에서 저 빈 팩토리를 꺼내서 사용해야 하는데, 문제는 내부적으로는 DefaultListableBeanFactory클래스 타입으로 정의되어있지만 AC의 getBeanFactory에 돌려주는 타입은 그냥 ConfigurableListableBeanFactory이다. 그럼 이 오브젝트의 정체를 아니까 BeanDefinitionRegistry인터페이스로 캐스팅을 하든, DefaultListableBeanFactory 클래스 타입으로 캐스팅을 해야 두번째 방법에서 썼던 것처럼 registerBeanDefinition을 쓸 수 있다. 지저분해지는 느낌.

그러나 친절한 스프링 개발자들은 GenericApplicationContext가 BeanDefinitionRegistry를 구현하도록 하고, 내부의 DefaultListableBeanFactory에 위임하도록 만들어 놨다. 따라서 설정 클래스 등록을 바로 할 수 있다.

따라서 DefaultListableBeanFactory를 썼을 때와 마찬가지로

ctx.registerBeanDefinition("config", new RootBeanDefinition(Config.class));

하면 된다. 그럼 내부의 DefaultListableBeanFactory에 빈 설정용 클래스 빈 등록이 완료 된다.

 

다음은 ConfigurationClassPostProcessor을 적용할 차례이다. 사실 여기서 두가지 방법으로 나뉠 수가 있는데, 그렇게 분리하다보면 4가지가 아니라 한 10가지쯤 될 것 같아서 그냥 한가지 방법으로 묶고 두가지 세부 방법으로 나누도록 한다.

첫번째 방법은 DefaultListableBeanFactory를 꺼내올 수 있으니, BeanFactory에서 수동으로 하듯이 팩토리 후처리기(BeanFactoryPostProcessor)를 만들고 여기의 후처리 작업에 빈 팩토리를 넣어서 돌려주는 것이다. ApplicationContext로부터 시작했지만 내부의 BeanFactory를 노출시키는 방법이다.

ConfigurationClassPostProcessor pp = new ConfigurationClassPostProcessor();
pp.postProcessBeanFactory(ctx.getBeanFactory());

AC에서 빈팩토리를 꺼내는 것만 다르고 이전 방법이랑 같다.

 

두번째 방법은 ApplicationContext의 자동화기능을 이용하는 것이다. AC는 BF에 비해서 자동으로 해주는 것이 많다. 싱글톤의 인스턴스 생성도 자동으로 해주고, 각종 포스트프로세서의 처리도 자동으로 해준다. AC에 필요한 것을 등록해주기만 하면 된다.

그래서 이번에는 ConfigurationClassPostProcessor을 만들어서 AC에 등록해주는 방법을 쓰도록 한다.

ctx.addBeanFactoryPostProcessor(new ConfigurationClassPostProcessor());

이렇게 등록한 PP는 refresh를 해주는 순간 모두 적용된다. refresh는 대략 13가지 종류의 초기화 작업을 해주는데 자세한 것은 나중에.. 그중에 하나가 바로 invokeBeanFactoryPostProcessors 작업이다. BFPP적용.

그래서 등록 후에 한번

ctx.refresh();

해주면 된다.

그러면 모든 작업 끝.

 

테스트 코드를 좀 모아보면, 첫번째 방법은

GenericApplicationContext ctx = new GenericApplicationContext();
ctx.registerBeanDefinition("config", new RootBeanDefinition(Config.class));
ConfigurationClassPostProcessor pp = new ConfigurationClassPostProcessor();
pp.postProcessBeanFactory(ctx.getBeanFactory());
String hello = ctx.getBean("hello", String.class);
assertThat(hello, is("Hello"));

두번째 방법은

GenericApplicationContext ctx = new GenericApplicationContext();
ctx.registerBeanDefinition("config", new RootBeanDefinition(Config.class));
ctx.addBeanFactoryPostProcessor(new ConfigurationClassPostProcessor());
ctx.refresh();
String hello = ctx.getBean("hello", String.class);
assertThat(hello, is("Hello"));

이다.

 

참, 정상혁님이 제안해주신 한가지 방법이 더 있는데 그것은 GenericApplicationContext의 하위 클래스인 StaticApplicationContext를 사용하고 registerBeanDefinition 대신에 registerSingleton을 이용하는 것이다. 내부적으로 registerSingleton은 GenericBeanDefinition을 만들어서 registerBeanDefinition을 호출한다.

ctx.registerBeanDefinition("config", new RootBeanDefinition(Config.class)); 

이걸 다음과 같이 바꿀 수 있다는 것이다.

ctx.registerSingleton(“config”, Config.class);

결국 그거나 그거나. 용도로 보자면 GenericApplicationContext는 실전에서는 별로 쓸 일 없는 그냥 일반적인 AC기능을 최소한 모아놓은 것이고, StaticApplicationContext는 거기다가 BeanDefinition을 읽어오는 방식말고 코드에서 직접 빈을 등록하는 방법을 추가해준 것이다. 둘다 이런 학습적인 용도와 테스트에만 쓴다. 아직까지는 실전에서 쓰는 것을 본 일이 없다.

 

 

휴.. 내용이 길어졌으니 네번째 방법은 내일 다시.

© 2017 Toby's Epril Suffusion theme by Sayontan Sinha