Spring Boot

Spring Boot Bean과 DI(의존성 주입) 핵심만: @Component/@Bean, 생성자 주입, 순환참조

IT Lab 2026. 3. 8. 10:00

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 차이: 자동 스캔이냐, 수동 등록이냐

@Component와 @Bean의 차이를 보여주는 Spring 컨테이너 등록 흐름 다이어그램

두 방식 모두 “결국 Bean을 만든다”는 점은 같지만, 등록 경로와 의도가 다릅니다.

구분 @Component @Bean
등록 방식 컴포넌트 스캔(자동) 설정 클래스에서 메서드로 수동 등록
주 사용처 내가 만든 클래스(서비스/리포지토리/핸들러 등) 외부 라이브러리 객체, 생성 로직이 필요한 객체
위치 클래스 위 @Configuration 클래스의 메서드 위
장점 구조가 단순, 패키지 구조로 역할이 드러남 생성 과정 커스터마이징/조건부 등록에 유리
흔한 실수 스캔 범위 밖 패키지에 둬서 미등록 @Configuration 없이 그냥 클래스에 두어 프록시 이슈 유발(아래 참고)

정리하면, **내가 만든 “역할이 있는 컴포넌트”는 @Component 계열(@Service/@Repository 포함)**로 두고, **외부 객체를 감싸거나 생성 로직이 필요한 경우 @Bean**을 선택하는 편이 유지보수에 유리합니다.

참고: @Bean@Configuration 안에 둘 때 “메서드 호출이 곧 컨테이너 조회”처럼 동작하도록 프록시가 적용됩니다. 단순히 @Bean만 붙여도 동작은 하지만, 설정 의도가 분명한 @Configuration 조합이 기본입니다.

생성자 주입을 권장하는 이유: “불변 + 테스트 + 실패가 빠름”

DI에는 여러 방식이 있지만(필드/세터/생성자), 실무에서 생성자 주입이 사실상 표준처럼 쓰이는 이유는 명확합니다.

  1. 필수 의존성을 강제할 수 있어요(불변성)
    생성자 파라미터로 받으면 “없으면 객체 생성 자체가 불가능”합니다. 런타임 중간에 누군가 의존성을 바꾸는 것도 막을 수 있어요(final).
  2. 테스트가 쉬워져요
    스프링 없이도 단위 테스트에서 new Service(fakeRepo)처럼 바로 생성 가능합니다. 필드 주입은 스프링 컨테이너가 없으면 주입이 안 되어 테스트가 번거로워요.
  3. 문제가 빨리 드러나요(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로 설정 타입 안전하게 받기