[Spring] 의존성 주입(Dependency Injection)

킹발·2022년 10월 5일
0

Spring

목록 보기
1/2
post-thumbnail

Dependency 의존관계란?

"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대 원칙을 잘 지킬 수 있을지 코드를 리팩토링 해보자.


DI 도입

인터페이스에만 의존하도록 설계를 변경하자.

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 참조변수에 주소값이 아무것도 대입되어 있지 않았으니까!


관심사의 분리

AppConfig 등장

사실 지금까지의 코드에서는 5대 원칙에서 단일책임원칙에도 위반된다.

이해를 돕기위해서 예를 들어보자.

애플리케이션을 하나의 공연이라고 생각해보자. 그리고 각각의 인터페이스 MemberServiceMemberRepository가 배우 역할이라고 생각해보자.

  • 배우 역할 (인터페이스) : 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 배우를 캐스팅하는 일까지 하고 있다.


AppConfig 적용

관심사의 분리를 위해서, 배우가 도맡아 하던 캐스팅 일을 다시 감독이 잘 하게끔 애플리케이션의 전체 동작 방식을 구성(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);
    }
}

DI 사용

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가 일어난다.


정리

DI 적용하는 법

  1. 인터페이스에만 의존하도록 수정한다.
  2. 그 인터페이스의 구현체가 생성자를 통해서 주입될 수 있도록 생성자를 만든다.
  3. 설정파일을 만들고 메서드들이 구현체를 반환하도록 만든다.

DI를 해서 얻은 점

  • AppConfig 도입으로 관심사를 확실하게 분리했다.
  • MemberServiceImpl은 인터페이스 MemberRepository 에만 의존한다.
  • MemberServiceImpl 입장에서 생성자를 통해서 어떤 구현 객체가 들어올지(주입되는지) 알 수 없다.
    • 다형성을 이용해서 받기 때문에 구체적으로 어떤애가 들어오는지 모른다.
  • MemberServiceImpl의 생성자를 통해서 주입되는 구현 객체는 AppConfig에서 결정된다.
    • 수정사항이 생기면 설정파일인 AppConfig만 수정하면 나머지 코드는 건들일 필요가 없다.
    • MemberServiceImpl은 실행에만 집중하면 된다.

0개의 댓글