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

안동민 개발노트

본문 시작
3장 : 컨테이너·빈·생명주기·스코프

컴포넌트 스캔

컴포넌트 스캔의 시작 패키지와 기본 빈 이름 규칙을 게시판 구조에 적용하고 누락·중복 등록을 컨텍스트 테스트로 고정합니다.

@Component를 붙였는데 주입되지 않거나, 분명 구현은 하나라고 생각했는데 같은 빈 이름 충돌로 애플리케이션이 시작하지 않는 문제가 자주 생깁니다. 컴포넌트 스캔은 클래스 경로 전체를 무작정 찾는 기능이 아닙니다. 지정한 기반 패키지 아래의 후보를 탐색하고 이름을 부여해 빈 정의로 등록하는 과정입니다.


컴포넌트 스캔 경계

@SpringBootApplication에는 @ComponentScan이 포함됩니다. 별도 범위를 지정하지 않으면 이 애노테이션이 붙은 클래스의 패키지부터 하위 패키지를 탐색합니다. 게시판의 메인 클래스를 최상위 업무 패키지에 두면 웹, 애플리케이션, 인프라를 자연스럽게 포함합니다.

src/main/java/board/BoardApplication.java
package board;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BoardApplication {
    public static void main(String[] args) {
        SpringApplication.run(BoardApplication.class, args);
    }
}

board.web.PostControllerboard.application.MemberRegistrationService는 탐색되지만, 형제 패키지인 external.audit.AuditClient는 탐색되지 않습니다. 이를 해결하려고 메인 클래스를 기본값 패키지에 두면 거의 모든 클래스 경로가 후보가 되어 범위가 너무 커집니다. 필요한 외부 구성을 @Import하거나 명시적 @Bean으로 연결하는 편이 경계가 보입니다.

src/test/java/board/config/ScanBoundaryTest.java
package board.config;

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

import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import board.application.MemberRegistrationService;

class ScanBoundaryTest {
    @Test
    void 지정한_package_아래만_탐색한다() {
        try (var context =
                new AnnotationConfigApplicationContext()) {
            context.scan("board.application");
            context.refresh();

            assertThat(context.getBeansOfType(
                    MemberRegistrationService.class)).hasSize(1);
            assertThat(context.containsBean("auditClient"))
                    .isFalse();
        }
    }
}

이 테스트를 실행하려면 MemberRegistrationService가 요구하는 리포지토리도 같은 픽스처에서 제공해야 합니다. 스캔 경계만 확인하려는 테스트가 실제 DB 자동 설정까지 끌어오지 않도록 테스트 구성으로 최소 협력을 등록합니다.

스테레오타입과 빈 이름

@Component가 일반 후보이고 @Controller, @Service, @Repository는 역할을 드러내는 특화 애노테이션입니다. 기본 빈 이름은 클래스의 짧은 이름 첫 글자를 소문자로 바꾼 값입니다.

클래스애노테이션기본 빈 이름
MemberRegistrationService@ServicememberRegistrationService
PostController@RestControllerpostController
MemoryMemberRepository@RepositorymemoryMemberRepository
URLMappingRegistry@Component이름 변환 규칙을 직접 확인해야 함

애플리케이션 의존성 주입은 타입을 우선 사용하므로 기본 이름을 업무 API처럼 외우지 않습니다. 이름을 명시해야 한다면 @Component("memoryRepository")처럼 줄 수 있지만, 문자열 이름이 설정과 테스트 곳곳에 퍼지지 않게 합니다.

@Repository는 단순 표식에 그치지 않고 영속성 예외 변환과 연결될 수 있습니다. 다만 메모리 구현에 무조건 영속성 기술 애노테이션을 붙이기보다 인터페이스와 구현 등록 위치가 어디인지 보고 선택합니다. Java 구성에서 @Bean으로 인프라 어댑터를 등록해도 충분합니다.

빈 이름 충돌

서로 다른 패키지의 클래스라도 같은 명시 이름을 사용하면 한 레지스트리 키를 차지하려고 충돌합니다.

충돌 재현에 저장 기술의 세부 동작은 필요하지 않으므로 픽스처의 계약은 구현 식별자 하나로 제한합니다. 세 파일을 그대로 두면 별도 의존성 없이 컴파일되고, 두 구현을 함께 스캔하는 순간 같은 빈 이름 때문에 컨텍스트가 거부됩니다.

src/main/java/board/domain/MemberRepository.java
package board.domain;

public interface MemberRepository {
    String storageKind();
}
src/main/java/board/infrastructure/memory/MemoryMemberRepository.java
package board.infrastructure.memory;

import org.springframework.stereotype.Repository;
import board.domain.MemberRepository;

@Repository("memberRepository")
public final class MemoryMemberRepository
        implements MemberRepository {
    @Override
    public String storageKind() {
        return "memory";
    }
}
src/main/java/board/infrastructure/jdbc/JdbcMemberRepository.java
package board.infrastructure.jdbc;

import org.springframework.stereotype.Repository;
import board.domain.MemberRepository;

@Repository("memberRepository")
public final class JdbcMemberRepository
        implements MemberRepository {
    @Override
    public String storageKind() {
        return "jdbc";
    }
}

두 패키지를 함께 스캔하면 후보 클래스는 다르지만 제안된 빈 이름과 레지스트리 키가 같습니다.

src/test/java/board/config/ComponentNameCollisionTest.java
package board.config;

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

import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

class ComponentNameCollisionTest {
    @Test
    void 같은_명시_이름의_component는_등록을_거부한다() {
        assertThatThrownBy(() -> {
            try (var context =
                    new AnnotationConfigApplicationContext()) {
                context.scan("board.infrastructure.memory",
                        "board.infrastructure.jdbc");
                context.refresh();
            }
        })
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("memberRepository")
                .hasMessageContaining("conflicts");
    }
}

실제 내부 예외 이름은 ConflictingBeanDefinitionException이지만 공개 API 타입이 아니므로 애플리케이션 테스트가 그 클래스를 직접 가져오기하지 않습니다. 메시지의 빈 이름과 두 컴포넌트 클래스를 확인해 충돌 원인을 고정합니다. 오류를 피하려고 전역 오버라이딩을 켜면 등록 순서에 따라 구현이 바뀔 수 있습니다. 메모리와 JDBC 중 하나를 선택하는 문제는 프로필, 속성 조건, 명시적 구성 같은 정책으로 표현해야 합니다.

실행 결과
ComponentNameCollisionTest
  > 같은_명시_이름의_component는_등록을_거부한다() PASSED
cause: ConflictingBeanDefinitionException
bean name: memberRepository

테스트 픽스처 스캔 방지

테스트 소스 집합도 테스트 실행 클래스 경로에 포함됩니다. src/test/java/board 아래에 범용 @Component 픽스처를 만들고 메인 애플리케이션 전체를 띄우면 운영 컴포넌트와 함께 탐색될 수 있습니다.

문제가 되는 테스트 fixture
package board.support;

@Component
class FakeClock implements Clock {
    // test에서만 쓸 고정 시계
}

이 픽스처가 우연히 전체 스캔에 잡히면 “운영에는 시계 빈이 하나인데 테스트에서는 둘”이 됩니다. 해결 순서는 다음과 같습니다.

  1. 순수 단위 테스트에서는 애노테이션 없이 생성자로 가짜를 직접 전달합니다.
  2. 컨텍스트 테스트가 필요하면 중첩 @TestConfiguration에서 테스트 빈을 명시합니다.
  3. 여러 테스트가 공유하면 운영 패키지와 분리된 테스트 구성을 @Import합니다.
  4. 넓은 제외 필터는 마지막 수단으로 쓰고 제외 이유를 테스트합니다.
명시적인 테스트 구성
@TestConfiguration(proxyBeanMethods = false)
class FixedClockTestConfig {
    @Bean
    Clock fixedClock() {
        return Clock.fixed(
                Instant.parse("2026-07-13T10:00:00Z"),
                ZoneOffset.UTC);
    }
}

@SpringBootTest
@Import(FixedClockTestConfig.class)
class RegistrationTimeIntegrationTest {
    // 필요한 테스트에서만 고정 시계를 추가한다.
}

@TestConfiguration은 일반 @Configuration과 달리 자동 스캔의 대상이 되지 않아 명시적으로 가져온 테스트에서만 적용됩니다. 운영 구현을 교체해야 한다면 무작정 같은 이름을 덮기보다 테스트 슬라이스의 구성과 @Primary 테스트 빈 중 어떤 계약을 검증하는지 분명히 합니다.

스캔·수동 등록 혼합

모든 클래스를 컴포넌트로 만들 필요는 없습니다. 업무 서비스처럼 역할이 명확하고 자동 탐색이 편한 객체는 스캔할 수 있고, 기술 구현 선택이나 외부 클라이언트 생성처럼 환경별 정책이 필요한 객체는 구성에서 등록할 수 있습니다.

대상권장 등록이유
컨트롤러, 애플리케이션 서비스컴포넌트 스캔패키지 역할이 안정적
순수 도메인 엔티티/값등록하지 않음요청마다 코드가 직접 생성
메모리/JDBC 구현 선택@Bean 또는 조건부 구성환경 선택이 명시적
외부 SDK 클라이언트@Bean빌더·타임아웃·인증 정보 필요
테스트 대역테스트 구성운영 스캔과 분리

스캔 범위를 여러 기능 패키지에 세밀하게 나열하면 패키지 이동 때 설정이 쉽게 누락됩니다. 메인 클래스를 적절한 루트에 두고, 경계 밖 통합만 명시적으로 가져오기하는 구조가 읽기 쉽습니다. 반대로 거대한 루트 하나 아래에 운영 코드와 마이그레이션 도구, 실험 코드를 모두 두면 의도하지 않은 후보가 늘어납니다.

연습 문제

board.feature.memberboard.feature.report 두 패키지에 각각 하나의 서비스를 만들고 FeatureScanConfig가 두 패키지만 스캔하게 하세요. board.experimental의 컴포넌트는 제외되어야 합니다. 이어 두 서비스의 클래스 이름을 모두 SummaryService로 만들었을 때 기본 이름 충돌을 재현하고 해결하세요.

해설 보기

탐색 경계를 표시 클래스로 지정하면 문자열 패키지 오타를 줄일 수 있습니다.

@Configuration(proxyBeanMethods = false)
@ComponentScan(basePackageClasses = {
        PostFeatureMarker.class,
        ReportFeatureMarker.class
})
class FeatureScanConfig {
}

서로 다른 패키지의 SummaryService는 기본 빈 이름 summaryService가 같아 충돌합니다. 단지 충돌을 피하려고 클래스 이름 뒤에 임의 숫자를 붙이지 않습니다. 역할을 PostSummaryServiceReportSummaryService로 명확히 하거나, 둘이 같은 인터페이스의 전략이라면 의미 기반 한정자와 명시적 선택 정책을 둡니다.

assertThat(context.containsBean("postSummaryService")).isTrue();
assertThat(context.containsBean("reportSummaryService")).isTrue();
assertThat(context.containsBean("experimentalService")).isFalse();

이 검증은 포함 대상뿐 아니라 제외 대상도 고정해 스캔 범위가 우연히 넓어지는 회귀를 잡습니다.

다음 문서에서는 후보를 찾은 뒤 실제 객체에 전달하는 주입 방식 가운데 생성자 주입을 기본으로 삼고, 반드시 있어야 하는 의존성과 없어도 되는 확장점을 타입으로 구분합니다.