해당 포스팅은 인프런에서 강의중이신 김영한 선생님의 스프링 강의를 듣고 복습하는 목적으로 작성되었습니다.
스프링 핵심 원리 - 기본편 강의 | 김영한 - 인프런
김영한 | 스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다., 스프링 핵심 원리를 이해하고, 성장하는 백엔드 개발자가 되어보
www.inflearn.com
인텔리제이 및 스프링 부트 설치는 생략하겠습니다!
1. 비즈니스 요구사항 설계
먼저 어떤 프로젝트를 만들 것인지 요구사항을 설정하는 것이 선행되어야 한다.
현재 우리에게 주어진 비즈니스 요구사항은
회원
- 회원을 가입하고 조회 가능
- 회원의 등급은 일반, VIP 두가지
- DB에 관한 것은 아직 미정
- 주문과 할인 정책
- 회원은 상품을 주문 가능
- 회원 등급에 따라 할인 정책을 적용할 수 있음
- VIP는 1000원을 고정으로 할인(나중에 변경 될 수 있음)
요구사항을 보면 아직 회원의 DB와 할인 정책에 대해 결정되지 않은 것을 확인할 수 있다.
그렇지만 '객체 지향' 설계 방법을 통해 인터페이스와 구현체를 분리하여 일단 개발을 시작해보자.
2. 회원 도메인 설계
도메인이란 해결하고자 하는 문제의 영역이다.
회원 도메인은 회원가입과 회원조회라는 서비스를 제공해야 한다.
그리고 회원 도메인은 회원 저장소에 저장되어야 한다.
따라서
클라이언트 -> 회원서비스 -> 회원저장소
이렇게 간단히 회원 도메인의 협력 관계를 작성해볼 수 있다.
회원 엔티티는 id, name, grade라는 속성을 가지고 있다.
그리고 grade 는 관리하기 쉽도록 enum 클래스로 작성한다.
// 회원 등급
public enum Grade{
BASIC,
VIP
}
//회원 엔티티
public class Member {
private void id;
private String name;
private Grade grade;
public Member(Long id, Stirng 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;
}
}
이렇게 회원 엔티티를 작성할 수 있다. Member 클래스의 필드는 private로 설정하고
getter,setter를 사용해서 값을 얻고 설정할 수 있게 했다.
그리고 회원 저장소를 만들자
package hello.core.member;
public interface MemberRepository {
void save (Member member);
Member findById(Long memberId);
}
회원 저장소는 아직 어떻게 구현할지 정해지지 않았기 때문에 최소한의 기능을 제공하도록 설계한다.
MemberRepository 인터페이스는 멤버를 저장하는 save 메서드와 memberId를 파라미터로 받아 Member를 반환하는 findById 메서드를 갖는다.
package hello.core.member;
import org.springframework.stereotype.Component;
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를 구현하는 메모리 회원 저장소를 만든다. MemberRepository가 설계 해둔 메서드를 구현해야 하는데 이 때 HashMap 자료구조를 사용한다.
원래 HashMap은 동시성 이슈가 발생할 수 있어 ConcurrentHashMap을 사용하지만 여기서는 간단한 예제를 만드는 것이기에 HashMap을 사용한다. save 메서드를 통해 store 객체에 memberId가 키값으로, 그 키 값에 대당하는 Member 객체가 value 값으로 들어간다. findById에서 Id 값으로 get 메서드를 통해 Member를 반환받을 수 있는 이유이다.
지금까지 회원 엔티티와 회원 저장소를 만들었으니 이제 회원 서비스만 만들어주면 된다.
package hello.core.member;
public interface MemberService {
void join(Member member);
Member findMember(Long memberId);
}
회원 서비스도 인터페이스로 먼저 설계를 한다. 회원 서비스는 1. 회원 가입, 2. 회원 조회 두 가지 기능을 제공해야 한다.
package hello.core_review.member;
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);
}
}
그리고 MemberService를 구현할 구현체를 만든다. 해당 구현체는 회원 가입과 회원 조회를 구현해야 한다. 그리고 회원 서비스는 회원 저장소 객체를 이용한다. 여기서 중요한 것은 MemberRepository를 의존하게 만드는 것이다.
이제 회원 도메인 구현이 완료 되었다.
우리가 원하는 회원 도메인은
클라이언트 -> 회원 서비스 -> 메모리 회원 저장소
의 구조를 만드는 것이었다.
우리는 이를 위해 멤버 엔티티를 먼저 만들었다. 그리고 멤버 엔티티 이용해 멤버 저장소를 만든다. 멤버 저장소는 인터페이스로 Member Repository를 만들고 그 구현체 MemoryMemberRepository로 구성되어 있다. 그리고 멤버 저장소를 멤버 서비스가 의존하도록 한다. 이러면 멤버 저장하는 로직이 바뀌더라도 구현체만 바꿔주면 되기 때문에 더 편리하다.
3. 회원 도메인 실행과 테스트
지금까지 구현한 회원 도메인 로직이 잘 작동하는지 테스트 해보자
먼저 순수 자바로 구현해보자
package hello.core_review;
import hello.core_review.member.Grade;
import hello.core_review.member.Member;
import hello.core_review.member.MemberService;
import hello.core_review.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("findMember = " + findMember.getName());
}
}
memberService에 우리가 원하는 기능이 다 들어이 있기 때문에 memberService를 하나 생성한다.
그리고 member를 하나 생성해 해당 맴버를 회원가입, 회원조회 해보겠다.
실행 결과는
잘 동작하는 것을 확인할 수 있다.
그런데 이렇게 애플리케이션 로직으로 이렇게 테스트 하는 것은 좋은 방법이 아니다.
JUnit 테스트를 사용해보자
package hello.core_review.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(member.getId());
//then
Assertions.assertThat(member).isEqualTo(findMember);
}
}
Test는 Test 패키지 밑에서 진행해야 한다.
Test 파일에서는 실행할 메서드 위에 @Test 애너테이션을 추가하면 테스트를 진행할 수 있다.
테스트는 given - when - then의 순서로 작성하면 좋다.
먼저 멤버 하나가 생성됐다.
그리고 그 멤버가 회원가입을 하고, 해당 멤버를 회원 조회한다
그러면 .isEqualTo 메서드르 통해 두 객체가 같은지 검증해봄을 통해 테스트 로직을 완료할 수 있다.
결과는?
성공!
이렇게 회원 도메인 기능의 서비스까지 성공적으로 마무리했으니 다 끝난 것일까?
아쉽게도 우리가 지금까지 작성한 코드는 문제점을 가지고 있다.
4. 회원 도메인 설계의 문제점
지금까지가 우리가 작성한 코드는 OCP 원칙과 DIP 원칙을 준수하고 있지 않다.
여기서 OCP 원칙과 DIP 원칙은 객체지향 설계 5원칙이라 불리는 SOLID 원칙 중
Open Closed Princple(개방 폐쇄 원칙)과 Dependency Inversion Priceplne(의존 역전 원칙)을 의미한다.
OCP는 쉽게 말해 확장에는 열려있어야 하지만 변경에는 닫혀 있어야 한다는 것이다. 이는 interface로 구현한다.
DIP는 추상화된 것이 구체적이 것에 의존해서는 안된다는 것이다. 구체적인 것이 추상화된 것에 의존해야 한다.
이렇게 말해서는 사실 그래서 지금 우리 코드에서 어떤 부분이 문제라는건지? 명확히 이해가 되지 않을 것이다.
또한 아직 우리는 주문과 할인 도메인에 관한 코드를 구현하지 않았다.
일단 주문 할인 도메인을 설계하고 구현해보자.
5. 주문과 할인 도메인 설계
주문과 할인 정책에 대해 다시 상기해보자
- 먼저 회원은 상품을 주문할 수 있어야 한다.
- 또한 회원 등급에 따라 할인 정책이 적용되어야 한다.
- 할인 정책은 모든 VIP 고객은 1000원을 고정 금액으로 할인해주는 것이다. (나중에 변경될 수 있다)
클라이언트는 주문 서비스를 사용할 수 있어야한다.
그리고 주문 서비스는 회원 저장소(회원 조회를 통한 등급 확인)와 할인 정책(회원 등급에 따른 할인)을 기반으로 작동된다.
그리고 다시 주문 서비스는 클라이언트에게 주문 결과를 반환해줘야 한다.
여기서 중요한 것은 우리가 지금까지 말한 주문 서비스, 회원 저장소, 할인 정책들을 '역할' 과 '구현'을 분리해 설계해야 한다는 것이다.
앞서 설계했던 회원 저장소처럼 할인 정책 또한 아직 어떤 할인 정책을 사용할지 결정되지 않았기 때문에, 할인 정책에 대한 역할
즉 interface를 중심으로 설계 해야한다. 그리고 그 구현체를 따로 만드는 것이다. 이렇게 하면 유연하게 구현체를 변경할 수 있게 된다.
또한 앞서 DIP 원칙에 대해 설명했듯 추상화된 인터페이스는 언제나 추상화된 '역할'을 의존해야 한다.
이런 클래스 다이어그램을 생각해볼 수 있을 것이다.
6. 주문과 할인 도메인 개발
먼저 할인 정책 인터페이스를 만들어보자
package hello.core_review.Discount;
import hello.core_review.member.Member;
public interface DiscountPolicy {
/*
@return 할인 대상금액
*/
int discount(Member member, int price);
}
DiscountPolicy의 목적은 할인 대상에 대해 할인될 금액을 반환하는 것이다.
package hello.core_review.Discount;
import hello.core_review.member.Grade;
import hello.core_review.member.Member;
public class FixDiscountPolicy implements DiscountPolicy {
private int discountFixAmount = 1000; // VIP 일시 할인 금액 1000원으로 고정
@Override
public int discount(Member member, int price) {
if (member.getGrade() == Grade.VIP) {
return discountFixAmount;
} else {
return 0;
}
}
}
먼저 구현한 할인 정책은 VIP 시 1000원을 고정으로 할인해주는 FixDiscountPolicy 이다.
member를 받아서 그 멤버의 등급이 VIP이면 고정된 금액을 할인해준다.
이제 주문 엔티티를 만들자
package hello.core_review.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 String getItemName() {
return itemName;
}
public int getItemPrice() {
return itemPrice;
}
public int getDiscountPrice() {
return discountPrice;
}
@Override
public String toString() {
return "Order{" +
"memberId=" + memberId +
", itemName='" + itemName + '\'' +
", itemPrice=" + itemPrice +
", discountPrice=" + discountPrice +
'}';
}
}
주문 엔티티는 주문하는 멤버의 id, 주문하는 아이템 이름, 가격, 할인 가격을 속성(필드값)으로 갖는다.
그리고 주문의 금액을 반환하는 메서드와 getter 메서드를 통해 속성의 값을 불러올 수 있게 했다.
이제 주문 서비스를 만들자
먼저 인터페이스 부터
package hello.core_review.order;
public interface OrderService {
Order createOrder(Long memberId, String itemName, int itemPrice);
}
주문 서비스의 목적은 주문을 만드는 것이다. 이때 멤버의 아이디, 아이템의 이름과 가격이 필요할 것이다.
package hello.core_review.order;
import hello.core_review.Discount.DiscountPolicy;
import hello.core_review.Discount.FixDiscountPolicy;
import hello.core_review.member.Member;
import hello.core_review.member.MemberRepository;
import hello.core_review.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);
}
}
주문 서비스 구현체는 주문을 생성한다. 그리고 클래스 다이어그램에서 보았듯 주문 서비스는 회원 저장소와 할인 정책에 의존한다.
7. 주문 할인 도메인 실행과 테스트
작성한 주문 서비스를 테스트해보자
package hello.core_review;
import hello.core_review.member.Grade;
import hello.core_review.member.Member;
import hello.core_review.member.MemberService;
import hello.core_review.member.MemberServiceImpl;
import hello.core_review.order.Order;
import hello.core_review.order.OrderService;
import hello.core_review.order.OrderServiceImpl;
public class OrderApp {
public static void main(String[] args) {
MemberService memberService = new MemberServiceImpl();
OrderService orderService = new OrderServiceImpl();
long memberId = 1;
Member member = new Member(memberId, "memeberA", Grade.VIP);
memberService.join(member);
Order order = orderService.createOrder(memberId, "itemA", 10000);
System.out.println("order = " + order);
}
}
OrderApp은 멤버 서비스와 오더 서비스를 이용한다. 멤버를 생성하고, 멤버를 회원가입한 뒤 주문을 만든다.
할인 정책이 잘 반영 된 오더가 생성되었을까?
잘 생성된 것을 확인할 수 있다.
그럼 Junit으로 다시 테스트해보자
package hello.core_review.order;
import hello.core_review.member.Grade;
import hello.core_review.member.Member;
import hello.core_review.member.MemberService;
import hello.core_review.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 = 1;
Member member = new Member(memberId, "memberA", Grade.VIP);
memberService.join(member);
Order order = orderService.createOrder(memberId, "itemA", 10000);
Assertions.assertThat(order.getDiscountPrice()).isEqualTo(1000);
}
}
Junit에서 createOrder Test를 실행해보았다.
결과는?
성공!
다음 포스팅에서는 지금까지 우리가 작성한 코드를 정리하고.
OCP,DIP를 지킨 코드는 어떻게 설계해야하는지 알아보도록 하겠다.
'Spring' 카테고리의 다른 글
[Spring 핵심 원리 이해] 6. 의존관계 자동 주입 (1) | 2025.01.03 |
---|---|
[Spring 핵심 원리 이해] 5. 컴포넌트 스캔 (0) | 2024.12.28 |
[Spring 핵심 원리 이해] 4. 싱글톤 컨테이너 (0) | 2024.12.27 |
[Spring 핵심 원리 이해] 3. 스프링 컨테이너와 스프링 빈 (4) | 2024.12.20 |
[Spring 핵심 원리 이해] 2. 객체 지향 원리 적용 (2) | 2024.12.18 |