스프링 @Value를 안전하게 쓰는 방법: 주입 시점부터 설정 바인딩 선택 기준까지
스프링 부트에서 설정값을 코드 밖으로 빼는 일은 거의 필수에 가깝습니다. 외부 API 주소, 타임아웃, 토큰, 기능 플래그처럼 환경마다 달라지는 값을 소스 코드에 직접 넣어두면 배포와 운영이 빠르게 꼬입니다. 이때 가장 먼저 떠올리기 쉬운 도구가 @Value입니다.
문제는 @Value가 간단해 보인다는 점입니다. 한 줄로 값을 넣을 수 있다 보니 동작 조건과 주입 시점을 놓치기 쉽고, 설정이 커졌을 때도 계속 같은 방식으로 밀어붙이기 쉽습니다. @Value는 분명 유용하지만, 어디까지가 적절한 사용 범위인지 알아두지 않으면 디버깅 비용이 커집니다.
@Value는 어떤 문제를 해결할까
@Value는 외부 설정값 하나를 스프링 빈에 주입할 때 가장 빠르게 적용할 수 있는 방법입니다. 예를 들어 base-url, timeout, enabled 같은 단일 값을 서비스나 컴포넌트 안으로 가져와야 할 때 코드 양이 적고 이해하기 쉽습니다.
핵심은 @Value가 “설정 한두 개를 직접 꺼내 쓰는 도구”라는 점입니다. 값이 적고 맥락이 단순하면 충분히 좋은 선택입니다. 반대로 같은 접두사 아래에 여러 설정이 묶여 있고, 그 설정들이 하나의 의미 있는 그룹을 이룬다면 @Value보다 @ConfigurationProperties가 더 자연스럽습니다.
@Value가 동작하는 조건과 주입 시점
가장 먼저 기억해야 할 사실은 @Value가 스프링이 관리하는 객체에서만 동작한다는 점입니다. @Component, @Service, @Configuration이 붙어 있거나 @Bean으로 등록된 객체여야 합니다. 직접 new로 생성한 객체에서는 스프링이 개입하지 않으므로 @Value도 작동하지 않습니다.
주입 시점도 중요합니다. 생성자 파라미터에 붙인 @Value는 객체를 만들기 전에 해석되어 전달됩니다. 반면 필드 주입과 세터 주입은 객체 생성 이후에 적용됩니다. 그래서 필드 주입 값을 필드 초기화 구문이나 생성자 안에서 먼저 사용하려고 하면 기대한 값이 아직 들어오지 않았을 수 있습니다.
실무에서는 이 차이 때문에 생성자 주입이 가장 안전합니다. 생성 시점에 필요한 값이 명확하고, final 필드로 유지할 수 있으며, 테스트에서도 다루기 쉽기 때문입니다. 반대로 필드 주입은 코드가 짧아 보이지만 객체가 어떤 설정에 의존하는지 숨기기 쉽고, 초기화 순서 문제를 만들기 쉽습니다.
또 하나 자주 헷갈리는 지점은 static 필드입니다. @Value는 일반적인 빈 주입 메커니즘을 따르므로 static 필드에 기대하는 방식으로 쓰는 패턴은 피하는 편이 낫습니다. 정적 접근이 필요해 보인다면 설계 자체를 다시 보는 쪽이 보통 더 낫습니다.
application.yml, 프로파일, 환경 변수는 어떻게 합쳐질까
스프링 부트는 여러 설정 소스를 하나의 환경처럼 합쳐서 사용합니다. 보통 기본값은 src/main/resources/application.yml에 두고, 환경별 차이는 application-local.yml, application-prod.yml, 환경 변수, 커맨드라인 인자로 덮어씁니다.
아래는 가장 흔한 기본 설정 예시입니다.
# src/main/resources/application.yml
external-api:
base-url: https://dev-api.example.com
connect-timeout-ms: 1500
read-timeout-ms: 3000
enabled: true
security:
api-token: local-dev-token
실무에서 자주 체감하는 우선순위만 단순하게 정리하면 이렇습니다.
- 커맨드라인 인자는 매우 높은 우선순위로 기존 값을 덮어씁니다.
- 환경 변수와 시스템 프로퍼티는 일반적인 설정 파일보다 우선합니다.
application-prod.yml같은 프로파일별 파일은 기본application.yml위에 덮어씌워집니다.${key:default}형태의 기본값은 해당 키가 없을 때만 사용됩니다.
즉 application.yml에 분명 값이 있는데도 다른 값이 들어온다면, 파일이 무시된 것이 아니라 더 높은 우선순위의 설정이 덮어썼을 가능성을 먼저 봐야 합니다.
@Value를 안전하게 쓰는 가장 무난한 패턴
설정이 꼭 한두 개 수준이고, 아직 별도의 설정 객체까지 만들 필요가 없다면 생성자 주입으로 @Value를 쓰는 방식이 가장 안정적입니다. 기본값이 필요한 항목만 : 문법으로 명시하고, 필수 값은 기본값 없이 두어 애플리케이션 시작 시점에 빠르게 실패하게 만드는 편이 낫습니다.
아래 예시는 실무에서 자주 보는 형태입니다.
import java.net.URI;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class ExternalApiClient {
private final URI baseUrl;
private final Duration connectTimeout;
private final boolean enabled;
public ExternalApiClient(
@Value("${external-api.base-url}") URI baseUrl,
@Value("${external-api.connect-timeout-ms:1500}") long connectTimeoutMs,
@Value("${external-api.enabled:false}") boolean enabled
) {
this.baseUrl = baseUrl;
this.connectTimeout = Duration.ofMillis(connectTimeoutMs);
this.enabled = enabled;
}
public boolean isEnabled() {
return enabled;
}
public URI getBaseUrl() {
return baseUrl;
}
public Duration getConnectTimeout() {
return connectTimeout;
}
}
이 방식의 장점은 명확합니다. 객체 생성에 필요한 값이 생성자에 모두 드러나고, 필드가 불변으로 유지됩니다. 반대로 필드 주입을 선택하면 생성자나 필드 초기화 로직에서 값을 먼저 쓰지 않도록 더 조심해야 합니다.
또한 기본값은 신중하게 써야 합니다. 선택적 설정에는 유용하지만, 원래 반드시 있어야 하는 값에 기본값을 넣어두면 설정 누락을 너무 늦게 발견할 수 있습니다. 예를 들어 API 토큰처럼 꼭 필요한 값은 애플리케이션이 시작 단계에서 바로 실패하도록 두는 편이 운영 안정성에 유리합니다.
실행 환경에 따라 값이 바뀌는 방식 이해하기
@Value를 붙인 코드가 같아도 실제 주입 결과는 실행 환경에 따라 달라집니다. 그래서 설정 이슈를 볼 때는 자바 코드만 보지 말고, 어떤 프로파일과 어떤 환경 변수로 실행했는지도 함께 봐야 합니다.
예를 들어 아래처럼 실행하면 application.yml의 기본값 대신 환경 변수와 실행 인자가 우선 적용됩니다.
export SPRING_PROFILES_ACTIVE=prod
export EXTERNAL_API_BASE_URL=https://api.example.com
export SECURITY_API_TOKEN=prod-secret-token
java -jar app.jar --external-api.connect-timeout-ms=2500
이 경우 external-api.base-url은 환경 변수 값으로, external-api.connect-timeout-ms는 커맨드라인 인자 값으로 해석됩니다. 특히 환경 변수는 external-api.base-url이 EXTERNAL_API_BASE_URL처럼 대문자와 밑줄 형태로 매핑된다는 점을 기억해두면 배포 설정을 읽을 때 훨씬 편합니다.
민감한 값도 주의해야 합니다. 토큰이나 비밀번호가 잘 들어왔는지 확인하고 싶더라도 전체 문자열을 로그로 남기면 안 됩니다. 길이만 확인하거나 앞 몇 글자만 마스킹해서 점검하는 편이 안전합니다.
@Value 사용 시 자주 만나는 문제와 점검 순서
@Value 문제는 대부분 몇 가지 패턴으로 반복됩니다. 에러를 만나면 아래 순서대로 확인하는 것이 가장 빠릅니다.
- 이 객체가 정말 스프링 빈인지 확인합니다.
- 어디선가
new로 직접 생성하고 있지 않은지 확인합니다. - 프로퍼티 키 오타가 없는지 확인합니다.
- 활성 프로파일이 예상과 같은지 확인합니다.
- 필드 주입 값을 생성자나 정적 컨텍스트에서 먼저 사용하지 않았는지 확인합니다.
- 필수 설정인데 기본값으로 숨겨두지 않았는지 확인합니다.
설정 키가 없으면 보통 시작 단계에서 아래와 비슷한 오류가 보입니다.
Could not resolve placeholder 'security.api-token' in value "${security.api-token}"
이 메시지가 나오면 원인은 대개 명확합니다. application.yml 또는 환경 변수에 값이 없거나, 키 이름이 다르거나, 다른 프로파일 파일에서 예상과 다르게 덮어쓰였을 가능성이 큽니다. 문제를 빨리 찾으려면 코드만 보지 말고 실제 실행 커맨드와 배포 환경 변수도 함께 확인해야 합니다.
언제 @ConfigurationProperties로 넘어가야 할까
@Value는 단일 값 주입에는 편하지만, 설정이 조금만 커져도 코드가 금방 흩어집니다. 예를 들어 external-api 아래에 주소, 연결 타임아웃, 읽기 타임아웃, 재시도 횟수, 인증 토큰, 기능 플래그까지 붙기 시작하면 @Value를 여러 줄 늘어놓는 방식은 유지보수성이 급격히 떨어집니다.
이럴 때는 관련 설정을 하나의 타입으로 묶는 @ConfigurationProperties가 더 적합합니다.
import java.net.URI;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@ConfigurationPropertiesScan
public class DemoApplication {
}
@ConfigurationProperties(prefix = "external-api")
public record ExternalApiProperties(
URI baseUrl,
int connectTimeoutMs,
int readTimeoutMs,
boolean enabled
) {
}
이 접근의 장점은 분명합니다. external-api 설정이 하나의 객체로 모이고, connect-timeout-ms가 connectTimeoutMs에 바인딩되는 식의 유연한 바인딩을 활용할 수 있습니다. 서비스에서는 여러 개의 문자열과 숫자를 따로 주입받는 대신 ExternalApiProperties 하나만 받으면 되므로 코드가 훨씬 읽기 쉬워집니다.
선택 기준은 단순하게 잡아도 충분합니다.
- 값이 한두 개이고 사용 위치가 분명하면
@Value - 같은 접두사 아래 설정이 여러 개 모이면
@ConfigurationProperties - 설정 객체를 재사용하거나 검증하고 싶으면
@ConfigurationProperties - 임시로 빠르게 단일 문자열, 숫자, 불리언을 주입할 때는
@Value
정리
@Value는 작은 설정을 다룰 때 여전히 좋은 도구입니다. 다만 스프링 빈에서만 동작하고, 주입 위치에 따라 시점이 다르며, 설정 우선순위를 모르면 예상과 다른 값을 읽을 수 있다는 점을 함께 이해해야 합니다. 특히 “왜 이 클래스만 값이 안 들어오지?”라는 문제는 대부분 빈 등록 여부와 생성 방식에서 시작합니다.
반대로 설정이 늘어나고 의미 있는 그룹이 만들어지기 시작했다면, @Value를 계속 늘리는 것보다 @ConfigurationProperties로 옮기는 편이 더 안정적입니다. 기준은 복잡하지 않습니다. 단일 값이면 @Value, 설정 묶음이면 @ConfigurationProperties입니다. 이 기준만 분명해도 설정 관련 코드는 훨씬 덜 흔들립니다.
원문 참고
https://www.maeil-mail.kr/question/7
댓글
댓글 쓰기