[Spring] 객체 지향 원리 적용

soonhankwon·2023년 9월 15일
0

간단한 예시를 가지고 스프링에서 객체 지향 원리를 어떻게 적용하는지 알아보겠습니다. (아래 예시는 부족한점이 많으니 참고해주세요)

개발자 Soon은 프로젝트에서 초기 미팅 후 요구사항에 따라 회원 도메인과 회원 등급에 따른 포인트 적립율 적용을 개발했습니다.

요구사항

  • 회원 등급과 적립율 : Platinum(10%), Gold(7%), Silver(5%), Default(3%)
  • 그리고 소수점은 반올림해주세요

Soon 은 객체지향을 조금 배웠기에 다음과 같이 개발했습니다.

  • User Domain
@Entity
@Getter
@NoArgsConstructor
@Table(name = "`user`")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    @Enumerated(EnumType.STRING)
    private Grade grade;

    private Long point;

    public User(Long id, String email, Grade grade, Long point) {
        this.id = id;
        this.email = email;
        this.grade = grade;
        this.point = point;
    }

    public void order(Long orderPrice, AccumulationPointPolicy pointPolicy) {
        Long earnedPoint = pointPolicy.calculateAccumulationPointByGrade(orderPrice, this.grade);
        this.point += earnedPoint;
    }
}
  • Grade (회원등급)
@Getter
public enum Grade {
    PLATINUM(0.10),
    GOLD(0.07),
    SILVER(0.05),
    DEFAULT(0.03);

    private final double accumulationRate;

    Grade(double accumulationRate) {
        this.accumulationRate = accumulationRate;
    }
}
  • 적립률 정책 인터페이스
public interface AccumulationPointPolicy {
    Long calculateAccumulationPointByGrade(Long orderPrice, Grade grade);
}
  • 평소 적립률 정책 구현체
public class RegularAccumulationPointPolicy implements AccumulationPointPolicy {

    @Override
    public Long calculateAccumulationPointByGrade(Long orderPrice, Grade grade) {
        return Math.round(orderPrice * grade.getAccumulationRate());
    }
}
  • OrderService
public class OrderService {

    private final UserRepository userRepository;
    private final AccumulationPointPolicy pointPolicy = new RegularAccumulationPointPolicy();

    public OrderService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void order(Long userId, Long orderPrice) {
        User user = userRepository.findById(userId).orElseThrow();
        user.order(orderPrice, pointPolicy);
    }
}
  • OrderRepository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}

테스트코드도 성공합니다.

@Test
    void order() {
        AccumulationPointPolicy pointPolicy = new RegularAccumulationPointPolicy();
        User user = new User(1L, "abc@naver.com", Grade.PLATINUM, 10_000L);
        user.order(50_000L, pointPolicy);

        assertThat(user.getPoint()).isEqualTo(15_000L);
    }

대략적으로 Soon은 흐름을 그려보았습니다.

새로운 이벤트 적립률 정책 개발

이번에 특정 기간에 이벤트로 적립률을 변경해달라는 요구사항이 들어왔습니다.

요구사항

  • 각 회원 등급당 5% 씩 추가로 적립률 적용 기간은 2023-09-15 09:00:00 부터 2023-09-22 23:59:00 까지
  • Soon은 다형성 덕분에 새로운 이벤트 적립률을 개발하는데 문제가 없었습니다.
  • 새로운 이벤트 적립률 정책
public class EventAccumulationPolicy implements AccumulationPointPolicy{

    @Override
    public Long calculateAccumulationPointByGrade(Long orderPrice, Grade grade) {
        LocalDateTime now = LocalDateTime.now();
        if(now.isAfter(LocalDateTime.of(2023, Month.SEPTEMBER, 22, 23, 59))) {
            throw new IllegalStateException("not event period");
        }

        return Math.round(orderPrice * (grade.getAccumulationRate() + 0.05));
    }
}

테스트코드도 성공합니다.

@Test
    void orderWithEventPeriod() {
        AccumulationPointPolicy pointPolicy = new EventAccumulationPolicy();
        User user = new User(1L, "abc@naver.com", Grade.PLATINUM, 10_000L);
        user.order(50_000L, pointPolicy);

        assertThat(user.getPoint()).isEqualTo(17_500L);
    }

그림도 다시한번 그려봅니다.

문제점

새로 개발한 이벤트 적립률 정책을 적용하려고 하니 클라이언트 코드인 주문 서비스 구현체도 함께 변경해주어야합니다.

적립률 정책 코드가 수천개 서비스에서 사용하고 있다고 극단적으로 가정해봅니다…. 수천개를 다 바꿔줘야하네요?! 🥲

public class OrderService {

    private final UserRepository userRepository;
//    private final AccumulationPointPolicy pointPolicy = new RegularAccumulationPointPolicy();
    private final AccumulationPointPolicy pointPolicy = new EventAccumulationPolicy();

    public OrderService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void order(Long userId, Long orderPrice) {
        User user = userRepository.findById(userId).orElseThrow();
        user.order(orderPrice, pointPolicy);
    }
}

주문 서비스 클라이언트가 인터페이스인 AccumulationPointPolicy 뿐만 아니라, 구체 클래스인 RegularPointPolicy 도 함께 의존하고 있습니다. → DIP 위반

어떻게해야되지?

관심사의 분리

  • 애플리케이션을 하나의 공연으로 생각해봅시다.
  • 기존에는 클라이언트가 의존하는 서버 구현 객체를 직접 생성하고 실행
    • 남자 주인공 배우가 공연도하고, 동시에 여자 주인공도 직접 초빙하는 다양한 책임을 가지고 있음
    • 공연을 구성하고, 담당 배우를 섭외하고, 지정하는 책임을 담당하는 별도의 공연 기획자가 나올 시점
  • 공연 기획자인 AppConfig가 등장합니다.
    • 전체 동작방식을 구성하기 위해 구현 객체를 생성하고 연결하는 책임을 가지고 있습니다.
    • 의존관계를 조립한다고 생각하면 됩니다.
  • AppConfig 리팩토링
    • 애플리케이션이 크게 사용영역과 객체를 생성하고 구성하는 영역으로 분리해줍니다.
    • 구성 정보에서 역할과 구현을 명확하게 분리합니다.
    • AppConfig 만 수정해준다면 단 한번의 작업으로 적립율 정책을 바꿀수 있습니다.
@Configuration
public class AppConfig {

    private final UserRepository userRepository;

    public AppConfig(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Bean
    public AccumulationPointPolicy accumulationPointPolicy() {
        return new EventAccumulationPolicy();
    }

    @Bean
    public OrderService OrderService() {
        return new OrderService(userRepository, accumulationPointPolicy());
    }
}
  • 최종 리팩토링된 OrderService
public class OrderService {

    private final UserRepository userRepository;
    private final AccumulationPointPolicy pointPolicy;

    public OrderService(UserRepository userRepository, AccumulationPointPolicy pointPolicy) {
        this.userRepository = userRepository;
        this.pointPolicy = pointPolicy;
    }

    @Transactional
    public void order(Long userId, Long orderPrice) {
        User user = userRepository.findById(userId).orElseThrow();
        user.order(orderPrice, pointPolicy);
    }
}

테스트도 성공적으로 통과합니다

@SpringBootTest
class OrderServiceTest {

    @Autowired OrderService orderService;
    @Autowired
    UserRepository userRepository;

    @Test
    void order() {
        User user = new User(1L, "abc@naver.com", Grade.PLATINUM, 10_000L);
        userRepository.save(user);
        orderService.order(1L, 50_000L);

        User res = userRepository.findById(1L).get();
        assertThat(res.getPoint()).isEqualTo(17_500L);
    }
}

Summary : SRP, DIP, OCP 적용

SRP 단일 책임 원칙

  • 한 클래스는 하나의 책임만 가져야 한다.
  • OrderService 에서 사용할 빈을 생성하고 연결하는 책임을 AppConfig 로 분리해줬습니다.

DIP 의존관계 역전 원칙

  • 프로그래머는 추상화에 의존해야지 구체화에 의존하면 안된다. 의존성 주입은 이 원칙을 따르는 방법 중 하나입니다. (구현체는 외부에서 주입!)
    • AppConfig 가 객체 인스턴스를 클라이언트 코드 대신 생성해서 클라이언트 코드에 의존관계를 주입 → DIP 원칙
    • 스프링은 컨테이너를 통해 등록된 빈을 DI 해줘서 이것을 지키도록 하고 있습니다.

OCP

  • 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
    • 다형성 & DIP
    • 애플리케이션을 사용 영역과 구성 영역으로 나눔
  • 소프트웨어 요소를 새롭게 확장해도 사용 영역의 변경은 닫혀 있는것을 볼 수 있습니다.

위의 내용들은 김영한님의 스프링 기초 핵심원리를 듣고 정리 및 개인적으로 학습한 내용들입니다. 물론 애노테이션으로 빈을 등록해서 사용하면 AppConfig 는 필요없지만 스프링이 기본적으로 뒤에서 수행하고 있는 것들을 이해하는데 도움이 될 것입니다.

profile
ProblemOverFlow

0개의 댓글