Spring 의존성 주입 방식과 실무 모범 사례 알아보기

Best Practices for Dependency Injection with Spring
Spring 프레임워크의 의존성 주입(Dependency Injection, DI)은 객체 간의 결합도를 낮추고 유연성을 높이는 핵심 기술입니다. 생성자 주입, 세터 주입, 필드 주입 등 다양한 주입 방식의 특징과 차이점을 살펴보고, 실무에서 널리 활용되는 모범 사례를 알아봅니다.
Dependency Injection Strategies in Spring
Spring에서 의존성 주입을 수행하는 방법은 여러 가지가 있으며, 대표적으로 세터 주입, 필드 주입, 생성자 주입 방식이 있습니다. 각 방식마다 장단점이 존재하며, 특정한 상황에서 더 적합한 방식을 선택해야 합니다.
Key Annotations
의존성 주입 방식을 살펴보기 전에, 주입을 수행할 때 활용되는 주요 어노테이션을 먼저 이해하는 것이 중요합니다. Spring은 다양한 어노테이션을 제공하여 개발자가 원하는 방식으로 빈을 생성하고 관리할 수 있도록 지원합니다.
@Autowired
: Spring 컨테이너가 자동으로 적절한 빈을 찾아 주입하도록 설정하는 핵심 어노테이션입니다.@Qualifier
: 같은 타입의 여러 빈이 존재할 때 특정 빈을 명시적으로 선택하는 어노테이션입니다.@Primary
: 여러 개의 동일 타입 빈이 있을 경우 기본적으로 선택할 빈을 지정하는 어노테이션입니다.@Bean
: 개발자가 직접 빈을 정의할 때 사용하는 어노테이션입니다.@Configuration
: 하나 이상의@Bean
메서드를 포함하는 설정 클래스로, Spring 컨테이너에 의해 관리되는 Bean 정의를 포함합니다.@Component
: Spring이 자동으로 빈으로 등록할 수 있도록 지정하는 기본적인 어노테이션@Service
: 서비스 레이어에서 사용되는 클래스를 명확히 구분하기 위해 제공되는 스테레오타입 어노테이션입니다.@Repository
: 데이터 액세스 레이어에서 사용되는 클래스를 명확히 구분하기 위해 제공되는 스테레오타입 어노테이션입니다.@Controller
: Spring MVC 컨트롤러 역할을 수행하는 클래스를 명확히 구분하기 위해 제공되는 스테레오타입 어노테이션입니다.
위의 어노테이션을 적절히 조합하여 Spring의 DI 기능을 활용하면, 유지보수성과 확장성이 뛰어난 애플리케이션을 개발할 수 있습니다.
1. 세터 주입 (Setter Injection)
세터 주입 방식은 클래스의 Setter 메서드를 통해 의존성을 주입하는 방식입니다. 이 방식은 객체의 상태를 동적으로 변경할 필요가 있을 때 유용하며, 필드 주입과 달리 의존성을 명시적으로 설정할 수 있어 코드의 가독성을 높이는 장점이 있습니다. 또한, DI 컨테이너 없이도 객체를 생성한 후 의존성을 변경할 수 있어 유연성이 높습니다. 그러나, 세터 메서드를 이용한 주입 방식은 객체의 불변성을 유지하기 어렵고, 주입되지 않은 상태로 객체가 생성될 가능성이 있어 안전성이 다소 낮을 수 있습니다.
@Slf4j
@RestController
public class SetterInjectionController {
private SetterInjectionService service;
@Autowired
public void setSetterInjectionService(SetterInjectionService setterInjectionService) {
this.service = setterInjectionService;
}
@RequestMapping("/api/setter-injection")
public String api() {
return service.process();
}
}
2. 필드 주입 (Field Injection)
필드 주입 방식은 클래스의 필드에 직접 @Autowired
를 적용하여 의존성을 주입하는 방식입니다. 이 방식은 코드가 간결하고 직관적이며, 추가적인 세터(setter) 메서드가 필요하지 않아 구현이 용이합니다. 또한, 필드 주입을 사용하면 클래스 내에서 의존성을 명확하게 표현할 수 있습니다. 그러나, 리플렉션을 활용하여 주입이 이루어지므로 테스트가 어려울 수 있으며, 의존성이 강제되지 않는 특성이 있습니다.
@Slf4j
@RestController
public class FieldInjectionController {
@Autowired
private FieldInjectionService service;
@RequestMapping("/api/field-injection")
public String api() {
return service.process();
}
}
3. 생성자 주입 (Constructor Injection)
생성자 주입 방식은 클래스의 생성자를 통해 의존성을 전달받는 방식입니다. 이 방식은 객체가 생성될 때 필요한 의존성을 명확하게 정의할 수 있으며, 생성된 객체가 항상 유효한 상태를 유지하도록 보장합니다. 또한, final
키워드를 사용하여 불변성을 유지할 수 있으며, 순환 의존성을 방지하는 효과도 있습니다. 생성자가 하나만 존재하는 경우, Spring 4.3 이상에서는 @Autowired
어노테이션을 생략해도 자동으로 주입됩니다.
@Slf4j
@RestController
public class ConstructorInjectionController {
private final ConstructorInjectionService service;
public ConstructorInjectionController(ConstructorInjectionService service) {
this.service = service;
}
@RequestMapping("/api/constructor-injection")
public String api() {
return service.process();
}
}
Dependency Injection in Action
Spring에서 의존성 주입 방식을 선택할 때는 단순한 편의성뿐만 아니라 코드의 유지보수성, 확장성, 안정성 등을 고려해야 합니다. 올바른 주입 방식을 선택하면 애플리케이션의 구조를 개선하고 유지보수를 용이하게 만들 수 있습니다. 또한, 테스트 코드의 작성이 쉬워지고, 객체 간의 결합도를 낮출 수 있어 확장성과 유연성이 향상됩니다. 따라서 프로젝트의 요구사항에 맞는 적절한 주입 방식을 이해하고 활용하는 것이 중요합니다.
Best Practices for Dependency Injection
실무에서는 코드의 유지보수성과 안정성을 고려하여 생성자 주입(Constructor Injection) 방식을 주로 권장합니다. 생성자 주입을 사용하면 객체의 불변성을 유지할 수 있으며, 순환 참조 문제를 방지할 수 있습니다. 또한, 명확한 의존성 선언을 통해 코드의 가독성을 향상시키고, 테스트 코드 작성이 더 용이해집니다.
@Slf4j
@RestController
public class ConstructorInjectionController {
private final ConstructorInjectionService service;
public ConstructorInjectionController(ConstructorInjectionService service) {
this.service = service;
}
}
- 불변성 유지: 생성자를 통해 의존성이 주입되면, 이후 변경이 불가능하여 객체의 상태를 안정적으로 유지할 수 있습니다.
final
키워드를 활용하면 의존성을 명확하게 고정할 수 있습니다. - 명확한 의존성 선언: 생성자 주입 방식은 필수 의존성을 명확히 선언해야 하므로, 불완전한 상태의 객체가 생성되는 것을 방지할 수 있습니다.
- 순환 참조 방지: 스프링 컨테이너는 순환 참조 문제를 감지하고 오류를 발생시킬 수 있어, 의존성 관계를 보다 쉽게 파악하고 유지할 수 있습니다.
- 객체의 일관성 보장: 생성자를 통해 필요한 모든 의존성이 초기화되므로, 미완성 상태의 객체가 생성되는 상황을 방지할 수 있습니다.
- 코드 가독성 및 유지보수성 향상: 의존성을 생성자를 통해 명확히 선언하면, 코드가 더 이해하기 쉬워지고 유지보수성이 향상됩니다.
- DI 컨테이너에 대한 의존성 감소:
@Autowired
를 사용하지 않고도 객체를 직접 생성할 수 있어, 스프링 컨테이너에 대한 의존성이 줄어들고 보다 유연한 코드 구조를 만들 수 있습니다.
참고로, Lombok의 @RequiredArgsConstructor
어노테이션을 사용하면, final
필드에 대한 생성자가 자동으로 생성되므로 보일러플레이트 코드가 줄어들고 가독성이 향상됩니다.
@Slf4j
@RestController
+ @RequiredArgsConstructor
public class ConstructorInjectionController {
private final ConstructorInjectionService service;
- public ConstructorInjectionController(ConstructorInjectionService service) {
- this.service = service;
- }
}
간단한 경우에는 이렇게 Lombok을 활용하면 코드가 더욱 간결해지지만, 다양한 의존성을 주입하고 조작해야 하는 경우에는 직접 생성자를 명시하는 것이 더 적절할 수 있습니다. 예를 들어, @Qualifier
를 활용하여 특정한 빈을 선택적으로 주입해야 할 경우 Lombok의 @RequiredArgsConstructor
를 사용할 수 없으며, 생성자를 직접 명시해야 합니다:
@Slf4j
@RestController
public class QualifierInjectionController {
private final ApiService apiService;
public QualifierInjectionController(
@Qualifier("adminApiService") ApiService apiService
) {
this.apiService = apiService;
}
}
이처럼 특정한 빈을 선택적으로 주입해야 하는 경우에는 생성자를 직접 정의하는 것이 필수적이며, 이를 통해 코드의 유연성을 확보할 수 있습니다.
Dynamic Dependency Injection
실행 시점에 따라 적절한 의존성을 선택해야 하는 경우, 동적 의존성 주입이 필요합니다. 예를 들어, 사용자 권한에 따라 서로 다른 서비스가 필요하거나, 특정 설정값에 따라 다른 컴포넌트를 사용해야 하는 경우가 있습니다. 이러한 상황에서는 실행 환경이나 비즈니스 로직에 맞게 적절한 빈을 동적으로 선택하는 것이 중요합니다. Spring에서는 이를 해결하기 위해 List<T>
, Map<K, V>
, 그리고 팩토리 패턴을 활용할 수 있습니다.
동적으로 의존성을 주입해야 하는 경우, 먼저 공통된 기능을 정의하는 인터페이스를 선언하고, 이를 구현하는 여러 개의 클래스를 정의해야 합니다. 예를 들어, 사용자 권한에 따라 다른 서비스가 주입되어야 한다면, 아래와 같이 인터페이스를 선언하고 역할별 구현체를 만들 수 있습니다:
public interface ApiService {...}
관리자 전용 서비스 구현체:
@Slf4j
@Service
public class AdminApiService implements ApiService {...}
일반 사용자 전용 서비스 구현체:
@Slf4j
@Service
public class UserApiService implements ApiService {...}
이제 이 구조를 활용하여 실제로 의존성을 주입하고 호출하는 방법을 살펴보겠습니다.
List-Based Dependency Injection
Spring에서는 동일한 타입의 여러 빈을 List<T>
형태로 주입받을 수 있습니다. 이를 활용하면 특정 조건에 맞는 빈을 동적으로 선택할 수 있습니다.
@Slf4j
@RestController
public class DynamicListBasedInjectionController {
private final List<ApiService> services;
public DynamicListBasedInjectionController(List<ApiService> services) {
this.services = services;
}
@GetMapping("/api/dynamic-list-based-injection")
public String getApiResponse(@RequestParam String role) {
ApiService service = getServiceByUserRole(role);
log.info("api: role={}, service={}", role, service.getClass().getSimpleName());
return service.process();
}
private ApiService getServiceByUserRole(String role) {
return switch (role.toLowerCase()) {
case "admin" -> services.stream()
.filter(service -> service instanceof AdminApiService)
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Admin service not found"));
case "user" -> services.stream()
.filter(service -> service instanceof UserApiService)
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("User service not found"));
default -> throw new IllegalArgumentException("Invalid role: " + role);
};
}
}
Map-Based Dependency Injection
보다 깔끔한 방식으로는 해시테이블 Map<K, V>
형태로 빈을 주입받는 것입니다. 여기서 키는 빈의 이름이며, 별도로 이름을 설정하지 않았다면 기본적으로 소문자로 시작하는 카멜케이스 형태로 지정됩니다. 이를 활용하면 적절한 서비스를 동적으로 선택할 수 있습니다.
@Slf4j
@RestController
public class DynamicMapBasedInjectionController {
private final Map<String, ApiService> services;
public DynamicMapBasedInjectionController(Map<String, ApiService> services) {
this.services = services;
}
@GetMapping("/api/dynamic-map-based-injection")
public String api(@RequestParam String role) {
ApiService service = getServiceByUserRole(role);
log.info("api: role={}, service={}", role, service.getClass().getSimpleName());
return service.process();
}
private ApiService getServiceByUserRole(String role) {
return switch (role.toLowerCase()) {
case "admin" -> services.get("adminApiService");
case "user" -> services.get("userApiService");
default -> throw new IllegalArgumentException("Invalid role: " + role);
};
}
}
Factory-Based Dependency Injection
팩토리 패턴을 사용하면 역할을 분리하고 코드의 응집도를 높일 수 있습니다. 특정 조건에 따라 적절한 구현체를 반환하는 팩토리를 활용하면, 클라이언트 코드에서 직접 빈을 선택하는 로직을 제거할 수 있어 유지보수성이 향상됩니다. 여기에서는 각각의 구현체를 직접 생성자에서 주입받아서 분배하는 것도 가능하다는 것을 확인하기 위해 아래와 같이 컬렉션이 아닌 개별적으로 주입하는 방식을 사용하였습니다.
@Slf4j
@Component
public class ApiServiceFactory {
private final AdminApiService adminApiService;
private final UserApiService userApiService;
public ApiServiceFactory(AdminApiService adminApiService, UserApiService userApiService) {
this.adminApiService = adminApiService;
this.userApiService = userApiService;
}
public ApiService getServiceByRole(String role) {
return switch (role.toLowerCase()) {
case "admin" -> adminApiService;
case "user" -> userApiService;
default -> throw new IllegalArgumentException("Invalid role: " + role);
};
}
}
이제 클라이언트 코드에서는 팩토리를 통해 적절한 서비스를 주입받아 사용할 수 있습니다. 이를 통해 직접 빈을 선택하는 로직을 제거하고, 보다 깔끔하고 유지보수하기 쉬운 구조를 만들 수 있습니다.
@Slf4j
@RestController
public class ApiServiceFactoryController {
private final ApiServiceFactory apiServiceFactory;
public ApiServiceFactoryController(ApiServiceFactory apiServiceFactory) {
this.apiServiceFactory = apiServiceFactory;
}
@GetMapping("/api/dynamic-service-factory-injection")
public String getApiResponse(@RequestParam String role) {
ApiService service = apiServiceFactory.getServiceByRole(role);
log.info("api: roles={}, service={}", role, service.getClass().getSimpleName());
return service.process();
}
}
Wrapping it Up
Spring의 다양한 의존성 주입 방식을 살펴보았습니다. 각 방식에는 고유한 장점과 단점이 있으며, 상황에 맞게 적절한 방법을 선택하는 것이 중요합니다. 실무에서는 유지보수성과 확장성을 고려하여 생성자 주입을 기본으로 사용하지만, 선택적 의존성이 필요한 경우 세터 주입을, 테스트 환경에서는 목(Mock) 객체를 주입하기 위해 필드 주입을 활용하는 경우도 있습니다. 결국, 프로젝트의 요구사항에 맞춰 유연하게 적용하는 것이 가장 중요한 원칙입니다.
언제나 Case by Case! ✅
- Spring