본문 바로가기
인프런 🍀

[스프링 핵심 원리 - 기본편] - 예제 만들기

by 의정부핵꿀밤 2022. 10. 14.
728x90

프로젝트 초기 생성

이번 예제에서는 순수하게 Java만 이용해서 진행하기 위해 Spring Boot의 의존 관계로 spring-boot-starter만 사용한다!

 

 

💡 프로젝트 실행 시 주의할 점

Gradle 설정을 IntelliJ IDEA로 모두 변경해준다!

영한님께서는 이게 더 빠른 것 같아서 추천한다고 하셨는데, 난 이거때문에 오류가 너무 많이 났어서 바꾸는 게 좋다고 생각한다!

 


비즈니스 요구사항과 설계

  • 제시된 요구사항을 보면 변경될 가능성이 높은 요구사항이 많다
  • 따라서 우리는 인터페이스를 만들고 구현체를 언제든지 갈아끼울 수 있도록 설계해서 요구사항에 유연하게 대처할 것이다!

 

 

 

회원 도메인 설계

  • 회원 서비스 계층은 2가지의 기능을 제공한다
    • 회원 가입
    • 회원 조회
  • 회원 저장소를 별도로 만든다
    • 회원 데이터에 대한 요구사항이 확실하게 결정되지 않았기 때문에 유연한 대처를 위해 회원 데이터에 접근하는 계층을 별도로 만든다
    • 회원 저장소의 역할에 대한 구현체는 위의 그림과 같이 3가지가 존재한다
    • 메모리 회원 저장소는 굉장히 간단하게 개발용으로만 만들어서 쓰고, 실제 요구사항이 정해지면 그 때 수정할 수 있도록 구현해둔다

 

 

회원 클래스 다이어그램

 


회원 도메인 개발

 

 

Grade.java

package hello.core.member;

public enum Grade {
    BASIC,
    VIP
}

회원 등급 관리를 위한 상태값(Enum)

 

 

Member.java

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

회원 객체(엔티티)

 

 

MemberRepository.java

package hello.core.member;

public interface MemberRepository {

    void save(Member member);

    Member findById(Long memberId);
}

아직 요구사항이 확정되지 않았기 때문에 인터페이스로 구현한다

 

 

MemoryMemberRepository.java

package hello.core.member;

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

public class MemoryMemberRepository implements MemberRepository {

    // HashMap은 동시성 이슈가 발생할 수 있어서 실무에서는 ConcurrentHashMap을 사용한다
    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 인터페이스의 구현체

 

💡 참고
HashMap은 동시성 이슈가 발생할 수 있어서 실무에서는 ConcurrentHashMap을 사용한다

 

 

MemberService.java

package hello.core.member;

public interface MemberService {
    
    void join(Member member); // 회원가입
    
    Member findMember(Long memberId); //회원 조회
}

서비스 계층의 인터페이스

 

 

MemberServiceImpl.java

package hello.core.member;

public class MemberServiceImpl implements MemberService {
    // 인터페이스만 선언해두면 NullPointerException 이 발생하기 때문에 구현체를 선택해야 한다
    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);
    }
}

서비스 계층의 구현체 클래스

 

 


회원 도메인 실행과 테스트

 

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.java

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

위처럼 JUnit을 통해서 MemberService의 테스트 코드를 구현했고, 이를 실행시키면 다음과 같이 올바른 테스트 결과를 얻을 수 있다

 

이 방법을 사용하면 애플리케이션 로직을 사용할 때와 달리, 직접 눈으로 콘솔을 확인하는 것이 아니라 테스트의 결과를 쉽고 빠르게 확인할 수 있다!

 

 

 

 

회원 도메인 설계의 문제점

Q. 위의 코드는 다른 저장소로 변경할 때 OCP 원칙을 잘 준수할까? DIP는 잘 지키고 있을까?

  • MemberServiceImpl에서 repository를 인터페이스로 선언했지만, 사실상 구현체를 선택해서 정의하였다
  • 결국 MemberServiceImpl은 MemberRepository 인터페이스와 MemoryMemberRepositry 구현체 모두 의존하고 있게 된다!
  • 따라서 저장소가 변경되면 해당 객체도 변경을 해야 한다
  • 즉, OCP와 DIP 모두 위반하고 있는 것이다!!

 


주문과 할인 도메인 설계

 

 

주문 도메인 전체


주문과 할인 도메인 개발

할인 정책 인터페이스

package hello.core.discount;

import hello.core.member.Member;

public interface DiscountPolicy {

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

 

정액 할인 정책 구현체

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;
        } else {
            return 0;
        }
    }
}
  • VIP면 1000원 할인, 아니면 할인 X

 

주문 엔티티

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

 

주문 서비스 인터페이스

package hello.core.order;

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

 

 

주문 서비스 구현체

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);
    }
}
  • 이는 할인 정책이 변경되어도 해당 로직은 변경이 되지 않기 때문에 SRP(단일 책임 원칙)을 잘 지켜서 구현한 것이다!
  • 주문 생성 요청이 오면 회원 정보를 조회하고, 할인 정책을 적용한 후 주문 객체를 생성하여 반환한다
  • MemoryMemberRepository와 FixDiscountPolicy를 구현체로 생성한다

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

 

주문과 할인 정책 실행

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

위와 같이 애플리케이션 로직을 통해서 테스트 코드를 구현하면 다음과 같은 결과를 확인할 수 있다

 

할인 금액이 잘 출력된다

그러나 애플리케이션 로직을 통해서 테스트를 하는 것은 좋지 않으므로 JUnit 테스트를 사용해보자

 

 

 

주문과 할인 정책 테스트 (with JUnit)

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

이렇게 구현하면 다음과 같이 올바른 테스트 결과를 얻을 수 있다

 

 

애플리케이션 로직을 사용하지 않고 이렇게 단위테스트를 구현하면 훨씬 빠르고 간결하게 테스트가 가능하다!

 

728x90

댓글