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

안동민 개발노트

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

조건부 자동 구성

@ConditionalOnClass·OnMissingBean·OnProperty·OnResource·OnWebApplication의 평가 시점과 기본값 양보 규칙을 비교하고, 파싱 단계 클래스 로딩·속성 기본값·검색 스코프·부정 테스트를 설계합니다.

조건부 구성은 “환경에 따라 if”를 빈 팩토리 메서드마다 흩뜨리지 않고 구성 등록 여부로 표현합니다. 조건을 너무 넓게 쓰면 필요한 빈이 조용히 빠지고, 기본값을 잘못 두면 기능이 예기치 않게 켜집니다. 긍정 일치만큼 부정 결과를 공개 계약으로 다룹니다.


클래스 조건

선택적 SDK 타입을 직접 빈 메서드 시그니처에 노출하면 JVM이 구성을 읽는 동안 클래스를 해석해 조건 평가 전에 실패할 수 있습니다. 중첩 구성으로 타입 참조를 격리하거나 애노테이션의 이름 속성을 사용합니다.

src/main/java/board/conditional/FeatureConditions.java
package board.conditional;

import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class FeatureConditions {
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry")
    static class MetricsAvailable {
    }
}

클래스가 있다는 사실은 빈이 있다는 뜻이 아닙니다. 기능이 레지스트리 빈을 요구하면 클래스 조건 뒤 @ConditionalOnBean을 추가하고 구성을 제공할 주체를 명확히 합니다.

빈 누락 조건의 검색 범위

기본값 직렬화기나 시계는 같은 타입 빈이 없을 때만 만듭니다. 부모-자식 컨텍스트, 파라미터화된 컨테이너, 애노테이션 한정자에 따라 검색 결과가 달라질 수 있습니다. 여러 빈이 합법적이면 타입 전체에 적용한 @ConditionalOnMissingBean 조건이 너무 강합니다.

빈 메서드 순서 지정에 의존해 @ConditionalOnMissingBean 조건을 평가하지 않습니다. 자동 구성 단계는 사용자 빈 정의를 먼저 볼 수 있도록 설계되어 있지만 같은 구성에서 조건부 기본값을 복잡하게 엮으면 결과가 불명확해집니다.

사용자 재정의 테스트는 사용자 빈 동일성이 최종 주입됐고 기본값 빈 개수가 0인지 확인합니다. @Primary로 둘 다 만들어 충돌을 숨기지 않습니다.

속성 조건

기능 플래그가 거짓일 때 빈을 전혀 만들지 않는지, 무동작 빈을 제공하는지 규칙을 정합니다. 누락을 활성화로 볼지 비활성화로 볼지는 안전한 기본값을 기준으로 선택합니다. 외부 전송이나 민감 엔드포인트는 명시적 활성화 방식이 보통 안전합니다.

src/main/java/board/conditional/AuditExportAutoConfiguration.java
package board.conditional;

import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;

@AutoConfiguration
@ConditionalOnProperty(
        prefix = "board.audit.export",
        name = "enabled",
        havingValue = "true",
        matchIfMissing = false)
public class AuditExportAutoConfiguration {
    @Bean
    @ConditionalOnMissingBean
    AuditExporter auditExporter() {
        return event -> System.out.println("audit=" + event);
    }

    public interface AuditExporter {
        void export(String event);
    }
}
property branch 관찰
enabled missing -> exporter beans=0
enabled false -> exporter beans=0
enabled true -> exporter beans=1
enabled true + user bean -> default beans=0

느슨한 바인딩이 속성 키 표기를 정규화하더라도 환경 변수 변환과 목록 인덱스는 별도 테스트합니다.

리소스·웹 조건

클래스 경로 리소스 조건은 선택적 템플릿이나 스키마의 존재를 확인할 수 있지만 쓰기 가능한 파일 시스템과 콘텐츠 유효성을 보장하지 않습니다. 리소스가 발견된 뒤 파싱에 실패하면 즉시 시작을 중단합니다.

웹 애플리케이션 조건은 서블릿, 리액티브, 비웹 컨텍스트를 구분합니다. 두 스택 클래스가 모두 있을 때 애플리케이션 타입 선택을 명시하고 의도치 않은 이중 스타터를 제거합니다.

Java 버전, JNDI, 표현식 조건 같은 도구도 있지만 사용자 정의 조건을 만들기 전에 기존 애노테이션 조합으로 의도를 표현할 수 있는지 봅니다.

사용자 정의 조건

구성 파싱 전에 결정 가능한 클래스 경로 조건과 빈 레지스트리가 필요한 조건은 평가 단계가 다릅니다. 사용자 정의 구현이 빈을 너무 일찍 조회하면 생명주기를 깨뜨립니다.

일치 결과에는 키와 기대값, 실제 존재 여부를 비밀 정보 없이 기록합니다. “조건이 false”라는 정보만 남기면 보고서가 도움이 되지 않습니다. 결정적인 입력만 사용하고 원격 API를 조건에서 호출하지 않습니다.

조건 조합 전수 테스트

전체 애플리케이션 하나에서는 현재 환경 분기만 봅니다. ApplicationContextRunner로 클래스 경로 필터링, 속성 값, 사용자 구성을 조합해 빈 개수와 시작 실패를 확인합니다. 조건이 false인 것이 정상인지 필수 기능 누락인지 테스트 이름으로 구분합니다.

부정 일치는 오류가 아니라 어느 구성이 기능을 제공할지 정한 결과일 수 있습니다. 예를 들어 사용자가 메트릭 내보내기 빈을 제공했을 때 기본 구현의 @ConditionalOnMissingBeanfalse인 것은 사용자 빈을 우선한 정상 결과입니다. 반대로 필요한 내보내기 라이브러리가 없는데 애플리케이션이 메트릭 전송을 필수 기능으로 선언했다면 컨텍스트가 조용히 시작하는 대신 별도 필수 속성 검증으로 실패시켜야 합니다. 선택 기능과 필수 기능을 조건 하나에 섞지 않습니다.

negative match와 back-off 판정
registry class=false, enabled=true -> DEFAULT_SKIPPED_CLASS_MISSING
registry class=true, enabled=false -> DEFAULT_SKIPPED_PROPERTY_DISABLED
registry class=true, enabled=true, user=true -> DEFAULT_BACKED_OFF_USER_OWNER
registry class=true, enabled=true, user=false -> DEFAULT_CREATED

조건 보고서를 비교할 때 애노테이션 이름만 보지 말고 결과 메시지에 기록된 입력을 확인합니다. @ConditionalOnProperty가 일치했는데 빈이 없다면 메서드 수준의 @ConditionalOnMissingBean이나 부모 컨텍스트 검색 결과를 다음으로 살펴봅니다. 사용자 빈이 자식 컨텍스트에만 있고 조건 검색이 부모에서 평가되면 기대와 다른 기본값이 생길 수 있으므로 컨텍스트 계층 테스트 구성을 별도로 둡니다. 컬렉션이나 제네릭 빈을 조건으로 삼을 때도 원시 타입 하나로 판단하지 말고 파라미터화된 컨테이너를 포함하는지 명시합니다.

조건 조합이 늘어날수록 불리언 곱을 모두 전체 애플리케이션으로 실행하지 않습니다. 클래스 경로는 FilteredClassLoader, 속성은 실행기의 속성 값, 사용자 빈은 테스트 구성으로 바꿔 2×2×2 표를 실제로 순회합니다. 각 사례는 빈 수뿐 아니라 기본값 빈의 부재, 사용자 빈 동일성 보존, 시작 실패 유무, 보고서 이유 코드를 함께 검증합니다. 이렇게 해야 @Primary가 중복을 숨기거나 matchIfMissing 변경이 조용히 기본값을 켜는 회귀를 잡습니다.

src/test/java/board/conditional/ConditionExpectation.java
package board.conditional;

public record ConditionExpectation(
        boolean registryClass,
        boolean enabled,
        boolean userOwner,
        int defaultCount,
        String reason
) {
    public static ConditionExpectation decide(
            boolean registryClass,
            boolean enabled,
            boolean userOwner
    ) {
        if (!registryClass) {
            return new ConditionExpectation(false, enabled, userOwner, 0, "CLASS_MISSING");
        }
        if (!enabled) {
            return new ConditionExpectation(true, false, userOwner, 0, "PROPERTY_DISABLED");
        }
        if (userOwner) {
            return new ConditionExpectation(true, true, true, 0, "USER_BACK_OFF");
        }
        return new ConditionExpectation(true, true, false, 1, "DEFAULT_CREATED");
    }
}

연습 문제

Prometheus 레지스트리 클래스와 board.metrics.export.enabled=true, 사용자 메트릭 내보내기 빈 세 조건을 가진 자동 구성을 설계하세요. 2×2×2 분기에서 기본값 빈 수와 부정 일치 이유를 표로 작성하세요.

해설 보기

클래스가 없으면 속성이 true여도 기본값을 만들 수 없습니다. 사용자 빈이 있으면 @ConditionalOnMissingBean 조건만 false가 되고 기능 전체가 비활성화되는 것은 아닐 수 있습니다.

package board.conditional;

public record ConditionCase(
        boolean registryClass,
        boolean enabled,
        boolean userBean
) {
    public int defaultBeanCount() {
        return registryClass && enabled && !userBean ? 1 : 0;
    }
}

false 분기의 이유 코드를 별도로 기록하면 조건 보고서 검증이 더 구체적입니다.