새로운 할인 정책을 확장해보자.
👽 악덕 기획자: 서비스 오픈 직전에 할인 정책을 지금처럼 고정 금액 할인이 아니라 좀 더 합리적인 주문 금액당 할인하는 정률% 할인으로 변경하고 싶어요. 예를 들어서 기존 정책은 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.assertThat이 assertThat이 됨.
조금 전에 추가했던 정률 할인을 적용해보자!
✔️ 할인 정책을 변경하기 위해서는 클라이언트인 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의 구현 객체를 대신 생성하고 주입해주면 되지. (다음 챕터에서 알아보자)
오늘은 중요한 이야기니까 집중 !!
???: 집중좀해 제바알!!! 👁🥄👁
애플리케이션 = 공연, 인터페이스 = 배우(역할)
이때, 역할에 맞는 배우는 누가 캐스팅하지??
우리가 짰던 코드는 로미오를 맡은 배우가 직접 줄리엣을 맡을 배우를 섭외해오는 형식이었다. 이는 너무 다양한 책임을 맡고 있는 것이다.
관심사를 분리해야 한다.
➡️ 공연 기획자를 만들고, 배우와 공연 기획자의 책임을 확실히 분리해보자.
✔️ 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);
}
}
⬆️
⬆️
⬆️
✔️ 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);
}
}
⬆️
✔️ 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에 그대로 나올 수 있어야 함.)
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());
}
}
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 를 보면 역할과 구현 클래스가 한 눈에 들어온다. 애플리케이션 전체 구성이 어떻게 되어있는지 빠르게 파악 가능 !! ‧˚₊̥( ⁰̷̴͈꒨⁰̷̴͈)‧˚₊*̥