스프링 백엔드 개발을 하다 보면 가장 기본적으로 사용하는 것이 바로 **의존성 주입(Dependency Injection)**입니다. 그런데 같은 의존성 주입이라도 @Autowired를 쓰는 방식과 @RequiredArgsConstructor를 쓰는 방식이 있죠.
"둘 다 되는데 뭐가 다른 거야?"라고 생각할 수 있지만, 실무에서는 명확한 차이가 있고 스프링 공식 문서에서도 생성자 주입을 권장합니다. 오늘은 그 이유를 실제 코드 예시와 함께 알아보겠습니다.
두 가지 방식 비교
1. @RequiredArgsConstructor 방식 (생성자 주입) ✅ 권장
@Service
@RequiredArgsConstructor
public class FinMngRegService {
private final BatchMapper mapper;
private final AnotherService service;
public void register() {
mapper.insert(...);
}
}
2. @Autowired 방식 (필드 주입) ❌ 비권장
@Service
public class FinMngRegService {
@Autowired
private BatchMapper mapper;
@Autowired
private AnotherService service;
public void register() {
mapper.insert(...);
}
}
얼핏 보면 비슷해 보이지만, 실무에서는 큰 차이가 있습니다.
@RequiredArgsConstructor가 더 나은 5가지 이유
1️⃣ final 키워드 사용 가능 → 불변성 보장
❌ 필드 주입의 문제점
@Service
public class PaymentService {
@Autowired
private PaymentMapper mapper; // final 사용 불가
public void someMethod() {
this.mapper = null; // 😱 실수로 null 할당 가능!
// 나중에 NPE 발생...
}
}
✅ 생성자 주입의 안전성
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentMapper mapper; // final로 불변 보장
public void someMethod() {
this.mapper = null; // ❌ 컴파일 에러! 바꿀 수 없음
}
}
실무 포인트: 멀티스레드 환경에서 객체 상태가 변경되지 않음을 보장하여 안전합니다.
2️⃣ 테스트 코드 작성이 훨씬 쉬움
❌ 필드 주입은 테스트하기 복잡
@Service
public class FinMngRegService {
@Autowired
private BatchMapper mapper;
public void register(String id) {
mapper.insert(id);
}
}
// 테스트 코드
@Test
void testRegister() {
FinMngRegService service = new FinMngRegService();
BatchMapper mockMapper = mock(BatchMapper.class);
// 문제: mapper에 mock을 어떻게 넣지?
// ReflectionTestUtils 같은 특수 도구 필요 😓
ReflectionTestUtils.setField(service, "mapper", mockMapper);
service.register("123");
}
✅ 생성자 주입은 테스트가 간단
@Service
@RequiredArgsConstructor
public class FinMngRegService {
private final BatchMapper mapper;
public void register(String id) {
mapper.insert(id);
}
}
// 테스트 코드
@Test
void testRegister() {
BatchMapper mockMapper = mock(BatchMapper.class);
FinMngRegService service = new FinMngRegService(mockMapper); // 간단!
service.register("123");
verify(mockMapper).insert("123");
}
실무 포인트: 단위 테스트 작성 시 스프링 컨텍스트 없이도 순수 자바로 테스트 가능합니다.
3️⃣ 순환 참조를 컴파일 단계에서 발견
❌ 필드 주입 - 런타임에서 발견 (앱 실행해야 알 수 있음)
@Service
public class AService {
@Autowired
private BService bService; // B를 참조
}
@Service
public class BService {
@Autowired
private AService aService; // A를 참조 (순환 참조!)
}
// 앱 실행 → 스프링 컨텍스트 로딩 중 에러 💥
// "The dependencies of some of the beans in the application context form a cycle"
✅ 생성자 주입 - IDE와 컴파일러가 미리 경고
@Service
@RequiredArgsConstructor
public class AService {
private final BService bService;
// IDE가 순환 참조 경고 표시
}
@Service
@RequiredArgsConstructor
public class BService {
private final AService aService;
// 컴파일 시 에러 또는 IDE 경고
}
실무 포인트: 개발 단계에서 미리 문제를 발견할 수 있어 배포 후 장애를 예방할 수 있습니다.
4️⃣ 의존성을 명확하게 파악 가능 (SRP 위반 체크)
❌ 필드 주입 - 의존성이 숨어있음
@Service
public class FinMngRegService {
@Autowired private BatchMapper mapper1;
@Autowired private BatchMapper mapper2;
@Autowired private AService service1;
@Autowired private BService service2;
@Autowired private CService service3;
@Autowired private DService service4;
@Autowired private EService service5;
@Autowired private FService service6;
@Autowired private GService service7;
// 스크롤 내려야 보임... 의존성이 무려 7개!
// 이 클래스 책임이 너무 많은 거 아닌가? 🤔
}
✅ 생성자 주입 - 의존성이 한눈에 보임
@Service
@RequiredArgsConstructor
public class FinMngRegService {
private final BatchMapper mapper1;
private final BatchMapper mapper2;
private final AService service1;
private final BService service2;
private final CService service3;
private final DService service4;
private final EService service5;
private final FService service6;
private final GService service7;
}
// 생성자 파라미터 7개를 보는 순간: "이거 너무 많은데?"
// → 클래스 분리 필요성을 바로 인지 가능
실무 포인트: 생성자 파라미터가 많아지면 자연스럽게 리팩토링을 고려하게 되어 **단일 책임 원칙(SRP)**을 지키는 데 도움이 됩니다.
5️⃣ NPE(NullPointerException) 방지
❌ 필드 주입의 위험한 시나리오
@Service
public class PaymentService {
@Autowired
private PaymentMapper mapper;
private BigDecimal feeRate;
public PaymentService() {
this.feeRate = new BigDecimal("0.03");
// 이 시점에 mapper는 아직 null!
// @Autowired는 생성자 실행 후에 주입됨
}
public void processPayment(BigDecimal amount) {
BigDecimal fee = amount.multiply(feeRate);
mapper.insertPayment(amount, fee); // mapper가 null일 수도!
}
}
✅ 생성자 주입은 항상 안전
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentMapper mapper; // 생성자 실행 시 반드시 주입
private final BigDecimal feeRate = new BigDecimal("0.03");
public void processPayment(BigDecimal amount) {
BigDecimal fee = amount.multiply(feeRate);
mapper.insertPayment(amount, fee); // mapper는 절대 null이 아님!
}
}
실무 포인트: 생성자 주입을 사용하면 객체 생성 시점에 모든 의존성이 주입되므로 NPE 걱정이 없습니다.
정리: 어떤 방식을 써야 할까?
스프링 공식 권장 순위
- 생성자 주입 (Constructor Injection) ✅ 강력 추천
- @RequiredArgsConstructor 사용
- 또는 생성자 직접 작성
- Setter 주입 (Setter Injection) - 선택적 의존성에만 사용
- 필드 주입 (Field Injection) ❌ 비권장
Lombok 없이 생성자 주입 하는 법
@Service
public class FinMngRegService {
private final BatchMapper mapper;
private final AnotherService service;
// 생성자 직접 작성
public FinMngRegService(BatchMapper mapper, AnotherService service) {
this.mapper = mapper;
this.service = service;
}
}
// 참고: Spring 4.3부터는 생성자가 1개면 @Autowired 생략 가능
실무 코드 리뷰에서
// 신입이 이렇게 짜면
@Autowired
private BatchMapper mapper;
// 👨💼 시니어: "생성자 주입으로 바꿔주세요"
// 이렇게 짜면
@RequiredArgsConstructor
private final BatchMapper mapper;
// 👨💼 시니어: "Good!"
마치며
의존성 주입은 스프링의 핵심 기능이지만, 어떻게 주입하느냐에 따라 코드 품질이 크게 달라집니다.
- ✅ 불변성 보장
- ✅ 테스트 용이성
- ✅ 순환 참조 조기 발견
- ✅ 명확한 의존성 파악
- ✅ NPE 방지
이 다섯 가지 이유만으로도 @RequiredArgsConstructor를 사용할 충분한 이유가 됩니다.
아직 @Autowired 필드 주입을 사용하고 계신다면, 이번 기회에 생성자 주입으로 리팩토링해보시는 것을 추천드립니다!
참고 자료
#Spring #SpringBoot #DependencyInjection #Lombok #BackEnd #Java
'IT' 카테고리의 다른 글
| System.out.println()의 성능 이슈와 Logback 비교 (0) | 2026.01.15 |
|---|---|
| @Async 적용하기 (0) | 2026.01.14 |
| 커넥션 타임아웃과 리드 타임아웃 (0) | 2026.01.13 |
| 실행계획과 인덱스 (0) | 2025.12.16 |
| 실서비스에서 커넥션 풀(hikari Cp) 제대로 이해하기 (0) | 2025.12.16 |
댓글