스프링 핵심원리 - 기본편을 정리한 글입니다.
- 지금부터 역할과 구현을 나눠서 개발을 진행할 것이다.
- 주의할 점은 스프링의 도움 없이 순수한 자바만 사용하여 개발할 것이다.
- 실제 요구사항이 변경됐을 때 다형성, OCP, DIP가 잘 지켜지면서 유연하게 대처가 되는지 알아볼 것이다.
시작하기 전에 스프링 없이 순수한 자바로 진행할 것이지만, 초기에 편리함을 위해 스프링 부트를 사용하여 세팅한다.
나중에 스프링을 적용할 때 라이브러리들이 필요하기 때문에 일단 스프링 부트를 사용하여 초기 세팅을 한다.
프로젝트 생성
준비물
- Java 11 설치
- IDE: 인텔리제이
https://start.spring.io 에서 스프링 프로젝트 생성
Dependencies는 아무것도 선택하지 않으면 스프링 부트가 스프링 핵심 라이브러리만 가지고 구성해준다.
위와 같이 설정 후 GENERATE 버튼을 눌러 압축 파일을 다운로드 받는다.
압축 파일을 해제하여 해제된 폴더에서 build.gradle을 Open as Project로 open한다.
조금 더 빠른 실행
다음을 사용하여 빌드 및 실행 부분이 gradle로 되어 있을 것이다.
빌드와 테스트 모두 인텔리제이로 바꿔주자.
실행할 때 gradle을 통해 실행하는 것이 아니라 인텔리제이로 바로 자바를 실행하기 때문에 조금 더 빠르다.
비즈니스 요구사항과 설계
회원
- 회원을 가입하고 조회할 수 있다.
- 회원은 일반과 VIP 두 가지 등급이 있다.
- 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다. (미확정)
주문과 할인 정책
- 회원은 상품을 주문할 수 있다.
- 회원 등급에 따라 할인 정책을 적용할 수 있다.
- 할인 정책은 모든 VIP는 1000원을 할인해주는 고정 금액 할인을 적용해달라. (나중에 변경 될 수 있다.)
- 할인 정책은 변경 가능성이 높다. 회사의 기본 할인 정책을 아직 정하지 못했고, 오픈 직전까지 고민을 미루고 싶다.
최악의 경우 할인을 적용하지 않을 수 도 있다. (미확정)
요구사항을 보면 회원, 할인 정책 같은 부분은 개발 초기에 결정하기 어려운 부분이다.
그렇다고 정책이 결정될 때까지 개발을 기다릴 수 없다.
객체 지향 설계 방법을 통해 개발하면서 나중에 변경된 요구사항을 적용할 수 있다.
위에서 말했듯이 스프링이 없는 순수 자바로만 개발을 진행한다. 추후에 스프링을 적용할 것이다.
회원 도메인 설계
회원 도메인 요구사항
- 회원을 가입하고 조회할 수 있다.
- 회원은 일반과 VIP 두 가지 등급이 있다.
- 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다. (미확정)
회원 도메인 협력 관계
요구사항에 따라 회원 도메인을 설계한다.
- 회원을 가입하고 조회할 수 있다.
➜ 회원 서비스: 회원가입, 회원조회 - 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다. (미확정)
➜ 회원 데이터에 접근하는 계층(회원 저장소)을 따로 만든다. 회원 저장소의 역할은 세 가지 중 한 개를 선택한다.
어떤 저장소를 사용할 지 정해지지 않았기 때문에 일단, 메모리 회원 저장소에서 로컬로 개발용으로 사용한다.
이렇게 개발하다가 나중에 데이터 저장 방법이 정해지면 해당 부분만 구현하여 교체하면 된다. 역할과 구현 분리
회원 클래스 다이어그램
구현 단계로 내려오면 클래스 다이어그램을 그려 전체적인 구조를 만든다.
- MemberService라는 역할을 인터페이스로 만들고, 구현체로 MemberServiceImpl을 만든다.
- 그 다음 회원 저장소에 해당하는 MemberRepository를 만든다.
MemberRepository의 구현체로 MemoryMemberRepository, DbMemberRepository를 구현할 수 있다.
회원 객체 다이어그램
실제 객체 간 참조는 위와 같이 이루어진다.
클라이언트는 회원 서비스를 참조하고, 회원 서비스는 메모리 회원 저장소를 참조한다.
회원 도메인 개발
위 다이어그램을 참고하면서 회원 도메인을 스프링없이 순수 자바로 개발해보자.
도메인
Grade
public enum Grade {
BASIC,
VIP
}
Member
public class Member {
private Long id;
private String name;
private Grade grade;
public Member(Long id, String name, Grade grade) {
this.id = id;
this.name = name;
this.grade = grade;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Grade getGrade() {
return grade;
}
public void setGrade(Grade grade) {
this.grade = grade;
}
}
서비스
MemberService
public interface MemberService {
void join(Member member);
Member findMember(Long memberId);
}
MemberServiceImpl
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
@Override
public void join(Member member) {
memberRepository.save(member);
}
@Override
public Member findMember(Long memberId) {
return memberRepository.findById(memberId);
}
}
리포지토리 - 저장소
MemoryMemberRepository
public class MemoryMemberRepository implements MemberRepository {
private static Map<Long, Member> store = new HashMap<>();
@Override
public void save(Member member) {
store.put(member.getId(), member);
}
@Override
public Member findById(Long memberId) {
return store.get(memberId);
}
}
회원 도메인 실행과 테스트
회원 도메인이 정상적으로 동작하는지 테스트해보자.
member 패키지에 MemberApp 클래스에서 테스트를 진행한다.
public class MemberApp {
public static void main(String[] args) {
MemberService memberService = new MemberServiceImpl();
Member member = new Member(1L, "memberA", Grade.VIP);
memberService.join(member);
Member findMember = memberService.findMember(1L);
System.out.println("new member = " + member.getName());
System.out.println("findMember = " + findMember.getName());
}
}
스프링 없이 자바로 테스트를 해봤다. 정상적으로 member가 join되는 것을 확인 할 수 있다.
이런식으로 테스트하는 데 한계가 있으니 JUnit이라는 테스트 프레임워크를 사용할 것이다.
test/hello/core/member 에서 테스트한다.
package hello.core.member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
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);
}
}
정상적으로 동작하는 것을 확인했다.
회원 도메인 설계의 문제점
이 코드처럼 설계하는 것은 문제가 있다. 앞선 강의에서 언급한 OCP와 DIP가 잘 지켜지고 있는지에 대한 여부다.
현재 DB는 미확정으로 일단 MemoryMemberRepository를 사용했다.
그런데 이 저장소를 나중에 다른 저장소로 교체할 때 OCP 원칙이 지켜질 것인가?
또, DIP가 잘 지켜지고 있을까?
이 코드는 MemberServiceImpl이 인터페이스(MemberRepository - 추상화)뿐만 아니라
구현(MemoryMemberRepository - 구체화)까지 모두 의존하는 문제점이 있다.
즉, 추상화와 구체화하는 것에 의존하고 있어 DIP를 위반한 것이다. 이 코드는 나중에 구현체 변경에 문제가 생길 수 있다.
여기까지 스프링 없이 순수한 자바로 회원 도메인을 설계했다.
설계 과정에서 발생한 문제들은 앞으로 계속 설계하면서 어떻게 해결할 지 생각하자.
주문과 할인 도메인 설계
주문과 할인 정책을 다시 보자.
- 회원은 상품을 주문할 수 있다.
- 회원 등급에 따라 할인 정책을 적용할 수 있다.
- 할인 정책은 모든 VIP는 1000원을 할인해주는 고정 금액 할인을 적용해달라. (나중에 변경 될 수 있다.)
- 할인 정책은 변경 가능성이 높다. 회사의 기본 할인 정책을 아직 정하지 못했고, 오픈 직전까지 고민을 미루고 싶다.
최악의 경우 할인을 적용하지 않을 수 도 있다. (미확정)
이 할인 정책을 고려해서 객체 지향 설계를 해보자.
주문 도메인 협력, 역할, 책임 - 역할
1. 주문 생성: 클라이언트는 주문 서비스에 주문 생성을 요청한다.
2. 회원 조회: 할인을 위해서는 회원 등급이 필요하다. 그래서 주문 서비스는 회원 저장소에서 회원을 조회한다.
3. 할인 적용: 주문 서비스는 회원 등급에 따른 할인 여부를 할인 정책에 위임한다.
4. 주문 결과 반환: 주문 서비스는 할인 결과를 포함한 주문 결과를 반환한다.
주문 도메인 전체 - 역할과 구현
- 저장소를 역할과 구현을 분리했던 것처럼 할인 정책에서 역할과 구현을 분리했다.
- 이렇게 분리함으로써 정책(구현체)을 교체만 하면 하면 할인 정책 역할을 수행할 수 있다.
- 역할과 구현을 분리함으로써 구현 객체를 조립할 수 있게 설계했다.
➜ 변경 가능성이 있는 미확정된 부분들에 대해 유연하게 변경할 수 있다.
주문 도메인 클래스 다이어그램
- OrderService 인터페이스를 만들고 그에 대한 구현체로
OrderServiceImpl(보통 인터페이스에 대한 구현체가 하나면 뒤에 Impl을 붙인다)을 구현한다. - OrderServiceImpl이 MemberRepository와 DicountPolicy를 사용한다.
주문 도메인 객체 다이어그램1
객체 다이어그램은 실제 내가 new로 생성하여 애플리케이션을 띄우면 동적으로 객체들의 연관관계를 보여준다.
- 회원을 메모리에서 조회하고, 정액 할인 정책(고정 금액)을 지원해도 주문 서비스를 변경하지 않아도 된다.
- 역할들의 협력 관계를 그대로 재사용 할 수 있다.
주문 도메인 객체 다이어그램2
- 회원을 메모리가 아닌 실제 DB에서 조회하고, 정률 할인 정책(주문 금액에 따라 % 할인)을 지원해도
주문 서비스를 변경하지 않아도 된다. - 협력 관계를 그대로 재사용 할 수 있다.
주문과 할인 도메인 개발
주문과 할인정책을 인터페이스부터 구현체까지 작성하자.
할인 정책 인터페이스, 구현
DiscountPolicy
package hello.core.discount;
import hello.core.member.Member;
public interface DiscountPolicy {
/**
* @return 할인 대상 금액
*/
int discount(Member member, int price);
}
FixDiscountPolicy
public class FixDiscountPolicy implements DiscountPolicy{
private int discountFixAmount = 1000; // 1000원 할인
@Override
public int discount(Member member, int price) {
if (member.getGrade() == Grade.VIP) {
return discountFixAmount;
} else {
return 0;
}
}
}
주문 인터페이스, 구현
Order
public class Order {
private Long memberId;
private String itemName;
private int itemPrice;
private int discountPrice;
public Order(Long memberId, String itemName, int itemPrice, int discountPrice) {
this.memberId = memberId;
this.itemName = itemName;
this.itemPrice = itemPrice;
this.discountPrice = discountPrice;
}
public int calculatePrice() {
return itemPrice - discountPrice;
}
public Long getMemberId() {
return memberId;
}
public void setMemberId(Long memberId) {
this.memberId = memberId;
}
public String getItemName() {
return itemName;
}
public void setItemName(String itemName) {
this.itemName = itemName;
}
public int getItemPrice() {
return itemPrice;
}
public void setItemPrice(int itemPrice) {
this.itemPrice = itemPrice;
}
public int getDiscountPrice() {
return discountPrice;
}
public void setDiscountPrice(int discountPrice) {
this.discountPrice = discountPrice;
}
@Override
public String toString() {
return "Order{" +
"memberId=" + memberId +
", itemName='" + itemName + '\'' +
", itemPrice=" + itemPrice +
", discountPrice=" + discountPrice +
'}';
}
}
OrderService
public interface OrderService {
Order createOrder(Long memberId, String itemName, int itemPrice);
}
OrderServiceImpl
public class OrderServiceImpl implements OrderService{
private final MemberRepository memberRepository = new MemoryMemberRepository();
private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
@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);
}
}
이제 주문과 할인 도메인을 테스트해보자.
주문과 할인 도메인 실행과 테스트
위에서 회원을 테스트했던 것처럼 출력하여 테스트를 해보자.
public class OrderApp {
public static void main(String[] args) {
MemberService memberService = new MemberServiceImpl();
OrderService orderService = new OrderServiceImpl();
Long memberId = 1L;
Member member = new Member(memberId, "memberA", Grade.VIP);
memberService.join(member);
Order order = orderService.createOrder(memberId, "itemA", 10000);
System.out.println("order = " + order.toString());
}
}
입력한대로 값이 잘 나온 것을 확인했다.
이제 JUnit으로 테스트 해보자.
public class OrderServiceTest {
MemberService memberService = new MemberServiceImpl();
OrderService orderService = new OrderServiceImpl();
@Test
void createOrder() {
Long memberId = 1L;
Member member = new Member(memberId, "memberA", Grade.VIP);
memberService.join(member);
Order order = orderService.createOrder(memberId, "itemA", 10000);
Assertions.assertThat(order.getDiscountPrice()).isEqualTo(1000);
}
}
Assertions은 org.assertJ.core.api.Assertions을 사용한다.
정상적으로 테스트를 통과했다.
정리
- 비즈니스 요구사항에 따라 회원 도메인과 주문과 할인 도메인을 설계하고 구현했다.
- 미확정된 부분을 감안하여 자유롭게 구현체를 교체하기 위해 역할과 구현을 분리했다.
- 인터페이스가 구현체를 의존하지 않게 설계하는 것이 중요하다.
- 출력하여 테스트, JUnit을 이용한 테스트 장단점과 테스트 코드의 중요성
- 스프링 없이 순수 자바로 회원과 주문 할인 정책 설계 구현
- 클라이언트가 주문 서비스에 1. 주문 생성을 하고
- 주문 서비스 역할이 회원 저장소역할을 이용해서 2. 회원 조회 하고
- 주문 서비스 역할이 할인 정책 역할을 이용해서 3. 할인 적용 하고
- 그 결과를 클라이언트에 반환한다.
여기선 정액 할인 정책을 사용했다.
다음 장에선 정률 할인 정책으로 바꿨을 때 클라이언트에 영향을 주지 않는지 확인할 것이다.