서론
application.yml은 yaml 사용하는데, 왜 @PropertySource는 properties를 사용하나요?
시작은 이렇다. 프로젝트 내에서 업무 외적인 환경변수는 application.yml에, 업무 내적인 환경변수는 별도의 @PropertySource로 biz.properties를 사용하는 구조였다. 그래서 아무래도 가독성이 좋은 yaml로 통일하는 게 좋은 것 같은데, property를 사용하는 특별한 이유가 있냐고 물어봤었다. 특별한 이유는 없었고 yaml을 쓰려면 추가적으로 설정해줘야 하는 게 있는데 그걸 한번 알아보라는 업무를 받았다.😂 property로 했던 기능들을 yaml로 별 이상 없이 쓸 수 있으면 yaml로 통일하자고 하셨다.
단순히 그냥 확장자만 바꾸면 될 줄 알았는데... 알아보니 스프링부트의 자동 설정, 라이프사이클 이벤트, 프로퍼티 우선순위 등 생각보다 좀 복잡하고 깊은 내용이 있어서 글로 작성하면서 정리해보려 한다.
본론
PropertySourceFactory
# biz.yml
app:
a: test-a
b: test-b
c:
- test-c1
- test-c2
우선 PropertySource는 property 파일을 위한 어노테이션이다. 그래서 yaml파일을 읽기 위해서는 별도의 Factory를 만들어서 지정해주는 것이 필요하다.
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
@Configuration
@PropertySource(value = "classpath:/props/biz.yml", factory = YamlPropertySourceFactory.class)
@ConfigurationProperties(prefix = "app")
public class Property {
private String a;
private String b;
private List<String> c;
}
import java.io.IOException;
import java.util.Properties;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.core.io.support.PropertySourceFactory;
public class YamlPropertySourceFactory implements PropertySourceFactory {
@Override
public PropertySource<?> createPropertySource(String name, EncodedResource encodedResource)
throws IOException {
YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
factory.setResources(encodedResource.getResource());
Properties properties = factory.getObject();
return new PropertiesPropertySource(encodedResource.getResource().getFilename(), properties);
}
}
@Bean
public CommandLineRunner run(Property property) {
return args -> {
System.out.println(property.getA()); // test-a
System.out.println(property.getB()); // test-b
property.getC().forEach(System.out::println); // test-c1, test-c2
};
}
생각보다 너무 간단했다. yml파일을 읽는 팩토리를 구현해서 지정해주는 방식! 그런데 profile을 적용하다 보니 문제가 생겼다. biz.yml에 profile(loc, dev)를 설정하고 loc로 실행시켰는데 dev 환경변수가 세팅됐다. 왜...?
@PropertySource는 Multi-document를 읽을 수 없다.
app:
a: test-a
b: test-b
c:
- test-c1
- test-c2
---
spring.profiles: loc
app:
a: test-loc-a
---
spring.profiles: dev
app:
a: test-dev-a
찾아보니 한 파일에 여러 profile 문서를 작성하는 multi-document-file은 @PropertySource로 읽을 수 없다고 한다. 그래서 ---로 구분한 profiles는 그냥 무시하고 제일 하단의 변수가 마지막에 읽히면서 덮어쓴 거였다.
Factory로 만들어서 많이 쓰던데... 그럼 다른 사람들은 다 multi-document를 이용 안 하고 별도의 파일로 관리하는 거였나?
YamlPropertySourceLoader
스택오버플로우에서 역시나 같은 문제로 누가 이미 질문을 올렸었고, 어떤 고수분이 멋지게 해결해주셨다.
public class YamlPropertySourceFactory implements PropertySourceFactory {
private static final String DEFAULT_PROFILE = "default";
private static final String SPRING_PROFILES = "spring.profiles";
private static final String SPRING_PROFILES_ACTIVE = "spring.profiles.active";
@Override
public PropertySource<?> createPropertySource(String name, EncodedResource encodedResource) throws IOException {
Resource resource = encodedResource.getResource();
List<PropertySource<?>> propertySources = new YamlPropertySourceLoader().load(resource.getFilename(), resource);
String activeProfile = Optional.ofNullable(System.getProperty(SPRING_PROFILES_ACTIVE)).orElse(DEFAULT_PROFILE).trim();
return propertySources.stream()
.filter(source -> activeProfile.equals(String.valueOf(source.getProperty(SPRING_PROFILES))))
.findFirst()
.orElse(propertySources.get(0));
}
}
YamlPropertySourceLoade#load는 하나의 yml 파일을 multi-document(---) 기준으로 읽어서 List로 반환한다. 이 리스트 돌면서 현재 프로파일과 같은 문서를 찾아 일치하면 반환한다. 없다면 첫 번째 파일을 반환한다.
여기서 끝인 줄 알았는데... 또 테스트해보니 이번에는 공통부분을 읽지 못했다. 즉, 흔히 property, yml 파일은 위에서 아래로 읽으면서 덮어 씌우는 방식이기 때문에 프로파일과 무관하게 공통적으로 쓰는 변수는 제일 위에 선언하곤 했다.
현재 설정으로는 프로파일과 일치하는 프로퍼티 소스만 읽으므로 SPRING_PROFILES이 없는 제일 위의 app.b, app.c는 읽을 수 없는 것이다.
그래서 SPRING_PROFILES가 없는 프로퍼티 소스를 읽어서 추가하려 했는데 방법이 없었다. 일단 createPropertySource 함수 자체의 리턴 타입이 PropertySource 하나라서 두 개의 프로퍼티 소스를 추가할 수가 없었다. 이 클래스의 역할 밖인 느낌?
그래서 다시 삽질 시작...😭 이것저것 찾다가 우연히 한 블로그에서 새로운 사실을 알게 됐다.
스프링 부트는 @PropertySource를 사용하는 것을 권장하지 않는다.
대충 알아보니 @PropertySource는 스프링부트 자동 설정 단계에서 너무 늦게 읽어 Environmet에 추가하기 때문에 몇몇의 기능이 예상대로 작동하지 않을 수도 있다는 것이었다.
자세한 내용은 이 블로그에 있다
스프링 부트는 EnvironmentPostProcessor를 사용하라고 추천하고 있다. 이 인터페이스 구현체는 application Context가 재생성되기 전에 실행된다. 참고로 @PropertySource는 Contetxt가 재생성된 후에 실행된다. 또한, EnvironmentPostProcessor에서는 Environment에 접근이 가능해서 여러 개의 프로퍼티 소스를 넣고 순서를 조작할 수 있다! 😆 (정확히는 Environment와는 조금 다르다지만 이렇게 부르겠다.)
import java.io.IOException;
import java.util.List;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
@Order(Ordered.LOWEST_PRECEDENCE)
public class YamlEnvProcessor implements EnvironmentPostProcessor {
private static final YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
private static final String SPRING_PROFILES = "spring.profiles";
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
Resource path = new ClassPathResource("props/biz.yml");
List<PropertySource<?>> propertySources;
try {
propertySources = loader.load("custom-resource", path);
} catch (IOException e) {
throw new IllegalStateException("Failed to load yaml configuration from " + path, e);
}
String activeProfile = environment.getActiveProfiles()[0]; // 없을 경우 default
MutablePropertySources envPropertyBox = environment.getPropertySources();
// profile과 무관한 프로퍼티 파일
propertySources.stream()
.filter(source -> ObjectUtils.isEmpty(source.getProperty(SPRING_PROFILES)))
.findFirst()
.ifPresent(source -> envPropertyBox.addLast(source));
// profile 관련 프로퍼티 파일
PropertySource<?> profileProperty = propertySources.stream()
.filter(source -> activeProfile.equals(String.valueOf(source.getProperty(SPRING_PROFILES))))
.findFirst()
.orElse(propertySources.get(0));
envPropertyBox.addLast(profileProperty);
}
}
EnvironmentPostProcessor를 구현한 클래스는 반드시 META-INF/spring.factories에 정의돼야 스프링 부트가 참조해서 환경변수를 설정한다.
# PostProcessor
org.springframework.boot.env.EnvironmentPostProcessor = com.blog.tistory.YamlEnvProcessor
이제 매우 빠른 시점에 Environment 객체에 세팅되기 때문에 별도의 @PropertySource를 사용할 필요가 없다. 물론 커스텀한 팩토리 또한 필요 없다.
@Setter
@Getter
@Configuration
//@PropertySource(value = "classpath:/props/biz.yml", factory = YamlPropertySourceFactory.class)
@ConfigurationProperties(prefix = "app")
public class Property {
private String a;
private String b;
private List<String> c;
}
참고로 EnvironmentPostProcessor를 읽는 시점에는 application.yml에서 정의된 내용을 Environment 객체에서 꺼내쓸 수 있다. 그래서 암호화가 필요한 프로퍼티의 경우 여기서 값을 복호화해서 값을 덮어쓸 수 있다.
@PropertySource와 EnvironmentPostProcessor를 같이 사용할 경우, EnvironmentPostProcessor가 실행되는 시점에 @PropertySource는 아직 읽기 전이기 때문에 안에 있는 내용들은 접근할 수 없고, 따라서 복호화를 할 수 없다.
추가적으로, 이때까지 PropertySource들을 순차적으로 읽으면서 무조건 overwrite 하는 줄 알았는데 아니었다. 프로퍼티를 읽는 순서는 우선순위와 전혀 상관없다. @PropertySource가 상당히 마지막에 읽히기 때문에 높은 우선순위를 가질 줄 알았는데, 스프링 부트 문서를 보면 전혀 아니다. application.yml 파일은 15번의 우선순위, @PropertySource는 16번의 우선순위라서 @PropertySource에서 읽는 값은 application.yml의 값을 overwrite 하지 못한다. 스프링 외부환경 우선순위
아마도 environment.getPropertySources().addLast(), addFirst(), addBefore(), addAfter()로 순서를 조절하는 것 같다.
결론
EnvironmentPostProcessor를 쓰는 게 좋아 보이긴 하는데... 장단점이 있는 것 같다. EnvironmentPostProcessor를 쓰면 안정적이지만 META-INF폴더가 생긴다는 점과 PropertySource 위치를 내부에서 작성해야 된다는 점이, 업무 외적으로 관리해야 할 소스들이 업무 개발자들에게 노출된다는 점이 아쉽다. 그래서 multi-document 기능을 포기하고 프로파일 별 yml을 분리하고 @PropertySource Custom Factory를 사용하기로 했다. 프로퍼티 암호화 같은 경우에는 정말 필요하다면 그때 다시, EnvironmentPostProcessor를 쓰는 방향으로 고민해보기로 결정했다.
SpringBoot 2.4.5 버전으로 업그레이드하면서 기존의 Custom Factory가 작동하지 않아 EnvironmetPostProcessor로 변경했다. 이전에 단점으로 생각했던 개발자들에게 노출된다는 점은 auto-configure 프로젝트를 별도로 구성해서 해당 프로젝트에서 자동으로 설정하도록 해결했다.
'Backend > Java' 카테고리의 다른 글
UUID, 정말 안전할까? (2) | 2021.11.06 |
---|---|
Tika로 파일 MIME 타입 검사 (0) | 2021.07.24 |
SpringBoot profile logback (0) | 2021.06.24 |
Java Stream과 Multi Thread (2) | 2021.04.17 |
JDBC query vs queryForObject (0) | 2021.03.30 |