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

Zion Yu·2021년 3월 4일
0
post-thumbnail

본 시리즈는 우아한형제들 개발 팀장이신 김영한님의 스프링 핵심 원리 - 기본편 강의를 들으며 개인적으로 정리한 내용을 담고 있습니다. 제가 들은 강의는 인프런에 등록되어 있습니다. 모든 다이어그램을 포함한 사진의 출처는 위 강의의 강의록임을 밝힙니다. 개인적으로 정리한 내용이기 때문에 글 내용에 오류가 있을 수 있으며 이에 대한 피드백은 댓글로 부탁드립니다.

다음으로 다룰 내용

  • 순수 Java로 역할과 구현을 나눠 개발해보기
    • OCP, DIP가 잘 지켜지는지 확인해보자.
  • 회원, 주문 예제를 갖고 실습을 한다.

프로젝트 생성

  • 환경
    • Java 버전: 11
    • IDE: IntelliJ
  • 프로젝트를 간단하게 생성하기 위해 Spring Initializr를 이용한다.
    • 프로젝트 설정
      • 스프링 부트 버전 옆에 뭔가가 써있으면 안정된 릴리즈가 아니다.
      • 즉, SNAPSHOT, M(마일스톤을 뜻하는 것 같다)은 완벽히 안정화된 버전이 아니다.
      • 우측의 Dependency는 비워둔다.

      • Build and run usingRun tests using의 값을 IntelliJ IDEA로 바꾼다. (기존에는 Gradle로 설정되어 있다.)
      • 이렇게 하면 프로그램을 실행할 때 Java로 직접 실행하게 되는데, 이는 Gradle을 통해 실행하는 것보다 속도가 빠르다.

비즈니스 요구사항 파악 및 설계

실습을 위해 가정한 비즈니스 요구사항은 다음과 같다.

  • 회원
    • 회원을 가입하고 조회할 수 있다.
    • 회원은 일반VIP 두 가지 등급으로 나뉜다.
    • 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동될 수 있다. (미확정)
  • 주문과 할인 정책
    • 회원은 상품을 주문할 수 있다.
    • 회원 등급에 따라 할인 정책을 적용할 수 있다.
      • 모든 VIP에게 1000원을 할인해주는 정액 할인제 적용
    • 할인 정책은 변경 가능성이 높다. 회사의 기본 할인 정책을 아직 정하지 못했고, 오픈 직전까지 고민을 미루고 싶다. 최악의 경우 할인을 적용하지 않을 수도 있다. (미확정)

복잡한 요구사항이다...
이 요구사항의 난점은 현재 미확정된 부분이 있다는 점이다. 그러나 객체 지향 설계를 잘 따르면 걱정할 필요가 없다!

참고: 위에서 언급했듯이 처음에는 Spring없이 순수한 Java로 개발한다.

회원 도메인 설계

  • 회원
    • 회원을 가입하고 조회할 수 있다.
    • 회원은 일반VIP 두 가지 등급으로 나뉜다.
    • 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동될 수 있다. (미확정)
  • 회원 도메인 협력 관계
    • DB가 어떻게 바뀔지 모르므로 회원 저장소의 구현을 유연하게 교체할 수 있도록 만든다.
  • 회원 클래스 다이어그램
    • MemberService를 굳이 인터페이스로 만든 이유는 캡슐화를 위해서이다. 추후 구현 내용을 수정하더라도 외부에서 알 수 없도록 하기 위해.
    • 통상 구현체가 하나만 있는 경우는 구현하는 인터페이스의 이름 뒤에 Impl을 붙여서 구현클래스의 이름으로 삼는다.
  • 회원 객체 다이어그램
    • 객체 다이어그램이란 실제 서버가 작동할 때 어떤 구현체를 사용하는지를 나타낸다.
    • 일단 실제 구현은 메모리 회원 저장소를 쓴다는 것

회원 도메인 개발

hello.core.member 패키지

Grade: 회원 등급을 enum으로 표현

public enum Grade {
	BASIC,
	VIP
}

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;
    }
    //이 아래로는 Getter, Setter 작성하면 된다.
}

MemberRepository: 저장소의 역할을 표현하는 interface

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

MemoryRepository: 메모리 저장소의 구현을 표현하는 구현 class

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);
    }
}
  • 원래라면 동시성 문제를 고려해서 Cuncurrent Map을 사용해야하지만, 예제를 간단화하기 위해 일반 Map을 사용한다.

MemberService

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

MemberServiceImpl

public class MemberServiceImpl implements MemberService {
    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);
    }
}

테스트

도메인 개발이 완료되었으니 이제 테스트를 해보자. hello.core 하위에 MemberApp을 작성한다.

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());
    }
}
  • 정상적으로 같은 이름이 나오는 것을 확인할 수 있다.
  • 하지만 매번 main 함수에서 테스트를 돌릴 수는 없는 노릇이다.

main함수에서 테스트하는 대신 JUnit을 사용해보자. test폴더 하위에 hello.core.member 패키지를 생성하고 MemberServiceTest를 작성한다.

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);
    }
}
  • 참고: Assertionsassertj의 class를 이용한다.
  • main 함수를 이용했을 땐 console 출력으로 눈으로 검증하지만, JUnit을 이용하면 테스트 실패 시 Error가 발생하기 때문에 훨씬 편하다.
  • 요즘엔 테스트코드가 필수!

그러나 위 코드엔 문제가 있다.

  • OCP가 지켜졌는가? NO
  • DIP가 지켜졌는가? NO
  • MemberServiceImpl은 저장소의 구현체를 의존한다.
    • 추상에도 의존하고, 구현체에도 의존한다.
  • 어떻게 해결해야할지는 다음 섹션에서..

주문과 할인 도메인 설계

  • 주문과 할인 정책
    • 회원은 상품을 주문할 수 있다.
    • 회원 등급에 따라 할인 정책을 적용할 수 있다.
      • 모든 VIP에게 1000원을 할인해주는 정액 할인제 적용
    • 할인 정책은 변경 가능성이 높다. 회사의 기본 할인 정책을 아직 정하지 못했고, 오픈 직전까지 고민을 미루고 싶다. 최악의 경우 할인을 적용하지 않을 수도 있다. (미확정)
  • 주문 도메인 협력, 역할, 책임
    1. 주문 생성: 클라이언트는 주문 서비스에 주문 생성을 요청한다.
    2. 회원 조회: 할인을 위해서는 회원 등급이 필요하다. 그래서 주문 서비스는 회원 저장소에서 회원을 조회한다.
    3. 할인 적용: 주문 서비스는 회원 등급에 따른 할인 여부를 할인 정책에 위임한다.
    4. 주문 결과 반환: 주문 서비스는 할인 결과를 포함한 주문 결과를 반환한다.

      참고: 실제로는 주문 데이터를 DB에 저장하겠지만, 예제를 간단히 하기 위해 단순히 반환하는 것으로 했다.

  • 주문 도메인 전체

    • 역할과 구현을 분리했기 때문에 구현 클래스를 유연하게 갈아끼울 수 있다. 이를테면 회원 정책과 할인 정책을 자유롭게 바꿀 수 있다.
  • 주문 도메인 클래스 다이어그램

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

    • 회원 저장소나 할인 정책의 구현체를 교체하더라도 주문 서비스는 변경할 필요 없다!
  • 주문 도메인 객체 다이어그램2

    • 객체 간의 협력 관계를 그대로 재사용할 수 있다.

주문과 할인 도메인 개발

hello.core.discount 패키지

DiscountPolicy: 할인 정책의 역할을 표현하는 interface

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

FixDiscountPolicy: 정액 할인 정책의 구현을 표현하는 class

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

hello.core.order 패키지

Order

public class Order {
    private Long memberId;
    private String itemName;
    private int itemPrice;
    private int discountPrice;

    // 여기에는 생성자 작성

    public int calculatetPrice() {
        return itemPrice - discountPrice;
    }

    // 여기에는 Getter, Setter 작성
    
    // 여기에는 toString 작성 (자동 생성)
}

OrderService

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

OrderServiceImpl

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);
    }
}
  • OrderServiceImpl에서는 할인이 어떤 방식으로 이루어지는지 알 필요가 없다!
  • 이는 SRP(=단일 책임 원칙)가 잘 지켜진 사례로 볼 수 있다.

테스트

구현을 완료했으니 테스트 코드를 작성하자!
test 폴더 하위에 hello.core.order 패키지를 생성하고 OrderServiceTest를 작성한다.

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

테스트가 잘 수행되는 것을 확인할 수 있다.

사용했던 IntelliJ 단축키 정리

  • Alt + Insert: Generate (생성자, Getter, Setter 등을 자동 생성)
  • Alt + Enter: 뭔가 빨간줄 그어졌을 때 해결
  • psvm치고 Enter: 메인함수 생성
  • sout치고 Enter: System.out.println 생성
  • soutv치고 Enter: 각종 변수의 값을 출력하는 코드 생성
  • Ctrl+Alt+V: Refactoring (해당 라인의 값을 지역 변수에 할당하는 기능으로 쓸 수 있음)
  • F2: 에러나는 곳(빨간줄)으로 바로 이동

0개의 댓글