안녕하세요. 개발자 Jindory입니다.
오늘은 의존성 주입에 대해서 정리하고 의존성 주입을하는 3가지 방법에 대해서 알아보는 글을 작성해보고자 합니다.
# 글 작성 목적
Spring Framework의 특징인 의존성 주입의 정의를 알고, 의존성 주입하는 다양한 방법 및 어떤 상황에서 어떤 의존성 주입을 하는것이 좋은가에 대해서 알아보고자 글을 작성합니다.
의존성 주입(Dipendency Injection)이란?
의존성 주입이란 외부에서 두 객체간의 관계를 결정해주는 디자인 패턴으로 인터페이스를 사이에 두고 클래스 레벨에서는 의존 관계가 고정되지 않도록 하고 런타임시에 관계를 동적으로 주입하여 결합도를 낮출 수 있게 하는 기법입니다.
의존성 주입을 배터리(의존객체)를 사용하는 장난감(객체)로 설명해보겠습니다.
배터리가 장난감과 일체형인 경우 : 생성자에서만 의존성(배터리)을 주입해주는 상황으로 배터리가 떨어지면 다른 배터리로 교체하는것이 아니라 새로운 장난감으로 바꿔줘야 한다.
배터리가 분리형인 경우 : setter, 생성자를 이용해서 외부에서 주입해주는 상황으로 외부에서 배터리를 교체해 줄 수 있기 때문에 배터리만 교체가 가능하다.
# 강한 결합
객체 내부에서 다른 객체를 생성하는것은 강한 결합도를 가지는 구조이다.
A클래스 내부에서 B라는 객체를 직접 생성하고 있다면(new 연산자를 통해) B객체를 C객체로 바꾸고 싶은 경우에 A클래스도 수정해야하는 방식이기 때문에 강한 결합입니다.
public class Pencil {
}
public class Store{
private Pencil pencil;
public store(){
this.pencil = new pencil()
}
}
예를들어, Store클래스에서 Pencil을 판매하고자 하여 new연산자를 통해 pencil 객체를 Store 클래스 내부에서 생성할 경우, Food로 바꾸고 싶을때 Store 클래스에서의 생성자 변경이 필요할 것이다.
또한, 위 코드는 객체들간의 관계가 아니라 클래스간 관계를 맺어지고 있다.
이는 근본적으로 Store에서 불필요하게 어떤 제품을 판매할지에 대한 관심이 분리되지 않았기 때문이다.
# 느슨한 결합
객체를 주입받는다는것은 외부에서 생성된 객체를 인터페이스를 통해 넘겨받는 것이다.
이렇게하면 런타임시에 의존관계가 결정되기 때문에 결합도를 낮출 수 있다.
[ 의존성 주입(Dependency Injection)을 통한 문제 해결 ]
이런 경우에 의존성 주입을 통해 문제를 해결할 수 있다.
먼저 다형성이 필요하다. Pencil, Food 등 여러가지 제품을 하나로 표현하기 위해 Product라는 Interface가 필요하다. 그리고 Product 인터페이스를 구현하는 구현 클래스(Pencil,Food)를 만든다.
그 후에 Store 클래스에서는 외부에서 상품(Product)을 주입 받을 수 있도록 한다.
public interface Product{
}
public class Pencil implements Product{
}
이제 우리는 Store와 Pencil이 강하게 결합되어 있는 부분을 제거해주어야 한다. 이를 제거하기 위해서 다음과 같이 외부에서 상품을 주입(Injection)받아야 한다.
public class Store{
private Product product;
public store(Product product){
this.product = product;
}
}
여기서 Store에서 Product 객체를 주입하기 위해서는 애플리케이션 실행시점에 필요한 빈을 생성하여, 의존성이 있는 두 객체를 연결하기 위해 한 객체를 다른 객체로 주입시켜줘야 한다.
예를들어 Product를 구현한 Pencil 클래스 객체를 생성하고, 그 객체를 Store로 주입시켜주는 역할을 위해 DI컨테이너가 필요한것이다.
public class BeanFactory{
public void store(){
// Bean의 생성
Product pencil = new Pencil();
// 의존성 주입
Store store = new Store(pencil);
}
}
그리고 이러한 개념은 제어의 역전(Inversion of Control, IoC)라고 불리기도 한다. 어떠한 객체를 사용할지에 대한 책임이 BeanFactory와 같은 클래스에게 넘어갔고, 자신은 수동적으로 주입받는 객체를 사용하기 때문이다. (실제 Spring에서는 BeanFactory를 확장한 Application Context를 사용한다.)
스프링의 의존성 주입
한 객체가 어떤 객체(구체 클래스)에 의존할 것인지는 별도의 관심사이다. Spring에서는 DI 컨테이너를 통해 서로 강하게 결합되어 있는 두 클래스를 분리하고, 두 객체 간의 관계를 결정해 줌으로써 결합도를 낮추고 유연성을 확보하고자 하였다. 의존성 주입으로 애플리케이션 실행 시점에 객체를 생성하고 관계를 결정해 줌으로써 다른 구체 클래스에 의존하는 코드를 제거하며 서로 다른 두 객체의 결합을 약하게 만들어 주었다. 또한 이러한 방법은 상속보다 휠씬 유연하다. 단 여기서 주의해야 하는 것은 다른 빈을 주입받으려면 자기 자신이 반드시 컨테이너의 빈이어야 한다는 것이다.
스프링은 다양한 의존성 주입방법을 제공하는데, 생성자 주입, 필드 주입, Setter 주입이 있다.
# 생성자 주입(Constructor Injection)
@Service
public class SportsClubServiceImpl implements SportsClubService{
private MemberRepository memberRepository;
private TeamRepository teamRepository;
public SportsClubServiceImpl(MemberRepository memberRepository, TeamRepository teamRepository){
this.memberRepository = memberRepository;
this.teamRepository = teamRepository;
}
}
생성자 주입은 생성자를 통해 의존 관계를 주입하는 방법이다.
생성자 주입은 생성자의 호출 시점에 1회 호출되는것을 보장된다. 그렇기 때문에 주입받은 객체가 변하지 않거나, 반드시 객체의 주입이 필요한 경우에 강제하기 위해 사용할 수 있다. 또한 Spring Framework에서는 생성자 주입을 적극 지원하고 있기 때문에, 생성자가 1개만 있을 경우에 @Autowired를 생략해도 주입이 가능하도록 편의성을 제공하고 있다.
# 수정자 주입(Setter Injection)
@Service
public class SportsClubImpl implements SportsClubService{
private MemberRepository memberRepository;
private TeamRepository teamRepository;
@Autowired
public void setMemberRepository(MemberRepository memberRepository){
this.memberRepository = memberRepository;
}
@Autowired
public void setTeamRepository(TeamRepository teamRepository){
this.teamRepository = teamRepository;
}
}
수정자 주입은 필드 값을 변경하는 Setter를 통해서 의존 관계를 주입하는 방법이다. Setter 주입은 생성자 주입과 다르게 주입받는 객체가 변경될 가능성이 있는 경우에 사용한다.
@Autowired로 주입할 대상이 없는 경우에는 오류가 발생한다. 위의 예제에서는 XXX 빈이 존재하지 않을경우 오류가 발생하는 것이다. 주입할 대상이 없어도 동작하도록 하려면 @Autowired(required = false)를 통해 설정할 수 있다.
# 필드 주입(Field Injection)
@Service
public class SportsClubImpl implements SportsClubService{
@Autowired
private MemberRepository memberRepository;
@Autowired
private TeamRepository teamRepository;
}
필드 주입(Filed Injection)은 필드에 바로 의존 관계를 주입하는 방법이다. 필드 주입을 이용하려면 코드가 간결해져서 과거에 상당히 많이 이용되었던 주입 방법이다. 하지만 필드 주입은 외부에서 접근이 불가능하다는 단점이 존재하는데, 테스트 코드의 중요성이 부각됨에 따라 필드의 객체를 수정할 수 없는 필드 주입은 거의 사용되지 않게 되었다. 또한 필드 주입은 반드시 DI 프레임워크가 존재해야 하므로 반드시 사용을 지양해야 한다. 그렇기에 애플리케이션의 실제 코드와 무관한 테스트 코드나 설정을 위해 불가피한 경우에만 이용하도록 하자.
생성자 주입을 사용해야하는 이유
최근에 Spring을 포함한 DI 프레임워크의 대부분이 생성자 주입을 권장하고 있는데, 자세한 이유는 아래와 같다.
1. 객체의 불변성 확보
2. 테스트 코드의 작성
3. final 키워드 작성 및 Lombok과의 결합
4. 순환 참조 에러 방지
1. 객체의 불변성 확보
실제로 개발을 하다 보면 의존 관계의 변경이 필요한 상황은 거의 없다. 하지만 수정자 주입이나 일반 메서드 주입을 이용하면 불필요하게 수정의 가능성을 열어두어 유지보수성을 떨어뜨린다. 그러므로 생성자 주입을 통해 변경의 가능성을 배제하고 불변성을 보장하는것이 좋다.
2. 테스트 코드의 작성
테스트가 특정 프레임워크에 의존하는 것은 침투적이므로 좋지 못하다. 가능하다면 순수 자바로 테스트를 작성하는 것이 가장 좋다. 그러나 생성자 주입이 아닌 다른 주입으로 작성된 코드는 순수한 자바 코드로 단위 테스트를 작성하는 것이 어렵다.
@Service
public class SportsClubImpl implements SportsClubService{
@Autowired
private MemberRepository memberRepository;
@Autowired
private TeamRepository teamRepository;
@Override
public void register(String name){
memberRepository.add(name);
}
}
예를 들어 위와 같은 코드에 대해 순수 자바 테스트 코드를 작성한다면 다음과 같이 작성 할 수 있다.
public class SportsClubServiceTest{
@Test
public void addTest(){
SportClubService sportClubService = new SportClubServiceImpl();
sportClubService.register("HongGilDong");
}
}
위와 같이 작성한 테스트 코드는 Spring과 같은 DI 프레임워크 위에서 동작하지 않으므로 의존 관계 주입이 되지 않을 것이고, memberRepository가 null이 되어 memberRepository의 add 호출 시 NullPointException가 발생할 것이다. 이를 해결하기 위해 Setter를 사용하면 변경 가능성을 열어두게 되는 단점을 갖게 된다. 반대로 테스트 코드에서도 @Autowired를 사용하기 위해 스프링 빈을 올리면 단위 테스트가 아니며 컴포넌트들을 등록하고 초기화하는 시간이 커져 비용이 증가하게 된다. 그렇다고 리플렉션을 사용하면 깨지기 쉬운 테스트가 된다.
반면에 생성자 주입을 사용하면 컴파일 시점에 객체를 주입받아 테스트 코드를 작성 할 수 있으며, 주입하는 객체가 누락된 경우 컴파일 시점에 오류를 발견할 수 있다. 심지어 우리가 테스트를 위해 만든 Test객체를 생성자로 넣어 편리함을 얻을 수 도 있다.
3. final 키워드 코드의 작성 및 Lombok과의 결합
생성자 주입을 사용하면 필드 객체에 final 키워드를 사용할 수 있으며, 컴파일 시점에 누락된 의존성을 확인할 수 있다.
반면에 생성자 주입을 제외한 다른 주입 방법들은 객체의 생성(생성자 호출) 이후에 호출되므로 final 키워드를 사용할 수 없다.
또한 final 키워드를 붙임으로써 Lombok과 결합되어 코드를 간결하게 작성할 수 있다. Lombok에는 final 변수를 위한 생성자를 대신 생성해주는 @RequiredArgsConstructor를 제공하고 있다.
Spring과 같은 DI 프레임워크는 Lombok과 환상적인 궁합을 보여주는데, 위에서 작성했던 생성자 주입 코드를 Lombok과 결합시키면 다음과 같이 간편하게 작성할 수 있다.
@Service
@RequiredArgsConstructor
public class SportsClubImpl implements SportsClubService{
private final MemberRepository memberRepository;
private final TeamRepository teamRepository;
@Override
public void register(String name){
memberRepository.add(name);
}
}
이러한 코드가 가능한 이유는 앞서 설명하였듯 Spring에서는 생성자가 1개인 경우 @Autowired를 생략할 수 있도록 도와주고 있으며, 해당 생성자를 Lombok으로 구현하였기 때문이다.
4. 순환 참조 에러 방지
생성자 주입을 사용하면 어플리케이션 구동시점(객체의 생성 시점)에 순환 참조 에러를 예방할 수 있다. 예를 들어 다음과 같은 필드를 사용해 서로 호출하는 코드가 있다고 하자.
@Service
public class TeamServiceImpl implements TeamService{
@Autowired
private MemberServiceImpl memberService;
@Autowired
private void register(String name){
memberService.add(name);
}
}
@Service
public class MemberServiceImpl extends MemberService{
@Autowired
private TeamServiceImpl teamService;
public void add(String name){
teamService.register(name);
}
}
위 두 메소드는 서로를 계속 호출할 것이고, 메모리에 함수의 CallStack이 계속 쌓여 StackOverFlow 에러가 발생하게 된다.
만약 이러한 문제를 발견하지 못하고 서버가 운영된다면 해당 메소드의 호출 시에 StackOverFlow 에러에 의해 서버가 죽게 될 것이다. 하지만 생성자 주입을 이용하면 이러한 순환 참조 문제를 방지할 수 있다.
어플리케이션 구동 시점(객체의 생성시점)에 에러가 발생하기 때문이다. 그러한 이유는 Bean에 등록하기 위해 객체를 생성하는 과정에서 다음과 같인 순환 참조가 발생하기 때문이다.
new MemberServiceImpl(new TeaServiceImpl(new MemberServiceImpl(new TeamServiceImpl()...)))
참고로 스프링부트 2.6부터는 순환 참조가 기본적으로 허용되지 않도록 변경되었다. 필드 주입을 받아도 순환 참조가 발생한다면 컴파일 시점에 에러가 발생하므로, 이 내용은 스프링 부트 2.6 이하의 버전을 사용하는 경우에 발생한다.
이렇게 의존성 주입의 정의 및 의존성 주입의 3가지 방법에 대해서 알아봤습니다.
혹시라도 정정할 내용이나 추가적으로 필요하신 정보가 있다면 댓글 남겨주시면 감사하겠습니다.
오늘도 Jindory 블로그에 방문해주셔서 감사합니다.
[참고]
https://mangkyu.tistory.com/150
https://esoongan.tistory.com/90?category=915638
'개발 > Spring' 카테고리의 다른 글
[Spring] Transaction Propagation Model과 Isolation Level (0) | 2024.02.11 |
---|---|
[Spring] Spring 초기 데이터베이스 데이터 설정 방법 (0) | 2022.07.03 |
[Spring Boot] Thymeleaf 사용하기 (0) | 2022.03.06 |
[Spring] @RequestParam과 @PathVariable의 차이는? (0) | 2022.02.20 |
[Spring] SSE(Server-Sent Events) 이해하기[실시간 서버 데이터 구독] (0) | 2022.02.10 |