파란하늘의 지식창고
반응형

spring-boot-properties-migrator 소개

Spring Boot 기반 프로젝트에서 버전 변경 시 property가 변경되는 경우가 있다.
spring-boot-properties-migrator depnedency를 추가하면 application 실행 시 변경된 property가 어떤 것인지 쉽게 확인할 수 있다.
log로 대략 다음과 같이 안내해 준다.

The use of configuration keys that have been renamed was found in the environment:

Property source 'configserver:class path resource [xxxx.properties]':
    Key: spring.http.encoding.charset
        Replacement: server.servlet.encoding.charset


Each configuration key has been temporarily mapped to its replacement for your convenience. To silence this warning, please update your configuration to use the new keys.

The use of configuration keys that are no longer supported was found in the environment:

Property source 'configserver:class path resource [xxxx.properties]':
    Key: server.use-forward-headers
        Reason: Replaced to support additional strategies.

이런 안내가 가능한 이유는 spring-boot-autoconfigureMETA-INF/spring-configuration-metadata.json 에 이런 변경 점에 대한 정보가 추가되어 있기 때문이다.

spring-boot-properties-migrator를 dependency에 추가하면 spring-boot-properties-migratorMETA-INF/spring.factories 에 등록된 PropertiesMigrationListener 가 application 시작 시 동작한다.

# Application Listeners
org.springframework.context.ApplicationListener=\
org.springframework.boot.context.properties.migrator.PropertiesMigrationListener

해당 Listerner를 보면 다음과 같은 처리를 한다.

  1. classpath 내 모든 classpath*:/META-INF/spring-configuration-metadata.json 을 수집
  2. 수집된 property 정보 중 deprecated 정보가 있는 경우를 취합
  3. 취합된 deprecated 정보가 있는 대상 property가 사용되었는지 확인하여 있으면 report 대상으로 수집
  4. 수집된 deprecated property 내용 log 출력

앞서 보여준 로그가 이런 과정을 통해 수집된 로그이다.

spring-configuration-metadata.json 추가 설명

META-INF/spring-configuration-metadata.json 파일에 저장된 데이터 중 앞서 로그 예시에서 보여준 server.servlet.encoding.charset property 만 보여주면 다음과 같다.

{
  "groups": [
    {
      "name": "server.servlet.encoding.charset",
      "type": "java.nio.charset.Charset",
      "description": "Charset of HTTP requests and responses. Added to the \"Content-Type\" header if not set explicitly.",
      "sourceType": "org.springframework.boot.web.servlet.server.Encoding",
      "defaultValue": "UTF-8"
    },
    {
      "name": "spring.http.encoding.charset",
      "type": "java.nio.charset.Charset",
      "description": "Charset of HTTP requests and responses. Added to the Content-Type header if not set explicitly.",
      "deprecated": true,
      "deprecation": {
        "level": "error",
        "replacement": "server.servlet.encoding.charset"
      }
    }
  ]
}

현재 사용 중인 server.servlet.encoding.charset 속성에 대한 정보와 deprecated 된 spring.http.encoding.charset property가 server.servlet.encoding.charset 로 바뀌었다는 정보가 있다.

이 정보는 ConfigurationMetadata 정보이다.
ConfigurationMetadataspring-boot-configuration-processor 에 위치한 class이다.

@ConfigurationProperties 가 정의된 class를 변경하면 target/classes 하위 META-INF/spring-configuration-metadata.json 에 해당 내용이 매번 갱신되는 것을 확인할 수 있다.

IDE에서 지원해 주는 경우 굳이 spring-boot-configuration-processor dependency를 추가하지 않아도 해당 처리가 된다.

변경 사항이 META-INF/spring-configuration-metadata.json 에 갱신될 때 java class에 정의되지 않은 내용은 초기화되면서 지워지게 된다.
때문에 이전 버전에서 사용하다 deprecate 된 property에 대한 정보는 META-INF/additional-spring-configuration-metadata.json 에서 별도로 관리한다.
https://github.com/spring-projects/spring-boot/commits/main/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json

  1. spring-boot-configuration-processor dependency가 추가되어 있고
  2. META-INF/additional-spring-configuration-metadata.json 에 추가하고자 하는 Configuration Metadata를 별도로 작성하면
  3. 매번 갱신 시 META-INF/additional-spring-configuration-metadata.json 의 내용도 META-INF/spring-configuration-metadata.json 에 추가되게 된다.

병합 처리가 되려면 컴파일 시 spring-boot-configuration-processor dependency가 필요하다.

META-INF/spring-configuration-metadata.json 파일은 spring-boot-properties-migrator 의 변경 사항 안내 로그뿐만 아니라 IDE에서 properties 파일 편집 시 자동 완성을 지원하는 경우에도 사용된다.

Spring Boot AutoConfiguration 기반 프로젝트에서 정의한 custom properties에 대해 spring-boot-properties-migrator를 적용하려면?

spring boot의 properties의 변경 내역만 뿐만 아니라 사용자가 만들고 관리하는 프로젝트의 custom properties 변경에 대해서도 동일하게 안내를 받을 수 있다.
변경되는 @ConfigurationProperties 대상이 있는 프로젝트가 spring-boot-configuration-processor dependency를 참조하고 있는 상태에서
META-INF/additional-spring-configuration-metadata.json 파일을 추가하고 변경된 property에 대한 내용을 작성하면 해당 내용을 기준으로 META-INF/spring-configuration-metadata.json 가 생성되게 된다.

예를 들어 user.someProperties 라는 값이 user.changeSomeProperties 라는 값으로 변경되었는데 관련 내용이 해당 프로젝트의 META-INF/additional-spring-configuration-metadata.json 에 다음과 같이 정리되어 있다면

{
  "groups": [
    {
      "name": "user.changeSomeProperties",
      "type": "java.lang.String",
      "defaultValue": "someValue"
    },
    {
      "name": "user.changeSomeProperties",
      "type": "java.lang.String",
      "deprecated": true,
      "deprecation": {
        "level": "error",
        "replacement": "user.changeSomeProperties"
      }
    }
  ]
}

이 값은 빌드 시 META-INF/spring-configuration-metadata.json 데이터에 추가되게 된다.

대상 프로젝트가 properties 파일에 이전 버전에서 사용하던 user.someProperties 를 아직 변경하지 않고 사용하고 있는 상태라면 관련하여 안내를 해주게 된다.

Property source 'class path resource [xxxx.properties]':
    Key: user.someProperties
        Replacement: user.changeSomeProperties

이렇게 migration 대상이 되는 기준은

  • "deprecated" : true 이면서
  • "deprecation" 정보로 변경 정보가 있는 경우
    이다.

META-INF/additional-spring-configuration-metadata.json 에 다음과 같이 작성하면

{
  "name": "bluesky-boot.core.some-property-key",
  "type": "java.lang.String",
  "deprecation" : {
    "replacement" : "bluesky-boot.changed-property.some-property-key",
    "level": "error"
  }
}

해당 내용은 빌드 시 META-INF/spring-configuration-metadata.json 에 다음과 같이 "deprecated": true 속성과 함께 추가된다.
작성 시 "deprecated": true 설정할 필요가 없다.

{
  "name": "brick-modules.core.some-property-key",
  "type": "java.lang.String",
  "deprecated": true,
  "deprecation": {
    "level": "error",
    "replacement": "brick-modules.changed-property.some-property-key"
  }
}

수작업으로 META-INF/additional-spring-configuration-metadata.json 을 작성하지 않고 @DeprecatedConfigurationProperty annotation을 사용해도 관련 설정이 생성된다.

다만 @DeprecatedConfigurationProperty annotation은 getter method에 사용해야 한다.

https://docs.spring.io/spring-boot/specification/configuration-metadata/format.html

해당 설정과 @Deprecated annotation이 @ConfigurationProperties에 있으면 META-INF/additional-spring-configuration-metadata.json 에 별도로 설정하지 않아도 META-INF/spring-configuration-metadata.json 에 deprecated 정보가 추가된다.

(getter에 @DeprecatedConfigurationProperty 를 선언하고 setter도 있어야 한다.)

추가 개발 1 - 모든 propertySource에 대해 검증하기 (SpringApplicationEvent 처리 변경)

앞서 작업을 진행하고 사용해 보면 특정 위치의 properties 파일에 있는 property의 경우 검증 대상에 포함되지 않는 경우를 확인할 수 있다.

이는 spring-boot-properties-migrator 에 설정된 PropertiesMigrationListener 에서 ApplicationPreparedEvent 시점에 environment의 propertySource를 사용하기 때문이다.

이 시점엔 Spring Boot의 AutoConfiguration 관련 ConfigurationProperties들만 로드된다.

@Override
public void onApplicationEvent(SpringApplicationEvent event) {
    if (event instanceof ApplicationPreparedEvent preparedEvent) {
        onApplicationPreparedEvent(preparedEvent);
    }
    if (event instanceof ApplicationReadyEvent || event instanceof ApplicationFailedEvent) {
        logLegacyPropertiesReport();
    }
}

따라서 호출 시점만 늦춰주면 해결이 된다.
ApplicationStartedEvent 또는 더 이후인 ApplicationReadyEvent 로 지정한다.

@Override
public void onApplicationEvent(SpringApplicationEvent event) {
    if (event instanceof ApplicationStartedEvent startedEvent) {
        onApplicationStartedEvent(startedEvent);
    }
    if (event instanceof ApplicationReadyEvent || event instanceof ApplicationFailedEvent) {
        logLegacyPropertiesReport();
    }
}

spring-boot-properties-migrator 는 유저가 custom 하게 변경하지 못하도록 class가 전부 default 접근 제어자로 선언되어 있어 구현을 변경 또는 참조할 수 없다.

프로젝트에 대상과 동일한 org.springframework.boot.context.properties.migrator package 위치에 별도의 PropertiesMigrationListener 을 만들고 별도로 구현한다.

package org.springframework.boot.context.properties.migrator;

import java.io.IOException;
import java.io.InputStream;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepository;
import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepositoryJsonBuilder;
import org.springframework.boot.context.event.ApplicationFailedEvent;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.boot.context.event.SpringApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

public class BlueskyBootPropertiesMigrationListener implements ApplicationListener<SpringApplicationEvent> {

    private static final Log logger = LogFactory.getLog(BlueskyBootPropertiesMigrationListener.class);

    private PropertiesMigrationReport report;

    private boolean reported;

    @Override
    public void onApplicationEvent(SpringApplicationEvent event) {
        if (event instanceof ApplicationStartedEvent applicationStartedEvent) {
            onApplicationStartedEvent(applicationStartedEvent);
        }
        if (event instanceof ApplicationReadyEvent || event instanceof ApplicationFailedEvent) {
            logLegacyPropertiesReport();
        }
    }

    private void onApplicationStartedEvent(ApplicationStartedEvent event) {
        ConfigurationMetadataRepository repository = loadRepository();
        PropertiesMigrationReporter reporter = new PropertiesMigrationReporter(repository, event.getApplicationContext().getEnvironment());
        this.report = reporter.getReport();
    }

    private ConfigurationMetadataRepository loadRepository() {
        try {
            return loadRepository(ConfigurationMetadataRepositoryJsonBuilder.create());
        }
        catch (IOException ex) {
            throw new IllegalStateException("Failed to load metadata", ex);
        }
    }

    private ConfigurationMetadataRepository loadRepository(ConfigurationMetadataRepositoryJsonBuilder builder)
            throws IOException {
        Resource[] resources = new PathMatchingResourcePatternResolver().getResources("classpath*:/META-INF/spring-configuration-metadata.json");
        for (Resource resource : resources) {
            try (InputStream inputStream = resource.getInputStream()) {
                builder.withJsonResource(inputStream);
            }
        }
        return builder.build();
    }

    private void logLegacyPropertiesReport() {
        if (this.report == null || this.reported) {
            return;
        }
        String warningReport = this.report.getWarningReport();
        if (warningReport != null) {
            logger.warn(warningReport);
        }
        String errorReport = this.report.getErrorReport();
        if (errorReport != null) {
            logger.error(errorReport);
        }
        this.reported = true;
    }

}

따로 구성한 MigrationListener가 실행되도록 META-INF/spring.factories 에 대상 Listener를 추가한다.

# Application Listeners
org.springframework.context.ApplicationListener=\
org.springframework.boot.context.properties.migrator.BlueskyBootPropertiesMigrationListener

추가 개발 2 - 특정 대상만 리포트하기 (대상 properties 지정 filter 변경)

추가 개발 처리를 시작하게 된 이상 기존 spring의 migrator 로그와 별도로 내가 원하는 로그에 대해서만 처리되도록 작업하려고 한다.
PropertiesMigrationReporter 를 따로 구현하고 호출 조건을 변경한다.

기존

Map<String, List<PropertyMigration>> properties = getMatchingProperties(ConfigurationMetadataProperty::isDeprecated);

변경

Map<String, List<PropertyMigration>> properties = getMatchingProperties(
        configurationMetadataProperty -> 
            configurationMetadataProperty.isDeprecated() 
            && configurationMetadataProperty.getDeprecation().getReplacement() != null 
            && configurationMetadataProperty.getDeprecation().getReplacement().startsWith("bluesky-boot"));

추가 개발 3 - deprecated 대상이 Map인 경우

ConfigurationMetadata의 설정은 name 규칙이 와일드카드나 패턴 매칭을 지원하지 않는다.
https://github.com/spring-projects/spring-boot/issues/9945

Map의 경우 deprecated report를 할 수 없는 것 같다.

참고 정보 - SpringApplicationEvent의 동작 순서

앞서 SpringApplicationEvent 호출 시점과 관련하여 별도 ApplicatinoListener를 설정하는 부분을 설명하였다.

SpringApplicationEvent의 종류와 실행 순서는 다음과 같다.

순서 SpringApplicationEvent (EventPublishingRunListener 등록) SpringApplicationRunListener 추가 설명
1 ApplicationStartingEvent starting  
2 ApplicationEnvironmentPreparedEvent environmentPrepared  
3 ApplicationContextInitializedEvent contextPrepared  
4 ApplicationPreparedEvent contextLoaded  
5 ApplicationStartedEvent started  
6 ApplicationFailedEvent failed 실패한 경우 수행됨
7 ApplicationReadyEvent ready  

SpringApplication run method에서 listener 호출 순서는 다음과 같다. (주석으로 대상 번호를 지정해 두었음)

public ConfigurableApplicationContext run(String... args) {
    Startup startup = Startup.create();
    if (this.registerShutdownHook) {
        SpringApplication.shutdownHook.enableShutdownHookAddition();
    }
    DefaultBootstrapContext bootstrapContext = createBootstrapContext();
    ConfigurableApplicationContext context = null;
    configureHeadlessProperty();
    SpringApplicationRunListeners listeners = getRunListeners(args);
    listeners.starting(bootstrapContext, this.mainApplicationClass);    // 1
    try {
        ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
        ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments);    // 2 & 3
        Banner printedBanner = printBanner(environment);
        context = createApplicationContext();
        context.setApplicationStartup(this.applicationStartup);
        prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner);    // 4
        refreshContext(context);
        afterRefresh(context, applicationArguments);
        startup.started();
        if (this.logStartupInfo) {
            new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), startup);
        }
        listeners.started(context, startup.timeTakenToStarted());    // 5
        callRunners(context, applicationArguments);
    }
    catch (Throwable ex) {
        throw handleRunFailure(context, ex, listeners);    // 6
    }
    try {
        if (context.isRunning()) {
            listeners.ready(context, startup.ready());    // 7
        }
    }
    catch (Throwable ex) {
        throw handleRunFailure(context, ex, null);
    }
    return context;
}
반응형
profile

파란하늘의 지식창고

@Bluesky_

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