개발이 거의 끝나가고 서비스 오픈 직전일 때 기획자가 갑자기 할인 정책을 기존의 정액 할인이 아닌 정률 할인으로 변경하고 싶다고 했을 경우를 가정하자
이전에 우리는 DiscountPolicy라는 인터페이스와 FixDiscountPolicy라는 구현체를 통해 역할과 구현을 분리해서 설계했다.
따라서 여기에 정률 할인 정책인 RateDiscountPolicy 구현체를 추가하여 사용하면 DiscountPolicy 역할을 그대로 수행하기 때문에 유연하게 변경이 가능하다.
RateDiscountPollicy
public class RateDiscountPolicy implements DiscountPolicy {
private int discountPercent = 10; //10% 할인
@Override
public int discount(Member member, int price) {
// VIP 등급이면
if (member.getGrade() == Grade.VIP) {
// 할인되는 가격 반환
return price * discountPercent / 100;
} else {
return 0;
}
}
}
RateDiscountPollicyTest
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
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
assertThat(discount).isEqualTo(0);
}
}
새로운 할인 정책 적용과 문제점
이제 새로운 할인 정책을 적용하기 위해 OrderServiceImpl에서 RateDiscountPolicy를 사용하도록 바꿔준다.
public class OrderServiceImpl implements OrderService {
// private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
}
정책을 바꾸면 바뀐 정책대로 잘 동작하지만 저번과 동일한 문제가 발생한다.
클라이언트의 코드를 변경함으로써 정책을 바꾸면 확장에는 열려 있지만 변경에도 열려있기 때문에 OCP 원칙을 위반하게 되고 FixDiscountPolciy와 RateDiscountPolicy 두 구현체를 모두 의존하고 있기 때문에 DIP원칙도 위반하게 되는 것이다.
현제 의존관계
다형성도 활용하고 인터페이스와 구현 객체를 분리해서 설계했지만 클래스 의존관계를 분석해보면 인터페이스 뿐만 아니라 구현 클래스에도 의존하고 있다는 것을 알 수 있다. 구현 클래스에도 의존하고 있기 때문에 코드를 변경해야 하는 것이다.
그렇다면 이 문제를 어떻게 해결할 수 있을까?
구현체에 의존하는 기존 코드를 인터페이스에만 의존하도록 설계해야 한다.
인터페이스에만 의존하도록 하기 위해 기존 코드에서 구현체 관련 코드를 싹 지워준다.
public class OrderServiceImpl implements OrderService {
//private DiscountPolicy discountPolicy;
private DiscountPolicy discountPolicy;
}
실제 실행을 해보면 당연히 구현체가 없기 때문에 Null Pointer Exception이 발생한다.
그럼 구현체가 없는데 어떤 정책을 사용할 지 어떻게 연결시켜주지?
어떤 누군가가 클라이언트인 OrderServiceImpl에 DiscountPolicy의 구현 객체를 대신 생성하고 주입해주어야 한다.
관심사의 분리
공연을 생각해보면 로미오와 줄리엣의 역할을 누가할 지는 배우들이 정하는 것이 아닌 공연 기획자가 정한다. 만약 로미오역을 맡은 배우가 줄리엣 역의 배우까지 섭외해야 한다면 너무 많은 책임을 가지게 되는 것이다. 상대역이 누가 됐던 각자 맡은 역할에만 충실하면 되는 것이다.
애플리케이션도 마찬가지로 실제 실행되는 객체들은 본인의 역할만 수행하도록 설계해야 하고 별도의 공연 기획자가 의존관계를 주입해주어야 한다.
방금 설계한 코드의 문제점은 인터페이스가 아닌 구현체 즉, 상대역이 누군지에 의존했기 때문에 생기는 문제였다. 여기서 공연 기획자 역할을 하는 AppConfig를 통해 관심사를 분리하여 문제를 해결하자
AppConfig
이전에는 객체를 생성하고 인터페이스 할당, 주입을 그 객체 안에서 직접 했었다. 배우가 담당 배우를 직접 섭외하는 꼴이다.
이제 애플리케이션의 전체 동작 방식을 구성하기 위해 공연기획자, 즉 AppConfig가 구현 객체를 생성하고, 연결하도록 설계한다.
public class AppConfig {
public MemberService memberService() {
return new MemberServiceImpl(new MemoryMemberRepository());
}
public OrderService orderService() {
return new OrderServiceImpl(
new MemoryMemberRepository(),
new FixDiscountPolicy());
}
}
이제 공연 기획자로써 애플리케이션의 실제 동작에 필요한 구현 객체를 생성하고 주입해주는 AppConfig가 있으니까 기존에 MemberServiceImpl과 OrderServiceImp에서 구현체에 대한 의존을 하고 있던 코드를 지워주고 생성자를 통해 주입될 수 있도록 설정한다.
MemberServiceImpl은 생성자를 통해 어떤 구현 객체가 들어올지 절대 알 수 없고 오직 AppConfig에서 결정된다. 이제 의존관계에 대한 고민은 외부에 맡기고 실행에만 집중하면 된다.
어떤 회원 데이터 저장소를 이용하던 알 바 없고 누구든 인터페이스에 맞춰 save()를 호출해서 사용할거다!
이제 인터페이스에만 의존하니까 DIP와 OCP 모두 만족하게 된다.
appConfg 객체는 memoryMemberRepository 객체를 생성하고 그 참조값을 memberServiceImpl을 생성하면서 생성자로 전달한다. 클라이언트인 memberServiceImpl 입장에서 보면 의존관계를 마치 외부에서 주입해주는 것 같아서 DI (Dependency Injection), 의존관계 주입이라고 한다.
OrederServiceImpl도 마찬가지!
AppConfig 리팩터링
현재 AppConfig는 중복이 있고, 코드에서 역할에 따른 구현이 명확하게 잘 드러나지 않는다.
중복을 제거하고 역할에 따른 구현이 보이도록 리팩터링 해준다.
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로 애플리케이션이 사용 영역과 객체를 사용하고 구성하는 구성 영역으로 분리되었다.
따라서 구성영역에서 할인정책만 바꿔주면 사용영역을 변경하지 않아도 재사용할 수 있다.
AppConfig에서 할인 정책 역할을 담당하는 구현을 RateDiscountPolicy 객체로 변경
public DiscountPolicy discountPolicy() {
// return new FixDiscountPolicy();
return new RateDiscountPolicy();
}
좋은 객체 지향 설계의 5가지 원칙 적용
SRP, DIP, OCP원칙의 적용을 알아봤다.
SRP 단일 책임 원칙
한 클래스는 하나의 책임만 가져야 한다.
AppConfig 등장 이전에는 클라이언트의 객체가 직접 구현 객체를 생성, 연결, 실행하는 너무 많은 책임을 수행하고 있었다. 하지만 AppConfig가 등장해 구현 객체를 생성하고 연결시켜줌으로써 관심사를 분리하여 SRP원칙을 따르도록 해주었다.
이로써 클라이언트 객체는 실행하는 책임만 담당할 수 있게 되었다.
DIP 의존관계 역전 원칙
추상화에 의존해야지, 구체화에 의존하면 안된다.
기존에는 추상화에도 의존하고 구현체에도 의존해서 DIP 원칙을 위반했지만 AppConfig의 등장으로 객체가 추상화의 의존하고 객체의 의존관계를 AppConfig를 통해 외부에서 주입해줌으로써 DIP원칙을 준수할 수 있었다.
OCP 개방-폐쇄 원칙
확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
새로운 정책을 개발하여 확장시켜도 AppConfig를 통해 클라이언트의 생성자로 주입하므로 클라이언트 코드를 변경하지 않아도 되게 되었다.
확장해도 사용 영역의 변경은 필요없게 되었으므로 OCP의 원칙을 준수할 수 있었다.
IoC, DI, 그리고 컨테이너
제어의 역전 IoC (Inversion of Control)
기존 프로그램은 클라이언트가 구현 객체를 직접 생성, 연결, 실행하는 너무 많은 책임을 담당하고 있었다.
그런데 AppConfig의 등장으로 객체는 자신의 로직을 실행하는 역할만 담당하게 되었고, AppConfig가 프로그램의 제어 흐름을 가져가면서 구현체를 직접 생성해서 주입시켜주는 역할을 하게 되었다.
OrderServiceImpl은 필요한 인터페이스들을 호출하지만 어떤 구현 객체들이 실행될지 전혀 모르는 상태이다. 내가 호출하는 것이 아닌 AppConfig가 내 코드를 대신 호출해주는 것이다. 말그대로 제어권이 뒤바뀐다고 해서 제어의 역전(IoC)이라고 불린다.
프레임워크 vs 라이브러리
내가 작성한 코드를 제어하고 대신 실행하면 그것은 프레임 워크이다. 반면, 내가 작성한 코드가 직접 제어의 흐름을 담당한다면 그것은 프레임워크가 아니라 라이브러리이다.
MemberApp은 개발자가 순차적으로 직접 돌리는 예제였고, JUnit으로 테스트를 돌린 것은 JUnit 프레임워크가 제어의 흐름을 담당한 예제이다.
테스트 코드를 작성할 때 @Test로 join() 메서드를 실행했었다. 개발자는 로직만 개발했을 뿐이고 실행과 제어는 모두 JUnit이라는 테스트 프레임워크가 자신만의 라이프사이클로 대신 실행해준 것이다.
의존관계 주입 DI (Dependency Injection)
클라이언트는 실제 어떤 구현 객체가 사용될 지는 모른는 상태로 인터페이스에만 의존한다.
정적인 클래스 의존관계 : 애플리케이션을 실행하지 않아도 import 코드만 보고도 분석 가능하다.
이러한 클래스 의존관계만으로는 실제 어떤 객체가 주입될 지 알 수가 없다.
동적인 객체(인스턴스) 의존관계 : 애플리케이션 실행 시점에 실제 생성된 객체 인스턴스의 참조가 연결된 의존관계
애플리케이션 실행 시점(런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서 클라이언트와 서버의 실제 의존관계가 연결되는 것을 의존관계 주입이라고 한다.
이렇게 의존관계 주입을 이용하면 클라리언트 코드를 변경하지 않고 클라리언트가 호출하는 대상의 타입 인스턴스를 변경할 수 있다.
이렇게 AppConfig처럼 객체를 생성하고 관리하면서 의존관계를 연결해주는 것을 IoC 컨테이너 혹은 DI 컨테이너라고 한다.
스프링으로 전환하기
지금까지는 순수한 자바 코드만으로 DI를 적용했는데 이젠 스프링 기반으로 설계해본다.
@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에 설정을 구성한다는 뜻을 가진다.
기존에는 AppConfig를 생성해 직접 객체를 생성하고 DI 했지만 ApplicationContext라는 스프링 컨테이너를 사용해 @Configuration이 붙은 AppConfig를 설정(구성) 정보로 사용하고 @Bean이 적힌 메서드를 모두 호출해서 반환된 객체를 스프링 컨테이너에 등록한다.
이렇게 스프링 컨테이너에 등록된 객체를 스프링 빈이라고 한다.
스프링 빈은 @Bean이 붙은 메서드명을 스프링 빈의 이름으로 사용한다.
스프링 빈은 applicationContex.getBean() 메서드를 통해 찾을 수 있다.