지나공 : 지식을 나누는 공간

스프링 만들어진 이유. 객체지향설계 5원칙부터 DI 까지. 1편 본문

Tech/Spring Boot

스프링 만들어진 이유. 객체지향설계 5원칙부터 DI 까지. 1편

해리리_ 2021. 6. 1. 00:14

김영한 님의 강의를 참고했습니다.

 

객체지향설계의 원칙은?

  • SRP(Single Responsibility Principle) 단일책임 원칙
    한 클래스는 하나의 책임만 가진다. 변경이 있을 때 파급효과가 적으면, 다른 곳에 영향을 덜 미치면 단일책임원칙을 잘 따른것임.
  • OCP(Open/closed principle) 개방폐쇄원칙
    SW 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
    확장을 하려면 당연히 기존 코드를 변경하는 것 같지만!!! 다형성을 떠올리자.
    인터페이스를 구현한 새로운 클래스를 하나 만들어서 새로운 기능을 구현하잖아?
      • 그런데 문제점이 있다. 구현 객체를 변경하려면 클라이언트 코드를 변경해야 한다는 것이다. 
      •  
    • 그래서 이걸 해결해야 한다. 객체를 생성하고 관계를 맺어주는 별도의 조립, 설정자가 필요하고 이걸 스프링 컨테이너가 한다.
  • LSP(Liskov substitution principle) 리스코프 치환 원칙
    다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 것. 자동차 인터페이스의 엑셀은 앞으로 가는 기능인데 이를 뒤로 가게 구현하면 LSP 위반임.
  • ISP(Interface segregation principle) 인터페이스 분리 원칙
    특정 클라이언트를 위한 인터페이스 여러 개가 여러 개가 범용 인터페이스 하나보다 낫다. 분리를 하면 인터페이스가 명확해지고 대체 가능성이 높아진다.
  • DIP(Dependency inversion principle) 의존관계 역전 원칙
    프로그래머는 "추상화에 의존해야지, 구체화에 의존하면 안된다.". 역할에 의존해야지, 구현에 의존하면 안된다. 역할과 구현을 분리해야 한다!! 클라이언트가 인터페이스에 의존해야 유연하게 구현체를 변경할 수 있다. 구현체에 의존하게 되면 변경이 아주 어려워 진다. 스프링의 의존성 주입이 이 원칙을 따르는 방법 중 하나다. 
    자동차 역할을 알아야지, 아반떼를 자세히 알 필욘 없다.

DIP를 위반하고 있다. 

위 예시는 DIP를 위반한다. 추상체인 MemberRepository 외에 MemoryMemberRepository도 알고 있기 때문이다.

추상체와 구현체 둘 다한테 의존하고 있는 상태다. MemberRepository한테만 의존해야 한다.

 

객체지향 설계 정리

- 객체 지향의 핵심은 다형성이다.

- 다형성만으로는 구현 객체를 변경할 때 클라이언트 코드도 함께 변경되어 버려서 쉽게 부품을 갈아 끼우듯이 개발할 수 없다. (MemberRepository의 구현체를 memory -> jdbc로 변경할 때 코드가 바뀌어 버리니까)

- 다형성 만으로는 OCP, DIP를 지킬 수 없다.

- 그래서 뭔가 필요하다. 그게 바로 스프링.

 

스프링 얘기에 왜 객체 지향 이야기가 나오는가?

스프링은 DI, DI 컨테이너를 통해 다형성 + OCP, DIP를 가능하게 지원한다.

그래서, 클라이언트의 코드를 변경할 필요 없이 기능을 확장할 수 있고, 쉽게 부품을 교체하듯 개발할 수 있다.

이 포스팅에서는 먼저 DIP가 적용되지 않은 개발의 예시를 보고, 그 다음 스프링을 활용해서 DIP를 적용할 것이다.

 

개발방향 정리

- 모든 설계에 있어, 역할과 구현을 분리하자.

- 애플리케이션의 설계에서 언제든지 부품을 유연하게 변경할 수 있도록 만드는 것이 좋은 객체 지향 설계다.

- 이상적으로는 모든 설계에 인터페이스를 부여하는 방안이 있다.

- 그러나, 인터페이스를 무분별하게 남발하면 추상화라는 비용이 발생한다. 이 비용은 성능이 아니라, 추상화를 하면 코드를 볼 때 두번 봐야한다. 구현체 보고나서 그럼 인터페이스가 뭐지? 하면서 두 번 봐야 한다... 추상화를 통한 장점과 단점이 동시에 있으니, 장점이 단점을 넘어설 때 사용해야 한다.

- 기능을 확장할 가능성이 없다면 구체 클래스를 직접 사용하고, 향후 꼭 필요할 때 리팩터링해서 인터페이스를 도입하는 것이 좋다. 

 

DIP가 잘 지켜지지 않은 예시를 살펴보자.

일단 도메인은 아래와 같다.

  • 회원등급(enum, Grade)
  • 회원엔티티(class, Member)
  • 회원저장소 추상체(interface, MemberRepository)
  • 메모리 회원저장소 구현체(class, MemoryMemberRepository, implements MemberRepository)
  • 회원서비스 추상체(interface, MemberService)
  • 회원서비스 구현채(calss, MemberServiceImpl, implements MemberService)
    • privae final MemberRepository memberRepository = new MemoryMemberRepository;

현재 상태에서는 회원서비스 구현체에 메모리회원저장을 사용한다고 해당 코드를 작성했다. 현재는 회원서비스 구현체가 추상체인 MemberRepository에도 의존하고, 동시에 구현체인 MemoryMemberRepository에도 의존하고 있기 때문에, 구현체를 변경해야 할 때 new 다음에 DBMemoryRepository 등의 다른 저장소 구현체로 클라이언트 내 코드를 변경해야 한다. 추상체와 구현체를 모두 의존하고 있으니 DIP를 지키지 않았다.

 

DIP가 안 지켜지면 뭐가 불편할까? 코드로 체감해보자.

추가 도메인은 아래와 같다.

  • 할인 정책 추상체 (interface, DiscountPolicy)
  • 정액 할인 구현체(class, FixDiscountPolicy, implements DiscountPolicy) 1000원을 할인.
    • 이후에 만들 RateDiscountPolicy는 10%를 할인하는 것으로 구현체를 만들 예정임.
  • 주문엔티티(class, Order)
  • 주문 서비스 추상체(interface, OrderService)
  • 주문 서비스 구현체(calss OrderServiceImpl, implements OrderService)
    • private final MemoryRepository memberRepository = new MemoryMemberRepository();
    • private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
    • int discountPrice = discountPolicy.discount(member, itemPrice);

마지막 코드인 discountPolicy.discout(member, itemPrice)는 객체지향설계 원칙 중 단일책임원칙이 잘 적용되었다. 할인 정책이 변해도 클라이언트 코드는 변경되지 않고 discount 메소드의 코드만 변경하면 되니까.

 

현재 상태는 다형성을 잘 활용했다. 인터페이스와 구현체로 분리했으니까. 하지만? 할인정책의 구현체를 바꿀 때 과연 DIP가 잘 지켜질 것인가는 아직 의문이다.

 

이제 상황을 가정해보자. 기획자가 할인정책을 Fix처럼 1000원 할인하는 방식이 아니라, Rate로 10%를 할인하는 방식으로 바꾸자고 한다면 어떻게 될까?

일단 이를 위해 필요한 도메인을 추가하자.

  • 정률 할인 구현체(calss, RateDiscountPolicy, implements DiscountPolicy) 10%를 할인

기획자 말대로 변경하려면 OrderServiceImple에 있는 코드를 변경해야 한다.

// private final DiscountPolicy discountPolicy = new FixDiscountPolicy(); 
private final DiscountPolicy discountPolicy = new RateDiscountPolicy(); //변경 후

문제점이 발견된 것이다.

  • 우리는 인터페이스와 구현체로 나눴으니 역할과 구현으 충실하게 분리하긴 했다.
    • 다형성도 활용하고 인터페이스와 구현 객체도 분리했음.
  • OCP, DIP 같은 객체 지향 설계 원칙을 준수했다. 라고 보이지만? 실상은 그렇지 않다.
  • DIP : 주문 서비스 클라이언트인 OrderServiceImpl은 DiscountPolicy라는 인터페이스에 의존하면서 동시에 구현체인 FixDiscountPolicy와 RateDiscountPolicy에도 의존하고 있다. 그래서 DIP를 위반한다.
  • OCP는 변경하지 않고 확장할 수 있어야 한다고 했는데, 지금 코드는 기능을 확장해서 변경하면 클라이언트 코드에 영향을 주므로 OCP를 위반한다. 할인 정책이 바뀔 때 OrderSerivceImple의 코드를 바꿔야 하고, 이를 바꾸는 순간 OCP 위반이다.

 

자 이제 어떻게 해야 OCP와 DIP를 지킬 수 있을까?

1. 일단 DIP를 지키기 위해 인터페이스에만 의존하게 해 보자.

// private final DiscountPolicy discountPolicy = new RaateDiscountPolicy();
private final DisocountPolicy discountPolicy;

구현체가 아닌 추상체에만 의존하게 바꿔봤다.

대신 이걸 test하면 null point exception이 발생한다.

해결방안 : 누군가 클라이언트인 OrderServiceImpl에 DiscountPolicy의 구현체(RateDiscountPolicy)를 대신 생성하고 주입해 주면 된다.

 

AppConfig의 등장.

애플리케이션의 전체 동작 방식을 구성(config)하기 위해, 구현 객체를 생성하고, 연결하는 책임을 가지는 별도의 설정 클래스.

기존의 MemberService

기존에는 MemberService에서 MemberRepository의 구현 객체를 직접 지정했었다. 이런 걸 전부 AppConfig가 하게 하자.

구현 객체를 노출하지 않고 생성자를 만들었다.
AppConfig에서 구현체인 MemoryMemberRepository()를 전달하고 있다. 

이렇게 하면 기존에 ServiceImpl에서 추상체와 new를 통해 구현체를 모두 갖고 있던 것을 추상체만 갖도록 만들 수 있다.

OrderService에서도 필요한 구현체 코드들을 없애도록 수정해보자.

 

public class OrderServiceImpl implements OrderService{

	//변경 전
	//private final MemberRepository memberRepository = new MemoryMemberRepository();
    //private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
    
    //변경 후
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;
    
    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy){
    	this.memberRepository = memberRepository;
        this.dicountRepository = discountRepository;
    }
}

public class AppConfig {
	//구현체 연결은 여기서 생성자 호출하면서 명시하기.
	public OrderService orderService(){
    	return new OrderServiceImple(new MemoryMemberRepository(), new FixDiscountPolicy()); 
    }
}

이제 OrderService는 DIP를 지킨다. 추상체에만 의존하고 어떤 구현체인지는 모르는 상태니까.

 

그래서 이제 AppConfig는 실제 동작에 필요한 구현 객체를 생성하게 됐다.

  • MemberServiceImpl
  • MemoryMemberRepository
  • OrderServiceImple
  • FixDiscountPolicy

AppConfig는 생성한 객체 인스턴스의 참조(레퍼런스)를 생성자를 통해 주입(연결)해준다.

  • MemberSerivceImpl -> MemoryMemberRepository
  • OrderServiceImple -> MemoryMemberRepository, FixDiscountPolicy

다시 정리.

  • 이렇게 변경했으니 이제는 MemberServiceImpl은 MemoryMemberRepository를 의존하지 않는다.
  • 단지 MemberRepository 인터페이스만 의존한다.
  • MemberServiceImpl입장에서 생성자를 통해 어떤 구현 객체가 들어올지(주입될지) 알 수 없다.
  • MemberServiceImpl의 생성자를 통해 어떤 구현 객체를 주입할지는 오직 외부, AppConfig에서 결정된다.
  • MemberServiceImpl은 이제부터 의존 관계에 대한 고민은 외부에 맡기고 오직 실행에만 집중하면 된다.

이로써 DIP 완성이다. 관심사의 분리, 객체를 생성하고 연결하는 역할과 실행하는 역할이 명확히 분리되었다.

 

메모리멤버레파지토리를 생성하고, 그 참조값을 서비스구현체에 같이 전달함으로써 주입한다.

appConfig는 memoryMmeberRepository 객체를 생성하고 그 참조값을 memberServiceImpl을 생성하면서 생성자로 전달하므로, 클라이언트인 memberServiceImpl 입장에서 보면 의존관계를 마치 외부에서 주입해주는 것 같다고 해서, DI라고 한다. 우리말로 의존관계 주입 또는 의존성 주입 이라고 한다.

 

다음 포스팅에 내용이 이어진다.

728x90
Comments