애자일 소프트웨어 개발 선언
우리는 소프트웨어를 개발하고, 또 다른 사람의 개발을 도와주면서 소프트웨어 개발의 더 나은 방법들을 찾아가고있다. 이 작업을 통해 우리는 다음을 가치 있게 여기게 되었다:
공정과 도구보다 개인과 상호작용을
포괄적인 문서보다 작동하는 소프트웨어를
계약 협상보다 고객과의 협력을
계획을 따르기보다 변화에 대응하기를
가치 있게 여긴다. 이 말은, 왼쪽에 있는 것들도 가치가 있지만, 우리는 오른쪽에 있는 것들에 더 높은 가치를 둔다는 것이다.
객체지향적으로 리팩토링
요구사항의 변경
이전 노트에 이어서 새로운 할인 정책을 확장해보자.
악덕 기획자: 서비스 오픈 직전에 할인 정책을 지금처럼 고정 금액 할인이 아니라 좀 더 합리적인 주문 금액당 할인하는 정률(%) 할인으로 변경하고 싶어요. 예를 들어서 기존 정책은 VIP가 10000원을 주문하든 20000원을 주문하든 항상 1000원을 할인했는데, 이번에 새로 나온 정책은 10%로 지정해두면 고객이 10000원 주문시 1000원을 할인해주고, 20000원 주문시에 2000원을 할인해주는 거에요!
순진 개발자: 제가 처음부터 고정 금액 할인은 아니라고 했잖아요.
악덕 기획자: 애자일 소프트웨어 개발 선언 몰라요? “계획을 따르기보다 변화에 대응하기를”
순진 개발자: ... (하지만 난 유연한 설계가 가능하도록 객체지향 설계 원칙을 준수했지 후후)
정말 변화에 대응할 수 있도록 유연하게 설계가 되었을까?
리팩토링
변경된 할인 정책 구현체: 정률 할인 정책
package hello.core.discount;
import hello.core.member.Grade;
import hello.core.member.Member;
///Users/dohk/Dropbox/Spring/project/core/src/main/java/hello/core/discount/RateDiscountPolicy.java
public class RateDiscountPolicy implements DiscountPolicy{
private int discountRate = 10;
@Override
public int discount(Member member, int price) {
if (member.getGrade() == Grade.VIP)
return price * discountRate / 100;
return 0;
}
}
정률 할인 정책에 대한 테스트
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;
import static org.junit.jupiter.api.Assertions.*;
///Users/dohk/Dropbox/Spring/project/core/src/test/java/hello/core/discount/RateDiscountPolicyTest.java
class RateDiscountPolicyTest {
DiscountPolicy discountPolicy = new RateDiscountPolicy();
@Test
@DisplayName("VIP는 상품 금액의 10% 할인을 적용받아야 한다.")
void discount_OK() {
Member member = new Member(1L, "TEDD", Grade.VIP);
int discountPrice = discountPolicy.discount(member, 1500000);
Assertions.assertThat(discountPrice).isEqualTo(150000);
}
@Test
@DisplayName("일반 회원은 상품 금액에 대한 할인이 적용되지 않는다.")
void discount_fail() {
Member member = new Member(2L, "LEO", Grade.BASIC);
int discountPrice = discountPolicy.discount(member, 1500000);
Assertions.assertThat(discountPrice).isEqualTo(0);
}
}
@DisplayName은 테스트에 표시할 내용이며, 다음과 같이 나온다.
의존성 주입
하지만 지금까지의 개발에 따르면 이 새로운 할인 정책을 적용하기 위해서는 OrderServiceImplementation을 수정해야 한다:
@Override
public Order createOrder(Long memberId, String itemName, int itemPrice) {
Member member = memberRepository.findById(memberId);
int discountPrice = discountPolicy.discount(member, itemPrice);
return new Order(memberId, itemName, itemPrice, discountPrice);
}
즉, FixDiscountPolicy 부분을 RateDiscountPolicy로 수정해야만 하는 문제가 있다.
package hello.core.order;
import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.Member;
import hello.core.member.MemberRepository;
import hello.core.member.MemoryMemberRepository;
// /Users/dohyung/Dropbox/Spring/project/core/src/main/java/hello/core/order/OrderServiceImplementation.java
public class OrderServiceImplementation implements OrderService{
private final MemberRepository memberRepository = new MemoryMemberRepository();
private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
@Override
public Order createOrder(Long memberId, String itemName, int itemPrice) {
Member member = memberRepository.findById(memberId);
int discountPrice = discountPolicy.discount(member, itemPrice);
return new Order(memberId, itemName, itemPrice, discountPrice);
}
}
지금까지의 개발 상에 문제가 있는지를 살펴보면 다음과 같다.
- 역할과 구현을 충실하게 분리했나? → Yes
- DIP를 준수하지 않으면서 OCP도 자연스럽게 준수하지 못한 설계가 되었다.
- DIP: 주문서비스 클라이언트( OrderServiceImplementation )는 DiscountPolicy 인터페이스에 의존하면서 DIP를 지킨 것 같지만, DiscountPolicy 인터페이스의 구현체(FixDiscountPolicy 또는 RateDiscountPolicy)에도 동시에 의존하고 있다.OCP, DIP 등의 객체지향 설계 원칙을 충실히 준수했나? → No
- OCP: DIP를 위반하면 자연스럽게 OCP도 위반하게 되는데, 기존의 코드를 변경하지 않고서도 기능을 확장할 수 있어야 하지만, 지금의 코드는 기능 추가에 따른 코드 수정이 불가피하다. 따라서 정책 변경에 따라 아래와 같이 구현체를 바꿔야 하므로 코드를 수정해야 한다.
따라서, 인터페이스에만 의존하도록(DIP 원칙을 준수하도록) 변경해야 한다.
package hello.core.order;
import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.Member;
import hello.core.member.MemberRepository;
import hello.core.member.MemoryMemberRepository;
// /Users/dohyung/Dropbox/Spring/project/core/src/main/java/hello/core/order/OrderServiceImplementation.java
public class OrderServiceImplementation implements OrderService{
private final MemberRepository memberRepository = new MemoryMemberRepository();
private DiscountPolicy discountPolicy;
@Override
public Order createOrder(Long memberId, String itemName, int itemPrice) {
Member member = memberRepository.findById(memberId);
int discountPrice = discountPolicy.discount(member, itemPrice);
return new Order(memberId, itemName, itemPrice, discountPrice);
}
}
그럼 위와 같은 코드가 되는데, discountPolicy가 가지는 구현체가 없기 때문에 null이 된다. 따라서 discountPolicy.discount(…)에서 결국 NullPointerException이 뜬다. 즉, 이 문제를 해결하기 위해서는 DiscountPolicy 구현체를 대신 생성해주고 주입해 줄 방법이 필요하다. → 의존성 주입(Dependency Injection)
package hello.core;
import hello.core.discount.FixDiscountPolicy;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImplementation;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImplementation;
///Users/dohk/Dropbox/Spring/project/core/src/main/java/hello/core/AppConfig.java
public class AppConfig {
public MemberService memberService() {
return new MemberServiceImplementation(new MemoryMemberRepository());
}
public OrderService orderService() {
return new OrderServiceImplementation(new MemoryMemberRepository(), new FixDiscountPolicy());
}
}
이를 위해 AppConfig.java라는 이름으로 Config 객체를 만들었고, 이 객체를 전체 오케스트라의 지휘자처럼 사용하여 객체 간의 의존성을 주입시켜준다. 즉, 구체적인 구현체를 주입시켜준다.
package hello.core.member;
// /Users/dohk/Dropbox/Spring/project/core/src/main/java/hello/core/member/MemberServiceImplementation.java
public class MemberServiceImplementation implements MemberService{
private final MemberRepository memberRepository;
public MemberServiceImplementation(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public void join(Member member) {
memberRepository.save(member);
}
@Override
public Member findMember(Long id) {
return memberRepository.findById(id);
}
}
의존성 주입이 필요한 객체에서는 필요한(의존해야 할) 객체를 생성자를 통해 주입받는다. 이 때, 어떤 구현체가 들어올지는 구체적으로 알 수 없으며 알 필요도 없다.
package hello.core.order;
import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.Member;
import hello.core.member.MemberRepository;
import hello.core.member.MemoryMemberRepository;
// /Users/dohyung/Dropbox/Spring/project/core/src/main/java/hello/core/order/OrderServiceImplementation.java
public class OrderServiceImplementation implements OrderService{
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
public OrderServiceImplementation(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);
int discountPrice = discountPolicy.discount(member, itemPrice);
return new Order(memberId, itemName, itemPrice, discountPrice);
}
}
즉, 위와 같이 외부의 AppConfig가 객체의 생성과 연결을 담당한다. 이것이 의존성 주입이며, 이로써 DIP 원칙이 달성된다. → 관심사의 분리
서비스 객체 입장에서는 생성자를 통해 어떤 구현체가 들어올지에 대해 알 수 없으며, 알 필요도 없다. → 동적인 객체 인스턴스 의존 관계(정적인 객체 의존 관계에 대한 참고)
이제 Service 객체들은 각자 필요한 구현체는 외부에서 주입받으며, 오로지 인터페이스에만 의존한다.
이렇게 의존성 주입을 통해 객체들끼리의 의존관계가 설정된다.
AppConfig를 사용하면 위 그림과 같이 개념적으로, 사용 영역과 구성 영역으로 나누어진다. 구성 영역인 AppConfig는 일종의 기획자 또는 지휘자의 역할을 한다.
public OrderService orderService() {
return new OrderServiceImplementation(new MemoryMemberRepository(), new FixDiscountPolicy());
}
이제 나머지 수정이 필요한 코드들도 수정한다.
package hello.core;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImplementation;
import hello.core.order.Order;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImplementation;
// /Users/dohyung/Dropbox/Spring/project/core/src/main/java/hello/core/OrderApp.java
public class OrderApp {
public static void main(String[] args) {
AppConfig appConfig = new AppConfig();
MemberService memberService = appConfig.memberService();
OrderService orderService = appConfig.orderService();
Long memberId = 1L;
Member tedd = new Member(memberId, "TEDD", Grade.VIP);
memberService.join(tedd);
Order order = orderService.createOrder(memberId, "IPhone 15 Pro", 1500000);
System.out.println("order = " + order);
System.out.println("order.calculatePrice() = " + order.calculatePrice());
}
}
//order = Order{memberId=1, itemName='IPhone 15 Pro', itemPrice=1500000, discountPrice=1000}
//order.calculatePrice() = 1499000
package hello.core;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImplementation;
// /Users/dohk/Dropbox/Spring/project/core/src/main/java/hello/core/MemberApp.java
public class MemberApp {
public static void main(String[] args) {
AppConfig appConfig = new AppConfig();
MemberService memberService = appConfig.memberService();
Member tedd = new Member(1L, "TEDD", Grade.VIP);
memberService.join(tedd);
Member foundMember = memberService.findMember(tedd.getId());
System.out.println("tedd = " + tedd.getName());
System.out.println("foundMember = " + foundMember.getName());
}
}
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 static org.junit.jupiter.api.Assertions.*;
// /Users/dohk/Dropbox/Spring/project/core/src/test/java/hello/core/member/MemberServiceImplementationTest.java
class MemberServiceImplementationTest {
MemberService memberService;
@BeforeEach
void beforeEach() {
AppConfig appConfig = new AppConfig();
memberService = appConfig.memberService();
}
@Test
void join() {
// given
Member tedd = new Member(1L, "TEDD", Grade.VIP);
// when
memberService.join(tedd);
Member member = memberService.findMember(tedd.getId());
// then
Assertions.assertThat(tedd).isEqualTo(member);
}
}
package hello.core.order;
import hello.core.AppConfig;
import hello.core.member.*;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
// /Users/dohyung/Dropbox/Spring/project/core/src/test/java/hello/core/order/OrderServiceImplementationTest.java
class OrderServiceImplementationTest {
MemberService memberService;
OrderService orderService;
@BeforeEach
void beforeEach() {
AppConfig appConfig = new AppConfig();
memberService = appConfig.memberService();
orderService = appConfig.orderService();
}
@Test
void createOrder() {
Long memberId = 1L;
Member member = new Member(memberId, "TEDD", Grade.VIP);
memberService.join(member);
Order order = orderService.createOrder(memberId, "IPhone 15 Pro", 1500000);
Assertions.assertThat(order.getDiscountPrice()).isEqualTo(1000);
}
}
테스트 코드를 돌린 결과는 다음과 같다.
AppConfig에는 문제가 없을까?
- MemoryMemberRepository()를 중복해서 생성하고 있다. → MemoryMemberRepository가 아닌 다른 구현체를 써야할 땐 중복된 모든 코드를 변경해야 한다.
- 역할이 명확히 드러나지 않는다.
이 두 문제를 모두 해결하기 위해 다음과 같이 수정한다.
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.MemberServiceImplementation;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImplementation;
///Users/dohk/Dropbox/Spring/project/core/src/main/java/hello/core/AppConfig.java
public class AppConfig {
public MemberService memberService() {
return new MemberServiceImplementation(memberRepository());
}
public OrderService orderService() {
return new OrderServiceImplementation(memberRepository(), discountPolicy());
}
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
public DiscountPolicy discountPolicy() {
return new FixDiscountPolicy();
}
}
이에 따라 할인 정책을 바꾸고자 할 땐, 아래와 같이 AppConfig 이외의 다른 어떤 코드도 변경할 필요가 없어진다.
package hello.core;
import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImplementation;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImplementation;
///Users/dohk/Dropbox/Spring/project/core/src/main/java/hello/core/AppConfig.java
public class AppConfig {
public MemberService memberService() {
return new MemberServiceImplementation(memberRepository());
}
public OrderService orderService() {
return new OrderServiceImplementation(memberRepository(), discountPolicy());
}
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy();
}
}
위 코드의 변경 후, OrderApp.java 실행 시 다음과 같은 기존과 동일한 결과가 나온다.
order = Order{memberId=1, itemName='IPhone 15 Pro', itemPrice=1500000, discountPrice=150000}
order.calculatePrice() = 1350000
결론적으로, 리팩토링 된 위 코드들은 SOLID 중 3가지인 SRP, DIP, OCP 원칙을 준수하는 코드가 되었다.
또한, 의존성 주입 방식으로 객체들끼리의 의존관계를 설정함으로써 정적인 클래스 의존관계는 변경할 필요없이 동적인 클래스 의존관계를 변경할 수 있게 되었다. 즉 리팩토링을 했지만, 정적인 클래스 의존관계를 나타내는 클래스 다이어그램인 아래 그림에서 바뀐 부분은 하나도 없다.
스프링으로 리팩토링
이 섹션부터는 스프링을 사용하여 동일한 기능을 구현한다.
package hello.core;
import hello.core.discount.DiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImplementation;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImplementation;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
///Users/dohk/Dropbox/Spring/project/core/src/main/java/hello/core/AppConfig.java
@Configuration
public class AppConfig {
@Bean
public MemberService memberService() {
return new MemberServiceImplementation(memberRepository());
}
@Bean
public OrderService orderService() {
return new OrderServiceImplementation(memberRepository(), discountPolicy());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy();
}
}
AppConfig에서는 @Configuration과 @Bean을 이용하여 의존성 연결이 필요한 객체들을 스프링 빈으로 등록한다.
이제 스프링 컨테이너 객체인 ApplicationContext 객체를 만든다. 즉 이 객체는 스프링 컨테이너이기 때문에 스프링 빈을 등록하고, 필요할 땐 꺼내주는 역할을 한다.
package hello.core;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImplementation;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
// /Users/dohk/Dropbox/Spring/project/core/src/main/java/hello/core/MemberApp.java
public class MemberApp {
public static void main(String[] args) {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
Member tedd = new Member(1L, "TEDD", Grade.VIP);
memberService.join(tedd);
Member foundMember = memberService.findMember(tedd.getId());
System.out.println("tedd = " + tedd.getName());
System.out.println("foundMember = " + foundMember.getName());
}
}
// tedd = TEDD
// foundMember = TEDD
package hello.core;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImplementation;
import hello.core.order.Order;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImplementation;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
// /Users/dohyung/Dropbox/Spring/project/core/src/main/java/hello/core/OrderApp.java
public class OrderApp {
public static void main(String[] args) {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
OrderService orderService = applicationContext.getBean("orderService", OrderService.class);
Long memberId = 1L;
Member tedd = new Member(memberId, "TEDD", Grade.VIP);
memberService.join(tedd);
Order order = orderService.createOrder(memberId, "IPhone 15 Pro", 1500000);
System.out.println("order = " + order);
System.out.println("order.calculatePrice() = " + order.calculatePrice());
}
}
//order = Order{memberId=1, itemName='IPhone 15 Pro', itemPrice=1500000, discountPrice=150000}
//order.calculatePrice() = 1350000
또한, 이 객체는 인터페이스이기 때문에, 구현체로 어노테이션 기반으로 Config를 수행하는 객체인 AnnotationConfigApplicationContext 객체를 만든다. 그리고 이 객체의 파라미터로 AppConfig 클래스를 넣어준다.
이제 프로그램은 AppConfig로 직접 필요한 객체를 찾아오는 것이 아닌, ApplicationContext을 통해 찾는다.
필요한(의존해야 하는) 객체는 ApplicationContext 의 getBean 메서드로 가져올 수 있다. 두 개의 파라미터가 필요한데, 첫 번째 파라미터는 가져올 빈의 이름으로서, AppConfig에서 @Bean으로 등록한 메서드의 이름이고, 두 번째 파라미터는 반환할 타입으로서 가져올 빈의 타입을 적는다.
@Bean 또는 @Bean(name = …)
만약 빈의 이름을 메서드의 이름이 아닌 다른 이름으로 호출하고 싶다면, Bean 어노테이션의 name 파라미터에 값을 넣어준다.
package hello.core;
import hello.core.discount.DiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImplementation;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImplementation;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
///Users/dohk/Dropbox/Spring/project/core/src/main/java/hello/core/AppConfig.java
@Configuration
public class AppConfig {
@Bean(name = "memberServiceBean")
public MemberService memberService() {
return new MemberServiceImplementation(memberRepository());
}
@Bean
public OrderService orderService() {
return new OrderServiceImplementation(memberRepository(), discountPolicy());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy();
}
}
package hello.core;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImplementation;
import hello.core.order.Order;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImplementation;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
// /Users/dohyung/Dropbox/Spring/project/core/src/main/java/hello/core/OrderApp.java
public class OrderApp {
public static void main(String[] args) {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = applicationContext.getBean("memberServiceBean", MemberService.class);
OrderService orderService = applicationContext.getBean("orderService", OrderService.class);
Long memberId = 1L;
Member tedd = new Member(memberId, "TEDD", Grade.VIP);
memberService.join(tedd);
Order order = orderService.createOrder(memberId, "IPhone 15 Pro", 1500000);
System.out.println("order = " + order);
System.out.println("order.calculatePrice() = " + order.calculatePrice());
}
}
//order = Order{memberId=1, itemName='IPhone 15 Pro', itemPrice=1500000, discountPrice=150000}
//order.calculatePrice() = 1350000
하지만 관례적으로, 별도의 이름을 부여하지는 않는다.
'TIL' 카테고리의 다른 글
2023-11-06-TIL | Spring (0) | 2023.11.07 |
---|---|
2023-11-04-TIL | Spring (0) | 2023.11.04 |
2023-11-01-TIL | Spring (1) | 2023.11.03 |
2023-10-31-TIL (1) | 2023.11.01 |
2023-10-30-TIL (2) | 2023.10.31 |