본문 바로가기

Study/Java

Spring Cloud Context의 @RefreshScope를 사용하여 properties 설정 갱신하기

반응형

기본적인 @ConfigurationProperties , @PropertySource 사용

Spring Boot 기반 프로젝트에서 @ConfigurationProperties 로 지정된 bean은 처음 application이 startup 할 때 bean이 생성되고 여러 properties 파일에서 읽어와 Environment에 저장된 값을 가져와 해당 bean에 바인딩해 준다.

다음과 같이 @ConfigurationProperties 를 선언하고

@ConfigurationProperties(prefix = "someProperties")
@Data
public class SomeProperties {

    private String someKey1;

    private long someKey2;

    //.. 이하 생략
}

값을 읽어올 properties 파일을 configuration에서 다음과 같이 호출해 준다.

@AutoConfiguration
@PropertySource("classpath:some.properties")
public class SomeConfiguration {

}

해당 properties 파일에 다음과 같이 키/값을 선언하였다면

someProperties.someKey1=key1
someProperties.someKey2=1234

해당 properties의 값이 SomeProperties bean에 바인딩되어 사용하게 된다.

Spring Cloud Config Server 나 외부 설정 값 사용

@PropertySource 로 로컬 파일에서 값을 관리하지 않고 spring cloud config server를 사용하거나 외부 URL이나 File 주소를 지정하여 properties를 호출하여 사용할 수도 있다.

# spring cloud config server 사용 예
spring.cloud.config.url=http://config-server/
spring.config.import=optional:configserver:

# http 주소 호출 예
spring.config.import=optional:http://anotherConfigLocation.dev/test.properties

위 예는 각각의 경우를 나누어 소개하였는데 외부 설정 소스를 여러 곳을 지정하여 사용하는 것도 가능하다.

# spring cloud config server 사용 예
spring.cloud.config.url=http://config-server/
spring.config.import=optional:configserver:,optional:http://anotherConfigLocation.dev/test.properties

위의 예에서는 config server와 외부 http 주소 호출을 같이 지정한 경우인데

N개의 spring cloud config server를 호출하거나 N개의 외부 http 주소를 호출하거나, N 개의 파일을 호출하거나 또는 앞서 열거한 여러 소스의 호출을 같이 섞어 지정할 수도 있다.

만약 @PropertySource 를 사용하여 호출한 로컬의 properties 파일에도 동일한 property 키가 선언되어 있더라도 spring.cloud.import 로 호출한 property 키의 값이 우선 적용된다.

configserver나 외부 주소의 test.properties 에서 제공하는 properties에 다음과 같은 키/값이 있는데

someProperties.someKey1=key1
someProperties.someKey2=1234

어느 시점에 해당 값이 다음처럼 변경되어 제공되고 있다면

someProperties.someKey1=key2
someProperties.someKey2=5678

그 변경된 값을 다시 호출하여 현재 실행 중인 application을 재시작하지 않고 적용하고 싶게 된다.

@RefreshScope 사용하기

@ConfigurationProperties bean의 값이 application이 시작 시 바인딩된 이후 변경된 값을 갱신할 수 있도록 Spring Cloud Context는 @RefreshScope 를 제공하고 있다.

@RefreshScope 을 기존 사용 중인 @ConfigurationProperties class에 다음과 같이 추가해 준다.
(상위 interface에 추가해도 구현한 클래스에 일괄 적용된다.)

@RefreshScope
@ConfigurationProperties(prefix = "someProperties")
@Data
public class SomeProperties {

    private String someKey1;

    private long someKey2;

    //.. 이하 생략
}

dependency에 Spring의 spring-boot-starter-actuator를 추가하고

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

다음과 같이 refresh endpoint를 노출해 준다.

management.endpoints.web.exposure.include=health,refresh,beans

RefreshEndpoint를 활성화하면 application을 운영하다 앞서 예처럼 config 서버의 properties가 변경된 경우 다음 예처럼 /actuator/refresh 주소로 POST 요청을 통해 변경된 값을 반영할 수 있다.

curl -X POST http://localhost:8080/actuator/refresh
fetch("/actuator/refresh", {
    "method": "POST",
    "headers": {
        "Content-Type": "application/json",
        "Accept" : "application/json",
        "Accept-Language" : "ko-KR"
    }
})
.then(response => response.json());

서버가 실시간으로 변경된 properties를 반영하는 것을 확인할 수 있다.

refresh가 되면 bean 객체가 다시 재등록되기 때문에 properties bean 등록 후 추가해야 하는 작업이 있다면 InitializingBean interface를 구현하여 afterPropertiesSet() method를 정의해 두면 refresh 될 때마다 수행된다.

여러 application refresh 요청 처리

앞서 refresh는 하나의 application에 refresh 요청을 하여 갱신한 경우이다.

보통 여러 application을 띄우고 사용하기 때문에 refresh 요청을 여러 application에 전파하기 위해서는 몇 가지 방법들을 추가로 사용해야 한다.

먼저 eureka 같은 service discovery를 사용하면 현재 k8s에 떠있는 pod 목록 같은 것들을 확인할 수 있다.
이를 통해 요청을 해야 할 대상 application 목록을 확보하여 refresh 요청을 일괄로 할 수 있다.

또는 Spring Cloud Bus를 통해 refresh message를 queue에 쌓고 각 서버가 주기적으로 refresh 요청을 확인하여 갱신할 수도 있다.

자세한 사용은 관련 글을 찾아보면 된다.

Spring Cloud @RefreshScope 문서 참고

Spring Cloud 문서의 @RefreshScope 관련 내용을 옮겨보았다.
https://docs.spring.io/spring-cloud-commons/reference/spring-cloud-commons/application-context-services.html#refresh-scope

configuration이 변경되면 @RefreshScope 를 적용한 Spring @Bean은 특별하게 처리된다.
이 기능은 초기화될 때만 configuration이 inject 되는 stateful bean의 문제를 해결한다.

예를 들어, 앞서 사용한 예처럼 @ConfigurationProperties 를 통해 설정된 datasource url이 refresh 요청으로 인해 변경될 때 DataSource 에 열려있는 connection이 있는 경우, 해당 연결의 holder가 현재 수행 중인 작업을 완료할 수 있기를 원할 수 있다.
그런 다음 pool에서 새 connection을 획득할 때 새 URL을 가진 connection을 얻게 된다.

때로는 한 번만 초기화할 수 있는 일부 bean에 @RefreshScope 를 적용해야 할 수도 있다.
bean이 불변(immutable)인 경우 @RefreshScope annotation을 달거나 spring.cloud.refresh.extra-refreshable property에 classname을 지정해야 한다.

[!WARNING]
다만 HikariDataSourceDataSource bean이 있는 경우 refresh 되지 않는다.
이 bean은 spring.cloud.refresh.never-refreshable 의 기본 값이다.
refresh 해야 하는 경우 다른 DataSource 구현을 사용해야 한다.

Refresh scope bean은 사용 시 (즉, method가 호출될 때) 초기화되는 lazy proxy이며, scope는 초기화된 값의 캐시 역할을 한다.

다음 method 호출 시 bean을 강제로 다시 초기화하려면 해당 cache 항목을 무효화(invalidate) 해야 한다.

RefresnEndpoint 는 모든 대상 bean을 refresh 하는 RefreshScoperefreshAll method 호출만 제공하고 있다.
하지만 RefreshScope 엔 개별 bean만 refresh 할 수 있는 refresh(Class type) , refresh(String name) method를 제공하고 있다.

/refresh endpoint를 노출하려면 다음과 같은 설정을 추가하면 된다.

management:
  endpoints:
    web:
      exposure:
        include: refresh

[!NOTE]
@RefreshScope 는 (기술적으로는) @Configuration class에서만 동작하지만, 의외의 동작이 발생할 수 있다.
예를 들어 해당 class에 정의된 모든 @Bean@RefreshScope 에 있다는 의미는 아니다.
특히, 해당 bean에 의존하는 모든 것은 @RefreshScope 에 있지 않는 한 refresh가 시작될 때 업데이트 되는 것에 의존할 수 없다.

[!NOTE]
configuration value를 제거한 이후 refresh를 수행해도 configuration value의 존재가 업데이트되지 않는다.
refresh 후 value를 업데이트하려면 configuration property가 있어야 한다.
application에 값이 있는지 여부에 의존하는 경우, 해당 값의 부재에 의존하도록 로직을 전환하는 것이 좋다.
또 다른 방법은 application의 configuration에 존재하지 않고 값 변경에 의존하는 것이다.

[!WARNING]
context refresh는 Spring AOT 변환 및 native image에는 지원되지 않는다.
AOT 및 native image의 경우 spring.cloud.refresh.enabledfalse 로 설정해야 한다.

Refresh Scope on Restart

restart 시 bean을 원활하게 refresh 하는 기능은 특히 JVM checkpoint restore와 함께 실행되는 (Project CRaC 같은) application에 유용하다.
이 기능을 허용하기 위해 이제 재시작 시 context refresh를 trigger 하여 configuration property를 rebinding 하고 @RefreshScope 로 annotation이 달린 모든 bean을 새로 고치는 RefreshScopeLifecycle bean을 instance화 한다.
spring.cloud.refresh.on-restart.enabledfalse 로 설정하여 이 동작을 비활성화 할 수 있다.

2025-01-27 @RefreshScope 사용 관련 추가 내용

앞서 @ConfiguratinoProperties@RefreshScope 를 지정해서 사용하는 식의 설명을 하였다.

이 부분은 잘못 설명한 부분이라 정정하기 위해 추가로 글을 적어본다.

Spring이 properties나 yaml 등의 파일로 부터 읽어들인 데이터는 Environment가 가져오고 이 데이터를 기준으로 @ConfigurationProperties 로 지정된 bean에 대해 bind 처리를 한다.

이 값이 변경되는 것에 대해서는 문서에 다음과 같이 설명되어 있다. (@RefreshScope 설명의 바로 앞에 있음)
https://docs.spring.io/spring-cloud-commons/reference/spring-cloud-commons/application-context-services.html#environment-changes

만약 이 값이 변경되면 EnvironmentChangeEvent 가 발생하고 다음 두 가지 작업을 진행한다.

  • 모든 @ConfigurationProperties bean에 대해 Re-bind (ConfigurationPropertiesRebinder 참고)
  • 모든 logging.level.* property의 대상인 logger level을 변경

따라서 @ConfigurationProperties 로 생성된 bean은 @RefreshScope 를 선언하지 않아도 갱신 대상이다.

  • @RefreshScope 가 선언된 bean은 proxy bean이 생성되어 관리 대상이 되며 refresh 요청 시 proxy bean을 사용하여 갱신한다.
    beanName은 기존 "scopedTarget." + beanName으로 생성된다.
  • @RefreshScope 로 선언되어 scopedTarget이 된 bean은 해당 대상을 지정하여 개별 refresh가 가능하다.
  • @RefreshScope 가 선언되지 않은 @ConfigurationProperties 는 scopedTarget bean 설정이 되지 않아 개별 refresh 지정을 할 수 없다.

개인적으로 @ConfigurationProperties 를 단순히 Environment를 bind 한 후 @Autowired 된 참조 bean을 사용하고 afterPropertiesSet 을 통해 가공하는 식으로 사용하고 있어서 오히려 @RefreshScope 로 proxy target이 되면 대상 객체의 깊은 복사를 하지 못해 의도했던 롤백이나 리셋 처리를 할 수 없어서 @RefreshScope 대상 지정으로 사용하지 못했다.

POST /actuator/Refresh 를 요청하면 RefreshEndpoint 를 통해 ContextRefresherrefresh() method가 호출되어 갱신이 실행된다.

이 때 코드를 보면 다음과 같다.

public synchronized Set<String> refresh() {
    Set<String> keys = refreshEnvironment();    // Environment를 갱신 바로 아래 method를 호출
    this.scope.refreshAll();                    // `RefreshScope` 대상 bean을 갱신
    return keys;
}

public synchronized Set<String> refreshEnvironment() {
    Map<String, Object> before = extract(this.context.getEnvironment().getPropertySources());                    // 이전 environment에 대해 임시 추출
    updateEnvironment();                                                                                         // environment를 갱신
    Set<String> keys = changes(before, extract(this.context.getEnvironment().getPropertySources())).keySet();    // 변경된 대상 목록을 수집하여 반환
    this.context.publishEvent(new EnvironmentChangeEvent(this.context, keys));                                   // EnvironmentChangeEvent 발생
    return keys;
}

위의 코드를 보면 Environment 를 갱신하고 publishEvent를 수행하여 Environment가 변경되었음을 알려주는데 이때 앞서 설명한 EnvironmentChangeEvent 로 인해 모든 @ConfigurationProperties
bean이 Re-bind가 실행된다.
이 후 @RefreshScope 대상에 대한 갱신이 실행된다.

@RefreshScoep 를 선언하지 않고 @ConfigurationProperties 의 변경 적용과 관련해서 테스트 해보면서 아래의 문제들이 있었다.

  • refresh 시 refresh 순서가 지정되지 않아 (@Autowired 변수가 있는) 참조 관계의 bean 들은 refresh 시 참조 관계의 bean을 사용한 갱신이 의도대로 되지 않는 문제가 생긴다.
  • @RefreshScope 를 사용하여 proxy bean이 생기면 SerializationUtils.clone(this) 을 사용한 deep copy를 할 수 없고 대상 bean 중 어떤 bean이 proxyTarget bean인지 확인하려면 모든 @ConfigurationProperties bean이 beanName을 관리해야 하며 이는 오히려 scopedTarget 객체와 원 객체 간 데이터가 불일치 하게 되는 모순이 생긴다.

개인 프로젝트에서 해결을 위해 사용한 방법

위와 같은 문제로 @RefreshScope 를 지정하지 않고 @ConfigurationProperties 의 refreshAll 처리 시 그 선행 조건으로 3개의 최상위 Properties (CoreBaseProperties, CoreProperties, CoreModuleProperties) 에 대해서 우선 처리하도록 ConfigDataContextRefresher 를 custom 구현(Spring 이 제공하는 RefreshEndpoint 에서 사용하는 bean과 겹치지 않도록 BlueskyPropertiesRefresherConfigDataContextRefresher 를 감싸도록 함)하고 AutowireCapableBeanFactory 를 통해 통해 해당 bean을 다시 autowired 및 initialize 처리하도록 하였다.

/**
 * actuator endpoint refresh를 약간 수정하여 Environment 변경 적용 후 BrickProperties 갱신 처리
 * refresh endpoint의 ContextRefresher 빈과 중복 선언되면 안되기 때문에 내부 변수로 새로 생성하여 사용
 */
@Slf4j
public class BlueskyPropertiesRefresher implements ApplicationListener<ContextRefreshedWithApplicationEvent> {

    private final ConfigurableApplicationContext context;

    private ContextRefreshedWithApplicationEvent event;

    private ConfigDataContextRefresher configDataContextRefresher;

    public BlueskyPropertiesRefresher(ConfigurableApplicationContext context, RefreshScope scope, RefreshAutoConfiguration.RefreshProperties properties) {

        this.context = context;

        this.configDataContextRefresher = new ConfigDataContextRefresher(context, scope, properties) {

            @Override
            public synchronized Set<String> refresh() {
                onApplicationEvent(getEvent());

                return refreshEnvironment();
            }

            @Override
            public synchronized Set<String> refreshEnvironment() {
                updateEnvironment();
                return new LinkedHashSet<>();
            }

        };

    }

    @Override
    public void onApplicationEvent(ContextRefreshedWithApplicationEvent event) {
        this.event = event;
    }

    private ContextRefreshedWithApplicationEvent getEvent() {
        return this.event;
    }

    public Set<String> refresh() {
        // 최초 properties 설정 복원
        BlueskyBootContextHolder.getContext().getInitialBlueskyResfreshPropertiesMap().forEach((key, value) -> 
            BeanUtils.copyProperties(SerializationUtils.clone(value), context.getBean(key, BlueskyRefreshProperties.class))
        );
        var keys = this.configDataContextRefresher.refresh();
        reloadPropertiesAll(keys);
        return keys;
    }

    /**
     * blueskyProperties 갱신 처리
     * 우선 갱신 해야 하는 3개를 먼저 갱신 처리한 후 나머지는 순차 갱신 처리한다.
     * 만약 더 잘 구성하고 싶다면 bean의 dependency를 참조해서 순서를 구성해야 하지만 참조 빈이 갱신되지 않으므로 이는 추후 개선이 필요하다.
     * @param keys
     */
    private void reloadPropertiesAll(Set<String> keys) {
        reloadProperties(CoreBaseProperties.BEAN_NAME, keys);
        reloadProperties(CoreProperties.BEAN_NAME, keys);
        reloadProperties(CoreModuleProperties.BEAN_NAME, keys);

        reloadProperties(BlueskyProperties.class, keys);
        reloadProperties(BlueskyModuleProperties.class, keys);
    }

    private <T extends BlueskyRefreshProperties> void reloadProperties(Class<T> type, Set<String> keys) {
        String[] blueskyPropertiesBeanNames = context.getBeanNamesForType(type);
        for(var beanName : blueskyPropertiesBeanNames) {
            if (beanName.equals(CoreBaseProperties.BEAN_NAME)
                || beanName.equals(CoreProperties.BEAN_NAME) 
                || beanName.equals(CoreModuleProperties.BEAN_NAME)) {
                continue;
            }
            reloadProperties(beanName, keys);
        }
    }

    @SneakyThrows
    private void reloadProperties(String beanName, Set<String> keys) {
        var targetPropertiesBean = context.getBean(beanName, BlueskyRefreshProperties.class);
        var beanFactory = context.getAutowireCapableBeanFactory();
//        beanFactory.destroyBean(targetPropertiesBean);
        beanFactory.autowireBean(targetPropertiesBean);
        beanFactory.initializeBean(targetPropertiesBean, beanName);
    }
}

bean의 의존성 관련 로드 순서 지정까지 찾아볼까 하였으나 오래 걸려서 일단 이렇게 처리하였다.

반응형