⚠️ 너무 구버전 스프링이라 최근 버전이랑 플로우 자체는 안 맞을 수도 있습니다.
1. Introduction
📌 Mistake
대체 왜 이런 실수를 했는지는 아직도 모르겠지만, properties에 설정을 넣다가 한 가지 실수를 했었다.
server.url= http://localhost.com
위와 같이 "키=값"이 아닌, "키= 값" 형태로 중간에 공백을 둔 것. (예시는 실제와 다릅니다)
여튼 로컬 및 개발 서버에서도 문제가 없어서 실수한지도 몰랐는데, 병합하던 사수 분께서 뒤늦게 공백을 발견하고 말씀해주셔서 알게 되었다.
찝찝해서 수정 커밋을 올리긴 했는데, 문득 이유가 궁금해졌다.
구분자 사이의 공백이 사라진 건 spring의 스펙일까, java의 스펙일까?


정답을 먼저 적어놓자면, 이건 java의 스펙이 맞고 java.util.Properties L345의 load0 함수에서 확인할 수 있다. (위는 틀렸고, 아래가 맞다)
@Value("#{property['key']}") 형태로 선언을 해두면 "${}"과 달리 SpEL Expression 쪽에서 처리를 수행하는데, 이걸 쭉 타고 들어가다보면 다음 코드를 확인할 수 있다.

이미 property에 대한 LineReader를 받은 상태에서 각 line을 차례대로 불러온 후, 각 line의 key 길이와 value 시작 인덱스를 조사한다.
"server.url= http://localhost.com"의 경우 ['s', 'e', 'r', 'v', 'e', 'r', '.', 'u', 'r', 'l', '=', ' ', 'h', 't', 't', 'p', ':', '/', '/', ...] 형태로 읽히는데, 첫 번째 while문에서는 구분자 "=", ":" 혹은 공백인 경우에 바로 다음 인덱스를 value의 시작 인덱스로 취급한다.
그렇다, 첫 번째 while문에서는 "key =value"나, "key= value"같은 공백 오차를 잡아내지 못한다.
하지만 이를 바로잡기 위해서 전자는 hasSep이라는 상태를 가지고, 후자는 문자 c가 공백 혹은 whitespace인지 확인하여 맞다면 valueStart를 증가시켜버린다.
Properties (Java Platform SE 8 )
Reads a property list (key and element pairs) from the input character stream in a simple line-oriented format. Properties are processed in terms of lines. There are two kinds of line, natural lines and logical lines. A natural line is defined as a line of
docs.oracle.com
공식문서만 봐도 확인할 수 있다.
그런데 나는 왜 클로드가 정답을 바로 알려줬음에도 포스팅을 할 만큼 열심히 뒤지고 다녔나.
처음에 디버깅 포인트 잘못 찍어놓고, '이자식이 나를 속였구나'하면서 한참을 방황하다가 다시 돌아왔다.
머리가 나쁘면 몸이 고생한다는데, 여튼 몸이 고생하면서 알게 된 사실이 몇 가지 있으니 그냥 적기로 결정.
2. Failure
📌 들어가며
실패한 경험에 대한 이야기다.
여기서 알게 된 게 있다면, @Value("#{property['key']}")를 했을 때 값만 읽어서 주입하고 끝날 거라 생각했지만, 실제로는 properties 파일을 모두 읽어서 bean으로 만들어 버린다는 점이었다.
그런데 처음에 이걸 몰라서 당연히 bean 생성할 때 작업 들어가겠거니 싶어서 뒤지다가, 이미 빈으로 등록된 property를 캐시에서 읽어오는 걸 보고 아차 싶었다.
내가 원했던 건, 제일 처음으로 properties를 조회하는 부분이었으나, 최종적으로 확인한 건 이미 다 읽어서 빈으로 등록된 후였던 것이다..
전제는 @Value를 필드 주입으로 넣었다고 하고 진행한다.
생성자 주입 방법은 플로우가 중간에 살짝 다를 수도 있다.
📌 Injection
이러한 어노테이션 기반 필드 주입을 처리하는 것은 주로 org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor가 담당한다는 것은 이제 지긋지긋할 정도로 많이 봐서 알고 있었다.
그리고 Bean을 Application Context에 등록하자마자 모든 주입을 수행하지는 않는다.
왜냐하면, A 빈이 B 빈을 의존할 때, B가 초기화되어 있을 것이라는 보장이 없기 때문이다.
따라서 의존 관계를 정리해서 Map에다가 쑤셔넣어놓은 후에, 모든 bean이 등록되면 후처리로 의존성 주입을 수행할 것이다.
따라서 @Value 어노테이션을 처리하는 것은 AutowiredAnnotationBeanPostProcessor의 postProcess- 로 시작하는 메서드에서 시작할 것이고, 조금 더 찾아보면 postProcessPropertyValues라는 함수를 찾아볼 수 있다.
public PropertyValues postProcessPropertyValues(
PropertyValues pvs, PropertyDescriptor[] pds, Object bean, String beanName) throws BeansException {
InjectionMetadata metadata = findAutowiringMetadata(bean.getClass());
try {
metadata.inject(bean, beanName, pvs);
}
catch (Throwable ex) {
throw new BeanCreationException(beanName, "Injection of autowired dependencies failed", ex);
}
return pvs;
}
여기서 다른 것은 모두 볼 필요없고, inject 부분만 찾아보기로 했었다.
(properties의 정보는 매번 필요한 부분만 읽어온다고 생각했으므로)
public void inject(Object target, String beanName, PropertyValues pvs) throws Throwable {
if (!this.injectedElements.isEmpty()) {
boolean debug = logger.isDebugEnabled();
for (InjectedElement element : this.injectedElements) {
if (debug) {
logger.debug("Processing injected method of bean '" + beanName + "': " + element);
}
element.inject(target, beanName, pvs);
}
}
}
InjectionMetadata 내부로 들어가면 80L~90L의 inject 메서드로 진행된다.
여기서 element.inject로 넘어가면 InjectionMetadata 146L로 이동할 텐데 이건 구라핑이다.
실제로는 AutowiredAnnotationBeanPostProcessor의 467L에서 처리가 이루어진다.
@Override
protected void inject(Object bean, String beanName, PropertyValues pvs) throws Throwable {
Field field = (Field) this.member;
try {
Object value;
if (this.cached) {
value = resolvedCachedArgument(beanName, this.cachedFieldValue);
}
else {
DependencyDescriptor descriptor = new DependencyDescriptor(field, this.required);
Set<String> autowiredBeanNames = new LinkedHashSet<String>(1);
TypeConverter typeConverter = beanFactory.getTypeConverter();
value = beanFactory.resolveDependency(descriptor, beanName, autowiredBeanNames, typeConverter);
...
}
현재 주입하려는 필드가 중복되지 않았다면 당연히 else 블록으로 흐름이 넘어간다.
아래에 코드가 죽 늘어져 있지만 필요없다.
resovleDependency 부분만 보면 된다.
그럼 이제 org.springframwork.beans.factory.support.DefaultListableBeanFactory가 왜 이제 왔냐며 환하게 반겨준다.
696L resolveDependency 내에서 if - else if - else 문이 있는데, 내가 확인하고 싶은 케이스는 else 블록으로 빠져서 711L로 넘어간다.
protected Object doResolveDependency(DependencyDescriptor descriptor, Class<?> type, String beanName,
Set<String> autowiredBeanNames, TypeConverter typeConverter) throws BeansException {
Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor);
if (value != null) {
if (value instanceof String) {
String strVal = resolveEmbeddedValue((String) value);
BeanDefinition bd = (beanName != null && containsBean(beanName) ? getMergedBeanDefinition(beanName) : null);
value = evaluateBeanDefinitionString(strVal, bd);
}
TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
return converter.convertIfNecessary(value, type);
}
...
후..뭐가 많긴 한데, `value = evaluateBeanDefinitionString`이 유독 눈에 띈다.
value라는 변수명도 그렇고, SpEL 평가식 처리를 하는 것이라 evaluate라는 키워드만 따라가도 절반은 맞는다.
(실제로 할 때는 모두 디버깅하면서 쫓아갔었다. 다만 사내 프로젝트라 디버깅 정보 캡처를 할 수가 없어서 기억을 더듬으며 다시 좇느라 헛소리를 늘어놓고 있을 뿐.)
protected Object evaluateBeanDefinitionString(String value, BeanDefinition beanDefinition) {
if (this.beanExpressionResolver == null) {
return value;
}
Scope scope = (beanDefinition != null ? getRegisteredScope(beanDefinition.getScope()) : null);
return this.beanExpressionResolver.evaluate(value, new BeanExpressionContext(this, scope));
}
여튼 따라가면 org.springframwork.beans.factory.support.AbstractBeanFactory 1294L에 도달한다. (AbstractBeanFactory를 기억해둬라. 이 녀석이 나중에 중요한 분기점이 된다.)
이제 여기서 예전에 봤던 친근한 녀석이 나온다.
org.springframwork.context.expression.StandardBeanExpressionResolver의 등장이다.
public class StandardBeanExpressionResolver implements BeanExpressionResolver {
/** Default expression prefix: "#{" */
public static final String DEFAULT_EXPRESSION_PREFIX = "#{";
/** Default expression suffix: "}" */
public static final String DEFAULT_EXPRESSION_SUFFIX = "}";
...
public Object evaluate(String value, BeanExpressionContext evalContext) throws BeansException {
if (!StringUtils.hasLength(value)) {
return value;
}
try {
Expression expr = this.expressionCache.get(value);
if (expr == null) {
expr = this.expressionParser.parseExpression(value, this.beanExpressionParserContext);
this.expressionCache.put(value, expr);
}
...
return expr.getValue(sec);
}
...
...
난 솔직히 여기서 게임이 끝났다고 생각했다.
아직 읽어보지는 않았지만, 너무 대놓고 "여기서 SpEL 평가해줄게~"하고 티를 팍팍 내는데 의심을 안 할 수가 있나.
그런데 org.springframwork.expression.common.TemplateAwareExpressionParser로 넘어가서 진짜 "평가"만 한다.
값은 언제 가져오는 건 expr.getValue(sec)를 따라가야 한다.
public Object getValue(EvaluationContext context) throws EvaluationException {
Assert.notNull(context, "The EvaluationContext is required");
return ast.getValue(new ExpressionState(context, configuration));
}
org.springframwork.expression.spel.standard.SpelExpression 86L에서 ast를 호출하는데, 왜 ast라는 이름인지는 모르겠다.
여튼, 여기서 또 들어가면 SpelNodeImpl.java로 넘어갔다가 내부에서 getValueInternal()로 위임해버리는데, 덕분에 실제 처리는 SpelNodelImpl을 구현하는 org.springframwork.expression.spel.ast.PropertyOrFieldReference.java에서 이루어진다.
쭉쭉 무시하고 넘어가서, 196L에 access.read(eContext, contextObject.getValue(), name)이라는 스니펫을 볼 수 있다.
여기선 PropertyAccessor의 구현체인 org.springframework.context.expression.BeanExpressionContextAccessor로 넘어간다.
public class BeanExpressionContextAccessor implements PropertyAccessor {
...
public TypedValue read(EvaluationContext context, Object target, String name) throws AccessException {
return new TypedValue(((BeanExpressionContext) target).getObject(name));
}
...
}
TypedValue는 열어보니 별 거 없었다.
중요한 건 BeanExpressionContext 타입으로 형변환 후 getObject를 하는 부분에 있을 것이라 확신했다.
이제 드디어 끝에 도달한 건가!
protected <T> T doGetBean(
final String name, final Class<T> requiredType, final Object[] args, boolean typeCheckOnly)
throws BeansException {
final String beanName = transformedBeanName(name);
Object bean;
// Eagerly check singleton cache for manually registered singletons.
Object sharedInstance = getSingleton(beanName);
if (sharedInstance != null && args == null) {
if (logger.isDebugEnabled()) {
if (isSingletonCurrentlyInCreation(beanName)) {
logger.debug("Returning eagerly cached instance of singleton bean '" + beanName +
"' that is not fully initialized yet - a consequence of a circular reference");
}
else {
logger.debug("Returning cached instance of singleton bean '" + beanName + "'");
}
}
bean = getObjectForBeanInstance(sharedInstance, name, beanName, null);
}
else {
BeanExpressionContext에서 BeanFactory를 구현한 AbstractBeanFactory로 넘어가서 229L의 doGetBean()에 도달했다.
그런데 여기서 if 조건에 걸려서 beanName(=property 이름)으로 조회한 sharedInstance로 getObjectForBeanInstance()하더니, org.springframwork.beans.factory.support.FactoryBeanRegistrySupport 84L에서 캐시를 참조해버린다.
protected Object getCachedObjectForFactoryBean(String beanName) {
Object object = this.factoryBeanObjectCache.get(beanName); // 요기
return (object != NULL_OBJECT ? object : null);
}
이미 모든 properties의 정보가 bean의 형태로 캐싱되어 있었기에, 내가 원했던 load 부분은 진즉에 끝났었던 것이었다.
📌 이제 어쩌지..
여기서 살짝 좌절할 뻔 했지만, 그래도 중요한 정보를 알게 되었다.
매번 properties에서 필요한 정보를 뽑아오는 줄 알았는데 이 전체를 bean으로 등록하고 있던 것이라면, 심지어 bean의 이름이 내가 설정한 id 그대로라면 분명히 한 번은 초기화를 하는 시점이 있을 것이다.
beanName.equals("properties 이름")으로 break point를 적절하게 찍을 위치만 선정하면 된다.
그리고 거기는 바로 전전 단계에서 봤던 doGetBean()이 되어야 한다.
왜냐면, 그 코드 잘 읽어보면 sharedInstance, 즉 singletone bean이 초기화가 안 되어 있으면 else 블럭으로 빠져서 bean 생성하는 로직이 있다.
반드시 여길 거쳐갈 것임에 틀림없었다.
3. Properties
📌 AbstractBeanFactory
public abstract class AbstractBeanFactory extends FactoryBeanRegistrySupport implements ConfigurableBeanFactory {
protected <T> T doGetBean(
final String name, final Class<T> requiredType, final Object[] args, boolean typeCheckOnly)
throws BeansException {
final String beanName = transformedBeanName(name);
Object bean;
// Eagerly check singleton cache for manually registered singletons.
Object sharedInstance = getSingleton(beanName);
if (sharedInstance != null && args == null) {
... // 여기로 빠지면 안 됨!
}
else {
...
// Create bean instance.
if (mbd.isSingleton()) {
sharedInstance = getSingleton(beanName, new ObjectFactory<Object>() {
public Object getObject() throws BeansException {
try {
return createBean(beanName, mbd, args); // 여기까지 도달해야 한다.
}
...
});
...
}
...
}
...
}

이렇게 조건부 breakpoint를 남기면, 가장 처음 한 번만 else 블럭으로 빠지는 것을 확인할 수 있다.
여기서 mbd는 MergedLocalBeanDefinition을 의미하는 약자고, 런타임 시 PropertiesFactoryBean.class가 들어간다.
이후 AbstractAutowireCapableBeanFactory의 createBean()이 doCreateBean(beanName, mbd, args)를 호출하고, 쭉쭉 나아가면 된다.
사실 여기서 흐름을 끊겼다.
다시 디버깅해보면 알 수 있을 거 같긴 한데, 딱히 중요한 지점은 아니므로 바로 핵심으로 ㄱㄱ
📌 PropertyOfFieldReference
이렇게 진행하다보면 어디선가 org.springframwork.core.io.support.PropertiesLoaderSupport의 mergeProperties()를 호출한다.
protected Properties mergeProperties() throws IOException {
Properties result = new Properties();
if (this.localOverride) {
// Load properties from file upfront, to let local properties override.
loadProperties(result);
}
if (this.localProperties != null) {
for (Properties localProp : this.localProperties) {
CollectionUtils.mergePropertiesIntoMap(localProp, result);
}
}
if (!this.localOverride) {
// Load properties from file afterwards, to let those properties override.
loadProperties(result);
}
return result;
}
localOverride가 뭔지는 기억 안 나는데, 여튼 마지막 조건문으로 빠진다.
protected void loadProperties(Properties props) throws IOException {
if (this.locations != null) {
for (Resource location : this.locations) {
if (logger.isInfoEnabled()) {
logger.info("Loading properties file from " + location);
}
InputStream is = null;
try {
is = location.getInputStream();
String filename = location.getFilename();
if (filename != null && filename.endsWith(XML_FILE_EXTENSION)) {
this.propertiesPersister.loadFromXml(props, is);
}
else {
if (this.fileEncoding != null) {
this.propertiesPersister.load(props, new InputStreamReader(is, this.fileEncoding));
}
else {
this.propertiesPersister.load(props, is); // 여기
}
}
}
...
}
위나 아래나 둘다 load(Properties, InputStream)으로 빠지기 때문에 의미는 없다.
여튼 여기가 내가 제일 처음에 디버깅 breakpoint를 잘못 찍었던 부분이었다.

org.springframework.util.DefaultPropertiesPersister에서 load를 호출하면, doLoad()가 아니라 Properties의 load를 호출하는데 엉뚱한 곳을 찍고 있었던 것.
대체 얼마나 돌아온 거냐..
여길 들어가면 포스팅 초입에 설명했던 load0를 확인할 수 있게 된다.
4. Conclustion
📌 뭐 쓰지.
그냥 고생한 게 아까워서 쓰긴 했는데, 예상한대로 포스팅 시간이 너무 오래 걸려서 괜히 썼나 싶다.
예전에 이런 패키지 분석하는 AI 있던 거 같아서 써보려고 했는데, 갑자기 이름이 생각이 안 나서 결국 직접 찾아다녔다. ㅜㅜ
왜 쓰려고 하면 기억이 안 나냐..
그래도 간만에 breakpoint 찍으면서 프레임워크 열어젖히고 다녔더니 재밌긴 했다.
+ 참고로 코드 읽어보면 구분자 전후의 공백은 무시가 되는데, value 이후의 공백은 따라온다는 것을 알 수 있다.