@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이며 prefix app.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를 사용하세요.

spring-bootconfigurationconfigurationpropertiesvalidationjava