본문 바로가기
  • GDG on campus Ewha Tech Blog
3-2기 스터디/Spring 입문

[6주차] 스프링 핵심 원리 이해1 - 예제 만들기

by Joong 2022. 5. 20.

Section 2. 스프링 핵심 원리 이해1 - 예제 만들기

📌 목차

1. 프로젝트 생성
2. 비즈니스 요구사항과 설계
3. 회원 도메인
    3-1. 회원 도메인 설계
    3-2. 회원 도메인 개발
    3-3. 회원 도메인 실행과 테스트
4. 주문과 할인 도메인
    4-1. 주문과 할인 도메인 설계
    4-2. 주문과 할인 도메인 개발 
    4-3. 주문과 할인 도메인 실행과 테스트



프로젝트 생성

일단 스프링을 사용하지 않고 순수 자바를 이용해 예제를 만들어 본 후, 불편한 점을 스프링으로 개선해보기

  • 스프링 부트 스타터 사이트를 통해 스프링 프로젝트를 생성하고 초기 세팅하기
    • 프로젝트 선택
      • Project: Gradle Project
      • Spring Boot: 2.6.x (SNAPSHOT, M 버전 제외 가장 높은 버전으로)
      • Language: Java
      • Packaging: Jar
      • Java: 11
    • Project Metadata
      • groupId: hello
      • artifactId: core
    • Dependencies: 아무것도 선택하지 않는다. (일단 라이브러리를 최소한으로 사용해 순수 자바로 구현)



비즈니스 요구사항과 설계

회원

  • 회원을 가입하고 조회할 수 있다.
  • 회원은 일반과 VIP 두 가지 등급이 있다.
  • 회원 데이터는 자체 DB를 구축할 수도 있고, 외부 시스템과 연동할 수도 있다. (미확정)

주문과 할인 정책

  • 회원은 상품을 주문할 수 있다.
  • 회원 등급에 따라 할인 정책을 적용할 수 있다.
  • 할인 정책은 모든 VIP는 1000원을 할인해주는 고정 금액 할인을 적용해달라. (변경될 수 있다.)
  • 할인 정책은 변경 가능성이 높다. 회사의 기본 할인 정책을 아직 정하지 못했고, 오픈 직전까지 고민을
    미루고 싶다. 최악의 경우 할인을 적용하지 않을 수 도 있다. (미확정)
    → 요구사항을 보면 회원 데이터나 할인 정책 같은 부분은 지금 결정하기 어려운 부분이다. 그렇다고 이런 정책이 결정될 때까지 개발을 미룰 수도 없다. 그러므로 객체 지향 설계 방법을 따라, 인터페이스를 만들고 구현체를 언제든지 갈아끼울 수 있도록 설계해 개발을 시작하면 된다!



회원 도메인 설계

회원 도메인 요구사항 정리

  • 회원을 가입하고 조회할 수 있다.
  • 회원은 일반과 VIP 두 가지 등급이 있다.
  • 회원 데이터는 자체 DB를 구축할 수도 있고, 외부 시스템과 연동할 수도 있다. (미확정)


회원 도메인 협력 관계

기획자들도 볼 수 있는 그림
스크린샷 2022-05-17 오전 11 37 44

→ 역할(인터페이스): 클라이언트, 회원 서비스, 회원 저장소
구현(구현체): 메모리 회원 저장소, DB 회원 저장소, 외부 시스템 연동 회원 저장소

→ 메모리 회원 저장소: 간단히 로컬에서 개발, 테스트할 때 사용 (컴퓨터를 재부팅하면 다 없어지므로, 딱 개발용으로만 사용)





회원 클래스 다이어그램

개발자가 도메인 협력 관계 그림을 바탕으로 구체화해서 만드는 그림
스크린샷 2022-05-17 오전 11 41 05

→ 인터페이스: MembrService(회원 서비스), MemberRepository(회원 저장소)
구현체: MemberServiceImpl (회원 서비스를 구현한 구현체), MemoryMemberRepository (메모리 회원 저장소),
DbMemberRepository (DB 회원 저장소)





회원 객체 다이어그램

서버가 뜰 때 동적으로 결정되는, 클라이언트가 실제로 사용하는 인스턴스를 표현해놓은 그림
스크린샷 2022-05-17 오후 2 46 44

→ 객체 간 참조가 어떻게 되는지 표현
→ 회원 서비스 = MemberServiceImpl, 메모리 회원 저장소 = MemoryMemberRepository





회원 도메인 개발

회원 엔티티

Grade (enum)

package hello.core.member;

public enum Grade {
    Basic,
    VIP
}

→ Basic과 VIP 두 가지 등급 존재



Member

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;
    }
}

→ 회원 엔티티
→ 생성자, getter/setter





회원 저장소

MemberRepository (인터페이스)

package hello.core.member;

public interface MemberRepository {

    void save(Member member);

    Member findById(Long memberId);
}

→ 저장 기능과 Id로 회원을 찾는 기능



MemoryMemberRepository (구현체)

개발 및 테스트 진행을 위해 일단 메모리 회원 저장소 구현. 나중에 DB가 확정되면 갈아끼우자!

package hello.core.member;

import java.util.HashMap;
import java.util.Map;

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);
    }
}

→ 구현체와 인터페이스는 다른 패키지에 두는 것이 설계 상 좋다 (이 예제에서는 다른 패키지에 두면 너무 복잡해져서 그냥 같은 패키지에 두기)
→ MemberRepository 인터페이스를 이용해 구현한 구현체
→ 실무에서는 동시성 이슈가 발생할 수 있기 때문에 ConcurrentHashMap을 써야 한다.





회원 서비스

MemberService (인터페이스)

package hello.core.member;

public interface MemberService {

    void join(Member member);

    Member findMember(Long memberId);
}

→ 회원 등록 기능과 회원을 찾는 기능



MemberServiceImpl (구현체)

구현체가 하나만 있을 때는 관례상 구현체의 이름을 인터페이스명 + Impl로 많이 쓴다.

package hello.core.member;

public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();

    public void join(Member member) {
        memberRepository.save(member);
    }

    public Member findMember(Long memberId) {
           return memberRepository.findById(memberId);
    } 
}

→ MemberService 인터페이스를 이용해 구현한 구현체
→ NullPointException이 발생하지 않도록 구현객체 선택해주기 (new MemoryMemberRepository())







회원 도메인 실행과 테스트

회원 도메인 - 회원 가입 main

MemberApp

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("new member = " + member.getName());
        System.out.println("find Member = " + findMember.getName());
    }
}

→ 애플리케이션 로직으로 테스트하는 것은 한계가 존재하고, 매번 눈으로 확인해야 하는 단점이 있다.
JUnit이라는 테스트 프레임워크를 사용하자!





회원 도메인 - 회원 가입 테스트

MemberServiceTest

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);
    }
}

→ @Test annotation (assertj api 사용)
→ given - when - then: 이런 것들이 주어졌을 때 (given), 이렇게 했을 때 (when), 이렇게 된다 (then).





회원 도메인 설계의 문제점

  • 다른 저장소로 변경할 때 OCP 원칙을 잘 준수할까?
  • DIP를 잘 지키고 있는가?
  • 의존관계가 인터페이스뿐만 아니라 구현까지 모두 의존하는 문제점이 존재
    MemberServiceImplMemberRepository(인터페이스)와 MemoryMemberRepository (구현체) 둘 다에 의존
    → DIP 위반!
  • 주문까지 개발하고 난 후 문제점과 해결 방안에 대해 알아보자!


주문과 할인 도메인 설계

주문과 할인 정책 정리

  • 회원은 상품을 주문할 수 있다.
  • 회원 등급에 따라 할인 정책을 적용할 수 있다.
  • 할인 정책은 모든 VIP는 1000원을 할인해주는 고정 금액 할인을 적용 (나중에 변경될 수 있다.)
  • 할인 정책은 변경 가능성이 높다. 회사의 기본 할인 정책을 아직 정하지 못했고, 오픈 직전까지 고민을 미루고
    싶다. 최악의 경우 할인을 적용하지 않을 수도 있다. (미확정)

주문 도메인 협력, 역할, 책임

스크린샷 2022-05-17 오후 8 31 58
클라이언트가 주문 서비스에 주문 생성을 요청하면 먼저 회원 저장소에서 회원 조회를 해 회원의 등급을 알아낸다. 그 회원 등급을 가지고 할인 정책 역할에 할인 적용이 가능한지 물어본다. 적용이 가능하면 할인 적용한 결과를 주문 서비스에 내려주고, 주문 서비스는 할인까지 적용된 최종 주문 결과를 클라이언트에 반환해준다.

→ 단계별 정리

  1. 주문 생성: 클라이언트는 주문 서비스에 주문 생성을 요청한다.
  2. 회원 조회: 할인을 위해서는 회원 등급이 필요하다. 그래서 주문 서비스는 회원 저장소에서 회원을 조회한다.
  3. 할인 적용: 주문 서비스는 회원 등급에 따른 할인 여부를 할인 정책에 위임한다.
  4. 주문 결과 반환: 주문 서비스는 할인 결과를 포함한 주문 결과를 반환한다. (실제로는 주문 데이터를 DB에 저장하지만, 이 예제에서는 주문 결과를 단순히 반환)


주문 도메인 전체 (역할 + 구현)

스크린샷 2022-05-17 오후 8 49 21

→ 역할과 구현을 분리해서 구현했기 때문에 구현체를 자유롭게 바꿔 낄 수 있게 되었다.





주문 도메인 클래스 다이어그램

객체 레벨로 구현
스크린샷 2022-05-17 오후 8 55 22

→ 인터페이스: OrderService (주문 서비스 역할), MemberRepository (회원 저장소 역할),
DiscountPolicy (할인 정책 역할)
 구현체: OrderServiceImpl(주문 서비스 구현체), MemoryMemberRepository (메모리 회원 저장소), DbMemberRepository (DB 회원 저장소), FixDiscountPolicy (정액 할인 정책), RateDiscountPolicy (정률 할인 정책)



주문 도메인 객체 다이어그램

동적으로 객체들의 연관관계가 맺어지는 그림

스크린샷 2022-05-17 오후 8 56 10

→ 클라이언트가 주문서비스구현체 (OrderServiceImpl)를 호출하면 메모리 회원 저장소
(MemoryMemberRepository)가 호출된다. 다시 말해, 클라이언트가 주문을 생성하면 메모리 회원 저장소에서 회원을 조회한다.
→ 저장소와 할인 정책이 변경되어도 주문 서비스 구현체와 역할들의 협력 관계는 그대로 유지된다.
만약 저장소가 DB 회원 저장소로, 할인 정책이 정률 할인 정책으로 변경되어도 다음과 같이 주문 서비스 구현체와 역할들의 협력 관계는 재사용할 수 있다.

스크린샷 2022-05-17 오후 8 57 18


주문과 할인 도메인 개발

할인

DiscountPolicy (인터페이스)

package hello.core.discount;

import hello.core.member.Member;

public interface DiscountPolicy {

    /**
     * @return 할인 대상 금액
     */
    int discount(Member member, int price);
}

→ 할인 정책 인터페이스
discount가 호출되면 얼마나 할인되었는지 그 금액을 return





FixDiscountPolicy (구현체 - 정액할인정책)

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) { //enum은 ==로 값 비교
            return discountFixAmount;
        } else {
            return 0;
        }
    }
}

→ enum은 == 로 값 비교
→ VIP면 discountFixAmount (1000원) return, 아니면 (일반 회원이면) 0 return







주문

Order

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 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 +
                '}';
    }
}

→ 주문했을 때, 할인이 다 끝나고 만들어지는 객체
→ 생성자, getter/setter, 최종 금액을 계산하는 calculatePrice()
→ 객체를 출력하면, 그 객체의 toString이 호출된다. (객체에 대한 정보를 보기 쉽게 출력)





OrderService (인터페이스)

package hello.core.order;

public interface OrderService {
    Order createOrder(Long memberId, String itemName, int itemPrice);
}

→ memberId와 itemName, itemPrice를 받아 주문을 생성하는 createOrder()





OrderServiceImpl (구현체)

package hello.core.order;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.member.Member;
import hello.core.member.MemberRepository;
import hello.core.member.MemoryMemberRepository;

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); //최종 생성된 주문 return
    }
}
  • OrderServiceImpl 입장에서는 할인에 대해서는 전혀 모르고 있고, 그 책임을 discountPolicy에 넘겨 그 결과만 받아 처리한다.
    단일 체계 원칙 준수!
    → 할인에 대한 변경이 필요하면 할인 쪽만 수정하면 되고, 주문 쪽은 수정할 필요가 없기 때문



주문과 할인 도메인 실행과 테스트

OrderApp

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.calculatePrice = " + order.calculatePrice());
    }
}

→ member를 생성해 join한 뒤 createOrder()로 주문 생성
→ 생성된 주문 객체를 출력해보고, 할인된 금액도 출력해보기
→ 애플리케이션 로직으로 테스트하지 말고 JUnit 테스트를 이용해보자!





OrderServiceTest

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;

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);
    }
}

→ @Test annotation
assertThat으로 검증

댓글