이 글은 김영한님의 스프링 핵심 원리 이해 강의중 중요한 의존관계 주입을 중심으로 그 원리는 무엇이고 스프링을 적용한 의존관계를 정리하기 위한 글이며, 기존의 자바코드로 이루어진 의존성 주입과 스프링을 이용한 의존성 주입까지 어떻게,왜 사용되었는지 정리해보고자한 글이다. 포스팅에 사용된 자료는 포스팅 가장 아래 링크강의에서 확인할 수 있다.
해당 포스팅에선 도출된 과정을 살펴보기 위해 기본적으로 강의자료에 사용된 클래스 다이어그램을 사용하였다.
먼저, 스프링을 적용하지 않고 Java코드로만 이루어진 예제를 정리해보자.
1. 회원 클래스 다이어그램
기본적으로 회원 서비스 인터페이스가 있고, 해당 인터페이스를 상속받는 구현체가있다.
또한 회원 DB와 연관된 repository인터페이스가 있고, 해당 인터페이스를 상속받는 두가지의 구현체가 있다.
위의 요구사항이 구현되었다고 가정하고, 회원 서비스 구현체는 아래와같이 생성되었다.
public class MemberServiceImpl implements MemberService{
MemberRepository memberRepository = new MemoryMemberRepository(); // OCP & DIP 위반
@Override
public void join(Member member){
memberRepository.save(member);
}
@Override
public Member findMember(Long memberId){
return memberRepository.findById(memberId);
}
}
이제 문제점을 살펴보자.
만약 저장소를 MemoryMemberRepository를 사용하다가 다른 저장소로 변경하고 싶을때의 상황을 생각해보면,
위의 new MemoryMemberRepository()를 주석 또는 삭제처리하고 아래에 new DbMemberRepository같은 또다른 생성자를 만들게 될것이다. 이는 코드의 수정이 일어나게 된다.
즉 기존의 코드를 변경하지 않고, 기능을 추가할수 있도록 설계하는 OCP(Open Closed Principle)원칙을 위반하게 된다.
또한 추상화에 의존하고 구체화에 의존하지 않는 DIP(Dependency Inversion Principle)를 위반하게 된다.
2. 주문 도메인 클래스 다이어그램
위와 같이 주문서비스와 할인정책이 추가되었다고 가정해보자.
public class OrderServiceImpl 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);
}
}
위 또한 위에서 설명한 문제점이 드러나고있다.
정액 할인 정책에서 정률 할인 정책으로 변경할때 위와 같이 코드의 변경이 발생하는 모습을 확인할 수 있다.
이제 여기서 객체 지향적인 관점에서 문제점들을 살펴보자
1. 먼저 역할과 구현을 충실하게 분리(객체 지향 방법을 준수)했나?
→ 그렇다, 역할을 각 주문과 회원 인터페이스로 두고, 구현은 인터페이스를 상속받게하여 충실하게 분리하였다.
2. 다형성도 활용하고, 인터페이스와 구현 객체를 분리했나?
→ 그렇다, 위에서 설명한것과 같이 인터페이스와 구현 객체를 분리하였기 때문에 자연스럽게 다형성도 활용하였다.
3. OCP, DIP 같은 객체 지향 설계 원칙을 충실히 준수했나?
→ 그렇게 보이지만 사실이 아니다
Q.주문서비스 클라이언트(OrderServiceImpl)는 DiscountPolicy인터페이스에 의존하면서 DIP를 지키고 있는거 아닌가?
A. 그렇지않다. 클래스 의존관계를 살펴보면 추상 인터페이스뿐만 아니라 구현 클래스에도 의존하고 있다.
인터페이스 의존 : DiscountPolicy
구현 클래스 의존 : FixDiscountPolicy, RateDiscountPolicy
즉, private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
좌변은 추상 인터페이스에 의존하고 있지만 우변은 구현 클래스에 의존하고 있다.
따라서 DIP를 위반하고 있으며, 정액 정책에서 정률 정책으로 변경시 위에서 살펴본것 처럼 코드의 변경이 일어나기때문에 OCP또한 위반하고 있다.
예를 들어보면 다음와 같다.
배우(구현)와 배역(역할)이 있다고 가정할때, 역할을 하는것은 배우들이 정하는것이 아니다.
로미오와 줄리엣 공연을 하면, 위 코드는 마치 로미오 역할(인터페이스)를 하는 배우가 줄리엣 역할(인터페이스)을 하는 여자 주인공을 직접 초빙하는 것과 같다. 남자 배우는 공연도 해야하고 여자 주인공도 직접 초빙해야하는 다양한 책임을 가지게 된다.
따라서 관심사를 분리하여, 배우는 본인의 역할인 배역만 집중해야 한다.
따라서 위 문제를 해결하기 위해 AppConfig가 등장하게 된다.
전체 동작 방식을 구성하기 위해, 구현 객체를 생성하고 , 연결하는 책임을 가지는 별도의 설정 클래스를 만드는것이다.
public class AppConfig {
public MemberService memberService(){
return new MemberServiceImpl(new MemoryMemberRepository());
}
public OrderService orderService(){
return new OrderServiceImpl(
new MemoryMemberRepository(),
new FixDiscountPolicy());
}
}
필요한 구현 객체(MemberServiceImpl,MemoryMemberRepository,OrderServiceImpl,FixDiscountPolicy)를 생성하고
연결(MemberServiceImpl → MemoryMemberRepository,
OrderServiceImpl → MemoryMemberRepository , FixDiscountPolicy)하는 책임을 모두 맡는다.
이제 MemberServiceImpl클래스 에서는 생성자에서 주입받기만 하면된다.
public class MemberServiceImpl implements MemberService{
private final MemberRepository memberRepository;
public MemberServiceImpl(MemberRepository memberRepository){
this.memberRepository = memberRepository;
}
...
}
자연스럽게 MemberServiceImpl은 MemoryMemberRepository를 의존하지 않게 되었고, 인터페이스만 의존(DIP를 준수)하게 된다. 생성자를 통해 어떤 구현 객체가 들어올지 알 수 없고, 어떤 구현 객체를 주입할지는 오직 외부(AppConfig)에서 결정된다. → DIP를 준수
또한 FixDiscountPolicy에서 RateDiscountPolicy로 변경할때, 클라이언트 코드(OrderServiceImpl)는 변경되지 않고, AppConfig에서만 변경되므로 소프트웨어 요소를 새롭게 확장해도 사용 영역의 변경은 닫혀 있다. → OCP 준수
아직도 DI가 이해가지 않는다면 아래 더보기를 클릭하자.
DI를 하지 않는 예시
class Controller{
private Service service;
public Controller(){
this.service = new Service()
}
}
DI를 하는 예시
class Controller{
private Service service;
public Controller(Service service){
this.service = service
}
}
만약 추후에 Service가 Repository로 변경되면?
DI를 하지 않는 예시는 public Controller(){this.repository = new Repository();}
DI를 하는 예시는 public Controller(Repository repository){this.repository = repository;}
두곳 모두다 변경된다.
이는 당연한 것이다. Service를 Repository로 설계도 자체를 변경하는 것이기 때문에 코드의 변경이 일어날 수 밖에 없다.
다른예시를 떠올려보자.
설계도는 동일하지만 구현체가 다른 Aservice, Bservice 클래스가 있고 Service 인터페이스가 있다고 가정해보자.
DI를 하지 않는 예시
class Controller{
private Service service;
public Controller(){
//this.service = new Service(); // 불가능 > 인터페이스는 인스턴스 생성이 불가능
this.service = new Aservice();
}
}
당연하게도 인터페이스는 인스턴스로 생성할 수 없다.
따라서 생성자 코드내에서 new Aservice()로 구현체를 지정해줘야 한다.
이렇게 되면 추후에 Bservice로 변경될때, 직접 Controller클래스 내부에서 new Aservice()를 new Bservice()로 변경해줘야 된다.
DI를 하는 예시
class Controller{
private Service service;
public Controller(Service service){
this.service = service;
}
}
Aservice , Bservice 어떤것이 와도 그것은 외부에서 결정할뿐, Controller 클래스 내부에선 코드의 변경이 일어나지 않게된다.
자,이제 스프링을 사용하여 위 코드를 변경해볼것이다.
먼저, 스프링은 왜 쓰는지 알아보고 스프링 컨테이너의 구조와 생성 과정을 살펴보자.
*스프링은 왜 쓰는가?
예전에 웹어플리케이션 개발에 사용하던 EJB(Enterprise Java Bean)는 Java가 오히려 EJB에 종속되어 개발을 했다면 스프링은 Java의 큰 특징인 객체 지향 언어를 살려내는 프레임워크라고 볼 수 있다. 즉 스프링은 EJB와 반대로 Spring이 Java에 초점이 맞춰져 좋은 객체 지향 어플리케이션을 개발할 수 있게 도와준다.
* BeanFactory와 ApplicationContext
* BeanFactory
BeanFactory는 스프링 컨테이너의 최상위 인터페이스다.
스프링 빈을 관리하고 조회하는 역할을 담당한다.
getBean()을 제공한다.
* ApplicationContext
BeanFactory 기능을 모두 상속받아서 제공한다.
빈을 관리하고 검색하는 기능을 BeanFactory가 제공해주는데 굳이 쓸 필요가 있을까?
어플을 개발할때는 수많은 부가기능이 필요하기 때문에 ApplicationContext는 아래와 같은 부가기능을 제공한다.
1. 메시지 소스를 활용한 국제화 기능 ( 한국에선 한국어로 영어권에선 영어로 출력)
2. 환경변수 처리 ( 로컬,개발,운영등을 구분하여 처리)
3. 애플리케이션 이벤트 ( 이벤트를 발행하고 구독하는 모델을 편리하게 지원 )
4. 편리한 리소스 조회 ( 파일, 클래스패스, 외부 등에서 리소스를 편리하게 조회 )
💡 보통 BeanFactory를 직접 사용할 일은 거의 없고, ApplicationContext를 사용하며, BeanFactory나 ApplicationContext를 스프링 컨테이너라고 부른다.
* 스프링 컨테이너의 다양한 설정 형식 지원
위와 같이 스프링 컨테이너는 자바 코드, XML, Groovy등등의 설정 정보를 받아들일 수 있도록 유연하게 설계되어있다.
💡최근에는 스프링 부트를 사용하면서 XML기반의 설정은 잘 사용하지 않는다.
스프링은 어떻게 위와 같은 다양한 설정 형식을 지원할까?
그 중심에는 BeanDefinition 이라는 추상화가 있다.
위에서 살펴본 객체지향방법의 핵심과 같이 스프링 컨테이너는 XML인지 Java코드인지 몰라도 된다. 오직 BeanDefinition만 알면 된다. 코드 레벨로 본다면 아래와 같다.
어노테이션을 사용한다고 가정하면, AnnotationConfigApplicationContext는 바로 아래에 있는 Reader를 사용하여 AppConfig.class를 읽고 BeanDefinition을 생성한다.
💡 BeanDefinition을 직접 생성해서 스프링 컨테이너에 등록할 순 있지만 실무에서 그럴일은 거의 없기 때문에 그냥 이해만 하는정도로 넘어가면 된다.
* 스프링 컨테이너 생성
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
보통 위의 코드에있는 ApplicationContext를 스프링 컨테이너라 한다. 우측 괄호 안에있는 AppConfig는 위에 있는 예제 클래스이다.
* 스프링 컨테이너의 생성과정
1. 스프링 컨테이너 생성 - new AnnotcationConfigApplicationContext(AppConfig.class)
2. 스프링 빈 등록
3. 스프링 빈 의존관계 설정 - 준비
4. 스프링 빈 의존관계 설정 - 완료
위와 같이 스프링을 사용하면 스프링 컨테이너를 생성하여 빈 저장소에 빈을 각각 등록하여 사용할 수 있다.
그런데, 메서드마다 @Bean을 붙여서 코드가 약간더 복잡해진것 같고 어떤 장점이 있는지 아직 드러나지 않는다.
그리곤 위 사진엔 노출되지 않았지만 클래스위에 @Configuration 어노테이션도 필요하다.
(이 어노테이션에 대해선 싱글톤의 정의를 살펴보고 알아보자.)
이제 장점을 알기위해 이제 싱글톤의 정의를 알아야한다.
먼저, 스프링은 태생이 기업용 온라인 서비스 기술을 지원하기 위해 탄생했기 때문에
대부분의 스프링 애플리케이션은 웹 애플리케이션이다.
즉, 보통 여러 고객이 동시에 요청을 한다.
만약 클라이언트A,클라이언트B,클라이언트C가 있으면 아래와 같이 3번의 memberService를 생성하게 된다.
따라서 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴을 싱글톤 패턴이라 한다.
이제 아래코드를 보자.
@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();
}
}
만약 위의 AppConfig가 있을때, 아래의 테스트 코드는 어떻게될까?
public class ConfigurationSingletonTest {
@Test
void configurationTest() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
MemberServiceImpl memberService = ac.getBean("memberService", MemberServiceImpl.class);
OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class);
MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);
assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
}
}
예상대로라면 memberService를 생성할때 새로운 memberRepository()를 생성하고,
orderService을 생성할때 새로운 memberRepository()를 생성하고,
memberRepository를 생성할때 새로운 memberRepository()를 생성하게 될것이다.
그러나 결과는 모두 같은 인스턴스가 나오게 된다.
이것은 @Configuration이 싱글톤을 보장해주기 때문이다.
* 스프링은 어떤식으로 싱글톤을 보장해줄까?
@Test
void configurationDeep() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
//AppConfig도 스프링 빈으로 등록된다.
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println("bean = " + bean.getClass());
//출력: bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$bd479d70
}
순수한 클래스라면 "class hello.core.AppConfig"가 노출되어야 하지만 위의 출력 결과와 같이
xxxCGLIB가 나오는 모습을 확인할 수 있다. 이것은 스프링이 CGLIB라는 바이트 코드 조작 라이브러리를 사용하여 AppConfig클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한 것이다.
* 스프링이 싱글톤을 보장하는 원리
위와 같이 스프링 컨테이너엔 AppConfig클래스가 아닌 CGLIB 클래스가 들어가있는 모습을 확인할 수 있다.
즉, AppConfig@CGLIB는 @Bean이 붙은 메서드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환하고, 없다면 생성해서 스프링 빈으로 등록하고 반환하는 코드를 동적으로 만들어준다. 따라서 싱글톤이 보장된다.
*만약 @Configuration을 적용하지 않고, @Bean을 적용하면 어떻게 될까?
싱글톤을 보장해주지 않기때문에, ConfigurationTest()는 각각 MemberRepository객체를 생성하게 된다.
따라서 스프링 설정 정보는 항상 @Configuration을 사용해야 한다.
결론적으로 스프링 컨테이너는 객체 인스턴스를 싱글톤(1개만 생성)으로 관리한다.
싱글톤 컨테이너
스프링 컨테이너는 싱글턴 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리한다.
스프링 컨테이너는 싱글톤 컨테이너 역할을 한다. 이렇게 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라 한다.
이제 왜 스프링 컨테이너에 빈으로 등록하고 사용하는지 알게되었다.
고객의 요청이 올 때마다 객체를 생성하는 것이 아닌, 이미 만들어진 객체를 공유해서 효율적으로 사용(싱글톤)할수 있고, 스프링 컨테이너의 다양한 설정 형식 지원과 부가 기능을 사용할 수 있기때문이다.
그런데, 메서드별로 @Bean을 일일이 붙이고 클래스별로 @Configuration을 붙이면 귀찮고 불편하지 않을까? 다음편에서 다뤄보자
* 강의 출처
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B8%B0%EB%B3%B8%ED%8E%B8/dashboard
'Spring' 카테고리의 다른 글
Spring MVC 정리 3) 검증 (0) | 2023.11.06 |
---|---|
Spring MVC 정리 2)단계별로 구현하며 알아보는 스프링의 핵심 기술 (0) | 2023.10.25 |
Spring MVC 정리 1)웹 어플리케이션의 이해 (0) | 2023.10.20 |
빈의 생명주기와 스코프 , DL(의존성 검색) (0) | 2023.09.22 |
의존성 주입으로 살펴보는 컴포넌트와 자동 주입 (0) | 2023.09.15 |