[SpringBoot] [3] 3. 스프링 핵심 원리 이해 2 - 객체 지향 원리 적용 (1)

윤경·2021년 8월 16일
1

Spring Boot

목록 보기
22/79
post-thumbnail

1️⃣ 새로운 할인 정책 개발

새로운 할인 정책을 확장해보자.

👽 악덕 기획자: 서비스 오픈 직전에 할인 정책을 지금처럼 고정 금액 할인이 아니라 좀 더 합리적인 주문 금액당 할인하는 정률% 할인으로 변경하고 싶어요. 예를 들어서 기존 정책은 VIP가 10000원을 주문하든 20000원을 주문하든 항상 1000원을 할인했는데, 이번에 새로 나온 정책은 10%로 지정해두면 고객이 10000원 주문시 1000원을 할인해주고, 20000원 주문시에 2000원을 할인해주는 거예요!^^

😺 순진한 개발자(나): 아니, 제가 처음부터 고정 금액 할인은 아니라고 했잖아요,,

👽 악덕 기획자: 순개씨, 애자일 소프트웨어 개발 선언 몰라요? “계획을 따르기보다 변화에 대응하기를” ;;

😺 순진한 개발자(나): ㅠㅠ..(하지만 유연한 설계가 가능하도록 객체 지향 설계 원칙을 준수했지 ㅋ ㅋ)

순진한 개발자(나)가 정말 객체 지향 설계 원칙을 잘 준수했나?
이번에는 주문한 금액의 %를 할인해주는 새로운 정률 할인 정책을 추가해보자.

✔️ discount 패키지 밑에 RateDiscountPolicy.java 생성

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

}

📌 (단축키) command + shift + t 이대로 테스트 생성하면 이렇게 자동 생성됨.

✔️ 생성된 RateDiscountPolicyTest.java

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() {  // vip라면 이라는 뜻
        // given
        Member member = new Member(1L, "memberVIP", Grade.VIP);

        // when
        int discount = discountPolicy.discount(member, 10000);

        // then
        // Assertions은 assertj 로 import 되어야 함.
        Assertions.assertThat(discount).isEqualTo(1000);
    }

    // 성공 테스트도 중요하지만 실패 테스트도 꼭! 만들어야 함.
    @Test
    @DisplayName("VIP가 아니면 할인이 적용되지 않아야 한다.")
    void vip_x() {  // vip가 아니라면 이라는 뜻
        // given
        Member member = new Member(2L, "memberBASIC", Grade.BASIC);

        // when
        int discount = discountPolicy.discount(member, 10000);

        // then
        Assertions.assertThat(discount).isEqualTo(1000);
        // Assertions.assertThat(discount).isEqualTo(0);
    }

}

⬇️ 이렇게 실행시키면 vip가 아닌 경우에도 1000인지 검사하고 있으므로 아래와 같이 에러가 남.

⬇️ 다시 주석 처리를 isEqualTo(0); 을 풀고 isEqualTo(1000);을 주석처리로 바꾸어주면 정상 작동하는 것을 볼 수 있다.

📌 그리고 참고로 Assertions는 command + enter로 static으로 바꾸어주는 것이 좋다.

⬇️ 이렇게 하면 Assertions.assertThatassertThat이 됨.


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

조금 전에 추가했던 정률 할인을 적용해보자!

✔️ 할인 정책을 변경하기 위해서는 클라이언트OrderserviceImpl.java를 수정

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

😧: 우리는 지금 어떤 문제점이 있을까?

  • 우리는 역할과 구현을 충실하게 분리했나? ➡️ ㅇㅇ 충실해
  • 다형성도 활용하고, 인터페이스와 구현 객체를 분리했나? ➡️ ㅇㅇ 했어
  • OCP, DIP 같은 객체지향 설계 원칙을 충실히 준수했어? ➡️ 아니 그렇게 보이지만 사실은 아니야

(DIP) 🤔: 주문서비스 클라이언트 (OrderServiceImpl)는 DiscountPolicy 인터페이스에 의존하며 DIP를 지킨 것 같은데? ➡️ 클래스 의존관계를 한 번 봐봐. 추상(인터페이스) 뿐만 아니라 구체(구현) 클래스에도 의존하고 있잖아.

  • 추상(인터페이스) 의존: DiscountPolicy
  • 구체(구현) 클래스: FixDiscountPolicy, RateDiscountPolicy

(OCP) 🤔: 변경하지 않고 확장할 수 있다고 하던데? ➡️ 지금 코드는 기능을 확장하여 변경하면 클라이언트 코드에 영향을 준다. 이는 OCP를 위반한 것!

😧: 그렇다면 왜 클라이언트 코드를 변경해야 하는거지?

우리가 기대한 의존관계

실제 의존관계


😧: 흠,, 이 문제를 어떻게 해결하지?

DIP 위반 ➡️ 추상에만 의존하도록 하자! (인터페이스에만 의존)
DIP를 위반하지 않도록 (인터페이스에만 의존하도록) 의존관계를 변경해주자.

✔️ 그렇다면 코드를 이렇게 변경해볼까? OrderServiceImpl.java

public class OrderServiceImpl implements OrderService {
      //private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
      private DiscountPolicy discountPolicy;
}

(관련 import들도 다 지워보자!)

다시 테스트 케이스를 돌려본다면??? ➡️ 당!연!히! 안되지!! NPE(null pointer exception) 발생 (당연하지 구현체가 없는데 -`д´-)

😧: 그럼 어떻게 해결하라는건데 ;;

➡️ 누군가 클라이언트인 OrderServiceImpl에 DiscountPolicy의 구현 객체를 대신 생성하고 주입해주면 되지. (다음 챕터에서 알아보자)


3️⃣ 관심사의 분리

오늘은 중요한 이야기니까 집중 !!
???: 집중좀해 제바알!!! 👁🥄👁

애플리케이션 = 공연, 인터페이스 = 배우(역할)
이때, 역할에 맞는 배우는 누가 캐스팅하지??

우리가 짰던 코드는 로미오를 맡은 배우가 직접 줄리엣을 맡을 배우를 섭외해오는 형식이었다. 이는 너무 다양한 책임을 맡고 있는 것이다.

관심사를 분리해야 한다.

  • 배우는 배역 수행에만 집중한다.
  • 로미오를 맡은 배우는 줄리엣을 어떤 배우가 맡든 똑같이 공연을 수행해야 한다.
  • 공연 구성, 담당 배우 섭외, 배우들 역할 지정 등 책임을 담당하는 별도의 공연 기획자가 필요하다 !!

➡️ 공연 기획자를 만들고, 배우와 공연 기획자의 책임을 확실히 분리해보자.

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

package hello.core;

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

// 이제 AppConfig가 "배우(구현체)를 섭외해오는 역할"을 수행
public class AppConfig {

    public MemberService memberService() {
        // 이렇게하면 얘가 생성자를 주입하라고 함. (MemberServiceImpl.java에 생성자를 주입해줌)
        return new MemberServiceImpl(new MemoryMemberReposiotry());
    }
    
    public OrderService orderService() {
        return new OrderServiceImpl(new MemoryMemberReposiotry(), new FixDiscountPolicy());
    }
}
  • AppConfig 는 애플리케이션의 실제 동작에 필요한 구현 객체 생성
    ➡️ MemberServiceImpl / MemoryMemberRepository / OrderServiceImpl / FixDiscountPolicy

  • AppConfig는 생성한 객체 인스턴스의 참조 (레퍼런스)를 생성자를 통해 주입(연결)
    ➡️ MemberServiceImpl → MemoryMemberRepository
    ➡️ OrderServiceImpl → MemoryMemberRepository, FixDiscountPolicy

여기까지 코드를 완료했다면 컴파일 오류가 발생했을 것이다.
이는 각 클래스에 생성자가 없기 때문!

이제 생성자를 만들어보자.

📌(참고) final이 되어있으면 기본으로 할당을 하던 생성자가 있던 해야함

✔️ 각 클래스에 생성자 만들기. MemberServiceImpl.java

package hello.core.member;

public class MemberServiceImpl implements MemberService {

//    // 추상화에도 의존하지만 실제 할당하는 부분은 (new MemoryMemberRepository()) 구현체를 의존하고 있음. (DIP 위반!!)
//    private final MemberRepository memberRepository = new MemoryMemberReposiotry();

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

⬆️

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

⬆️

  • 객체의 생성과 연결은 AppConfig가 담당
  • DIP 완성: MemberServiceImpl은 MemberRepository인 추상에만 의존하면 된다. 이제 구체 클래스를 몰라도 된다.
  • 관심사의 분리: 객체를 생성, 연결하는 역할과 실행하는 역할이 명확히 분리되었다.

⬆️

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

✔️ OrderServiceImpl.java

package hello.core.order;

import hello.core.discount.DiscountPolicy;
import hello.core.member.Member;
import hello.core.member.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;
    }

    @Override
    public Order createOrder(Long memberId, String itemName, int itemPrice) {
        Member member = memberRepository.findById(memberId);

        // 할인 정책은 오직 discountPolicy에게 맡기고 있기 때문에 설계가 잘 된 케이스
        int discountPrice = discountPolicy.discount(member, itemPrice);

        return new Order(memberId, itemName, itemPrice, discountPrice);
    }
}

⬆️

  • 설계를 변경했기 때문에 OrderServiceImpl은 FixDiscountPolicy를 의존하지 않는다.
  • 단지, DiscountPolicy 인터페이스만 의존한다.
  • OrderServiceImpl 입장에서 생성자를 통해 어떤 구현 객체가 주입될지는 알 수 없다. (맡아야 하는 일이 줄어들었음.)
  • 오직 외부 즉, AppConfig에서 결정된다.
  • OrderServiceImpl은 이제부터 실행에만 집중하면 된다.
  • OrderServiceImpl에는 MemoryMemberRepository, FixDiscountPolicy 객체의 의존관계가 주입된다.

✔️ MemberApp.java

package hello.core;

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

public class MemberApp {

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

        // 아래 상태에서 command + option + v 단축키 이용하면 2줄 아래의 내용 생성
        // new Member(1L, "memberA", Grade.VIP);    // L은 Long 타입을 의미
        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());

    }
}

✔️ OrderApp.java

package hello.core;

import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.order.Order;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;

public class OrderApp {
    // psvm + tab
    public static void main(String[] args) {
        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());

    }
}

✔️ 테스트 코드 오류 수정 MemberServiceTest

package hello.core.member;

import hello.core.AppConfig;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.util.Assert;

public class MemberServiceTest {

    MemberService memberService;
    // memberService와 @BeforeEach 코드 추가
    @BeforeEach
    public void beforeEach() {
        AppConfig appConfig = new AppConfig();
        memberService = appConfig.memberService();
    }
    
    @Test
    void join() {
        // given: 이러이러한게
        Member member = new Member(1L, "memberA", Grade.VIP);

        // when: 주어졌을 때
        memberService.join(member);
        Member findMember = memberService.findMember(1L);

        // then: 이렇게 된다.
        Assertions.assertThat(member).isEqualTo(findMember);

    }
}

✔️ OrderServiceTest

package hello.core.order;

import hello.core.AppConfig;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class OrderServiceTest {

    MemberService memberService;
    OrderService orderService;
	// member, orderService와 @BeforeEach 코드 추가
    @BeforeEach
    public void beforeEach() {
        AppConfig appConfig = new AppConfig();
        memberService = appConfig.memberService();
        orderService = appConfig.orderService();
    }

    @Test
    void createOrder() {
        // long을 써도 되나 이는 null을 쓸 수 없음.
        Long memberId = 1L;
        Member member = new Member(memberId, "memberA", Grade.VIP);
        memberService.join(member);

        Order order = orderService.createOrder(memberId, "itemA", 10000);
        // VIP니까 1000원이 맞는지 검증
        Assertions.assertThat(order.getDiscountPrice()).isEqualTo(1000);

    }

}

📌 테스트 코드에서 @BeforeEach는 각 테스트를 실행하기 전에 호출됨.

정리

  • AppConfig를 통해 관심사를 확실하게 분리했다.
  • 배역과 배우에 대입해서 생각해보자.
  • AppConfig = 공연 기획자
  • AppConfig는 구체 클래스를 선택, 배역에 맞는 배우를 캐스팅. 애플리케이션이 어떻게 동작할지 전체 구성을 책임진다.
  • 이제 선택된 배우들은 담당 기능을 실행하는 책임만 가지면 된다.
  • OrderServiceImpl은 기능을 실행만 하면 된다.

4️⃣ AppConfig 리팩터링

현재 AppConfig를 보면 중복이 있고, 역할에 따른 구현이 잘 보이지 않는다.

우리가 기대하는 그림

(설계에 대한 그림이 AppConfig에 그대로 나올 수 있어야 함.)

✔️ 리팩터링 AppConfig.java (중복 제거, 역할에 따른 구현 보이기 전)

package hello.core;

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

// 이제 AppConfig가 "배우(구현체)를 섭외해오는 역할"을 수행
public class AppConfig {

    public MemberService memberService() {
        // 이렇게하면 얘가 생성자를 주입하라고 함. (MemberServiceImpl.java에 생성자를 주입해줌)
        return new MemberServiceImpl(new MemoryMemberReposiotry());
    }

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

✔️ 리팩터링 AppConfig.java

package hello.core;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberReposiotry;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;

// 이제 AppConfig가 "배우(구현체)를 섭외해오는 역할"을 수행
public class AppConfig {

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

    private MemberRepository memberRepository() {
        return new MemoryMemberReposiotry();
    }

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

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

⬆️ new MemoryMemberRepository() 이 부분이 중복이었는데 제거되었다.
이제 MemoryMemberRepository를 다른 구현체로 변경할 때 한 부분만 변경할 수 있게 되었다.

AppConfig 를 보면 역할과 구현 클래스가 한 눈에 들어온다. 애플리케이션 전체 구성이 어떻게 되어있는지 빠르게 파악 가능 !! ‧˚₊̥( ⁰̷̴͈꒨⁰̷̴͈)‧˚₊*̥


profile
개발 바보 이사 중

0개의 댓글