쌩로그

스프링 핵심원리 기본편 - Ch03. 스프링 핵심 원리 이해2 - 객체 지향 원리 적용 본문

Spring/Spring & Spring Boot

스프링 핵심원리 기본편 - Ch03. 스프링 핵심 원리 이해2 - 객체 지향 원리 적용

.쌩수. 2023. 12. 15. 11:21
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. 새로운 할인 정책 개발
      2-2. 새로운 할인 정책 적용과 문제점
      2-3. 관심사의 분리
      2-4. AppConfig 리팩터링
      2-5. 새로운 구조와 할인 정책 적용
      2-6. 좋은 객체 지향 설계의 5가지 원칙의 적용
      2-7. IoC, DI, 그리고 컨테이너
      2-8. 스프링으로 전환하기
  3. 요약

1. 포스팅 개요

인프런에서 영한님의 스프링 핵심 원리 기본편 Section03 스프링 핵심 원리 이해2 - 객체 지향 원리 적용를 학습하며 정리한 포스팅이다.

| 참고 이전 포스팅

2. 본론

2-1. 새로운 할인 정책 개발

이전 내용에서 할인 정책을 정액 할인을 적용했지만, 비율에 따라 할인되는 정률 할인 정책으로 변경되었다고 하자.

이미 정액 할인으로 적용하도록 했지만, 유연한 설계가 되도록 해놓았기 때문에 다시 적용하면 된다.

정률 할인 정책 클래스는 다음과 같다.

public class RateDiscountPolicy implements DiscountPolicy {

    private int discountPercent = 10;

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

다음은 테스트 코드이다.

class RateDiscountPolicyTest {

    RateDiscountPolicy discountPolicy = new RateDiscountPolicy();

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

        // than
        assertThat(discount).isEqualTo(1000);
    }

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

        // than
        assertThat(discount).isEqualTo(0);
    }
}

결과는 다음과 같다.

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

정률 할인 정책을 적용하려면 OrderServiceImple 클래스에서 다음과 같이 코드를 수정해야한다.

//    private final DiscountPolicy discountPolicy = new FixDiscountPolicy(); // 기존 코드
private final DiscountPolicy discountPolicy = new RateDiscountPolicy();   // <- 수정된 코드

그런데 이는 OCP(개방-폐쇄 원칙), DIP(의존 관게 역전 법칙)에 위배된다.

DIP 위반

  • 클래스 의존관계를 분석해보면, 추상(인터페이스)뿐만 아니라, 구체(구현) 클래스에도 의존하고 있다.

이처럼 기대했던 것과는 다른 의존관계다.

OCP 위반

  • 클라이언트의 코드도 변경해줘야한다.

따라서 문제를 해결하려면, 위반된 법칙이 위반이 되지않도록 해야한다.
즉 DIP, OCP의 문제를 해결해야한다.

이는 추상에만 의존(인터페이스에만 의존)하도록 변경하면 해결된다.

그래서 코드를 다음과 같이 변경했다.

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

그러나 NPE(NullPointerException)이 나온다.
(테스트 코드에서 테스트를 실행시켜주면 확인 가능하다.)

이 문제를 해결하려면 3인칭의 누군가가 OrderServiceImpl에 DiscountPolicy의 구현 객체를 대신 생성하고 주입해줘야 한다.

2-3. 관심사의 분리

공연 중에서 역할을 하는 배우를 선택하는 것은 누구일까?
배우가 선택하는 것이 아니라, 공연 기획자가 선택하고 섭외한다

마찬가지로 애플리케이션에서 인터페이스(역할)의 구현 객체를 생성하고 연결하는 책임은 애플리케이션 구성 정보 설정(Config)클래스가 한다.

현재까지의 코드는 마치 로미오와 줄리엣이라는 극을 기획한다고 할 때 로미오 역할을 하는 배우가 줄리엣 역할을 할 배우를 선택하는 것과 같은 것이다.

배우는 배역을 수행하는 것에만 집중하면되고, 로미오 배우는 줄리엣 역할을 하는 어떤 배우가 선택되더라도 똑같이 로미오 배역을 할 수 있어야 한다.
공연을 구성하고, 배우를 섭외하는 역할을 공연 기획자의 역할이지 배우의 역할이 아니다.

마찬가지로 애플리케이션도 구현 객체를 생성하고 연결하는 책임은 애플리케이션 구성을 설정하는 클래스(Config)가 하는 것이지, 클라이언트 역할 클래스가 하는 것이 아니다.

AppConfig 클래스이다.

public class AppConfig {

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

}

MemberServiceImple 클래스

// 기존
public class MemberServiceImpl implements MemberService {
  private final MemberRepository memberRepository = new MemoryMemberRepository();

  ...

}

// 변경된 코드
public class MemberServiceImpl implements MemberService {
    private final MemberRepository memberRepository;

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

    ...
}

이렇게 변경함으로써 MemberServiceImpl 클래스는 추상화에 의존하게 된다. (DIP를 지킨다.)
그리고 생성자를 통해서 객체가 생성되는 것을 생성자 주입이라고 한다.

OrderService도 AppConfig에 등록해보자.

AppConfig에 다음 코드를 추가한다.

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

OrderServiceImpl 클래스


// 기존
public class OrderServiceImpl implements OrderService {

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

    ...

}

// 변경된 코드

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

    ...

}

이처럼 구체 클래스가 아니라 인터페이스에 의존하게 됨으로 위반했던 DIP 문제를 해결하게 된다.
의존 관계에 대한 고민은 외부에 맡기고, 실행에만 집중하면된다.
클래스 다이어그램은 다음과 같다.

  • 객체의 생성과 연결AppConfig가 담당한다.
  • DIP가 완성되었다.
  • 관심사가 분리되었다. 객체를 생성하고 연결하는 역할실행하는 역할이 명확히 분리되었다.

다음은 회원 객체 인스턴스 다이어그램이다.

  • appConfig 객체가 memoryMemberRepository 객체를 생성하고 그 참조값을 memberServiceImpl 을
    생성하면서 생성자로 전달한다.
  • 클라이언트인 memberServiceImpl 입장에서 보면 의존관계를 마치 외부에서 주입하는 것 같다고 하여 이를 DI(Dependency Injection)라고 하는데 우리말로는 의존관계 주입 또는 의존성 주입이라 한다

이제 이를 실행해보면 다음과 같다.

MemberApp 클래스

public class MemberApp {

    public static void main(String[] args) {

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

        // 수정
        AppConfig appConfig = new AppConfig();
        MemberService memberService = appConfig.memberService();
        Member member = new Member(1L, "memberA", Grade.VIP);
        memberService.join(member);

        Member findMember = memberService.findMember(1L);
        System.out.println("new member = " + member.getName());
        System.out.println("find Member = " + findMember.getName());

    }
}

// 실행 결과
new member = memberA
find Member = memberA

OrderApp 클래스

public class OrderApp {
    public static void main(String[] args) {
        // 기존
        // MemberService memberService = new MemberServiceImpl();
        // OrderService orderService = new OrderServiceImpl();

        // 수정
        AppConfig appConfig = new AppConfig();
        MemberService memberService = appConfig.memberService();
        OrderService orderService = appConfig.orderService();

        Long memberId = 1L;
        Member member = new Member(memberId, "memberA", Grade.VIP);
        memberService.join(member);

        Order order = orderService.createOrder(memberId, "itemA", 10000);

        System.out.println("order = " + order);
        System.out.println("order.calculatePrice() = " + order.calculatePrice());
    }
}

// 실행 결과
order = Order{memberId=1, itemName='itemA', itemPrice=10000, discountPrice=1000}
order.calculatePrice() = 9000

이처럼 AppConfig 클래스로 구체 클래스를 정해주게 된다.
테스트 코드도 수정해주면 다음과 같다.

// MemberServiceTest
public class MemberServiceTest {

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

    // 변경된 코드
    MemberService memberService;

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

    ...

}

// OrderServiceTest
public class OrderServiceTest {

    // 기존
    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();
    }

    ...
}

테스트 결과는 다음과 같다.

2-4. AppConfig 리팩터링

기존 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());
    }

    private static MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

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

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

이렇게 놔두면 추후에 Memory로 저장하는 기능을 JDBC를 이용한 DB로 저장하려고할 때, memberRepository() 메서드의 코드만 변경하면 된다.

할인 정책도 정률에서 정액, 혹은 정액에서 정률로 변경할 때는 discountPolicy() 메소드의 코드만 변경하면 된다.

정리하자면, new MemoryMemberRepository()에 대한 중복이 제거되어, 다른 구현체로 변경할 때 한 부분만 변경하면 된다. 또한 AppConfig에서 역할과 구현 클래스 정보가 한 눈에 들어온다.
(메모리에 저장하고, 정률 정책을 사용한다는 정보 등)

다음은 정액 할인 정책에서 정률 할인 정책으로 변경해본다.

2-5. 새로운 구조와 할인 정책 적용

정액 할인 정책에서 정률 정책으로 변경할 것이다.
원래는 클라이언트에서 필요한 객체를 생성해주는 코드를 수정해야 했지만, AppConfig를 작성함으로 AppConfig만 수정해주면 손쉽게 할인 정책을 변경할 수 있다.

AppConfig 클래스에서 다음과 같이 수정해주면 된다.

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

다른 코드의 수정없이 OrderApp이 실행된다.

이처럼 AppConfig만 변경함으로써 OrderServiceImpl을 포함한 다른 어떤 코드도 변경할 필요가 없다.
AppConfig는 공연 이야기에서의 공연기획자라고 생각하면 된다.

이처럼 AppConfig만 변경함으로써 애플리케이션의 기능이 확장된다.

2-6. 좋은 객체 지향 설계의 5가지 원칙의 적용

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

기존의 클라이언트 객체(OrderServiceImpl, MemberServiceImpl 등)들은 직접 구현 객체를 생성하고, 연결하고, 실행하는 다양한 책임을 가졌다.
그런데 구현 객체를 생성하고, 연결하는 책임은 AppConfig에 맡김으로 관심사를 분리시켜 SRP(단일 책임 원칙)를 따르게 되었다.
클라이언트 객체는 실행하는 책임만 지게 되었다.

DIP(의존관계 역전 법칙) - "프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다. 의존성 주입은 이 원칙을 따르는 방법 중 하나다."

클라이언트 코드들이 AppConfig가 등장함으로 추상화 인터페이스에만 의존하도록 코드를 변경했다.

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

AppConfig가 정책 할인에 대한 의존 관계를 FixDiscountPolicy -> RateDiscountPolicy로 변경해서 클라이언트 코드에 주입하므로 클라이언트 코드는 변경하지 않아도 되게 되었다.
이처럼 소프트웨어 요소를 새롭게 확장해도 사용 영역의 변경은 닫혀있다.

2-7. IoC, DI, 그리고 컨테이너

참고로 이런 용어는 스프링에서만 한정된 용어가 아니다.

제어의 역전(IoC - Inversion of Control)

  • 프로그램 대한 제어권이 프로그래머가 아니라, 프레임워크에 있는 것이다.
  • 변경 전 기존 프로그램은 클라이언트 구현 객체가 스스로 필요한 서버 구현객체를 생성하고, 연결하고, 실행했다. 즉 구현 객체가 프로그램의 제어 흐름을 스스로 조종했다.
  • 반면, AppConfig가 등장한 이후에 구현 객체는 자신의 로직을 실행하는 역할만 담당한다. 프로그램의 제어 흐름은 이제 AppConfig가 가져가는 것이다.
  • 이처럼 프로그램의 제어 흐름을 구현 클래스가 직접 제어하는 것이 아니라 외부(AppConfig)에서 관리하는 것을 제어의 역전(IoC)라고 한다.

프레임워크 vs 라이브러리

  • 레임워크가 내가 작성한 코드를 제어하고, 대신 실행하면 그것은 프레임워크다.(JUnit)
    • JUnit의 경우 개발자는 로직만 개발했지, 실행과 제어권은 JUnit이 가져갔다.
    • 예를 들어 BeforeEach를 먼저 실행하고, Test를 수행하는 라이프사이클 중에서 내가 개발한 코드를 적절한 시점에 실행한다.
  • 내가 작성한 코드가 직접 제어의 흐름을 담당한다면 그것은 프레임워크가 아니라 라이브러리

의존관계 주입 DI(Dependency Injection)

  • OrderServiceImple은 DiscountPolicy 인터페이스에 의존한다. 실제 어떤 구현체가 사용될지는 모른다.
  • 의존관계는 정적인 클래스 의존관계와, 실행 시점에 결정되는 동적인 객체(인스턴스) 의존 관계를 분리해서 생각해야 한다.

정적인 클래스 의존 관게

  • 정적인 클래스 의존 관계는 import 코드만 보고 쉽게 판단할 수 있다.
  • 정적인 클래스 의존 관계는 애플리케이션을 실행하지 않아도 분석가능하다.

현재 클래스 다이어그램 정적 의존 관계는 다음과 같다.

참고로 이 그림을 보려면, 인텔리제이에서 패키지 우클릭 - Diagrams - show Diagram... - Java Classes
그리고 상단의 링크 표시(Show Dependencies)를 누르면 된다.

다만 정적 클래스 의존 관계에서는 구현체로 어떤 구현체가 들어올지 알 수 없다.

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

애플리케이션 실행 시점에 실제 생성된 객체 인스턴스의 참조가 연결된 의존 관계다.

  • 애플리케이션 실행 시점(런타임)외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서
    클라이언트와 서버의 실제 의존관계가 연결 되는 것의존관계 주입이라 한다.
  • 객체 인스턴스를 생성하고, 그 참조값을 전달해서 연결된다.
  • 의존관계 주입을 사용하면 클라이언트 코드를 변경하지 않고, 클라이언트가 호출하는 대상의 타입 인스턴스를 변경할 수 있다.
  • 의존관계 주입을 사용하면 정적인 클래스 의존관계를 변경하지 않고, 동적인 객체 인스턴스 의존관계를 쉽게 변경할 수 있다.
    • 참고로 정적인 클래스 의존관계를 변경하지 않는다는 말은 애플리케이션 코드(클라이언트 코드)를 변경하지 않는다는 것이다.

2-8. 스프링으로 전환하기

순수한 자바코드를 스프링으로 전환한다.

@Configuration // AppConfig에 추가한다.
public class AppConfig {

    @Bean // 추가
    public MemberService memberService() {...}

    @Bean // 추가
    public static MemoryMemberRepository memberRepository() {...}

    @Bean // 추가
    public OrderService orderService() {...}

    @Bean // 추가
    public DiscountPolicy discountPolicy() {...}

}

@Configuration : 애플리케이션의 구성 및 설정 정보를 등록한다.
@Ben : 메서드를 통해 생성되는 객체가 스프링 빈으로 등록된다.

  • 참고로 name 속성으로 이름을 변경할 수 있다.

실행 클래스를 스프링을 사용하는 버전으로 바꿔보자.

MemberApp클래스이다.

public class MemberApp {

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

        ApplicationContext applicationContext = new AnnotationConfigApplicationContex(AppConfig.class);
        // AnnotationConfigApplicationContext로 생성하는 이유는 @Configuration를 사용하여 구성 및 설정 정보를 등록하기 때문이다. 그리고 매개변수로는 사용할 Config 클래스를 인자로 넘겨준다.
        // AppConfig.class 내의 @Bean이 붙은 메서드의 반환 객체를 스프링 빈으로 등록해준다.
        MemberService memberService = applicationContext.getBean("memberService", MemberService.class);

        Member member = new Member(1L, "memberA", Grade.VIP);
        memberService.join(member);

        Member findMember = memberService.findMember(1L);
        System.out.println("new member = " + member.getName());
        System.out.println("find Member = " + findMember.getName());

    }
}

결과는 다음과 같다.

참고

스프링 부트 버전 3.1 이상이면 강의대로 로그가 출력되지 않을 것이다.
링크를 확인하면 된다.

다음은 OrderApp이다.

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

        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
        // AnnotationConfigApplicationContext로 생성하는 이유는 @Configuration를 사용하여 구성 및 설정 정보를 등록하기 때문이다. 그리고 매개변수로는 사용할 Config 클래스를 인자로 넘겨준다.
        // AppConfig.class 내의 @Bean이 붙은 메서드의 반환 객체를 스프링 빈으로 등록해준다.
        MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
        OrderService orderService = applicationContext.getBean("orderService", OrderService.class);

        Long memberId = 1L;
        Member member = new Member(memberId, "memberA", Grade.VIP);
        memberService.join(member);

        Order order = orderService.createOrder(memberId, "itemA", 10000);

        System.out.println("order = " + order);
        System.out.println("order.calculatePrice() = " + order.calculatePrice());
    }
}

결과는 다음과 같다.

스프링 컨테이너

  • ApplicationContext스프링 컨테이너라 한다.
  • 기존에는 개발자가 AppConfig를 사용해서 직접 객체를 생성하고 DI를 했지만, 이제부터는 스프링 컨테이너를 통해서 사용한다.
  • 스프링 컨테이너@Configuration이 붙은 AppConfig를 설정(구성) 정보로 사용한다. 여기서 @Bean이라 적힌 메서드를 모두 호출해서 반환된 객체를 스프링 컨테이너에 등록한다. 이렇게 스프링 컨테이너에 등록된 객체스프링 빈이라 한다.
  • 스프링 빈@Bean이 붙은 메서드의 명을 스프링 빈의 이름으로 사용한다. (예 : memberService ,orderService)
  • 이전에는 개발자가 필요한 객체를 AppConfig를 사용해서 직접 조회했지만, 이제부터는 스프링 컨테이너를 통해서 필요한 스프링 빈(객체)를 찾아야 한다. 스프링 빈은 applicationContext.getBean()메서드를 사용해서 찾을 수 있다.
  • 기존에는 개발자가 직접 자바코드로 모든 것을 했다면 이제부터는 스프링 컨테이너에 객체를 스프링 빈으로 등록하고, 스프링 컨테이너에서 스프링 빈을 찾아서 사용하도록 변경되었다.

3. 요약

기존에 요구사항이 변경될 때 의존하는 클라이언트 클래스마다 구현체를 직접 일일이 수정해주어야 했다.
따라서 OCP, DIP, SRP등의 객체지향 설계원칙을 위반했다.
하지만, 공연을 생각했을 때 공연기획자가 역할(추상, 인터페이스)에 대한 배우(구현채)를 섭외하고 초빙하듯이, AppConig 클래스를 통해서 인터페이스의 구현체를 선택하게함으로써 클라이언트 클래스는 인터페이스에만 의존하게되었다.
그리고 구현체를 변경할 때는 AppConfig 클래스만 수정해주면 된다.
클라이언트 클래스에서 했던 객체 생성, 연결을 AppConfig가 대신 해주게 된 것이다.
이로써 위반했던 객체지향 설계 원칙인 OCP, DIP, SRP 의 문제점이 해결되었다.

또한 이제 스프링을 활용해서 AppConfig에서 @Configuration애너테이션을 사용해서 구성 정보 클래스로 등록했다.
그리고 각 메서드마다 @Bean을 설정해주면 반환되는 객체를 스프링이 스프링 빈으로 등록한다.
그리고 ApplicationContext에 AppConfig를 등록해줘야 한다.
또한 getBean()메서드로 등록된 스프링 빈을 가져올 수 있다.

728x90
Comments