김영한 강사님의 [Spring 핵심 원리 기본편] 강의를 수강하며 회원 및 주문 도메인 애플리케이션을 설계하였다. 강사님께서는 강의 내용과 마찬가지로 프레임워크 없이 순수 Java를 이용하여 구현하였다. 사실 강사님께서 강의하신 내용은 너무 유명한지라 강의 내용을 그대로 정리한 수많은 글들이 존재한다. 따라서 강의 내용을 정리하기보다는 강의에서 주어진 비즈니스 요구사항을 바탕으로, 배운 내용을 활용하여 직접 애플리케이션을 구현해보았다.
비즈니스 요구사항
회원
- 회원을 가입하고 조회할 수 있다.
- 회원은 일반과 VIP 두 가지 등급이 있다.
- 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다 (미확정)
주문과 할인 정책
- 회원은 상품을 주문할 수 있다.
- 회원 등급에 따라 할인 정책을 적용할 수 있다.
- 모든 VIP에게는 1,000원을 할인해주는 고정 금액 할인을 적용한다. (나중에 변경 가능, 미확정)
- 할인 정책은 변경 가능성이 높다. 회사의 기본 할인 정책을 아직 정하지 못했고, 오픈 직전까지 고민을 미루고 싶다. 최악의 경우 할인을 적용하지 않을 수도 있다.
회원 도메인 설계
강의 내용과 마찬가지로 회원 등급은 BASIC과 VIP로 구분되며 Enum을 활용하여 관리한다.
Enum은 상수와 유사한 역할을 하면서도 타입 안정성을 보장하므로 회원 등급을 코드로 표현하기에 적합하다.
public enum Grade {
BASIC,
VIP
}
Member 클래스는 회원의 고유 ID, 이름, 등급 정보를 포함하며, 데이터 무결성을 보장하기 위해 불변 객체로 설계하였다. 불변 객체는 외부에서 데이터를 변경할 수 없기 때문에 무결성이 보장된다. 객체 생성 시 데이터를 초기화하는 생성자만 제공하고, 데이터 수정 메서드는 제공하지 않는다.
public class Member {
private final Long id;
private final String name;
private final 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 String getName() { return name; }
public Grade getGrade() { return grade; }
}
회원 데이터를 저장하고 조회하는 책임은 MemberRepository가 가지며, 인터페이스 기반 설계를 통해 구현체를 자유롭게 교체할 수 있도록 했다. 초기 단계에서는 메모리 기반 저장소를 구현했고, 팩토리 패턴을 사용해 저장소 구현체를 생성하도록 설계하였다.
public interface MemberRepository {
void save(Member member);
Member findById(Long memberId);
}
public class MemoryMemberRepository implements MemberRepository {
private final 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);
}
}
public class MemberRepositoryFactory {
public static MemberRepository createRepository(String type) {
if ("memory".equalsIgnoreCase(type)) {
return new MemoryMemberRepository();
}
throw new IllegalArgumentException("Unsupported repository type: " + type);
}
}
회원가입 및 조회와 같은 비즈니스 로직은 MemberService에서 처리한다. MemberService는 저장소 인터페이스를 의존성으로 주입받아 구체적인 저장소 구현체와 분리된 상태를 유지한다. 이로 인해 의존성 역전 원칙(DIP)을 준수하며, 저장소 교체 시에도 서비스 로직은 수정되지 않도록 설계하였다.
public interface MemberService {
void join(Member member);
Member findMember(Long memberId);
}
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
public MemberServiceImpl(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public void join(Member member) {
memberRepository.save(member);
}
@Override
public Member findMember(Long memberId) {
return memberRepository.findById(memberId);
}
}
Junit으로 테스트 코드를 작성하였다.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class MemberServiceTest {
@Test
void join() {
// given
MemberRepository memberRepository = new MemoryMemberRepository();
MemberService memberService = new MemberServiceImpl(memberRepository);
Member member = new Member(1L, "홍길동", Grade.VIP);
// when
memberService.join(member);
Member findMember = memberService.findMember(1L);
// then
assertThat(findMember).isEqualTo(member);
}
}
할인 도메인 설계
주어진 요구사항 중에서 할인 정책은 확정이라고 볼 수 있는 요구사항이 단 한 개도 없다. 끔찍하지만 비즈니스 상황에서 충분히 일어날 수 있는 상황이기에 최대한 수정과 확장이 용이한 구조로 설계하고자 했다. 강의에서 배운 객체지향 설계 원칙을 할인 도메인 설계 상황에 적용해보면 다음과 같다.
- 할인 정책은 오직 할인 금액 계산만 책임져야 하며, 다른 비즈니스 로직과 결합하지 않는다. (단일 책임 원칙)
- 할인 정책의 확장(=새로운 정책 추가)에 열려 있고, 기존 코드 수정에는 닫혀 있어야 한다. (개방-폐쇄 원칙)
- 주문 서비스는 할인 정책의 구체적인 구현에 의존하지 않고, 인터페이스를 통해 정책을 의존하도록 한다 (의존성 역전 원칙)
할인 정책 인터페이스
DiscountPolicy 인터페이스는 모든 할인 정책의 공통 동작(할인 금액 계산)을 정의하는 인터페이스이다. 해당 인터페이스를 통해 다양한 할인 정책을 구현하며, 주문 서비스는 인터페이스에만 의존하도록 설계하였다.
public interface DiscountPolicy {
int discount(Member member, int price);
}
구현체 1 - 고정 금액 할인 정책
FixDiscountPolicy는 VIP 등급 회원에게 1,000원 고정 금액 할인을 적용하는 정책이다. 비즈니스 요구사항에 따라 VIP 회원에게 1,000원을 고정으로 할인해주는 정책이 기본적으로 적용되지만 향후 변경 가능성이 있기 때문에 구현체를 인터페이스로 추상화하여 주문 서비스에서 구체적인 구현을 모르도록 설계하였다.
public class FixDiscountPolicy implements DiscountPolicy {
private static final int DISCOUNT_AMOUNT = 1000;
@Override
public int discount(Member member, int price) {
if (member.getGrade() == Grade.VIP) {
return DISCOUNT_AMOUNT;
}
return 0;
}
}
FixDiscountPolicy는 단일 책임 원칙에 따라 고정 금액 할인 로직만 처리하며, 다른 비즈니스 로직과는 독립적이다. 새로운 할인 정책이 추가되어도 FixDiscountPolicy 구현체는 수정되지 않는다.
구현체 2 - 비율 할인 정책
회사의 할인 정책은 아직 결정되지 않은 상태이며 고정 금액 할인 정책 역시 향후 변경 가능성이 있기 때문에, VIP 회원에게 주문 금액의 일정 비율(10%)을 할인해주는 비율 할인 정책을 임의로 구현해보았다.
public class RateDiscountPolicy implements DiscountPolicy {
private static final double DISCOUNT_RATE = 0.1;
@Override
public int discount(Member member, int price) {
if (member.getGrade() == Grade.VIP) {
return (int) (price * DISCOUNT_RATE);
}
return 0;
}
}
구현체 3 - 할인 없음
비즈니스 요구사항이 명확하지 않은 상황에서 기본 정책으로 사용할 수 있는 할인 미적용 상황을 구현해보았다. NoDiscountPolicy 클래스는 특정 조건에서 할인을 적용하지 않는 경우를 처리한다.
public class NoDiscountPolicy implements DiscountPolicy {
@Override
public int discount(Member member, int price) {
return 0;
}
}
할인 정책이 필요 없는 경우 기본 정책으로 NoDiscountPolicy를 설정하거나 특정 회원 등급에 대한 할인 정책이 없을때 적용할 수도 있다.
할인 정책 팩토리
다양한 할인 정책을 유연하게 생성하기 위해 팩토리 패턴을 적용하였다. 팩토리 패턴은 객체 생성 로직을 별도의 클래스로 분리하여 객체 생성과 사용을 분리하고, 정책 변경 시 코드 수정 범위를 최소화하기 위해 주로 사용된다. 정책 변경이나 추가가 필요한 경우에는 팩토리 메서드만 수정하면 된다는 장점이 있다.
public class DiscountPolicyFactory {
public static DiscountPolicy createPolicy(String type) {
switch (type.toLowerCase()) {
case "fix":
return new FixDiscountPolicy();
case "rate":
return new RateDiscountPolicy();
case "none":
return new NoDiscountPolicy();
default:
throw new IllegalArgumentException("Unknown discount policy type: " + type);
}
}
}
주문 도메인 설계
- 주문 서비스는 주문 생성 로직만 담당하며, 할인 계산은 할인 정책(DiscountPolicy)에 위임한다.
- 주문 서비스는 할인 정책의 변경이나 추가에 대해 열려 있지만 수정에는 닫혀 있다.
- 할인 정책은 DiscountPolicy 인터페이스를 통해 교체 가능하며, 주문 서비스는 구현체의 변경에 영향을 받지 않는다.
- 주문 서비스는 할인 정책의 구현체가 아닌 인터페이스(DiscountPolicy)에 의존하므로, 구체적인 구현과의 결합도를 최소화한다.
주문 클래스
Order 클래스는 주문 생성 시 필요한 정보(회원 ID, 상품명, 상품 가격, 할인 금액)를 받아 저장하며, 최종 결제 금액을 계산하는 메서드를 제공한다.
public class Order {
private final Long memberId;
private final String itemName;
private final int itemPrice;
private final 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;
}
@Override
public String toString() {
return "Order{" +
"memberId=" + memberId +
", itemName='" + itemName + '\'' +
", itemPrice=" + itemPrice +
", discountPrice=" + discountPrice +
", finalPrice=" + calculatePrice() +
'}';
}
}
주문 서비스 인터페이스
OrderService는 주문 생성과 관련된 비즈니스 로직을 정의하는 서비스 인터페이스이다. 인터페이스 기반 설계를 통해 주문 서비스의 구체적인 구현체를 추상화하며, 구현체와 호출자의 결합도를 낮추는 역할을 한다. 메서드는 주문 생성 로직만 정의하며, 실제 구현은 OrderService의 구현체에서 처리한다.
public interface OrderService {
Order createOrder(Long memberId, String itemName, int itemPrice);
}
주문 서비스 구현체
OrderServiceImpl 클래스는 주문 생성 로직을 구현한 주문 서비스의 구체적인 구현체로, 회원 정보를 조회하고 할인 정책을 적용하여 주문 객체를 생성한다. MemberRepository와 DiscountPolicy는 생성자를 통해 전달받기 때문에 클래스 내부에서 직접 객체를 생성하지 않고 외부에서 주입받아 결합도를 낮출 수 있다.
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository; // 회원 저장소
private final DiscountPolicy discountPolicy; // 할인 정책
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
@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);
}
}
실제 사용 시 주문 서비스의 createOrder 메서드가 호출되면 다음과 같은 순서로 동작하게 된다.
1. MemberRepository를 통해 입력된 memberId로 회원 정보를 조회
2. DiscountPolicy를 사용해 입력된 상품 가격(itemPrice)에 따라 할인 금액을 계산
3. 회원 정보와 상품 정보를 조합해 Order 객체를 생성하고 반환
다음 글에서는 Spring Boot를 통해 동일한 애플리케이션을 API 방식으로 설계해보고자 한다.
To be continued...