김영한 선생님의 스프링 핵심 원리 - 기본편 강의를 듣고 정리하였습니다.
앞에서 다형성의 원리를 적용해 객체지향을 잘 지켜 개발을 했다고 생각했다.
하지만 MemberServiceImpl
클래스를 확인해보면
privete final MemberRepository memberRepository = new memoryMemberRepository();
를 통해 인터페이스뿐만 아니라 실체 구현체까지 의존한 것을 확인할 수 있다. 그렇다면 이러한 설계는 정말 클라이언트에게 영향을 주지 않는 것일까?
"지금처럼 고정 금액 할인이 아닌 주문 금액당 할인하는 정률 할인으로 변경하고 싶어요!"
기획자의 요청으로 새로운 할인 정책이 필요하다.
RateDiscountPolicy
를 적용하면 될 것이라고 생각할지 모른다.
하지만,
할인 정책을 변경하려면 클라이언트 코드도 함게 변경된다.
public class OrderServiceImpl implements OrderService {
// private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
역할과 구현을 분리했고, 다형성도 활용하고 인터페이스와 구현 객체를 분리하였다.
하지만 OCP, DIP 같은 객체지향 설계 원칙은 준수되지 않았다.
OrderServiceImpl
)은 DiscountPolicy
인터페이스에 의존하면서 DIP를 지켰을까?DiscountPolicy
FixDiscountPolicy
, RateDiscountPolicy
그림처럼 실체 구현 클래스에 의존하고 있기 때문에 RateDiscountPolicy
로 변경되면 OrderServiceImpl
소스 코드도 함께 변경되어야 한다.
그렇다면, DIP를 위반하지 않도록 인터페이스에만 의존하도록 의존관계를 변경한다.
public class OrderServiceImpl implements OrderService {
private final DiscountPolicy discountPolicy;
그런데 구현체가 없는데 어떻게 코드를 실행할 수 있을까?
문제를 해결하려면 DiscountPolicy
의 구현 객체를 대신 생성하고 주입한다.
지금까지는 공연하는 배우가 어떤 배우로 캐스팅하고, 어떤 작품을 선보일지 모두 결정했다. 책임을 확실히 분리하기 위해 공연 기획자가 필요하다.
AppConfig
를 생성하고 실제 동작에 필요한 구현 객체를 생성하여, 애플리케이션에 대한 환경 구성이 필요한다.
public class AppConfig {
public MemberService memberService() {
return new MemberServiceImpl(new MemoryMemberRepository());
}
public OrderService orderService() {
return new OrderServiceImpl(new MemoryMemberRepository(), new FixDiscountPolicy());
}
}
AppConfig는 생성한 객체 인스턴스의 참조(레퍼런스)를 생성자를 통해 주입된다.
MemberServiceImpl
-> MemoryMemberRepository
OrderServiceImpl
-> MemoryMemberRepository
, FixDiscountPolicy
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
public MemberServiceImpl(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
}
생성자를 만들고 구현 객체에 대한 의존을 제거하고 MemberRepository
인터페이스에만 의존하도록 수정한다.
이제 MemberServiceImpl은
AppConfig
에서 결정된다.이제 MemberServiceImpl
에 오버라이딩된 메서드는 어떤 DB를 사용하는지 모르고 오직 인터페이스에만 맞춰서 동작한다.
클래스 다이어그램
객체의 생성과 연결은 AppConfig
가 담당하고, MemberServiceImpl
은 MemberRepository
인 추상에만 의존한다. 이처럼 객체를 생성하고 연결하는 역할과 실행하는 역할이 명확하게 분리되었다.
회원 객체 인스턴스 다이어그램
appConfig
객체는 memoryMemberRepository
객체를 생성하고 그 참조값을 memberServiceImpl
을 생성하면서 생성자로 전달한다.memberServiceImpl
입장에서 보면 의존관계를 마치 외부에서 주입해주는 것 같다고 해서 DI(Dependency Injection) 의존관계 주입이라고 한다.MemberServiceImpl
에 생성자를 주입한 것처럼 OrderServiceImpl
도 주입해보겠다.
먼저 인터페이스에만 의존하도록 변경하고, 생성자를 만든다.
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;
}
}
이제 OrderServiceImpl
은 FixDiscountPolicy
를 의존하지 않고, DiscountPolicy
인터페이스만 의존한다.
OrderServiceImpl
은 생성자를 통해 어떤 구현 객체가 주입될지 알 수 없다. 이는 오직 AppConfig
에서 결정되므로 실행에만 집중하면 된다. 테스트 코드도 의존성을 제거하고, AppConfig
에서 만든 memberService
를 꺼내온다.
public class MemberServiceTest {
MemberService memberService;
@BeforeEach
public void beforeEach() {
AppConfig appConfig = new AppConfig();
memberService = appConfig.memberService();
}
...
}
@BeforeEach
어노테이션을 사용하면 테스트 실행 전 무조건 해당 메서드가 실행된다.
역시 의존성을 제거하고, AppConfig
에서 만든 OrderService
를 꺼내온다.
public class OrderServiceTest {
MemberService memberService;
OrderService orderService;
@BeforeEach
public void beforeEach() {
AppConfig appConfig = new AppConfig();
memberService = appConfig.memberService();
orderService = appConfig.orderService();
}
...
}
AppConfig 코드를 확인해보면 중복(new MemoryMemberRepository()
)이 존재하고 AppConfig만 봐서는 역할에 따른 구현이 무엇인지 알 수 없다. 따라서 역할들이 드러나도록 수정이 필요하다.
메서드를 만들고 파라미터를 교체한다. 이때 메서드명은 역할이 드러나도록 지정하는 것이 좋다.
command + option + M
을 이용하면 메서드를 바로 생성할 수 있다.public class AppConfig {
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
...
}
후에 Memory가 아닌 외부 DB를 사용한다면 memberRepository()
메서드의 return
부분만 수정하면 된다.
할인 정책에 대한 부분도 구체 클래스를 생성하는 메서드를 만들고 수정한다.
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy();
}
추후에 할인 정책을 변경해도, 애플리케이션의 구성 역할을 담당하는 AppConfig만 변경한다. 역할을 수행하는 클라이언트 코드 OrderServiceImpl은 어떤 코드도 변경될 필요 없다. 이처럼 AppConfig를 기획자라 생각하고, 기획자이므로 구현 객체를 모두 알아야한다고 이해하면 된다.
이제 바뀐 할인 정책으로 코드를 확인해보겠다.20000의 10%에 해당하는 2000이 올바르게 출력되었다.
오직 AppConfig에 해당하는 코드만 수정하였다.
구성 영역에서만 정책을 수정하고 사용 영역은 전혀 영향을 받지 않는다.
1. 새로운 할인 정책 개발
다형성을 지켜 개발했기 때문에 할인 정책이 추가되어도 문제 없다.
2. 새로운 할인 정책 적용과 문제점
OrderServiceImpl 클래스
주문 서비스 클라이언트가 인터페이스인 DiscountPolicy
뿐만 아니라, 구체 클래스인 FixDiscountPolicy
도 함께 의존하여 DIP 위반이 발생한다.
private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
3. 관심사의 분리
"클라이언트 객체는 자신의 역할을 실행하는 것만 집중하여 권한이 줄어들고 명확한 책임을 갖을 수 있다."
4. 새로운 구조와 할인 정책 적용
AppConfig가 생성되어 애플리케이션이 크게 사용 영역과 객체를 생성하고 구성하는 영역으로 분리된다.
지금까지 정리한 것을 바탕으로 좋은 객체 지향에 대해 생각해보자.
하나의 클래스는 하나의 책임만 갖는다.
프로그래머는 추상화에 의존하고, 구체화에 의존하면 안된다.
계속 반복되지만, private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
를 생각하자. 추상화 인터페이스 뿐만 아니라 구체화 구현 클래스에도 함께 의존했다.
클라이언트가 추상화 인터페이스에만 의존하도록 코드를 수정하고, AppConfig가 FixDiscountPolicy
객체 인스턴스를 대신 생성해서 클라이언트 코드에 의존관계를 주입해라.
소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
다형성을 사용하여 클라이언트가 DIP를 지킨다. 애플리케이션을 사용 영역과 구성 영역으로 나누어 역할을 분리한다.
구성 영역의 AppConfig에서 의존 관계를 FixDiscountPolicy
에서 RateDiscountPolicy
로 변경해서 클라이언트 코드에 주입하므로 코드는 변경하지 않아도 된다. 즉 사용 영역의 변경은 닫혀있다.
지금까지 해온 것을 돌아보자.
기존에 클라이언트 구현 객체는 스스로 필요한 서버 구현 객체를 생성, 연결, 실행했다. 하지만 이는 구체화에 의존하면 안된다는 DIP(의존관계 역전 원칙)을 위반했다. 그래서 AppConfig가 등장했다. 이제 구현 객체는 자신의 로직을 실행하는 역할만 하고, 제어 흐름은 AppConfig에서 관리한다.
이렇게 프로그램의 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것을 제어의 역전(IoC)라 한다.
의존관계는 정적인 클래스 의존 관계와, 실행 시점에 결졍되는 동적인 객체(인스턴스) 의존관계를 분리해서 생각한다.
어플리케이션을 실행하지 않아도 import 코드만으로 의존관계를 분석할 수 있다.
OrderServiceImpl
은 MemoryRepository
, DiscountPolicy
에 의존한다는 것을 알 수 있다. 하지만 실제 어떤 겍체가 주입되는지 알 수 없다.실행 시점에 실제 생성된 객체 인스턴스의 참조가 연결된 의존 관계다.
객체 다이어 그램
애플리케이션 실행 시점(=런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서 클라이언트와 서버의 실제 의존관계가 연결된다. 객체는 인스턴스를 생성하고, 참조값을 전달해서 연결된다.
이렇게 AppConfig처럼 객체를 생성하고 관리하면서 의존관계를 연결해주는 것을 IoC 컨테이너, DI 컨테이너(또는 어셈블리, 오브젝트 팩토리)라고 부른다. 최근에는 의존관계 주입에 초점을 맞추어 DI 컨테이너라고 한다.
프로그램의 흐름을 관리하는 클래스에 @Configuration
어노테이션을 추가한다.
@Configuration
public class AppConfig {
...
}
흐름을 관리하는 메서드 앞에 @Bean
어노테이션을 추가한다.
@Configuration
public class AppConfig {
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
@Bean
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy();
}
}
스프링 컨테이너를 통해서 사용할 서비스를 불러온다.
public class MemberApp {
public static void main(String[] args) {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
...
}
}
스프링 컨테이너를 통해 찾고, AppConfig에서 해당하는 메서드명과 메서드의 타입을 지정한다.
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
AppConfig에서 보면 메서드명과 타입을 확인할 수 있다.
OrderApp도 마찬가지로 스프링 컨테이너로 수정해보겠다.
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);
...
}
}
이제 코드를 실행하면 스프링 관련 로그가 실행되고 결과가 출력된다. @Bean
어노테이션으로 등록된 것을 확인할 수 있다.
ApplicationContext
를 스프링 컨테이너라고 한다. 이 컨테이너는 AppConfig
를 통해 직접 객체를 생성하고 DI를 하는 것을 대체한다.
@Configuration
: AppConfig
를 설정(구성) 정보로 사용한다.@Bean
: 메서드를 호출해서 반환된 객체를 스프링 컨테이너에 등록하고, 스프링 컨테이너에 등록된 객체를 스프링 빈이라고 한다. 스프링 빈에서는 메서드의 명을 스프링 빈의 이름으로 한다. 스프링 빈은 applicationContext.getBean()
메서드를 사용해서 찾을 수 있다.이제부터 스프링 컨테이너에 객체를 스프링 빈으로 등록하고, 스프링 컨테이너에서 스프링 빈을 찾아서 사용한다.