파란하늘의 지식창고
Published 2020. 6. 25. 07:14
Spring Boot Dynamic Bean 등록 Study/Java
반응형

Spring Boot는 설정을 자동화해주어 많은 부분에서 편리하지만 datasource 설정 같은 것들은 단일 설정에 대해서 자동화를 제공해주어 여러 datasource를 사용하는 경우 개별 설정해야 한다.

비슷한 설정을 반복 선언하여 사용하는 것도 불편하여 properties에 설정이 있으면 자동으로 빈을 생성해주는 처리가 있었으면 하는 요구사항이 생기게 된다.

예를 들어 mongo를 사용하는 경우 Spring이 제공하는 기본 설정은 다음과 같다.

spring.data.mongodb.host=127.0.0.1
spring.data.mongodb.port=27017
spring.data.mongodb.authentication-database=admin
spring.data.mongodb.username=<username specified on MONGO_INITDB_ROOT_USERNAME>
spring.data.mongodb.password=<password specified on MONGO_INITDB_ROOT_PASSWORD>
spring.data.mongodb.database=<the db you want to use>

이 설정은 단일 설정에 대한 제공이라 사용자가 여러 개의 mongo db를 사용하려는 경우 별도 설정을 해야 한다.

또한 별도 설정값을 읽어 MongoClient, MongoDatabaseFactory, MongoTemplate과 같은 bean들을 생성하고 해당 bean을 사용하는 @EnableMongoRepositories 설정을 해야 한다.

좀 더 편하게 사용하기 위해 properties 공통화와 bean 생성 공통화를 하는 방법을 소개해본다.

properties 공통화

속성 값은 대부분은 공통으로 사용하고 연결 부분만 다르게 사용할 것이다.

따라서 아래와 같이 공통 설정과 개별 설정을 등록하고 개별 설정에 공통 설정값이 추가되는 형태로 사용하면 좋을 듯하다.

# 공통 사항
bluesky-modules.mongodb.default-properties.host=localhost
bluesky-modules.mongodb.default-properties.port=27017
bluesky-modules.mongodb.default-properties.authentication-database=admin
bluesky-modules.mongodb.default-properties.username=<username specified on MONGO_INITDB_ROOT_USERNAME>
bluesky-modules.mongodb.default-properties.password=<password specified on MONGO_INITDB_ROOT_PASSWORD>

# 개별 사항
bluesky-modules.mongodb.connection-map.blog.host=localhost
bluesky-modules.mongodb.connection-map.blog.database=blog
bluesky-modules.mongodb.connection-map.test2.host=192.168.12.3

위와 같이 등록된 properties값을 읽어 들일 수 있는 Properties 객체를 만든다.

@Data
@ConfigurationProperties(prefix = "bluesky-modules.mongodb")
public class MongoProperties implements InitializingBean {

	private BlueskyMongoProperties defaultProperties;
	
	private Map<String, BlueskyMongoProperties> connectionMap = new HashMap<>();
	
	@Data
	@EqualsAndHashCode(callSuper = true)
	@NoArgsConstructor
	public static class BlueskyMongoProperties extends org.springframework.boot.autoconfigure.mongo.MongoProperties {
			
		@Builder
		public BlueskyMongoProperties(String host, Integer port, String uri, String database,
				String authenticationDatabase, String gridFsDatabase, String username, char[] password,
				String replicaSetName, Class<?> fieldNamingStrategy, UuidRepresentation uuidRepresentation,
				Boolean autoIndexCreation) {
			setHost(host);
			setPort(port);
			setUri(uri);
			setDatabase(database);
			setAuthenticationDatabase(authenticationDatabase);
			setGridFsDatabase(gridFsDatabase);
			setUsername(username);
			setPassword(password);
			setReplicaSetName(replicaSetName);
			setFieldNamingStrategy(fieldNamingStrategy);
			setUuidRepresentation(uuidRepresentation == null ? UuidRepresentation.JAVA_LEGACY : uuidRepresentation);
			setAutoIndexCreation(autoIndexCreation);
		}
	}

	@Override
	public void afterPropertiesSet() throws Exception {
		
		PropertyMapper propertyMapper = PropertyMapper.get().alwaysApplyingWhenNonNull();
		
		for (var key : connectionMap.keySet()) {
			var blueskyMongoProperties = connectionMap.get(key);
			
			BlueskyMongoPropertiesBuilder builder = BlueskyMongoProperties.builder();
			
			propertyMapper.from(defaultProperties::getHost).to(builder::host);
			propertyMapper.from(blueskyMongoProperties::getHost).to(builder::host);
			propertyMapper.from(defaultProperties::getPort).to(builder::port);
			propertyMapper.from(blueskyMongoProperties::getPort).to(builder::port);
			propertyMapper.from(defaultProperties::getUri).to(builder::uri);
			propertyMapper.from(blueskyMongoProperties::getUri).to(builder::uri);
			propertyMapper.from(defaultProperties::getDatabase).to(builder::database);
			propertyMapper.from(blueskyMongoProperties::getDatabase).to(builder::database);
			propertyMapper.from(defaultProperties::getAuthenticationDatabase).to(builder::authenticationDatabase);
			propertyMapper.from(blueskyMongoProperties::getAuthenticationDatabase).to(builder::authenticationDatabase);
			propertyMapper.from(defaultProperties::getGridFsDatabase).to(builder::gridFsDatabase);
			propertyMapper.from(blueskyMongoProperties::getGridFsDatabase).to(builder::gridFsDatabase);
			propertyMapper.from(defaultProperties::getUsername).to(builder::username);
			propertyMapper.from(blueskyMongoProperties::getUsername).to(builder::username);
			propertyMapper.from(defaultProperties::getPassword).to(builder::password);
			propertyMapper.from(blueskyMongoProperties::getPassword).to(builder::password);
			propertyMapper.from(defaultProperties::getReplicaSetName).to(builder::replicaSetName);
			propertyMapper.from(blueskyMongoProperties::getReplicaSetName).to(builder::replicaSetName);
			propertyMapper.from(defaultProperties::getFieldNamingStrategy).to(builder::fieldNamingStrategy);
			propertyMapper.from(blueskyMongoProperties::getFieldNamingStrategy).to(builder::fieldNamingStrategy);
			propertyMapper.from(defaultProperties::getUuidRepresentation).to(builder::uuidRepresentation);
			propertyMapper.from(blueskyMongoProperties::getUuidRepresentation).to(builder::uuidRepresentation);
			propertyMapper.from(defaultProperties::isAutoIndexCreation).to(builder::autoIndexCreation);
			propertyMapper.from(blueskyMongoProperties::isAutoIndexCreation).to(builder::autoIndexCreation);
			
			connectionMap.put(key, builder.build());
			
		}
		
	}
}

properties를 따로 지정해서 Spring의 MongoProperties와 연동하는 것보단 아예 Spring의 MongoProperties를 그대로 사용하는 것이 좋다.

PropertyMapper와 lombok builder를 통해 설정 값을 merge 처리한다.

Dynamic Bean 등록 - BeanFactoryPostProcessor, BeanPostProcessor

Spring에서 Bean을 동적으로 등록하기 위해서 사용할만한 포인트로 BeanFactoryPostProcessor와 BeanPostProcessor가 있다.

각각 인터페이스를 구현하고 bean으로 등록하면 동작한다.

BeanFactoryPostProcessor를 사용하는 게 가장 좋은 방법이고 확실하지만 Bean을 생성하는 시점이 아닌 Bean을 생성하기 위해 등록하는 시점의 후처리이기 때문에 다른 Bean을 호출해서 쓰지 못한다.

위에 설정한 properties 객체도 호출을 할 수 없다.

BeanPostProcessor는 Bean이 생성된 시점 이후 수행되기 때문에 위에 선언한 MongoProperties를 사용할 수 있다.

BeanPostProcessor를 구현한 Bean은 모든 Bean이 생성될 때마다 호출된다.

위에서 만든 MongoProperties가 생성된 이후 시점에 해당 정보를 토대로 Dynamic Bean 등록을 하고자 하는 경우 예제는 다음과 같다.

public class MongoPropertiesBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware {

	private ApplicationContext applicationContext;
	
	private String mongoClientBeanNameFormat = "{0}MongoClient";
	
	@Override
	public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
   		// MongoProperties를 생성한 이후에만 실행되도록 체크
		if (!(bean instanceof MongoProperties)) {
			return bean;
		}
		
		var autowireCapableBeanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();
		var mongoProperties = (MongoProperties) bean;
		var environment = applicationContext.getEnvironment();
		var builderCustomizers = applicationContext.getBeanProvider(MongoClientSettingsBuilderCustomizer.class);
		var settings = applicationContext.getBeanProvider(MongoClientSettings.class);
		
		mongoProperties.getConnectionMap().forEach((key, value) -> {
			var blueskyMongoProperties = mongoProperties.getConnectionMap().get(key);

			var mongoClient = new MongoClientFactory(blueskyMongoProperties, environment, builderCustomizers.orderedStream().collect(Collectors.toList())).createMongoClient(settings.getIfAvailable());
			
			var mongoClientBeanName = MessageFormat.format(mongoClientBeanNameFormat, key);
			autowireCapableBeanFactory.registerSingleton(mongoClientBeanName, mongoClient);
		});
		
		return bean;
	}

	@Override
	public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
			this.applicationContext = applicationContext;
	}
}

하지만 BeanPostProcessor를 사용하면 단점이 있다.

Bean이 생성되는 시점 이후에 만들어지는 Bean이기 때문에 MongoTemplate을 위와 비슷한 형태로 생성하면 아래와 같이 사용하는 경우 존재하지 않는 Bean을 사용한다는 에러가 발생한다.

@Configuration
@EnableMongoRepositories(basePackages = "net.bluesky.blog.repository.mongo", mongoTemplateRef = "blogMongoTemplate")
public class BlogDataMongoConfig {
}

이 경우 아래처럼 사용하면 그나마 종속 관계의 Bean이 먼저 생성된 이후 사용되도록 순서가 재 정렬된다.

@Configuration
@EnableMongoRepositories(basePackages = "net.bluesky.blog.repository.mongo", mongoTemplateRef = "blogMongoTemplate")
public class BlogDataMongoConfig {
    @Autowired
    private MongoProperties mongoProperties;	// spring boot의 MongoProperties가 아님. 위에 선언한 MongoProperties임
}

하지만 후처리로 생성한 빈을 사용하기 위해 이런 식의 특이한 처리를 하는 것도 이상하다.

BeanPostProcessor에서 Bean을 등록하면

  • 장점 : ConfigurationProperties나 다른 Bean들을 사용할 수 있음
  • 단점 : Bean 생성 시점이 늦어져서 @Configuration 내에서 참조가 되지 않을 수 있음

BeanFactoryPostProcessor에서 Bean을 등록하면

  • 장점 : 다른 Bean의 의존성에 문제가 없음
  • 단점 : ConfigurationProperties를 가져오지 못해 별도로 설정을 읽는 처리를 해야 함

이와 같이 두 Processor가 호출 시점이 다르기 때문에 각각의 장단점이 있다.

만약 복잡하지 않은 설정으로 bean을 생성한다면 BeanFactoryPostProcessor를 사용하면 된다.

하지만 뭔가 복잡한 설정을 사용하거나 설정을 Spring의 ConfigurationProperties로 위임해서 편하게 사용하고자 한다면 BeanPostProcessor를 사용하면 된다.

하지만 BeanPostProcessor를 사용할 경우 위에 언급한 @Configuration 내에서 참조가 되지 않는 단점이 있다.

두 방식을 병행 사용하기

그래서 두 방법의 단점을 보완하는 방법을 고민하다 아래 방식을 생각해보았다.

  1. BeanFactoryPostProcessor에서 임의의 Bean을 등록
  2. BeanPostProcessor에서 앞에 등록한 Bean을 삭제하고 다시 원래 등록하려던 Bean을 등록

이렇게 하면 @Configuration에서 참조가 되지 않는 문제도 해결하고 ConfigurationProperties를 참조하여 Bean을 생성할 수 있다.

우선 BeanFactoryPostProcesor에서 임의의 Bean을 등록한다.

public class MongoPropertiesBeanFactoryPostProcessor implements BeanFactoryPostProcessor, ApplicationContextAware {
	
	private ApplicationContext applicationContext;
	
	private String propertiesPrefix = "bluesky-modules.mongodb.connection-map";
	
	private String mongoClientBeanNameFormat = "{0}MongoClient";

	@Override
	public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
		getPropertiesMapKeySet().forEach(key -> {
			beanFactory.registerSingleton(MessageFormat.format(mongoClientBeanNameFormat, key), MongoClients.create());
		});
	}
	
	private Map<String, String> getPropertiesMap() {
		var properties = new HashMap<String, String>();
		var environment = applicationContext.getEnvironment();
		if (environment instanceof ConfigurableEnvironment) {
		    for (PropertySource<?> propertySource : ((ConfigurableEnvironment) environment).getPropertySources()) {
		        if (propertySource instanceof EnumerablePropertySource) {
		            for (String key : ((EnumerablePropertySource<?>) propertySource).getPropertyNames()) {
		                if (key.startsWith(propertiesPrefix)) {
		                    properties.put(key, (String) propertySource.getProperty(key));
		                }
		            }
		        }
		    }
		}
		return properties;
	}
	
	private Set<String> getPropertiesMapKeySet() {
		Map<String, String> propertiesMap = getPropertiesMap();
		var keySet = new HashSet<String>();
		propertiesMap.forEach((key, value) -> {
			 keySet.add(key.replace(propertiesPrefix, "").split("\\.")[1]);
		});
		return keySet;
	}

	@Override
	public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
		this.applicationContext = applicationContext;
	}

}

그리고 BeanPostProcessor를 아래처럼 등록한다.

public class MongoPropertiesBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware {

	private ApplicationContext applicationContext;
	
	private String mongoClientBeanNameFormat = "{0}MongoClient";
	
	@Override
	public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
		if (!(bean instanceof MongoProperties)) {
			return bean;
		}
		
		if (applicationContext == null) {
			return bean;
		}
		
		var autowireCapableBeanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();
		var mongoProperties = (MongoProperties) bean;
		var environment = applicationContext.getEnvironment();
		var builderCustomizers = applicationContext.getBeanProvider(MongoClientSettingsBuilderCustomizer.class);
		var settings = applicationContext.getBeanProvider(MongoClientSettings.class);
		
		mongoProperties.getConnectionMap().forEach((key, value) -> {
			var blueskyMongoProperties = mongoProperties.getConnectionMap().get(key);

			var mongoClient = new MongoClientFactory(blueskyMongoProperties, environment, builderCustomizers.orderedStream().collect(Collectors.toList())).createMongoClient(settings.getIfAvailable());
			
			var mongoClientBeanName = MessageFormat.format(mongoClientBeanNameFormat, key);
			autowireCapableBeanFactory.destroySingleton(mongoClientBeanName);
			autowireCapableBeanFactory.registerSingleton(mongoClientBeanName, mongoClient);
		});
		
		return bean;
	}

	@Override
	public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
			this.applicationContext = applicationContext;
	}
}

앞서 언급한 예시에서 destorySingleton 처리만 한 줄 추가되었다.

위와 같이 두 방법을 같이 사용하면 단점이 해결된다.

여기까지의 과정을 거쳐 공통화 설정이 되었다면 이후엔 아래 설정만 추가하면 된다.

  • connection-map 설정 추가
  • spring data mongo를 사용하고자 하는 경우 @EnableMongoRepositories 선언 추가
반응형
profile

파란하늘의 지식창고

@Bluesky_

도움이 되었다면 광고를 클릭해주세요