SpringBootTest에서 생성자 주입 시 @Autowired를 명시해야하는 이유

2024. 2. 25. 18:52자바/스프링

728x90
반응형

 

테스트에서 생성자 주입을 인식하지 못한다?


 

컨트롤러 테스트 코드 개선을 진행하다 다음과 같은 예외를 발견했습니다.

 

ParameterResolution 예외 발생

 

해당 예외는 테스트를 실행하자마자 발생했고, 내용을 확인해보니 파라미터를 제대로 등록하지 못했다는 내용이 적혀있었습니다. 빈 주입 과정이 올바르게 동작하지 못한다고 생각해 곧바로 테스트 코드의 필드와 생성자 부분을 확인했습니다. 

 

문제가 발생한 테스트 코드의 필드와 생성자

 

생성자는 하나만 사용하고 있었고, 파라미터로 전달받는 값들 또한 모두 빈으로 등록되어있었습니다.

 

 

스프링 빈 생성자 주입


 

스프링에서 생성자를 하나만 작성하고, 그 파라미터가 모두 빈으로 등록이 되어있다면 @Autowired를 명시하지 않아도 빈 주입이 가능하다고 공식문서에서는 설명하고 있습니다.

생성자 주입 시 생성자가 하나인 경우 @Autowired를 생략해도 된다.

 

 

실제 Autowired가 명시된 생성자를 찾아서 등록하는 코드는 빈 후처리기(BeanPostProcessor)에 있습니다. 다음은 AutowiredAnnotationBeanPostProcessor 클래스에서 Contructor를 등록하는 메소드입니다.

	@Override
	@Nullable
	public Constructor<?>[] determineCandidateConstructors(Class<?> beanClass, final String beanName)
			throws BeanCreationException {

		checkLookupMethods(beanClass, beanName);

		// Quick check on the concurrent map first, with minimal locking.
		Constructor<?>[] candidateConstructors = this.candidateConstructorsCache.get(beanClass);
		if (candidateConstructors == null) {
			// Fully synchronized resolution now...
			synchronized (this.candidateConstructorsCache) {
				candidateConstructors = this.candidateConstructorsCache.get(beanClass);
				if (candidateConstructors == null) {
					Constructor<?>[] rawCandidates;
					try {
						rawCandidates = beanClass.getDeclaredConstructors();
					}
					catch (Throwable ex) {
						throw new BeanCreationException(beanName,
								"Resolution of declared constructors on bean Class [" + beanClass.getName() +
								"] from ClassLoader [" + beanClass.getClassLoader() + "] failed", ex);
					}
					List<Constructor<?>> candidates = new ArrayList<>(rawCandidates.length);
					Constructor<?> requiredConstructor = null;
					Constructor<?> defaultConstructor = null;
					Constructor<?> primaryConstructor = BeanUtils.findPrimaryConstructor(beanClass);
					int nonSyntheticConstructors = 0;
					for (Constructor<?> candidate : rawCandidates) {
						if (!candidate.isSynthetic()) {
							nonSyntheticConstructors++;
						}
						else if (primaryConstructor != null) {
							continue;
						}
						MergedAnnotation<?> ann = findAutowiredAnnotation(candidate);
						if (ann == null) {
							Class<?> userClass = ClassUtils.getUserClass(beanClass);
							if (userClass != beanClass) {
								try {
									Constructor<?> superCtor =
											userClass.getDeclaredConstructor(candidate.getParameterTypes());
									ann = findAutowiredAnnotation(superCtor);
								}
								catch (NoSuchMethodException ex) {
									// Simply proceed, no equivalent superclass constructor found...
								}
							}
						}
						if (ann != null) {
							if (requiredConstructor != null) {
								throw new BeanCreationException(beanName,
										"Invalid autowire-marked constructor: " + candidate +
										". Found constructor with 'required' Autowired annotation already: " +
										requiredConstructor);
							}
							boolean required = determineRequiredStatus(ann);
							if (required) {
								if (!candidates.isEmpty()) {
									throw new BeanCreationException(beanName,
											"Invalid autowire-marked constructors: " + candidates +
											". Found constructor with 'required' Autowired annotation: " +
											candidate);
								}
								requiredConstructor = candidate;
							}
							candidates.add(candidate);
						}
						else if (candidate.getParameterCount() == 0) {
							defaultConstructor = candidate;
						}
					}
					if (!candidates.isEmpty()) {
						// Add default constructor to list of optional constructors, as fallback.
						if (requiredConstructor == null) {
							if (defaultConstructor != null) {
								candidates.add(defaultConstructor);
							}
							else if (candidates.size() == 1 && logger.isInfoEnabled()) {
								logger.info("Inconsistent constructor declaration on bean with name '" + beanName +
										"': single autowire-marked constructor flagged as optional - " +
										"this constructor is effectively required since there is no " +
										"default constructor to fall back to: " + candidates.get(0));
							}
						}
						candidateConstructors = candidates.toArray(new Constructor<?>[0]);
					}
					else if (rawCandidates.length == 1 && rawCandidates[0].getParameterCount() > 0) {
						candidateConstructors = new Constructor<?>[] {rawCandidates[0]};
					}
					else if (nonSyntheticConstructors == 2 && primaryConstructor != null &&
							defaultConstructor != null && !primaryConstructor.equals(defaultConstructor)) {
						candidateConstructors = new Constructor<?>[] {primaryConstructor, defaultConstructor};
					}
					else if (nonSyntheticConstructors == 1 && primaryConstructor != null) {
						candidateConstructors = new Constructor<?>[] {primaryConstructor};
					}
					else {
						candidateConstructors = new Constructor<?>[0];
					}
					this.candidateConstructorsCache.put(beanClass, candidateConstructors);
				}
			}
		}
		return (candidateConstructors.length > 0 ? candidateConstructors : null);
	}

 

중요한 부분만 뽑아서 보면 다음과 같습니다.

public Constructor<?>[] determineCandidateConstructors(Class<?> beanClass, final String beanName) {

    if (candidateConstructors == null) { 
        // (1) 기존에 등록된 candidateConstructor가 없다면
        // (2) 생성자 조회
        if (rawCandidates.length == 1 && rawCandidates[0].getParameterCount() > 0) {
            // (3) 생성자가 하나 있는 경우 해당 생성자를 candidateConstructors에 추가
            candidateConstructors = new Constructor<?>[] {rawCandidates[0]};
        }
        else {
            candidateConstructors = new Constructor<?>[0];
        }
        this.candidateConstructorsCache.put(beanClass, candidateConstructors);
    }
    return (candidateConstructors.length > 0 ? candidateConstructors : null);
}

 

3번 과정에서 생성자가 하나 있는 경우에는 생성자 주입을 편리하게 해주는 것을 확인할 수 있습니다. 그런데 SpringBootTest를 진행할 때는 왜 생성자 주입이 제대로 이뤄지지 못했을까요?

 

에러 로그 확인해보기


 

이전에 확인해보았을 때 'ParameterResolutionException'가 발생했습니다. 왜 해당 예외가 발생했는지 알아보기 위해 예외가 발생한 지점을 하나씩 따라가보기로 했습니다.

 

 

가장 먼저 예외가 발생한 부분입니다. 메소드의 이름은 resolveParameter이고 파라미터와 매칭되는 리조버가 존재하지 않아 예외가 발생하는 것으로 보입니다. 그리고 그 위쪽의 코드 블록에서는 현재 생성자의 파라미터의 Context와 extension의 Context를 비교해가며 파라미터를 resolve할 수 있는지 판단하고 있습니다.

 

사실 이때까지만 해도 ParameterResolver가 무엇인지 알지 못해서 테스트를 돌려보면서 디버깅을 했습니다. 디버깅을 해보니 SpringExtension이 등록되어 있는 것을 확인할 수 있습니다.

 

SpringExtension이 등록되어있다.

 

그리고 SpringExtension의 supportParameter를 확인하면 어떤 값들을 resolve하도록 지원하는지 알 수 있습니다. 

 

 

return에 4가지 값이 있는 것을 확인할 수 있는데요. 마지막에 ParameterResolutionDelegate에서 isAutowirable인지를 확인하는 코드가 있습니다. 

 

 

isAutowirable 메소드에는 Autowired가 명시되어있으면 true를 반환하도록 구현되어있습니다. 반대로 직접적으로 Autowired를 명시하지 않으면 해당 값이 false가 되고, 결과적으로 supportsParameter가 false가 되어 생성자 주입을 지원하지 않는다는 것을 알 수 있습니다.

 

실제로 생성자 위에 @Autowired를 명시하면 잘 동작하는 것을 확인할 수 있습니다.

@Autowired 추가

 

테스트 성공!

 

 

 

결론


 

다음과 같은 결론을 내릴 수 있습니다.

 

 

@SpringBootTest에서는 생성자가 하나이고, 파라미터가 모두 빈으로 등록되어있더라도
@Autowired를 생성자에 명시해야 생성자 주입이 올바르게 동작한다.

 

 

 

흠... 다소 불편해보이는데, 왜 이렇게 구현했을까요? 고민해보면서 코드를 살펴보니 이유를 확인할 수 있었습니다.

 

 

 

위에 있는 코드블럭은 아까 살펴본 코드입니다. 매칭되는 Resolver가 없어서 예외를 발생시킨 코드블럭이죠. 아래에는 매칭되는 Resolver가 두 개 이상일 때 발생하는 예외입니다.

 

스프링에서 제공해주는 편리함처럼 `생성자를 하나만 두고 모든 파라미터가 빈으로 등록`되어있으면 자동으로 생성자 주입이 가능하다면 '다른 ParameterResolver'를 사용하고자할 때 ParameterResolverException이 발생할 겁니다. 두 개 이상의 Resolver가 사용가능하게 되기 때문이죠. 스프링에서는 편리하고자 넣은 기능이지만 테스트에서는 여러 extension을 사용하는 경우가 있기 때문에 오히려 불편함이 생긴다는 것을 알 수 있습니다.

 

이러한 불편함을 제거하고자 @Autowired를 명시해야지만 SpringExtension에서 생성자 주입을 하도록 구현한 것으로 보입니다. 

 

아무튼 앞으로는 스프링 테스트를 할 때 생성자 주입에서 @Autowired를 써야지 잘 동작한다는 사실을 잊지 않을 수 있겠네요!

728x90
반응형