본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
14장 : Spring Boot와 외부 설정

타입 안전 외부 설정

느슨한 바인딩·생성자 바인딩·중첩 레코드·지속 시간·DataSize·Jakarta 검증을 적용하고 YAML 목록 재정의와 프로필 활성화를 구분해 회원 게시판 설정이 잘못됐을 때 빠르게 실패하도록 만듭니다.

외부 설정이 열 개를 넘으면 문자열 키를 개별 조회하는 방식은 구조를 잃습니다. @ConfigurationProperties는 같은 접두사의 값을 하나의 타입으로 묶고, 생성자 바인딩과 검증으로 애플리케이션이 사용할 수 있는 상태만 허용합니다. YAML은 사람이 읽는 입력 표현이고 속성 객체는 코드가 의존하는 계약입니다. 두 층을 같은 것으로 취급하지 않습니다.


불변 설정 객체

회원 게시판 저장소 설정에는 엔드포인트, 타임아웃, 배치 크기, 재시도 정책이 함께 움직입니다. 각각 @Value로 흩어 놓으면 키 이름 변경과 관계 검증이 어렵습니다. 레코드 생성자에 바인딩하면 생성 뒤 값이 바뀌지 않고 필수 컴포넌트가 타입에 나타납니다.

src/main/java/board/config/BoardStorageProperties.java
package board.config;

import java.net.URI;
import java.time.Duration;
import java.util.List;

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

@Validated
@ConfigurationProperties("board.storage")
public record BoardStorageProperties(
        @NotNull URI endpoint,
        @NotNull Duration timeout,
        @Min(1) @Max(500) int batchSize,
        @Valid Retry retry,
        List<String> regions
) {
    public BoardStorageProperties {
        regions = regions == null ? List.of() : List.copyOf(regions);
    }

    public record Retry(
            @Min(1) @Max(5) int maximumAttempts,
            @NotNull Duration backoff
    ) {
    }
}

단일 생성자인 레코드에는 별도 생성자-바인딩 애노테이션이 필요하지 않습니다. 컴포넌트 스캔과 혼동하지 말고 @ConfigurationPropertiesScan 또는 @EnableConfigurationProperties(BoardStorageProperties.class)로 등록합니다. 라이브러리 자동 구성은 후자를 사용해 공개 설정 타입을 명시하는 편이 분명합니다.

완화된 바인딩

Java 필드 batchSize에는 board.storage.batch-size, 환경 변수 BOARD_STORAGE_BATCH_SIZE 같은 표기가 연결됩니다. 이는 배포 시스템의 이름 지정 차이를 흡수하는 규칙이지 서로 다른 별칭을 무제한 허용한다는 뜻이 아닙니다. 문서와 메타데이터에는 정규 케밥 표기법 키 하나를 사용합니다.

Duration에는 2s, 500ms 같은 단위를, 데이터 크기에는 10MB 같은 단위를 붙입니다. 단위 없는 숫자는 코드 기본값 단위에 기대게 되어 운영자가 잘못 읽기 쉽습니다. URI, 열거형, 목록, 맵처럼 변환 가능한 타입을 사용하되 의미 검증은 생성자나 Jakarta 제약 조건으로 추가합니다.

시작 시 설정 검증

타입 변환 성공은 유효한 설정이라는 뜻이 아닙니다. 배치 크기 0은 정수지만 처리 반복을 깨뜨리고, HTTP 엔드포인트는 URI지만 비밀 정보 전송 정책을 위반할 수 있습니다. 필드 단일 범위는 제약 조건으로, 여러 필드 관계는 생성자 불변식이나 클래스 수준 검증기로 검사합니다.

중첩 객체 검증에는 @Valid를 붙입니다. 설정 빈에 @Validated가 없거나 검증 공급자가 빠지면 애노테이션이 장식으로 남을 수 있으므로 유효하지 않은 속성으로 컨텍스트가 실패하는 테스트를 둡니다.

검증 실패 관찰
board.storage.batch-size=0
binding target=BoardStorageProperties
field=batchSize
constraint=Min(1)
result=APPLICATION_STARTUP_REJECTED

오류 메시지에는 정규 속성 경로와 거부된 이유를 남기고 비밀번호 원시 값은 마스킹합니다. 기본값으로 보정해 시작할 값과 반드시 거부할 값을 설정 정책 표에 나눕니다.

바인더 단위 테스트

설정 타입의 순수 생성자 테스트만으로 느슨한 바인딩과 단위 변환은 검증되지 않습니다. ApplicationContextRunner에 속성 값을 넣고 속성 빈과 시작 실패를 관찰하면 작은 실제 컨텍스트로 규칙을 고정할 수 있습니다.

src/test/java/board/config/StoragePropertiesContextTest.java
package board.config;

import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;

import static org.assertj.core.api.Assertions.assertThat;

class StoragePropertiesContextTest {
    private final ApplicationContextRunner runner = new ApplicationContextRunner()
            .withUserConfiguration(PropertyConfiguration.class);

    @Test
    void bindsUnitsAndNestedRetry() {
        runner.withPropertyValues(
                "board.storage.endpoint=https://storage.internal",
                "board.storage.timeout=750ms",
                "board.storage.batch-size=50",
                "board.storage.retry.maximum-attempts=3",
                "board.storage.retry.backoff=100ms",
                "board.storage.regions[0]=ap-northeast-2")
                .run(context -> {
                    var properties = context.getBean(BoardStorageProperties.class);
                    assertThat(properties.timeout().toMillis()).isEqualTo(750);
                    assertThat(properties.retry().maximumAttempts()).isEqualTo(3);
                    assertThat(properties.regions()).containsExactly("ap-northeast-2");
                });
    }

    @Configuration(proxyBeanMethods = false)
    @EnableConfigurationProperties(BoardStorageProperties.class)
    static class PropertyConfiguration {
    }
}

유효하지 않은 분기에서는 context.getStartupFailure()과 원인 연쇄의 속성 경로를 검증합니다. 전체 @SpringBootTest 하나로 바인딩 성공만 확인하면 누락 키와 경계값을 놓칩니다.

YAML 평탄화

다음 YAML은 점 표기 속성으로 펼쳐져 같은 바인더에 들어갑니다. 들여쓰기는 구조이므로 탭과 수준 오류를 검토하고, 값에 콜론이나 특수 문자가 있으면 따옴표로 묶습니다. .properties와 YAML을 동시에 유지하면 어느 파일이 기준인지 불분명하므로 주 형식을 하나로 정합니다.

src/main/resources/application.yml
board:
  storage:
    endpoint: "https://storage.internal"
    timeout: 750ms
    batch-size: 50
    retry:
      maximum-attempts: 3
      backoff: 100ms
    regions:
      - ap-northeast-2
      - ap-southeast-1

맵은 키별 재정의가 가능한 경우가 있지만 목록은 높은 소스가 전체 목록을 교체한다고 보는 것이 안전합니다. 운영 환경 프로필에서 regions[0] 하나만 바꿔 나머지가 합쳐질 것이라 기대하지 않습니다. 최종 결합된 객체를 테스트하고 프로필별 구성 차이를 검토합니다.

프로필별 설정 선택

프로필은 로컬·테스트·운영 환경 같은 실행 환경에 따라 구성 문서를 활성화합니다. spring.config.activate.on-profile로 문서가 적용될 조건을 쓰고, 활성 프로필은 명령 라인이나 환경처럼 외부에서 결정합니다. 활성화될 문서 안에서 활성 프로필 자체를 다시 바꾸는 순환 설정은 피합니다.

src/main/resources/application.yml의 profile 문서
board:
  storage:
    endpoint: "https://storage.local"
    timeout: 2s

---
spring:
  config:
    activate:
      on-profile: production

board:
  storage:
    endpoint: "https://storage.production"
    timeout: 500ms

@Profile로 데이터베이스 어댑터 빈 전체를 교체할 수 있지만 프로필 이름이 도메인 코드 곳곳에 퍼지면 조합 수가 급격히 늘어납니다. 기능이 속성 하나로 결정된다면 조건부 속성이 더 직접적입니다. 프로필 그룹은 productionobservabilitysecure-cookie 같은 묶음을 활성화하게 할 수 있지만 실제 활성 프로필 집합을 시작 로그와 테스트에서 확인합니다.

설정 메타데이터·문서

구성 처리기가 생성한 메타데이터는 IDE 자동 완성과 설명 표시를 돕습니다. 사용 중단된 키에는 대체 키와 마이그레이션 기간을 남기고, 알 수 없는 키를 무조건 무시할지 엄격히 거부할지 결정합니다. 공개 스타터는 접두사, 기본값, 필수 여부, 단위, 민감성, 재시작 필요 여부를 버전이 있는 API처럼 관리합니다.

운영에서는 전체 구성 덤프 대신 허용 목록에 등록된 비민감 값과 출처를 보여 줍니다. 프로필 이름만으로 운영 환경 안전성을 가정하지 않고 실제 결합된 설정값을 마스킹된 스냅샷으로 비교합니다.

연습 문제

회원 게시판의 내보내기 엔드포인트, 연결/읽기 타임아웃, 배치 크기, 재시도, 영역 목록을 불변 속성으로 만드세요. 로컬과 운영 환경 YAML 문서를 작성하고 운영 환경에서 HTTP 엔드포인트, 배치 0, 재시도 6이 각각 시작을 막는 테스트를 추가하세요. 목록이 병합된다고 가정하지 말고 최종 regions를 검증하세요.

해설 보기

제약 조건 애노테이션만 나열하기 전에 설정 표에서 허용 범위와 기본값의 근거를 정합니다. 여러 값을 함께 보는 정책은 레코드 생성자에 둡니다.

package board.config;

import java.time.Duration;

public record TimeoutPolicy(Duration connect, Duration read) {
    public TimeoutPolicy {
        if (connect.isNegative() || connect.isZero()) {
            throw new IllegalArgumentException("connect timeout must be positive");
        }
        if (read.compareTo(connect) < 0) {
            throw new IllegalArgumentException("read timeout must not be shorter than connect timeout");
        }
    }
}

운영자가 단위를 빠뜨리지 않도록 문서 예시는 항상 ms, s, MB를 명시합니다.