[Spring] 스프링 기본 1 - DIP, OCP 원칙과 스프링 컨테이너
💡Refferecne [외부 링크]’스프링 핵심원리 - 기본편’
- 스프링 기본 1 - DIP, OCP 원칙과 스프링 컨테이너
- 스프링 기본 2 - 싱글톤 패턴과 싱글톤 컨테이너
- 스프링 기본 3 - 빈 스코프와 생명주기
1. 개요
데이터베이스가 정해지지 않아서 임시 메모리 저장소를 구현해서 개발하던 중, DB가 확정되고 JPA로 레포지터리 레이어를 구현한다고 가정한다. DI 컨테이너, 혹은 스프링 컨테이너에 대해 공부하기 전에는 의존관계를 직접 구현체를 설정하여 지정해 주었지만, 이는 SOLID 5원칙 중 DIP, OCP에 위배되는 방식이다. 그렇다면 객체지향적인 설계를 위해서는 어떻게 해야 할까?
2. 스프링이 없다고 가정하면
먼저 우리가 최종적으로 성공해야 하는 테스트 코드를 먼저 작성 해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MemberServiceImplTest {
//아직 별다른 DI 컨테이너가 없으므로
//서비스 객체도 직접 생성하여 주입하도록 한다.
MemberService memberService = new MemberServiceImpl();
@Test
@DisplayName("회원가입 성공")
void join() {
//given
Member member = new Member("name", MemberGrade.USER);
//when
Long joinedId = memberService.join(member);
//then
Member findMember = memberService.findOne(joinedId).get();
Assertions.assertThat(findMember.getId()).isEqualTo(member.getId());
Assertions.assertThat(findMember.getGrade())
.isEqualTo(member.getGrade());
}
}
만약 스프링의 기능이 없이 구현체를 설정한다고 하면 다음과 같이 다형성을 이용해볼 수 있다.
1
2
3
4
5
6
public class MemberServiceImpl implements MemberService {
//private MemberRepository memberRepository = new MemoryMemberRepository();
private MemberRepository memberRepository = new JpaMemberRepository();
// 이하 서비스 로직
다음과 같이 의존성을 직접 수정해 주었다면 테스트 코드도 잘 통과할 것이다.
하지만 위와 같은 코드는 사실 위에서 서술했듯 DIP, OCP 원칙에 위배된다.
☝️DIP - 의존관계 역전 원칙 추상화에 의존해야 하고, 구체화에 의존하면 안된다. ☝️OCP - 개방-폐쇄 원칙 확장에는 열려있으나 변경에는 닫혀있어야 한다.
서비스 객체는 비지니스 로직에 집중 해야하고, 자신이 직접 내부에 어떤 구현체를 선택할지 결정하면 안된다.
그렇다면 어떠한 방식으로 서비스 객체 내부에 직접 의존관계를 설정하지 않고 개발자의 의도대로 의존관계를 주입할 수 있을까?
우선 직접적인 의존성 주입 코드를 삭제 해보도록 하자.
1
2
3
4
5
public class MemberServiceImpl implements MemberService {
private MemberRepository memberRepository;
// 이하 서비스 로직
다음과 같이 수정하고 테스트 코드를 실행한다면 당연히 memberRepository
객체를 사용할 때 NullpointerException
이 발생한다.
왜냐하면 아무도 memberRepository
객체를 주입해주지 않았기 때문이다.
그래서 이를 해결하기 위해 설정자인 Config 객체의 개념이 등장하게 된다.
다음과 같은 MemberConfig
객체를 생성한다.
1
2
3
4
5
6
7
8
9
10
11
public class MemberConfig {
public MemberRepository memberRepository() {
return new JpaMemberRepository();
}
public MemberService memberService() {
return new MemberServiceImpl();
}
}
Member 도메인에 관한 설정 객체를 생성한 후 의존관계 주입을 이 Config 객체에게 완전히 일임하는 것이다.
그렇다면 MemberServiceImpl 클래스 내부의 의존성은 다음과 같이 생성자를 통해 주입할 수 있다.
1
2
3
4
5
6
7
8
9
public class MemberServiceImpl implements MemberService {
private MemberRepository memberRepository;
public MemberServiceImpl(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
// 이하 서비스 로직
1
2
3
4
5
6
7
8
9
10
class MemberServiceImplTest {
MemberService memberService;
@BeforeEach
void beforeEach() {
//Config 객체를 통해 의존성을 주입받는다.
MemberConfig memberConfig = new MemberConfig();
this.memberService = memberConfig.memberService();
}
이렇게 MemberConfig
객체를 통해 의존관계를 주입받는다면, 아무리 MemberRepository
인터페이스에 대한 구현체가 바뀌어도 Config
객체 내부에서만 의존관계를 선택하고, 서비스 객체 내부에 직접 의존관계를 선택할 일이 사라지게 된다.
즉, 의존관계 선택 책임을 완전히 일임하는 것이다.
이처럼 프로그램(객체)이 구현 객체 제어를 직접 하지 않고, 외부에서 관리하는 것을 제어의 역전 IoC(Inversion of Control) 이라고 한다.
또한 MemberConfig 처럼 제어 흐름에 대한 권한을 가지고 있는 역할의 객체를 IoC 컨테이너 혹은 DI 컨테이너라고 한다.
이렇게 책임을 분리하게 된다면 다음과 같은 구조가 된다.
이제 OCP 원칙과 DIP 원칙은 준수할 수 있게 되었지만, 현재는 스프링의 도움 없이 구현하였기 때문에 싱글톤 패턴도 준수되지 않고 있고, 사용자가 일일히 의존성을 설정해주어야 하는 문제가 남아있다.
작은 서비스라도 상당히 많은 인터페이스와 구현체가 생성될텐데 모든 의존성을 사용자가 Config 객체에 직접 주입해야 할까?
3. 스프링 컨테이너
기존 소스에서는 우리가 직접 DI 컨테이너를 작성하여 수동으로 등록된 객체의 참조값을 받아와서 사용하였다.
하지만 스프링에서 지원하는 스프링 DI 컨테이너 (앞으로 스프링 컨테이너라 하겠음)를 사용하면 이러한 객체들을 ‘스프링 빈’ 으로 등록하여 저장해주고 편리하게 객체들을 주입하여 사용할 수 있다.
그렇다면 어떻게 스프링 컨테이너에 빈(객체)을 등록할 수 있을까?
Spring Boot 어플리케이션은 시작할 때 컴포넌트 스캔이라는 동작이 일어나는데 이 때 스프링 애플리케이션 지정 범위에 포함된 객체들을 모두 스캔하여 스프링 컨테이너에 빈으로 포함시킨다.
스프링 컨테이너에 빈을 등록하는 방법은 크게 나누자면 수동으로 등록하는 방법과 자동으로 등록 대상에 포함시키는 방법이 있다.
3.1 수동 등록
앞서 살펴본 Config 객체, 즉 DI 컨테이너를 활용하는 방법이 있다. 앞서 작성한 MemberConfig 객체에 다음과 같이 @Configuration
을 추가하고, 의존성을 반환할 메서드에 @Bean
애노테이션을 추가하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class MemberConfig {
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public MemberService memberService() {
return new MemberServiceImpl(new MemoryMemberRepository());
}
}
다음과 같이 @Configuration
과 @Bean
애노테이션을 통해 DI 컨테이너와 주입할 객체들을 명시적으로 지정하면, 스프링 애플리케이션이 기동되면서 위에서 기술한 컴포넌트 스캔의 수집 대상이 된다.
그렇다면 Cofig 객체를 통해 수집한 빈들이 스프링 컨테이너에 잘 되는지 조회하는 테스트 코드를 작성해보도록 하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class ApplicationContextTest {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(MemberConfig.class);
@Test
@DisplayName("전체 애플리케이션 빈 조회하기")
void findAllBeans() {
String[] beanDefinitionNames = ac.getBeanDefinitionNames();
for (String beanName : beanDefinitionNames) {
BeanDefinition beanDefinition = ac.getBeanDefinition(beanName);
if (beanDefinition.getRole() == BeanDefinition.ROLE_APPLICATION) {
Object bean = ac.getBean(beanName);
System.out.println("beanName = " + beanName + " | bean = " + bean);
}
}
}
@Test
@DisplayName("회원 서비스 빈 찾아오기")
void findMemberServiceBean() {
MemberService memberService = ac.getBean(MemberService.class);
Assertions.assertThat(memberService).isInstanceOf(MemberServiceImpl.class);
}
@Test
@DisplayName("회원 리포지토리 빈 찾아오기")
void findMemberRepositoryBean() {
MemberRepository memberRepository = ac.getBean(MemberRepository.class);
Assertions.assertThat(memberRepository).isInstanceOf(MemoryMemberRepository.class);
}
}
AnnotationApplicationContext
객체는 ApplicationContext
의 구현체 중 하나로, 스프링 빈의 관리를 자바로 구현하는 타입의 구현체이다. 과거 XML 관리 방식 및 Groovy 등을 이용하는 구현체도 있다.
엄밀히 말하자면 ApplicationContext
보다 빈을 관리하는 BeanFactory
객체를 스프링 컨테이너라고 정의하는 것이 맞지만, 최근에는 BeanFactory 에서 국제화 기능, 환경변수, 애플리케이션 이벤트, 편리한 리소스 조회 등 여러 기능을 덧붙인 ApplicationContext 객체를 스프링 컨테이너라고 부른다.
소스에 대한 설명으로 돌아가자면, 스프링 컨테이너에서 전체 등록된 스프링 빈을 가져와서 사용자가 직접 등록한 객체 (ROLE_APPLICATION) 들만 정리하여 출력한다. BeanDefinition
은 객체의 메타데이터를 포함하고 있는 객체이다.
실행 결과를 보면 Config 객체를 포함하여 Bean으로 등록한 객체들이 잘 출력 되고, 직접 지정한 객체 반환 타입이 출력되고 있는 것을 확인할 수 있다.
3.2 자동 등록
스프링 애플리케이션이 시작하면서 컴포넌트 스캔을 통해 객체들을 수집하고 스프링 빈으로써 자동으로 저장한다고 앞서 설명한 바 있다.
그렇다면 자동으로 빈으로 수집되는 객체들은 어떠한 객체들일까?
사실은 위에서 수동 등록을 테스트하며 한 가지 자동 등록의 예를 이미 살펴보았다. 바로 @Configuration
애노테이션이 붙은 설정 객체이다.
스프링에서는 자주 쓰는 객체들 몇 가지를 대상으로 하는 애노테이션이 존재하는데 이 애노테이션들을 스프링이 기동하며 자동으로 수집한다.
대표적인 예로는 @Configuration
, @Controller
, @Service
, @Repository
등이 있다.
이들은 모두 @Component
애노테이션을 상속받는데, 결국 @Component
애노테이션이 붙은 객체를 모두 수집하다고 정리할 수 있다.
애노테이션의 상속은 공식적인 자바 문법이 아니고, 스프링이 편의상 제공하는 기능이다.
원래라면 스프링이 기동되면서 컴포넌트 스캔이 한번 이루어지지만, 우리는 테스트 환경에서 컴포넌트 스캔을 수동으로 실행해 주어야 하기 때문에 별도의 설정 환경을 만들어 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
@ComponentScan(
basePackages = "jwjung.spring.remind",
excludeFilters = @ComponentScan.Filter(
type = FilterType.ANNOTATION, classes = Configuration.class
)
)
public class MemberAutoConfig {
}
@Repository
public class MemoryMemberRepository implements MemberRepository {
// 이하 생략
@Service
public class MemberServiceImpl implements MemberService {
// 이하 생략
컴포넌트 스캔만을 명시하는 Config 객체를 생성하고 스캔의 대상이 될 객체들에 다음과 같이 @Component
의 하위 애노테이션을 붙여준다.
그리고 아까 했던 테스트와 비슷하게 테스트를 작성해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ComponentScanTest {
AnnotationConfigApplicationContext ac =
new AnnotationConfigApplicationContext(MemberAutoConfig.class);
@Test
@DisplayName("전체 애플리케이션 빈 조회하기")
void findAllBeans() {
String[] beanDefinitionNames = ac.getBeanDefinitionNames();
for (String beanName : beanDefinitionNames) {
BeanDefinition beanDefinition = ac.getBeanDefinition(beanName);
if (beanDefinition.getRole() == BeanDefinition.ROLE_APPLICATION) {
Object bean = ac.getBean(beanName);
System.out.println("beanName = " + beanName + " | bean = " + bean);
}
}
}
}
분명 Config
객체 내에 명시한 스프링 빈이 없음에도 불구하고 Service 객체와 Repository
객체가 스프링 빈으로 컨테이너 안에 저장되어 있음을 확인할 수 있다.
이를 통해 어떻게 여태까지 일일히 명시적인 DI를 지정하지 않아도 의존성이 주입되어 왔는지를 알아보았다.
그렇다면 혹시 같은 부모 인터페이스를 상속받는 구현체 두개가 모두 컴포넌트로 등록되어 있다면, 스프링이 MemberServiceImpl
에 주입할 객체를 어떻게 판단할 수 있을까?
MemberRepository
를 상속받는 MemoryMemberRepository
와 JpaMemberRepository
양쪽에 모두 @Repository
애노테이션을 붙이고 같은 테스트 메서드를 실행해보자.
다음과 같은 친절한 에러 문구를 확인할 수 있다.
No qualifying bean of type ‘jwjung.spring.remind.repository.MemberRepository’ available: expected single matching bean but found 2: jpaMemberRepository,memoryMemberRepository
MemberServiceImpl
객체에 주입해야 할 의존성을 스프링이 판단 할 수가 없으므로 NoUniqueBeanDefinitionException
런타임 예외가 발생한다.
이럴때 두가지 방법을 통해 객체 주입 선택권을 결정할 수 있는데, 첫번째는 @Primary
애노테이션을 사용하는 방법과, @Qualifer
애노테이션을 사용하는 방법이다.
@Primary
애노테이션을 사용하는 방법은 애매한 상황의 여러 가지 구현체중, 실제로 주입받을 객체 위에 @Primary
애노테이션을 붙여주는 것이다.
1
2
3
4
@Repository
@Primary
public class MemoryMemberRepository implements MemberRepository {
// 이하 생략
또 한가지 @Qualifer
애노테이션을 사용하는 방법은, 각 구현체에 @Qualifer
애노테이션으로 명칭을 명시하고, 실제로 주입받는 객체에도 주입받는 대상의 @Qualifer
애노테이션을 일치시켜주면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Repository
@Qualifier("memoryMemberRepository")
public class MemoryMemberRepository implements MemberRepository {
// 이하 생략
@Repository
@Qualifier("jpaMemberRepository")
public class JpaMemberRepository implements MemberRepository {
// 이하 생략
@Service
public class MemberServiceImpl implements MemberService {
private MemberRepository memberRepository;
public MemberServiceImpl(@Qualifier("memoryMemberRepository")
MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
// 이하 생략
4. 마무리
지금까지 객체지향적인 설계를 도와주는 스프링의 빈 관련 기능을 순차적으로 알아보았다.
단순히 스프링이 제공하는 기능을 사용하기만 하다가 무슨 이유로 이러한 기능들을 제공하기에 이르렀는지 한번 공부해보는 유익한 시간이었다.