[Spring] @Value이 가져오는 property 구분자 전후의 공백은 어디서 무시될까

2025. 12. 10. 16:19·Backend/Spring Boot & JPA
⚠️ 너무 구버전 스프링이라 최근 버전이랑 플로우 자체는 안 맞을 수도 있습니다.

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 이후의 공백은 따라온다는 것을 알 수 있다.

저작자표시 비영리 (새창열림)
'Backend/Spring Boot & JPA' 카테고리의 다른 글
  • [Spring Boot] 대용량 엑셀 파일을 내려주세요
  • [Spring Boot] @Transactional의 성능 저하 (Tx에 readOnly=true 하면 성능상 이점이 있다면서요. 🥲)
  • [Spring Boot] Spring에도 Event Loop를 적용할 수 있을까? (Spring WebFlux에 대한 고찰)
  • [JPA] Value Object를 이용한 영속성 상태 검사 (Entity와 영속성이란)
나죽못고나강뿐
나죽못고나강뿐
싱클레어, 대부분의 사람들이 가는 길은 쉽고, 우리가 가는 길은 어려워요. 우리 함께 이 길을 가봅시다.
  • 나죽못고나강뿐
    코드를 찢다
    나죽못고나강뿐
  • 전체
    오늘
    어제
    • 분류 전체보기 (483)
      • Computer Science (60)
        • Git & Github (4)
        • Network (17)
        • Computer Structure & OS (13)
        • Software Engineering (5)
        • Database (9)
        • Security (5)
        • Concept (7)
      • Frontend (22)
        • React (14)
        • Android (4)
        • iOS (4)
      • Backend (85)
        • Spring Boot & JPA (53)
        • Django REST Framework (14)
        • MySQL (10)
        • Nginx (1)
        • FastAPI (4)
        • kotlin (2)
        • OpenSearch (1)
      • DevOps (24)
        • Docker & Kubernetes (11)
        • Naver Cloud Platform (1)
        • AWS (2)
        • Linux (6)
        • Jenkins (0)
        • GoCD (3)
      • Coding Test (112)
        • Solution (104)
        • Algorithm (7)
        • Data structure (0)
      • Reference (139)
        • Effective-Java (90)
        • Pragmatic Programmer (0)
        • CleanCode (11)
        • Clean Architecture (5)
        • Test-Driven Development (4)
        • Relational Data Modeling No.. (0)
        • Microservice Architecture (2)
        • 알고리즘 문제 해결 전략 (9)
        • Modern Java in Action (0)
        • Spring in Action (0)
        • DDD start (0)
        • Design Pattern (6)
        • 대규모 시스템 설계 (7)
        • JVM 밑바닥까지 파헤치기 (4)
        • The Pragmatic Programmer (1)
      • Service Planning (2)
      • Side Project (5)
      • AI (1)
      • MATLAB & Math Concept & Pro.. (2)
      • Review (24)
      • Interview (4)
      • IT News (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃
  • 공지사항

    • 요새 하고 있는 것
    • 한동안 포스팅은 어려울 것 같습니다. 🥲
    • N Tech Service 풀스택 신입 개발자가 되었습니다⋯
    • 취업 전 계획 재조정
    • 취업 전까지 공부 계획
  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
나죽못고나강뿐
[Spring] @Value이 가져오는 property 구분자 전후의 공백은 어디서 무시될까
상단으로

티스토리툴바