본문 바로가기

조건부 자동 구성

조건부 자동 구성의 기본값 양보 규칙을 이해하고 순수 모델의 예상과 작은 Spring 컨텍스트의 확인 방법을 연결합니다.

조건부 구성은 “환경에 따라 if”를 빈 팩토리 메서드마다 흩뜨리지 않고 구성 등록 여부로 표현합니다.

조건을 너무 넓게 쓰면 필요한 빈이 조용히 빠지고, 기본값을 잘못 두면 기능이 예기치 않게 켜집니다.

긍정 일치만큼 부정 결과를 공개 계약으로 다룹니다.

CONDITION MODEL · EIGHT CASES AND REASONS

여덟 입력 조합은 네 결과 그룹으로 읽힌다

본문 ConditionExpectation 모델의 예상입니다. C=클래스 존재, P=기능 속성 활성, U=사용자 빈 존재이며 1은 있음/활성, 0은 없음/비활성입니다. 이어지는 작은 Spring 컨텍스트 예제에서는 같은 여덟 입력으로 기본·사용자 빈을 확인합니다.

기본값의 세 조건과 사용자 빈의 보존원 순수 분기 모델에서 C/P/U는 불리언이다. 기본 빈은 C&&P&&!U, 사용자 빈 수는 U, 합계는 그 합이다. 사용자 빈은 기본 조건 불일치로 제거되지 않는다. 화살표는 논리 입력의 의존 관계이며 Boot의 실제 조건 평가 순서가 아니다. 모바일도 같은 6노드6관계이다. ApplicationContextRunner의 실제 실행은 별도로 확인해야 한다.C:클래스 있음P:속성 활성U:사용자 빈 있음기본 빈 조건C ∧ P ∧ ¬U기본 빈 수조건 참이면 1전체 빈 수 = 기본 빈 수 + UU개
기본값의 세 조건과 사용자 빈의 보존원 순수 분기 모델에서 C/P/U는 불리언이다. 기본 빈은 C&&P&&!U, 사용자 빈 수는 U, 합계는 그 합이다. 사용자 빈은 기본 조건 불일치로 제거되지 않는다. 화살표는 논리 입력의 의존 관계이며 Boot의 실제 조건 평가 순서가 아니다. 모바일도 같은 6노드6관계이다. ApplicationContextRunner의 실제 실행은 별도로 확인해야 한다.C클래스있음P속성 활성U사용자 빈 있음기본 빈 조건C ∧ P ∧ ¬U기본 빈 수조건 참이면 1전체 빈 수기본 빈 수 + UU개

C=0 · 클래스 없음 → SKIP

C0 · P0 · U0

C0 · P0 · U1

C0 · P1 · U0

C0 · P1 · U1

네 조합 모두 class missing이 모델의 첫 이유입니다. 기본값을 생성하지 않습니다.

C=1, P=0 · 속성 비활성 → SKIP

C1 · P0 · U0

C1 · P0 · U1

클래스는 있지만 property disabled가 먼저 선택됩니다. 사용자 빈이 있다면 그 존재 자체를 지우는 뜻은 아닙니다.

C=1, P=1, U=1 → BACK-OFF

C1 · P1 · U1

기능은 가능하지만 사용자가 같은 역할을 소유합니다. 기본값은 양보하고 사용자 빈을 유지합니다.

C=1, P=1, U=0 → CREATE

C1 · P1 · U0

클래스와 기능 조건이 맞고 사용자 소유자가 없으면 기본 빈을 만듭니다.

모델의 class → property → owner 이유 순서는 Boot 보고서의 전체 평가 순서가 아닙니다. 본문 컨텍스트 예제는 정상 시작·빈 개수·사용자 동일성과 소스별 보고 항목을 구별합니다. 클래스나 속성이 불일치해도 별도 사용자 빈은 유지하며, 구성 조건이 불일치하면 기본 빈 메서드의 보고 항목이 없을 수 있습니다.


클래스 조건

선택적 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인 것이 정상인지 필수 기능 누락인지 테스트 이름으로 구분합니다.

부정 일치는 오류가 아니라 어느 구성이 기능을 제공할지 정한 결과일 수 있습니다.

예를 들어 사용자가 메트릭 내보내기 빈을 제공했을 때 기본 구현의 @ConditionalOnMissingBean이 false인 것은 사용자 빈을 우선한 정상 결과입니다.

반대로 필요한 내보내기 라이브러리가 없는데 애플리케이션이 메트릭 전송을 필수 기능으로 선언했다면 컨텍스트가 조용히 시작하는 대신 별도 필수 속성 검증으로 실패시켜야 합니다.

선택 기능과 필수 기능을 조건 하나에 섞지 않습니다.

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이나 부모 컨텍스트 검색 결과를 다음으로 살펴봅니다.

사용자 빈이 자식 컨텍스트에만 있고 조건 검색이 부모에서 평가되면 기대와 다른 기본값이 생길 수 있으므로 컨텍스트 계층 테스트 구성을 별도로 둡니다.

컬렉션이나 제네릭 빈을 조건으로 삼을 때도 원시 타입 하나로 판단하지 말고 파라미터화된 컨테이너를 포함하는지 명시합니다.

먼저 아래 모델로 기본 빈의 예상 개수와 설명용 이유를 정합니다. 이 코드는 Spring 컨텍스트를 시작하지 않고 세 불리언으로 값을 계산합니다.

CLASS_MISSING 같은 문자열도 이 모델이 정한 이름입니다. 클래스 → 속성 → 사용자 순서로 이유 하나를 선택할 뿐, Boot가 모든 조건을 이 순서로 평가하거나 보고서에 같은 문자열을 기록한다는 뜻은 아닙니다.

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");
    }
}

모델을 작은 컨텍스트와 연결하기

예상을 정했으면 같은 입력을 ApplicationContextRunner에 전달합니다. 앞의 MeterRegistry는 일반 메트릭 API의 클래스이고, 이번에는 연습 문제의 Prometheus 구현 클래스인 io.micrometer.prometheusmetrics.PrometheusMeterRegistry를 조건으로 사용합니다.

누적 프로젝트의 빌드 설정에 있는 테스트 의존성을 유지하고, 기존 dependencies 블록에 다음 한 줄을 추가합니다. 버전은 현재 프로젝트의 Boot BOM이 관리하도록 둡니다. C=1 사례와 클래스 필터의 인수는 이 클래스가 테스트 클래스 경로에 있다는 전제가 필요합니다.

build.gradle의 dependencies 블록에 추가
testImplementation 'io.micrometer:micrometer-registry-prometheus'

위 ConditionExpectation.java와 다음 테스트를 같은 패키지에 둡니다. C는 클래스 존재, P는 속성 활성, U는 사용자 빈 존재입니다. C=0은 특정 클래스를 숨기는 FilteredClassLoader로 만들고, P는 true 또는 false를 명시하며, U=1은 사용자 구성을 등록합니다. 필터는 컨텍스트가 닫힌 뒤 try 블록을 벗어나면서 닫힙니다.

MetricsExporter.owner()는 메트릭을 보내는 대신 빈 제공자가 default인지 user인지 돌려줍니다. 기본 구현과 UserMetricsExporter 모두 SDK 타입을 필드나 반환형에 쓰지 않으므로 C=0에서도 사용자 빈을 유지할 수 있습니다. 실제 레지스트리 생성이나 메트릭 전송은 이 코드에 없습니다.

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

import io.micrometer.prometheusmetrics.PrometheusMeterRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport;
import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport.ConditionAndOutcomes;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

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

class MetricsConditionsContextTest {
    interface MetricsExporter {
        String owner();
    }

    static final class UserMetricsExporter implements MetricsExporter {
        @Override
        public String owner() {
            return "user";
        }
    }

    @Configuration(proxyBeanMethods = false)
    static class UserConfiguration {
        @Bean
        UserMetricsExporter userMetricsExporter() {
            return new UserMetricsExporter();
        }
    }

    @AutoConfiguration
    @ConditionalOnClass(name = "io.micrometer.prometheusmetrics.PrometheusMeterRegistry")
    @ConditionalOnProperty(
            prefix = "board.metrics.export",
            name = "enabled",
            havingValue = "true",
            matchIfMissing = false)
    static class MetricsAutoConfiguration {
        @Bean
        @ConditionalOnMissingBean(MetricsExporter.class)
        MetricsExporter defaultMetricsExporter() {
            return () -> "default";
        }
    }

    @Test
    void optionalMetricsUsesTheDefaultOnlyWhenAllConditionsMatch() throws Exception {
        for (boolean c : new boolean[]{false, true}) {
            for (boolean p : new boolean[]{false, true}) {
                for (boolean u : new boolean[]{false, true}) {
                    runCase(c, p, u);
                }
            }
        }
    }

    private void runCase(boolean c, boolean p, boolean u) throws Exception {
        var runner = new ApplicationContextRunner()
                .withConfiguration(AutoConfigurations.of(MetricsAutoConfiguration.class))
                .withPropertyValues("board.metrics.export.enabled=" + p);
        if (u) {
            runner = runner.withUserConfiguration(UserConfiguration.class);
        }

        if (c) {
            runner.run(context -> verify(context, c, p, u));
        } else {
            try (var filtered = new FilteredClassLoader(PrometheusMeterRegistry.class)) {
                runner.withClassLoader(filtered).run(context -> verify(context, c, p, u));
            }
        }
    }

    private void verify(AssertableApplicationContext context, boolean c, boolean p, boolean u) {
        var expected = ConditionExpectation.decide(c, p, u);
        String input = "C=" + c + ", P=" + p + ", U=" + u;
        assertThat(context).as(input).hasNotFailed();

        int defaultCount = context.containsBean("defaultMetricsExporter") ? 1 : 0;
        int total = context.getBeansOfType(MetricsExporter.class).size();
        assertThat(defaultCount).as(input).isEqualTo(expected.defaultCount());
        assertThat(total).as(input).isEqualTo(expected.defaultCount() + (u ? 1 : 0));
        assertThat(context.containsBean("userMetricsExporter")).as(input).isEqualTo(u);

        if (u) {
            var supplied = context.getBean("userMetricsExporter", UserMetricsExporter.class);
            assertThat(context.getBean(MetricsExporter.class)).as(input).isSameAs(supplied);
            assertThat(supplied.owner()).isEqualTo("user");
        } else if (expected.defaultCount() == 1) {
            assertThat(context.getBean(MetricsExporter.class).owner()).isEqualTo("default");
        }

        var outcomes = ConditionEvaluationReport.get(context.getBeanFactory())
                .getConditionAndOutcomesBySource();
        String source = MetricsAutoConfiguration.class.getName();
        System.out.printf("%s -> default=%d, total=%d, model=%s, config=%s, method=%s%n",
                input, defaultCount, total, expected.reason(),
                reportState(outcomes.get(source)),
                reportState(outcomes.get(source + "#defaultMetricsExporter")));
    }

    private String reportState(ConditionAndOutcomes outcomes) {
        if (outcomes == null) {
            return "NO_ENTRY";
        }
        return outcomes.isFullMatch() ? "MATCH" : "NO_MATCH";
    }
}

이 테스트는 후보를 AutoConfigurations.of로 직접 지정합니다. 자동 구성 등록 파일을 JAR에서 발견하는 경로를 확인하는 테스트는 아닙니다. 속성 바인딩 자체를 살펴보는 ApplicationContextRunner 예제와도 입력과 확인 대상이 다릅니다.

각 run은 새 컨텍스트를 만들고 콜백 뒤 닫습니다. 먼저 hasNotFailed로 정상 시작을 확인한 다음 기본 빈 개수, 전체 역할 빈 개수, 사용자 빈의 이름과 동일성을 확인합니다. 기본값과 사용자 빈을 둘 다 만든 뒤 @Primary로 하나만 고르는 방식은 전체 개수 검사에서 드러납니다.

다음은 위 구성의 소스 기준 예상입니다. 모든 사례에서 메트릭은 선택 기능이므로 정상 시작을 예상하며, 기본 빈은 C=1·P=1·U=0에서만 하나입니다.

CPU기본 빈사용자 빈전체 MetricsExporter모델 이유
000000CLASS_MISSING
001011CLASS_MISSING
010000CLASS_MISSING
011011CLASS_MISSING
100000PROPERTY_DISABLED
101011PROPERTY_DISABLED
110101DEFAULT_CREATED
111011USER_BACK_OFF

P=false는 이번 자동 구성의 기본 빈을 끕니다. 별도 사용자 구성의 빈까지 제거하지 않으므로 U=1이면 C와 P에 관계없이 사용자 빈 하나를 예상합니다. 속성을 아예 누락하는 입력은 이 여덟 조합에 없으며, 그때는 matchIfMissing=false에 따라 기본 빈이 등록되지 않습니다.

출력의 model은 앞의 순수 함수가 선택한 이유이고, config와 method는 실제 보고서의 해당 소스 항목을 요약합니다. 구성 조건이 불일치하면 기본 빈 메서드의 항목은 없을 수 있어 NO_ENTRY도 허용합니다. 보고서의 각 조건 메시지에서 사용한 클래스·속성·빈 입력을 읽고, 모델 이유 문자열과 메시지 전체를 그대로 같다고 검사하지 않습니다. 구성이나 메서드가 일치해도 빈 팩토리에서 예외가 나면 시작에 실패할 수 있으므로 일치 결과만으로 빈 생성 성공을 판단하지 않습니다.

C=1·P=1에서는 구성 항목의 MATCH를 예상합니다. 이때 U=0이면 기본 빈 메서드도 MATCH, U=1이면 사용자 빈에 양보하여 메서드는 NO_MATCH를 예상합니다. C=0 또는 P=0에서는 구성 항목의 NO_MATCH를 예상하고, 메서드 항목이 남지 않을 수 있습니다.

예상과 다르면 C=1의 실제 테스트 클래스 경로와 Prometheus 클래스 이름부터 확인합니다. 다음으로 후보 지정, 속성 키·값, 사용자 빈의 선언 반환형과 검색 범위, 기본 빈 이름을 봅니다. 사용자 빈이 있는데 기본 빈도 생겼다면 누락 조건과 사용자 구성 등록을 확인하고, 시작 검사가 실패했다면 조건 메시지와 별도로 예외의 원인 연쇄를 읽습니다.

이 작은 구성은 부모·자식 컨텍스트나 파라미터화된 컨테이너, 필수 기능 누락 정책을 확인하지 않습니다. Prometheus 레지스트리 빈·HTTP scrape 엔드포인트·실제 전송, 배포 JAR의 후보 발견, Native/AOT와 성능도 별도 확인 대상입니다.


연습 문제

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;
    }
}

위 ConditionCase도 Spring을 시작하지 않는 개수 모델입니다. 앞의 여덟 조합에서 모델이 선택한 이유와 실제 보고서에 남은 소스별 조건 메시지를 나란히 읽어, 기본값 미등록과 사용자 빈 양보를 구별하세요.