💣 문제상황

줍줍에는 북마크 설정한 메시지를 모아서 보는 기능이 있다. 기존에는 이 북마크 목록을 보여줄 때, 북마크가 등록 된 메시지의 생성 시간 순서대로 보여줬다. 그러다 사용자 편의를 위해 이를 북마크 등록 시간 순서대로 보여주기로 바꿨다. 이 때, 북마크 등록 시간을 편하게 관리하기 위해 JPA Auditing을 도입했다. 그리고 해당 PR은 순조롭게 머지 되었는데…

팀원 봄이 진행한 컨트롤러 테스트를 WebMvcTest로 개선하는 PR에서 문제가 생겼다. 봄의 로컬에서 테스트가 잘 돌아가는 상태였는데, PR의 github action에서는 테스트가 실패했다.

깃헙 액션 실패

@WebMvcTest로 변경한 모든 RestDocs용 컨트롤러 테스트가 같은 이유로 실패하고 있었다.

java.lang.IllegalStateException: Failed to load ApplicationContext...
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaAuditingHandler': Cannot resolve reference to bean 'jpaMappingContext' while setting constructor argument; 
nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaMappingContext': Invocation of init method failed; 
nested exception is java.lang.IllegalArgumentException: JPA metamodel must not be empty!
...
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaMappingContext': Invocation of init method failed; 
nested exception is java.lang.IllegalArgumentException: JPA metamodel must not be empty!
...

JPA metamodel must not be empty라는 문구가 눈에 띈다. 해당 문구로 라이브러리를 검색해보면 JpaMetamodelMappingContext라는 클래스가 나온다.

public class JpaMetamodelMappingContext
		extends AbstractMappingContext<JpaPersistentEntityImpl<?>, JpaPersistentProperty> {

	private final Metamodels models;
	private final PersistenceProvider persistenceProvider;

	/**
	 * Creates a new JPA {@link Metamodel} based {@link MappingContext}.
	 *
	 * @param models must not be {@literal null} or empty.
	 */
	public JpaMetamodelMappingContext(Set<Metamodel> models) {

		Assert.notNull(models, "JPA metamodel must not be null!");
		Assert.notEmpty(models, "JPA metamodel must not be empty!");
        ...

사실 이 문구로 검색하면 많은 사람들이 해결법을 이미 올려두었다. 그런데 여기까지 오니, JPA metamodel이 대체 뭔데? null도 아니고 empty라고? 이거랑 Auditing은 무슨 상관이지? 등등 궁금한 점이 생겨서 코드를 조금 열어봤다.


👾 JPA Metamodel

그렇다면 테스트가 잘 실행 될 때와, @WebMvcTest를 붙여 실행되지 않을 때, 저 models에는 무슨 값이 들어가있을까? 정상적으로 실행되는 경우, MetamodelImpl이라는 클래스의 인스턴스에 엔티티 클래스와 관련된 정보가 담겨있다.

metamodel

@WebMvcTest로 실행되지 않을 때는 해당 models가 비어있다.

metamodel empty

이 이슈를 검색하면 제일 자주 나오는 해결법이 @WebMvcTest를 쓰는 곳에 아래와 같이 @MockBean을 추가로 사용하는 것이다.

@WebMvcTest
@MockBean(JpaMetamodelMappingContext.class)
public class ControllerTest { ... }

일단 이 해결법을 적용했었지만, 내부 코드를 열고 보니 의문이 든다. 이렇게 하면 JpaMetamodelMappingContext 생성자의 Assert문이 실행되지 않으니 터지지는 않는다. 그런데 컨트롤러 슬라이스 테스트에서 사용하지 않을 JPA 관련 빈을 모킹해주는 것이 맞을까?

어쨌든 이 MetamodelImpl이 어디서 생성되는가를 보자면…

SessionFactoryObserver

@Incubating
public class TypeConfiguration implements SessionFactoryObserver, Serializable {
    ...
    public MetamodelImplementor scope(SessionFactoryImplementor sessionFactory) {
		log.debugf( "Scoping TypeConfiguration [%s] to SessionFactoryImpl [%s]", this, sessionFactory );

		for ( Map.Entry<String, String> importEntry : scope.metadataBuildingContext.getMetadataCollector().getImports().entrySet() ) {
			if ( importMap.containsKey( importEntry.getKey() ) ) {
				continue;
			}

			importMap.put( importEntry.getKey(), importEntry.getValue() );
		}

		scope.setSessionFactory( sessionFactory );
		sessionFactory.addObserver( this );
		return new MetamodelImpl( sessionFactory, this );
	}
    ...  

SessionFactoryObserver를 구현한 TypeConfiguration 클래스의 scope라는 메서드에서 생성해주고 있다. 그리고 이 메서드를 호출하는 곳은 HibernateSessionFactoryImpl라는 클래스다. 이 클래스는 JPAEntityManagerFactory를 상속하고 있다.

SessionFactoryImpl

public class SessionFactoryImpl implements SessionFactoryImplementor {
    ...
	public SessionFactoryImpl(
			final MetadataImplementor metadata,
			SessionFactoryOptions options,
			QueryPlanCache.QueryPlanCreator queryPlanCacheFunction) {
        ...
        ( (MetamodelImpl) this.metamodel ).initialize(
			metadata,
			determineJpaMetaModelPopulationSetting( properties )
		);

여기서 MetamodelImpl.initialize()를 호출하고…

public class MetamodelImpl implements MetamodelImplementor, Serializable {
    ...
    public void initialize(MetadataImplementor mappingMetadata, JpaMetaModelPopulationSetting jpaMetaModelPopulationSetting) {
            this.imports.putAll( mappingMetadata.getImports() );

파라미터로 전달받은 mappingMetadata에서 여러 값을 불러와 저장하게 된다. 이 mappingMetadataSessionFactoryBuilderImpl을 통해 SessionFactoryImpl에 주입되고, SessionFactoryBuilderImplDefaultSessionFactoryBuilderService을 통해 생성되는데… 의 과정을 따라가다보니 어느새 MetadataImpl로 돌아오고 있었다. 😵

결론은 이 MetamodelImpl은 엔티티 정보를 담은 JPA Metamodel의 하이버네이트의 구현체며, 그래서 @WebMvcTest에서 생성되지 않았던 것이다. 원래 최초 생성 시점을 찾으려고 했는데 결국 찾지 못했다.

JPA 관련 빈이 로딩되는 @DataJpaTest가 붙은 테스트는 models가 정상적으로 생성되어 있는 것을 볼 수 있었다.


🎧 EnableJpaAuditing

그렇다면 어떤 경위로 이 Metamodel이 필요했던 걸까? Auditing을 위해 추가했던 @EnableJpaAuditing 어노테이션을 보면 JpaAuditingRegistrar 클래스를 import 하고 있다.

@Import(JpaAuditingRegistrar.class)
public @interface EnableJpaAuditing { ... }

JpaAuditingRegistrar는 spring data jpa에 들어있는데, spring의 ImportBeanDefinitionRegistrar 클래스를 상속하고 있다.

JpaAuditingRegistrar

스프링 @Enable 설정 세 가지 구현 방식 - 자바캔 포스팅에서 해당 클래스 이해에 도움을 받았는데, 이 클래스를 구현해 빈 설정을 직접 등록할 수 있다고 한다. 바로 상위의 AuditingBeanDefinitionRegistrarSupport에서 하위 JpaAuditingRegistrarregisterAuditListenerBeanDefinition를 호출하고 있고…

public abstract class AuditingBeanDefinitionRegistrarSupport implements ImportBeanDefinitionRegistrar {
    ...
	@Override
	public void registerBeanDefinitions(AnnotationMetadata annotationMetadata, BeanDefinitionRegistry registry) {

		Assert.notNull(annotationMetadata, "AnnotationMetadata must not be null");
		Assert.notNull(registry, "BeanDefinitionRegistry must not be null");

		AbstractBeanDefinition ahbd = registerAuditHandlerBeanDefinition(registry, getConfiguration(annotationMetadata));
		registerAuditListenerBeanDefinition(ahbd, registry);
	}
    ...

이 메서드에서 포스팅 초반에 봤던 키워드를 발견할 수 있다.

class JpaAuditingRegistrar extends AuditingBeanDefinitionRegistrarSupport {
    @Override
	protected void registerAuditListenerBeanDefinition(BeanDefinition auditingHandlerDefinition,
			BeanDefinitionRegistry registry) {

		if (!registry.containsBeanDefinition(JPA_MAPPING_CONTEXT_BEAN_NAME)) {
			registry.registerBeanDefinition(JPA_MAPPING_CONTEXT_BEAN_NAME, //
					new RootBeanDefinition(JpaMetamodelMappingContextFactoryBean.class));
		}
    ...

JPA metamodel must not be empty 에러 문구가 있던 JpaMetamodelMappingContext 클래스가 기억나는가? JpaMetamodelMappingContextFactoryBean라는 정직한 이름의 팩토리 빈에서 해당 클래스를 생성해주고 있다.

public class JpaMetamodelMappingContextFactoryBean extends AbstractFactoryBean<JpaMetamodelMappingContext> implements ApplicationContextAware {
    ...
    @Override
	protected JpaMetamodelMappingContext createInstance() {

		if (LOG.isDebugEnabled()) {
			LOG.debug("Initializing JpaMetamodelMappingContext…");
		}

		JpaMetamodelMappingContext context = new JpaMetamodelMappingContext(getMetamodels());
        context.initialize();

		if (LOG.isDebugEnabled()) {
			LOG.debug("Finished initializing JpaMetamodelMappingContext!");
		}

		return context;
	}

	/**
	 * Obtains all {@link Metamodel} instances of the current {@link ApplicationContext}.
	 **/
	private Set<Metamodel> getMetamodels() {

		if (beanFactory == null) {
			throw new IllegalStateException("BeanFactory must not be null!");
		}

		Collection<EntityManagerFactory> factories = BeanFactoryUtils
				.beansOfTypeIncludingAncestors(beanFactory, EntityManagerFactory.class).values();

		return factories.stream() //
				.map(EntityManagerFactory::getMetamodel) //
				.collect(StreamUtils.toUnmodifiableSet());
	}

getMetamodels() 메서드에 친절하게 현재 애플리케이션 컨텍스트의 모든 Metamodel 인스턴스를 찾는다고 적혀있다. 아까 보았던 EntityManagerFactory도 보인다.


👀 더 나은 해결법?

@WebMvcTest로 실행할 때는 JPA 관련 빈이 로딩되지 않아서. 라는 이유를 납득하기 위해 많이 돌아왔다. @MockBean을 쓰면 @WebMvcTest를 사용하는 테스트가 늘어날 때 마다 붙여줘야 한다는 문제가 있다. 하지만 이 사소한 귀찮음이 진짜 문제는 아니라고 생각한다. 진짜 문제@WebMvcTest에서 의도한 대로 빈 설정이 분리되고 있지 않는 상황이 아닐까?

Auditing을 도입할 때, Application 클래스에 @EnableJpaAuditing을 붙였었다.

@EnableJpaAuditing
@ConfigurationPropertiesScan
@SpringBootApplication
public class PickpickApplication {

    public static void main(String[] args) {
        SpringApplication.run(PickpickApplication.class, args);
    }
}

@MockBean 해결법이 검색에 많이 나오는 걸 보면, 이렇게 설정한 사람들이 많은 듯 하다. 이를 @Configuration 클래스를 분리해 @EnableJpaAuditing를 거기 붙이는 해결 방법이 있다.

@ConfigurationPropertiesScan
@SpringBootApplication
public class PickpickApplication {

    public static void main(String[] args) {
        SpringApplication.run(PickpickApplication.class, args);
    }
}

@Configuration
@EnableJpaAuditing
public class AuditingConfig {
}

이렇게 하면 @WebMvcTest에서 @MockBean을 사용하지 않아도 된다. 해당 방식으로 리팩토링 하기를 제안해봐야겠다.


📚 부록 - 자바독을 잘 읽자

추가로 볼만한 부분이 있다. Auditing을 사용할 엔티티에 붙이는 리스너 클래스다.

@Entity
@EntityListeners(AuditingEntityListener.class)
public class Bookmark { ... }

해당 리스너 클래스의 자바독 주석을 보면 @Configuration 클래스를 분리한 것을 볼 수 있다.


/**
 * JPA entity listener to capture auditing information on persiting and updating entities. To get this one flying be
 * sure you configure it as entity listener in your {@code orm.xml} as follows:
 * ...
 * After that it's just a matter of activating auditing in your Spring config:
 *
 * @Configuration
 * @EnableJpaAuditing
 * class ApplicationConfig {
 *
 * }
 */
@Configurable
public class AuditingEntityListener { ... }

물론 베스트 프랙티스라고 올려둔 것은 아니겠으나… 자바독을 챙겨보는 습관을 기르면 도움이 될 것 같다.

로컬에서 잘 돌아가고, 머지 때 충돌도 나지 않는 코드가, 깃헙 액션에서 터지는 이번 같은 때가 종종 있다. CI 구축… 참 소중하다.