본문 바로가기
빌드노트 검색

빌드노트

Spring 설정에 @Min을 붙여도 음수로 시작하는 이유

@Min은 숫자 범위를 선언하지만 설정 바인딩 자체를 막지는 않는다. Boot 3.5.7의 격리 실행으로 @Validated 전후, 검증 구현체 누락, 정상값과 타입 변환 오류를 비교하고 실제 설정 빈의 등록·검증 조건을 확인한다.

Spring Boot 설정 클래스에 @Min(1)을 붙였는데 demo.retry.max-attempts=-1로도 앱이 시작한다면, 바인딩 성공과 검증 실행을 나눠 확인해야 한다. -1은 int로 변환할 수 있는 값이다. 필드의 제약을 시작 시 검사하려면 실제로 등록된 설정 빈에 검증을 활성화하고, 검증 구현체도 실행 클래스패스에 있어야 한다.

먼저 앱이 주입받는 설정 클래스에 Spring의 @Validated가 있는지 본다. 이어 그 타입이 설정 빈으로 등록되는지, 실행할 때 Hibernate Validator 같은 구현체가 포함되는지 확인한다. 제약 선언만 더 붙여서는 빠진 실행 조건을 채울 수 없다.

설정 클래스와 등록 위치를 함께 확인한다

다음은 최대 시도 횟수가 1 이상이어야 하는 JavaBean 방식 설정이다. 이미 @ConfigurationProperties와 @Min이 있다면 수정할 부분은 클래스의 @Validated다. getter와 setter는 바인딩에 사용하므로 유지한다.

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

@ConfigurationProperties("demo.retry")
@Validated
public class RetryProperties {
    @Min(1)
    private int maxAttempts = 5;

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

여기서 @Min(1)은 허용할 숫자 범위를 선언하고, @Validated는 Boot가 설정 객체의 검증을 시도하도록 한다. Boot의 설정 검증 문서 (새 탭에서 열림)는 이때 Bean Validation 구현체가 필요하다고 설명한다. @Min이 정수의 최솟값을 검사한다는 계약은 Jakarta Validation의 Min API (새 탭에서 열림)에서 확인할 수 있다.

이 클래스가 존재하는 것만으로 설정 빈이 등록되지는 않는다. @ConfigurationPropertiesScan의 탐색 범위에 들어 있는지, 또는 @EnableConfigurationProperties(RetryProperties.class)로 등록하는지 확인한다. 이미 등록돼 있다면 같은 등록을 추가할 필요는 없다. 명시적으로 등록할 경우에는 앱이 실제로 읽는 구성 클래스에 다음 선언을 둔다.

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RetryProperties.class)
class RetryConfiguration {}

이 구성 클래스도 메인 앱의 컴포넌트 탐색에 포함되거나 @Import 등으로 읽혀야 한다. 설정 타입 등록 문서 (새 탭에서 열림)에 두 등록 방식이 설명돼 있다. 코드에서 직접 new RetryProperties()로 만든 객체는 이 설정 빈의 바인딩·검증 경로를 거치지 않는다.

같은 -1이 어디서 멈추는지 비교했다

실제 앱과 분리해 Boot 3.5.7, Spring Framework 6.2.12, Jakarta Validation API 3.0.2, Hibernate Validator 8.0.3.Final을 사용하는 최소 앱을 만들었다. Windows 11과 OpenJDK 17.0.2에서 실행했고, 운영 설정 대신 합성 명령행 설정만 넣었다. 웹 서버는 열지 않았으며 SpringApplication.run()이 컨텍스트를 반환하는지를 시작 성공으로 삼았다.

설정 클래스를 등록하고 검증 구현체도 둔 채, 위 클래스의 @Validated만 제거한 경우와 추가한 경우를 비교했다. 기본값은 5였지만 두 실행에 같은 --demo.retry.max-attempts=-1을 넣었다. 비교용 코드는 시작에 성공하면 해당 타입의 빈 수와 getter 값을 출력하도록 했다.

@Validated 없음: STARTED retryBeans=1 maxAttempts=-1
@Validated 있음: 시작 실패, BindValidationException

첫 실행은 종료 코드 0이었다. 빈이 하나 있고 getter가 -1을 반환했으므로 설정 키가 연결되지 않아 기본값이 남은 상황도 아니었다. 음수의 바인딩은 성공했고, 범위 제약이 실행되지 않은 것이다.

두 번째 실행은 종료 코드 1이었다. 실패 로그에는 다음 내용이 있었다. 영어 메시지는 이 비교 실행에서 언어를 고정한 결과이며, 환경에 따라 문구는 달라질 수 있다.

Property: demo.retry.maxAttempts
Value: "-1"
Origin: "demo.retry.max-attempts" from property source "commandLineArgs"
Reason: must be greater than or equal to 1

예외 원인을 따라가면 ConfigurationPropertiesBindException → BindException → BindValidationException이었다. 필드 오류도 maxAttempts, 거절된 값 -1, 제약 코드 Min으로 확인했다. 가장 바깥의 “바인딩 실패”만 읽으면 타입 변환 실패와 혼동하기 쉽다. 안쪽 원인이 검증 오류인지, 어느 필드의 어떤 제약인지까지 읽어야 수정할 위치가 정해진다.

입력만 3으로 바꾼 검증 활성화 실행은 종료 코드 0, 출력은 STARTED retryBeans=1 maxAttempts=3이었다. 반대로 abc는 @Validated가 없어도 있어도 종료 코드 1이었다. 두 경우 모두 안쪽 원인이 ConversionFailedException → NumberFormatException이었고 BindValidationException은 없었다. 숫자로 바꿀 수 없는 입력은 이 범위 검증에 도달하기 전에 실패했다.

검증 API가 있다고 구현체까지 있는 것은 아니다

Boot의 parent 또는 BOM으로 의존성 버전을 관리하는 Maven 프로젝트라면, 검증 구현체를 포함하는 다음 의존성을 확인한다. API 라이브러리만 추가한 상태라면 이 부분이 다르다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Boot 검증 안내 (새 탭에서 열림)는 Hibernate Validator를 보통 이 starter로 제공한다고 설명한다. 실제 3.5.7 starter의 POM (새 탭에서 열림)에도 Hibernate Validator 의존성이 있다. 빌드 파일에 선언돼 있는지만 보지 말고, 실행용 의존성에서 제외되거나 누락되지 않았는지도 확인해야 한다.

같은 최소 앱에서 @Validated와 Jakarta Validation API는 유지하고 Hibernate Validator JAR만 실행 클래스패스에서 뺐다. -1은 NoProviderFoundException으로 시작에 실패했다. 3도 같은 예외로 실패했다. 이것은 숫자 범위 위반이 아니라 검증기를 만들 수 없는 상태였다. 따라서 “구현체가 없으면 잘못된 설정도 조용히 통과한다”고 일괄 설명하면 이 실행 결과와 맞지 않는다.

위 import는 Boot 3.5.7에서 사용한 jakarta.validation.constraints.Min이다. 다른 세대의 예제에서 가져온 javax.validation 제약과 섞어 쓰지 않는다. 이번 비교는 javax.validation 조합의 동작까지 검증하지 않았다.

등록 여부도 별도 대조했다. @Validated와 구현체를 유지한 채 RetryProperties 등록만 뺀 실행은 시작했지만 출력이 STARTED retryBeans=0이었다. 이 최소 앱에는 해당 타입을 필수로 주입받는 소비자를 두지 않았다. 시작 성공만으로 그 설정 클래스가 검증됐다고 볼 수 없는 이유다.

수정 뒤에는 음수·정상값·문자열을 따로 넣는다

자신의 로컬 검증용 앱에서는 변경을 반영한 실행 JAR로 다음 세 입력을 비교한다. 파일 이름은 자신의 JAR 이름으로 바꾸고, 설정 키와 @Min의 경계도 실제 클래스에 맞춘다. 다음은 Windows의 PowerShell 7에서 입력할 명령이다. 각 명령은 별도 실행이며, 웹 앱이 정상 기동해 계속 실행된다면 종료한 뒤 다음 입력을 검사한다.

java "-Dfile.encoding=UTF-8" -jar app.jar --demo.retry.max-attempts=-1
java "-Dfile.encoding=UTF-8" -jar app.jar --demo.retry.max-attempts=3
java "-Dfile.encoding=UTF-8" -jar app.jar --demo.retry.max-attempts=abc

-Dfile.encoding=UTF-8은 Java 17에서 로그 인코딩을 고정하기 위한 옵션이며, 전체를 따옴표로 감싸 하나의 인자로 전달한다. 인용하지 않은 명령은 PowerShell 7.6.6에서 .encoding=UTF-8을 메인 클래스로 읽는 ClassNotFoundException으로 실패했다. 이 경우에는 Spring 검증까지 도달하지 못한다. 외부 프로그램의 인자도 먼저 셸이 해석한다는 점은 PowerShell의 인자 처리 문서 (새 탭에서 열림)에서 확인할 수 있다.

격리 앱에서 위 명령 문자열을 PowerShell 7.6.6으로 실행해 각각 범위 검증 실패, 시작 성공과 getter 값 3, 타입 변환 실패를 확인했다. @Validated가 없는 JAR에는 같은 음수 명령을 실행해 시작 성공과 getter 값 -1도 대조했다. 사용한 작은 JAR은 외부 라이브러리를 참조하는 일반 실행 JAR이므로, 이 결과가 Boot 실행 JAR의 패키징까지 검증한 것은 아니다.

자신의 앱에서 -1이 여전히 통과하면 실제 주입받는 빈의 클래스·등록 경로와 getter 값을 먼저 확인한다. 다른 값이 들어 있다면 검증 제약을 고치기 전에 설정 키나 덮어쓴 입력을 조사해야 한다. 3도 provider 예외로 실패하면 의존성을, abc만 실패하면 범위 검증의 활성화 조건을 다시 본다.

여기서 확인한 범위는 기본 검증 그룹을 쓰는 JavaBean 설정 빈의 시작 시 검증이다. 생성자 바인딩, 사용자 정의 Validator, 지연 초기화, 중첩 설정과 HTTP 서비스 준비 완료 여부는 검사하지 않았다. 실제 설정값이 들어왔는지와 그 값에 검증이 실행됐는지를 나눠 확인해야, 타입 변환 오류를 막은 것을 숫자 범위까지 보장한 것으로 오해하지 않는다.

← 전체 글 보기