Spring MVC 부모-자식 컨텍스트 중복 빈(Bean) 생성으로 인한 NullPointerException 분석 및 해결

Spring MVC 기반 프로젝트에서 `@Service`, `@Controller`, `@Autowired`와 같은 어노테이션은 설정의 번거로움을 줄여주는 매우 유용한 도구입니다. 하지만 어노테이션의 작동 원리와 Spring 컨텍스트의 계층 구조를 정확히 이해하지 못하고 사용하면, 예상치 못한 런타임 오류에 직면할 수 있습니다. 특히 특정 클래스에 `@Service` 어노테이션을 추가했을 때 발생하는 `NullPointerException` 사례를 통해 빈(Bean) 주입 과정의 함정을 분석해 보겠습니다.

발생 현상: 프로퍼티 주입 실패와 NPE

외부 HTTP API를 호출하는 기능을 구현하고 XML 설정을 통해 URL과 타임아웃 등의 속성을 주입했습니다. 로컬 단위 테스트는 통과했으나, 실제 서버 환경에 배포하자마자 `NullPointerException`이 발생했습니다.
ERROR [qtp-thread-1] c.e.utils.HttpRemoteClient.execute(HttpRemoteClient.java:120) - Target host is not specified
java.lang.NullPointerException: null
    at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:186)
    ...
이상한 점은 XML 파일에 명확히 프로퍼티 값이 설정되어 있었음에도 불구하고, 실제 런타임에서는 해당 필드가 `null`이었다는 점입니다. 조사 결과, 해당 클래스 선언부에 `@Service` 어노테이션이 붙어 있을 때만 문제가 발생하고, 이를 제거하면 정상적으로 작동하는 것을 확인했습니다.

문제 진단: 중복된 빈 인스턴스

어노테이션 유무에 따라 동작이 달라지는 원인을 파악하기 위해 `jmap` 도구를 사용하여 메모리 내의 객체 인스턴스 개수를 확인했습니다.
$ jmap -histo:live [PID] | grep ExternalApiService
1024:             2            128  com.example.service.impl.ExternalApiServiceImpl
Spring의 빈은 기본적으로 싱글톤(Singleton)으로 관리되어야 함에도 불구하고, 메모리상에 동일한 클래스의 인스턴스가 2개 존재하는 것을 발견했습니다. 힙 덤프(Heap Dump)를 분석한 결과, 각 인스턴스의 상태는 다음과 같았습니다.
  • Instance A: XML 설정을 통해 프로퍼티(URL 등)가 정상적으로 주입된 상태.
  • Instance B: `@Service` 어노테이션에 의해 기본 생성자로 생성되어 프로퍼티가 모두 null인 상태.
더 큰 문제는 DispatcherServlet이 실제로 요청을 처리할 때 속성 값이 비어 있는 Instance B를 참조하고 있었다는 점입니다.

원인 분석: 컨텍스트 계층 구조와 스캔 범위

이 현상의 근본 원인은 web.xml에 정의된 ContextLoaderListenerDispatcherServlet의 관계, 그리고 잘못된 컴포넌트 스캔(Component Scan) 설정에 있습니다. Spring MVC 웹 애플리케이션은 보통 두 종류의 컨텍스트를 가집니다.
  1. Root WebApplicationContext: ContextLoaderListener에 의해 생성되며, 서비스나 저장소(DAO) 등 비즈니스 로직 빈을 관리합니다.
  2. Child WebApplicationContext: DispatcherServlet에 의해 생성되며, 컨트롤러와 뷰 리졸버 등 웹 관련 빈을 관리합니다. 부모 컨텍스트를 참조할 수 있습니다.
문제는 두 설정 파일(applicationContext.xmlservlet-context.xml) 모두에서 동일한 패키지 범위를 스캔하도록 설정되어 있었다는 점입니다. root-context.xml (applicationContext.xml):
<context:component-scan base-package="com.example.service" />
<!-- XML을 통한 명시적 빈 설정 -->
<bean id="externalApiService" class="com.example.service.impl.ExternalApiServiceImpl">
    <property name="apiUrl" value="https://api.example.com" />
</bean>
servlet-context.xml:
<!-- 자식 컨텍스트에서도 동일 패키지 스캔 -->
<context:component-scan base-package="com.example" />
이 과정에서 DispatcherServlet은 패키지를 스캔하다가 `@Service`가 붙은 클래스를 발견하고 별도의 빈(Instance B)을 직접 생성합니다. 자식 컨텍스트는 부모 컨텍스트에 이미 설정된 빈이 있더라도 자신의 영역 내에 빈이 존재하면 이를 우선적으로 사용하기 때문에, 결국 설정이 누락된 "껍데기 빈"이 주입되어 NPE가 발생한 것입니다.

해결 방안: 스캔 범위 격리 및 필터링

가장 권장되는 해결책은 부모와 자식 컨텍스트가 스캔하는 어노테이션 대상을 명확히 분리하는 것입니다. 1. servlet-context.xml 설정 변경 자식 컨텍스트인 DispatcherServlet은 `@Controller`만 스캔하도록 제한합니다.
<context:component-scan base-package="com.example" use-default-filters="false">
    <context:include-filter type="annotation" expression="org.springframework.stereotype.Controller" />
</context:component-scan>
2. applicationContext.xml 설정 변경 부모 컨텍스트에서는 컨트롤러를 제외한 서비스, 저장소 등을 스캔합니다.
<context:component-scan base-package="com.example">
    <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller" />
</context:component-scan>
use-default-filters="false" 설정을 활용하면 스캔 대상을 더욱 엄격하게 제어할 수 있습니다. 이를 통해 중복 빈 생성을 방지하고, XML 설정과 어노테이션 설정이 충돌하는 상황을 예방할 수 있습니다.

결론

Spring MVC에서 어노테이션 기반 설정은 편리하지만, 컨텍스트의 계층 구조를 무시한 무분별한 컴포넌트 스캔은 빈 중복 생성과 데이터 불일치를 초래합니다. 특히 XML 설정과 어노테이션을 혼용할 때는 어떤 컨텍스트가 어떤 빈을 소유하는지 명확히 정의하는 것이 중요합니다. jmap과 같은 도구를 활용해 런타임 객체 상태를 점검하는 습관은 이러한 복잡한 의존성 문제를 해결하는 데 큰 도움이 됩니다.

태그: SpringMVC WebApplicationContext ComponentScan NullPointerException java

9월 20일 02:36에 게시됨