주문 시스템 예제와 예제가 갖는 문제점
0. 비즈니스 요구사항
- 👥 회원
- 회원 가입 & 조회기능 구현
- 회원은 일반(BASIC)과 VIP 두 가지 등급으로 나뉨
- 회원 데이터는 DB 저장 또는 외부 시스템과 연동 가능(미확정)
- 🛒 주문 & 할인 도메인
- 주문 기능 ✔️ 회원은 상품을 주문할 수 있음
- 주문 시 할인 정책 적용 가능
- 할인 정책 VIP 등급은 고정 금액(1000원) 할인
- 할인 정책은 변경될 가능성이 있음!
요구사항들을 보면 지금 결정하기 어려운 부분들이 있다. 그렇다고 이런게 결정될 때까지 개발을 무기한 기다릴 순 없다.
우리는 앞에서 배운대로 인터페이스를 만들고 구현체로 언제든지 갈아끼울 수 있도록 설계하면 된다.
1. 회원 도메인 설계
설계의 핵심: 역할과 책임을 나누기
클래스 다이어그램
멤버 저장소 인터페이스
public interface MemberRepository {
//멤버 저장
void save(Member member);
//id로 멤버 찾기
Member findById(Long memberId);
}
멤버 저장소 구현체(MemoryMemberRepository) 이지만 나중에 DBMemberRepository 이렇게 바뀔 수 있다.
public class MemoryMemberRepository implements MemberRepository {
private static Map<Long, Member> store = new 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);
}
회원 서비스 구현체
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);
}
}
- 이 회원 도메인의 문제점은
- MemberServiceImpl이 MemoryMemberRepository를 직접 사용하고 있다! (어떤 저장소를 쓸지 직접 정하고 있음)
- 이렇게 되면, 저장 방식을 바꿀 때 코드 수정이 필요 🤔 DIP 위반
- 해결책: 인터페이스를 만들어서 저장소를 쉽게 교체할 수 있도록 설계해야 함.
2. 주문(Order) 도메인 폴더
클래스 다이어그램
주문 서비스
public interface OrderService {
Order createOrder(Long memberId, String itemName, int itemPrice) ;
//최종 오더 결과 반환
}
주문 서비스의 구현체
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);
//1. 주문 생성 - 클라이언트는 주문 요청
//2. 회원 조회 : 할인을 위한 회원 등급이 필요. 회원 저장소에서 조회
//3. 할인 정보 : 등급에 따른 할인 여부 정책
//4. 주문 객체 생성해서 반환
}
}
- 마찬가지로 `OrderServiceImpl`이 할인 정책(FixDiscountPolicy)을 직접 참조하고 있음!
- 할인 정책을 변경하려면 코드를 수정해야 하는 문제 발생
3. 할인(Discount) 정책 폴더
📂 discount
┣ 📜 DiscountPolicy.java # 할인 정책 인터페이스
┣ 📜 FixDiscountPolicy.java # 정액 할인 (VIP는 1000원 할인)
할인 정책 인터페이스
public interface DiscountPolicy {
int discount(Member member, int price);
}
할인 정책 구현체(1) -
public class FixDiscountPolicy implements DiscountPolicy {
private int discountFixAmount = 1000;
@Override
public int discount(Member member, int price) {
if(member.getGrade() == Grade.VIP){
return discountFixAmount;
}else {
return 0;
}
}
}
할인 정책 구현체(2)
public class RateDiscountPolicy implements DiscountPolicy {
private int discountPercent = 10;
public RateDiscountPolicy() {
this.discountPercent = discountPercent;
}
@Override
public int discount(Member member, int price) {
if(member.getGrade() == Grade.VIP){
return price * discountPercent/100;
}else {
return 0;
}
}
}
지금 이렇게 쭉 코드를 작성했는데 이 코드들의 문제점은
1. 회원 도메인에서 저장 방식을 바꾸려면 코드의 수정이 필요하다.
2. 할인 정책이 바뀌면 또 내부 코드의 수정이 필요하다.
https://fhtepgocprkswjfgka.tistory.com/28
OCP, DIP 에 대한 정리는 여기서 정리해뒀다.
Spring - 좋은 객체 지향 설계란?
/* 이 글은 김영한님의 강의를 보고 정리하려고 작성한 글입니다. 개인적인 공부를 위해 올리는 글입니다. */ 스프링의 핵심 자바 언어 기반의 프레임워크객체 지향 언어스프링은 객체 지향
fhtepgocprkswjfgka.tistory.com
새로운 할인 정책 개발과 OCP, DIP 문제점
악덕 기획자 : 기존에는 VIP 회원에게 무조건 1,000원 할인을 제공했지만, 👉 주문 금액의 10%를 할인하는 방식으로 바꾸고 싶어.
우리는 인터페이스를 만들어서 구현체만 따로 만들지 않았나? 이것만 갈아 끼우면 되지 않을까?
RateDiscountPolicy(정률 할인 정책)
서비스 구현체로 가서 할인 방식을 Rate로 바꾸면 된다 아님?
public class OrderServiceImpl implements OrderService {
// 기존 코드 (고정 할인)
// private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
// 새로운 코드 (정률 할인)
private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
}
문제점 발견
- 우리는 역할과 구현을 충실하게 분리했다. 인터페이스와 구현체를 구현해서 분리를 하지 않았는가
- OCP(개방-폐쇄 원칙) 위반 ❌
- 기존 코드를 수정해야만 새로운 할인 정책을 적용할 수 있음
- 코드를 확장하려면 클라이언트 코드에 영향을 준다는 것이다.
- DIP(의존관계 역전 원칙) 위반 ❌
- OrderServiceImpl이 DiscountPolicy 인터페이스뿐만 아니라, 구현 클래스(FixDiscountPolicy, RateDiscountPolicy)에도 의존하고 있음! (의존하고 있다 = 알고 있다)
- 추상(인터페이스 의존) : DiscountPolicy
- 구현(구현한 클래스) : FixDiscountPolicy, RateDiscountPolicy
- 인터페이스에만 의존하는 줄 알았는데 아래의 사진처럼 실제로는 구현체, 구현 클래스도 함께 의존을 하는 것이다.
- 추상에만 의존하게, 인터페이스에만 의존하게 변경을 해야 한다.
- OrderServiceImpl이 DiscountPolicy 인터페이스뿐만 아니라, 구현 클래스(FixDiscountPolicy, RateDiscountPolicy)에도 의존하고 있음! (의존하고 있다 = 알고 있다)
✔️ 해결책: 인터페이스에만 의존하도록 코드를 변경해야 함!
public class OrderServiceImpl implements OrderService {
// 인터페이스에만 의존하도록 코드 수정
// private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
private DiscountPolicy discountPolicy;
}
❌ 문제점 : 변경을 해도 null point exception이 발생한다.
누군가가 대신 클라이언트인 `OrderServiceImpl`에 `DiscountPolicy`의 구현체를 넣어주어야 문제가 해결된다.
관심사의 분리
1. 현실세계와의 예시 - 패스트푸드 매장과 키오스크 시스템 🍔
버X킹 매장에서 우리가 햄버거를 주문할 때 어떤 과정을 거칠까?
위의 코드 방식 대로면
고객이 햄버거를 주문할 때 직접 주방으로 가서 요리사에게 햄버거를 만들어 달라고 요청하는 것이다.이 방식은 햄버거 메뉴가 바뀌면 고객이 또 요리사한테 가서 새로운 메뉴 요청을 해야하고 주방마다 조리 방식이 다르면 고객이 신경을 써야 한다.
- OCP(개방-폐쇄)원칙 위반 -> 메뉴가 바뀌면 고객이 요리사한테 가서 행동해야 함
- DIP(의존관계 역전 원칙) 위반 -> 고객이 요리사(구체 클래스)에 의존하고 있음
새로운 방식 - 키오스크 도입(관심사의 분리 적용!)
이제 매장에 키오스크가 도입이 되었다고 가정해보자. 고객이 매장에 방문하면 키오스크(주문 시스템)에 가서 메뉴를 선택하고 메뉴 정보가 주방에 전달된다. 요리사가 주문을 받고 햄버거를 조리 후 고객은 계산을 하면 된다.
- 햄버거 메뉴가 바뀌어도 고객은 키오스크를 이용하면 됨(고객이 요리사를 신경 쓸 필요가X)
- 메뉴가 바뀌면 키오스크 시스템만 업데이트 하면 됨(주방 시스템은 그대로 유지)
- OCP 준수 -> 새로운 메뉴 추가 시 주문 시스템만 수정하면 됨
- DIP 준수 -> 고객은 키오스크(인터페이스)에만 의존하고, 요리사(구현 클래스)엔 의존하지 않음
2. AppConfig 클래스 추가
애플리케이션의 전체 동작 방식을 구성(config)하기 위해 구현 객체를 생성하고 연결하는 클래스를 추가한다.
public class AppConfig {
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
public DiscountPolicy discountPolicy() {
return new FixDiscountPolicy(); // 정책 변경 가능!
}
}
- `AppConfig`가 객체를 대신 생성해주고 연결해줌
- `MemberServiceImpl`
`MemoryMemberRepository`
`OrderServiceImpl`
`FixDiscountPolicy`
- `MemberServiceImpl`
- AppConfig는 생성한 객체 인스턴스의 참조(레퍼런스)를 **생성자를 통해서 주입(연결) 해준다
- `MemberServiceImpl` `MemoryMemberRepository`
- `OrderServiceImpl` `MemoryMemberRepository` , `FixDiscountPolicy`
- `OrderServiceImpl`이 할인 정책을 직접 선택하는 것이 아니라, 외부에서 주입받음(생성자 주입)
- 클라이언트는 의존관계에 대한 고민은 외부(AppConfig)에 맡기고 실행에만 집중 가능해짐
OCP(개방-폐쇄 원칙) 준수
- 새로운 할인 정책을 적용하고 싶다면 AppConfig 클래스에서 ` FixDiscountPolicy`를 `RateDiscountPolicy`로 변경하면 끝
public DiscountPolicy discountPolicy() {
return new RateDiscountPolicy(); // 할인 정책을 정률 할인으로 변경!
}
DIP(의존관계 역전 원칙) 준수
- `OrderServiceImpl`은 `DiscountPolicy` 인터페이스에 의존
- 구체적인 구현체(FixDiscountPolicy, RateDiscountPolicy)를 몰라도 됨! (코드에 Fix어쩌고가 없다)
- 인터페이스에만 의존하는 것을 볼 수 있다. (DiscountPolicy는 인터페이스 , Fix~ Rate~가 구현체)
- `AppConfig` 객체는 `memoryMemberRepository` 객체를 생성하고 그 참조값으로 `memberServiceImpl`을 생성하면서 생성자를 전달한다.
전체 흐름 정리
악덕 기획자들이 새로운 할인 정책을 개발하라고 했다.
-> 인터페이스로 구현체를 분리했고 새로운 할인 정책 코드를 추가로 개발하는 것 자체는 문제가 없음
하지만 새로운 할인 정책 적용할 때 문제점이 발생한다.
새로운 할인 정책을 적용하려니 클라이언트 코드인 주문 서비스 구현체까지 함께 변경해야 해서 OCP도 위반하고,
주문 서비스가 인터페이스, 구현체까지 함께 의존하는 DIP 위반 문제도 생겨버렸다.
그래서 우리는 관심사의 분리를 했다.
- 또 다른 예를 들면 애플리케이션이 하나의 공연이라고 생각해보자.
- 기존에는 클라이언트가 의존하는 서버 구현 객체를 직접 생성하고, 실행했다.
- 공연으로 들자면 공연의 주인공이 배우 섭외에 공연까지 공연 기획자들이 해야할 역할까지 본인이 다 한 셈이다.
- 그래서 공연을 구성하고, 담당 배우를 섭외하고, 누구한테 배역을 줄 지에 대한 책임을 담당하는 공연 기획자의 등장이 필요! 바로바로 AppConfig의 등장
- AppConfig는 애플리케이션(공연)의 전체 동작 방식을 구현하기 위해 구현 객체를 생성하고, 연결하는 책임을 얘가 하게 된다.
- 이제부터 배우는 공연에만 집중! 클라이언트 객체는 자신의 역할을 하는것에만 집중!하게 된다.
좋은 객체 지향 설계 5가지 원칙 중 3가지의 원칙이 적용된 셈이다.
DIP, OCP만 얘기했었는데 나머지 하나는 SRP 단일 책임 원칙이다.
하나의 클래스는 하나의 책임만 가져야 한다.
클라이언트는 원래 직접 구현체를 생성하고, 연결하고, 실행하는 다양한 책임을 가지고 있었는데 (배우가 캐스팅하고 배역 정하고 공연까지 하는 상황) SRP, 단일 책임 원칙을 따르면서 관심사의 분리를 하게 됐다.
구현을 생성하고 연결하는 책임을 AppConifg가 담당하고 클라이언트 객체는 실행하는 책임만 담당하게 되는 것이다.
DIP 의존관계 역전
프로그래머는 “추상화에 의존해야지, 구체화에 의존하면 안된다.”
새로운 할인 정책을 개발하고 적용하려니 클라이언트 코드에도 변경이 필요했다. 기존의 클라이언트는 인터페이스에 구현한 추상 클래스까지 다 의존하고 있었다. 이것을 인터페이스에만 의존하게 코드를 변경했다.
하지만 이 인터페이스만 의존하게 해서 코드는 실행되지 않는다. 어떤 할인 정책을 적용할지 모르니까
OCP 개방 폐쇄 원칙
소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다
아주 중요한 IoC, DI 그리고 컨테이너
제어의 역전 IoC(Inversion of Control)
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
}
처음 만들었던 프로그램을 보면 클라이언트 구현 객체가 스스로 필요한 서버 구현 객체를 생성하고, 연결하고, 실행했다. 한마디로 프로그램 제어 흐름을 스스로 조종했다. 개발자 입장에서는 개발자가 필요할 때 이거 꺼내쓰고 저거 꺼내쓰고 아주 자연스러운 일이다.
반면 AppConfig를 추가하고 나서 클라이언트는 실행만 하면 됐다. 본인이 해야됐었 던 다양한 책임이 줄어든 것이다.
그럼 프로그램에 제어 흐름은 이제 AppConfig가 가져가게 된다.
public class AppConfig {
public OrderService orderService() {
//오더서비스에는 멤버정보와 할인정보
return new OrderServiceImpl(getMemoryMemberRepository(),discountPolicy());
}
}
원래 였다면 오더 서비스의 구현체는 할인 정책들 이런거 다 본인이 정했지만 AppConfig의 등장으로 얘가 오더서비스의 구현체를 생성하고 실행도 해주게 된 것이다. OrderServiceImpl은 그런 것도 모르고 자신의 로직만을 묵묵하게 실핼할 것이다.
IoC는
제어의 흐름을 거꾸로 뒤집는 것이고 객체(OrderServiceImpl)는 자기가 사용할 객체도 스스로 선택하지 않는다. 또 자기 자신(객체 자신)도 어떻게 만들이지고 어디서 사용되는지 알 수 없다. (이것을 전부 AppConfig가 해줌)
모든 제어 권한을 자신이 아닌 다른 사람에게 위임하기 때문이다.
이렇게 프로그램의 제어 흐름을 내가 직접 제어하는 것이 아니라 외부에서 관리해 주는 것을 제어의 역전(IoC)라고 말한다.
프레임워크 VS 라이브러리
프레임워크는 내가 작성한 코드를 제어하고, 대신 실행하면 그것은 프레임워크이다.
예를 들면 테스트 코드를 작성할 때 쓰는 JUnit은 프레임 워크이다. 내가 테스트 코드를 작성하면 얘가 대신 테스트를 해주니깐.
@Test
@DisplayName("VIP는 10%할인이 적용되어야 한다.")
void vip_o(){
//given
Member member = new Member(1L, "memberVIP", Grade.VIP);
//when
int discount = rateDiscountPolicy.discount(member, 10000);
//then
assertThat(discount).isEqualTo(1000);
}
반면 라이브러리는 내가 작성한 코드가 직접 제어의 흐름을 담당하는 것이다
의존관계 주입 DI (Dependency Injection)
`OrderServiceImpl`는 할인 정책(`DiscountPolicy`) 인터페이스에 의존한다. 실제 어떤 구현 객체가 사용될지는 모른다.
의존 관계는 정적인 클래스 의존관계와, 실행 시점에 결정되는 동적인 객체(인스턴스) 의존 관계 둘을 분리해서 생각해야 한다.
정적인 클래스 의존관계(설계도 단계)
짠 이렇게 클래스 다이어그램을 보면 OrderServiceImpl은 MemberRepository와 DiscountPolicy에 의존하는 것을 알 수 있다.
하지만 이런 의존관계만으론 실제 어떤 객체가 OrderServiceImpl에 주입될 지 모른다. (어떤 저장 방식, 어떤 할인 정책인지 모른다는 것이다)
- 쉽게 말하면 설계도를 보면 어떤 부품이 어떤 부품과 연결되는 지 알 수 있다.
- 하지만 이 단계에선 실제로 건물이 지어진게 아니고 부품도 만들어진 게 아니다
- 즉 클래스 다이어그램에서 표현되는 관계이다.
동적인 서비스 의존관계(부품 교체 가능)
애플리케이션 실행 시점에 생성된 객체 인스턴스의 참조가 연결된 의존 관계이다.
애플리케이션 실행 시점(런타임)에 외부에서 구현 객체를 생성하고 클라이언트로 전달해서 서버의 실제 의존 관계가 연결되는 것을 의존관계 주입이라고 한다. 객체는 인스턴스를 생성하고, 그 참조값을 전달한다.
의존 관계 주입을 사용하면 코드를 변경하지 않고도 클라이언트가 호출하는 대상의 타입 인스턴스를 변경할 수 있다.
- 건물을 지을 때 처음부터 모든 부품을 고정하지 않고 필요에 따라 바꾸도록 설계할 수 있다.
- ex ) 문을 나무문에서 유리 문으로 바꾸면 훨 씬 유지보수가 쉬워지겠죠?
- 이처럼 소프트웨어에선 의존관계 주입을 통해 실행 중에도 객체를 바꿀 수 있다
- 덕분에 클라이언트는 변경하지 않고도 내부 구현을 바꿀 수 있다.
Ioc 컨테이너, DI 컨테이너
- Appconfig 처럼 객체를 생성하고 의존관계를 연결해 주는 것을 IoC컨테이너 또는 DI 컨테이너 라고 한다.
- 의존 관계 주입의 초점을 맞춰 최근엔 DI 컨테이너 라고 한다.
내 생각 정리
정말! 어려운 개념같은데 실제로도 어려운 것 같다.
IoC(제어의 역전)은 직접 운전하는 대신에 택시를 타고 목적지만 말하면 운전기사가 알아서 데려다 주는 것과 같다
원래라면 프로그램에서 개발자가 직접 객체를 만들고 호출을 해야되지만
IoC가 적용이 되면 객체의 생성과 실행 흐름을 프레임워크(Spring 등)이 관리해준다.
즉 개발자는 필요한 기능만 만들기만 하면 되고 실행 순서나 객체 관리 등은 프레임워크가 해주는 것이다.
의존 관계 주입은 조립식 컴퓨터를 생각하면 될 거 같다
직접 조립(의존 관계 주입X)
- 만약 조립식 컴퓨터를 사서 내가 직접 조립하게 된다면 CPU, RAM, 그래픽 카드를 구매해서 내가.혼자.스스로 조립을 해야한다. 이것은 객체가 직접 모든 객체를 생성하고 연결하는 것과 같다.
조립된 부품을 받는 방식(의존 관계 주입O)
- 부품을 우리가 고르지만 조립 자체는 조립 전문가(프레임워크)가 해주는 것이다.
- DI를 사용하면 필요한 부품(객체)을 외부에서 자동으로 주입받아 손쉽게 교체할 수 있다.
- 예를 들어 CPU를 Intel에서 AMD로 바꾸고 싶다면 부품만 변경하면 되듯, 서비스 로직에서 특정 구현체만 변경하면 애플리케이션이 그대로 동작한다.
즉 DI를 사용하면 마치 조립식 컴퓨터처럼 부품을 쉽게 교체하면서도 최적의 성능을 유지할 수 있는 개발이 가능해진다는 것이다.
'spring' 카테고리의 다른 글
Spring - 객체의 종류(VO, DTO, DAO, Entity) (1) | 2025.03.27 |
---|---|
Spring - 좋은 객체 지향 설계란? (3) | 2025.03.18 |
Spring - 프로젝트 구조/ 라이브러리 (1) | 2025.03.17 |