이 글은 김영한 님의 스프링 핵심 원리 - 기본편 강좌 수강 후 정리한 글입니다.
(https://www.inflearn.com/course/스프링-핵심-원리-기본편/dashboard)
프로젝트 생성
https://start.spring.io/에서 다음과 같이 설정 한 뒤 압축 해제 후에 Intellij에서 실행한다.
여기서는 순수 자바의 역량을 보여주기 위해서 어떠한 외부 Dependecies도 추가하지 않고 진행하였다.
비즈니스 요구 사항과 설계
요구사항을 보면 회원 데이터, 할인 정책 같은 부분은 지금 결정하기 어려운 부분이다. 오픈 일정은 정해져 있기 때문에 그렇다고 이런 정책이 결정될 때까지 개발을 무기한 기다릴 수 없다. 앞에서 배운 객체 지향 설계 방법을 사용하여 인터페이스를 만들고 구현체를 언제든지 갈아 끼울 수 있도록 설계하면 된다.
※ 프로젝트 환경설정을 편리하게 하려고 스프링 부트를 사용한 것이다. 지금은 스프링 없는 순수한 자바로만 개발을 진행한다.
회원 도메인 설계
1. 요구 사항
- 회원을 가입하고 조회할 수 있다.
- 회원은 일반과 VIP 두 가지 등급이 있다.
- 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다. (미확정)
2. 회원 도메인 협력 관계
위의 관계도는 개발자뿐만 아니라 기획자들도 함께 보는 관계도이다. 아직 회원 저장소를 어떻게 할지 확정 짓지 않았으므로 세 가지로 나눠져 있는 것을 볼 수 있다.
3. 회원 클래스 다이어그램
위의 관계도를 보고 프로그래밍을 위해 개발자가 사용할 클래스들로 만든 다이어그램이다.
MemberService와 MemberRepository는 인터페이스이고 MemberServiceImpl과 MemoryMemberRepository, DbMemberRepository는 인터페이스의 구현체들이다.
4. 회원 객체 다이어그램
객체 간 참조 관계를 나타낸 다이어그램으로 어떤 MemoryRepository를 사용하는지는 동적으로 서버 실행 시점에 결정되기 때문에 클래스 다이어그램만으로 판단하기 어렵기 때문에 사용한다.
※ 회원 서비스 == MemberServiceImpl, 메모리 회원 저장소 == MemoryMemberRepository
회원 도메인 개발
● 회원 엔티티
1. 회원 등급
package hello.core.member;
public enum Grade {
BASIC,
VIP
}
회원의 등급을 나타내기 위해 enum 객체를 사용하였다.
※ enum : 열거형(enumerated type)이라고 부른다. 열거형은 서로 연관된 상수들의 집합이라고 할 수 있다.
2. 회원 엔티티
package hello.core.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;
}
}
회원의 ID, 이름, 등급을 담고 있는 객체이다. 또한 각 변수마다 setter와 getter가 존재해 Member내의 변수들의 값을 설정하고 값을 받아올 수 있다.
● 회원 저장소
1. 회원 저장소 인터페이스
package hello.core.member;
public interface MemberRepository {
void Save(Member member);
Member findById(Long memberId);
}
회원 저장소의 인터페이스이다. 앞에서 배운 대로 인터페이스에서는 저장(Save), 아이디로 조회(findById)와 같은 역할을 정해두었다.
2. 회원 저장소 인터페이스 구현체
위에서 역할을 정한 회원 저장소 인터페이스를 직접 구현체이다. 아직 정확히 DB가 확정되지 않았기 때문에 가장 단순한 메모리인 HashMap을 사용하여 구현하였다.
※ HahsMap은 동시성 이슈가 발생할 수 있다. 이럴 때는 ConcurrentMashMap을 사용해야 한다.
● 회원 서비스
1. 회원 서비스 인터페이스
package hello.core.member;
public interface MemberService {
void join(Member member);
Member findMember(Long memberId);
}
회원 서비스의 인터페이스이다. 마찬가지로 인터페이스에서는 회원 가입(join), 회원 조회(findMember)라는 역할을 정해두었다.
2. 회원 서비스의 구현체
package hello.core.member;
public class MemberServiceImpl implements MemberService{
private final MemberRepository memberRepository = new MemberMemoryRepository();
@Override
public void join(Member member) {
memberRepository.Save(member);
}
@Override
public Member findMember(Long memberId) {
return memberRepository.findById(memberId);
}
}
위에서 역할을 정한 회원 서비스 인터페이스의 구현체이다. memberRepository에서 정한 역할들을 사용하여 원하는 기능들을 구현했다.
MemberRepository는 인터페이스이기 때문에 그대로 사용하면 NullPointerException이 발생한다.
private final MemberRepository memberRepository = new MemberMemoryRepository();
따라서 위 코드처럼 반드시 구현체와 연결 지어 사용해야 한다.
※ final을 사용하는 이유 : MemberRepository 타입의 memberRepository를 다른 타입의 객체로 변경하지 않기 위해서이다.
회원 도메인 실행과 테스트
1. 회원 도메인 - 회원 가입 main
package hello.core;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
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("member = " + member.getName());
System.out.println("findMember = " + findMember.getName());
}
}
결과
member = memberA
findMember = memberA
원하는대로 member의 이름과 findMember의 이름이 동일하게 잘 출력되었음을 확인할 수 있다.
애플리케이션 로직으로 테스트하는 것은 좋은 방법이 아니다. 테스트를 진행할 때는 꼭 JUnit 테스트를 사용하도록 하자.
2. 회원 도메인 - 회원 가입 테스트
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(){
//give
Member member = new Member(1L, "memberA", Grade.VIP);
//when
memberService.join(member);
Member findMember = memberService.findMember(1L);
//then
Assertions.assertThat(member).isEqualTo(findMember);
}
}
이렇게 JUnit을 이용하여 테스트를 진행해야 한다.
현대적인 앱을 개발하려면 이런 테스트 작성 방법을 익히는 것은 필수라고 한다.
3. 회원 도메인 설계의 문제점
MemberService memberService = new MemberServiceImpl();
위 코드에서 명확한 문제점이 드러나 있다.
의존관계가 인터페이스(MemberService) 뿐만 아니라 구현(MemberServiceImpl)까지 모두 의존하는 문제점이 있다.
즉 추상화에도 의존하고, 구체화에도 의존한다.
주문과 할인 도메인 설계
※ 실제로는 주문 결과도 DB에 저장해야 하지만 예제가 너무 복잡해질 수 있어서 생략하고, 단순하게 주문 결과를 반환하도록 한다.
역할과 구현을 분리해서 자유롭게 구현 객체를 조립할 수 있게 설계했다.
덕분에 회원 저장소는 물론이고, 아직 확정이 나지 않은 할인 정책도 유연하게 변경할 수 있다.
회원을 메모리에서 조회하고, 정액 할인 정책(고정 금액)을 지원하더라도 주문 서비스를 변경할 필요는 없다.
역할 간의 협력 관계를 그대로 재사용할 수 있다.
회원을 메모리가 아닌 실제 DB에서 조회하고, 정률 할인 정책(주문금액에 따른 % 할인)을 지원해도 주문 서비스를 변경하지 않아도 된다.
역할 간의 협력 관계를 그대로 재사용할 수 있다.
주문과 할인 도메인 개발
1. 할인 정책 인터페이스
package hello.core.discount;
import hello.core.member.Member;
public interface DiscountPolicy {
//@return 할인 금액
int discount(Member member, int price);
}
할인 정책의 인터페이스이다. 인터페이스에서 할인 금액을 반환하는 할인(discount)이라는 역할을 정해두었다.
2. 정액 할인 정책 구현체
package hello.core.discount;
import hello.core.member.Grade;
import hello.core.member.Member;
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;
}
return 0;
}
}
위에서 역할을 정한 할인 정책 인터페이스의 구현체이다. 아직 할인 정책이 정해지지 않았으나, VIP면 1000원을 할인해주는 정액 할인제로 구현하였다.
3. 주문 엔티티
package hello.core.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 calculatedPrice(){
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 +
'}';
}
}
주문자의 ID, 상품명, 상품 가격, 할인 가격과 같이 주문 정보를 담고 있는 객체이다.
각 변수마다 setter와 getter가 존재해 Order내의 변수들의 값을 설정하고 값을 받아올 수 있다.
calculatedPrice 함수는 discountPrice가 존재할 때 상품값을 계산하기 위한 함수이다.
또한 toString 함수는 객체 정보를 출력하기 위한 함수이다.
4. 주문 서비스 인터페이스
package hello.core.order;
public interface OrderService {
Order createOrder(Long memberId, String itemName, int itemPrice);
}
주문 서비스를 위한 인터페이스이다. 인터페이스에서 주문 내역을 생성할 주문 생성(createOrder)이라는 역할을 정해두었다.
5. 주문 서비스 구현체
package hello.core.order;
import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.member.Member;
import hello.core.member.MemberMemoryRepository;
import hello.core.member.MemberRepository;
public class OrderServiceImpl implements OrderService{
private final MemberRepository memberRepository = new MemberMemoryRepository();
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);
}
}
위에서 역할을 정한 주문 서비스 인터페이스의 구현체이다.
discountPrice를 알기 위해서 회원의 정보를 알아야 하기 때문에 memberRepository를 사용했고, 또 할인 가격을 알기 위해서 할인 정책을 알아야 하기 때문에 dicsountPolicy를 사용했다.
주어진 정보들(memberId, itemName, itemPrice)과 알아낸 정보(discountPrice)를 저장하여 생성한 주문 내역(Order)을 반환한다.
주문과 할인 도메인 실행과 테스트
1. 주문과 할인 정책 실행
package hello.core;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.order.Order;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;
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);
System.out.println("order.calculatedPrice = " + order.calculatedPrice());
}
}
결과
order = Order{memberId=1, itemName='itemA', itemPrice=10000,
discountPrice=1000}
할인 금액이 원하는대로 잘 출력되는 것을 확인할 수 있다.
애플리케이션 로직으로 테스트하는 것은 좋은 방법이 아니다. 테스트를 진행할 때는 꼭 JUnit 테스트를 사용하도록 하자.
2. 주문과 할인 정책 테스트
package hello.core.order;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
public class OrderServiceTest {
MemberService memberService = new MemberServiceImpl();
OrderService orderService = new OrderServiceImpl();
@Test
void createOrder(){
//given
Long memberId = 1L;
Member member = new Member(memberId, "nameA", Grade.VIP);
//when
memberService.join(member);
Order order = orderService.createOrder(memberId, "itemA", 10000);
//then
Assertions.assertThat(order.getDiscountPrice()).isEqualTo(1000);
}
}
이렇게 JUnit을 이용하여 테스트를 진행해야 한다.
다시 한번 말하면 현대적인 앱을 개발하기 위해서 이런 테스트 작성 방법을 익히는 것은 필수라고 한다.
'개발 > 스프링 기본' 카테고리의 다른 글
(스프링 기본) 6. 컴포넌트 스캔 (0) | 2022.10.27 |
---|---|
(스프링 기본) 5. 싱글톤 컨테이너 (0) | 2022.10.08 |
(스프링 기본) 4. 스프링 컨테이너와 스프링 빈 (0) | 2022.10.05 |
(스프링 기본) 3. 스프링 핵심 원리 이해 2 - 객체 지향 원리 적용 (0) | 2022.10.02 |
(스프링 기본) 1. 객체 지향 설계와 스프링 (0) | 2022.09.26 |