새로운 할인 정책 개발
기존에 주문금액에 관계없이 1,000원을 고정해서 할인해주던 정책을 주문금액의 10%를 할인해주는 정책으로 변경한다. 이전에 사용하던 FixDiscountPolicy 구현 클래스와 이번에 변경할 RateDiscountPolicy 구현 클래스를 나타낸 클래스 다이어그램이다.
RateDiscountPolicy와 정책을 테스트 하기 위한 테스트 코드를 작성한다.
RateDiscountPolicy
package spring.basic.discount;
import spring.basic.member.Grade;
import spring.basic.member.Member;
public class RateDiscountPolicy implements DiscountPolicy {
private int discountPercent = 10;
@Override
public int discount(Member member, int price) {
if (member.getGrade() == Grade.VIP) {
return price * discountPercent / 100;
} else {
return 0;
}
}
}
RateDiscountPolicyTest
package spring.basic.discount;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import spring.basic.member.Grade;
import spring.basic.member.Member;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
class RateDiscountPolicyTest {
RateDiscountPolicy discountPolicy = new RateDiscountPolicy();
@Test
@DisplayName("VIP는 10% 할인이 적용되어야 한다")
void vip_o() {
//given
Member member = new Member(1L, "memberVIP", Grade.VIP);
//when
int discount = discountPolicy.discount(member, 10000);
//then
assertThat(discount).isEqualTo(1000);
}
@Test
@DisplayName("VIP가 아니면 할인이 적용되지 않아야 한다")
void vip_x() {
//given
Member member = new Member(2L, "memberBASIC", Grade.BASIC);
//when
int discount = discountPolicy.discount(member, 10000);
//then
assertThat(discount).isEqualTo(0);
}
}
새로운 할인 정책 적용과 문제점
할인 정책 변경을 위해 클라이언트인 OrderServiceImpl
을 수정한다.
public class OrderServiceImpl implements OrderService{
...
//private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
...
}
인터페이스와 구현 클래스 분리로 다형성 활용 등 객체지향 설계 원칙을 준수한 것으로 보이지만 사실은 아니다. 현재 클라이언트인 OrderServiceImpl
은 인터페이스인 DiscountPolicy
를 의존하면서 구현 클래스인 FixDiscountPolicy
도 의존하고 있었다. 그래서 할인 정책을 변경하기 위해 클라이언트를 수정할 수 밖에 없었다. 이는 DIP(추상화에만 의존)와 OCP(확장에만 열림)를 위반하게 된다. 위반한 원칙을 준수하기 위해 구현 클래스가 아닌 인터페이스에만 의존하도록 코드 수정해보았다.
public class OrderServiceImpl implements OrderService{
...
//private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
private DiscountPolicy discountPolicy;
...
}
클라이언트에서 인터페이스만 있으면 실제 구현 클래스가 없어서 NullPointerException 오류가 발생한다.
관심사의 분리와 AppConfig
애플리케이션을 하나의 연극이라고 했을 때, 인터페이스는 배역이고 구현 클래스는 배우로 비유할 수 있다. 지금까지의 내용은 배우가 상대 배역의 배우까지 초빙하는 느낌이다. 배우는 배역에만 집중해야하고 배우의 초빙은 기획자가 진행하는게 인상적이다. 이와 같이 애플리케이션 동작을 구성하기 위해 AppConfig 클래스를 만든다.
AppConfig
package spring.basic;
import spring.basic.discount.FixDiscountPolicy;
import spring.basic.member.MemberService;
import spring.basic.member.MemberServiceImpl;
import spring.basic.member.MemoryMemberRepository;
import spring.basic.order.OrderService;
import spring.basic.order.OrderServiceImpl;
public class AppConfig {
public MemberService memberService() {
return new MemberServiceImpl(new MemoryMemberRepository());
}
public OrderService orderService() {
return new OrderServiceImpl(new MemoryMemberRepository(), new FixDiscountPolicy());
}
}
AppConfig가 실제 동작에 필요한 MemberServiceImpl, MemoryMemberRepository, OrderServiceImpl, FixDiscountPolicy 객체를 생성하고, 생성한 객체 인스턴스의 레퍼런스를 생성자를 통해 주입한다. 아직 각 클래스에 생성자가 없어 생성자를 작성해준다.
MemberServiceImpl
package spring.basic.member;
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);
}
}
생성자를 작성하면서 위에서처럼 MemberRepository를 구현 클래스가 아닌 인터페이스만 의존하도록 수정한다. MemberServiceImpl 입장에서는 생성자를 통해 어떤 객체가 주입될 지 알 수 없고, 오직 AppConfig를 통해서 결정된다. 이렇게 객체의 생성과 연결을 AppConfig가 담당하면서 DIP를 준수하게 된다.
동일한 방식으로 OrderServiceImpl도 수정하고 AppConfig를 통해 애플리케이션이 실행될 수 있게 메인 메소드가 있는 MemberApp과 OrderApp, 테스트를 위한 MemberServiceTest와 OrderServiceTest를 수정한다.
MemberApp
package spring.basic;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import spring.basic.member.Grade;
import spring.basic.member.Member;
import spring.basic.member.MemberService;
public class MemberApp {
public static void main(String[] args) {
// AppConfig appConfig = new AppConfig();
// MemberService memberService = appConfig.memberService();
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
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());
}
}
OrderApp
package spring.basic;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import spring.basic.member.Grade;
import spring.basic.member.Member;
import spring.basic.member.MemberService;
import spring.basic.order.Order;
import spring.basic.order.OrderService;
public class OrderApp {
public static void main(String[] args) {
// AppConfig appConfig = new AppConfig();
// MemberService memberService = appConfig.memberService();
// OrderService orderService = appConfig.orderService();
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
OrderService orderService = applicationContext.getBean("orderService", OrderService.class);
Long memberId = 1L;
Member member = new Member(memberId, "memberA", Grade.VIP);
memberService.join(member);
Order order = orderService.createOrder(memberId, "itemA", 20000);
System.out.println("order = " + order);
System.out.println("order = " + order.calculatePrice());
}
}
MemberServiceTest
package spring.basic.member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import spring.basic.AppConfig;
public class MemberServiceTest {
MemberService memberService;
@BeforeEach
public void beforeEach() {
AppConfig appConfig = new AppConfig();
memberService = appConfig.memberService();
}
@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);
}
}
OrderServiceTest
package spring.basic.order;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import spring.basic.AppConfig;
import spring.basic.member.Grade;
import spring.basic.member.Member;
import spring.basic.member.MemberService;
import spring.basic.member.MemberServiceImpl;
public class OrderServiceTest {
MemberService memberService;
OrderService orderService;
@BeforeEach
public void beforeEach() {
AppConfig appConfig = new AppConfig();
memberService = appConfig.memberService();
orderService = appConfig.orderService();
}
@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);
}
}
좋은 객체 지향 설계의 5가지 원칙의 적용
지금까지 SRP, DIP, OCP 3가지 원칙을 적용했다.
SRP(Single Responsibility Principle, 단일 책임 원칙)
클래스는 하나의 책임만을 가져야 한다.
- AppConfig는 구현 객체를 생성하고 연결만 함
- 클라이언트 객체는 실행만 함
DIP(Dependency Inversion Principle, 의존관계 역전 원칙)
고수준 모듈은 저수준 모듈에 의존하지 않아야 하며, 둘 다 추상화에 의존해야 한다.
- 처음에 OrderServiceImpl 에서는 인터페이스인 DiscountPolicy와 구현 클래스인 FixDiscountPolicy를 의존
- 이를 인터페이스 DiscountPolicy만 의존하도록 변경
- AppConfig에서 FixDiscountPolicy를 주입하도록 변경
OCP(Open-Closed Principle, 개방-폐쇄 원칙)
소프트웨어 요소(Entity)는 확장에는 열려 있어야 하지만 수정에는 닫혀 있어야 한다.
- DIP 준수를 위해 소스를 수정하면서 FixDiscountPolicy에서 RateDiscountPolicy로 변경을 AppConfig만 수정하면서 가능
- 클라이언트는 수정할 필요가 없음
IoC와 DI
IoC(Inversion of Control, 제어의 역전)란 객체의 생성과 관리 책임을 개발자가 아닌 프레임워크가 담당한다. 전통적인 프로그래밍 방식에서는 개발자가 객체를 직접 생성하고 제어 흐름을 관리했지만, IoC에서는 프레임워크가 이 역할을 대신한다.
DI(Dependency Injection, 의존성 주입)란 IoC를 구현하는 구체적인 방법으로, 객체 간의 의존성을 외부에서 주입받는 디자인 패턴이다. DI를 통해 객체가 직접 다른 객체를 생성하거나 참조하지 않고, 컨테이너가 필요한 의존성을 주입해준다.
스프링으로 전환
AppConfig를 우선 스프링 기반으로 변경한다. @Configuration 어노테이션을 클래스에 붙여주고, @Bean 어노테이션을 각 메소드에 붙여준다.
AppConfig
package spring.basic;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import spring.basic.discount.DiscountPolicy;
import spring.basic.discount.FixDiscountPolicy;
import spring.basic.discount.RateDiscountPolicy;
import spring.basic.member.MemberRepository;
import spring.basic.member.MemberService;
import spring.basic.member.MemberServiceImpl;
import spring.basic.member.MemoryMemberRepository;
import spring.basic.order.OrderService;
import spring.basic.order.OrderServiceImpl;
@Configuration
public class AppConfig {
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
@Bean
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public DiscountPolicy discountPolicy() {
// return new FixDiscountPolicy();
return new RateDiscountPolicy();
}
}
MemberApp과 OrderApp도 스프링 기반으로 변경한다.
OrderApp
package spring.basic;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import spring.basic.member.Grade;
import spring.basic.member.Member;
import spring.basic.member.MemberService;
import spring.basic.order.Order;
import spring.basic.order.OrderService;
public class OrderApp {
public static void main(String[] args) {
// AppConfig appConfig = new AppConfig();
// MemberService memberService = appConfig.memberService();
// OrderService orderService = appConfig.orderService();
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
OrderService orderService = applicationContext.getBean("orderService", OrderService.class);
Long memberId = 1L;
Member member = new Member(memberId, "memberA", Grade.VIP);
memberService.join(member);
Order order = orderService.createOrder(memberId, "itemA", 20000);
System.out.println("order = " + order);
System.out.println("order = " + order.calculatePrice());
}
}
OrderApp 기준으로 봤을 때 기존의 AppConfig를 불러오는 부분과, AppConfig를 통해서 MemberService와 OrderService를 불러오는 부분을 스프링 컨테이너인 ApplicationContext를 통해서 AppConfig를 불러와 사용한다. 스프링 컨테이너는 위에서 @Configuration 어노테이션이 있는 AppConfig 클래스를 설정 정보로 사용하고, @Bean 어노테이션이 있는 메소드를 모두 호출하여 반환된 객체를 스프링 컨테이너에 등록하게 된다. 이를 스프링 빈이라고 한다.
스프링 빈은 기본적으로 @Bean 어노테이션이 붙은 메소드 명을 스프링 빈의 이름으로 사용하는데, 설정을 통해 이름을 변경해서 사용할 수도 있다. 스프링 빈은 applicationContext.getBean() 메소드를 통해 찾아서 사용할 수 있다.
'스터디 > Spring' 카테고리의 다른 글
Spring 스터디2 - 5 (1) | 2025.04.19 |
---|---|
Spring 스터디2 - 4 (1) | 2025.04.17 |
Spring 스터디2 - 2 (0) | 2025.03.30 |
Spring 스터디2 - 1 (0) | 2025.03.23 |
Spring 스터디 - 7 (1) | 2025.03.09 |