Part 3. 의존성 주입(DI)과 활용
3.1 의존성 주입(DI)의 종류
앞서 DI(Dependency Injection)는 객체가 필요한 의존성을 직접 생성하지 않고 외부(Spring 컨테이너)로부터 전달받아 연결하는 메커니즘임을 살펴보았습니다.Spring Framework는 객체 간의 의존성을 주입하기 위해 총 4가지 방식을 지원합니다.
- 생성자 주입 (Constructor Injection)
- 수정자 주입 (Setter Injection)
- 필드 주입 (Field Injection)
- 일반 메서드 주입 (Method Injection)
모든 방식이 기술적으로 동작은 가능하지만, 실무와 Spring 공식 문서에서는 생성자 주입을 절대적인 표준으로 권장합니다.
각 주입 방식의 코드 형태와 특징, 그리고 왜 생성자 주입이 권장되는지 비교 분석해 보겠습니다.
1. 생성자 주입 (Constructor Injection)
클래스의 생성자를 통해 의존성을 전달받는 방식입니다.
객체가 생성(인스턴스화)되는 시점에 생성자의 파라미터로 의존 대상 Bean이 전달됩니다.
@Service
public class MemberService {
private final MemberRepository repository;
// 생성자를 통한 의존성 주입
public MemberService(MemberRepository repository) {
this.repository = repository;
}
}
주요 특징
- 완전한 상태의 객체 보장: 객체가 생성되는 단 한 번의 시점에 의존성이 모두 전달되므로, 생성 직후부터 안전하게 사용할 수 있습니다.
- final 키워드 사용 가능: 필드를 불변(Immutable) 상태로 유지할 수 있어 외부 변경 위험을 차단합니다.
- 순환 참조 조기 발견: 애플리케이션 구동 시점(객체 생성 단계)에 순환 참조 에러를 즉시 감지할 수 있습니다.
2. 수정자 주입 (Setter Injection)
의존성을 받아오는 Setter(수정자) 메서드를 작성하고 상단에 @Autowired를 부착하여 주입받는 방식입니다.
@Service
public class MemberService {
private MemberRepository repository;
@Autowired
public void setRepository(MemberRepository repository) {
this.repository = repository;
}
}
동작 흐름
- Spring 컨테이너에서 객체 생성
- Setter 메서드 호출(`@Autowired`)
- 의존성 주입 완료
주요 특징
- 주입 시점: 객체가 먼저 생성된 이후, Spring이 Setter 메서드를 호출하여 의존성을 넘겨줍니다.
- 장점: 런타임에 의존 대상을 유연하게 변경하거나 선택적으로 주입할 수 있습니다.
- 단점: 필드에 final을 사용할 수 없으며, 객체 생성 후 Setter 호출 전까지 의존성이 누락된 불완전한 상태(NullPointerException 위험)로 노출될 수 있습니다.
3. 필드 주입 (Field Injection)
클래스의 필드 선언부에 직접 @Autowired 어노테이션을 붙여 Spring이 의존성을 바로 주입하도록 하는 방식입니다.
@Service
public class MemberService {
@Autowired
private MemberRepository repository;
}
주요 특징 및 치명적 단점
- 외견상 간결함: 코드가 매우 짧아 보여 초심자가 유혹받기 쉽습니다.
- Spring 컨테이너에 강하게 종속: 외부에서 의존성을 주입할 방법(생성자나 Setter)이 전혀 없어, Spring 프레임워크 없이는 순수 자바 단위 테스트(Unit Test) 작성이 불가능합니다.
- final 선언 불가: 객체의 불변성을 보장할 수 없으며 의존 관계가 클래스 외부로 드러나지 않아 은폐됩니다.
// 순수 자바 테스트 코드 작성 시 에러 발생 예시
MemberService service = new MemberService(); // repository는 null 상태!
service.signUp(); // NullPointerException 발생 (DI 불가)
4. 일반 메서드 주입 (Method Injection)
Setter 메서드가 아닌 임의의 일반 메서드에 @Autowired를 부착하여 여러 필드의 의존성을 한 번에 주입받는 방식입니다.
@Service
public class MemberService {
private MemberRepository repository;
@Autowired
public void init(MemberRepository repository) {
this.repository = repository;
}
}
주요 특징
- Setter 주입과 동작 방식 및 특징(객체 생성 후 주입, final 사용 불가)이 완전히 동일합니다.
- 실무에서는 거의 사용되지 않는 방식입니다.
의존성 주입 4가지 방식 비교
| 구분 | 생성자 주입 | Setter 주입 | 필드 주입 | 일반 메서드 주입 |
| 주입 시점 | 객체 생성 시점 | 객체 생성 완료 후 | 객체 생성 완료 후 | 객체 생성 완료 후 |
| final 사용 여부 | 가능 (Immutable) | 불가능 | 불가능 | 불가능 |
| 순수 자바 테스트 | 매우 용이함 | 가능 (Setter 필요) | 불가능 | 가능 (메서드 필요) |
| 의존성 필수 여부 | 필수적 (Mandatory) | 선택적 (Optional) | 필수적 | 선택적 |
| 실무 권장도 | 표준 (★★★★★) | 제한적 (★★☆☆☆) | 금지 (★☆☆☆☆) | 미사용 (★☆☆☆☆) |
Spring이 생성자 주입을 강력히 권장하는 핵심 이유
- 객체의 불변성(Immutability) 보장 (final 키워드 활용)
- 필수 의존성의 누락 없는 안전한 생성
- Spring 컨테이너 없는 순수 자바 단위 테스트 작성 용이
- 순환 참조(Circular Dependency) 문제의 애플리케이션 시작 즉시 방지
생성자 주입은 객체의 생명주기 중 "생성과 의존성 주입"이 단 한 번에 완결되므로, 객체지향 설계 원칙에서 말하는 안정적이고 견고한 불변 객체를 만드는 데 가장 완벽하게 부합합니다.
정리
- DI의 4가지 유형: 생성자 주입, Setter 주입, 필드 주입, 일반 메서드 주입이 존재합니다.
- 실무 선택 전략: 기본적으로 100% 생성자 주입을 사용합니다. 주입받을 의존성이 실행 중에 변경되거나 선택적(Optional)인 특수한 경우에만 제한적으로 Setter 주입을 고려합니다.
- 핵심 가치: 생성자 주입을 통해 final 키워드로 불변성을 확보하고, 컨테이너 독립적인 단위 테스트 환경을 쉽게 구축할 수 있습니다.
다음 파트에서는 생성자 주입이 왜 객체지향 아키텍처와 단위 테스트 관점에서 최고의 선택인지, 생성자 주입을 권장하는 심화 원리와 코드적 이점을 자세히 분석합니다.
핵심 질문
Q1. Spring에서 지원하는 대표적인 의존성 주입(DI) 방식 4가지는 무엇인가요?
생성자 주입, 수정자(Setter) 주입, 필드 주입, 일반 메서드 주입이 있습니다.
Q2. 실무에서 생성자 주입을 가장 많이 사용하고 권장하는 이유는 무엇인가요?
객체 생성 시점에 필수 의존성을 모두 주입받아 완전한 상태의 객체를 보장할 수 있고, final 키워드를 활용해 불변성(Immutability)을 확보할 수 있기 때문입니다. 또한 Spring 컨테이너에 의존하지 않고 순수 자바 코드로 단위 테스트를 쉽게 작성할 수 있습니다.
Q3. 필드 주입(@Autowired private MemberRepository repo;)을 사용하면 안 되는 이유는 무엇인가요?
필드 주입은 Spring 컨테이너 없이는 외부에 의존성을 주입할 방법이 없어 순수 자바 환경에서의 단위 테스트가 불가능해집니다. 또한 final 키워드를 쓸 수 없어 객체의 불변성이 깨지고 의존 관계가 외부에 명확히 드러나지 않는 문제점이 있습니다.
Q4. Setter 주입은 언제 활용할 수 있나요?
의존 대상이 항상 존재하지 않고 선택적(Optional)이거나, 애플리케이션 실행 중 의존성이 동적으로 변경될 필요가 있는 경우 제한적으로 활용할 수 있습니다.
3.2 왜 생성자 주입을 사용하는가?
앞서 Spring이 지원하는 4가지 의존성 주입 방식(생성자, Setter, 필드, 일반 메서드)을 살펴보았습니다. 기술적으로는 어떤 방식을 선택하더라도 의존성 주입 자체는 가능합니다. 하지만 Spring 공식 문서를 포함해 대부분의 실무 프로젝트에서는 생성자 주입(Constructor Injection)을 표준(Default)으로 채택하고 있습니다. 생성자 주입이 제공하는 객체지향 설계, 유지보수성, 단위 테스트 측면에서의 압도적인 이점을 자세히 분석해 보겠습니다.
1. 객체를 완전한 상태로 생성 (불완전 상태 방지)
생성자 주입은 객체가 인스턴스화되는 바로 그 순간에 필요한 모든 의존성을 전달받습니다.
@Service
public class MemberService {
private final MemberRepository repository;
public MemberService(MemberRepository repository) {
this.repository = repository;
}
}
주입 방식에 따른 상태 비교
- 생성자 주입: MemberService가 메모리에 생성된 직후에는 이미 MemberRepository가 주입되어 있어 언제나 즉시 사용 가능한 완전한 상태가 됩니다.
- Setter / 필드 주입: 객체가 먼저 생성된 후 의존성이 주입되므로, 객체 생성과 주입 사이의 미세한 시점에 의존성이 없는 불완전한 상태(Null 상태)로 존재할 위험이 있습니다.
2. 필수 의존성 강제 및 명확한 표현
생성자 주입은 객체를 생성하기 위해 의존성 전달을 언어 차원에서 강제합니다.
// 올바른 객체 생성 (의존성 전달 필수)
MemberRepository repository = new JpaMemberRepository();
MemberService service = new MemberService(repository);
// 컴파일 에러 발생 (의존성 미전달 시 생성 불가)
MemberService service = new MemberService(); // Compile Error!
생성자 시그니처 자체가 "이 클래스가 정상 동작하려면 반드시 이 의존성들이 필요하다"라는 계약(Contract)을 명확히 명시하므로, 코드의 의도가 투명하게 드러납니다.
3. final 키워드 활용과 불변성(Immutability) 보장
생성자 주입을 사용하면 필드에 final 키워드를 선언할 수 있습니다.
private final MemberRepository repository;
final 선언의 장점
- 불변성 확보: 객체가 생성된 이후 런타임에 의존 대상이 무단으로 변경되는 것을 원천 차단합니다.
- 오류 예방: 실수로 필드 값을 재할당하려는 코드가 작성되면 컴파일 시점에 즉시 감지됩니다.
- 안정성: Setter 주입처럼 service.setRepository(...)를 통해 실행 중 의존성이 바뀌는 위험을 방지합니다.
4. 객체의 의존 관계가 한눈에 파악됨
클래스 상단의 생성자 파라미터만 확인하면 해당 객체가 어떤 외부 객체들과 협력하는지 직관적으로 알 수 있습니다.
// 생성자 주입: 3개의 의존 관계가 한눈에 파악됨
public MemberService(
MemberRepository repository,
DiscountPolicy discountPolicy,
EventPublisher eventPublisher) {
this.repository = repository;
this.discountPolicy = discountPolicy;
this.eventPublisher = eventPublisher;
}
반면 필드 주입은 @Autowired가 여러 필드에 흩어져 있어 클래스 규모가 커질수록 의존성을 한눈에 파악하기 어렵습니다.
5. 프레임워크 없는 순수 자바 단위 테스트 용이성
생성자 주입은 Spring 컨테이너(IOC Container)를 가동하지 않고도 순수 자바 코드로 단위 테스트(Unit Test)를 작성하기 매우 쉽습니다.
@Test
void signUpTest() {
// Spring 없이 가짜(Fake/Mock) 객체를 생성자로 직접 주입
MemberRepository repository = new FakeMemberRepository();
MemberService service = new MemberService(repository);
// 단위 테스트 수행
service.signUp(new Member("userA"));
}
필드 주입의 경우 Spring 컨테이너 없이는 필드에 의존성을 넣을 방법이 없어 NullPointerException이 발생하지만, 생성자 주입은 테스트 프레임워크나 Mocking 라이브러리와의 결합도를 획기적으로 낮춰줍니다.
6. 순환 참조(Circular Dependency)의 조기 발견
두 개 이상의 Bean이 서로를 참조하는 순환 참조 구조가 발생했을 때, 주입 방식에 따라 감지 시점이 다릅니다.

- Setter / 필드 주입: 객체 생성이 완료된 후 주입되므로, 해당 메서드가 실제 호출되는 런타임 시점에 StackOverflowError가 발생합니다.
- 생성자 주입: 객체를 생성하는 단계에서 서로의 생성자를 호출하다가 애플리케이션 구동 시점(부팅 단계)에 Spring이 순환 참조를 감지하고 구동을 즉시 중단합니다. (실패를 가장 빠르게 감지)
7. 객체지향 설계 원칙(DIP / SRP)과의 부합
객체지향 관점에서의 생성자 주입 이점
- DIP (의존역전 원칙): 인터페이스 타입에 의존하고, 구현체 생성 및 주입은 외부(Spring)에 전적으로 위임
- SRP (단일 책임 원칙): 생성자 파라미터가 너무 많다면, 클래스의 책임이 과도하다는 코드 악취(Code Smell) 감지
생성자 매개변수가 너무 많아진다면?
파라미터가 5~6개 이상으로 늘어난다면 이는 생성자 주입의 불편함이 아니라, 해당 클래스가 단일 책임 원칙(SRP)을 위배하고 있다는 경고 신호입니다. 생성자 주입은 이처럼 설계상의 문제점을 조기에 수면 위로 올려 리팩토링을 유도하는 긍정적인 역할을 합니다.
정리
| 관점 | 생성자 주입이 제공하는 가치 |
| 객체 상태 | 생성 시점에 의존성이 완비되어 불완전 객체 상태 방지 |
| 안정성 | final 키워드를 통한 런타임 불변성(Immutability) 확보 |
| 테스트 | Spring 컨테이너 없이 순수 자바 및 Mock 객체 기반 단위 테스트 용이 |
| 설계 개선 | 순환 참조 조기 발견 및 과도한 매개변수를 통한 SRP 위반 감지 |
생성자 주입은 단순히 'Spring의 한 가지 기능'이 아니라, 객체지향적인 코드를 작성하도록 강제해 주는 안전장치입니다. 실무에서는 특별한 예외가 없는 한 항상 생성자 주입을 기본으로 채택합니다. 다음 파트에서는 Spring이 여러 생성자 중 어떤 생성자를 선택하여 의존성을 주입하는지, 그리고 내부적으로 @Autowired가 어떻게 동작하는지 메커니즘을 상세히 다룹니다.
핵심 질문
Q1. Spring 프로젝트에서 생성자 주입을 권장하는 핵심 이유는 무엇인가요?
객체 생성 시점에 필수 의존성이 모두 전달되어 완전한 상태의 객체를 보장하고, final 키워드를 통해 불변성을 가질 수 있기 때문입니다. 또한 Spring 컨테이너 없이도 단위 테스트 작성이 용이하며, 애플리케이션 구동 시점에 순환 참조를 조기 발견할 수 있습니다.
Q2. 생성자 주입 시 필드에 final 키워드를 선언하는 이유는 무엇인가요?
의존성 필드가 생성 시점 이후 런타임에 실수로 변경되거나 재할당되는 것을 방지하여 객체의 불변성과 상태 안정성을 확보하기 위해서입니다.
Q3. 생성자 주입이 단위 테스트(Unit Test) 작성에 주는 이점은 무엇인가요?
@SpringBootTest 같은 무거운 프레임워크 환경을 띄우지 않고도, 순수 자바 코드(new)로 가짜 객체(Mock/Fake)를 생성자에 직접 전달하여 매우 빠르고 독립적인 단위 테스트를 작성할 수 있습니다.
Q4. 생성자의 매개변수가 너무 많아지는 문제(예: 5개 이상)는 어떻게 해석하고 해결해야 하나요?
이는 생성자 주입 방식 자체의 문제가 아니라, 해당 클래스가 너무 많은 역할을 수행하여 단일 책임 원칙(SRP)을 위배하고 있다는 신호(Code Smell)입니다. 클래스의 책임을 더 작은 단위로 분리하고 재설계해야 합니다.
3.3 @Autowired와 의존성 자동 주입
앞서 생성자 주입이 Spring에서 가장 권장되는 표준 방식이라는 점을 확인했습니다.그그렇다면 Spring 컨테이너는 객체를 생성할 때 생성자의 매개변수를 어떻게 파악하고, 적절한 Bean을 찾아 전달해 줄까요? 이 핵심 역할을 수행하는 어노테이션이 바로 @Autowired입니다. @Autowired는 Spring이 관리하는 IoC 컨테이너 내부에서 필요한 타입의 Bean을 자동으로 검색하여 해당 위치에 주입(Injection)해 주는 메커니즘입니다. 이번 파트에서는 @Autowired의 동작 원리와 자동 주입 과정에서 적용되는 주요 규칙을 살펴봅니다.
1. @Autowired의 기본 사용법
@Autowired는 Spring에게 "이 위치에 필요한 의존성 Bean을 자동으로 찾아 주입하라"는 지시를 전달합니다.
@Service
public class MemberService {
private final MemberRepository repository;
// 생성자에 @Autowired 부착
@Autowired
public MemberService(MemberRepository repository) {
this.repository = repository;
}
}
Spring 컨테이너가 생성될 때 MemberRepository 타입의 Bean을 찾아 MemberService 생성자의 매개변수로 자동 전달하므로, 개발자가 직접 new 키워드로 객체를 생성하고 조립할 필요가 없습니다.
2. 생성자가 단 하나일 때의 @Autowired 생략 규칙
Spring 4.3 버전부터는 클래스 내에 생성자가 단 하나만 존재할 경우 @Autowired 어노테이션을 생략할 수 있습니다.
@Service
public class MemberService {
private final MemberRepository repository;
// 생성자가 1개이므로 @Autowired 생략 가능 (실무 표준 방식)
public MemberService(MemberRepository repository) {
this.repository = repository;
}
}
생성자가 하나뿐이라면 Spring은 해당 생성자를 의존성 주입의 유일한 창구로 판단하여 자동으로 주입을 진행합니다.
실무 프로젝트에서는 이 생략 패턴을 기본 표준으로 사용합니다.
3. @Autowired를 적용할 수 있는 4가지 위치
@Autowired는 클래스 내부의 다양한 위치에 선언할 수 있습니다.
// 1. 생성자 (Constructor Injection) - [가장 권장]
@Autowired
public MemberService(MemberRepository repository) {
this.repository = repository;
}
// 2. 수정자 (Setter Injection) - [선택적 의존성에 사용]
@Autowired
public void setRepository(MemberRepository repository) {
this.repository = repository;
}
// 3. 필드 (Field Injection) - [권장하지 않음]
@Autowired
private MemberRepository repository;
// 4. 일반 메서드 (Method Injection) - [사용 빈도 매우 낮음]
@Autowired
public void init(MemberRepository repository) {
this.repository = repository;
}
4. 의존성 자동 주입의 내부 동작 과정
다음과 같은 서비스 클래스와 인터페이스 구현체가 존재할 때, Spring 컨테이너 내부에서는 다음과 같은 순서로 자동 주입이 완료됩니다.
@Repository
public class JpaMemberRepository implements MemberRepository {
// MemberRepository의 구현체 Bean
}
자동 주입 프로세스
- MemberService 객체 생성 요청
- 생성자 매개변수의 '타입' 확인 (MemberRepository)
- Spring 컨테이너 내에서 해당 '타입'을 지원하는 Bean 검색
- JpaMemberRepository Bean 발견
- MemberService 생성자 매개변수로 해당 Bean 객체 주입
5. 자동 주입 시 발생할 수 있는 예외 상황
1) 같은 타입의 Bean이 2개 이상 등록된 경우 (조회 빈이 2개 이상)
@Repository
public class JpaMemberRepository implements MemberRepository {}
@Repository
public class MemoryMemberRepository implements MemberRepository {}

MemberRepository 구현체가 2개 이상 Bean으로 등록되어 있다면, Spring은 어떤 객체를 주입해야 할지 판단하지 못해 애플리케이션 시작 시점에 NoUniqueBeanDefinitionException 예외를 던집니다.
2) 주입할 Bean이 컨테이너에 존재하지 않는 경우
생성자 매개변수로 요구하는 타입의 Bean이 Spring 컨테이너에 하나도 등록되어 있지 않다면, 주입을 진행할 수 없어 UnsatisfiedDependencyException 예외가 발생하며 구동이 즉시 중단됩니다. 이는 객체가 불완전한 상태로 구동되는 것을 방지하기 위한 안전장치입니다.
6. 의존성 자동 주입의 핵심 이점
@Autowired 자동 주입의 장점
- 객체 생명주기 관리 자동화 (조립 및 연결 코드 제거)
- 결합도(Coupling) 감소 (인터페이스 기반 유연한 설계)
- 높은 보수성 (구현체가 변경되어도 서비스 로직 수정 없음)
- DIP(의존역전 원칙)의 자연스러운 충족
정리
- @Autowired 기능: Spring 컨테이너에서 타입(Type)을 기준으로 적절한 Bean을 찾아 의존성을 자동 주입하는 어노테이션입니다.
- 생성자 생략 규칙: 단일 생성자 구조에서는 @Autowired를 생략하더라도 자동으로 생성자 주입이 동작합니다.
- 예외 발생 조건: 주입하려는 타입의 Bean이 컨테이너에 전혀 없거나, 반대로 2개 이상 존재하여 충돌이 발생하면 구동 시점에 예외가 발생합니다.
동일한 타입의 Bean이 2개 이상일 때 발생하는 충돌 문제는 @Primary와 @Qualifier 어노테이션을 통해 해결할 수 있습니다.
다음 파트에서 그 사용법과 우선순위를 상세히 알아봅니다.
핵심 질문
Q1. @Autowired 어노테이션의 역할과 기본 주입 탐색 기준은 무엇인가요?
Spring IoC 컨테이너가 관리하는 Bean 중 필요한 의존성을 찾아 자동으로 주입해 주는 역할을 하며, 기본적으로 타입(Type)을 기준으로 Bean을 검색하여 주입합니다.
Q2. 생성자 주입 시 @Autowired를 생략할 수 있는 조건은 무엇인가요?
클래스 내에 생성자가 단 하나만 존재할 경우, Spring 4.3부터는 @Autowired 어노테이션을 생략해도 자동으로 의존성 주입이 수행됩니다.
Q3. 동일한 인터페이스를 구현한 Bean이 2개 이상 등록되어 있을 때 @Autowired를 사용하면 어떻게 되나요?
Spring이 어떤 Bean을 주입할지 결정할 수 없어 NoUniqueBeanDefinitionException 예외가 발생하며 애플리케이션 구동에 실패합니다. 이를 해결하려면 @Primary나 @Qualifier를 사용해 우선순위나 이름을 명시해야 합니다.
Q4. 주입받으려는 Bean이 Spring 컨테이너에 등록되어 있지 않은 경우 어떻게 동작하나요?
의존성 주입을 완료할 수 없으므로 UnsatisfiedDependencyException 예외가 발생하며 애플리케이션 시작 단계에서 구동이 즉시 중단됩니다.
3.4 @Primary와 @Qualifier
앞서 @Autowired는 기본적으로 타입(Type)을 기준으로 적절한 Bean을 탐색하여 자동 주입을 진행한다는 점을 살펴보았습니다.
그렇다면 동일한 인터페이스를 구현한 Bean이 둘 이상 등록되어 있다면 Spring은 어떻게 동작할까요? 예를 들어 MemberRepository 인터페이스의 구현체 Bean이 2개라면, Spring은 어떤 Bean을 선택해야 할지 결정할 수 없어 애플리케이션 시작 단계에서 예외를 발생시킵니다. Spring Framework는 이러한 Bean 충돌 문제를 명확하게 해결할 수 있도록 @Primary와 @Qualifier라는 두 가지 핵심 어노테이션을 제공합니다. 이번 파트에서는 동일한 타입의 Bean이 여러 개 존재하는 상황에서 주입 우선순위를 제어하는 방식을 다룹니다.
1. 동일한 타입의 Bean 충돌 상황
다음과 같이 MemberRepository를 구현한 2개의 클래스가 모두 @Repository로 선언되어 Spring Bean으로 등록된 상황을 가정해 보겠습니다.
public interface MemberRepository {}
@Repository
public class JpaMemberRepository implements MemberRepository {}
@Repository
public class MemoryMemberRepository implements MemberRepository {}
이 상태에서 MemberService에 MemberRepository 타입의 의존성을 주입하려 시도하면 충돌이 발생합니다.
@Service
public class MemberService {
private final MemberRepository repository;
// MemberRepository 타입 Bean이 2개이므로 충돌 발생!
public MemberService(MemberRepository repository) {
this.repository = repository;
}
}

2. @Primary: 기본(Default) Bean 지정
@Primary는 동일한 타입의 여러 Bean 중 우선적으로 주입될 기본 Bean을 지정하는 어노테이션입니다.
@Repository
@Primary // 기본 주입 대상 지정
public class JpaMemberRepository implements MemberRepository {}
@Repository
public class MemoryMemberRepository implements MemberRepository {}
@Service
public class MemberService {
private final MemberRepository repository;
public MemberService(MemberRepository repository) {
this.repository = repository; // @Primary가 붙은 JpaMemberRepository 선택
}
}
@Primary 주요 특징 및 활용처
- 기본 전략(Default Strategy) 설정: 주입받는 코드 측에 별도의 어노테이션을 붙이지 않아도 자동으로 @Primary가 선언된 Bean이 채택됩니다.
- 실무 활용: 대부분의 로직에서 Main Database(예: JPA)를 활용하고, 아주 예외적인 상황에서만 별도의 저장소(예: In-Memory/Redis)를 사용할 때 가장 깔끔한 해결책입니다.
3. @Qualifier: 특정 Bean 명시적 지정
@Qualifier는 주입할 Bean의 고유 식별자(이름)를 지정하여 특정 Bean을 명확하게 핑포인트로 선택하는 방식입니다.
// 1. Bean 등록 측에 Qualifier 이름 부여
@Repository
@Qualifier("mainRepository")
public class JpaMemberRepository implements MemberRepository {}
@Repository
@Qualifier("tempRepository")
public class MemoryMemberRepository implements MemberRepository {}
// 2. 주입받는 생성자 파라미터에 Qualifier 지정
@Service
public class MemberService {
private final MemberRepository repository;
public MemberService(@Qualifier("mainRepository") MemberRepository repository) {
this.repository = repository; // mainRepository 명시적 선택
}
}
@Qualifier 동작 프로세스
- MemberRepository 타입 검색
- @Qualifier("mainRepository") 식별자 확인
- 해당 Qualifier 이름을 가진 JpaMemberRepository 핀포인트 선택
4. @Primary와 @Qualifier 우선순위 비교
만약 @Primary와 @Qualifier가 동시에 적용된 환경이라면 Spring은 어떤 Bean을 선택할까요?
@Repository
@Primary
public class JpaMemberRepository implements MemberRepository {}
@Repository
@Qualifier("memoryRepo")
public class MemoryMemberRepository implements MemberRepository {}
@Service
public class MemberService {
private final MemberRepository repository;
public MemberService(@Qualifier("memoryRepo") MemberRepository repository) {
this.repository = repository; // memoryRepo 주입
}
}
우선순위 원칙: @Qualifier가 @Primary보다 더 높은 우선순위를 가집니다.
Spring은 더 상세하고 구체적인 지시를 우선시합니다. 메인 환경 전체에 적용되는 기본값(@Primary)보다 개발자가 특정 주입 지점에 직접 지정한 매칭 구문(@Qualifier)의 결합력이 더 강력하기 때문입니다.
탐색 및 선택 우선순위
- 1순위: @Qualifier 지정을 통한 핀포인트 매칭
- (미지정 시) 2순위: @Primary가 부여된 대표 Bean 선택
- (미선택 시) 3순위: 파라미터 변수명과 일치하는 Bean 이름 매칭
5. 실무에서의 모범 활용 패턴 (Best Practice)
실무 프로젝트에서는 시스템 전체에 공통으로 적용되는 주 스토리지와 보조 스토리지를 함께 운용할 때 두 어노테이션의 조합을 적극적으로 활용합니다.

- 메인 로직: 대부분의 결제 서비스에는 기본값으로 동작할 @Primary Bean을 주입하여 간결함을 유지합니다.
- 특수 로직: 포인트 결제 등 명확하게 특정 구현체가 필요한 일부 서비스 클래스에서만 @Qualifier("pointPay")를 지정하여 수동으로 채택합니다.
정리
| 구분 | @Primary | @Qualifier |
| 개념 | 대표 기본 Bean 선언 | 특정 Bean을 고유 이름으로 직접 지정 |
| 설정 위치 | Bean 클래스 선언부 | Bean 클래스 선언부 + 주입받는 위치 |
| 우선순위 | 보통 (2순위) | 높음 (1순위) |
| 장점 | 주입받는 코드가 깔끔해짐 | 오해 없이 주입 대상을 명확하게 지정 가능 |
| 추천 케이스 | 메인/서브 관계가 명확할 때 (메인 지정용) | 여러 구현체가 자주 개별 선택되는 케이스 |
동일한 타입의 Bean이 여러 개 있을 때는 기본값으로 사용될 메인 Bean에 @Primary를 붙이고, 특별한 용도로 구분해서 사용하는 Bean들에 @Qualifier를 부여하여 관리하는 것이 코드의 가독성과 유지보수성을 극대화하는 표준 접근법입니다.
다음 파트에서는 생성자 주입 코드를 획기적으로 줄여주는 Lombok의 @RequiredArgsConstructor를 활용한 간결한 DI 구현법을 알아봅니다.
핵심 질문
Q1. 동일한 타입의 Bean이 여러 개 등록되어 있을 때 주입 시 어떤 문제가 발생하나요?
Spring이 어떤 Bean을 선택해야 할지 판단할 수 없어 애플리케이션 시작 단계(구동 시점)에서 NoUniqueBeanDefinitionException 예외가 발생합니다.
Q2. @Primary와 @Qualifier의 차이점은 무엇인가요?
@Primary는 여러 Bean 중 기본(Default)으로 채택될 메인 Bean을 설정하여 주입받는 코드의 간결성을 유지해 줍니다. 반면 @Qualifier는 주입받는 지점에 직접 이름을 명시하여 특정 Bean을 핀포인트로 선택하는 방식입니다.
Q3. @Primary와 @Qualifier가 동시에 설정된 경우 어떤 어노테이션이 우선적으로 적용되나요?
@Qualifier가 더 높은 우선순위를 가집니다. Spring은 더 구체적이고 명시적으로 지정된 지시어(@Qualifier)를 기본 설정(@Primary)보다 우선하여 처리합니다.
Q4. 실무에서는 두 어노테이션을 어떻게 조합하여 사용하는 것이 권장되나요?
자주 사용되는 대표 구현체(예: 메인 DB Repository)에는 @Primary를 적용해 주입받는 코드를 깔끔하게 유지하고, 보조적이거나 특수한 목적으로 사용되는 구현체에는 @Qualifier를 사용해 필요한 곳에서만 명시적으로 선택하도록 설계합니다.
3.5 @RequiredArgsConstructor와 Lombok
앞서 생성자 주입이 Spring에서 가장 권장되는 표준 방식이며, final 키워드를 활용해 불변성과 안전성을 동시에 확보할 수 있음을 확인했습니다.하지만 프로젝트 규모가 커지고 클래스가 참조하는 의존성이 늘어날수록 매번 동일한 형태의 생성자 코드를 반복해서 작성해야 하는 번거로움이 발생합니다. 이러한 반복적인 보일러플레이트(Boilerplate) 코드를 획기적으로 줄여주는 자바 라이브러리가 바로 Lombok이며, 의존성 주입에서는 @RequiredArgsConstructor 어노테이션이 실무 표준으로 사용됩니다. 이번 파트에서는 Lombok을 활용해 생성자 주입 코드를 단축하는 메커니즘을 다룹니다.
1. Lombok이란?
Lombok은 자바 클래스 작성 시 상투적으로 반복되는 Getter, Setter, Constructor, ToString, Builder 등의 코드를 컴파일 시점에 어노테이션 기반으로 자동 생성해 주는 라이브러리입니다.Spring Boot 프로젝트 설정 시 필수적으로 추가하는 핵심 라이브러리 중 하나입니다.
2. 생성자를 직접 작성하는 경우의 한계
Lombok 없이 보편적인 생성자 주입 방식으로 서비스를 구현하면 다음과 같이 작성하게 됩니다.
@Service
public class MemberService {
private final MemberRepository repository;
private final DiscountPolicy discountPolicy;
// 생성자를 통한 직접 주입
public MemberService(MemberRepository repository, DiscountPolicy discountPolicy) {
this.repository = repository;
this.discountPolicy = discountPolicy;
}
}
의존성이 2개일 때는 문제가 되지 않지만, 협력 객체가 늘어날수록 다음과 같이 반복적인 필드 대입 생서자 코드가 점차 길어지게 됩니다.
private final MemberRepository repository;
private final DiscountPolicy discountPolicy;
private final MailSender mailSender;
private final EventPublisher eventPublisher;
private final CacheManager cacheManager;
// 필드가 추가될 때마다 생성자 매개변수와 내부 대입 코드를 매번 수정해야 함!
3. @RequiredArgsConstructor를 통한 극적인 코드 단축
Lombok이 제공하는 @RequiredArgsConstructor를 부착하면 위 생성자 코드를 완전히 대체할 수 있습니다.
@Service
@RequiredArgsConstructor // final 필드를 모아 생성자를 자동 생성
public class MemberService {
private final MemberRepository repository;
private final DiscountPolicy discountPolicy;
}
개발자가 직접 생성자 코드를 작성하지 않아도, 자바 컴파일 과정(Annotation Processing)에서 Lombok이 내부적으로 다음과 같은 생성자를 바이트코드에 생성해 줍니다.
// 컴파일 시점에 Lombok이 바이트코드에 자동 생성하는 생성자
public MemberService(MemberRepository repository, DiscountPolicy discountPolicy) {
this.repository = repository;
this.discountPolicy = discountPolicy;
}
4. 생성자 포함 대상: 필수 필드 (Required Field)
@RequiredArgsConstructor는 이름 그대로 초기화가 필요한 '필수 필드'만을 대상으로 생성자의 파라미터로 포함합니다.
생성자 대상 필드 조건
- final이 선언된 필드
- @NonNull 어노테이션이 선언된 필드
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository repository; // [포함] final 필드
private final DiscountPolicy discountPolicy; // [포함] final 필드
private String optionalNote; // [제외] 일반 필드 (생성자에 포함 안 됨)
}
final이 아닌 값이 바뀔 수 있는 일반 변수 필드는 생성자 주입 대상에서 제외되므로, 필수 의존성은 final로 명확히 고정할 수 있습니다.
5. Spring DI와 Lombok의 명확한 역할 분담
많은 개발자들이 "Lombok의 @RequiredArgsConstructor가 의존성 주입을 대신해 준다"고 오해하곤 합니다.
하지만 두 기술은 명확히 다른 역할을 수행합니다.
Lombok과 Spring의 역할 분담 체계
- Lombok (컴파일 타임): `final` 필드를 모아서 자바 생성자 코드를 '자동 작성'
- Spring Framework (런타임/구동 타임): 단일 생성자를 감지하여 해당 생성자로 'Bean 주입(DI)' 수행

Lombok은 단지 자바 언어 차원의 생성자 작성 노동을 대체해 줄 뿐이며, 실제로 객체를 찾아서 넘겨주는 의존성 주입 주체는 여전히 Spring입니다.
6. 실무에서의 표준 작성 스타일 (Best Practice)
현재 대부분의 Spring Boot 및 자바 웹 프로젝트에서는 다음 스타일을 기본 표준으로 채택하고 있습니다.
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository repository;
private final DiscountPolicy discountPolicy;
public void signUp(Member member) {
repository.save(member);
}
}
이 조합이 주는 이점
- 최고의 가독성: 불필요하게 늘어나는 생성자 코드가 없어져 깔끔함 유지가 가능합니다.
- final을 통한 불변성 유지: 의존성이 런타임에 변형될 위험을 원천 차단합니다.
- @Autowired 작성 불필요: 생성자가 단 1개만 생성되므로 @Autowired 표시를 할 필요가 없습니다.
- 완벽한 단위 테스트 지원: Spring 없이도 생성자를 호출하여 가짜(Mock) 객체를 넣는 단위 테스트 작성이 가능합니다.
정리
- @RequiredArgsConstructor 역할: final 또는 @NonNull이 붙은 필수 필드를 모아서 컴파일 시점에 생성자를 자동으로 만들어 줍니다.
- Spring 단일 생성자 규칙과의 시너지: 생성자가 1개만 생성되므로 Spring 4.3+의 @Autowired 생략 규칙이 자동 적용됩니다.
- 핵심 구분: Lombok은 생성자 생성을 담당하고, Spring은 그 생성자를 이용해 의존성 주입(DI)을 수행합니다.
다음 파트에서는 의존성 주입 시 발생할 수 있는 가장 치명적인 문제인 순환 참조(Circular Dependency)의 원인과 해결 방안을 집중적으로 분석합니다.
핵심 질문
Q1. Lombok의 @RequiredArgsConstructor는 어떤 역할을 수행하나요?
클래스 내에 선언된 final 필드나 @NonNull 필드만을 매개변수로 갖는 생성자를 컴파일 시점에 자동으로 생성해 주는 어노테이션입니다.
Q2. @RequiredArgsConstructor를 사용할 때 @Autowired를 명시하지 않아도 의존성 주입이 되는 이유는 무엇인가요?
Lombok이 생성자를 1개만 만들어내기 때문입니다. Spring 4.3부터는 클래스 내 단일 생성자에 한해 @Autowired 생략이 가능하므로, Spring이 이를 감지하여 자동으로 의존성을 주입합니다.
Q3. @RequiredArgsConstructor가 의존성 주입(DI) 기능 자체를 수행하는 것인가요?
아닙니다. Lombok은 컴파일 시점에 자바 생성자 코드를 생성해 주는 역할만 수행하며, 애플리케이션 실행 시점에 해당 생성자를 통해 실제 Bean 객체를 주입해 주는 주체는 Spring IoC 컨테이너입니다.
Q4. 실무에서 생성자 주입과 @RequiredArgsConstructor 조합을 가장 선호하는 이유는 무엇인가요?
final 키워드를 활용해 객체의 불변성을 유지하고 생성자 코드를 작성하는 반복 노동을 최소화할 수 있기 때문입니다. 또한 코드 가독성이 향상되며 단위 테스트 작성도 매우 용이해집니다.
3.6 순환 참조(Circular Dependency)
앞서 생성자 주입은 객체를 완전한 상태로 생성하고 final을 활용할 수 있어 Spring에서 가장 권장되는 방식임을 다루었습니다. 생성자 주입의 또 다른 결정적인 장점은 순환 참조(Circular Dependency) 문제를 애플리케이션 구동 시점에 조기에 발견해 준다는 점입니다. 순환 참조는 시스템의 정상 구동을 막는 대표적인 아키텍처 및 설계 결함 중 하나입니다. 이번 파트에서는 순환 참조의 정의와 원인, 그리고 올바른 해결 전략을 분석합니다.
1. 순환 참조란?
순환 참조는 둘 이상의 Bean 객체가 서로를 직·간접적으로 참조(의존)하면서 의존성 형성 과정이 원형(Cycle) 고리를 이루는 상태를 의미합니다.

@Service
public class MemberService {
private final OrderService orderService;
public MemberService(OrderService orderService) {
this.orderService = orderService;
}
}
@Service
public class OrderService {
private final MemberService memberService;
public OrderService(MemberService memberService) {
this.memberService = memberService;
}
}
2. 순환 참조 시 생성자 주입이 동작하는 메커니즘 (무한 루프)
Spring 컨테이너가 Bean을 인스턴스화하는 과정에서 생성자 주입을 만나면, 생성자 파라미터로 들어갈 의존 대상을 우선적으로 생성하려 시도합니다.
- MemberService 인스턴스 생성 시도
- 생성자 파라미터인 OrderService Bean 필요
- OrderService 인스턴스 생성 시도
- 생성자 파라미터인 MemberService Bean 필요
- MemberService는 아직 [1]번 단계에서 생성 완료되지 못함 (Null/Uninitialized)
- 무한 인스턴스화 대기 루프 ──> BeanCurrentlyInCreationException 예외 발생!
생성자 주입 구조에서는 어느 한쪽도 완시점의 객체로 만들어지지 못하므로, Spring은 애플리케이션 부팅 과정에서 이를 직관적으로 감지하고 구동을 즉시 중단(Fail-Fast)시킵니다.
3. 주입 방식별 순환 참조 감지 시점의 차이
주입 방식에 따른 순환 참조 감지 시점
- 생성자 주입: 애플리케이션 시작 단계(Buildup)에서 감지 → 구동 시 예외 발생으로 운영 장애 원천 차단 (Fail-Fast)
- Setter / 필드 주입: 우선 빈 객체를 생성 후 주입 수행 → 애플리케이션은 정상 부팅되나, 런타임에 메서드 호출 시 StackOverflowError 발생 위험에 노출
Past Spring 버전에서는 Setter 주입의 지연 주입 특성을 통해 순환 참조를 일시적으로 허용하기도 했으나, 이는 심각한 설계 결함을 은폐하는 위험이 있습니다. 현재의 Spring Boot(2.6+) 환경에서는 필드/Setter 주입이더라도 순환 참조 발생 시 기본적으로 구동을 거부하도록 정책이 강화되어 있습니다.
4. 순환 참조가 발생하는 근본적인 원인
순환 참조는 단순한 Framework 설정 오류가 아니라 객체지향 설계 관점에서 클래스의 역할과 책임이 명확히 분리되지 않았다는 강한 경고 신호(Code Smell)입니다.
- 책임의 얽힘: MemberService와 OrderService가 서로의 로직을 상호 호출하고 있다면, 두 클래스의 경계가 모호해졌음을 뜻합니다.
- 단일 책임 원칙(SRP) 위반: 하나의 클래스가 너무 많은 일을 담당하거나 두 서비스의 공통 도메인 로직이 분리되지 않았을 때 발생합니다.
5. 순환 참조 해결 방안
방안 1: 공통 로직 분리를 통한 단방향 의존 설계 (가장 권장)
두 클래스가 서로 의존하게 만드는 공통 로직이나 연관 로직을 추출하여 제3의 서비스 클래스(예: CommonService 또는 OrderFacade)로 분리합니다.

@Service
@RequiredArgsConstructor
public class OrderProcessFacade { // 공통 또는 중재 로직 전담
private final MemberService memberService;
private final OrderService orderService;
public void processOrder() {
// 회원 검증 및 주문 처리 유즈케이스 통합 수행
}
}
방안 2: 이벤트 기반 아키텍처(ApplicationEventPublisher) 적용
한 서비스가 다른 서비스의 상태 변경을 알릴 때 직접 참조 대신 Spring의 이벤트 발행 메커니즘을 이용해 결합도를 완화합니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public void createOrder() {
// 주문 생성 로직...
eventPublisher.publishEvent(new OrderCreatedEvent(this)); // 직접 참조 없이 이벤트 발행
}
}
방안 3: @Lazy 어노테이션 활용 (비권장 임시방편)
@Lazy를 사용하면 주입 시점에 실제 객체 대신 프록시(Proxy) 객체를 주입하여 Bean 생성을 실제 사용 시점까지 지연시킴으로써 순환 고리를 임시로 끊어줍니다.
public MemberService(@Lazy OrderService orderService) {
this.orderService = orderService;
}
주의: @Lazy는 설계를 개선하지 않고 순환 참조를 우회 및 은폐하는 미봉책에 불과하므로, 실무에서는 최후의 수단으로만 고려하고 반드시 클래스 분리를 통한 리팩토링을 우선해야 합니다.
6. 순환 참조를 예방하는 레이어드 아키텍처 원칙
순환 참조를 원천 예방하기 위해서는 의존성의 방향이 항상 단방향(상위 계층 -> 하위 계층)으로만 흐르도록 설계해야 합니다.

- 절대 금지: Repository가 Service를 참조하거나, Service가 Controller를 역참조하는 구조는 지양해야 합니다.
- 동일 계층 참조 제한: Service 간의 상호 참조가 빈번하다면 계층을 재조정하거나 Facade 패턴 도입을 검토해야 합니다.
정리
- 순환 참조 개념: 둘 이상의 Bean이 서로를 원형 구조로 참조하여 객체 생성이 불가능해지는 상태입니다.
- 생성자 주입의 장점: 애플리케이션 시작 단계(구동 시점)에서 순환 참조를 조기에 감지하고 구동을 중단시켜 안정성을 높여줍니다.
- 근본 해결책: @Lazy 같은 우회 어노테이션에 의존하지 않고, 공통 클래스 추출 및 단방향 의존 관계로의 리팩토링을 진행해야 합니다.
Part 3. 의존성 주입(DI)과 활용편이 모두 마무리되었습니다. 다음 Part 4에서는 Spring Bean의 생명주기(Lifecycle)와 스코프(Scope)에 대해 자세히 다루어 보겠습니다.
핵심 질문
Q1. 순환 참조(Circular Dependency)란 무엇이며 왜 발생하는가?
둘 이상의 Bean 객체가 서로를 직·간접적으로 참조하면서 의존성 주입 관계가 원형 고리(Cycle)를 형성하는 문제입니다. 주로 클래스의 책임이 명확히 분리되지 않고 기능이 서로 얽혀 있을 때(SRP 위반) 발생합니다.
Q2. 생성자 주입을 사용할 때 순환 참조가 발생하면 어떻게 처리되는가?
Spring 컨테이너가 Bean을 인스턴스화하는 생성자 호출 단계에서 무한 루프를 감지하고, **애플리케이션 구동 시점에 BeanCurrentlyInCreationException 예외를 발생시키며 즉시 구동을 중단(Fail-Fast)**합니다.
Q3. 순환 참조 문제를 해결하기 위한 올바른 접근법은 무엇인가?
가장 근본적인 해결책은 객체의 책임을 재설계하는 것입니다. 서로 참조하는 공통 로직을 별도의 클래스(또는 Facade 레이어)로 분리하거나, Spring 이벤트를 활용해 의존 관계를 단방향으로 전환해야 합니다.
Q4. 순환 참조 해결을 위해 @Lazy 어노테이션을 사용하는 것은 바람직한가?
바람직하지 않습니다. @Lazy는 프록시 객체를 이용해 주입 시점을 지연시켜 문제를 임시로 피할 뿐, 설계적 결함을 은폐하게 됩니다. 따라서 특수한 상황을 제외하고는 클래스 리팩토링을 통한 근본적 해결을 우선해야 합니다.
'🍃SpringBoot' 카테고리의 다른 글
| Spring: Part 5. Spring Configuration (0) | 2026.07.29 |
|---|---|
| Spring: Part 4. Bean 생명주기(Lifecycle) (1) | 2026.07.29 |
| Spring: Part 2. IoC 컨테이너와 Bean (0) | 2026.07.29 |
| Spring: Part 1. Spring Framework의 이해 (0) | 2026.07.28 |
| Spring IoC & DI 완전 정복: Chapter 8. Proxy와 Spring AOP (1) | 2026.07.22 |
