Spring framework

스프링과 객체지향

rohyun 2021. 11. 4. 16:54

스프링과 객체 지향


스프링의 핵심 개념

  • 이 기술을 왜 만들었는가?, 이 기술의 핵심 컨셉은?
    • 스프링은 자바 언어 기반의 프레임워크이다.
    • 자바 언어의 가장 큰 특징은 객체 지향 언어 이다.
    • 스프링은 객체 지향 언어가 가진 강력한 특징을 살려내는 프레임워크이다.
    • 스프링은 좋은 객체 지향 애플리케이션을 개발할 수 있게 도와주는 프레임워크이다.

좋은 객체 지향 프로그래밍이란?

다형성 ( polymorphism ) 이 진짜 중요하다!

  • 다형성의 예로, 운전자 - 자동차 관계를 보자.

    • cardriver

    • 위의 사진과 같이, 운전자는 K3 를 타다가 아반떼를 타도 운전을 할 수 있다. 왜냐면 자동차라는 역할에 대한 구현만 바뀌었기 때문이다. 따라서 자동차가 바뀌어도 운전자한테 전혀 영향을 주지 않는다. 운전자는 자동차 인터페이스에 대해서만 알고 있고 구체적인 구현체에 대해서는 모른다.

    • 그렇다면 왜 자동차 역할과 구현을 나눴을까? 바로 운전자 ( 클라이언트 ) 를 위해서다. 말했듯, 운전자는 자동차의 내부구조를 알 필요가 없다. 구현이 내부적으로 바뀌더라도 자동차의 역할만 수행한다면 운전자에게 영향을 주지 않게 된다. 결과적으로 운전자에게 영향을 주지 않고 새로운 기능을 무한하게 제공할 수 있다는 말이 된다.

  • 역할과 구현을 분리 : 역할구현으로 구분하면 세상이 단순해지고, 유연해지며 변경도 편리해진다.

  • 장점

    • 클라이언트는 대상의 역할( 인터페이스 )만 알면 된다.
    • 클라이언트는 구현 대상의 내부 구조를 몰라도 된다.
    • 클라이언트는 구현 대상의 내부 구조가 변경되어도 영향을 받지 않는다.
    • 클라이언트는 구현 대상 자체를 변경해도 영향을 받지 않는다.
  • 자바 언어에서의 역할과 구현 적용

    • 자바 언어의 다형성을 활용, 역할 = 인터페이스 / 구현 = 인터페이스를 구현한 클래스, 구현 객체
    • 객체를 설계할 때 역할과 구현을 명확하게 분리한다.
    • 객체 설계 시 역할( 인터페이스 )을 먼저 부여하고, 그 역할을 수행하는 구현 객체를 만든다.
  • 다형성의 본질

    • 인터페이스를 구현한 객체 인스턴스를 실행 시점에 유연하게 변경할 수 있다.
    • 다형성의 본질을 이해하려면 협력이라는 객체사이의 관계에서 시작해야 한다.
    • 클라이언트를 변경하지 않고, 서버의 구현 기능을 유연하게 변경할 수 있다.

스프링과 객체 지향

  • 다형성이 가장 중요하다!
  • 스프링은 다형성을 극대화해서 이용할 수 있게 도와준다.
  • 스프링에서 이야기하는 제어의 역전(IoC), 의존관계 주입(DI)은 다형성을 활용해서 역할과 구현을 편리하게 다룰 수 있도록 지원한다.
  • 스프링을 사용하면 마치 레고 블럭 조립하듯이 구현을 편리하게 변경할 수 있다.

SOLID

  • SRP : 단일 책임 원칙 ( Single Responsibility Principle )
  • OCP : 개방-폐쇄 원칙 ( Open/Closed Principle )
  • LSP : 리스코프 치환 원칙 ( Liskov Substitution Principle )
  • ISP : 인터페이스 분리 원칙 ( Interface Segregation Principle )
  • DIP : 의존관계 역전 원칙 ( Dependency Inversion Principle )

단일 책임 원칙 - SRP

  • 하나의 클래스는 하나의 책임만 가져야 한다.
  • 하나의 책임이라는 것은 모호하다.
    • 클 수 있고, 작을 수 있다.
    • 문맥과 상황에 따라 다르다.
      • 중요한 기준은 변경이다. 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것이다.
      • 예) UI 변경, 객체의 생성과 사용을 분리

개방 폐쇄 원칙 - OCP ( 진짜 중요한 원칙 )

  • 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.

  • 엥? 확장을 하려면 당연히 기존의 코드를 변경해야되는거 아니야?

  • 다형성을 활용해보자.

  • 인터페이스를 구현한 새로운 클래스를 하나 만들어서 새로운 기능을 구현한다.

  • 역할과 구현의 분리!

  • polyreposi

  • 위의 사진을 보면 다형성이 잘 지켜져 있는 코드라고 볼 수 있다.

  • 하지만, OCP 의 관점에서 보면 코드를 변경해야 한다.

  • MemberService 클라이언트가 구현 클래스를 직접 선택하고 있다.

  • 구현 객체를 변경하려면 클라이언트 코드를 변경해야 한다.

  • 분명히 다형성을 사용했지만 OCP 원칙을 지킬 수 없다.

  • 해결 방법은? 객체를 생성하고, 연관관계를 맺어주는 별도의 조립, 설정자가 필요하다. 스프링 컨테이너!

  • 이러한 OCP 를 지키기 위해 DI, IoC 가 필요한 것이다!

리스코프 치환 원칙 - LSP

  • 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
  • 다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 것, 다형성을 지원하기 위한 원칙, 인터페이스를 구현한 구현체는 믿고 사용하려면 이 원칙이 필요하다.
  • 단순히 컴파일에 성공하는 것을 넘어서는 이야기이다.
  • 예를 들어, 자동차 인터페이스가 있다. 그럼 이 것을 구현체가 구현해야한다. 악셀이라는 기능을 구현하면 앞으로 가는 기능을 구현해야 한다. 사실 악셀을 밟아도 뒤로 가는 차를 만들 수도 있다. 악셀을 뒤로 가는 기능으로 구현해도 컴파일 에러는 나지 않는다. 하지만 LSP 는 컴파일 단을 얘기하는 것이 아니다. 하지만 인터페이스 규약이 '엑셀은 앞으로 가야 한다.' 이므로 이 규약을 항상 지키고 보장해주어야 한다는 것이다.

인터페이스 분리 원칙 - ISP

  • 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
  • 예를 들어, 자동차 인터페이스 -> 운전 인터페이스, 정비 인터페이스로 분리하고, 사용자 클라이언트 -> 운전자 클라이언트, 정비사 클라이언트로 분리한다면, 정비 인터페이스 자체가 변해도 운전자 클라이언트에 영향을 주지 않는다.
  • 인터페이스가 명확해지고, 대체 가능성이 높아진다.

의존관계 역전 원칙 - DIP

  • 프로그래머는 "추상화에 의존해야지, 구체화에 의존하면 안된다." 의존성 주입은 이 원칙을 따르는 방법 중 하나다.
  • 쉽게 말해, 클라이언트 코드가 구현 코드를 바라보지 말고, 인터페이스만 바라보라는 것이다.
  • 앞서 이야기한 역할(role)에 의존하게 해야한다는 것과 같다. 객체 세상도 클라이언트가 인터페이스에 의존해야 유연하게 구현체를 변경할 수 있다! 구현체에 의존하게 되면 변경이 아주 어려워진다.
  • 그런데 OCP 에서 설명한 MemberService 는 인터페이스에 의존하지만, 동시에 구현 클래스에도 의존한다.
  • MemberService 클라이언트가 구현 클래스를 직접 선택하고 있다. 이것은 DIP 를 위반한 것이다.

정리

  • 객체 지향의 핵심은 다형성이다.
  • 다형성 만으로는 쉽게 부품을 갈아 끼우듯이 개발할 수 없다.
  • 다형성 만으로는 구현 객체를 변경할 때 클라이언트 코드도 함께 변경된다.
  • 다형성 만으로는 OCP, DIP 를 지킬 수 없다.
  • 무엇인가 더 필요하다.

객체 지향 설계와 스프링

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

  • 스프링은 다음 기술로 다형성 + OCP, DIP 를 가능하게 지원한다.
    • DI : 의존관계, 의존성 주입
    • DI 컨테이너 제공
  • 클라이언트 코드를 변경 없이 기능 확장
  • 쉽게 부품을 교체하듯이 개발

순수 자바 코드의 객체 지향 원리 적용

새로운 할인 정책 적용과 문제점

  • 할인 정책을 변경하려면 클라이언트인 OrderServiceImpl 코드를 고쳐야 한다.
public class OrderServiceImpl implements OrderService {
    // private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
    private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
}
  • 문제점

    • 우리는 역할과 구현을 충실하게 분리했다. -> OK
    • 다형성도 활용하고, 인터페이스의 구현 객체를 분리했다. -> OK
    • OCP, DIP 같은 객체지향 설계 원칙을 준수했다. -> 그렇게 보이지만 아니다.
    • OrderServiceImpl 은 인터페이스 ( DiscountPolicy ) 에도 의존하고 있지만, 뿐만 아니라 구현 클래스 ( RateDiscountPolicy ) 에도 의존하고 있다!
    • 따라서 지금 코드는 기능을 확장해서 변경하면 클라이언트 코드에 영향을 준다. 따라서 OCP 를 위반한다.
  • 이러한 문제를 어떻게 해결할 수 있을까?

    • DIP 를 위반하지 않도록 인터페이스에만 의존 관계를 변경하면 된다.

      public class OrderServiceImpl implements OrderService {
      private DiscountPolicy discountPolicy;
    • 그런데 실행하면? 구현체가 없기 때문에 당연히 NPE 가 터진다!

    • 이 문제를 해결하려면 누군가 클라이언트인 OrderServiceImpl 에 DiscountPolicy 의 구현 객체를 대신 생성하고 주입해주어야 한다.

관심사의 분리

  • 애플리케이션을 하나의 공연이라고 생각해보자. 각각의 인터페이스를 배우(배우 역할)이라 생각하자. 그런데! 실제 배역에 맞는 배우를 선택하는 것은 누가 하는가?

  • 로미오와 줄리엣을 공연을 하면 로미오 역할을 누가 할지 줄리엣 역할을 누가 할지는 배우들이 정하는 것이 아니다. 로미오 역할을 맡은 남자 배우는 공연도 해야하고 동시에 여자 주인공도 공연에 직접 초빙해야 하는 다양한 책임 을 가지고 있다.

  • 관심사를 분리하자

    • 배우는 본인의 역할인 배역을 수행하는 것에만 집중해야 한다.
    • 로미오 역할을 맡은 남자 배우는 어떤 여자 주인공이 선택되더라도 똑같이 공연을 할 수 있어야 한다.
    • 공연을 구성하고, 담당 배우를 섭외하고, 역할에 맞는 배우를 지정하는 책임을 담당하는 별도의 공연 기획자가 있어야 한다.
    • 공연 기획자를 만들고, 배우와 공연 기획자의 책임을 확실하게 분리하자.

AppConfig

  • 애프리케이션 전체 동작 방식을 구성(config)하기 위해 구현 객체를 생성하고, 연결하는 책임을 가진 별도의 설정 클래스를 만들어보자.
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;

    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}
public class AppConfig {

    public MemberService memberService() {
        return new MemberServiceImpl(new MemoryMemberRepository);
    }

    public OrderService orderService() {
        return new OrderServiceImpl(new MemoryMemberRepository(), new FixDiscountPolicy());
    }
}
  • AppConfig는 애플리케이션의 실제 동작에 필요한 구현 객체를 생성한다.

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

  • appconfig

  • 객체의 생성과 연결은 AppConfig 가 담당한다.

  • DIP 완성 : MemberServiceImpl 은 MemberRepository인 추상에만 의존하면 된다. 이제 구현 클래스를 몰라도 된다.

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

public class MemberApp {
    public static void main(String[] args) {
        AppConfig appConfig = new AppConfig();
        MemberService memberService = appConfig.memberService();

        memberService.join(member);
    }
}

AppConfig Refactoring

  • 현재 AppConfig에는 중복이 있고, 역할에 따른 구현이 잘 보이지 않는다.
// 리팩토링 전
public class AppConfig {

    public MemberService memberService() {
        return new MemberServiceImpl(new MemoryMemberRepository);
    }

    public OrderService orderService() {
        return new OrderServiceImpl(new MemoryMemberRepository(), new FixDiscountPolicy());
    }
}
// 리팩토링 후
public class AppConfig {

    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    public OrderService orderService() {
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    public DiscountPolicy discountPolicy() {
        return new FixDiscountPolicy();
    }
}

SOLID 원칙 적용

SRP ( 단일 책임 원칙 ) - 한 클래스는 하나의 책임만 가져야 한다.

  • 구현 객체를 생성하고 연결하는 책임은 AppConfig 가 담당
  • 클라이언트 객체는 객체를 실행하는 책임만 담당

DIP ( 의존관계 역전 원칙 ) - 추상화에 의존, 구체화에 의존하면 안된다.

  • AppConfig 가 FixDiscountPolicy 객체 인스턴스를 클라이언트 코드 대신 생성해서 클라이언트 코드에 의존관계를 주입했다.

OCP ( 개방 - 폐쇄 원칙 ) - 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.

  • 다형성을 사용하고 클라이언트가 DIP 를 지키면 OCP 원칙을 지킬 수 있는 가능성이 열리는 것이다.
  • AppConfig 가 FixDiscountPolicy -> RateDiscountPolicy 로 변경해서 클라이언트 코드에 주입하므로 클라이언트 코드는 변경하지 않아도 된다.
  • 소프트웨어 요소를 새롭게 확장해도 사용 영역의 변경은 닫혀 있다!

IoC, DI, 그리고 컨테이너

IoC ( Inversion of Control )

  • 기존 프로그램은 클라이언트 구현 객체가 스스로 필요한 서버 구현 객체를 생성하고, 연결하고, 실행했다. 한마디로 구현 객체가 프로그램의 제어 흐름을 스스로 조종했다. 개발자 입장에서는 자연스러운 흐름이다.
  • 반면, AppConfig 가 등장한 이후 구현 객체는 자신의 로직을 실행하는 역할만 담당한다. 프로그램의 제어 흐름은 이제 AppConfig 가 가져간다. 예를 들어, OrderServiceImpl 은 필요한 인터페이스들을 호출하지만 어떤 구현 객체들이 실행될 지 모른다.
  • 프로그램에 대한 제어 흐름에 대한 권한은 모두 AppConfig 가 가지고 있다. 심지어 OrderServiceImpl 도 AppCofig 가 생성한다. 그리고 AppConfig 는 OrderServiceImpl 이 아닌 OrderService 인터페이스의 다른 구현 객체를 생성하고 실행할 수도 있다. 그런 사실도 모른채 OrderServiceImpl 은 묵묵히 자신의 로직을 실행할 뿐이다.
  • 이렇듯 프로그램 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것을 제어의 역전이라고 한다.

프레임워크 vs. 라이브러리

  • 프레임워크 : 프레임워크가 내가 작성한 코드를 제어하고, 대신 실행하면 그것은 프레임워크이다. (Junit)

    • 개발자는 테스트 로직만 개발하고, Junit 프레임워크가 실행 및 제어권을 갖는다. 또한 @BeforeEach 와 같은 라이프 사이클이 있다. 이러한 라이프 사이클 안에서 내가 개발한 코드만 콜백으로 불러지는 것이다.
  • 라이브러리 : 내가 작성한 코드가 직접 제어의 흐름을 담당한다면 그것은 라이브러리이다.

DI ( Dependency Injection )

  • OrderServiceImpl 은 DiscountPolicy 인터페이스에 의존한다. 실제 어떤 구현 객체가 사용될 지는 모른다.

  • 의존관계는 정적인 클래스 의존관계와, 실행 시점에 결정되는 동적인 객체(인스턴스) 의존 관계를 분리해서 생각해야 한다.

  • cldia

  • 정적인 클래스 의존관계

    • 클래스가 사용하는 import 코드만 보고 의존관계를 쉽게 판단할 수 있다. 정적인 의존관계는 애플리케이션이 실행하지 않아도 분석할 수 있다. 위의 클래스 다이어그램을 보자.
    • OrderServiceImpl 은 MemberRepository, DiscountPolicy 에 의존한다는 것을 알 수 있다. 그런데 이러한 클래스 의존관계 만으로는 실제 어떤 객체가 OrderServiceImpl 에 주입될 지 알 수 없다.
  • obdia

  • 동적인 객체 인스턴스 의존관계

    • 애플리케이션 실행 시점에 실제 생성된 객체 인스턴스의 참조가 연결된 의존 관계다.
  • 애플리케이션 실행 시점(런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서 클라이언트와 서버의 실제 의존관계가 연결 되는 것을 의존관계 주입이라 한다.

  • 객체 인스턴스를 생성하고, 그 참조값을 전달해서 연결된다.

  • 의존관계 주입을 사용하면 클라이언트 코드를 변경하지 않고, 클라이언트가 호출하는 대상의 타입 인스턴스를 변경할 수 있다.

  • 의존관계 주입을 사용하면 정적인 클래스 의존관계를 변경하지 않고, 동적인 객체 인스턴스 의존관계를 쉽게 변경할 수 있다.

IoC 컨테이너, DI 컨테이너

  • AppConfig 처럼 객체를 생성하고 관리하면서 의존관계를 연결해주는 것을 IoC 컨테이너 또는 DI 컨테이너라고 한다.
  • 의존관계 주입에 초점을 맞추어 최근에는 주로 DI 컨테이너라고 한다.
  • 또는 어셈블러, 오브젝트 팩토리 등으로도 불린다.

스프링으로 전환하기

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new FixDiscountPolicy();
    }
}
public class MemberApp {
    public static void main(String[] args) {
        // AppConfig appConfig = new AppConfig();
        // appConfig.memberService();
        ApplicationContext applicationContext = new AnnotaionConfigApplicationContext(AppConfig.class);
        MemberService memberService = applicationContext.getBean("memberService", MemberService.class);

        memberService.join(member);
    }
}

스프링 컨테이너

  • ApplicationContext 를 스프링 컨테이너라 한다.

  • 기존에는 개발자가 AppConfig 를 사용해서 직접 객체를 생성하고 DI 를 했지만, 이제부터는 컨테이너를 통해서 사용한다.

  • 스프링 컨테이너는 @Configuration 이 붙은 AppConfig 를 설정 정보로 사용한다. 여기서 @Bean 이라 적힌 메서드를 모두 호출해서 반환된 객체를 스프링 컨테이너에 등록한다. 이렇게 스프링 컨테이너에 등록된 객체를 스프링 빈이라고 한다.

  • 스프링 빈은 @Bean 이 붙은 메서드의 명을 스프링 빈의 이름으로 사용한다. ( memberService )

  • 이전에는 개발자가 필요한 객체를 AppConfig 를 사용해 직접 조회했지만, 이제부터는 스프링 컨테이너를 통해서 필요한 스프링 빈을 찾아야 한다. 스프링 빈은 applicationContext.getBean() 메서드를 사용해서 찾을 수 있따.

  • 기존에는 개발자가 직접 자바코드로 모든 것을 했다면, 이제부터는 스프링 컨테이너에 객체를 스프링 빈으로 등록하고, 스프링 컨테이너에서 스프링 빈을 찾아서 사용하도록 변경하였다.

  • 코드가 약간 더 복잡해진 것 같은데, 스프링 컨테이너를 사용하면 어떤 장점이 있을까? 앞으로 작성하겠음.

REFERENCE

김영한 님 스프링 핵심 원리 - 기본편

'Spring framework' 카테고리의 다른 글

스프링 컴포넌트 스캔과 @Autowired  (0) 2021.11.04
스프링 싱글톤 컨테이너  (0) 2021.11.04
스프링 컨테이너와 스프링 빈  (0) 2021.11.04