Spring Boot 3에서 Bean 등록 방식(@Component vs @Bean)과 생성자 주입을 권장하는 이유, 그리고 순환참조를 빠르게 진단·해결하는 감을 잡아봅니다.
도입 (문제 상황)
Spring Boot로 기능을 붙이다 보면 “이 클래스는 @Component 붙이면 되나, @Bean으로 등록해야 하나?” 같은 고민을 자주 하게 됩니다. 또 DI는 되긴 되는데, 왜 다들 “생성자 주입이 정답”이라고 말하는지 근거가 궁금해지기도 해요. 마지막으로 어느 날 갑자기 애플리케이션이 뜨지 않으면서 순환참조 에러가 터지면, 어디부터 봐야 할지 막막해집니다.
핵심 개념 — Spring Boot Bean 등록과 DI에서 꼭 알아야 할 것들
Spring Boot Bean이란: “스프링 컨테이너가 관리하는 객체”
Bean은 스프링이 생성/초기화/주입/수명주기까지 관리하는 객체입니다. 자바에서 new로 직접 만드는 객체와 달리, Bean은 “필요한 곳에 알아서 꽂히는(주입되는) 부품”처럼 동작해요. 이 부품을 어떤 방식으로 컨테이너에 올릴지(등록할지)가 @Component와 @Bean의 갈림길입니다.
@Component vs @Bean 차이: 자동 스캔이냐, 수동 등록이냐

두 방식 모두 “결국 Bean을 만든다”는 점은 같지만, 등록 경로와 의도가 다릅니다.
| 구분 | @Component | @Bean |
|---|---|---|
| 등록 방식 | 컴포넌트 스캔(자동) | 설정 클래스에서 메서드로 수동 등록 |
| 주 사용처 | 내가 만든 클래스(서비스/리포지토리/핸들러 등) | 외부 라이브러리 객체, 생성 로직이 필요한 객체 |
| 위치 | 클래스 위 | @Configuration 클래스의 메서드 위 |
| 장점 | 구조가 단순, 패키지 구조로 역할이 드러남 | 생성 과정 커스터마이징/조건부 등록에 유리 |
| 흔한 실수 | 스캔 범위 밖 패키지에 둬서 미등록 | @Configuration 없이 그냥 클래스에 두어 프록시 이슈 유발(아래 참고) |
정리하면, **내가 만든 “역할이 있는 컴포넌트”는 @Component 계열(@Service/@Repository 포함)**로 두고, **외부 객체를 감싸거나 생성 로직이 필요한 경우 @Bean**을 선택하는 편이 유지보수에 유리합니다.
참고:
@Bean은@Configuration안에 둘 때 “메서드 호출이 곧 컨테이너 조회”처럼 동작하도록 프록시가 적용됩니다. 단순히@Bean만 붙여도 동작은 하지만, 설정 의도가 분명한@Configuration조합이 기본입니다.
생성자 주입을 권장하는 이유: “불변 + 테스트 + 실패가 빠름”
DI에는 여러 방식이 있지만(필드/세터/생성자), 실무에서 생성자 주입이 사실상 표준처럼 쓰이는 이유는 명확합니다.
- 필수 의존성을 강제할 수 있어요(불변성)
생성자 파라미터로 받으면 “없으면 객체 생성 자체가 불가능”합니다. 런타임 중간에 누군가 의존성을 바꾸는 것도 막을 수 있어요(final). - 테스트가 쉬워져요
스프링 없이도 단위 테스트에서new Service(fakeRepo)처럼 바로 생성 가능합니다. 필드 주입은 스프링 컨테이너가 없으면 주입이 안 되어 테스트가 번거로워요. - 문제가 빨리 드러나요(Fail Fast)
의존성이 빠졌거나 순환참조가 있으면 애플리케이션 시작 시점에 바로 터집니다. 세터/필드 주입은 늦게 터지거나(NullPointerException) 원인 추적이 어려워질 수 있어요.
순환참조 맛보기: “A가 B를, B가 A를 필요로 하는 상황”
순환참조는 보통 설계가 꼬였다는 신호입니다. 특히 Spring Boot 3.x에서는 순환참조를 기본적으로 허용하지 않기 때문에(운영 안전성 측면), 아래처럼 서로를 생성자 주입하면 바로 실패합니다.
graph TD
A["Service A"] --> B["Service B"]
B["Service B"] --> A["Service A"]
서로가 서로를 필요로 하면 스프링이 어떤 것부터 만들어야 할지 결정할 수 없어 순환이 발생합니다.
해결은 보통 “둘이 서로 직접 알 필요가 없도록” 구조를 바꾸는 쪽이 정답입니다. 예를 들어 공통 로직을 Facade/Domain Service로 분리하거나, 이벤트 발행/구독으로 결합도를 낮추는 방식이 많이 쓰입니다. 정말 불가피한 경우에만 @Lazy 같은 우회가 등장하지만, 우회는 “증상 완화”에 가깝고 설계 개선이 우선입니다.
코드 예제 — @Component/@Bean, 생성자 주입, 순환참조 재현까지 한 번에
아래 예제는 그대로 복붙해서 실행할 수 있고, **정상 케이스 + 순환참조 케이스(주석 해제 시 실패)**를 한 프로젝트에서 확인할 수 있게 구성했습니다.
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.2'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
application.yml
spring:
application:
name: bean-di-demo
# 참고: Boot 3.x에서는 순환참조가 기본적으로 허용되지 않습니다.
# 아래 옵션은 "우회"이며, 실무에서 추천 설정이 아닙니다.
# spring:
# main:
# allow-circular-references: true
패키지 구조(권장 예)
config: 설정(@Configuration, @Bean)service: 비즈니스 서비스(@Service)web: 컨트롤러(@RestController)
DemoApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
config/AppConfig.java — @Bean으로 외부 객체/생성 로직 등록
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
@Configuration
public class AppConfig {
@Bean
public Clock clock() {
// 외부 라이브러리 객체(Clock)처럼 내가 소스에 @Component를 붙일 수 없는 경우 @Bean이 자연스럽습니다.
return Clock.systemUTC();
}
}
service/TimeService.java — 생성자 주입(권장) + @Component 계열
package com.example.demo.service;
import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.Instant;
@Service
public class TimeService {
private final Clock clock;
// 생성자 주입: 필수 의존성을 강제하고, 테스트가 쉬워집니다.
public TimeService(Clock clock) {
this.clock = clock;
}
public Instant now() {
return Instant.now(clock);
}
}
web/TimeController.java — @RestController는 @Component의 특수화
package com.example.demo.web;
import com.example.demo.service.TimeService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TimeController {
private final TimeService timeService;
public TimeController(TimeService timeService) {
this.timeService = timeService;
}
@GetMapping("/time")
public String time() {
return timeService.now().toString();
}
}
(선택) 순환참조 재현 — 주석 해제하면 애플리케이션 시작 실패
아래 두 클래스를 추가하고 실행해 보세요. 서로 생성자 주입을 하고 있어 순환참조가 발생합니다.
package com.example.demo.service;
import org.springframework.stereotype.Service;
@Service
public class AService {
private final BService bService;
public AService(BService bService) {
this.bService = bService;
}
}
package com.example.demo.service;
import org.springframework.stereotype.Service;
@Service
public class BService {
private final AService aService;
public BService(AService aService) {
this.aService = aService;
}
}
에러가 나면 “AService → BService → AService” 같은 의존 그래프가 로그에 힌트로 나오는데, 이때는 둘 중 하나가 정말로 상대를 ‘직접’ 알아야 하는지부터 의심해 보는 게 좋습니다.
실무 팁
💡 실무에서는: @Component vs @Bean 선택 기준을 “변경 가능성”으로 잡아보세요
- 팀 내부에서 자주 변경되는 비즈니스 로직/정책 객체는
@Component로 두면 파일 이동/리팩터링이 편합니다. - 생성 파라미터가 환경에 따라 달라지거나(시간/클라이언트/서드파티 SDK), 조건부로 켜고 꺼야 하는 객체는
@Bean이 더 읽기 좋습니다. 설정은 “부품 조립 설명서”처럼 한 곳에 모이는 편이 운영/디버깅에 유리합니다.
💡 실무에서는: 순환참조를 “옵션으로 풀기” 전에 구조를 먼저 고쳐보세요
spring.main.allow-circular-references=true는 최후의 우회로에 가깝습니다. 런타임 프록시/지연 초기화가 섞이며 디버깅 난이도가 올라갈 수 있습니다.- 먼저 공통 책임을 제3의 서비스로 분리하거나, 한쪽이 다른 쪽의 “결과”만 필요하다면 인터페이스/이벤트로 결합도를 낮춰 보세요. 순환참조가 사라지면 테스트도 같이 쉬워지는 경우가 많습니다.
핵심 요약: @Component는 자동 스캔으로, @Bean은 설정에서 수동 등록으로 Bean을 만든다.
핵심 요약: 생성자 주입은 불변/테스트 용이/Fail Fast 관점에서 가장 안전한 기본값이다.
핵심 요약: 순환참조는 우회보다 설계 분리가 우선이며, Boot 3에서는 기본적으로 막힌다.
다음 글: #08 @ConfigurationProperties로 설정 타입 안전하게 받기