개발자로 후회없는 삶 살기

spring PART.스프링 본질 예제 본문

[백엔드]/[spring | 학습기록]

spring PART.스프링 본질 예제

몽이장쥰 2023. 3. 20. 21:48

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복 - 인프런 | 로드맵

Spring, MVC 스킬을 학습할 수 있는 개발 · 프로그래밍 로드맵을 인프런에서 만나보세요.

www.inflearn.com

 

 

본론

- 스프링 본질에 관한 예제

위에서 말한 것처럼 인터페이스와 구현을 나눠서 개발을 해볼 것입니다. 먼저 순수 자바로 개발을 해볼 것입니다. 그 후 요구 사항의 변경이 있을 때 확장과 변경이 용이한가를 보고 스프링으로 채감할 것입니다.

 

- 비즈니스 요구사항 분석

회원, 주문 할인 정책 요구사항이 있습니다.

 

1. 회원

1) 회원 가입하고 조회할 수 있다.
2) 회원은 일반과 vip 등급이 있다.
3) 회원 데이터는 자체 DB를 구축할 수도 있고, 아니면 외부 시스템(외주를 줄수도 있다.)과 연동할 수도 있다. (미확정)

 

2. 주문과 할인 정책

1) 회원은 상품을 주문할 수 있다.
2) 회원 등급에 따라서 할인 정책을 적용할 수 있다.
3) 할인 정책은 모두 vip는 1000원을 할인해주는 고정 금액 할인을 적용(나중에 변경 될 수 있음)
4) 할인 정책은 변경 가능성이 높다. 오픈 직전까지 고민을 미루고 싶다. 최악의 경우 할인을 적용하지 않을 수 도 있다.( ★ 하지만 우리는 걱정이 없습니다. 역할과 구현을 구분하면 됩니다!)

 

> 요구사항을 보면 회원 데이터, 할인 정책을 지금 결정하기 어려운 부분입니다. 그렇다고 결정될 때까지 개발을 무기한 연기할 수는 없습니다. (★ 객체 지향 설계 방법을 쓰면 됩니다!) 인터페이스를 만들고 구현체를 언제든지 갈아끼울 수 있도록 설계해보겠습니다.

 

- 회원 도메인 설계

1) 회원 가입하고 조회할 수 있다.
2) 회원은 일반과 vip 등급이 있다.
3) 회원 데이터는 자체 DB를 구축할 수도 있고, 아니면 외부 시스템(외주를 줄수도 있다.)과 연동할 수도 있다. (미확정)

이것이 회원 도메인의 요구사항이었습니다. 아직 결정이 안 났지만 일단 설계를 들어갑니다. 

 

-> 그림

 

클라이언트가 회원 서비스를 호출합니다. 회원 서비스는 두가지 기능을 제공합니다. 가입과 조회입니다. 회원 저장소를 별도로 만듭니다. 왜냐면 지금 DB가 자체 구축일 수도 있고 아니면 외부 시스템과 연동할 수도 있다고 했으니 회원 데이터를 저장할 계층을 따로 만드는 것입니다. 그래야 나중에 바꿔 끼울 수 있습니다.

 

> 회원 저장소 역할의 구현은 메모리, DB, 외부 시스템 연동 회원 저장소로 구현할 것입니다. 이 셋중에 하나의 구현체를 꽂을 것입니다. 지금 아무리 결정이 안 났다고 해도 개발을 손 놓고 있을 수는 없습니다, 그래서 메모리 멤버 저장소를 만들어야합니다. 이것으로 일단 개발을 진행합니다. 이렇게 개발하다가 나중에 db가 선택이 되면 교체하면 됩니다. 구현을 갈아끼우면 되는 것입니다.

> 이것은 도메인에 대한 큰 그림이고 실제 구현 레벨로 내려오면 회원 클래스 다이어그램으로 그려집니다. 회원 서비스, 회원 레포를 인터로 만들고  그거에 대한 구현체로 멤버서비스impl, 메모리 멤버 레포, DB멤버 레포를 만듭니다. 이렇게 하고 코딩 딱 하면 되는 것입니다. 

 

- 회원 도메인 개발

클래스 다이어그램을 보고 개발을 할 것입니다. member 패키지를 만듭니다. 등급을 enum으로 만들고 그리고 entity도 만듭니다. (흠 domain 패키지로 안 만듭니다★) 이게 이전에 도메인 패키지 안에 있던 Member입니다!

 

> 그리고 멤버 패키지안에 멤버 레포지토리를 만듭니다. (이것도 레포따로 패키지 안 만듭니다. 스프링 고수의 코드를 보면 도메인 패키지 안에 카테고리 별로 패키지를 만들고 그 안에 엔터티, 레포, 서비스가 다 있습니다. 이렇게 하나 봅니다.)

 

public interface MemberRepository {
    void save(Member member);
    Member findById(Long memberId);
}

기능은 아까 회원을 가입하고 조회하는 것만 있다고 했습니다.

 

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

이제 구현체를 만들겠 습니다. 저장소니깐 MAP이 있어야합니다. 아직 db가 확정이 안 났으니 이렇게 메모리를 간단하게 만든 것이고 메모리로만 쓸 것이니 테스트용으로만 써야합니다.

 

public interface MemberService {
    void join(Member member);
    Member findMember(Long memberId);
}

저장소를 다 만들었으니 이제 서비스를 만듭니다. (원래는 분리하지만 쉽게 member 패키지 안에 만듭니다.) 얘는 두가지 기능이 있습니다. 역시나 레포와 같은 기능이니 저장과 조회가 있을 것이고 레포는 단순 개발용 서비스는 비즈니스용이라고 했습니다. 가입하는 join, 조회하는 find를 만듭니다. 역시나 인터페이스 먼저 만들고 구현체를 만듭니다. (역할과 구현!)

 

 

public class MemberServiceImpl implements MemberService{
    MemberRepository memberRepository = new MemoryMemberRepository();

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

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

이제 구현체를 만듭니다. 가입을 하고 회원을 찾으려면 레포가 필요하죠! 왜냐하면 서비스는 비즈니스 로직이 있어야하는데 그 로직을 간단한 개발인 레포에 작성해놨기 때문입니다. 따라서 멤버 레포를 선언하고 구현 객체를 new로 선택을 해줘야합니다. (★ 이게 바로 멤버 서비스 구현체[클라]가 역할과 구현을 다 아는 것입니다. 이렇게 하면 OCP, DIP를 위반해서 클라의 코드를 바꾸지 않고 갈아끼우는게 안 됩니다.)

 

- 회원 도메인 실행과 테스트

// 테스트 코드
@Test
void join(){
    //given
    Member member = new Member(1L, "A", Grade.VIP);

    //when
    memberService.join(member);
    Member findMember = memberService.findMember(1L);

    //then
    Assertions.assertThat(member).isEqualTo(findMember);
}

// DIP, OCP 위반
public class MemberServiceImpl implements MemberService{
    MemberRepository memberRepository = new MemoryMemberRepository();

junit으로 테스트 해보겠습니다. Test 클래스를 만들고 테스트를 합니다. 그런데 이 코드의 설계상 문제는 new를 하는 것입니다. 다른 저장소로 변경을 할 때 클라의 코드를 변경해야 합니다. 멤버 서비스가 멤버 레포 역할뿐 만아니라 메모리 멤버 레포 구현체도 의존합니다.

 

 

- 주문과 할인 도메인 설계

1) 회원은 상품을 주문할 수 있다.
2) 회원 등급에 따라서 할인 정책을 적용할 수 있다.
3) 할인 정책은 모두 vip는 1000원을 할인해주는 고정 금액 할인을 적용(나중에 변경 될 수 있음)
4) 할인 정책은 변경 가능성이 높다. 오픈 직전까지 고민을 미루고 싶다. 최악의 경우 할인을 적용하지 않을 수 도 있다.

 

요구사항은 이러했습니다. 그림을 보면 역시나 먼저 도메인 설계를 먼저하고 그것을 보고 클래스 다이어그램을 만든다고 했습니다. 클라이언트는 주문을 할 것이니 주문 서비스를 만들 것입니다. 주문을 할 때 할인을 받을 것이기 때문에 회원 저장소 역할에서 멤버를 가지고 와서 할인 정책 역할에 주면서 물어봅니다. 물어본 결과를 주문 서비스에 내려주고 주문 서비스는 최종적으로 할인까지 적용된 주문 결과를 클라에게 반환해줍니다.

 

 

 

클라는 주문 서비스를 호출할 때 회원 id, 상품명, 상품가격을 넘깁니다. 그러면 주문 서비스는 할인을 하려면 회원 등급이 필요하니 회원 저장소에 가서 회원을 조회합니다. 그 등급을 가지고 할인 정책 역할에 물어봅니다. 주문 서비스는 물어본 것을 가지고 할인 결과를 포함한 주문 결과를 반환합니다. 

 

 

이것을 클래스 다이어그램으로 보면 주문 서비스, 회원 저장소, 할인 정책 역할을 먼저 만들고 구현을 그 다음에 만들었습니다. 이렇게 역할과 구현을 분리했기 때문에 자유롭게 구현 객체를 조립할 수 있도록 설계가 된 것입니다. 덕분에 회원 저장소를 메모리와 DB로 변경할 수 있고 할인 정책 역할도 정액(무조건 1000원), 정율(10%) 구현 클래스로 변경을 할 수 있습니다. 

 

> 개발을 처음 할 때 먼 미래를 봤는데 "나는 정액은 좀 아닌 거 같아. 정율도 구현으로 만들어 두고 할인 정책을 역할로 만들자!"라는 생각을 할 수 있으면 갖다 꽂으면 끝나는 문제가 됩니다. 

 

 

오더 서비스라는 역할을 만들고 구현체를 impl로 만들고 impl이 멤버 레포 역할과 할인 정책 역할을 가져다 쓰는 것이고(역할을 인지) 할인 정책 역할이 구현으로 fix와 rate를 가질 것입니다.

 

살짝 룰을 만들어 보자면 메모리 멤버 레포를 쓸 때는 정액 할인 정책을 구현체로 가져다 쓰고 DB 멤버 레포를 쓸 때는 정율을 쓸 것입니다. 이게 가능한 이유는 막 바꾸어도 주문 서비스 구현체(클라)를 변경할 필요가 없기 때문입니다!

 

- 주문과 할인 도메인 개발

discount 패키지를 하나 만드는데 member와 같은 계층에 만듭니다. 이것이 바로 도메인 카테고리 별로 패키지를 만드는 것입니다.

 

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

안에 할인 정책 인터를 만들고 추상 메서드를 만드는데 discount만 가지고 있게 할 것입니다. 얼마가 할인됐다는 것을 리턴해주는 메서드입니다. ex) 1000이 할인됐다고 리턴합니다.

 

public class FixDiscounPolicy implements DiscountPolicy{
    private final int discountFixAmount = 1000;
    @Override
    public int discount(Member member, int price) {
        if(member.getGrade() == Grade.VIP){
            return discountFixAmount;
        }
        else {
            return 0;
        }
    }
}

 

> 구현체를 만듭니다. fix를 만듭니다. fix는 vip면 무조건 1000원만 할인해줍니다. vip가 아니면 할인을 0합니다. 이게 도메인, 클래스 다이어그램에서 설계한 대로 개발을 하고 있는 중인 겁니다.

 

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 calculatorPrice(){
        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, 제품명을 필드로 가지고 주문 결과를 price(원가)와 discount로 제품 가격 얼마고 할인 가격 얼마야를 나타냅니다. 주문 가격이 얼마고랑 할인률이 얼마인지 다 끝나고 만들어지는 객체라고 보면 됩니다.

 

생성자와 게터세터 만들고 계산 로직을 하나 만들 것입니다. 원가가 얼마고 할인 가격이 얼마고를 아니깐 그걸 계산해서 뺀 최종 계산 금액을 구하는 것입니다. > 그리고 toString을 재정의할 하여 객체를 출력하면 편하게 볼 수 있게 만듭니다.

 

> 이제 주문 서비스 역할을 만듭니다. 당연히 멤버 패키지에 멤버 엔터티랑, 등급 enum, 레포 서비스 다 있는 것처럼 order 패키지에 주문 서비스를 만듭니다.

 

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

}

createOrder 메서드를 만드는데 서비스는 그 도메인의 엔터티를 다루는 경우가 많은 것 같습니다. 그래서 반환형으로 그 엔터티, 여기서는 Order를 쓰고 member에서는 Member를 쓰는 경우가 있었습니다. 이 메서드는 아까 그림에서 봤듯이 클라가 주문 서비스를 사용할 때 회원id, 상품명, 상품가격을 넣는다고 했고 주문 결과를 반환한다고 했습니다.

 

public class OrderServiceImpl implements OrderService{
    private final MemberRepository memberRepository = new MemoryMemberRepository();
    private final DiscountPolicy discountPolicy = new FixDiscounPolicy();
    @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);
    }
}

이제 주문 서비스 구현을 합니다. 주문 서비스 impl을 만들고 재정의를 하는데 아까 주문 서비스는 두가지를 한다고 했습니다. 멤버 레포에서 회원을 찾고 그 찾은 회원의 등급에 맞는 할인을 적용할 것입니다. 이게 주문 서비스의 비즈니스 로직입니다. 따라서 필수 txt에 서비스 만들면 바로 관련 레포 선언하고 본다고 했는데 이번엔 회원을 찾아야하니 필요한 메모리 멤버 레포와 정책을 적용하기 위한 fix를 선언합니다. createOrder가 회원을 찾고 정책을 적용하는 걸 다 수행하는 메서드입니다. 하나의 메서드에 두가지 기능을 넣는 것입니다.

 

> 이게 보면 코드가 굉장히 잘 설계가 된 것입니다. 단일 책임 원칙을 잘 지킨 것입니다. 만약에 할인에 변경할게 생기면 fix 쪽만 고치면 되는 것입니다! 

 

> 마지막으로 new로 주문을 만들어서 반환을 해주면 주문 서비스의 역할이 끝납니다. 참 신기하게 짭는 것 같습니다. 주문이라는 객체를 만드니깐 주문 서비스에서 주문을 수행하고 끝이 아니고 주문 객체를 만들어서 반환을 해버립니다. (여기서 grade만 넘길수도 있고 멤버를 넘길수도 있는데 프로젝트 상황에 따라 다르다. 그리고 다시 생각해보니 주문 클래스를 만드는 것이 OOP의 클래스 분리였습니다.) 

 

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

public class OrderServiceTest {
    OrderService orderService = new OrderServiceImpl();
    MemberService memberService = new MemberServiceImpl();

    @Test
    public void createOrder(){
        //given
        Member member = new Member(1L, "A", Grade.VIP);


        //when
        memberService.join(member);
        Order order = orderService.createOrder(1L, "itemA", 10000);


        //then
        Assertions.assertThat(order.calculatorPrice()).isEqualTo(9000);
    }
}

주문 서비스가 동작하는 로직을 Test에 담아야하므로 주문 서비스가 필요하고 주문 서비스가 메모리에서 멤버를 가져와서 조회하고 할인 정책을 만드니 멤버를 만들고 멤버 서비스를 만들어서 join을 한다.  > 그리고 주문을 만들어서 주문 계산 결과 9000원과 order의 calcuPrice의 값이 똑같은지 본다. 정책은 어디에 만드는 지 궁금할 수 있는데 createOrder안에 있습니다.

 

-> 정리

이렇게만 개발해도 굉장히 잘 한 것입니다. 역할과 구현을 잘 구분했습니다. 다형성을 잘 활용을 한 것입니다. 다음 시간부터 정액을 정률로 바꿨을 때 유연하게 변경되나 보겠습니다.

 

Comments