설명에 앞서 고객관련 서비스를 만든다고 가정해봅시다.
고객관련 서비스가 제공되는 CustomerService가 존재하고 데이터의 저장, 수정, 조회, 삭제를 수행하는 CustomerRepository라는 객체가 존재한다고 가정해봅시다.
CustomerRepository는 저장 방식에 따라 동작이 다를수 있어서 CustomerRepository라는 인터페이스를 만들고 이를 구현하는 MemoryCustomerRepository(인-메모리 방식), JdbcCustomerRepository(DB 저장, Jdbc 사용)로 나눴다고 가정해봅시다.
서비스의 경우 저장된 고객정보를 토대로 기능이 수행되기 때문에 CustomerRepository가 필요한 상태라고 가정해봅시다.
현재 상황을 그림으로 요약해보면 다음과 같은 의존관계를 가지는 것이 목표가 됩니다.
각 객체들의 구현이 완료되었다고 가정하고 이제 CustomerService에서 CustomerRepository를 사용하려면 어떻게 할까요?
가장 먼저 생각드는 방법으로는 다음과 같이 객체 내부에서 필요한 객체를 직접 생성하고 해당 객체를 사용할 수 있을 것입니다.
class CustomerService {
// 객체 내부에서 직접 생성하고 제어
private CustomerRepository customerRepository = new MemoryCustomerRepository()
...
...
}
하지만 위와 같이 객체 내부에서 필요한 객체를 직접 생성하게 된다면 객체지향 설계 원칙 중 OCP(개방-폐쇄 원칙), DIP(의존관계 역전 원칙)을 지킬 수 없게 됩니다.
왜 OCP와 DIP를 위반한 것일까요?
아래 코드를 먼저 살펴봅시다.
private CustomerRepository customerRepository = new MemoryCustomerRepository()
먼저 DIP를 지키지 못한 이유에 대해서 살펴봅시다.
위의 코드는 CustomerRepository를 사용하면서 구체화가 아닌 추상화에 의존해야하는 DIP를 잘 지킨 것 처럼 보이지만 사실은 DIP를 지키지 못했습니다.
왜냐하면 객체를 생성할때 구체 클래스인 MemoryCustomerRepository를 사용하고 있기때문에 실제로는 구체화에 의존한 상태가 되었기 때문입니다.
이를 그림으로 나타내보면 다음과 같습니다.
결국 CustomerService는 CustomerRepository라는 추상화된 인터페이스를 사용하고는 있지만 이를 사용하기 위해 구체클래스를 생성하므로 DIP를 지키지 못하게 된 것입니다.
그 다음으로 OCP를 지키지 못한 이유에 대해서 살펴봅시다.
만약 Repository의 종류를 Memory에서 Jdbc로 변경하고 싶다면 실제 코드에서 다음과 같이 수정을 해주어야 합니다.
// 변경 전
private CustomerRepository customerRepository = new MemoryCustomerRepository();
// 변경 후
private CustomerRepository customerRepository = new JdbcCustomerRepository();
결국 종류를 변경할때마다 실제 코드에서 계속해서 변경사항이 발생하고 OCP를 지키지 못하게 된 것입니다.
**DI(Dependency Injection, 의존성 주입)**를 하면 이런 문제를 해결할 수 있습니다.
DI(Dependency Injection, 의존성 주입)
DI는 다른 객체를 사용해야 할때, 사용하는 객체에서 직접 생성하는 것이 아닌 외부에서 생성한 후 외부에서 해당 객체를 주입시켜주는 방식입니다.
의존성 주입을 별도의 설정 클래스를 통해서 직접 자바코드로 구현할 수 있지만 스프링 프레임워크를 사용하면 스프링에서 이를 관리해주기 때문에 간단하게 객체에 의존성 주입을 받을 수 있습니다.
IoC(Inversion of Control, 제어의 역전)
앞서 DI에 대해 이야기 할때 스프링 프레임워크를 사용할 경우 스프링에서 의존성 주입을 위한 클래스를 관리해준다고 했습니다.
이를 IoC라고 부르는데 IoC란 객체의 생명주기(생성 → 설정 → 초기화 → 소멸)를 개발자가 아닌 외부에서 담당하는 것을 의미합니다. (스프링을 사용하니 여기서 외부는 스프링 프레임워크가 됩니다.)
IoC를 통해 객체들이 관리되므로 어떤 객체에서 다른 객체를 사용하려고 할때 IoC를 통해 주입받을 수 있게 됩니다.
이를 통해 개발자는 객체간의 결합도를 줄이고 변경에 유연한 코드를 작성할 수 있게 됩니다.
의존성 주입 방법
- 생성자를 이용한 전달
@Service
public class CustomerService {
private CustomerRepository customerRepository;
public CustomerRepository(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
}
생성자를 이용한 전달방법은 순환 참조를 하고 있는지 알 수 있으며, 의존성 전달이 필요한 필드를 final로 선언해 불변성을 가지게 할 수 있다는 장점이 있습니다.
- 멤버 필드로 전달
@Service
public class CustomerService {
@Autowired
private CustomerRepository customerRepository;
}
멤버 필드에 @Autowired 애노테이션을 사용하여 전달 받는 방식입니다.
- Setter 메서드를 이용한 전달
@Service
public class CustomerService {
private CustomerRepository customerRepository;
public void setCustomerRepository(CustomerRepository customerRepository) {
this.customerRepository = repository;
}
}
Setter 메서드를 사용하는 방식은 순환참조를 방지하지 못합니다.
위의 경우에서 순환참조가 발생할 경우 CustomerService와 CustomerRepository가 서로를 계속 호출하다가 StackOverflowError가 발생하며 종료됩니다.
생성자를 이용한 의존성 주입을 추천하는 이유
의존성 주입이 필요한 필드를 final로 선언하여 불변성을 가지게 할 수 있습니다.
@Service
public class CustomerService {
private final CustomerRepository customerRepository;
public CustomerRepository(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
}
객체가 final을 통해 불변성을 가지게 된다면 개발자의 실수등으로 구현체가 변경되는 상황을 막을 수 있어 더 안전합니다.
스프링의 경우 순환참조를 감지하여 실행시에 이를 감지하고 실행을 막아 순환참조가 발생하는 상황을 방지할 수 있습니다.
앞서 setter를 통한 의존성 주입의 문제로 언급되었던 것 처럼 순환참조를 하게 되면 서로 계속 호출하다가 메모리 부족으로 애플리케이션에 장애가 발생할 수 있으므로 순환참조는 최대한 피해야 합니다.
스프링에서는 생성자를 통해 주입하면 이를 감지해주기 때문에 생성자로 주입하는 것이 좋습니다.
테스트 코드를 작성하기 좋습니다.
만약, CustomerServiceImpl에 대해서 단위테스트를 작성하고 싶다고 가정해봅시다.
public class CustomerServiceImpl {
@Autowired
private CustomerRepository customerRepository;
// impl...
}
위와 같은 구현체를 테스트할때, 멤버 필드로 주입하는 방식은 스프링 IoC 컨테이너가 Bean들을 생성하고 이들을 필요한 객체에 자동 주입해주는 방식이기 때문에 외부에 노출되어 있지 않습니다.
그래서 넣을 방법이 없어 null이 들어가게 되고 이때문에 NullPointerException이 발생할 수 밖에 없습니다. (스프링 설정을 불러오지 않는 이상)
@Service
public class CustomerServiceImpl {
private CustomerRepository customerRepository;
public CustomerRepository(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
}
하지만 생성자를 통한 주입방식의 경우 생성자를 통해 필요한 구현체를 주입해주면 되기때문에 스프링 설정을 불러오지 않고 직접 생성자를 호출해서 넣어주면 됩니다.
의존관계 설정이 되지 않으면 객체 생성이 불가능합니다. (컴파일 에러 발생)
멤버 필드로 전달하는 방식의 경우 Bean이 생성되지 않으면 null값을 가지게 되지만 생성자 주입 방식의 경우 컴파일 에러가 발생해 실행자체가 되지 않습니다.
Spring에서 권장하는 의존성 주입 방법
- 불변성을 목표로 하는 경우 생성자 주입 사용
- 변경 가능한 종속성인 경우 setter 주입 사용
- 필드 주입은 피할것
필드 주입 방법을 피해야 하는 이유
- 생성자 주입으로 할 수 있는 것처럼 변경할 수 없는 객체를 만들 수 없습니다.
- 자바 클래스가 IoC 컨테이너에 의해 주입되며 외부로 부터 주입을 받을 수 없습니다.(생성자, setter와 같이)
- 리플렉션이 없이는 클래스를 인스턴스화 할 수 없습니다. (단위 테스트 작성시 문제), 이를 위해 스프링 설정을 불러와야 하므로 통합테스트와 같이 만들어지게 됩니다.
참고자료
https://velog.io/@gillog/Spring-DIDependency-Injection
https://www.inflearn.com/course/스프링-핵심-원리-기본편/dashboard
https://velog.io/@ggomjae/Spring-Framework-DI-Dependency-Injection
https://velog.io/@mincodin/DI-Dependency-Injection-이란
https://yaboong.github.io/spring/2019/08/29/why-field-injection-is-bad/
https://stackoverflow.com/questions/39890849/what-exactly-is-field-injection-and-how-to-avoid-it
'Java > Spring' 카테고리의 다른 글
스프링에서 어떻게 캐싱이 이루어질까? - 1편 스프링에서 캐싱 사용하기 (1) | 2024.08.04 |
---|---|
스프링 부트 3.0 이상에서 QueryDSL 설정 (2) | 2022.12.03 |
@RequestBody로 지정한 DTO에 기본생성자가 필요한 이유 (2) | 2022.10.11 |
스프링 빈과 스프링 컨테이너 (0) | 2022.09.04 |
OpenApi Spec을 이용한 RestDocs to Swagger 변환 자동화 (1) | 2022.07.28 |