기본 콘텐츠로 건너뛰기

스프링 `@Value`가 기대대로 주입되지 않는 이유와 안전하게 사용하는 방법

스프링 @Value가 기대대로 주입되지 않는 이유와 안전하게 사용하는 방법

스프링 부트에서 설정값을 읽을 때 가장 먼저 떠오르는 도구가 @Value("${...}")입니다. 문법이 짧고 바로 쓸 수 있어서 편하지만, 동작 원리를 모른 채 남용하면 의외로 자주 문제를 만납니다. 어떤 클래스에서는 값이 잘 들어오는데 다른 곳에서는 null처럼 보이거나, 애플리케이션 시작 시점에 Could not resolve placeholder 예외가 터지는 식입니다.

핵심은 하나입니다. @Value는 임의의 객체에 값을 꽂아 넣는 기능이 아니라, 스프링 컨테이너가 관리하는 빈에 대해 정해진 생명주기 안에서 실행되는 주입 메커니즘입니다. 이 전제를 이해하면 @Value가 잘 맞는 범위, 피해야 할 패턴, 그리고 언제 @ConfigurationProperties로 넘어가야 하는지가 한 번에 정리됩니다.

@Value는 어떤 문제를 풀고, 어디까지 써야 할까

@Value는 외부 설정에서 단일 값을 읽어올 때 가장 간단합니다. 예를 들어 API 기본 URL, 타임아웃, 기능 플래그처럼 "몇 개 안 되는 설정"을 빠르게 주입하는 데 적합합니다. application.yml, application.properties, 환경 변수, JVM 옵션, 커맨드라인 인자처럼 스프링 Environment에 올라온 값이라면 대부분 읽을 수 있습니다.

반대로 관련 설정이 여러 개로 늘어나기 시작하면 @Value의 약점이 드러납니다. 문자열 키가 서비스 클래스 곳곳에 흩어지고, 리스트나 중첩 구조를 다루려면 표현식이 복잡해지며, 설정 검증도 분산됩니다. 그래서 실무에서는 보통 다음 기준으로 나눕니다.

  • 설정 1~2개를 빠르게 읽는다: @Value
  • 하나의 도메인 설정을 묶어서 다룬다: @ConfigurationProperties

중요한 점은 @Value가 "간단해서 무조건 좋은 방식"은 아니라는 것입니다. 단순한 경우에는 충분히 실용적이지만, 구조화된 설정까지 억지로 밀어붙이면 유지보수 비용이 빠르게 커집니다.

@Value는 언제 주입될까: 빈 생성 시점과 흔한 오해

@Value는 스프링이 설정 소스를 읽고, 빈을 만들고, 의존성을 연결하는 과정에서 처리됩니다. 흐름을 단순화하면 다음과 같습니다.

  • 스프링이 application.yml, 프로필별 설정, 환경 변수, 실행 인자를 읽어 Environment를 구성합니다.
  • 컨테이너가 빈을 생성합니다.
  • @Value가 붙은 필드, 생성자 파라미터, setter를 해석해 값을 주입합니다.
  • 이후 @PostConstruct나 이벤트 리스너 같은 초기화 로직이 실행됩니다.

이 순서 때문에 가장 많이 생기는 문제가 두 가지입니다.

첫째, 대상 객체가 스프링 빈이 아니면 @Value는 동작하지 않습니다. @Component, @Service, @Configuration이 없거나 @Bean으로 등록되지 않은 객체는 스프링이 관리하지 않습니다. 직접 new로 만든 객체는 당연히 주입 대상이 아닙니다.

둘째, 필드 주입 값은 생성자 실행 이후에 채워집니다. 그래서 생성자 내부, 필드 초기화 시점, 혹은 정적 초기화 구문에서 해당 값을 쓰면 아직 주입 전일 수 있습니다. @Value를 필드에 붙여 놓고 생성자에서 사용하는 패턴이 특히 위험한 이유입니다.

아래 예시는 왜 생성자 주입이 더 안전한지 보여줍니다.

@Component
public class BadClient {

    @Value("${app.payment.base-url}")
    private String baseUrl;

    public BadClient() {
        // 이 시점에는 baseUrl이 아직 주입되지 않았다.
        // 생성자에서 baseUrl을 사용하면 기대와 다르게 동작할 수 있다.
    }
}

@Component
public class GoodClient {

    private final URI baseUrl;
    private final Duration timeout;

    public GoodClient(
            @Value("${app.payment.base-url}") URI baseUrl,
            @Value("${app.payment.timeout:3s}") Duration timeout) {
        this.baseUrl = baseUrl;
        this.timeout = timeout;
    }

    public URI getBaseUrl() {
        return baseUrl;
    }

    public Duration getTimeout() {
        return timeout;
    }
}

필드 주입은 짧아 보이지만 객체가 완전하지 않은 상태로 생성될 여지를 남깁니다. 생성자 주입은 필요한 값이 어디서 들어오는지 명확하고, final로 고정하기 쉬우며, 누락 시 애플리케이션 시작 단계에서 빠르게 실패합니다.

설정 파일, 환경 변수, 프로필 우선순위를 함께 봐야 하는 이유

@Value 문제가 코드 때문이라고 생각했는데 실제 원인은 설정 우선순위인 경우가 많습니다. 파일에 적은 값이 최종값이 아닐 수 있기 때문입니다. 기본적으로는 application.yml의 공통 설정이 있고, 프로필별 설정이 그것을 덮고, 그 위에 환경 변수나 실행 인자가 다시 올라옵니다.

아래처럼 공통 설정과 prod 프로필을 함께 두면, 실행 환경에 따라 주입 결과가 달라집니다.

app:
  payment:
    base-url: "http://localhost:8081"
    timeout: 3s
    feature-enabled: false
  allowed-origins: "http://localhost:3000,http://127.0.0.1:3000"

---
spring:
  config:
    activate:
      on-profile: prod

app:
  payment:
    base-url: "https://api.example.com"
    timeout: 5s
    feature-enabled: true

이 설정만 보면 로컬에서는 3s, prod에서는 5s가 주입될 것 같지만, 운영 환경 변수나 배포 스크립트에 같은 키가 있으면 그 값이 다시 덮어쓸 수 있습니다. 그래서 설정 문제를 볼 때는 application.yml만 읽고 끝내면 안 됩니다. IDE 실행 설정, CI/CD 파이프라인, 컨테이너 환경 변수까지 함께 확인해야 합니다.

또 하나 실무에서 자주 놓치는 부분은 키 이름 표기입니다. @Value에는 보통 설정 파일에 적은 실제 키 이름을 그대로 넣는 편이 안전합니다. 예를 들어 app.payment.base-url처럼 kebab-case를 기준으로 통일해 두면 설정 파일과 코드 사이의 대응 관계가 훨씬 분명해집니다.

더 안전한 사용법: 생성자 주입, 기본값, 타입 변환

@Value의 장점은 단순 문자열 주입에 그치지 않는다는 점입니다. 스프링은 꽤 많은 타입으로 변환을 도와줍니다. boolean, int, long은 물론이고 Duration, URI 같은 타입도 실무에서 자주 사용됩니다. 또한 ${key:default} 문법으로 기본값을 둘 수 있어 선택적 설정을 다룰 때 유용합니다.

아래 코드는 실무에서 많이 쓰는 형태를 짧게 정리한 예시입니다.

@Component
public class PaymentClient {

    private final URI baseUrl;
    private final Duration timeout;
    private final boolean featureEnabled;

    public PaymentClient(
            @Value("${app.payment.base-url}") URI baseUrl,
            @Value("${app.payment.timeout:3s}") Duration timeout,
            @Value("${app.payment.feature-enabled:false}") boolean featureEnabled) {
        this.baseUrl = baseUrl;
        this.timeout = timeout;
        this.featureEnabled = featureEnabled;
    }

    @EventListener(ApplicationReadyEvent.class)
    public void logSettings() {
        System.out.println("baseUrl = " + baseUrl);
        System.out.println("timeout = " + timeout);
        System.out.println("featureEnabled = " + featureEnabled);
    }
}

이 코드에서 눈여겨볼 점은 세 가지입니다.

  • app.payment.timeout이 없으면 3s를 기본값으로 사용합니다.
  • 문자열 "3s"Duration으로 변환됩니다.
  • 실행 완료 후 실제 주입값을 로그로 확인할 수 있습니다.

이 패턴은 운영 장애를 줄이는 데 꽤 도움이 됩니다. 설정 누락이 있으면 애플리케이션이 시작 단계에서 빠르게 실패하고, 값이 들어오긴 했지만 예상과 다를 때는 로그로 즉시 확인할 수 있기 때문입니다.

리스트나 복잡한 구조는 왜 금방 불편해질까

@Value로 리스트도 주입할 수는 있습니다. 하지만 그 순간부터 표현식이 급격히 복잡해집니다. 쉼표 문자열을 나누기 위해 SpEL을 섞기 시작하면, 코드가 설정을 읽는지 표현식을 해석하는지 한눈에 들어오지 않게 됩니다.

예를 들어 허용 오리진 목록을 @Value로 읽으려면 대개 다음과 같은 코드가 나옵니다.

@Component
public class CorsSettings {

    private final List<String> allowedOrigins;

    public CorsSettings(
            @Value("#{'${app.allowed-origins:http://localhost:3000}'.split(',')}") List<String> allowedOrigins) {
        this.allowedOrigins = allowedOrigins;
    }

    public List<String> getAllowedOrigins() {
        return allowedOrigins;
    }
}

기술적으로는 가능하지만, 읽는 순간 이미 부담이 생깁니다. 기본값, 문자열 분리, 리스트 변환이 한 줄에 섞여 있고, 나중에 공백 제거 규칙이나 항목 검증이 필요해지면 더 복잡해집니다. 이 시점부터는 @Value를 계속 쓰는 것보다 @ConfigurationProperties로 옮기는 편이 훨씬 낫습니다.

즉, @Value는 "할 수 있는가"보다 "계속 읽고 유지할 수 있는가"를 기준으로 판단해야 합니다.

값이 안 들어오거나 다르게 들어올 때 확인할 포인트

@Value 문제는 대체로 다음 몇 가지 범주 안에서 해결됩니다. 순서대로 보면 원인을 빠르게 좁힐 수 있습니다.

  • 대상 클래스가 스프링 빈인지 확인합니다. @Component, @Service, @Configuration, @Bean 등록이 없으면 주입되지 않습니다.
  • 객체를 직접 new로 생성하지 않았는지 확인합니다.
  • 필드 주입 값을 생성자나 필드 초기화 코드에서 사용하지 않았는지 확인합니다.
  • static 필드에 @Value를 붙이지 않았는지 확인합니다.
  • 프로퍼티 키 철자가 정확한지, 특히 점(.)과 하이픈(-)이 맞는지 확인합니다.
  • application.ymlsrc/main/resources 아래에 있고 실제로 빌드 결과 클래스패스에 포함되는지 확인합니다.
  • 활성 프로필이 무엇인지 확인합니다. 기대한 application-prod.yml이 실제로 적용되지 않았을 수 있습니다.
  • IDE 실행 설정, 환경 변수, 커맨드라인 인자가 같은 키를 덮어쓰고 있지 않은지 확인합니다.
  • 기본값 문법 ${...:default} 때문에 설정 누락이 가려진 것은 아닌지 확인합니다.
  • 테스트에서 스프링 컨텍스트 없이 일반 객체만 생성하고 있지는 않은지 확인합니다.

실행 시 최종값을 빨리 검증하려면 환경 변수와 실행 인자를 함께 줘 보는 방법이 가장 단순합니다.

SPRING_PROFILES_ACTIVE=prod \
APP_PAYMENT_TIMEOUT=7s \
APP_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
./gradlew bootRun --args='--app.payment.feature-enabled=true'

이렇게 실행했을 때 파일에는 feature-enabled: false가 적혀 있어도, 커맨드라인 인자로 true를 넘기면 최종 주입값은 true가 됩니다. 파일 설정만 보고 원인을 찾으려 하면 계속 헷갈리는 이유가 여기에 있습니다.

문제가 계속되면 Environment에서 실제 값을 직접 확인하는 것도 좋은 방법입니다. @Value가 오작동하는 것이 아니라, 애초에 Environment에 올라온 최종값이 예상과 다른 경우가 더 많기 때문입니다.

언제 @ConfigurationProperties로 바꿔야 할까

설정이 도메인처럼 보이기 시작하면 @Value를 접고 @ConfigurationProperties로 옮기는 편이 맞습니다. 예를 들어 결제 설정이라는 묶음 안에 base-url, timeout, feature-enabled, allowed-origins가 함께 움직인다면, 이를 문자열 키 네 개로 흩어 두기보다 하나의 타입으로 묶는 편이 훨씬 읽기 쉽습니다.

@ConfigurationProperties의 장점은 다음과 같습니다.

  • 관련 설정을 하나의 타입으로 묶을 수 있습니다.
  • 리스트와 중첩 객체를 자연스럽게 바인딩할 수 있습니다.
  • 설정 이름을 구조적으로 다루기 쉬워집니다.
  • 검증 애너테이션을 붙이기 좋은 형태로 발전시킬 수 있습니다.

아래처럼 구성하면 서비스 코드에서 더 이상 개별 키 문자열을 반복하지 않아도 됩니다.

@SpringBootApplication
@ConfigurationPropertiesScan
public class DemoApplication {
}

@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
        URI baseUrl,
        Duration timeout,
        boolean featureEnabled,
        List<String> allowedOrigins) {
}

@Service
public class PaymentService {

    private final PaymentProperties properties;

    public PaymentService(PaymentProperties properties) {
        this.properties = properties;
    }

    public void printSettings() {
        System.out.println(properties.baseUrl());
        System.out.println(properties.timeout());
        System.out.println(properties.allowedOrigins());
    }
}

이 방식의 장점은 코드가 설정의 구조를 그대로 드러낸다는 데 있습니다. @Value는 "키 하나를 읽는다"에 최적화되어 있고, @ConfigurationProperties는 "설정 묶음을 모델링한다"에 최적화되어 있습니다. 둘을 경쟁 관계로 보기보다, 설정 크기에 따라 도구를 바꾼다고 생각하는 편이 현실적입니다.

실무 기준으로 정리하면 이렇습니다.

  • 설정이 한두 개이고 단순하다면 @Value로 충분합니다.
  • 설정이 세 개 이상 묶여 다니기 시작하면 @ConfigurationProperties를 먼저 검토합니다.
  • 리스트, 중첩 객체, 검증 요구사항이 있으면 거의 바로 @ConfigurationProperties 쪽이 낫습니다.

정리: @Value는 편하지만, 생명주기와 범위를 이해하고 써야 한다

@Value 자체가 위험한 기능은 아닙니다. 다만 "스프링이 관리하는 빈에만 주입된다", "필드 주입은 생성자 이후에 채워진다", "최종값은 설정 파일 하나로 결정되지 않는다"는 세 가지를 놓치면 쉽게 오해가 생깁니다.

안전하게 쓰려면 기준은 단순합니다. 단일 값은 생성자 주입으로 읽고, 기본값과 타입 변환은 필요한 만큼만 사용합니다. 리스트나 설정 묶음이 보이기 시작하면 바로 @ConfigurationProperties로 넘어갑니다. 이 원칙만 지켜도 @Value 때문에 겪는 대부분의 문제는 초기에 정리할 수 있습니다.

원문 참고

https://www.maeil-mail.kr/question/7

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...