스프링 핵심 원리 이해1 - 예제 만들기
예제 만들기
비즈니스 요구사항과 설계
- 회원
- 회원을 가입하고 조회할 수 있다.
- 회원은 일반과 VIP 두 가지 등급이 있다.
- 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다. (미확정)
- 주문과 할인 정책
- 회원은 상품을 주문할 수 있다.
- 회원 등급에 따라 할인 정책을 적용할 수 있다.
- 할인 정책은 모든 VIP는 1000원을 할인해주는 고정 금액 할인을 적용해달라. (나중에 변경 될 수 있다.)
- 할인 정책은 변경 가능성이 높다. 회사의 기본 할인 정책을 아직 정하지 못했고, 오픈 직전까지 고민을미루고 싶다. 최악의 경우 할인을 적용하지 않을 수 도 있다. (미확정)
요구 사항을 보면 회원 데이터와 할인정책같이 수정될 수 있는 부분은 객체 지향 설계 방법으로 대비할 수 있다.
우선 스프링 없이 순수 자바 코드로만 개발을 진행해보자.
회원 도메인 설계
- 회원
- 회원을 가입하고 조회할 수 있다.
- 회원은 일반과 VIP 두 가지 등급이 있다.
- 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다. (미확정)
MemberService의 구현체인 MemberSerciveImpl는 회원가입과 회원 조회의 기능을 구현하고 MemberRepository에 데이터를 저장하거나 조회할 수 있다.
여기서 MemberRepository는 자체 DB를 구축할 수도 있고 외부 시스템과도 연동할 수 있음으로 MemoryMemberServiceImpl와 DbMemberServiceImpl를 객체지향 설계를 바탕으로 구현해둔다.

회원 도메인 개발
회원 엔티티
엔티티에 대한 설명
자바에서 "엔티티"라는 용어는 일반적으로 두 가지 주요 의미로 사용될 수 있습니다.
- JPA(Java Persistence API) 엔티티: JPA는 자바에서 데이터베이스를 사용하는 애플리케이션을 위한 ORM(Object-Relational Mapping) 기술을 위한 스펙입니다. JPA 엔티티는 데이터베이스의 테이블과 매핑되는 자바 클래스를 의미합니다. 이러한 엔티티 클래스는 데이터베이스 테이블과 필드 간의 매핑, 데이터 조작을 위한 메소드 등을 포함할 수 있습니다.
- 일반적인 의미의 엔티티: 일반적인 소프트웨어 개발 컨텍스트에서 "엔티티"는 애플리케이션에서 다루는 개별적인 객체나 데이터의 단위를 나타냅니다. 예를 들어, 엔티티는 고객, 주문, 제품 등과 같은 비즈니스 관련 객체를 가리킬 수 있습니다. 이러한 엔티티들은 시스템의 핵심 개념이며, 애플리케이션의 기능과 데이터 처리를 구현하는 데 사용됩니다.
요약하면, 자바에서 "엔티티"는 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에게 물어봤다.
인터페이스의 사용은 프로그램의 유연성, 확장성, 유지보수성을 향상시키는 중요한 개념 중 하나입니다. 여기에 몇 가지 이유를 살펴보겠습니다:
- 미래의 변경 가능성: 변화는 불가피한 것이며, 향후에도 변경 사항이 예상되지 않더라도 새로운 요구사항, 환경 변화 등으로 인해 코드가 수정되어야 할 수 있습니다. 이때 인터페이스를 사용하면 변경된 부분을 최소화하고 다른 구성 요소에 영향을 덜 주는 장점이 있습니다.
- 코드 확장성: 코드가 기존 기능을 초과하는 새로운 기능을 추가해야 할 경우, 인터페이스를 활용하면 기존 코드를 수정하지 않고 새로운 구현 클래스를 추가하여 기능을 확장할 수 있습니다.
- 모듈화 및 유지보수성: 인터페이스를 사용하면 코드를 모듈화하고 각 모듈 간의 의존성을 최소화할 수 있습니다. 이로써 한 모듈의 변경이 다른 모듈에 영향을 덜 주며, 유지보수성이 향상됩니다.
- 테스트 용이성: 인터페이스를 사용하면 단위 테스트 등을 더 쉽게 수행할 수 있습니다. 모의(mock) 객체를 사용하여 테스트하거나, 다양한 구현체를 주입하여 테스트하는 등의 작업이 더 간편해집니다.
- 코드 재사용: 인터페이스를 사용하면 다양한 구현 클래스에서 같은 인터페이스를 구현하여 재사용성을 높일 수 있습니다.
요약하면, 변경사항이 예상되지 않는다고 해도 인터페이스를 사용하는 것은 소프트웨어의 유연성과 확장성을 고려한 좋은 설계 원칙입니다. 변경사항에 대비하여 코드를 구조화하고 모듈화하는 것은 시간과 인력을 절약하며, 시스템의 복잡성을 관리할 수 있는 방법 중 하나입니다.
회원 서비스 구현체
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
테스트 성공
*/
하지만 이렇게 애플리케이션 로직에서의 테스트는 좋은 방법이 아니다.
● 좋은 방법이 아닌 이유
애플리케이션 로직에서의 테스트가 좋지 않은 이유는 다양한 측면에서 발생할 수 있습니다. 아래는 그 중 일부를 설명한 것입니다:
- 결합도 증가: 애플리케이션 로직에서 테스트를 수행하는 경우, 테스트 코드와 실제 로직이 강하게 결합될 수 있습니다. 이로 인해 로직 변경 시 테스트 코드도 함께 수정해야 할 가능성이 높아집니다.
- 테스트 가독성 및 유지보수성 감소: 애플리케이션 로직에서 테스트를 직접 수행하는 경우, 테스트 코드와 비즈니스 로직이 섞일 수 있습니다. 이는 테스트 코드의 가독성과 유지보수성을 감소시킬 수 있습니다.
- 변경의 어려움: 로직 수정 시 테스트 코드도 함께 수정해야 하기 때문에, 변경 사항을 반영하는데 추가 작업이 필요합니다. 이는 코드 변경을 어렵게 만들고 실수를 유발할 수 있습니다.
- 테스트 커버리지 부족: 애플리케이션 로직에서 직접 테스트를 작성하는 것은 모든 시나리오를 충분히 커버하기 어렵게 만들 수 있습니다. 모든 가능한 경로와 조건을 테스트하는 것은 매우 어려운 작업이며, 결과적으로 테스트 커버리지가 부족할 수 있습니다.
- 코드 중복: 로직과 테스트 코드 간의 중복이 발생할 수 있습니다. 로직을 변경하면 테스트 코드도 수정해야 하고, 이로 인해 유사한 코드가 중복될 수 있습니다.
- 단위 테스트 제한: 애플리케이션 로직을 직접 테스트하는 경우, 다양한 의존성과 환경 설정을 모방하기 어려울 수 있습니다. 이로 인해 단위 테스트의 효과를 제한할 수 있습니다.
따라서 애플리케이션 로직에서 테스트가 좋지 않은 이유는 유연성, 유지보수성, 가독성, 테스트 커버리지 등 다양한 측면에서 문제를 야기할 수 있기 때문입니다. 대신, 테스트 가능한 코드를 작성하고 테스트 주도 개발(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 객체를 생성해 할인이 적용된 모습이다.