본문 바로가기

조건부 자동 구성

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

조건부 구성은 환경에 따라 달라지는 if를 빈 팩토리 메서드마다 흩어 놓지 않고, 구성을 등록할지 말지로 표현합니다. 조건을 너무 넓게 쓰면 필요한 빈이 조용히 빠지고, 기본값을 잘못 두면 기능이 예상치 않게 켜집니다. 그래서 조건이 맞은 결과만큼, 맞지 않은 결과도 사용자에게 약속하는 동작으로 다룹니다.

이 글에서는 클래스, 빈, 속성, 리소스와 웹 조건을 차례로 살펴본 뒤, 세 조건의 여덟 가지 조합을 순수 모델로 예상하고 작은 Spring 컨텍스트로 확인하는 방법을 연결합니다.

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

그림은 이 글 뒤쪽의 ConditionExpectation 모델이 내는 예상입니다. C는 클래스 존재, P는 기능 속성의 활성, U는 사용자 빈의 존재이며, 기본 빈은 C와 P가 참이고 U가 거짓일 때만 하나 생기고, 전체 빈 수는 기본 빈 수에 U를 더한 값입니다. 여덟 조합은 C=0이면 클래스가 없어 건너뛰고, C=1·P=0이면 속성이 꺼져 건너뛰며, C=1·P=1·U=1이면 사용자 빈에 양보하고, C=1·P=1·U=0이면 기본 빈을 만드는 네 그룹으로 나뉩니다. 이유를 클래스, 속성, 사용자 빈 순서로 고르는 것은 모델의 규칙일 뿐이며 Boot 보고서의 평가 순서와 같다는 뜻은 아닙니다.


클래스 조건

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

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로 충돌을 숨기지 않습니다.

더 깊이: 빈 조건은 객체가 아니라 빈 정의를 봅니다

@ConditionalOnMissingBean이 검사하는 대상은 이미 만들어진 빈 객체가 아니라, 그 시점까지 컨텍스트가 처리한 빈 정의입니다. Spring Boot 4.1.2의 API 문서도 이 조건이 지금까지 처리된 빈 정의와만 맞춰 볼 수 있으므로 자동 구성 클래스에서만 쓰라고 강하게 권하고, 후보 빈을 다른 자동 구성이 만든다면 그 구성 뒤에 실행되도록 순서를 지정하라고 합니다. 일반 @Configuration 클래스에 이 조건을 붙이면, 같은 타입의 사용자 빈이 아직 처리되지 않은 시점에 평가되어 기본값이 함께 만들어질 수 있습니다.

빈 정의를 본다는 것은 타입 정보도 정의에 적힌 만큼만 안다는 뜻입니다. Spring Boot 문서는 @Bean 메서드의 반환 타입에 가능한 한 구체적인 타입을 적으라고 하며, 빈 조건의 평가는 메서드 시그니처에 있는 타입 정보에만 기댈 수 있기 때문이라고 설명합니다. 예를 들어 사용자가 실제로는 Clock을 돌려주면서 반환 타입을 Object로 선언했다면, 기본 Clock의 누락 조건은 그 빈을 찾지 못해 두 빈이 함께 생길 수 있습니다. 또 같은 문서는 이 조건을 클래스에 붙이든 각 @Bean 메서드에 붙이든 @Configuration 클래스 자체의 생성을 막지는 않으며, 차이는 클래스 수준의 조건이 맞지 않을 때 그 구성 클래스를 빈으로 등록하지 않는다는 것뿐이라고 덧붙입니다.


속성 조건

기능 플래그가 거짓일 때 빈을 아예 만들지 않을지, 아무 일도 하지 않는 빈을 제공할지 규칙을 정합니다. 속성이 없을 때를 활성으로 볼지 비활성으로 볼지는 더 안전한 쪽을 기준으로 고릅니다. 외부로 데이터를 보내거나 민감한 엔드포인트를 여는 기능은 대개 명시적으로 켜야 동작하게 하는 편이 안전합니다.

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

matchIfMissing = false이므로 속성이 없으면 비활성으로 봅니다. 네 입력에 대해 소스를 기준으로 예상한 결과는 다음과 같습니다.

property branch 소스 기준 예상
enabled missing -> exporter beans=0
enabled false -> exporter beans=0
enabled true -> exporter beans=1
enabled true + user bean -> default beans=0

느슨한 바인딩(relaxed binding)이 속성 키의 표기를 정규화해 주더라도, 환경 변수로의 변환과 목록 인덱스는 따로 테스트합니다.


리소스와 웹 조건

클래스 경로 리소스 조건은 선택적인 템플릿이나 스키마 파일이 있는지 확인할 수 있지만, 쓰기 가능한 파일 시스템이나 내용의 유효성까지 보장하지는 않습니다. 리소스를 찾았는데 파싱에 실패한다면 시작을 즉시 중단합니다.

웹 애플리케이션 조건은 서블릿, 리액티브, 비웹 컨텍스트를 구분합니다. 두 스택의 클래스가 모두 있다면 애플리케이션 유형을 명시하고, 의도하지 않게 들어온 스타터를 제거합니다. 자바 버전, JNDI, 표현식 조건 같은 도구도 있지만, 사용자 정의 조건을 만들기 전에 기존 애노테이션의 조합으로 의도를 표현할 수 있는지 먼저 봅니다.


사용자 정의 조건

구성 파싱 전에 정할 수 있는 클래스 경로 조건과, 빈 레지스트리가 있어야 정할 수 있는 조건은 평가되는 단계가 다릅니다. 사용자 정의 조건이 빈을 너무 일찍 조회하면 생명 주기가 깨집니다.

조건의 결과 메시지에는 키, 기대값, 실제로 있었는지를 비밀 정보 없이 기록합니다. 조건이 거짓이었다는 사실만 남기면 보고서가 도움이 되지 않습니다. 조건에는 결정적인 입력만 쓰고, 원격 API를 호출하지 않습니다.


조건 조합 전수 테스트

전체 애플리케이션 하나를 띄우면 현재 환경의 분기 하나만 볼 수 있습니다. ApplicationContextRunner로 클래스 경로 필터링, 속성 값, 사용자 구성을 조합해 빈 개수와 시작 실패를 확인합니다. 조건이 거짓인 결과가 정상인지 필수 기능의 누락인지는 테스트 이름으로 구분합니다.

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

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=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에서의 후보 발견, 네이티브·AOT 환경과 성능도 따로 확인할 대상입니다.


정리

  • 클래스 조건은 선택적 SDK의 존재를, 속성 조건은 기능의 활성을, 빈 누락 조건은 사용자 빈이 있는지를 봅니다.
  • 선택적 타입은 중첩 구성이나 name 속성으로 격리해, 조건 평가 전에 클래스 해석이 실패하지 않게 합니다.
  • 속성이 없을 때의 동작은 matchIfMissing으로 정하며, 외부 전송 같은 기능은 명시적으로 켜야 동작하게 합니다.
  • 조건 조합은 순수 모델로 예상하고 ApplicationContextRunner로 빈 개수, 사용자 빈의 동일성, 보고서 항목을 확인합니다.

연습 문제

1. 세 조건의 자동 구성 설계

Prometheus 레지스트리 클래스, board.metrics.export.enabled=true, 사용자 메트릭 내보내기 빈이라는 세 조건을 가진 자동 구성을 설계하세요. 2×2×2 분기마다 기본 빈의 수와 조건이 맞지 않은 이유를 표로 작성하세요.

해설 보기

클래스가 없으면 속성이 true여도 기본값을 만들 수 없습니다. 사용자 빈이 있으면 @ConditionalOnMissingBean 조건만 거짓이 될 뿐, 기능 전체가 꺼지는 것은 아닐 수 있습니다. 기본 빈의 수만 계산하는 더 단순한 모델은 다음과 같습니다.

package board.conditional;

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

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

2. 속성 값의 여러 표기

AuditExportAutoConfiguration에서 다음 다섯 입력의 기본 내보내기 빈 수를 구하세요. 속성이 없는 경우, board.audit.export.enabled=false, board.audit.export.enabled=true, board.audit.export.enabled=yes, 환경 변수 BOARD_AUDIT_EXPORT_ENABLED=true만 있는 경우입니다. 사용자 AuditExporter 빈은 없습니다.

해설 보기

속성이 없으면 matchIfMissing = false이므로 0개, false도 havingValue와 달라 0개, true는 1개입니다. yes는 true라는 값과 다르므로 0개입니다. 사람이 읽기에 켜는 뜻처럼 보여도, 조건은 지정한 값과 같은지만 봅니다.

환경 변수 BOARD_AUDIT_EXPORT_ENABLED는 느슨한 바인딩 규칙에 따라 board.audit.export.enabled로 연결되므로 1개입니다. 이런 표기 변환은 규칙이 있지만 오타를 알려 주지는 않으므로, 운영 환경에서 쓸 실제 표기를 테스트 입력에 넣어 둡니다.

3. 누락 조건을 빠뜨리면

MetricsConditionsContextTest의 defaultMetricsExporter에서 @ConditionalOnMissingBean(MetricsExporter.class)를 실수로 지웠습니다. C=1, P=1, U=1 사례에서 verify의 어느 검사가 먼저 실패하나요? 기본 빈에 @Primary를 붙여 이 상황을 숨기려 하면 어떤 검사가 여전히 잡아내나요?

해설 보기

조건이 사라졌으므로 기본 빈이 사용자 빈과 함께 만들어집니다. defaultCount가 1이 되어, 0을 기대한 defaultCount 검사가 가장 먼저 실패합니다. 그 뒤의 전체 개수 검사도 2를 보고 실패할 상태입니다.

@Primary를 붙이면 getBean(MetricsExporter.class)가 기본 빈을 돌려주게 되어 하나를 고를 수는 있지만, defaultCount와 전체 개수 검사는 여전히 실패합니다. 또 getBean(MetricsExporter.class)가 사용자 빈과 같은 객체인지 보는 isSameAs 검사도 실패합니다. 두 빈을 모두 만든 뒤 하나를 고르는 방식으로는 사용자 재정의를 존중했다고 할 수 없다는 점이 이 검사들로 드러납니다.

다음 글 사용자 정의 스타터에서는 지금까지 만든 자동 구성과 조건을 묶어, 다른 애플리케이션이 의존성 하나로 가져다 쓸 수 있는 스타터 모듈을 설계합니다.