스프링 핵심 원리 04] 객체 지향 원리 적용

컴업·2022년 1월 7일
1

본 포스트는 Inflearn 김영한 선생님 강의를 정리한 것 입니다!

안녕하세요~~

지난 포스트에서 간단한 주문 프로그램을 다형성의 특징을 잘 살려 개발해보았습니다.

그러나 우리가 만들었던 프로그램은 SOLID원칙중 DIP, OCP두가지를 위반하고있어 객체 지향적으로 2%부족한 프로그램이라고 말씀드렸었죠.

이번 시간에는 지난 포스트에서 만들었던 프로그램을 좀 더 객체 지향적으로 리팩토링 해보겠습니다.

뤳츠고우!

1. 새로운 할인 정책 개발

다시 SI 개발자로 돌아가봅시다.

예상했던대로 서비스 오픈이 얼마 남지 않는 시점에서 기획자가 기존 1000원씩 무조건 적용하는 할인이 아닌 전체 주문 금액에서 10%를 할인하는 정책으로 변경하고싶어합니다.

화가 날 수 도 있겠지만 선생님께선 애자일 소프트웨어 개발 선언을 기억하라고 하십니다...

계획을 따르기보다 변화에 대응하기를!!

다행이 우리는 자바의 다형성의 특징을 잘 살려 할인 정책을 인터페이스로 만들어두었기 때문에 손쉽게 변경할 수 있습니다.

새로운 할인 정책 개발

지금 까지 프로젝트 구조

자 그럼 새로운 할인 정책 클래스를 만들어보겠습니다.

discount 패키지 아래 RateDiscountPolicy 클래스를 만들었습니다.

package hello.core.discount;

import hello.core.member.Grade;
import hello.core.member.Member;

public class RateDiscountPolicy implements DiscountPolicy{

    private int discountPercent = 10; //10% 할인
    
    @Override
    public int discount(Member member, int price) {
        if (member.getGrade() == Grade.VIP) {
            return price * discountPercent / 100;
        } else {
            return 0;
        }
    }
}

테스트케이스

테스트 케이스도 당연히 작성해야겠죠??

shift + command + T -> Create test !

package hello.core.discount;

import hello.core.member.Grade;
import hello.core.member.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class RateDiscountPolicyTest {

    RateDiscountPolicy discountPolicy = new RateDiscountPolicy();

    @Test
    @DisplayName("VIP는 10% 할인이 적용되어야 한다.")
    void vip_o() {
        //given
        Member member = new Member(1L, "memberVIP", Grade.VIP);
        //when
        int discount = discountPolicy.discount(member, 10000);
        //then
        Assertions.assertThat(discount).isEqualTo(1000);
    }

    @Test
    @DisplayName("VIP가 아니면 할인이 적용되지 않아야 한다.")
    void vip_x() {
        //given
        Member member = new Member(2L, "memberBASIC", Grade.BASIC);
        //when
        int discount = discountPolicy.discount(member, 10000);
        //then
        Assertions.assertThat(discount).isEqualTo(0);
    }
}

VIP회원일 경우 10000원짜리 물건을 구매하였을 때 10%할인을 적용해 1000원이 측정되고, BASIC 회원일 경우 할인을 받지 못하게 된다는 테스트 케이스 입니다.

죠기 클래스 왼쪽에 초록 삼각형을 눌러 실행해줍니다.

😎 통과했네요!!

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

이제 방금 추가한 할인 정책을 우리 프로그램에 적용해볼까요??

할인 정책을 변경하기 위해 클라이언트인 OrderServiceImpl코드를 수정합니다.

public class OrderServiceImpl implements OrderService{

    private final MemberRepository memberRepository = new MemoryMemberRepository();
    
    //private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
    private final DiscountPolicy discountPolicy = new RateDiscountPolicy();

    ...
    
}

끝!

휴 ~ 다형성의 특징을 잘 살려 인터페이스와 구현 객체를 잘 구분해 설계했더니, 새로운 할인 정책을 도입해도 아주 간단하게 바꿀 수 있네요! (?)

...

...

아니닙니다!

언듯 보면 OCP, DPI를 잘 지킨것 같지만 사실 클라이언트인 OrderServiceImple은 DiscountPolicy 뿐만아니라 그 구현체인 FidDiscountPolicy 혹은 RateDiscountPolicy를 의존하고 있습니다.

구현체가 아닌 추상체에 의존하라는 DIP 원칙을 위반한 것이지요.

이 때문에 기존 코드인 OrderSerivceImpl에 변경 없이는 확장할 수 없게 되는 것입니다.

확장에는 열려있고 변경에는 닫혀있어야 한다는 OCP또한 위반하게 된것이죠.

그렇다면 왜 우리는 클라이언트 코드를 변경해야 했을까요???

위 그림은 우리가 기대했던 의존관계를 나타내는 그림입니다.

단순히 DiscountPolicy에 의존하고있다고 생각했었죠.

그러나 엄밀히 따지면 위 그림은 잘못되었습니다.

코드 입장에서 바라본다면 실제 의존관계는 위 그림과 같게됩니다.

따라서 우리는 정책을 변경하는 순간 OrderSerivceImpl의 코드를 변경할 수 밖에 없었던 겁니다.


이렇게요.

어떻게 문제를 해결할 수 있을까요?

문제의 근본적인 원인은 구현체인 OrderServiceImpl이 구현체인 FixDiscountPolicy, RateDiscountPolicy에 의존한다는 것 입니다.

따라서 구현체가 아닌 DiscountPolicy 인터페이스에만 의존할 수 있도록 설계를 변경해보겠습니다.

public class OrderServiceImpl implements OrderService{

    private final MemberRepository memberRepository = new MemoryMemberRepository();

    //private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
    private DiscountPolicy discountPolicy;

  	...
    
}

DiscountPolicy에만 의존하도록 코드를 변경했습니다.

지금은 구현체없이 인터페이스만 띡 올려놓았으니 당연히 널포인트예외가 터지게됩니다.

이 문제를 해결하기 위해서는 다른 누군가가 OrderServiceImpl에 DiscountPolicy 구현 객체를 대신 생성하고 주입해 주어야합니다.

3. 관심사의 분리

애플리케이션을 로미오와 줄리엣 연극이라고 생각해봅시다.

로미오, 줄리엣 각 배역은 인터페이스에 해당되고, 실제 연기를 하는 배우들은 그 구현체라고 생각할 수 있습니다.

실제 공연을 보면 연기자는 연기에만 집중하고, 배우를 섭외하는 전문 캐스터가 따로 있죠.

만약 로미오(인터페이스)를 연기하는 잭블랙(구현체)이 줄리엣을 직접 초빙한다면, 잭블랙은 공연도 하고 기획도하는 다양한 책임을 갖게 됩니다.

이렇게 된다면 잭블랙을 다른 배우로 변경할 수 있을까요??

혹은 잭블랙 모르게 줄리엣을 다른 배우로 변경할 수 있을까요??

이 때문에 관심사를 분리해야합니다!

배우는 연기에만 집중해야합니다. 잭블랙은 여배우로 누가오던 자기 역할에만 집중해야합니다.

배우를 섭외하는 일은 전문 캐스터가 담당해야할 일입니다.

AppConfig의 등장

그렇다면 전문 캐스터역할을 해줄 별도의 클래스를 만들어 보겠습니다.

이 클래스는 애플리케이션 동작 방식을 구성하기 위해, 구현 객체를 생성하고 연결하는 역할을 합니다.

core패키지에 AppConfig 클래스를 만들었습니다.

package hello.core;

import hello.core.discount.FixDiscountPolicy;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;

public class AppConfig {

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

    public OrderService orderService() {
        return new OrderServiceImpl(new MemoryMemberRepository(),
                new FixDiscountPolicy());
    }
}

보시면 AppConfig는 이 프로그램에서 사용되는 구현체들을 모두 관리하고있습니다.

구현체인 MemberServiceImpl, MemoryMemberRepository, OrderServiceImpl, FixDiscountPolicy 객체를 생성하고있고,

생성자를 통해 구현체간의 의존성을 주입(연결)해주고있습니다.

참고
지금은 각 클래스에 생성자가 없어 컴파일 오류가 발생합니다.

public class MemberServiceImpl implements MemberService{

    //private final MemberRepository memberRepository = new MemoryMemberRepository();
    private final MemberRepository memberRepository;

    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    ...
    
}

설계 변경으로 MemberServiceImpl은 MemoryMemberRepository를 전혀 의존하지 않고 단지 MemberRepository 인터페이스에만 의존하고있습니다.

MemberServiceImpl입장에서는 생성자를 통해 어떤 구현체가 들어올지 알 수 없고, 오직 외부 AppConfig에서 결정됩니다.

잠시 구조를 살펴보겠습니다.

클래스 다이어그램이 코드 입장에서 보더라도 정말 인터페이스에만 의존하고있습니다.

객체의 생성과 연결은 AppConfig가 담당하게됩니다.

DIP가 완성되는 순간이에요!

AppConfig 객체는 MemoryMemberRepository객체를 생성하고 그 참조값을 memberServiceImpl을 생성하면서 생성자로 전달하고있습니다.

클라이언트 입장에서는 의존관계를 외부에서 주입하는 것 같다고 해서 이를 DI(Dependency Injection) 우리말로 의존성 주입, 의존관계 주입이라고 합니다!

이게바로 그 유명한 DI입니다.


OrderServiceImpl도 마찬가지로 변경해주겠습니다.

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;
    }

	...
}

테스트 케이스 수정

테스트 케이스를 수정하고 실제 잘 작동하는지 확인해보도록 하겠습니다.

class MemberServiceImplTest {

    //MemberService memberService = new MemberServiceImpl();
    MemberService memberService;

    @BeforeEach
    void beforeEach() {
        AppConfig appConfig = new AppConfig();
        memberService = appConfig.memberService();
    }

	...
}

@BeforeEach는 각 테스트 메소드를 실행하기 전에 한번씩 실행되는 아이입니다.

여기서 AppConfig객체가 memberService 구현체를 주입하도록 수정하였습니다.

OrderService도 해줍시다.

class OrderServiceImplTest {

    //MemberService memberService = new MemberServiceImpl();
    //OrderService orderService = new OrderServiceImpl();
    MemberService memberService;
    OrderService orderService;

    @BeforeEach
    public void beforeEach() {
        AppConfig appConfig = new AppConfig();
        memberService = appConfig.memberService();
        orderService = appConfig.orderService();
    }
    
    ...
}

프로그램을 시작할 때 AppConfig를 먼저 실행해 모든 의존관계 주입을 끝내고 들어가게됩니다.


4. AppConfig 리팩터링

지금의 AppConfig를 보면 구현체가 한눈에 잘 들어오지 않습니다.

중복을 제거하고 역할에 따른 구현이 좀 더 잘 보이도록 리펙터링을 해보겠습니다.

변경전

public class AppConfig {

    public MemberService memberService() {
        return new MemberServiceImpl(new MemoryMemberRepository());
    }
    
    public OrderService orderService() {
        return new OrderServiceImpl(
                new MemoryMemberRepository(),
                new FixDiscountPolicy());
    }
}

변경 전에는 역할과 구현체를 한 눈에 보기 어렵습니다.

MemoryMemberRepository가 어떤 역할을 구현한 구현체인지 알 수 없죠.

또 중복이 많아 만약 MemoryMemberRepository()를 바꾸려면 여러군데를 수정해야한다는 문제점이있습니다.

public class AppConfig {

    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }
    
    public OrderService orderService() {
        return new OrderServiceImpl(
                memberRepository(),
                discountPolicy());
    }
    
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
    
    public DiscountPolicy discountPolicy() {
        return new FixDiscountPolicy();
    }
}

이처럼 중복을 제거함으로서 구현체가 변경될 때 한 부분만 수정하면 되도록 수정했습니다. 또 역할과 구현클래스가 한눈에 파악할 수 있게되었습니다. 😆

새로운 구조와 할인 정책 적용

이제 AppConfig를 도입한 애플리케이션에서 할인 정책을 변경해보도록 하겠습니다.

FixDiscountPolicy에서 RateDiscountPolicy로 변경해보겠습니다.

public class AppConfig {

	...

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

이렇게 AppConfig를 수정하여 기존 코드의 변경 없이 할인 정책을 변경할 수 있었습니다.

DIP, OCP를 완전히 만족시키고 있습니다.

모든 구현체는 구현체에 의존하지 않고 추상체인 인터페이스에만 의존하고있습니다(DIP).

기능을 변경 확장할 때 기존 클라이언트 코드를 변경할 필요가 없어졌습니다(OCP).

게다가 구성이라는 역할을 좀더 분리함으로서 SRP또한 좀 더 만족시킬 수 있었습니다.

아 물론 구성 영역의 코드가 변경되긴 하지만 클라이언트 코드는 전혀 변경이 없었죠.

5. IoC, DI, 그리고 컨테이너

제어의 역전 IoC (Inversion of Controll)

일반적으로 프로그램은 구현 객체가 직접 필요한 객체를 생성하고, 연결하고, 실행했습니다.

구현 객체가 프로그램의 제어 흐름을 스스로 조정한 것이지요.

반면 AppConfig를 도입한 이후로는 구현 객체는 자신의 로직만 수행할뿐 이제 프로그램의 흐름은 AppConfig가 가져갑니다.

예를들어 OrderServiceImpl은 인터페이스를 호출하지만 어떤 구현 객체들이 실행될지 모릅니다.

이처럼 프로그램의 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것을 제어의 역전 (IoC)라고 합니다.

프레임워크 vs 라이브러리

프레임워크와 라이브러리를 Ioc를 이용해 구분할 수 있습니다.

프레임워크는 프레임워크가 개발자가 작성한 코드의 흐름을 제어하고 대신 실행하고있습니다. 제어의 주체가 개발자에서 프레임워크로 넘어간 것이지요.

반면에 라이브러리는 프로그램 제어의 주체를 개발자가 쥐고있습니다.
라이브러리는 단지 도구처럼 개발자가 필요할 때 생성되고 호출됩니다.

비유하자면 성만들기 레고 패키지와 같다할 수 있습니다. 세세한 부분은 우리가 만들 수 있지만 결국에는 레고 패키지가 고안된 모양의 성이 만들어 질 것입니다.

건설의 제어를 패키지가 가지고있는 것이지요.

반만 라이브러리는 우리가 성을 만들 때 감옥 레고 패키지, 마굿간 레고 패키지 등등을 구매해서 성을 완성시키는 것과 같습니다.

감옥 모양은 패키지 대로 갈지언정 그 감옥을 어디다 어떤식으로 둘 지 우리가 결정할 수 있습니다.

건설의 제어를 우리가 가지고있는 것이지요.

선생님이 해주신 비유가 아니라 적절할지는 잘 모르겠습니다만

핵심은 IoC! 제어권을 누가 가지고있느냐!

의존관계 주입 DI (Dependency Injection)

OrderServiceImpl은 DiscountPolicy인터페이스에 의존합니다.

실제 어떤 구현객체가 오게될지 모릅니다.

따라서 의존관계를 정리 할 때 정적 클래스 의존 관계와, 동적 객체(인스턴스) 의존 관계 둘을 분리해서 생각해야 합니다.

정적인 클래스 의존관계

딱 생각할 수 있는 클래스 다이어그램.

애플리케이션을 실행하지 않아도 분석할 수 있습니다. 단 어떤 구현 객체가 주입될지는 알 수 없습니다.

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

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

의존관계 주입을 통해 정적인 클래스 의존관계의 변경없이, 공적인 객체 인스턴스 의존관계를 쉽게 변경할 수 있습니다.

IoC 컨터이너, DI 컨테이너

AppConfig처럼 객체를 생성하고, 의존관계를 연결하는 것을 IoC 컨테이너 혹은 DI 컨테이너라고합니다.

6. 스프링으로 전환하기

지금까지 순수 자바 코드만으로 DI를 구현했습니다.

이제 드디어 스프링을 사용해 이를 구현해보도록 하겠습니다.

package hello.core;

...

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppConfig {

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

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

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

    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();
    }
}
  • @Configuration:
    AppConfig에 설정을 구성한다는 어노테이션.

  • @Bean:
    메소드의 반환값을 스프링 컨테이너에 스프링 빈으로 등록한다.

설명은 뒤에 하고 우선 테스트 케이스를 수정해 스프링 컨테이너를 직접 사용해보겠습니다.

class MemberServiceImplTest {

    MemberService memberService;

    @BeforeEach
    void before() {
        //AppConfig appConfig = new AppConfig();
        //memberService = appConfig.memberService();
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
        memberService = applicationContext.getBean("memberService", MemberService.class);
    }

   ...
   
}
class OrderServiceImplTest {

    MemberService memberService;
    OrderService orderService;

    @BeforeEach
    public void beforeEach() {
    AppConfig appConfig = new AppConfig();
        //memberService = appConfig.memberService();
        //orderService = appConfig.orderService();
        AppConfig appConfig = new AppConfig();
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
        memberService = applicationContext.getBean("memberService", MemberService.class);
        orderService = applicationContext.getBean("orderService", OrderService.class);
    }

    ...

}

스프링 컨테이너

ApplicationContext를 스프링 컨테이너라고 합니다.

기존에는 AppConfig를 통해 직접 객체를 생성하고 DI를 했지만 스프링에서는 스프링 컨테이너를 통해 하게됩니다.

스프링 컨테이너는 @Configuration이 붙은 클래스를 설정(구성) 정보로 사용합니다.

이 클래스에서 @Bean 어노테이션이 붙은 메서드를 모두 호출해 반환된 객체를 스프링 컨테이너에 등록합니다.

(빈의 이름은 메소드 명을 사용합니다. @Bean(name = "hello")로 바꿀 수 있다.)

이렇게 등록된 객체들을 이라고 합니다.

AppConfig에서 필요한 객체를 직접 조회한 것처럼 이제 스프링 컨테이너에서 applicationContext.getBean() 메서드를 사용해서 찾을 수 있습니다.


원리가 비슷합니다.

코드만 더 많아지고 개념만 더 어려워진것 같은데.

스프링 컨테이너 이거 좋은거 맞나? 생각하실 수도 있으실거에요.

다음 포스트에서 스프링 컨테이너를 사용하면 어떠한 장점이 있는지 확인해보도록 하겠습니다.

끝!

profile
좋은 사람, 좋은 개발자 (되는중.. :D)

0개의 댓글