본문 바로가기

타입 안전 외부 설정

ConfigurationProperties로 중첩 설정과 단위를 안전하게 바인딩하고 검증·YAML 재정의·프로필 활성화를 구분합니다.

외부 설정이 열 개를 넘어가면, 문자열 키를 하나씩 조회하는 방식으로는 설정의 구조가 보이지 않습니다. @ConfigurationProperties는 같은 접두사 아래의 값을 하나의 타입으로 묶고, 생성자 바인딩과 검증을 거쳐 애플리케이션이 쓸 수 있는 상태만 받아들입니다.

YAML은 사람이 읽고 쓰는 입력 표현이고, 속성 객체는 코드가 의존하는 설정의 규칙입니다. 이 글에서는 두 층을 구분해, 불변 설정 객체를 만들고, 느슨한 바인딩과 단위 변환, 시작 시 검증, YAML과 프로필의 동작을 차례로 확인합니다.

프로필은 입력 문서를 고르고 바인더는 하나의 유효한 설정 객체를 만든다

프로필 입력·바인딩·변환·선언된 제약 검증·시작 성공 또는 실패의 상태 경계가 있다.

CONFIGURATION PROPERTIES · BIND AND VALIDATE

프로필은 문서를 선택하고 바인더는 타입을 만든다

YAML/properties는 입력 표현이고 ConfigurationProperties는 코드의 설정 계약입니다. 문서 선택·우선순위·타입 변환과 의미 검증을 구분합니다.

변환 실패와 선언된 제약 위반을 구별한다설정 등록과 검증 공급자가 준비됐다는 전제다. 변환 성공 후 BoardStorageProperties에 선언된 NotNull/Min/Max와 존재하는 Retry의 Valid를 검사한다. null Retry·HTTP·음수 Duration을 추가 정책 없이 자동 거부하지 않는다. 변환 실패와 제약 실패는 시작 실패의 다른 원인이다. 모바일도 같은 5노드5관계이고 실제 바인딩·Spring 시작은 ContextRunner로 별도 확인한다.외부 문자열키·단위속성 객체타입 변환 성공선언된 제약통과?서비스에 주입추가 정책은 별도시작 실패속성 경로 확인예아니요변환 실패
변환 실패와 선언된 제약 위반을 구별한다설정 등록과 검증 공급자가 준비됐다는 전제다. 변환 성공 후 BoardStorageProperties에 선언된 NotNull/Min/Max와 존재하는 Retry의 Valid를 검사한다. null Retry·HTTP·음수 Duration을 추가 정책 없이 자동 거부하지 않는다. 변환 실패와 제약 실패는 시작 실패의 다른 원인이다. 모바일도 같은 5노드5관계이고 실제 바인딩·Spring 시작은 ContextRunner로 별도 확인한다.외부 문자열키·단위속성 객체타입 변환 성공선언된 제약통과?서비스에 주입추가 정책은 별도시작 실패속성 경로 확인예아니요변환 실패

입력 → 설정 객체

  1. 프로필과 소스에서 입력 선택

    활성 프로필로 문서를 고르고 같은 키의 우선순위를 적용합니다. 프로필 이름만으로 안전성을 보장하지 않습니다.

  2. 완화된 바인딩과 타입 변환

    정규 batch-size 키, Duration·URI·중첩 객체를 바인딩합니다. 예시 단위는 ms/s/MB처럼 명시합니다.

  3. 등록과 실제 시작 검증

    ConfigurationPropertiesScan 또는 EnableConfigurationProperties로 등록합니다. Validated·검증 공급자와 실제 실패를 확인합니다.

원문이 검사하는 범위

현재: endpoint·timeout 필수, batchSize 1~500, retry가 있으면 maximumAttempts 1~5와 backoff 필수입니다.

추가할 정책: HTTPS, 양수 Duration, retry 필수는 원문이 검사하지 않습니다. @Valid만으로 retry 누락을 거부하지 않습니다.

목록: 높은 소스가 regions 목록 전체를 교체합니다. 한 원소만 바꿔 낮은 목록의 나머지가 병합된다고 가정하지 않습니다.

생성자 테스트와 바인더 테스트는 다른 증거입니다. 프로필별 최종 객체·목록 교체·누락/경계값·중첩 검증을 확인하고 속성의 필수성·단위·민감성·재시작 계약을 문서화합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 설정 등록과 검증 공급자가 준비됐다는 전제다. 변환 성공 후 BoardStorageProperties에 선언된 NotNull/Min/Max와 존재하는 Retry의 Valid를 검사한다. null Retry·HTTP·음수 Duration을 추가 정책 없이 자동 거부하지 않는다. 변환 실패와 제약 실패는 시작 실패의 다른 원인이다. 모바일도 같은 5노드와 5관계이고 실제 바인딩·Spring 시작은 ContextRunner로 별도 확인한다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1프로필은 입력 문서를 고르고 바인더는 하나의 유효한 설정 객체를 만든다
6설정 등록과 검증 공급자가 준비됐다는 전제다. 변환 성공 후 BoardStorageProperties에 선언된 NotNull/Min/Max와 존재하는 Retry의 Valid를 검사한다. null Retry·HTTP·음수 Duration을 추가 정책 없이 자동 거부하지 않는다. 변환 실패와 제약 실패는 시작 실패의 다른 원인이다. 모바일도 같은 5노드와 5관계이고 실제 바인딩·Spring 시작은 ContextRunner로 별도 확인한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
프로필 입력 바인딩

선택된 문서를 바인더가 타입으로 변환합니다.

입력: 프로필 문서 · 실패: 변환 실패문자열 자료가 설정 값이 됩니다.
선언된 제약 검사

NotNull·Min·Max와 존재하는 Retry의 Valid를 검사합니다.

검사: 선언된 제약 · 추가 정책: 자동 추가 없음실제 선언한 정책만 시작 조건이 됩니다.
시작 또는 실패

변환·검증을 통과한 설정으로 시작하거나 원인별로 실패합니다.

정상: 설정 객체 · 실패: 시작 실패 · 증거: ContextRunner유효 설정과 실제 시작 증거를 구분합니다.

프로필은 입력 문서를 고르고 바인더는 하나의 유효한 설정 객체를 만든다

프로필 입력·바인딩·변환·선언된 제약 검증·시작 성공 또는 실패의 상태 경계가 있다.

CONFIGURATION PROPERTIES · BIND AND VALIDATE

프로필은 문서를 선택하고 바인더는 타입을 만든다

YAML/properties는 입력 표현이고 ConfigurationProperties는 코드의 설정 계약입니다. 문서 선택·우선순위·타입 변환과 의미 검증을 구분합니다.

변환 실패와 선언된 제약 위반을 구별한다설정 등록과 검증 공급자가 준비됐다는 전제다. 변환 성공 후 BoardStorageProperties에 선언된 NotNull/Min/Max와 존재하는 Retry의 Valid를 검사한다. null Retry·HTTP·음수 Duration을 추가 정책 없이 자동 거부하지 않는다. 변환 실패와 제약 실패는 시작 실패의 다른 원인이다. 모바일도 같은 5노드5관계이고 실제 바인딩·Spring 시작은 ContextRunner로 별도 확인한다.외부 문자열키·단위속성 객체타입 변환 성공선언된 제약통과?서비스에 주입추가 정책은 별도시작 실패속성 경로 확인예아니요변환 실패
변환 실패와 선언된 제약 위반을 구별한다설정 등록과 검증 공급자가 준비됐다는 전제다. 변환 성공 후 BoardStorageProperties에 선언된 NotNull/Min/Max와 존재하는 Retry의 Valid를 검사한다. null Retry·HTTP·음수 Duration을 추가 정책 없이 자동 거부하지 않는다. 변환 실패와 제약 실패는 시작 실패의 다른 원인이다. 모바일도 같은 5노드5관계이고 실제 바인딩·Spring 시작은 ContextRunner로 별도 확인한다.외부 문자열키·단위속성 객체타입 변환 성공선언된 제약통과?서비스에 주입추가 정책은 별도시작 실패속성 경로 확인예아니요변환 실패

입력 → 설정 객체

  1. 프로필과 소스에서 입력 선택

    활성 프로필로 문서를 고르고 같은 키의 우선순위를 적용합니다. 프로필 이름만으로 안전성을 보장하지 않습니다.

  2. 완화된 바인딩과 타입 변환

    정규 batch-size 키, Duration·URI·중첩 객체를 바인딩합니다. 예시 단위는 ms/s/MB처럼 명시합니다.

  3. 등록과 실제 시작 검증

    ConfigurationPropertiesScan 또는 EnableConfigurationProperties로 등록합니다. Validated·검증 공급자와 실제 실패를 확인합니다.

원문이 검사하는 범위

현재: endpoint·timeout 필수, batchSize 1~500, retry가 있으면 maximumAttempts 1~5와 backoff 필수입니다.

추가할 정책: HTTPS, 양수 Duration, retry 필수는 원문이 검사하지 않습니다. @Valid만으로 retry 누락을 거부하지 않습니다.

목록: 높은 소스가 regions 목록 전체를 교체합니다. 한 원소만 바꿔 낮은 목록의 나머지가 병합된다고 가정하지 않습니다.

생성자 테스트와 바인더 테스트는 다른 증거입니다. 프로필별 최종 객체·목록 교체·누락/경계값·중첩 검증을 확인하고 속성의 필수성·단위·민감성·재시작 계약을 문서화합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 설정 등록과 검증 공급자가 준비됐다는 전제다. 변환 성공 후 BoardStorageProperties에 선언된 NotNull/Min/Max와 존재하는 Retry의 Valid를 검사한다. null Retry·HTTP·음수 Duration을 추가 정책 없이 자동 거부하지 않는다. 변환 실패와 제약 실패는 시작 실패의 다른 원인이다. 모바일도 같은 5노드와 5관계이고 실제 바인딩·Spring 시작은 ContextRunner로 별도 확인한다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1프로필은 입력 문서를 고르고 바인더는 하나의 유효한 설정 객체를 만든다
6설정 등록과 검증 공급자가 준비됐다는 전제다. 변환 성공 후 BoardStorageProperties에 선언된 NotNull/Min/Max와 존재하는 Retry의 Valid를 검사한다. null Retry·HTTP·음수 Duration을 추가 정책 없이 자동 거부하지 않는다. 변환 실패와 제약 실패는 시작 실패의 다른 원인이다. 모바일도 같은 5노드와 5관계이고 실제 바인딩·Spring 시작은 ContextRunner로 별도 확인한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
프로필 입력 바인딩

선택된 문서를 바인더가 타입으로 변환합니다.

입력: 프로필 문서 · 실패: 변환 실패문자열 자료가 설정 값이 됩니다.
선언된 제약 검사

NotNull·Min·Max와 존재하는 Retry의 Valid를 검사합니다.

검사: 선언된 제약 · 추가 정책: 자동 추가 없음실제 선언한 정책만 시작 조건이 됩니다.
시작 또는 실패

변환·검증을 통과한 설정으로 시작하거나 원인별로 실패합니다.

정상: 설정 객체 · 실패: 시작 실패 · 증거: ContextRunner유효 설정과 실제 시작 증거를 구분합니다.

그림의 위쪽은 설정 등록과 검증 구현이 준비됐다는 전제에서, 외부 문자열을 속성 객체로 변환한 뒤 선언된 제약을 검사하는 흐름입니다. 변환에 실패하거나 @NotNull, @Min, @Max, 그리고 값이 있는 Retry에 대한 @Valid 검사에 실패하면 시작이 실패하며, 두 실패는 원인이 다릅니다. 통과한 객체는 서비스에 주입되지만, null인 Retry나 HTTP 엔드포인트, 음수 Duration은 추가 정책 없이 저절로 거부되지 않습니다. 아래 칸들은 입력이 객체가 되기까지의 단계와, 이 예제가 실제로 검사하는 범위와 검사하지 않는 범위를 나눠 정리합니다.


불변 설정 객체

회원 게시판의 저장소 설정에는 엔드포인트, 타임아웃, 배치 크기, 재시도 정책이 함께 움직입니다. 이 값들을 @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)로 등록합니다. 라이브러리의 자동 구성이라면 공개 설정 타입을 명시하는 뒤쪽 방식이 더 분명합니다.


느슨한 바인딩

자바의 batchSize에는 board.storage.batch-size라는 속성이나, BOARD_STORAGE_BATCHSIZE라는 환경 변수가 연결됩니다. 이것은 배포 시스템마다 다른 이름 표기를 흡수해 주는 규칙일 뿐, 서로 다른 별칭을 얼마든지 허용한다는 뜻은 아닙니다. 문서와 메타데이터에는 정규형인 케밥 표기(kebab-case) 키 하나를 씁니다.

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

더 깊이: 환경 변수 이름은 어떤 규칙으로 속성이 될까

Spring Boot 문서의 외부 설정은 정규형 속성 이름을 환경 변수 이름으로 바꾸는 규칙을 세 가지로 정리합니다. 점은 밑줄로 바꾸고, 대시는 지우고, 대문자로 바꿉니다. board.storage.batch-size가 BOARD_STORAGE_BATCHSIZE가 되는 이유가 이것이며, 목록의 원소는 번호를 밑줄로 감싸 my.service[0].other를 MY_SERVICE_0_OTHER로 적습니다. 이 변환은 systemEnvironment 속성 소스와 이름이 -systemEnvironment로 끝나는 소스에만 적용되므로, 다른 방식으로 넣은 속성 소스에 대문자 키를 넣으면 같은 바인딩을 기대할 수 없습니다.

맵에는 함정이 하나 있습니다. 같은 문서에 따르면 Boot는 환경 변수를 속성 클래스에 바인딩하기 전에 변수 이름을 소문자로 바꾸므로, 환경 변수로 넣은 맵의 키는 언제나 소문자가 됩니다. MY_PROPS_VALUES_KEY=VALUE라면 맵에는 key라는 키로 VALUE가 들어가며, 값은 바뀌지 않습니다. 또 설정 파일에서 맵의 키에 영숫자, -, . 말고 다른 문자가 있다면 my.map[/key1]처럼 대괄호로 감싸야 원래 키가 보존되고, 그렇지 않으면 그 문자가 지워집니다. 지역 코드처럼 대소문자나 특수 문자가 의미를 갖는 키를 맵으로 받는다면, 환경 변수 대신 설정 파일이나 대괄호 표기로 넣고 바인딩 결과를 테스트로 고정합니다.


시작할 때의 설정 검증

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

중첩 객체를 검증하려면 @Valid를 붙입니다. 다만 이 예제의 retry에는 @NotNull이 없으므로 값이 빠져도 거부하지 않으며, Jakarta Validation 명세에 따라 @Valid는 null 참조를 건너뜁니다. URI가 HTTPS인지, Duration이 양수인지도 검사하지 않습니다. 이런 정책은 연습 문제에서 추가합니다.

설정 클래스에 @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 {
    }
}

750ms가 750밀리초로, retry.maximum-attempts가 중첩 레코드의 3으로, regions[0]이 목록의 첫 원소로 바인딩되는지 확인합니다. 유효하지 않은 값의 분기에서는 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] 하나만 바꿔 놓고 나머지 원소가 합쳐질 것이라 기대하지 않습니다. 최종적으로 결합된 객체를 테스트하고, 프로필별 구성의 차이를 검토합니다.


프로필별 설정 선택

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

src/main/resources/application.yml의 profile 문서
board:
  storage:
    endpoint: "https://storage.internal"
    timeout: 750ms

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

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

위 블록의 첫 문서는 앞 절 application.yml의 기본 문서에서 엔드포인트와 타임아웃만 옮겨 적은 것이고, 그 뒤에 ---로 구분한 production 문서를 덧붙입니다. production 프로필이 활성화되면 아래 문서가 위 문서의 엔드포인트와 타임아웃을 덮어쓰고, 배치 크기와 재시도, 영역 목록은 기본 문서의 값을 그대로 씁니다.

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


설정 메타데이터와 문서

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

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


정리

  • @ConfigurationProperties 레코드는 같은 접두사의 값을 불변 객체로 묶고, @EnableConfigurationProperties나 @ConfigurationPropertiesScan으로 등록합니다.
  • 느슨한 바인딩은 표기 차이를 흡수하고, Duration과 데이터 크기에는 단위를 명시합니다.
  • 타입 변환과 의미 검증은 다른 단계이며, @Valid는 null 참조를 건너뛰므로 필수 여부는 따로 선언합니다.
  • YAML의 목록은 높은 소스가 전체를 교체하고, 프로필은 문서를 고를 뿐 안전성을 보장하지 않으므로 최종 객체를 테스트합니다.

연습 문제

1. 내보내기 설정 객체

회원 게시판의 내보내기 엔드포인트, 연결 타임아웃과 읽기 타임아웃, 배치 크기, 재시도, 영역 목록을 불변 속성으로 만드세요. 로컬과 운영 환경의 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를 적습니다.

2. 세 입력에서 거부되는 곳

변환, 설정 등록, 검증 구현이 모두 준비됐다고 합니다. A는 batchSize=0이고 retry가 없습니다. B는 batchSize=50, retry.maximumAttempts=6, backoff=100ms입니다. C는 batchSize=50이고 retry가 없습니다. BoardStorageProperties의 제약으로 각각 어디서 거부되나요? 연습 1의 TimeoutPolicy(connect=100ms, read=50ms)는 어떤 별도의 관계를 검사하나요?

해설 보기

A는 batchSize의 @Min(1)에서, B는 값이 있는 Retry에 연쇄 검증한 @Max(5)에서 거부됩니다. C는 retry가 null이라 @Valid가 건너뛰므로, 이 누락만으로는 거부되지 않습니다. 재시도를 필수로 만들려면 retry에 @NotNull 같은 제약을 더합니다.

TimeoutPolicy는 읽기 타임아웃이 연결 타임아웃보다 짧은 경우를 생성자에서 거부하므로, 50 ms와 100 ms의 조합은 IllegalArgumentException이 납니다. 두 값이 모두 Duration이라는 타입만으로는 이런 대소 관계가 보장되지 않습니다.

3. 지금의 제약이 놓치는 입력

BoardStorageProperties의 제약만 적용하고 변환은 모두 성공한다고 합니다. endpoint=http://storage.internal, timeout=-1s, batchSize=50이고 retry와 regions는 없습니다. 이 입력은 거부되나요? regions()는 무엇을 돌려주나요?

해설 보기

거부되지 않습니다. endpoint와 timeout은 null이 아니라 @NotNull을 통과하고, batchSize는 1~500 범위 안이며, retry는 null이라 검증을 건너뜁니다. HTTPS 여부와 Duration의 부호, 재시도의 필수 여부는 지금의 제약이 검사하지 않는 정책입니다.

regions는 생성자에서 null을 빈 목록으로 바꾸므로 regions()는 빈 목록을 돌려줍니다. 원하는 보안·시간 정책과 지금의 애노테이션이 실제로 검사하는 범위를 따로 적어 두어야 이런 차이가 보이며, 이 결과는 선언된 제약을 따라 손으로 추론한 것이므로 ApplicationContextRunner로 실제 시작 결과를 확인합니다.

이것으로 14장을 마칩니다. 14장에서는 Spring Boot가 맡는 일과 서블릿 컨테이너를 손으로 조립하는 과정에서 시작해, 내장 Tomcat과 실행 JAR, 스타터와 의존성 관리, 자동 구성의 과정과 조건, 사용자 정의 스타터, 외부 설정의 우선순위와 타입 안전한 바인딩까지 살펴봤습니다. 다음 장의 Actuator 엔드포인트부터는 이렇게 띄운 애플리케이션의 상태와 지표를 운영 환경에서 들여다보는 방법을 다룹니다.