코딩딩/Spring

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

전낙타 2023. 8. 13. 20:54

예제 만들기

 

비즈니스 요구사항과 설계

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

 

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

 

요구 사항을 보면 회원 데이터와 할인정책같이 수정될 수 있는 부분은 객체 지향 설계 방법으로 대비할 수 있다.

우선 스프링 없이 순수 자바 코드로만 개발을 진행해보자.

 

회원 도메인 설계

 

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

 

MemberService의 구현체인 MemberSerciveImpl는 회원가입과 회원 조회의 기능을 구현하고 MemberRepository에 데이터를 저장하거나 조회할 수 있다.

여기서 MemberRepository는 자체 DB를 구축할 수도 있고 외부 시스템과도 연동할 수 있음으로 MemoryMemberServiceImpl와 DbMemberServiceImpl를 객체지향 설계를 바탕으로 구현해둔다.

 

회원 도메인 개발

 

회원 엔티티

 

  • 엔티티에 대한 설명

    자바에서 "엔티티"라는 용어는 일반적으로 두 가지 주요 의미로 사용될 수 있습니다.

    1. JPA(Java Persistence API) 엔티티: JPA는 자바에서 데이터베이스를 사용하는 애플리케이션을 위한 ORM(Object-Relational Mapping) 기술을 위한 스펙입니다. JPA 엔티티는 데이터베이스의 테이블과 매핑되는 자바 클래스를 의미합니다. 이러한 엔티티 클래스는 데이터베이스 테이블과 필드 간의 매핑, 데이터 조작을 위한 메소드 등을 포함할 수 있습니다.
    1. 일반적인 의미의 엔티티: 일반적인 소프트웨어 개발 컨텍스트에서 "엔티티"는 애플리케이션에서 다루는 개별적인 객체나 데이터의 단위를 나타냅니다. 예를 들어, 엔티티는 고객, 주문, 제품 등과 같은 비즈니스 관련 객체를 가리킬 수 있습니다. 이러한 엔티티들은 시스템의 핵심 개념이며, 애플리케이션의 기능과 데이터 처리를 구현하는 데 사용됩니다.

    요약하면, 자바에서 "엔티티"는 JPA에서는 데이터베이스 엔티티를 의미하며, 일반적인 개념에서는 애플리케이션 내에서 다루는 객체나 데이터의 개별 단위를 의미합니다.

JPA에서의 엔티티와 소프트웨어 개발 컨텍스트에서 엔티티와 혼용하지 말자

 

회원 등급 : 회원은 일반과 VIP 두 가지 등급이 있다.

enum 타입으로 두 가지 등급을 선언해준다.

package hello.core.member;

public enum Grade {
    BASIC,
    VIP
}

 

회원 엔티티

가입과 조회를 위해 회원 고유 아이디, 이름, 등급과 같은 회원을 구성하는 필드값을 선언해준다.

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

// Lombok을 사용하면 Getter and Setter, Constructer 선언을 생략할 수 있다.

 

회원 저장소

 

회원 저장소 인터페이스 : 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다. (미확정)

아직 어떤 DB를 사용할지 정해지지 않았음으로 MemberRepository가 기본적으로 구현해야 할 저장과 조회 메서드를 인터페이스로 선언해준다.

package hello.core.member;

public interface MemberRepository {

    void save(Member member);
    Member findById(Long memberId);
}
// 이 인터페이스를 바탕으로 MemoryMemberRepository와 DbMemberRepository를 구현할 것이다.

 

메모리 회원 저장소 구현

데이터베이스가 아직 확정이 안되었다. 그래도 개발은 진행해야 하니 가장단순한, 메모리 회원 저장소를 구현해서 우선 개발을 진행하자.

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<>();
    // 여기서 Map은 자바의 컬렉션 프레임 워크로
		// 파이썬의 딕셔너리와 완전히 같은 개념이라 봐도 무방하다.
		// 참고: HashMap 은 동시성 이슈가 발생할 수 있다.
		// 이런 경우 ConcurrentHashMap 을 사용하자

    @Override
    public void save(Member member) {
        store.put(member.getId(), member);
    }

    @Override
    public Member findById(Long memberId) {
        return store.get(memberId);
    }
}

 

회원 서비스

 

회원 서비스 인터페이스 : 회원을 가입하고 조회할 수 있다.

회원 가입과 조회를 제공하는 서비스를 구현하기 위해 회원 서비스 인터페이스를 먼저 제작한다.

package hello.core.member;

public interface MemberService {

    void join(Member member);
    Member findMember(Long memberId);
}

(이 강의에서 회원 서비스 구현체는 변경이 없는것으로 계획되어있는데 왜 인터페이스를 구현했을까?)

  • ● chat GPT에게 물어봤다.

    인터페이스의 사용은 프로그램의 유연성, 확장성, 유지보수성을 향상시키는 중요한 개념 중 하나입니다. 여기에 몇 가지 이유를 살펴보겠습니다:

    1. 미래의 변경 가능성: 변화는 불가피한 것이며, 향후에도 변경 사항이 예상되지 않더라도 새로운 요구사항, 환경 변화 등으로 인해 코드가 수정되어야 할 수 있습니다. 이때 인터페이스를 사용하면 변경된 부분을 최소화하고 다른 구성 요소에 영향을 덜 주는 장점이 있습니다.
    1. 코드 확장성: 코드가 기존 기능을 초과하는 새로운 기능을 추가해야 할 경우, 인터페이스를 활용하면 기존 코드를 수정하지 않고 새로운 구현 클래스를 추가하여 기능을 확장할 수 있습니다.
    1. 모듈화 및 유지보수성: 인터페이스를 사용하면 코드를 모듈화하고 각 모듈 간의 의존성을 최소화할 수 있습니다. 이로써 한 모듈의 변경이 다른 모듈에 영향을 덜 주며, 유지보수성이 향상됩니다.
    1. 테스트 용이성: 인터페이스를 사용하면 단위 테스트 등을 더 쉽게 수행할 수 있습니다. 모의(mock) 객체를 사용하여 테스트하거나, 다양한 구현체를 주입하여 테스트하는 등의 작업이 더 간편해집니다.
    1. 코드 재사용: 인터페이스를 사용하면 다양한 구현 클래스에서 같은 인터페이스를 구현하여 재사용성을 높일 수 있습니다.

    요약하면, 변경사항이 예상되지 않는다고 해도 인터페이스를 사용하는 것은 소프트웨어의 유연성과 확장성을 고려한 좋은 설계 원칙입니다. 변경사항에 대비하여 코드를 구조화하고 모듈화하는 것은 시간과 인력을 절약하며, 시스템의 복잡성을 관리할 수 있는 방법 중 하나입니다.

 

회원 서비스 구현체

package hello.core.member;

public class MemberServiceImpl implements MemberService{

    // 자바의 OCP, DIP 원칙에 위배되는 코드.
		// MemoryMemberRepository를 DbMemberRepository로 변경시
    // 직접 코드를 변경해주어야 한다. 이를 극복하기 위해 스프링을 사용한다.
    private final MemberRepository memberRepository = new MemoryMemberRepository();

    @Override
    public void join(Member member) {
        memberRepository.save(member); // Repository의 save 메소드를 호출해서 저장
    }

    @Override
    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
        // Repository의 findById 메소드에 memberId값을 넘겨 Member 객체를 호출한다.
    }
}

 

회원 도메인 실행과 테스트

 

애플리케이션 로직에서의 테스트

package hello.core.member;

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

/*
result
new member = hello.core.member.Member@133314b
find Member = hello.core.member.Member@133314b
테스트 성공
*/

하지만 이렇게 애플리케이션 로직에서의 테스트는 좋은 방법이 아니다.

  • ● 좋은 방법이 아닌 이유

    애플리케이션 로직에서의 테스트가 좋지 않은 이유는 다양한 측면에서 발생할 수 있습니다. 아래는 그 중 일부를 설명한 것입니다:

    1. 결합도 증가: 애플리케이션 로직에서 테스트를 수행하는 경우, 테스트 코드와 실제 로직이 강하게 결합될 수 있습니다. 이로 인해 로직 변경 시 테스트 코드도 함께 수정해야 할 가능성이 높아집니다.
    1. 테스트 가독성 및 유지보수성 감소: 애플리케이션 로직에서 테스트를 직접 수행하는 경우, 테스트 코드와 비즈니스 로직이 섞일 수 있습니다. 이는 테스트 코드의 가독성과 유지보수성을 감소시킬 수 있습니다.
    1. 변경의 어려움: 로직 수정 시 테스트 코드도 함께 수정해야 하기 때문에, 변경 사항을 반영하는데 추가 작업이 필요합니다. 이는 코드 변경을 어렵게 만들고 실수를 유발할 수 있습니다.
    1. 테스트 커버리지 부족: 애플리케이션 로직에서 직접 테스트를 작성하는 것은 모든 시나리오를 충분히 커버하기 어렵게 만들 수 있습니다. 모든 가능한 경로와 조건을 테스트하는 것은 매우 어려운 작업이며, 결과적으로 테스트 커버리지가 부족할 수 있습니다.
    1. 코드 중복: 로직과 테스트 코드 간의 중복이 발생할 수 있습니다. 로직을 변경하면 테스트 코드도 수정해야 하고, 이로 인해 유사한 코드가 중복될 수 있습니다.
    1. 단위 테스트 제한: 애플리케이션 로직을 직접 테스트하는 경우, 다양한 의존성과 환경 설정을 모방하기 어려울 수 있습니다. 이로 인해 단위 테스트의 효과를 제한할 수 있습니다.

    따라서 애플리케이션 로직에서 테스트가 좋지 않은 이유는 유연성, 유지보수성, 가독성, 테스트 커버리지 등 다양한 측면에서 문제를 야기할 수 있기 때문입니다. 대신, 테스트 가능한 코드를 작성하고 테스트 주도 개발(Test-Driven Development, TDD) 원칙을 적용하여 효과적으로 테스트를 수행하는 것이 좋습니다.

 

JUnit 테스트

에플리케이션 로직이 아닌 JUnit 프레임워크에서 테스트를 진행한다.

package hello.core.member;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

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
        assertThat(member).isEqualTo(findMember);
        // 두개의 객체가 같은 객체인지 비교해주는 메서드
    }
}

 

 

주문과 할인 도메인 설계

 

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

 

OrderService의 구현체인 OrderSerciveImpl는 주문 생성과 주문 결과 반환의 기능을 구현하고 MemberRepository에서 회원 등급을 조회할 수 있다.

여기서 DiscountPolicy는 Fixdiscount를 적용할 수도 있고 RateDiscountPolicy를 적용할 수 있음으로 Fixdiscount와 RateDiscountPolicy를 객체지향 설계를 바탕으로 구현해둔다.

 

주문과 할인 도메인 개발

 

할인 정책 인터페이스

 

할인 대상의 금액을 맴버의 엔티티와 함께 넘겨받는다.

package hello.core.discount;

import hello.core.member.Member;

public interface DiscountPolicy {

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

 

정액 할인 정책 구현체

 

member 객체와 금액을 넘겨받고 맴버의 등급을 조회한 뒤 VIP일시 할인 금액인 1000원을 리턴하는 간단한 코드

package hello.core.discount;

import hello.core.member.Grade;
import hello.core.member.Member;

public class FixDiscountPolicy implements DiscountPolicy {

    private int discountFixAmount = 1000; // 고정 할인 금액
    @Override
    public int discount(Member member, int price) {
        if (member.getGrade() == Grade.VIP) { // enum은 == 비교 연산자로 비교해야 한다.
            return  discountFixAmount;
        } else {
            return 0;
        }

    }
}

 

주문 엔티티

 

주문을 위해 필요한 정보를 담아줄 객체를 생성하는 class

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

 

주문 서비스 인터페이스

 

각종 매개변수를 담아 Order 객체를 리턴하는 메소드를 선언해준다.

package hello.core.order;

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

 

주문 서비스 구현체

 

createOrder 메소드에 입력받은 매개변수를 바탕으로 member 객체를 반환받는다. 해당 member객체의 Grade를 바탕으로 할인 여부를 판단하여 discountPrice로 반환하고. 이를 Order 객체에 담아 최종적으로 return한다.

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

 

 

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

 

주문과 할인 정책 실행

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.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

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);
        assertThat(order.getDiscountPrice()).isEqualTo(1000);
    }
}

VIP 등급의 member 객체를 바탕으로 order 객체를 생성해 할인이 적용된 모습이다.