5) RateDiscountPolicy 를 의존관계로 주입하려는 클래스 OrderServiceImpl코드를 열어보자.
DiscountPolicy 에 @Qualifier 를 지정하되, 방금 만든 @MainDiscountPolicy를 쓰자.
6) 로그를 보면 정상적으로 스프링 빈이 등록되고 의존관계도 주입됬음을 확인할 수 있다.
Creating shared instance of singleton bean 'orderServiceImpl'
Autowiring by type from bean name 'orderServiceImpl' via constructor to bean named 'memoryMemberRepository'
Autowiring by type from bean name 'orderServiceImpl' via constructor to bean named 'rateDiscountPolicy'
OrderServiceImpl 싱글톤 빈이 생성됬고,
생성자를 통해 memoryMemberRepository와 rateDiscountPolicy 빈이 의존관계 주입이 됬다.
다음 강의에서는조회한 빈이 모두 필요할 때 List, Map 으로 처리하는 방법을 배운다.
@ComponentScan 에 includeFilters와 excludeFilters를 적용할 수 있다.
includeFilters 에는 포함할 애노테이션인 MyIncludeComponent.class를 써주고,
excludeFilters 에는 제외하는 필터 애노테이션이니까 MyExcludeComponent.class를 써주자.
3) 필터가 적용된 컴포넌트 스캔을 하되, 스프링 빈이 생성됬는지 확인하자.
BeanA만 컴포넌트 스캔에 포함되어야 한다. BeanA 스프링 빈으로 등록되고, BeanB 라는 빈은 찾을 수 없어야 테스트 성공이다.
3) FilterType 옵션 5가지를 알아보자.
* ANNOTATION : 기본값. 애노테이션을 인식해서 동작하는 것이 기본이다.
" type = FilterType.ANNOTATION "을 지워도 똑같이 동작한다.
* ASSIONABLE_TYPE : 지정한 타입과 자식 타입을 인식해서 동작한다
org.example.SomeClass
* ASPECTJ : AspectJ 패턴을 사용한다
org.example..*Service+
* REGEX : 정규표현식을 줄 수 있다
org\.example\.Default.*
* CUSTOM : TypeFilter 라는 인터페이스를 구현해서 처리한다
org.example.MyTypeFilter
[ 참고 ]
@Component 애노테이션으로 충분하기 때문에 includeFilters 를 사용할 일은 드물다. excludeFilters는 아주 가끔 쓴다.
스프링 부트가 기본으로 제공하는 컴포넌트 스캔을 사용하는 것을 권장한다.
4. 중복 등록과 충돌
컴포넌트 스캔에서 같은 빈 이름을 등록하면 어떻게 될까?
다음 두 가지 상황이 있다.
1) 자동 빈 등록 vs 자동 빈 등록
컴포넌트 스캔에 의해 자동으로 스프링 빈이 등록될 때 이름이 같으면 오류가 발생한다.
예를 들어, 2개의 빈에 service라고 이름 붙여보자.
MemberServiceImpl 과 OrderServiceImpl에 둘 다 @Component("service")라고 붙이자.
컴포넌트 스캔을 해보면, 빈이 충돌난다고 ConflictingBeanDefinitionException 에러가 난다.
org.springframework.beans.factory.BeanDefinitionStoreException:
Failed to parse configuration class [hello.corebasic.AutoAppConfig];
nested exception is org.springframework.context.annotation.ConflictingBeanDefinitionException:
Annotation-specified bean name 'service' for bean class [hello.corebasic.order.OrderServiceImpl] conflicts with existing,
non-compatible bean definition of same name and class [hello.corebasic.member.MemberServiceImpl]
에러 내용이 굉장히 친절하다. 애노테이션으로 이름지어진 'service'라는 빈 이름이 있는데,
MemberServiceImpl 에도 정의되어 있고, OrderServiceImpl 에도 같은 이름으로 정의되어 있다는 내용이다.
2) 수동 빈 등록 vs 자동 빈 등록
이 경우, 수동 빈 등록이 우선권을 가진다. 수동 빈이 자동 빈을 오버라이딩한다.
예를 들어, MemoryMemberRepository는 @Component 를 달아놔서 컴포넌트 스캔의 대상이되고
빈 이름은 기본으로 클래스 명 앞글자 하나만 소문자로 바꿔서 memoryMemberRepository 로 이름지어진다.
@ComponentScan 하는 설정에서 memoryMemberRepository라는 똑같은 이름으로 MemoryMemberRepository 빈 등록을 시도해보자.
컴포넌트 스캔 하는 테스트를 실행하면? 성공하긴 한다.
수동 빈이 자동 빈을 오버라이딩 한다고 로그 메시지가 출력된다.
Overriding bean definition for bean 'memoryMemberRepository' with a different definition:
replacing [Generic bean: class [hello.corebasic.member.MemoryMemberRepository];
하지만 현실은 개발자가 의도적으로 설정해서 이런 결과를 낳기보다는 여러 설정들이 꼬여서 이렇게 되는 경우가 다반사다!
이러면 정말 잡기 어려운 버그가 만들어진다. 항상 잡기 어려운 버그는 애매한 버그다.
3) 최근 스프링 부트에서는 수동 빈 등록과 자동 빈 등록이 이름 충돌이 나면 아래 내용의 오류를 내도록 바뀌었다.
스프링 부트를 실행해보자.
"이미 AutoAppConfig.class 에 정의된(수동으로 등록한) 'memoryMemberRepository' 빈이 등록되지 못했다.
MemoryMemberRepository 에 정의된 이름으로 빈이 등록되었기 때문이다. 그리고 오버라이딩이 불가능하다. "
The bean 'memoryMemberRepository',
defined in class path resource [hello/corebasic/AutoAppConfig.class],
could not be registered.
A bean with that name has already been defined in file [/Users/.../hello/corebasic
/member/MemoryMemberRepository.class] and overriding is disabled.
스프링 부트의 기본 설정으로는 이렇게 오류를 내지만,
application.yml 애플리케이션 프로퍼티 파일에 옵션을 아래처럼 바꾸면 수동 등록 빈 오버라이딩을 허용해준다.
Consider renaming one of the beans or enabling overriding by setting
spring.main.allow-bean-definition-overriding=true
[ 참고 ]
어설픈 추상화를 하지 말자. 코드 양이 좀더 나오더라도 명확하게 코딩하는 것이 낫다.
애매한 상황과 코드는 피해야 한다. 애매한 버그가 제일 잡기 어렵기 때문이다. 그리고 개발은 혼자하는 것이 아니기 때문이다.
AppConfig에 '구성 정보'를 의미하는 @Configuration 애노테이션을 붙인다.
DI 하는 메서드에 @Bean애노테이션을 붙인다.
2) ApplicationContext에 Bean을 등록
테스트를 위해 작성했던 MemberApp를 열어보자.
기존에는 AppConfig 에서 직접 필요한 객체를 꺼냈었다. 이제 스프링을 쓰자.
스프링은 ApplicationContext 로 시작한다. 이것이 객체(Bean)을 관리해주는 스프링 컨테이너다.
AppConfig.class 를 파라미터로 넘겨서 ApplicationContext를 생성하자.
AppConfig에 있는 설정 정보를 가지고 Bean을 스프링 컨테이너에 넣고 관리해준다.
객체(Bean)의 이름은 각각의 메서드 이름으로 붙여진다. ex) memberService 빈, memberRepository 빈
memberService 객체가 필요할 때 AppConfig에서 꺼내지 않고, 스프링 ApplicationContext에서 꺼낸다.
스프링 컨테이너에 빈으로 등록된 인스턴스가 로그에 뜬다!
3) OrderApp도 스프링으로 바꿔보자.
AppConfig.class 를 파라미터로 넘겨서 ApplicationContext를 생성하자.
ApplicationContext 에서 memberService와 orderService를 꺼낸다.
4) 스프링 컨테이너 (== DI 컨테이너)
ApplicationContext를 스프링 컨테이너라 한다.
기존에는 개발자가 AppConfig 에 객체를 생성하고 직접 DI를 구현했다.
이제 스프링 컨테이너가 @Configuration 애노테이션이 붙은 AppConfig를 설정(구성)정보로 사용한다. 여기서 @Bean이 붙은 메서드를 모두 호출해서 반환된 객체를 스프링 컨테이너에 모두 등록한다. 이렇게 스프링 컨테이너에 등록된 메서드를 스프링 빈이라고 한다.
스프링 빈은 @Bean애노테이션이 붙은 메서드 이름을 스프링 빈의 이름으로 사용한다.
스프링 빈은 applicationContext.getBean() 메서드로 찾을 수 있다.
코드가 더 복잡해진 것 같은데. 스프링 컨테이너를 사용하면 어떤 장점이 있을까 ? 다음 시간에 장점을 배우자.
지금은 스프링 없는 순수한 자바로만 개발을 진행한다는 점을 꼭 기억하자! 스프링 관련은 한참 뒤에 등장한다. 스프링부트는 프로젝트 생성이 편하니까 이용하는 것이다.
3) IntelliJ Gradle 대신에 자바 직접 실행
최근 IntelliJ 버전은 Gradle을 통해서 실행 하는 것이 기본 설정이다. 이렇게 하면 실행속도가 느리다.
다음과 같이 변경하면, IntelliJ가 자바를 바로 실행해서 실행속도가 더 빠르다.
Preferences -> Build, Execution, Deployment -> Build Tools -> Gradle 열고 아래 내용으로 선택.
Build and run using: IntelliJ IDEA Run tests using: IntelliJ IDEA
4) Gradle Dependency에서 spring-boot-starter를 보면, 스프링 core 라이브러리를 확인할 수 있다.
2. 비즈니스 요구사항과 설계
기획자로부터 아래의 요구사항을 듣게 되는 상황이다.
요구사항은 크게 회원, 주문과 할인 2 가지로 구성된다.
1) 회원
회원을 가입하고 조회할 수 있다.
회원은 일반과 VIP 두 가지 등급이 있다.
회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다. (미확정)
2) 주문과 할인 정책
회원은 상품을 주문할 수 있다.
회원 등급에 따라 할인 정책을 적용할 수 있다.
할인 정책은 모든 VIP는 1000원을 할인해주는 고정 금액 할인을 적용해달라. (나중에 변경 될 수 있다.)
할인 정책은 변경 가능성이 높다. 회사의 기본 할인 정책을 아직 정하지 못했고, 오픈 직전까지 고민을 미루고 싶다. (미확정)
확정되지 않은 부분이 있더라도, 인터페이스를 만들어 두면 구현체는 언제든 갈아 끼울 수 있도록 설계하면 된다!
도메인 설계 부터 시작해보자.
3. 회원 도메인 설계
1) 회원 도메인 요구사항
회원을 가입하고 조회할 수 있다.
회원은 일반과 VIP 두 가지 등급이 있다.
회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다. (미확정)
2) 도메인 협력 관계
기획자도 보는 그림이다. 도메인 협력 관계를 기반으로 클래스 다이어그램을 그린다.
회원 서비스 : 회원 데이터에 접근할 수 있는 계층을 따로 만든다.
회원 저장소 : 저장소 인터페이스를 먼저 만든다. 자체 DB를 쓸 지, 외부 시스템 연동을 할지 미정이기 때문이다. 일단 메모리 저장소를 사용하자.
(역할과 구현을 분리한다!)
3) 클래스 다이어그램
실제 구현 레벨로 내려오면 클래스, 인터페이스 명세를 작성한 클래스 다이어그램이 필요하다.
클래스 간의 의존 관계, 연관 관계, 제약 조건 등을 기반으로 개발한다. 백문이 불여일타! 곧 코드로 옮겨보자.
회원 서비스 인터페이스 : MemberService
회원 서비스 구현체 : MemberServiceImpl
저장소 인터페이스 : MemberRepository
저장소 구현체 : MemoryMemberRepository
4) 객체 다이어그램 : 런타임에서 객체가 생성됬을 때의 시나리오를 다이어그램으로 표현
저장소가 어떤 것이 생성되는지는 런타임에서 객체가 생성 됬을때 정해진다.
특정 순간에 객체 간의 관계 및 흐름을 표현한다.
4. 회원 도메인 개발
1) 회원 엔티티
class Member, enum Grade
2) 회원 저장소
인터페이스: interface MemberRepository
구현체: class MemoryMemberRepository
인터페이스와 구현체는 다른 패키지에 두면 좋다. 지금은 작은 예제니까 member 패키지에 함께 뒀다.
오류처리 같은 예외처리는 제쳐두고 가입, 조회 기능에 집중한다.
메모리 회원 저장소는 동시성 이슈 때문에 실무에서는 ConcurrentHashMap을 써야 하지만, 간단한 하게 HashMap을 쓴다.
private static Map<Long, Member> store = new HashMap<>(); // 메모리 저장소
3) 회원 서비스
인터페이스 interface MemberService
구현체 class MemberServiceImpl
public class MemberServiceImpl implements MemberService{
// 구현 객체를 MemoryMemberRepository 로 선택해주자
private final MemberRepository memberRepository = new MemoryMemberRepository();
5. 회원 도메인 실행과 테스트
1) JUnit Test Framework를 이용한다.
main메서드에서 시험하는 것에는 한계가 있기 때문이다. 테스트 코드를 제대로 짜야 좋은 코드가 나온다.
패키지 레벨을 맞추고 Test파일 만들기
Tip ) 아래와 같은 구조로 테스트 메서드를 작성하면 좋다.
2) 회원 서비스에서 저장 및 조회 JUnit 테스트 코드
public class MemberServiceTest {
MemberService memberService = new MemberServiceImpl();
@Test
void join(){
// given : 테스트 대상
Member member = new Member(1L, "memberA", Grade.VIP);
// when : 시험 내용
memberService.join(member);
Member findMember = memberService.findMember(1L);
// then : 기댓값
Assertions.assertThat(member).isEqualTo(findMember);
}
}
3) 주의 ! Assertions 는 assertj.core.api 를 쓰자. 헷갈리지 말기.
import org.assertj.core.api.Assertions;
4) 회원 서비스 구현체를 보면 구조적 문제가 있다?
의존 관계가 인터페이스 뿐만 아니라 구현까지 모두 의존하는 문제가 있다.
아래 코드를 보자.
MemberServiceImpl 구현체가 MemberRepository 인터페이스 뿐만 아니라, MemoryMemberRepository 구현체 까지 모두 의존하고 있다.
-> DIP위반
public class MemberServiceImpl implements MemberService{
// 구현 객체를 MemoryMemberRepository 로 선택해주자
private final MemberRepository memberRepository = new MemoryMemberRepository();
6. 주문과 할인 도메인 설계
1) 주문과 할인 정책 요구사항
회원은 상품을 주문할 수 있다.
회원 등급에 따라 할인 정책을 적용할 수 있다.
할인 정책은 모든 VIP는 1000원을 할인해주는 고정 금액 할인을 적용해달라. (나중에 변경 될 수 있다.)
할인 정책은 변경 가능성이 높다. 회사의 기본 할인 정책을 아직 정하지 못했고, 오픈 직전까지 고민을 미루고 싶다. (미확정)
2) 주문 도메인 협력, 역할, 책임 : 기획자와 개발자가 구상하는 설계도
주문 생성 : 파라미터 3개를 넘겨서 주문. 회원id, 상품명, 상품 가격 -> 주문 서비스 인터페이스
회원 조회 : 할인 받을 수 있는 등급인지 회원id로 저장소에 조회 -> 레포지토리 인터페이스
할인 적용 : 할인 정책은 회원 등급에 따라 할인을 적용해줌 -> 할인 정책 인터페이스
주문 결과 반환 : 실무에서는 데이터를 DB에 저장하지만, 예제를 단순화 하기 위해 주문 결과만 반환. -> 주문 서비스 인터페이스
3) 주문 도메인 전체 : 역할과 구현의 분리
앞서 배운대로 역할은 인터페이스, 구현은 구현클래스다.
역할과 구현을 분리해서 자유롭게 구현 객체를 조립할 수 있게 설계했다.
구현 객체가 점선 화살표로 인터페이스를 바라보고 있다.
회원 저장소는 물론이고 할인 정책도 유연하게 변경할 수 있다.
4) 클래스 다이어그램 : 정적 정보. 클래스, 인터페이스의 명세
주문 서비스는 인터페이스와 구현체로 분리되어 있다.
주문 서비스 구현체는 회원 저장소 인터페이스에 의존한다.
주문 서비스 구현체는 할인 정책 인터페이스에 의존한다.
구현체가 인터페이스(역할)에만 의존한다
5) 객체 다이어그램 : 동적 정보. 런타임에서 특정 순간에 객체 간의 관계 및 상황을 표현
회원을 메모리에서 조회하고, 정액 할인 정책을 지원하는 경우.
역할들의 협력관계를 그대로 재사용 할 수 있다.
즉, 저장소의 구현체가 바뀌어도, 주문 서비스 구현체를 변경할 필요가 없다.
객체 다이어그램1
회원을 DB에서 조회하고, 정률 할인 정책을 지원하는 경우.
이 경우에도 역할들의 협력관계를 그대로 재사용 할 수 있다.
객체 다이어그램2
이제 개발하자.
7. 주문과 할인 도메인 개발
구현내용 : 주문 생성 요청이 오면, 회원 정보를 조회하고, 할인 정책을 조회 한 다음 주문 객체를 생성해서 반환한다.
주문 생성 요청 받기 -> 주문 서비스 인터페이스에 의존
회원 정보를 조회 -> 멤버 리포지토리 인터페이스에 의존
할인 정책 조회 -> 할인 정책 인터페이스에 의존
1) 할인 정책 인터페이스 : interface DiscountPolicy
public interface DiscountPolicy {
// @return 할인 대상 금액
int discount(Member member, int price);
}
2) 고정 할인 정책 구현체 : class FixDiscountPolicy
3) 주문 : class Order
4) 주문 서비스 인터페이스 : interface OrderService
Order createOrder(Long memberId, String itemName, int itemPrice);
5) 주문 서비스 구현체 : class OrderServiceImpl
8. 주문과 할인 도메인 실행과 테스트
1) main() 메서드에서 테스트 코드
JUnit 테스트에 익숙치 않은 사람을 위해 임시로..
2) JUnit 테스트 코드 "JUnit 단위 테스트 정말 중요합니다."
public class OrderServiceTest {
MemberService memberService = new MemberServiceImpl();
OrderService orderService = new OrderServiceImpl();
@Test
void createOrder(){
Long memberId = 1L; // null 이 들어갈 수도 없어서 wrapper type 썼음
Member member = new Member(memberId, "itemA", Grade.VIP);
memberService.join(member);
Order order = orderService.createOrder(memberId, "itemA", 10000);
Assertions.assertThat(order.getDiscountPrice()).isEqualTo(1000);
}
}