본문 바로가기
Tech Stack/Backend

🍃어노테이션 기반 수동 빈 등록 (AppConfig) & 스프링 어노테이션(@Autowired) 없이 수동 주입

by 박수무당벌레 2026. 6. 18.

1. 핵심 개념 (명시적 수동 등록)

스프링 프레임워크의 제어를 받되, 스프링이 알아서 클래스를 찾아가게 두는 것(컴포넌트 스캔)이 아니라, 개발자가 설정용 자바 클래스(@Configuration 클래스)를 직접 만들고, 그 안에서 메서드(@Bean)를 통해 객체를 생성하여 스프링 컨테이너에 객체를 빈으로 등록해달라고 직접 명시하는 방식.

  • 개념: 개발자가 직접 수동으로 빈을 정의하고 관계를 맺어주는 객체 생성 및 관계 정립의 주도권(제어권)을 일부 유지하되, 컨테이너에 빈(Bean)으로 등록함으로써 생명주기 관리 권한을 스프링에 '위임'하는 형태
  • @Configuration: 해당 자바 클래스는 애플리케이션의 설정 정보(구성 정보)를 담고 있는 파일이라고 스프링에게 알려줌.
  • @Bean: 메서드 위에 붙이며, 해당 메서드가 반환하는 자바 객체를 스프링 컨테이너에 빈으로 등록해달라고 명령.
💡 설정용 자바 클래스는 객체 생성(+ 조립)과 로직을 분리했기 때문에 일종의 팩토리(Factory) 클래스 역할을 한다. 
💡*Lite 모드(@Configuration 없이 사용하는 @Bean)의 위험성:
프로젝트가 시작될 때는 아무런 에러도 나지 않지만 싱글톤이 깨져 서로 다른 객체가 주입되므로, 실제 서비스 운영 중에 데이터가 누락되거나 원인을 찾기 힘든 심각한 런타임 오류(버그)를 발생시킬 수 있음. 수동 빈 등록 시 @Configuration은 필수!
더보기

✨ Lite 모드에 대하여

 

1) 왜 스프링은 진작 에러를 안 낼까?

  • 스프링 입장에서는 @Configuration이 없어도 내부의 @Bean 메서드를 보면 스프링 컨테이너에 등록을 해줌 (이걸 Lite 모드라고 부름)
  • 즉, 문법적으로 틀린 게 아니기 때문에 구동 시점에는 얌전하게 통과하는 것.

2) 런타임에 발생할 수 있는 대참사 예시

  1. 회원이 가입해서 MemberService를 통해 MemoryRepository에 회원 정보를 저장
  2. 이제 주문을 하려고 OrderService에서 회원 정보를 조회
  3. 그런데 OrderService가 들고 있는 MemoryRepository는 new로 새로 만들어진 텅 빈 2번 레포지토리(싱글톤이 깨짐).
  4. 결과: 시스템에 널 포인터 에러(NullPointerException)가 나거나 비즈니스 로직이 터짐
// @Configuration이 빠진 상태
@Component 
public class MyLiteConfig {

    @Bean
    public MemoryRepository memoryRepository() {
        return new MemoryRepository(); // 호출할 때마다 new 생성
    }

    @Bean
    public MemberService memberService() {
        return new MemberService(memoryRepository()); // 1번 레포지토리 주입
    }

    @Bean
    public OrderService orderService() {
        return new OrderService(memoryRepository()); // 2번 레포지토리 주입 (1번과 다른 객체!)
    }
}

📊 자동 등록 vs 수동 등록 (스프링 어노테이션 비교)

  • 자동 빈 등록 (@Component 계열)
    • 대상: 클래스 자체
    • 의미: 스프링에게 내가 만든 클래스(@Component)를 스프링이 알아서 찾아서(@Autowired) 통째로 가져다가 객체로 만들어서 등록하라고 명시 → 편리함과 생산성 극대화
  • 수동 빈 등록 (@Configuration + @Bean)
    • 대상: 설정 클래스와 메서드
    • 의미: 내가 AppConfig라는 파일에 직접 자바 코드로 객체 만드는 과정(+ 조립)을 적고, 스프링에게 이 메서드가 리턴하는 객체를 등록하라고 명시. → 아래 정리

2. 코드 예시로 보기

수동 빈 등록은 보통 아래와 같이 AppConfig 같은 설정 클래스를 만들어 관리한다.

  • 어떻게 동작할까? 스프링이 구동될 때 @Configuration이 붙은 설정 클래스를 읽고, 그 안에서 @Bean이 붙은 메서드들을 실행해 리턴된 객체들을 스프링 컨테이너에 집어넣음.
  • 빈 이름: @Bean이 붙은 메서드의 이름(memberService, memberRepository)이 곧 스프링 빈의 이름이 됨.
  • 의존관계 주입(메서드가 리턴하는 객체 등록): memberService() 메서드 내부를 보면 memberRepository()를 직접 호출해서 의존관계를 자바 코드로 연결해 주는 것을 볼 수 있음.
@Configuration
public class AppConfig {

    @Bean // 메서드의 반환 객체를 스프링 컨테이너에 "수동 빈"으로 등록
    public Repository memoryRepository() {
        return new MemoryRepository();
    }

    @Bean
    public UserService userService() {
        // 개발자가 직접 new 객체를 생성하고, 필요한 의존성(Repository)을 주입함
        return new UserService(memoryRepository()); 
    }
}
더보기

👀 기존 순수 자바 코드

// 1. 의존성을 가질 인터페이스와 구현체
interface Repository {
    void save();
}

class MemoryRepository implements Repository {
    public void save() { System.out.println("메모리에 저장 중..."); }
}

// 2. 의존성을 주입받을 클래스
class UserService {
    private final Repository repository;

    // 생성자를 통해 외부에서 Repository를 주입받음
    public UserService(Repository repository) {
        this.repository = repository;
    }

    public void register() {
        repository.save();
    }
}

// 3. 실행 클래스 (수동 주입이 일어나는 곳)
public class Main {
    public static void main(String[] args) {
        // 개발자가 직접 객체를 생성하고 조립함
        Repository repository = new MemoryRepository();
        UserService userService = new UserService(repository); 

        userService.register();
    }
}

3. 수동 의존관계(DI) 주입

프레임워크(예: Spring의 @Autowired 나 NestJS의 Injectable 등)에 아예 위임하는 방식이 아닌, 설정 클래스 안에서 자바 코드로 메서드를 직접 호출하여 의존관계를 주입. 다른 주입 방식은 여기서 확인.

생성자 주입 방식 (가장 권장)

  • 조립 방식: 빈(객체)을 생성할 때 @Autowired 같은 어노테이션을 쓰지 않고, @Configuration 클래스 내부에서 스프링이 관리하는 다른 빈(메서드)을 생성자의 인자로 직접 넣어주면서 조립.
    • 의존 객체가 반드시 먼저 스프링 컨테이너에 빈으로 등록되어 있어야 함. 주입할 빈을 찾지 못하면 스프링은 구동 시점에 에러(NoSuchBeanDefinitionException 등)를 발생. (스프링 컨테이너가 기존 생성자보다 자동으로 생성해서 주입 X)
    • 개발자의 역할: 생성자를 직접 호출하면서 의존 객체 넣어주기 (스프링 컨테이너의 제어를 벗어난 순수한 자바 코드)
  • 💡 왜 생성자 주입이 좋을까? 외부 설정 값이나 의존 객체를 주입받을 때 생성자 주입을 선택하면 다음과 같은 강력한 안전성을 얻을 수 있음
    • 객체의 완벽한 초기화 (생성 시점 세팅): 객체가 생성되는 동시에 외부 설정 값이나 의존성이 필수로 주입됨. 즉, 주입이 누락된 채로(Null인 상태로) 객체가 돌아다니는 것을 원천 차단.
    • 불변성 보장 (private final): 주입받는 필드를 private final로 선언할 수 있음. 한 번 세팅된 값이나 객체는 애플리케이션 실행 도중 불시의 사고로 변경되지 않아 안전.
    • 컴파일 시점의 에러 발견 (의존성 누락 방지): 만약 필요한 값이나 의존 객체를 누락한 채 객체를 생성하려고 하면, 실행(Runtime)하기도 전에 컴파일러가 즉시 에러를 발생. (가장 안전한 방어막)
@Bean
public UserService userService() {
    // 생성자 파라미터로 위에서 정의한 빈 메서드를 직접 호출!
    return new UserService(memoryRepository()); 
}
더보기

 ✨ 자바 코드인데, 스프링은 어떻게 싱글톤을 유지할까?

 

 위 예시 코드의  userService() 메서드 부분에서 UserService 클래스의 생성자를 직접 호출(new)하면서, 인자로 memoryRepository() 메서드의 호출 결과(반환값)를 넘겨주는 순수한 자바 코드처럼 보이지만, 실제 동작할 때는 조금 다르게 변화함.

 

─ 실제 작동 방식 설명

자바 코드 그대로 해석하면 userService()를 호출할 때 memoryRepository()가 실행되는데, 만약 다른 빈에서도 memoryRepository()를 호출하면 매번 새로운 MemoryRepository 객체가 생성되어야 함. 하지만 스프링은 이를 막기 위해 CGLIB이라는 바이트코드 조작 라이브러리를 사용한다.

  1. @Configuration이 붙은 설정 클래스를 스프링이 읽을 때, 스프링은 이 클래스를 그대로 쓰지 않고 이를 상속받은 가짜 프록시 클래스를 만들어 컨테이너에 등록.
  2. 이 가짜 클래스는 내부적으로 다음과 같은 로직으로 작동하도록 메서드를 재정의(Override)한다.
// 스프링이 내부적으로 조작한 대략적인 예시 로직
public MemoryRepository memoryRepository() {
    if (스프링 컨테이너에 이미 MemoryRepository 빈이 등록되어 있다면?) {
        return 컨테이너에서 기존에 있던 빈을 찾아서 반환;
    } else {
        return 원래 순수 자바 코드대로 new MemoryRepository()를 호출해서 반환;
    }
}

4. 롬복(@RequiredArgsConstructor)과의 조합

주입 대상이 되는 구현체 클래스들(MemberServiceImpl), 즉 수동 빈 등록을 위해 서비스 클래스에 순수 자바 생성자를 만들어 두면, 필드가 늘어날 때마다 생성자 코드를 매번 수정해야 해서 귀찮아짐. 이때 롬복을 사용하면 서비스 클래스 내부 코드를 깔끔하게 유지하면서도, 수동 빈 설정 파일(AppConfig)에서의 조립 구조는 그대로 가져갈 수 있음

ℹ️ 수동 등록 클래스(AppConfig) 자체에는 롬복을 쓸 일이 거의 없음. 

롬복 적용하기

  • 활용 원리: 서비스 클래스 내부에서 주입받을 필드를 final로 선언(이 시점에 의존성 고정)하면, 생성자는 코드가 고정된다 (딱 한번 작성). 
    • 의존할 대상(final 필드)을 정해두면, 애플리케이션이 실행되는 도중에 이 의존성이 다른 객체로 바뀌는 배신이 일어날 수 없음. → 시스템이 매우 안정적.
    • 생성자 코드가 어차피 필드에 맞춰 기계적으로 고정(정형화)되기 때문에, 롬복을 쓰면 개발자가 이 고정된 코드를 타이핑할 필요조차 없어짐.
  • 롬복(Lombok)을 만나면 생성자가 필드에 맞춰 "자동 고정": 이때 필드가 많아져서 매번 새로운 생성자를 타이핑하는 게 귀찮다면(수정 필요), 서비스 클래스 내부에서 여전히 롬복을 활용할 수 있음.
    • 기존: 의존 필드가 늘어날 때마다 서비스 클래스에 생성자를 매번 새로 타이핑해야 함.
    • 롬복 활용: 서비스 클래스를 준비해두면, 정작 수동 등록하는 설정 파일(AppConfig)에서는 롬복이 만들어준 생성자를 사용해 그대로 수동 조립만 하면 됨
      • 생성자 코드 대신 final 필드 늘리기: final이 붙은 필드를 모아 생성자를 자동으로 만들어 줌
      • 서비스 클래스 내부 코드는 롬복 덕분에 깔끔해졌지만, AppConfig에서 수동으로 빈을 등록할 때는 결국 new 키워드로 객체를 생성해야 함.
      • 이때 롬복이 눈에 안 보이게 만들어둔 생성자를 가져와서 AppConfig 안에서 매개변수를 그대로 채워 넣어 조립(주입)해주면 된다.
  • 장점: 의존 관계를 연결하는 생성자 코드는 신경 쓸 필요 없이 안전하게 비즈니스 로직에만 집중하면 되는 것.
  • 역할 비교: AppConfig(수동) vs 서비스 클래스(롬복)
    • AppConfig (100% 수동 등록): 이 서비스 객체는 내가 직접 new로 만들어서 스프링에 등록할 것이라고 선언하는 공간
    • OrderService (롬복 사용): 수동 등록의 '대상'이 되는 실제 클래스.
  • ⚠️ 주의할 점: 서비스 클래스에 @RequiredArgsConstructor를 붙여서 생성자를 생략하더라도, 설정 클래스(AppConfig)에서 빈을 조립할 때 new OrderService(memberRepository(), discountPolicy()) 형태로 인자를 넣어주는 구조는 변하지 않음. 롬복이 눈에 안 보이는 생성자를 대신 만들어 준 것.
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor // 의존성 주입을 위한 생성자를 자동으로 생성
public class OrderService {
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;
    
    // 생성자 코드가 통째로 생략 (롬복이 컴파일 시점에 알아서 만들어줌)
}
ℹ️ final 변수의 진짜 규칙
자바에서 final은 한 번 값이 결정되면 절대 바꿀 수 없다(Immutable)는 뜻. 값은 선언과 동시에 결정되거나 객체가 생성될때(생성자 호출 시) 값을 채워 넣을 수 있음. (안 채워 넣으면 컴파일 에러)

5. 언제 수동 빈 등록을 써야 할까?

실무(특히 Spring 환경)에서는 대부분 자동 빈 등록과 주입(@Component, @Service, @Repository + 생성자 자동 주입)을 사용하지만, 다음과 같은 상황에서는 여전히 수동이 필수적이거나 훨씬 유리하다.

1) 외부 라이브러리(Open Source, Jar)의 클래스를 빈으로 쓸 때 (+ 스프링 자체 제공 클래스)

  • 이유: 내가 직접 만든 코드가 아니므로 소스 코드를 수정할 수 없음. 즉, 클래스 위에 @Component를 붙이는 것 자체가 불가능. (컴포넌트 스캔을 쓸 수 없음)
  • 해결책: @Configuration 설정 클래스(자바 설정 파일)를 만들고, 그 안에서 메서드에 @Bean을 붙여 직접 객체를 생성하고 리턴해 주어야 함. (수동으로 의존성 주입까지)

2) 애플리케이션 전반에 영향을 주는 '기술 지원 빈'을 설정할 때

  • 비즈니스 로직 빈: Controller, Service, Repository 등 핵심 비즈니스 요구사항(회원가입, 주문 등)을 처리하는 빈. 양이 많기 때문에 자동 등록(@Component)을 써서 생산성을 높이는 게 좋음.
  • 기술 지원 로직 빈: DB 연결(데이터베이스 커넥션 풀, DataSource), 보안(Security), 공통 로그, AOP 등 인프라적인 설정을 처리하는 빈.
    • 왜 수동으로 할까? 기술 지원 빈은 수는 적지만 애플리케이션 전반에 큰 영향을 미침. 문제가 생겼을 때 바로 파악할 수 있도록 설정 정보(AppConfig)에 명시적으로 드러내는 것이 유지보수에 훨씬 유리.
ℹ️ 빈은 크게 비즈니스 로직(Controller, Service, Repository 등)과 기술 지원 로직(데이터베이스 연결, 보안 설정, 공통 로그 처리 등)으로 나뉨.

3) 다형성을 활용해 구현체를 동적으로 변경해야 할 때

만약 런타임이나 비즈니스 요구사항에 따라 MemoryRepository를 JdbcRepository로 완전히 교체해야 하는 상황이라면 수동 등록이 압도적으로 유리.

  • 자동 등록 시: 각 Repository(MemoryRepository) 클래스를 찾아가 @Component를 지우거나 붙여야 하고, JdbcRepository에 @Component를 붙이거나 의존 관계를 맺는 곳마다 @Qualifier나 @Primary를 수정해야 함. (수정할 코드가 여러 클래스에 분산됨)
  • 수동 등록 시: 오직 중앙 설정 파일(AppConfig)의 코드 단 한 줄만 바꾸면 다른 비즈니스 로직은 전혀 손댈 필요가 없음.
@Configuration
public class AppConfig {
    @Bean
    public Repository repository() {
        // return new MemoryRepository(); // 기존 코드를
        return new JdbcRepository();      // 이 한 줄만 바꾸면 끝!
    }
}

6. 수동 주입 방식의 장단점

프레임워크가 자동으로 해주는 자동 주입(Auto DI)이 있는데도 수동 주입을 이해하고 사용하는 데는 명확한 이유가 있음.

👍 장점

  • 기술적 분리와 유연성(위 5번에서 설명): 정밀한 제어가 필요한 외부 라이브러리(예: RestTemplate, ObjectMapper 등)나 비즈니스 로직 외에 전역적으로 적용되는 기술 지원 객체(AOP, DB 설정 등)를 명확하게 빈으로 등록할 수 있음.
  • 구성 정보의 시각화: 어떤 수동 빈들이 애플리케이션에 등록되어 있는지 특정 설정 파일(@Configuration) 하나만 보면 쉽게 파악할 수 있음. 구성 정보 변경도 자유로움
  • 다형성 활용 용이: 같은 인터페이스를 구현한 여러 클래스 중 상황에 따라 특정 구현체를 선택해서 빈으로 등록하고 싶을 때 메서드 내부 로직으로 자유롭게 제어할 수 있음.

👎 단점

  • 스프링 프레임워크 종속성: 순수 자바가 아니므로 여전히 스프링 프레임워크가 필수적이며 가볍지 않음.
  • 생산성과 관리 비용 증가: 클래스를 새로 만들 때마다 설정 클래스(@Configuration)로 이동해서 @Bean 메서드를 일일이 추가해 주어야 하므로, 전적으로 자동 스캔(@Component)을 쓰는 것보다 번거로움. 등록해야 할 빈이 수백개로 늘어나면 설정 파일의 크기가 비대해짐
  • 수정 파급 효과: 수동 등록하려는 클래스의 생성자가 변경되면 @Bean 등록 메서드의 new 생성자 코드도 함께 수정해야 함. (다만, 메서드 파라미터로 의존성을 주입받도록 짜면 이 단점은 어느 정도 상쇄.)
더보기

ℹ️ 순수 자바 DI 방식 (수동 등록)의 경우

 

1. 기술적 특징: 완벽한 POJO(Plain Old Java Object) 실현

  • 수동 빈 등록(AppConfig)의 본질: 스프링 설정과 비즈니스 로직을 코드 레벨에서 완벽히 분리하는 것. 
  • 프레임워크 독립성: Spring 같은 거대 프레임워크 없이 순수한 언어 특징(Pure Java/Kotlin/TypeScript 등)만으로 구현하므로, 가볍고 이식성이 좋음. (new 키워드로 직접 조립.)
    • 어노테이션 기반의 한계: 클래스에 @Component, @Autowired 같은 스프링 어노테이션이 직접 붙기 때문에, 외관상 스프링 프레임워크에 종속적인 구조가 되기 쉬움.
    • No Spring의 이점: 아래처럼 비즈니스 클래스 내부에는 스프링(프레임워크) 관련 코드가 단 하나도 묻지 않은 순수한 자바 코드(POJO)만 남길 수 있음.
  • 명확한 제어 흐름:  설정 정보가 한곳(AppConfig)에 모여 있어서 애플리케이션의 전체적인 구조와 객체 간의 연결 관계를 한눈에 파악하기 쉬움. 구성 정보의 변경도 매우 자유롭고 디버깅할 때 추적하기 쉬움.  
// 스프링 어노테이션이 전혀 없는 순수한 자바 객체(POJO)
public class OrderServiceImpl implements OrderService {
    private final DiscountPolicy discountPolicy;

    // 스프링 없이도 자바 문법(생성자)만으로 의존성 주입이 가능함
    public OrderServiceImpl(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

 

2. 테스트 관점의 장점: 초고속 단위 테스트 가능

  • 스프링 프레임워크에 종속되지 않는 순수한 객체가 되었기 때문에, 테스트 코드를 작성할 때 무거운 스프링 컨테이너를 띄울 필요가 없어짐.
  • 스프링 컨테이너를 띄우면 (@SpringBootTest 등): 아무리 간단한 로직이라도 톰캣이 뜨고 빈을 스캔하는 데 수 초에서 수십 초가 소요. (테스트 생산성 저하)
  • 순수 자바로 테스트하면: 무거운 설정 없이 new 연산자로 테스트용 가짜 객체(Mock)나 컴포넌트를 직접 주입하여 0.1초 만에 핵심 로직만 빠르게 테스트할 수 있음. 단위 테스트(Unit Test)를 작성하기 매우 편함.
// 스프링을 전혀 띄우지 않는 순수 자바 단위 테스트
class OrderServiceTest {
    @Test
    void createOrder() {
        // 1. 테스트용 컴포넌트를 개발자가 직접 수동으로 생성하고 주입 (AppConfig와 유사한 방식)
        DiscountPolicy fixDiscountPolicy = new FixDiscountPolicy();
        OrderService orderService = new OrderServiceImpl(fixDiscountPolicy);

        // 2. 스프링 없이 0.1초 만에 실행되는 핵심 로직 테스트
        Order order = orderService.createOrder(1L, "itemA", 10000);
        assertThat(order.getDiscountPrice()).isEqualTo(1000);
    }
}

 

3. 단점

  • 보일러플레이트 코드 증가: 애플리케이션의 규모가 커지면 설정 파일 자체의 크기가 늘어나고, new A(new B(new C(new D()))) 같은 비대하고 복잡한 객체 생성 코드가 생김.
  • 유지보수 부담: 만약 C 클래스의 생성자 파라미터가 하나 추가되면, 그동안 C를 수동으로 생성하던 모든 곳의 코드를 찾아가서 고쳐야 함.