@Value 대신 @ConfigurationProperties로 설정하기
1. 개요
@Value는 한두 개의 설정값만 읽을 때 매우 편리합니다. 설정 프로퍼티 개수가 많아지면 ${...} 문자열이 여러 빈에 흩어지기 시작하고, 찾기 어렵고, 오타를 낼 수 있으며, 검증되지 않습니다.
이 글은 Spring Boot 3.x에서 @ConfigurationProperties로 전환하는 방법을 안내합니다. 이 글을 읽은 후에는 명시적인 타입의 record로 설정을 그룹화하고 Duration, List, Map, 중첩된 object 같은 타입을 바인드하는 방법을 알게 됩니다. 또한 시작 시 설정을 검증하고 설정에 대한 테스트를 작성하는 방법도 알게 됩니다.
2. @Value의 문제점
결제 게이트웨이를 호출해야 하는 PaymentService를 생각해봅시다:
@Service
public class PaymentService {
@Value("${app.payment.gateway-url}")
private String gatewayUrl;
@Value("${app.payment.merchant-id}")
private String merchantId;
@Value("${app.payment.timeout:10s}")
private Duration timeout;
@Value("${app.payment.max-retries:3}")
private int maxRetries;
// ...
}
코드는 작동하지만 몇 가지 문제가 있습니다:
- 키가 자유 형식 문자열입니다.
gateway-url대신app.payment.gatewayUrl을 입력하면 런타임에만 오류를 알 수 있습니다. - 검증이 없습니다.
max-retries=-5도 정상적으로 주입됩니다. - 설정이 분산되어 있습니다.
RefundService도merchant-id가 필요하면 그곳에도@Value를 반복해야 합니다. - 테스트하기 어렵습니다.
PaymentService를 Spring context 없이 테스트하려면 reflection을 사용하여 필드를 할당해야 합니다. - 복잡한 타입은 매우 불편합니다.
@Value를 사용하여Map<String, BigDecimal>을 주입하려면 상당히 복잡한 SpEL이 필요합니다.
3. @ConfigurationProperties로 설정 클래스 선언하기
3.1. Record 사용 (권장)
application.yml 파일:
app:
payment:
gateway-url: https://pay.example.com/api
merchant-id: NONLA-001
timeout: 5s
max-retries: 3
해당 설정 클래스:
package com.nonla.payment;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
String gatewayUrl,
String merchantId,
@DefaultValue("10s") Duration timeout,
@DefaultValue("3") int maxRetries) {
}
설명:
prefix = "app.payment"는 Spring에 이 record가app.payment아래의 모든 키를 받음을 알립니다.- Spring Boot 3는 record에 자동으로 constructor binding을 사용합니다. setter나
@ConstructorBinding이 필요하지 않습니다(클래스에 constructor가 하나만 있을 때). - Relaxed binding은
gateway-url,gatewayUrl,GATEWAY_URL(환경 변수APP_PAYMENT_GATEWAYURL)을 모두 같은 파라미터gatewayUrl에 매핑합니다. @DefaultValue는 설정에 키가 없을 때 기본값을 제공합니다.- 문자열
5s는 자동으로Duration.ofSeconds(5)로 변환됩니다.
3.2. 등록 및 사용
위의 record는 아직 bean이 아닙니다. main 클래스에서 활성화해야 합니다:
@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
@ConfigurationPropertiesScan은 현재 패키지와 하위 패키지를 검사하여 @ConfigurationProperties가 있는 클래스를 찾습니다. 각 클래스를 명시적으로 선언하려면 @Configuration 클래스에서 @EnableConfigurationProperties(PaymentProperties.class)를 사용합니다.
이제 PaymentService는 단순히 객체 하나를 주입하면 됩니다:
@Service
public class PaymentService {
private final PaymentProperties props;
public PaymentService(PaymentProperties props) {
this.props = props;
}
public String describe() {
return "호출: %s, 상인: %s, 타임아웃: %s, 재시도: %d회"
.formatted(props.gatewayUrl(), props.merchantId(),
props.timeout(), props.maxRetries());
}
}
describe() 호출 결과:
호출: https://pay.example.com/api, 상인: NONLA-001, 타임아웃: PT5S, 재시도: 3회
PaymentService는 이제 순수 Java 클래스입니다. 단위 테스트에서는 Spring이 필요 없이 new PaymentService(new PaymentProperties(...))로 충분합니다.
주의: constructor binding을 사용하는 record에
@Component를 붙이지 마세요. 그러면 Spring이 이것을 일반 bean으로 취급하고String gatewayUrl을 다른 bean으로 자동 연결하려고 시도합니다. 애플리케이션이 시작 시 오류가 발생합니다.@ConfigurationPropertiesScan또는@EnableConfigurationProperties로 등록하세요.
3.3. Setter가 있는 JavaBean 타입
변경 가능한 객체가 필요하거나 기존 코드와의 호환성을 위해서는 getter/setter가 있는 클래스를 사용할 수 있습니다:
@ConfigurationProperties(prefix = "app.mail")
public class MailProperties {
private String host = "localhost";
private int port = 25;
public String getHost() { return host; }
public void setHost(String host) { this.host = host; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
}
Spring은 기본 constructor를 호출한 다음 찾은 각 키에 대해 setter를 호출합니다. 기본값은 필드의 초기값입니다. 이 방법은 잘 작동하지만 object는 런타임에 어디서나 수정될 수 있습니다. 따라서 record는 여전히 더 안전한 선택입니다.
4. 복잡한 데이터 타입 바인딩
목록, map 및 중첩 객체로 결제 설정을 확장합니다:
app:
payment:
gateway-url: https://pay.example.com/api
merchant-id: NONLA-001
timeout: 5s
max-retries: 3
max-payload: 2MB
supported-currencies:
- VND
- USD
fees:
card: 1.5
wallet: 0.8
webhook:
enabled: true
secret: s3cr3t
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
String gatewayUrl,
String merchantId,
@DefaultValue("10s") Duration timeout,
@DefaultValue("3") int maxRetries,
@DefaultValue("1MB") DataSize maxPayload,
@DefaultValue({"VND"}) List<String> supportedCurrencies,
Map<String, BigDecimal> fees,
@DefaultValue Webhook webhook) {
public record Webhook(
@DefaultValue("false") boolean enabled,
String secret) {
}
}
각 파라미터는 다음과 같이 바인드됩니다:
DataSize는B,KB,MB,GB단위를 이해합니다.2MB는DataSize.ofMegabytes(2)가 됩니다.List<String>은 YAML 목록 형식과 쉼표로 구분된 문자열(VND,USD)을 모두 받습니다.Map<String, BigDecimal>은 하위 키(card,wallet)를 map의 키로 사용합니다.Webhook은 중첩 record이며 prefixapp.payment.webhook에 따라 재귀적으로 바인드됩니다.- 값이 없는
@DefaultValue는webhook에 설정되어 전체webhook블록이 없을 때 Spring이 기본값으로Webhook을 만들도록 합니다. 이 annotation이 없으면webhook()은null을 반환합니다.
주의:
Duration의 경우 단위 없는 숫자는 밀리초로 해석됩니다.timeout: 30은 30초가 아니라 30ms를 의미합니다. 항상 단위(30s,500ms)를 쓰거나 파라미터에@DurationUnit(ChronoUnit.SECONDS)를 설정해야 합니다.
5. 시작 시 설정 검증하기
validation starter를 추가합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
그런 다음 @Validated를 표시하고 Jakarta Bean Validation 제약을 추가합니다:
@Validated
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
@NotBlank String gatewayUrl,
@NotBlank String merchantId,
@DefaultValue("10s") Duration timeout,
@Min(0) @Max(10) @DefaultValue("3") int maxRetries,
@DefaultValue("1MB") DataSize maxPayload,
@NotEmpty @DefaultValue({"VND"}) List<String> supportedCurrencies,
Map<String, BigDecimal> fees,
@Valid @DefaultValue Webhook webhook) {
public record Webhook(
@DefaultValue("false") boolean enabled,
String secret) {
}
}
@Validated는 바인딩이 완료된 직후 검증을 활성화합니다.webhook의@Valid는Webhook내의 제약도 검사되도록 합니다.@Valid가 없으면 Spring은 중첩 객체를 무시합니다.
누군가 merchant-id를 비우면 애플리케이션이 명확한 메시지와 함께 시작 시 중단됩니다:
***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target com.nonla.payment.PaymentProperties failed:
Property: app.payment.merchantId
Value: ""
Origin: class path resource [application.yml] - 4:17
Reason: must not be blank
Action:
Update your application's configuration
시스템은 배포 시 즉시 오류를 보고하며, 몇 시간 동안 실행된 후 첫 번째 결제 요청에서 중단되지 않습니다. 이것이 @Value에 비한 가장 큰 장점입니다.
6. configuration processor를 통한 IDE 힌트
annotation processor를 추가합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
컴파일 시 processor는 app.payment 아래의 모든 키를 설명하는 META-INF/spring-configuration-metadata.json 파일을 생성합니다: 타입, 기본값 및 설명. IntelliJ IDEA 및 VS Code(Spring Tools 포함)는 이 파일을 사용하여 키 자동완성, 존재하지 않는 키 경고 및 application.yml에서 키 위에 마우스를 올렸을 때 설명서를 표시합니다.
각 property의 설명은 Javadoc에서 가져옵니다. record의 경우 @param을 사용하여 Javadoc을 작성합니다:
/**
* 결제 게이트웨이 설정.
*
* @param gatewayUrl 결제 게이트웨이의 기본 URL
* @param maxRetries 게이트웨이 호출 실패 시 최대 재시도 횟수
*/
주의: 프로젝트에서 이미
maven-compiler-plugin에annotationProcessorPaths를 선언했다면(예: Lombok 또는 MapStruct),spring-boot-configuration-processor를 해당 목록에 추가해야 합니다. 그렇지 않으면 processor가 실행되지 않습니다.
7. 설정 클래스 테스트하기
@SpringBootTest 없이도 됩니다. ApplicationContextRunner는 필요한 것만 포함하는 작은 context를 빌드합니다:
package com.nonla.payment;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
class PaymentPropertiesTest {
@Configuration
@EnableConfigurationProperties(PaymentProperties.class)
static class TestConfig {
}
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestConfig.class);
@Test
void shouldBindValuesAndApplyDefaults() {
contextRunner
.withPropertyValues(
"app.payment.gateway-url=https://pay.example.com/api",
"app.payment.merchant-id=NONLA-001",
"app.payment.timeout=2s",
"app.payment.fees.card=1.5")
.run(context -> {
PaymentProperties props = context.getBean(PaymentProperties.class);
assertThat(props.timeout()).isEqualTo(Duration.ofSeconds(2));
assertThat(props.maxRetries()).isEqualTo(3);
assertThat(props.supportedCurrencies()).containsExactly("VND");
assertThat(props.fees()).containsEntry("card", new BigDecimal("1.5"));
assertThat(props.webhook().enabled()).isFalse();
});
}
@Test
void shouldFailWhenMaxRetriesOutOfRange() {
contextRunner
.withPropertyValues(
"app.payment.gateway-url=https://pay.example.com/api",
"app.payment.merchant-id=NONLA-001",
"app.payment.max-retries=50")
.run(context -> assertThat(context)
.hasFailed()
.getFailure()
.hasRootCauseInstanceOf(BindValidationException.class));
}
}
이 두 테스트는 다음 사항들을 확인합니다:
- 첫 번째 테스트는 값이 올바른 타입으로 바인드됨을 확인합니다(
2s가Duration으로,1.5가BigDecimal로). 또한@DefaultValue가maxRetries,supportedCurrencies및webhook에 적용됨을 확인합니다. - 두 번째 테스트는
max-retries=50이@Max(10)을 위반하고BindValidationException으로 인해 context 시작이 실패함을 확인합니다.
각 테스트는 web server나 다른 auto-configuration이 없어서 몇 백 밀리초 안에 실행됩니다.
8. 언제 여전히 @Value를 사용할까
@ConfigurationProperties가 @Value를 완전히 대체하지는 않습니다. 다음과 같은 경우에는 여전히 @Value를 사용할 수 있습니다:
- SpEL이 필요할 때. 예:
@Value("#{${app.payment.timeout-seconds} * 1000}")@ConfigurationProperties는 SpEL 표현식을 지원하지 않습니다. - 단 하나의 산발적인 값만 필요하고 한 곳에만 사용할 때. 예: log bean에서
@Value("${spring.application.name}")이 경우 전체 record를 만드는 것은 과합니다.
@ConfigurationProperties로 전환할 때 흔한 실수들:
- 클래스 등록 잊기.
@ConfigurationPropertiesScan또는@EnableConfigurationProperties가 없으면 “No qualifying bean of type PaymentProperties” 오류가 발생합니다. - Prefix 명명 규칙 오류. Prefix는 kebab-case, 소문자여야 합니다(
app.Payment가 아닌app.payment). 잘못되면 Spring이 시작 시 오류를 보고합니다. - 여러 constructor. record나 class에 constructor가 여러 개 있으면 바인딩에 사용할 constructor를
@ConstructorBinding으로 표시해야 합니다. - 모든 것을 위해 하나의 클래스 사용. 전체 애플리케이션 설정을 거대한
AppProperties에 넣지 마세요. 업무 도메인별로 나누세요:PaymentProperties,MailProperties,StorageProperties…
주의: 설정 record는 불변입니다. Spring Cloud를 사용하여 런타임에 설정을 새로 고치려면 bean을 다시 만들어야(
@RefreshScope또는 rebind 메커니즘을 통해) 새 값이 적용됩니다. record가 자동으로 변경될 것으로 기대하지 마세요.
9. 결론
이 글에서는 산발적인 @Value를 명시적인 타입의 PaymentProperties record로 바꿨습니다. 구체적으로 다음을 배웠습니다:
Duration,DataSize,List,Map및 중첩 record를 바인드하고@DefaultValue를 통해 기본값을 제공할 수 있습니다.@Validated와 Jakarta 제약을 사용하여 시작 시 설정을 검증할 수 있습니다.spring-boot-configuration-processor덕분에 IDE에서 키 자동완성을 받을 수 있습니다.ApplicationContextRunner를 사용하여 설정 부분을 빠르고 간단하게 테스트할 수 있습니다.
3개 이상의 키가 있는 모든 설정 그룹에 대해 @ConfigurationProperties는 기본 선택이어야 합니다. 산발적인 값이나 SpEL이 정말 필요할 때만 @Value를 사용하세요.