여러 프로젝트에서 동일한 설정 코드를 복사-붙여넣기하고 있다면, 커스텀 스타터로 한 번에 해결할 수 있지 않을까요?

커스텀 스타터란

Spring Boot 스타터는 특정 기능에 필요한 의존성과 자동 설정을 하나의 패키지로 묶은 것입니다. spring-boot-starter-web처럼 의존성만 추가하면 필요한 설정이 자동으로 적용됩니다.

회사 내부의 공통 기능(로깅, 인증, 메트릭 등)을 커스텀 스타터로 만들면 모든 프로젝트에서 일관된 설정을 사용할 수 있습니다.

프로젝트 구조

공식 스타터는 두 개의 모듈로 구성하지만, 단순한 경우 하나의 모듈로 합칠 수 있습니다.

정석 구조 (두 모듈)

PLAINTEXT
my-spring-boot-starter/            ← starter 모듈 (의존성만)
my-spring-boot-autoconfigure/      ← autoconfigure 모듈 (자동 설정 로직)

간단한 구조 (단일 모듈)

PLAINTEXT
my-spring-boot-starter/
├── build.gradle
└── src/main/java/
│   └── com/example/starter/
│       ├── MyAutoConfiguration.java
│       ├── MyProperties.java
│       └── MyService.java
└── src/main/resources/
    └── META-INF/
        └── spring/
            └── org.springframework.boot.autoconfigure.AutoConfiguration.imports

네이밍 규칙

  • 공식 스타터: spring-boot-starter-{name} (예: spring-boot-starter-web)
  • 커스텀 스타터: {name}-spring-boot-starter (예: my-logging-spring-boot-starter)

실전 예제: 공통 로깅 스타터

1. 프로퍼티 클래스

JAVA
@ConfigurationProperties(prefix = "my.logging")
@Getter
@Setter
public class MyLoggingProperties {

    /** 요청/응답 로깅 활성화 여부 */
    private boolean enabled = true;

    /** 요청 바디 로깅 여부 */
    private boolean includeRequestBody = false;

    /** 응답 바디 로깅 여부 */
    private boolean includeResponseBody = false;

    /** 로깅 제외 경로 패턴 */
    private List<String> excludePatterns = List.of(
        "/actuator/**", "/health"
    );
}

2. 핵심 기능 구현

JAVA
public class RequestLoggingFilter extends OncePerRequestFilter {

    private final MyLoggingProperties properties;

    public RequestLoggingFilter(MyLoggingProperties properties) {
        this.properties = properties;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

이어서 나머지 구현 부분입니다.

JAVA
        long startTime = System.currentTimeMillis();

        // 요청 ID 생성
        String requestId = UUID.randomUUID().toString().substring(0, 8);
        MDC.put("requestId", requestId);

        try {
            log.info("[{}] {} {} 시작", requestId,
                request.getMethod(), request.getRequestURI());

이어서 필터 체인을 통해 요청을 다음 단계로 전달하는 부분입니다.

JAVA
            filterChain.doFilter(request, response);

            long duration = System.currentTimeMillis() - startTime;
            log.info("[{}] {} {} 완료 - {}ms, status={}",
                requestId,
                request.getMethod(),
                request.getRequestURI(),
                duration,
                response.getStatus());
        } finally {
            MDC.clear();
        }
    }

이어서 나머지 어노테이션 기반 구현부입니다.

JAVA
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getRequestURI();
        return properties.getExcludePatterns().stream()
            .anyMatch(pattern ->
                new AntPathMatcher().match(pattern, path));
    }
}

3. 자동 설정 클래스

JAVA
@AutoConfiguration
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnProperty(
    prefix = "my.logging",
    name = "enabled",
    havingValue = "true",
    matchIfMissing = true  // 기본 활성화
)
@EnableConfigurationProperties(MyLoggingProperties.class)
public class MyLoggingAutoConfiguration {

이어서 @Bean을 적용한 나머지 구현부입니다.

JAVA
    @Bean
    @ConditionalOnMissingBean  // 사용자가 직접 정의하면 우선
    public RequestLoggingFilter requestLoggingFilter(
            MyLoggingProperties properties) {
        return new RequestLoggingFilter(properties);
    }

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnClass(
        name = "io.micrometer.core.instrument.MeterRegistry")
    public RequestMetricsCollector requestMetricsCollector(
            MeterRegistry registry) {
        return new RequestMetricsCollector(registry);
    }
}

4. AutoConfiguration.imports 등록

PLAINTEXT
# src/main/resources/META-INF/spring/
# org.springframework.boot.autoconfigure.AutoConfiguration.imports

com.example.starter.MyLoggingAutoConfiguration

이 파일에 FQCN(Fully Qualified Class Name)을 한 줄에 하나씩 작성합니다.

5. build.gradle

GROOVY
plugins {
    id 'java-library'
    id 'maven-publish'
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-autoconfigure'

    // 선택적 의존성 — 없어도 동작하지만 있으면 추가 기능 제공
    compileOnly 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'io.micrometer:micrometer-core'

이어서 나머지 빌드 설정을 추가합니다.

GROOVY
    // 프로퍼티 메타데이터 생성
    annotationProcessor \
        'org.springframework.boot:spring-boot-configuration-processor'
}

// 사용하는 프로젝트에서 불필요한 의존성을 끌고 오지 않도록
java {
    withSourcesJar()
    withJavadocJar()
}

@ConfigurationProperties 메타데이터

spring-boot-configuration-processor를 추가하면 빌드 시 META-INF/spring-configuration-metadata.json이 자동 생성됩니다.

JSON
{
  "groups": [
    {
      "name": "my.logging",
      "type": "com.example.starter.MyLoggingProperties",
      "sourceType": "com.example.starter.MyLoggingProperties"
    }
  ],
  "properties": [
    {
      "name": "my.logging.enabled",

이어서 나머지 구현 부분입니다.

JSON
      "type": "java.lang.Boolean",
      "description": "요청/응답 로깅 활성화 여부",
      "defaultValue": true
    },
    {
      "name": "my.logging.include-request-body",
      "type": "java.lang.Boolean",
      "description": "요청 바디 로깅 여부",
      "defaultValue": false
    }
  ]
}

이 메타데이터 덕분에 IDE에서 my.logging. 까지 입력하면 자동완성이 동작합니다.

사용하는 프로젝트

JAVA
// build.gradle
implementation 'com.example:my-logging-spring-boot-starter:1.0.0'
YAML
# application.yml — 프로퍼티 커스터마이징
my:
  logging:
    enabled: true
    include-request-body: true
    exclude-patterns:
      - /actuator/**
      - /static/**

의존성만 추가하면 자동으로 요청 로깅 필터가 등록됩니다. 프로퍼티로 동작을 커스터마이징하고, 필요하면 빈을 직접 정의하여 오버라이드할 수 있습니다.

JAVA
// 사용자가 직접 필터를 정의하면 자동 설정의 필터는 무시됨
@Configuration
public class MyConfig {
    @Bean
    public RequestLoggingFilter requestLoggingFilter(
            MyLoggingProperties properties) {
        // 커스텀 구현
        return new CustomRequestLoggingFilter(properties);
    }
}

테스트

JAVA
@SpringBootTest
class MyLoggingAutoConfigurationTest {

    @Autowired
    private ApplicationContext context;

    @Test
    void autoConfigurationEnabled() {
        assertThat(context.getBean(RequestLoggingFilter.class))
            .isNotNull();
    }
}

// 조건부 테스트
class ConditionalTest {

    private final ApplicationContextRunner contextRunner =
        new ApplicationContextRunner()
            .withConfiguration(AutoConfigurations.of(
                MyLoggingAutoConfiguration.class));

이어서 테스트 코드를 통해 동작을 검증합니다.

JAVA
    @Test
    void disabledWhenPropertyFalse() {
        contextRunner
            .withPropertyValues("my.logging.enabled=false")
            .run(context -> {
                assertThat(context)
                    .doesNotHaveBean(RequestLoggingFilter.class);
            });
    }

이어서 나머지 어노테이션 기반 구현부입니다.

JAVA
    @Test
    void userBeanTakesPrecedence() {
        contextRunner
            .withBean(RequestLoggingFilter.class,
                () -> new CustomFilter())
            .run(context -> {
                RequestLoggingFilter filter =
                    context.getBean(RequestLoggingFilter.class);
                assertThat(filter).isInstanceOf(CustomFilter.class);
            });
    }
}

설계 원칙

  1. ** 사용자 설정 우선 **: 항상 @ConditionalOnMissingBean으로 사용자의 빈이 우선하게 합니다.
  2. ** 기본값 제공 **: matchIfMissing = true로 프로퍼티 없이도 동작하게 합니다.
  3. ** 선택적 의존성 **: compileOnly로 필수가 아닌 의존성은 선택적으로 처리합니다.
  4. ** 메타데이터 제공 **: configuration-processor로 IDE 자동완성을 지원합니다.
  5. ** 문서화 **: 프로퍼티의 Javadoc이 메타데이터에 반영되므로 주석을 꼼꼼히 작성합니다.

주의할 점

1. AutoConfiguration.imports 파일 경로를 틀리면 자동 설정이 아무 에러 없이 무시된다

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 파일의 경로나 이름이 정확하지 않으면 스프링부트가 자동 설정 클래스를 인식하지 못합니다. 에러 메시지 없이 조용히 무시되므로, 스타터를 추가했는데 빈이 등록되지 않는 원인을 찾기 매우 어렵습니다. 파일명과 경로를 정확히 확인하세요.

2. @ConditionalOnMissingBean을 빠뜨리면 사용자의 커스텀 빈이 무시된다

자동 설정 클래스의 @Bean 메서드에 @ConditionalOnMissingBean을 붙이지 않으면, 사용자가 같은 타입의 빈을 직접 정의해도 자동 설정 빈이 함께 등록됩니다. 사용자가 커스터마이징하려고 해도 자동 설정 빈이 우선하거나 충돌이 발생합니다. "사용자 설정이 항상 우선한다"는 원칙을 지키려면 반드시 @ConditionalOnMissingBean을 추가하세요.

3. 스타터의 의존성을 implementation으로 선언하면 사용 프로젝트에서 접근할 수 없다

스타터 모듈에서 핵심 의존성을 implementation으로 선언하면, 이 스타터를 사용하는 프로젝트에서 해당 클래스에 접근할 수 없습니다. 사용자가 직접 접근해야 하는 클래스(프로퍼티, 서비스 인터페이스 등)의 의존성은 api로 선언해야 전이 의존성으로 노출됩니다.

정리

  • 커스텀 스타터는 AutoConfiguration 클래스 + @ConfigurationProperties + AutoConfiguration.imports 로 구성됩니다.
  • Spring Boot 3에서는 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 파일에 등록합니다.
  • @ConditionalOnMissingBean으로 사용자 설정이 항상 우선하도록 합니다.
  • spring-boot-configuration-processor로 IDE 자동완성을 지원하세요.
  • ApplicationContextRunner로 조건부 동작을 테스트합니다.
댓글 로딩 중...