스프링 @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.yml이src/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
댓글
댓글 쓰기