Spring Boot

Spring Boot에서 @ConfigurationProperties로 설정을 타입 안전하게 받기 (Value 남용 탈출)

IT Lab 2026. 3. 8. 20:00

@Value를 남용할 때 생기는 유지보수/검증 문제를 정리하고, Spring Boot 3의 @ConfigurationProperties + Validation로 설정을 타입 안전하게 분리하는 방법을 예제로 보여드립니다.

도입 (문제 상황)

@Value("${...}")를 여기저기 붙여서 설정을 읽다 보면, 어느 순간 “이 설정이 어디서 쓰이지?”를 추적하기가 꽤 힘들어집니다. 게다가 숫자/URL/시간 같은 값이 문자열로 흘러다니다가 런타임에야 터지는 경우도 자주 보게 됩니다. 설정을 타입 안전하게 받고, 검증까지 한 번에 묶어두는 방법이 필요합니다.

핵심 개념 — Spring Boot @ConfigurationProperties가 중요한 이유

Spring Boot에서 설정을 다루는 방식은 크게 두 가지로 나뉩니다.

  • @Value: “한 값”을 빠르게 주입하기는 편하지만, 값이 늘어나면 산재하고, 타입 변환/기본값/검증이 흩어지기 쉽습니다.
  • @ConfigurationProperties: 특정 prefix 아래 설정을 한 클래스에 응집하고, 필드를 타입으로 선언하며, Bean Validation으로 시작 시점에 검증까지 연결할 수 있습니다.

설정을 @ConfigurationProperties로 모아두면, 마치 “설정 전용 DTO”처럼 동작합니다. 코드 곳곳에 흩어진 @Value는 여러 사람이 동시에 메모를 붙여놓은 벽면 같고, @ConfigurationProperties는 문서화된 설정 파일 한 권에 정리해 둔 느낌에 가깝습니다.

@Value vs @ConfigurationProperties 비교

항목 @Value @ConfigurationProperties
설정 위치 코드 전반에 분산되기 쉬움 prefix 기준으로 한 클래스에 응집
타입 안전성 제한적(SpEL/문자열 의존) 강함(필드 타입 기반 바인딩)
검증(Validation) 직접 if 체크 필요 @Validated + Bean Validation로 자동
유지보수 설정 늘수록 추적 어려움 설정 구조가 명확하고 확장 쉬움
추천 용도 정말 단일 값 1~2개 기능 단위 설정 묶음(대부분의 경우)

설정 바인딩과 검증 흐름(부팅 시점)

Spring Boot configuration properties binding and validation flow

flowchart LR
  A["application.yml"] --> B["Binder"]
  B --> C["@ConfigurationProperties Bean"]
  C --> D["Bean Validation"]
  D --> E["ApplicationContext Ready"]

부팅 시 설정이 바인딩되고 검증에 실패하면, 애플리케이션이 “조용히 잘못된 값으로 실행”되지 않고 바로 실패합니다. 운영에서 가장 무서운 건 “실패”가 아니라 “조용히 잘못 동작”이기 때문에, 이 차이는 생각보다 큽니다.
위 다이어그램은 Spring Boot가 설정을 바인딩한 뒤 검증을 거쳐 컨텍스트를 준비하는 흐름을 보여줍니다.

코드 예제 — @ConfigurationProperties + Validation로 설정 클래스 분리하기

아래 코드는 Spring Boot 3.x / Java 17 기준으로 그대로 복붙해서 실행할 수 있는 형태입니다. 예시는 “외부 API 호출”에 필요한 설정을 app.external prefix로 묶고, URL/타임아웃/재시도 횟수를 검증합니다.

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

application.yml

app:
  external:
    base-url: "https://api.example.com"
    connect-timeout: 1s
    read-timeout: 2s
    retry:
      max-attempts: 3

설정 클래스: ExternalApiProperties.java

package com.example.demo;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import org.hibernate.validator.constraints.URL;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

import java.time.Duration;

@Validated
@ConfigurationProperties(prefix = "app.external")
public class ExternalApiProperties {

    @NotNull
    @URL // URL 형식 검증(https://... 같은 형태)
    private String baseUrl;

    @NotNull
    private Duration connectTimeout;

    @NotNull
    private Duration readTimeout;

    @Valid
    @NotNull
    private Retry retry = new Retry();

    public String getBaseUrl() {
        return baseUrl;
    }

    public void setBaseUrl(String baseUrl) {
        this.baseUrl = baseUrl;
    }

    public Duration getConnectTimeout() {
        return connectTimeout;
    }

    public void setConnectTimeout(Duration connectTimeout) {
        this.connectTimeout = connectTimeout;
    }

    public Duration getReadTimeout() {
        return readTimeout;
    }

    public void setReadTimeout(Duration readTimeout) {
        this.readTimeout = readTimeout;
    }

    public Retry getRetry() {
        return retry;
    }

    public void setRetry(Retry retry) {
        this.retry = retry;
    }

    public static class Retry {

        @Min(0)
        @Max(10)
        private int maxAttempts = 0;

        public int getMaxAttempts() {
            return maxAttempts;
        }

        public void setMaxAttempts(int maxAttempts) {
            this.maxAttempts = maxAttempts;
        }
    }
}

포인트는 3가지입니다.

  • @ConfigurationProperties(prefix = "app.external")로 설정을 “한 덩어리”로 받습니다.
  • Duration 같은 타입을 그대로 쓰면 1s, 200ms처럼 사람이 읽기 쉬운 값으로 관리할 수 있습니다.
  • @Validated + 제약 애노테이션으로 부팅 시점 검증이 됩니다(틀리면 즉시 실패).

설정 등록: DemoApplication.java

Spring Boot 3에서는 @ConfigurationPropertiesScan으로 패키지 스캔 등록을 많이 씁니다.

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@ConfigurationPropertiesScan
@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

사용 예: ExternalApiClient.java

package com.example.demo;

import org.springframework.stereotype.Component;

@Component
public class ExternalApiClient {

    private final ExternalApiProperties props;

    public ExternalApiClient(ExternalApiProperties props) {
        this.props = props;
    }

    public String describe() {
        return "baseUrl=" + props.getBaseUrl()
                + ", connectTimeout=" + props.getConnectTimeout()
                + ", readTimeout=" + props.getReadTimeout()
                + ", maxAttempts=" + props.getRetry().getMaxAttempts();
    }
}

확인용 컨트롤러: DebugController.java

package com.example.demo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DebugController {

    private final ExternalApiClient client;

    public DebugController(ExternalApiClient client) {
        this.client = client;
    }

    @GetMapping("/debug/config")
    public String config() {
        return client.describe();
    }
}

이제 application.yml에서 base-url을 비워두거나(null), connect-timeout을 이상한 문자열로 넣거나, retry.max-attempts를 100으로 올리면 애플리케이션이 부팅 단계에서 명확한 에러로 실패합니다. “나중에 장애로 알게 되는 설정 실수”를 “배포 전에 바로 잡는 실수”로 바꿔주는 셈입니다.

실무 팁

💡 실무에서는: 설정 클래스를 “기능 단위”로 쪼개세요
app.external, app.storage, app.security처럼 prefix를 기능 경계로 잡고, 설정 클래스도 그 경계에 맞춰 분리해 보세요. @Value가 많아지는 순간은 보통 “설정의 소유자가 불명확해지는 순간”이라서, 설정을 모아두는 것만으로도 변경 영향도를 크게 줄일 수 있습니다.

💡 실무에서는: 검증은 “반드시 필요한 것만” 강하게 거세요
모든 필드에 무조건 @NotNull을 붙이면 환경별로 유연하게 꺼야 하는 옵션까지 부팅 실패를 만들 수 있습니다. 예를 들어 특정 프로파일에서만 필요한 설정이라면, 프로파일별 yml 분리 또는 별도 설정 클래스로 분리하고, 그 범위 안에서만 검증을 강하게 적용하는 편이 운영에서 더 안전합니다.


핵심 요약: @ConfigurationProperties는 설정을 한곳에 모아 타입 안전하게 관리합니다.
@Validated를 붙이면 잘못된 설정을 부팅 시점에 잡아낼 수 있습니다.
@Value는 단발성에만 쓰고, 기능 단위 설정은 클래스로 분리해 보세요.

다음 글: #09 Spring MVC 요청 처리 흐름 한눈에 보기