의존성 주입(Dependency Injection, DI)을 왜 사용하는가?
DI의 필요성
코드를 짜다보면 다른 클래스를 의존해야 하는 상황이 생기기 마련이다.
클래스를 의존한다는 것은 그 클래스를 생성해서 사용한다는 의미이다.
예시코드를 한 번 살펴보자
public interface MemberRepository {
String save();
}
public class MyBatisMemberRepository implements MemberRepository {
@Override
public String save() {
return "MyBatis를 사용해서 저장됨";
}
}
public class JpaMemberRepository implements MemberRepository {
@Override
public String save() {
return "JPA를 사용해서 저장됨";
}
}
먼저 1개의 인터페이스와 2개의 클래스를 만든 후 진행해보도록 하겠다.
인터페이스는 값을 저장하는 기능을 가진 인터페이스이고 2개의 클래스는 인터페이스에 대한 구현체이다.
class MemberService {
private final MemberRepository memberrepository = new MyBatisMemberRepository();
public String call() {
return memberRepository.save();
}
}
이와 같은 상황을 MeberService가 MemberRepository를 의존한다고 할 수 있다.
그런데 이렇게 코드를 짜게되면 객체의 결합도가 높아지게 된다. 객체지향 설계를 할 때는 결합도는 낮게 응집도는 높게 설정하는 것이 좋은 설계 방법이다.
그리고 언뜻보면 MemberRepositry라는 인터페이스에 의존하고 있는것 같지만 사실 MyBatisMemberRepository라는 구현체에도 동시에 의존하고 있다. 이것은 객체지향 설계의 5가지 원칙중 DIP를 위배하고 있는 상황이다.
만약 시간이 지나서 이제 MyBatis가 아니라 Jpa를 사용하고 싶어서 구현체를 MyBatis가 아닌 Jpa로 바꾸고 싶다면 MemberService클래스에 직접 들어와서 구현체를 변경해주어야 할 것이다. 이렇게 되면 확장을 위해서 클래스를 직접 변경해야 하는 상황이 생기므로 OCP도 위반하게 된다.
이렇게 하나의 모듈이 다른 모듈을 직접적으로 의존하는 것은 많은 문제를 가져올 수 있다. 이 문제를 해결하기 위한 방법이 DI, 의존성 주입이다.
DI형태로 만들어보기
DI라는 것은 Dependency Injection 즉, 의존성 주입이다. 말 그대로 의존성을 외부에서 주입받는 것을 이야기한다.
만약에 영화촬영을 한다고 했을 때 영화감독과 영화에서의 어떤 역할과 그 역할을 수행할 배우가 있을 것이다. 이때 배우를 섭외하는 것은 감독의 역할일 것이다. 배우가 직접 역할을 찾아가고 또 다른 배우를 직접 섭외하지는 않는다. 배우는 그저 섭외받은 영화에서 부여받은 역할을 수행할 뿐이다.
역할이라는 것은 인터페이스이다. 그리고 그걸 수행하는 배우는 구현체이다. 그리고 이 배우를 적절하게 찾아주는 감독이 필요할 것이다.
위의 예시에서 MemberService가 필요한 구현체를 직접 의존하면서 많은 문제들이 나타났다. 이 문제를 해결하기 위해서는 인터페이스를 의존하고 구현체를 외부에서 주입받는 방식으로 해결할 수 있다.
그렇다면 주입을 해주는 주체가 있어야 할 것이다. 이것을 위해서 구현객체를 생성하고 연결해주는 설정 클래스를 만들어서 연결해보도록 하겠다.
코드로 살펴보자.
public class MemberService {
private MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public String call() {
return memberRepository.save();
}
}
MeberService에서 생성자를 이용해서 MemberRepository의 구현체를 받아와서 주입받는 방식으로 변경한다.
이렇게하면 MemberService입장에서는 어떤 구현체가 들어오는지 알 수 없기 때문에 자신의 역할에만 집중할 수 있게된다. 직접 구현체를 의존하지 않고 인터페이스를 의존하고 있으며 만약 구현체가 변경된다고 하더라도 MemberService가 신경쓰고 관리해줄 필요가 없게된다. 그저 외부에서 주입해주는 구현체를 사용하기만 하면 된다.
이제 감독의 역할을 해줄 클래스를 만들어보자.
public class AppConfig {
private MemberRepository memberRepository() {
return new JpaMemberRepository();
}
public MemberService memberService() {
return new MemberService(memberRepository());
}
}
감독의 역할인 AppConfig를 만들어주었다.
MemberRepository를 내부에서 생성한 후 MemberService에 생성한 구현체를 주입하는 방식이다. 이렇게 짜여진 코드에서 수정이 일어나면 MemberRepository를 여러 클래스에서 의존하고 있어도 AppConfig하나만 수정해준다면 다른 클래스는 수정할 필요가 없게된다. 이렇게 함으로서 OCP와 DIP의 원칙도 지켜졌다.
이제 MemberService를 실행시켜보자
public class Main {
public static void main(String[] args) {
AppConfig appConfig = new AppConfig();
MemberService memberService = appConfig.memberService();
System.out.println(memberService.call()); // JPA를 사용해서 저장됨
}
}
MemberService를 직접 불러오는 것이 아니라 객체를 생성해주는 AppConfig를 불러온 후 AppConfig에서 MemberService를 불러온다.
결과값으로는 AppConfig에서 Jpa를 구현체로 사용하고 있기 때문에 JPA를 사용해서 저장된다는 문구가 출력된다.
DI를 활용한 코딩은 객체간의 결합도를 낮춰주며 더 유연한 코드를 짤 수 있게 만들어준다.
이렇게 외부에서 의존성을 주입받는 것은 내 스스로가 제어권을 가지지 못한다고도 표현할 수 있다. 그리고 이렇게 제어권이 역전되는 현상을 '제어의 역전(Inversion of Control, IoC)'라고 부르고 있다.
Spring이 가지는 중요한 특징 중 하나가 바로 '제어의 역전'이다. 이번에는 AppConfig를 스프링을 사용한 방식으로 변경해보도록 하겠다.
@Configuration
public class AppConfig {
@Bean
private MemberRepository memberRepository() {
return new JpaMemberRepository();
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
}
먼저 클래스레벨에 어노테이션으로 @Configuration을 설정해준다. 이 어노테이션을 설정함으로서 이 클래스를 감독의 열할을 하는 클래스로 지정하는 것이다.
다음으로는 생성 메소드들을 빈으로 등록하기 위해서 @Bean메소드를 사용해준다.
스프링은 스프링컨테이너라는 것을 가지고있다. 빈으로 등록한 객체는 스프링 컨테이너에서 관리되어진다.
이제 코드를 실행하면 이전처럼 직접 AppConfig를 생성해서 사용하는 것이 아니라 스프링에게 필요한 것을 요청해서 사용할 수 있다.
다시 코드를 실행시켜보자
public class Main {
public static void main(String[] args) {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = ac.getBean("memberService", MemberService.class);
System.out.println(memberService.call()); // JPA를 사용해서 저장됨
}
}
Spring컨테이너가 관리하게 되면 불러오는 방식이 조금 바뀐다.
AnnotationConfigApplicationContext라는 구현체에 AppConfig를 넣어주면 스프링이 안에있는 어노테이션을 확인 후 @Bean이 붙은 메소드들을 빈으로 등록한다.
getBean을 이용해서 스프링빈으로 등록된 빈을 가져올 수 있다. 빈으로 등록될 때 메소드 이름이 빈의 이름이 되므로 원하는 빈의 이름을 넣어주면 해당 객체를 리턴한다.
실행시켜보면 Java로만 만들었을 때와 같은 결과값을 보여주고있다.