객체지향 프레임워크로 개발을 하다보면 만나는 키워드가 바로 "의존성 주입" 혹은 "의존관계 주입"
이라는 키워드 이다.
의존성 주입의 정의를 한번 위키백과에서 찾아보자
의존성 주입
위키백과, 우리 모두의 백과사전.
소프트웨어 엔지니어링에서 의존성 주입(dependency injection)은 하나의 객체가 다른 객체의 의존성을 제공하는 테크닉이다. "의존성"은 예를 들어 서비스로 사용할 수 있는 객체이다. 클라이언트가 어떤 서비스를 사용할 것인지 지정하는 대신, 클라이언트에게 무슨 서비스를 사용할 것인지를 말해주는 것이다. "주입"은 의존성(서비스)을 사용하려는 객체(클라이언트)로 전달하는 것을 의미한다. 서비스는 클라이언트 상태의 일부이다. 클라이언트가 서비스를 구축하거나 찾는 것을 허용하는 대신 클라이언트에게 서비스를 전달하는 것이 패턴의 기본 요건이다.
위키백과 의존성 주입
음.. 무슨말인지 잘 와닿지가 않는다.
가령 스프링 개발을 하다 보면 서비스에서 특정 레포지토리를 가져와서 로직을 처리한다고 할 때,
//GoodsService.java
@Component
public class GoodsServiceImpl implements GoodsService {
private final GoodsRepository goodsRepository;
@Autowired
public GoodsServiceImpl(GoodsRepository goodsRepository) {
this.goodsRepository = goodsRepository;
}
@Override
public Member findGoods(Long GoodsId) {
return goodsRepository.findById(goodsId);
}
...
}
//AppConfig.java
@Configuration
public class AppConfig {
@Bean
public GoodsService goodsService(){
return new GoodsServiceImpl(goodsRepository());
}
@Bean
public GoodsRepository goodsRepository() {
return new GoodsRepository();
}
}
이런식으로 코드를 작성했다고 하자. 여기서
//스프링 의존성 주입 @Autowired public GoodsServiceImpl(GoodsRepository goodsRepository) { this.goodsRepository = goodsRepository; }
이 부분이 스프링이 해주는 의존성 주입이라고 하는데 이게 왜 의존성 주입이지?
@Autowired 이 에너테이션이 붙으면 스프링이 알아서 빈 컨텍스트로 인식해서 뭐 해주는 건 알겠는데
왜 직접 GoodsRepository 객체를 new 연산자로 생성하지 않고 번거롭게 생성자로 만드는 걸까?
저렇게 하면 다른 자바 클래스에서 생성자를 사용하는 부분을 추가해줘야 할 텐데 말이다.
위 질문에 대한 답을 하기 전에 먼저 객체지향의 5가지 원칙 SOLID 라는 것에 대해 잠깐 짚고 넘어가야 한다.
왜 생성자를 사용하는지 궁금했는데 갑자기 왠 SOLID? 아 모르겠고 빨리 답이나 내노라고~
SOLID 원칙은
SRP(Single responsibility principle) 단일 책임 원칙
"한 클래스는 하나의 책임만 가져아한다."
OCP(Open/closed principle) 개방-폐쇄 원칙
"소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다."
LSP(Liskov substitution principle) 리스코프 치환 원칙
"프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다."
ISP(Interface segregation principle) 인터페이스 분리 원칙
"특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다."
DIP(Dependency inversion principle) 의존관계 역전 원칙 Dependency inversion principle
프로그래머는 "추상화에 의존해야지, 구체화에 의존하면 안된다."
라는 내용이다. 근데 이게 뭐?
자 그러면 다시 위의 예제를 가져와서 이번에는 생성자 없이 그냥 new 연산자로 코드를 작성했다고 해보자.
//GoodsServiceImpl.java
@Component
public class GoodsServiceImpl implements GoodsService {
@Autowired
private GoodsRepository goodsRepository = new GoodsRepository();
@Override
public Member findGoods(Long GoodsId) {
return goodsRepository.findById(goodsId);
}
...
}
만약, 스프링이 위와 같이 사용할 수 있게 코드를 변경하면 AppConfig.java 파일도 작성안해도 되고 생성자도 없어져서
우리가 작성해야 하는 코드의 양도 줄고 좋을 것 같지 않은가?
그런데 위와 같이 작성하면 위 클래스가 여러 책임을 맡게 된다.
이게 무슨말이냐면 GoodsServiceImpl 라는 클래스가 상품에 관한 여러 로직들을 담고 있는데
"이 로직들을 처리하는 것" 이외에도 "직접 다른 클래스를 가져다가 사용하는 일" 까지 도맡아 하고 있다.
아니 뭔... 그냥 클래스 생성자 하나 사용했다고 오바는;;
당장은 아무런 문제가 없어 보이지만 위처럼 설계하게 되면 나중에 코드를 변경하게 될때 문제가 생기게 된다.
만약, 나중에 코드를 변경할 일이 생기면 큰 화를 입게 될지도 모른다.
가령 레포지토리를 GoodsRepository 가 아니고 NewGoodsRepository 라는 레포지토리로 변경한다고 해보자.
그럼 위와 같은 방식으로 작성한 코드는
//GoodsServiceImpl.java
@Component
public class GoodsServiceImpl implements GoodsService {
//여기만 변경 나머지는 동일
@Autowired
private NewGoodsRepository newGoodsRepository = new NewGoodsRepository();
//private GoodsRepository goodsRepository = new GoodsRepository();
@Override
public Member findGoods(Long GoodsId) {
return goodsRepository.findById(goodsId);
}
...
}
이런 식으로 작성해야 변경이 가능하다.
SOLID 중 OCP(Open/closed principle) 개방-폐쇄 원칙 을 다시 한번 생각해보면
"소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다." 라는 말을 위반하고 있다.
즉, GoodsServiceImpl 의 코드를 변경하는 부분이 생긴다.
아니, 확장을 할려면 당연히 변경을 해야하는 거 아닌가?
자, 그럼 맨 처음 작성한 정상적인 스프링으로 만든 코드를 통해 레포지토리 변경을 한다고 해보자.
//GoodsServiceImpl.java
@Component
public class GoodsServiceImpl implements GoodsService {
private final GoodsRepository goodsRepository;
@Autowired
public GoodsServiceImpl(GoodsRepository goodsRepository) {
this.goodsRepository = goodsRepository;
}
@Override
public Member findGoods(Long GoodsId) {
return goodsRepository.findById(goodsId);
}
...
}
//AppConfig.java
@Configuration
public class AppConfig {
//여기만 변경 나머지는 동일
@Bean
public GoodsRepository goodsRepository(){
return new GoodsServiceImpl(newGoodsRepository());
//return new GoodsServiceImpl(goodsRepository());
}
@Bean
public GoodsRepository goodsRepository() {
return new GoodsRepository();
}
@Bean
public NewGoodsRepository newGoodsRepository() {
return new NewGoodsRepository();
}
}
정상적인 스프링 코드라면 AppConfig.java 에서 변경을 해주면 될 것이다.
둘의 차이가 뭔지 알겠는가? 아니 이것도 코드 변경했으니까 OCP 위반인디? 크크
AppConfig.java 클래스 파일을 사용한 방식과 그렇지 않은 방식의 큰 차이는 "책임을 분리 했다" 라는 점이다.
AppConfig.java 클래스 파일을 사용한 방식에서는 (옳은 방식)
"이 로직들을 처리하는 것" 은 GoodsServiceImpl.java 클래스가 해주고
"직접 다른 클래스를 가져다가 사용하는 일" 은 AppConfig.java 가 해주고 있는 반면
GoodsServiceImpl.java 만을 사용하는 방식에서는 (틀린 방식)
GoodsServiceImpl.java 클래스가 두가지 일을 모두 도맡아 하고 있다.
(편의상 두 방식을 옳은 방식과 틀린 방식으로 말하겠다.)
옳은 방식에서도 분명 코드의 변경이 존재하지만 이는
"직접 다른 클래스를 가져다가 사용하는 일" 을 맡아서 수행하는 AppConfig.java 의 수정이 있기 때문에 올바르게 원칙을 준수 했다.
반면 틀린 방식에서는 레포지토리의 변경을 통해 분명 수정하면 안되는 GoodsServiceImpl.java 의 수정이 있기 때문에 문제가 생긴다.
즉, OCP 에서 말하는 수정에는 닫혀있어야한다는 말은
무조건 모든 코드를 변경하면 안돼! 라는 말이 아니고
"객체 중간에서 연결해주는 녀석이 아닌 객체의 변경이 있으면 안된다." 라는 말이다.
그래서 스프링에서는 다른 객체를 사용할 때 직접 new 연산자를 통해 객체를 생성해서 사용하는 것이 아닌,
객체 사이를 연결해주는 파일을 새로 만들고 그 파일대로 스프링 컨테이너에 올려서 관리해 주는 방식으로 사용한다.
이렇게 객체 사이의 의존 관계를 다른 이가 주입해 준다고 하여 이를 "의존성 주입(DI - Dependency Injection)" 혹은 "의존 관계 주입" 이라고 한다.
객체 지향적으로 설계하기 위한 일종의 프로그래밍 방식으로 생각해주면 되겠다.
이렇게 하면 이제 객체를 사용하는 부분에서 다른 객체로 변경할 때 연결하는 부분만 건들면 되므로 유지보수가 용이해진다는 강력한 장점을 갖게 되며 이는 자바 문법의 다형성(polymolphism) 과 일맥상통하는 내용이다.