Spring Boot Custom Starter 설계

Spring Boot Starter란?

Spring Boot Starter는 특정 기능에 필요한 의존성과 자동 설정을 하나의 모듈로 묶어 제공하는 패턴입니다. spring-boot-starter-web처럼 의존성 하나만 추가하면 관련 설정이 자동으로 적용됩니다. 이 글에서는 팀 내부용 Custom Starter를 직접 만드는 방법 — 자동 설정, @ConfigurationProperties, META-INF/spring 등록, 조건부 설정까지 심화 내용을 다룹니다.

Starter 프로젝트 구조

Custom Starter는 보통 두 모듈로 구성합니다:

my-starter/
├── my-starter-autoconfigure/     # 자동 설정 로직
│   ├── src/main/java/
│   │   └── com/example/starter/
│   │       ├── MyServiceAutoConfiguration.java
│   │       ├── MyServiceProperties.java
│   │       └── MyService.java
│   └── src/main/resources/
│       └── META-INF/
│           └── spring/
│               └── org.springframework.boot.autoconfigure.AutoConfiguration.imports
└── my-starter/                   # 의존성만 모은 빈 모듈
    └── build.gradle              # autoconfigure + 필요 라이브러리 의존

소규모 Starter는 하나의 모듈로도 충분합니다. 이 글에서는 단일 모듈 방식으로 설명합니다.

@ConfigurationProperties: 설정 바인딩

사용자가 application.yml에서 Starter 동작을 제어할 수 있도록 Properties 클래스를 정의합니다:

@ConfigurationProperties(prefix = "my.notification")
public class NotificationProperties {

    /**
     * 알림 기능 활성화 여부
     */
    private boolean enabled = true;

    /**
     * Slack Webhook URL
     */
    private String slackWebhookUrl;

    /**
     * 기본 채널 이름
     */
    private String defaultChannel = "#general";

    /**
     * 재시도 설정
     */
    private Retry retry = new Retry();

    // Getters, Setters...

    public static class Retry {
        private int maxAttempts = 3;
        private long delayMs = 1000;
        private double multiplier = 2.0;
        // Getters, Setters...
    }
}
# 사용자의 application.yml
my:
  notification:
    enabled: true
    slack-webhook-url: https://hooks.slack.com/services/xxx
    default-channel: "#alerts"
    retry:
      max-attempts: 5
      delay-ms: 2000

서비스 클래스 구현

public class NotificationService {

    private final NotificationProperties properties;
    private final RestClient restClient;

    public NotificationService(NotificationProperties properties,
                                RestClient.Builder restClientBuilder) {
        this.properties = properties;
        this.restClient = restClientBuilder.build();
    }

    public void sendSlack(String message) {
        sendSlack(properties.getDefaultChannel(), message);
    }

    public void sendSlack(String channel, String message) {
        if (!properties.isEnabled()) {
            return;
        }

        Map<String, String> payload = Map.of(
            "channel", channel,
            "text", message
        );

        executeWithRetry(() ->
            restClient.post()
                .uri(properties.getSlackWebhookUrl())
                .contentType(MediaType.APPLICATION_JSON)
                .body(payload)
                .retrieve()
                .toBodilessEntity()
        );
    }

    private void executeWithRetry(Runnable action) {
        var retry = properties.getRetry();
        long delay = retry.getDelayMs();

        for (int i = 0; i < retry.getMaxAttempts(); i++) {
            try {
                action.run();
                return;
            } catch (Exception e) {
                if (i == retry.getMaxAttempts() - 1) throw e;
                try { Thread.sleep(delay); } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
                delay = (long) (delay * retry.getMultiplier());
            }
        }
    }
}

AutoConfiguration: 자동 설정

핵심인 자동 설정 클래스입니다. @Conditional 어노테이션으로 조건부로 Bean을 등록합니다:

@AutoConfiguration
@EnableConfigurationProperties(NotificationProperties.class)
@ConditionalOnProperty(
    prefix = "my.notification",
    name = "enabled",
    havingValue = "true",
    matchIfMissing = true    // 설정 없으면 기본 활성화
)
public class NotificationAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean   // 사용자가 직접 정의하면 덮어쓰지 않음
    public NotificationService notificationService(
            NotificationProperties properties,
            RestClient.Builder restClientBuilder) {
        return new NotificationService(properties, restClientBuilder);
    }

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnBean(MeterRegistry.class)  // Micrometer 있을 때만
    public NotificationMetrics notificationMetrics(
            MeterRegistry registry) {
        return new NotificationMetrics(registry);
    }
}

주요 @Conditional 어노테이션들입니다:

어노테이션 조건
@ConditionalOnMissingBean 해당 타입의 Bean이 없을 때만 등록
@ConditionalOnBean 특정 Bean이 존재할 때만 등록
@ConditionalOnClass 클래스패스에 특정 클래스가 있을 때
@ConditionalOnProperty 설정 프로퍼티 값에 따라
@ConditionalOnWebApplication 웹 애플리케이션일 때만

이 조건부 설정에 대한 더 깊은 내용은 Spring @Conditional 자동 설정을 참고하세요.

Spring Boot 3 등록: AutoConfiguration.imports

Spring Boot 3에서는 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 파일에 자동 설정 클래스를 등록합니다:

# src/main/resources/META-INF/spring/
# org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.example.starter.NotificationAutoConfiguration

주의: Spring Boot 2.x의 spring.factories 방식은 3.x에서 deprecated입니다.

IDE 자동완성: additional-spring-configuration-metadata.json

사용자가 application.yml에서 자동완성과 설명을 볼 수 있도록 메타데이터를 추가합니다:

// src/main/resources/META-INF/additional-spring-configuration-metadata.json
{
  "properties": [
    {
      "name": "my.notification.enabled",
      "type": "java.lang.Boolean",
      "description": "알림 기능 활성화 여부. false로 설정하면 모든 알림이 비활성화됩니다.",
      "defaultValue": true
    },
    {
      "name": "my.notification.slack-webhook-url",
      "type": "java.lang.String",
      "description": "Slack Incoming Webhook URL"
    },
    {
      "name": "my.notification.default-channel",
      "type": "java.lang.String",
      "description": "기본 Slack 채널",
      "defaultValue": "#general"
    }
  ],
  "hints": [
    {
      "name": "my.notification.default-channel",
      "values": [
        {"value": "#general", "description": "일반 채널"},
        {"value": "#alerts", "description": "알림 채널"},
        {"value": "#dev", "description": "개발 채널"}
      ]
    }
  ]
}

build.gradle 설정

// build.gradle
plugins {
    id 'java-library'
    id 'maven-publish'
    id 'org.springframework.boot' version '3.3.0' apply false
    id 'io.spring.dependency-management' version '1.1.5'
}

dependencyManagement {
    imports {
        mavenBom org.springframework.boot.gradle.plugin
            .SpringBootPlugin.BOM_COORDINATES
    }
}

dependencies {
    api 'org.springframework.boot:spring-boot-starter'
    api 'org.springframework.boot:spring-boot-starter-web'

    // 선택적 의존성 (있으면 활성화)
    compileOnly 'io.micrometer:micrometer-core'

    annotationProcessor 
        'org.springframework.boot:spring-boot-configuration-processor'
    // ↑ ConfigurationProperties 메타데이터 자동 생성

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

// Maven 로컬/사내 저장소에 배포
publishing {
    publications {
        maven(MavenPublication) {
            groupId = 'com.example'
            artifactId = 'my-notification-starter'
            version = '1.0.0'
            from components.java
        }
    }
}

spring-boot-configuration-processor@ConfigurationProperties에서 메타데이터 JSON을 자동 생성합니다.

테스트 작성

@SpringBootTest(classes = NotificationAutoConfiguration.class)
@EnableConfigurationProperties(NotificationProperties.class)
class NotificationAutoConfigurationTest {

    // 기본 설정으로 Bean이 등록되는지
    @Test
    void autoConfiguresNotificationService() {
        new ApplicationContextRunner()
            .withConfiguration(
                AutoConfigurations.of(NotificationAutoConfiguration.class))
            .withPropertyValues(
                "my.notification.slack-webhook-url=https://hooks.slack.com/test")
            .run(context -> {
                assertThat(context).hasSingleBean(NotificationService.class);
            });
    }

    // enabled=false이면 Bean이 없어야 함
    @Test
    void disabledWhenPropertyIsFalse() {
        new ApplicationContextRunner()
            .withConfiguration(
                AutoConfigurations.of(NotificationAutoConfiguration.class))
            .withPropertyValues("my.notification.enabled=false")
            .run(context -> {
                assertThat(context)
                    .doesNotHaveBean(NotificationService.class);
            });
    }

    // 사용자가 직접 Bean을 정의하면 Starter 것은 등록 안 됨
    @Test
    void backsOffWhenUserDefinesBean() {
        new ApplicationContextRunner()
            .withConfiguration(
                AutoConfigurations.of(NotificationAutoConfiguration.class))
            .withUserConfiguration(CustomConfig.class)
            .run(context -> {
                assertThat(context).hasSingleBean(NotificationService.class);
                assertThat(context.getBean(NotificationService.class))
                    .isInstanceOf(CustomNotificationService.class);
            });
    }

    @Configuration
    static class CustomConfig {
        @Bean
        NotificationService notificationService() {
            return new CustomNotificationService();
        }
    }
}

ApplicationContextRunner는 Spring Boot의 자동 설정 테스트를 위한 핵심 도구입니다. 경량 컨텍스트를 빠르게 생성해 검증합니다.

사용자 측 사용법

// build.gradle
dependencies {
    implementation 'com.example:my-notification-starter:1.0.0'
}

// application.yml
my:
  notification:
    slack-webhook-url: ${SLACK_WEBHOOK_URL}
    default-channel: "#backend-alerts"

// 서비스에서 주입받아 사용
@Service
@RequiredArgsConstructor
public class OrderService {

    private final NotificationService notificationService;

    public void createOrder(OrderRequest request) {
        // 주문 생성 로직...
        notificationService.sendSlack(
            "새 주문: " + request.getOrderId()
        );
    }
}

의존성 추가와 yml 설정만으로 기능이 활성화됩니다. 이 선언적 설정 방식은 Spring ApplicationEvent 이벤트 설계와 함께 사용하면 이벤트 기반 알림 시스템을 깔끔하게 구축할 수 있습니다.

운영 베스트 프랙티스

  • 네이밍 규칙: 공식 Starter는 spring-boot-starter-*, 커스텀은 *-spring-boot-starter
  • @ConditionalOnMissingBean 필수: 사용자가 커스터마이징할 수 있도록 항상 추가
  • 선택적 의존성: compileOnly + @ConditionalOnClass로 선택적 기능 제공
  • 문서화: Properties에 Javadoc 주석 작성 → IDE 자동완성에 표시됨
  • 버전 관리: Semantic Versioning 준수, Breaking Change 시 메이저 버전 업
  • Failure Analyzer: 설정 오류 시 친절한 메시지를 제공하는 FailureAnalyzer 구현 고려

마무리

Spring Boot Custom Starter는 팀의 공통 기능을 모듈화하는 가장 Spring스러운 방법입니다. @ConfigurationProperties로 선언적 설정을, @Conditional로 조건부 활성화를, @ConditionalOnMissingBean으로 확장 가능성을 제공하면, 사용자는 의존성 추가만으로 기능을 사용하고 필요 시 커스터마이징할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux