"A는 B에 의존한다."
, "A가 B를 의존한다."
, "A는 B에 의존적이다."
등 스프링과 자바를 공부를 하다보면 게슈탈트 붕괴가 올 정도로 많이 들어보았다. 추상적인 표현이지만 이해를 해보자.
A가 B를 의존하고 있을 때, B가 변하면 그 영향이 A에도 미친다.
예를 들어 컴퓨터 환경은 OS에 의존하고 있다.
OS가 매킨토시라면 맥 컴퓨터가 되고, 윈도우라면 윈도우 환경인 컴퓨터가 된다. OS에 의존하고 있기 때문에 OS의 변화가 컴퓨터 환경에 영향을 미친다.
스프링으로 개발을 할 때 mvc 패턴으로 하게 된다. Controller에서는 Service에 의존하고, Service는 Repository(DAO)를 의존해서 코드를 짜는데 굉장히 많은 의존관계가 성립된다. 스프링부트를 사용하게 되면 이 의존관계에 필요한 모든 요소들을 빈으로 관리하며 적절하게 필요한 곳에 알아서 주입까지 해주기 때문에 매우매우 편하다.
DI가 스프링에서 많이 사용되고 왜 필요한지 결론부터 말하자면 DI를 이용해서 설계를 하면 DIP, OCP를 잘 준수하면서 좋은 코드를 짤 수 있기 때문이다.
DI를 하지 않는다 해서 구현이 안되거나 하진 않겠지만, 객체지향 프로그래밍의 5가지 원칙(SOLID)를 잘 지키기 위해서 사용한다.
예시를 위해 다음과 같은 클래스를 만들었다. 간단하게 하기 위해 mvc패턴에서 컨트롤러는 제외하였다.
MemberService(인터페이스)
join()
, findMember()
MemberServiceImpl
MemberRepository(인터페이스)
save()
, findById()
MemoryMemberRepository
, JdbcMemberRepository
, MybatisMemberRepository
지금부터는 의존성 주입을 하지 않을 때 부터 작성한 코드를 보면서 5대 원칙에 어긋나는 문제점들을 고쳐 나가면서 DI가 필요한 이유를 설명한다.
public class MemberServiceImpl implements MemberService{
private final MemoryMemberRepository memoryMemberRepository = new MemoryMemberRepository();
@Override
public void join(Member member) {
memoryMemberRepository.save(member);
}
@Override
public Member findMember(Long memberId) {
return memoryMemberRepository.findById(memberId);
}
}
MemberService
의 구현체인 MemberServiceImpl
의 코드이다.
당연히 기능상으론 문제가 없다. 우리가 의도한 결과도 내어준다. 하지만 MemoryMemberRepository
에 의존성이 매우 강하게 보인다 아래 그림처럼 나타낼 수 있다.
의존성이 강하면 생기는 문제가 변경을 할 때 잘 나타난다. 그렇기에 새로운 상황을 가정한다.
이제는 메모리에 저장을 하지 않고 DB에 Jdbc 템플릿을 이용해서 데이터를 저장한다고 위에서 지시가 내려왔다.
다음과 같이 코드를 바꿀 수 있다.
public class MemberServiceImpl implements MemberService{
private final JdbcMemberRepository jdbcMemberRepository = new JdbcMemberRepository();
@Override
public void join(Member member) {
jdbcMemberRepository.save(member);
}
@Override
public Member findMember(Long memberId) {
return jdbcMemberRepository.findById(memberId);
}
}
수정한 코드 역시 JdbcMemberRepository
에 의존성이 강하다.
그리고 이렇게 짧은 코드지만 바꿔야하는 곳이 많은데 실제로는 메서드도 더 많고 복잡할뿐더러 MemoryMemberRepository
을 의존하고 있는 클래스가 더 존재할 것이다. 실제로는 수정해야 하는 곳이 엄청 많겠지,,,
인터페이스
다형성
을 이용해서 문제를 해결해보자.
우리가 수정後 원하는 그림은 아래와 같다.
public class MemberServiceImpl implements MemberService{
private final MemberRepository memberRepository = new JdbcMemberRepository();
@Override
public void join (Member member) {
memberRepository.save(member);
}
@Override
public Member findMember (Long memberId) {
return memberRepository.findById(memberId);
}
}
참조변수의 타입을 인터페이스인 MemberRepository
로 선언해주면서 다형성을 적용하였다.
다형성을 적용하고 나서 또 다시
MybatisRepository
로 구현체를 변경하는 상황이 생기게 된다면?
public class MemberServiceImpl implements MemberService{
// private final MemberRepository memberRepository = new JdbcMemberRepository();
private final MemberRepository memberRepository = new MybatisRepository();
@Override
public void join(Member member) {
memberRepository.save(member);
}
@Override
public Member findMember(Long memberId) {
return memberRepository.findById(memberId);
}
}
전에 보다는 수정해야하는 곳이 좀 줄긴했지만 그래도 여전히 저장소 구현체를 어떤 것을 쓰냐에 따라서 MemberServiceImpl
코드 수정은 불가피하다.(OCP위반
)
이 코드를 보면 join()
, findMember()
의 메서드 로직을 다형성을 잘 이용해서 인터페이스인 MemberRepository
에 의존하면서 DIP를 잘 지킨 것 처럼 보이나, 사실은 인터페이스 뿐만 아니라 구현체인 MemoryMemberRepository
에도 의존하고 있다.(DIP위반
)
결과적으로 다형성
을 활용해보았지만 여전히 5대 원칙에 어긋나는 코드이다. 그럼 이제 DI 패턴을 도입하여 5대 원칙을 잘 지킬 수 있을지 코드를 리팩토링 해보자.
public class MemberServiceImpl implements MemberService{
private final MemberRepository memberRepository;
@Override
public void join(Member member) {
memberRepository.save(member);
}
@Override
public Member findMember(Long memberId) {
return memberRepository.findById(memberId);
}
}
인터페이스에만 의존하도록 수정하였다. 이 상태로만 본다면 아래 그림과 같은 상황이다. 근데 과연 이 코드가 정상적으로 실행이 될까?
실제로 실행을 해보면 null pointer exception
이 발생한다.
memberRepository
참조변수에 주소값이 아무것도 대입되어 있지 않았으니까!
사실 지금까지의 코드에서는 5대 원칙에서
단일책임원칙
에도 위반된다.
이해를 돕기위해서 예를 들어보자.
애플리케이션을 하나의 공연이라고 생각해보자. 그리고 각각의 인터페이스 MemberService
와 MemberRepository
가 배우 역할이라고 생각해보자.
MemberRepository
MemberService
JdbcMemberRepository
MemoryMemberRepository
MybatisMemberRepository
MemberServiceImpl
배우는 공연에서 열연을 펼치는게 일이고 감독은 캐릭터에 적절한 배우를 캐스팅하는게 일이다.
그런데 지금까지의 코드는 배우가 감독의 일까지 같이 하고 있는 셈이 된다.
public class MemberServiceImpl implements MemberService{
private final MemberRepository memberRepository = new JdbcMemberRepository();
@Override
public void join (Member member) {
memberRepository.save(member);
}
@Override
public Member findMember (Long memberId) {
return memberRepository.findById(memberId);
}
}
MemberServiceImpl
배우가 MemberRepository
역할에 적합한 JdbcMemberRepository
배우를 캐스팅하는 일까지 하고 있다.
관심사의 분리를 위해서, 배우가 도맡아 하던 캐스팅 일을 다시 감독이 잘 하게끔 애플리케이션의 전체 동작 방식을 구성(config)하기 위해 별도의 설정 클래스 AppConfig(임의로 지은 이름)를 만들자.
public class AppConfig {
public MemberService memberService() {
return new MemberServiceImpl(new MemoryMemberRepository());
}
}
그리고 MemberServiceImpl
에서 생성사를 통해서 MemberRepository
의 구현체가 주입될 수 있도록 생성자를 추가한다.
public class MemberServiceImpl implements MemberService{
private final MemberRepository memberRepository;
public MemberServiceImpl(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public void join(Member member) {
memberRepository.save(member);
}
@Override
public Member findMember(Long memberId) {
return memberRepository.findById(memberId);
}
}
public class MemberApp {
public static void main(String[] args) {
AppConfig appConfig = new AppConfig();
MemberService memberService = appConfig.memberService();
Member member = new Member(1L, "memberA", Grade.VIP);
memberService.join(member);
}
}
AppConfig
인스턴스를 생성하고 appConfig.memberSerivce()
로 메서드를 호출하면 MemberServiceImpl
인스턴스가 생성되면서 반환되는데, 이 때 인스턴스가 생성될 때 DI가 일어난다.
MemberServiceImpl
은 인터페이스 MemberRepository
에만 의존한다.MemberServiceImpl
입장에서 생성자를 통해서 어떤 구현 객체가 들어올지(주입되는지) 알 수 없다.MemberServiceImpl
의 생성자를 통해서 주입되는 구현 객체는 AppConfig
에서 결정된다.AppConfig
만 수정하면 나머지 코드는 건들일 필요가 없다.MemberServiceImpl
은 실행에만 집중하면 된다.