도입
단순히 붙이면 끝나는 어노테이션처럼 보이지만, 실제로는 컴포넌트 스캔(Component Scanning), BeanDefinition 생성, 생명주기, 스코프, AOP 프록시, 레이어링(Controller/Service/Repository) 같은 핵심 메커니즘과 맞물려 동작합니다.
정의
스프링에서 “관리한다”는 말은 곧 스프링 컨테이너(ApplicationContext)가 객체(Bean)의 생성/초기화/의존성 주입/소멸까지 책임진다는 뜻입니다.
@Component는 그 관리 대상이 되기 위한 가장 기본적인 표식이며, 클래스패스 스캐닝 과정에서 발견되면 스프링은 이 클래스를 BeanDefinition(설계도)으로 등록하고 필요 시 실제 객체를 생성합니다.
- @Component가 붙은 클래스는 컴포넌트 스캔 대상이 된다.
- 스캔 결과로 BeanDefinition이 등록되고, 컨테이너가 싱글톤(기본)으로 인스턴스를 관리한다.
- @Service/@Repository/@Controller는 내부적으로 @Component의 특수화다(= 결국 컴포넌트 스캔 기반).
필요성
스프링이 강력한 이유는 개발자가 객체 생성(new)과 생명주기 관리에서 벗어나 “구성(설정)과 사용(비즈니스)”을 분리할 수 있기 때문입니다. 그 분리의 실행 장치가 IoC(Inversion of Control), 그리고 의존성을 외부에서 주입하는 DI(Dependency Injection)입니다.
- Bean 등록을 일일이 @Bean으로 나열하면 설정이 비대해진다.
- 클래스 수가 늘어날수록 수동 등록 누락이 빈번해진다.
- 레이어별(Controller/Service/Repository) 구조에서 자동 스캔이 생산성을 크게 올린다.
import org.springframework.stereotype.Component;
@Component
public class Clock {
public long now() {
return System.currentTimeMillis();
}
}
이렇게만 해도(그리고 스캔 범위에 포함되어 있다면) 스프링은 Clock을 Bean으로 등록하고, 다른 Bean에서 생성자 주입으로 사용할 수 있게 됩니다.
동작 원리
- 스캔 범위 결정: 어느 패키지를 대상으로 클래스패스를 훑을지 정한다.
- 후보 탐색: .class 메타데이터를 읽어 어노테이션/메타어노테이션 기반으로 후보를 찾는다.
- 후보 판별: 인터페이스/추상클래스 여부, 필터 규칙 포함/제외 여부 등 조건을 통과해야 한다.
- BeanDefinition 등록: “이 타입은 어떤 스코프/이름/생성 방식으로 만들 것인가”를 설계도로 저장한다.
- 인스턴스 생성 및 DI: 싱글톤이면 컨테이너 초기화 시점에(보통) 생성하고 의존성을 주입한다.
- 후처리: BeanPostProcessor가 개입해 AOP 프록시 생성, @Autowired 처리, @PostConstruct 호출 등이 수행된다.
스프링 부트에서는 보통 @SpringBootApplication이 붙은 메인 클래스의 패키지를 기준으로 “하위 패키지”를 기본 스캔합니다. 즉, 메인 클래스가 com.example에 있으면 기본적으로 com.example.* 아래가 스캔 대상입니다.
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication // 내부적으로 @ComponentScan 포함
public class App { }
스캔을 “전부” 해도 되지만, 모듈이 커질수록 불필요한 Bean이 잡히거나 테스트 환경에서 원치 않는 Bean이 올라오는 문제가 생깁니다. 이때 includeFilters / excludeFilters로 스캔 대상을 정교하게 제어할 수 있습니다.
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan(
basePackages = "com.example",
excludeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "com\\.example\\.legacy\\..*"
)
)
public class ScanConfig { }
이름 규칙
- 기본적으로 클래스의 단순 이름(Simple Name)을 사용하고, 첫 글자를 소문자로 만든다. (예:
UserService→userService) - 단, 첫 두 글자가 모두 대문자인 경우(예:
URLParser)는 관례상 소문자화가 적용되지 않는 케이스가 있다.
import org.springframework.stereotype.Component;
@Component("systemClock")
public class Clock { }
이름을 직접 정하면 Bean 충돌을 피하거나, 레거시/외부 연동에서 특정 이름을 요구할 때 유리합니다. 하지만 무분별한 네이밍은 오히려 유지보수를 어렵게 만들 수 있으니 충돌 해결이 목적일 때만 전략적으로 쓰는 편이 좋습니다.
스코프/생명주기
스프링의 기본 스코프는 singleton입니다. 즉, 애플리케이션 컨텍스트당 인스턴스가 1개만 생성되어 공유됩니다. 그래서 @Component Bean은 기본적으로 “상태를 들고 있지 않는(stateless)” 설계를 권장합니다.
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
@Component
@Scope("prototype")
public class TraceId {
private final String value = java.util.UUID.randomUUID().toString();
public String value() { return value; }
}
prototype은 “요청할 때마다 새 인스턴스”를 만들지만, 컨테이너가 소멸(destroy)까지 관리하지는 않는다는 점이 중요합니다. 즉, 프로토타입 Bean에 자원 정리 로직이 있다면 직접 정리 전략을 잡아야 합니다.
- @PostConstruct: 의존성 주입 완료 이후 초기화
- @PreDestroy: 컨텍스트 종료 시 소멸 콜백(싱글톤 중심)
- 또는 InitializingBean/DisposableBean, @Bean(initMethod/destroyMethod) 등 다양한 방식이 있다
비교
| 방식 | 등록 단위 | 강점 | 추천 사용처 |
|---|---|---|---|
| @Component | 클래스 | 자동 스캔/등록, 레이어 구조에 자연스러움 | 애플리케이션 내부 구성요소(도메인 서비스, 유틸, 어댑터 등) |
| @Bean | 메서드 | 외부 라이브러리/생성 로직 제어/조건부 조립에 강함 | 서드파티 객체 등록, 생성 파라미터/팩토리/빌더가 필요한 경우 |
| @Service | 클래스 | 서비스 레이어 의도 명확(팀 합의/툴링 가독성) | 유스케이스/비즈니스 로직 중심 |
| @Repository | 클래스 | 일부 환경에서 예외 변환(퍼시스턴스 예외 처리)에 도움 | DB 접근/영속성 레이어 |
| @Controller | 클래스 | 웹 핸들러로 인식(MVC, 라우팅) | 웹 요청 처리(REST는 @RestController) |
실전 패턴
import org.springframework.stereotype.Component;
@Component
public class OrderFacade {
private final PaymentClient paymentClient;
public OrderFacade(PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}
}
생성자 주입은 필수 의존성 강제, 불변성, 테스트 용이성에서 장점이 큽니다.
예를 들어 PaymentClient 구현체가 2개(@Component)라면, 스프링은 “어떤 Bean을 넣어야 할지” 결정 못하고 예외를 던집니다. 이때 @Qualifier 또는 @Primary가 해법이 됩니다.
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
public interface PaymentClient { }
@Component
@Primary
class CardPaymentClient implements PaymentClient { }
@Component("bankPay")
class BankPaymentClient implements PaymentClient { }
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
@Component
public class CheckoutService {
private final PaymentClient paymentClient;
public CheckoutService(@Qualifier("bankPay") PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}
}
AOP/프록시
@Transactional, @Async, @Cacheable 같은 기능은 대개 AOP 프록시를 통해 동작합니다. 중요 포인트는 “@Component가 프록시를 만든다”가 아니라, Bean으로 등록된 후 BeanPostProcessor가 필요에 따라 프록시로 감싼다는 것입니다.
this.someMethod()로 호출하면 프록시를 거치지 않아 @Transactional 등이 기대대로 동작하지 않을 수 있습니다. (해결: 메서드 분리/구조 분리, 외부 Bean을 통한 호출, 설계 재조정 등)
GOOD / BAD
- 레이어 의도가 명확하면 @Service/@Repository/@Controller 사용
- 도메인/어댑터/유틸 성격이면 @Component로 단순화
- 스캔 범위는 “필요한 패키지”로 좁히고 모듈 경계를 지킨다
- 모든 클래스에 무지성 @Component → 레이어링/역할이 흐려짐
- 전 패키지 무차별 스캔 → 테스트/운영에서 원치 않는 Bean이 올라옴
- 상태를 가진 싱글톤 Bean 남발 → 동시성 버그/테스트 난이도 증가
고급
스프링의 스테레오타입은 “@Component가 붙은 어노테이션”을 통해 확장 가능합니다. 즉, 우리 팀/도메인에 맞춘 “역할 어노테이션”을 만들어 코드 의도를 더 선명하게 표현할 수 있습니다.
import org.springframework.stereotype.Component;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface DomainAdapter {
String value() default "";
}
@DomainAdapter
public class KakaoPayAdapter { }
디버깅
요약
- ✅ 스캔 범위(패키지 경계)를 설명할 수 있는가?
- ✅ Bean 이름 규칙/충돌 해결(@Qualifier/@Primary)을 알고 있는가?
- ✅ 싱글톤 기본 전제를 지키며 상태를 최소화했는가?
- ✅ 레이어 의도에 맞춰 @Service/@Repository/@Controller를 선택했는가?
- ✅ “등록은 됐는데 동작이 이상함”에서 AOP/프록시/자기호출을 의심할 수 있는가?
- ✅ 외부 라이브러리는 @Bean으로, 내부 구성요소는 @Component로 구분했는가?