6 분 소요



이번 포스팅에서 새로운 할인 정책을 적용 해보고, IOC, DI를 못 지키는 문제점을 해결해보자.



1. 새로운 할인 정책 개발

  • 기존의 고정 금액 할인 정책(FixDiscountPolicy) 대신 정률 할인 정책(RateDiscountPolicy) 로 변경하고자 하면 어떻게 할까?
  • VIP 회원에게 주문 금액의 10%를 할인해주는 정책으로 변경해보자.

RateDiscountPolicy 추가

간단하다. RateDiscountPolicy 만 추가해주면 된다. 이것이 객체 지향의 힘


RateDiscountPolicy 코드

기존에 생성한 discount 패키지에 RateDiscountPolicy 클래스를 추가해주자.

package hello.core.discount;

import hello.core.member.Grade;
import hello.core.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;
        }
    }
}

RateDiscountPolicy가 정말 10% 할인이 적용되는지 확인하기 위해 테스트 코드를 생성해서 확인해보자.

  • 테스트 단축키
    • window: Ctrl + Shift + T
    • Mac: Command + Shift + T

package hello.core.discount;

import hello.core.member.Grade;
import hello.core.member.Member;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

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);
    }
}

정상적으로 테스트가 성공한 것을 확인할 수 있다.



2. 새로운 할인 정책 적용과 문제점

방금 추가한 할인 정책을 애플리케이션에 적용해보자.

할인 정책을 변경하려면 클라이언트인 OrderServiceImpl 코드를 수정해야 한다.

기존의 OrderServiceImpl은 FixDiscountPolicy에 직접 의존하고 있었기 때문에, RateDiscountPolicy로 변경하려면 코드를 수정해야 했다.

public class OrderServiceImpl implements OrderService{

   // private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
   private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
}


문제점

① DIP(Dependency Inversion Principle) 위반

클라이언트인 OrderServiceImpl이 인터페이스(DiscountPolicy)뿐만 아니라 구체 클래스(FixDiscountPolicy, RateDiscountPolicy)에도 의존하고 있다.


② OCP(Open-Closed Principle) 위반

클라이언트 코드인 OrderServiceImpl은 DiscountPolicy의 인터페이스 뿐만 아니라 구체 클래스도 함께 의존하기 때문에, 구체 클래스를 변경할 때(할인 정책 변경) 클라이언트 코드(OrderServiceImpl)도 함께 변경해야 한다.


해결 방안

DIP를 위반하지 않도록 인터페이스에만 의존(추상에만 의존)하도록 의존관계를 변경하면 된다.

 public class OrderServiceImpl implements OrderService {
    //private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
    private DiscountPolicy discountPolicy;
 }

인터페이스에만 의존하도록 설계와 코드를 변경했다.

그런데 구현체가 없는데 어떻게 코드를 실행할 수 있을까?

실제 실행을 해보면 NPE(null pointer exception)가 발생한다.

이 문제를 해결하려면 누군가가 클라이언트인 OrderServiceImpl에 DiscountPolicy의 구현 객체를 대신 생성하고 주입해주어야 한다.



3. 관심사의 분리⭐

객체의 생성과 사용을 분리하여 각 객체가 자신의 역할에만 집중하도록 한다.

  • 이전 코드는 로미오 역할(인터페이스)을 하는 레오나르도 디카프리오(구현체, 배우)가 줄리엣 역할(인터페이스)을 하는 여자 주인공(구현체, 배우)을 직접 초빙하는 것과 같다.
  • 따라서 관심사를 분리하여 배우(클래스)는 연기에만 집중하고, 배우 섭외는 공연 기획자(AppConfig)가 담당하게 해야한다.


3.1 AppConfig 도입

애플리케이션의 전체 동작 방식을 구성(config)하기 위해, 구현 객체를 생성하고, 연결하는 책임을 가지는 별도의 설정 클래스를 만들자.

기존엔 MemberServiceImple이 직접 의존 관계 주입했다.


AppConfig를 사용하기 위해 위 코드를 아래와 같이 변경하자.

  1. MemoryMemberRepository 삭제
  2. 생성자 만들기 (단축키: Alt + Insert)
package hello.core.member;

public class MemberServiceImpl implements MemberService{

    // 1. MemoryMemberRepository 삭제
    private final MemberRepository memberRepository;

    // 2. 생성자 만들기
    public MemberServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }

    @Override
    public void join(Member member) {
        memberRepository.save(member);
    }

    @Override
    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }
}


그 후, AppConfig에서 MemoryMemberRepository 구현 객체를 생성하자.

package hello.core;

import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;

public class AppConfig {
    public MemberService memberService(){
        return new MemberServiceImpl(new MemoryMemberRepository());
    }
}


동작 흐름은 아래와 같다.


OrderService도 마찬가지다.

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
  • AppConfig는 생성한 객체 인스턴스의 참조(레퍼런스)를 생성자를 통해서 주입(연결)해준다
    • MemberServiceImpl -> MemberServiceImpl
    • OrderServiceImpl -> MemoryMemberRepository, FixDiscountPolicy


MemberServiceImpl - 생성자 주입

package hello.core.member;

public class MemberServiceImpl implements MemberService{

    private final MemberRepository memberRepository;

    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public void join(Member member) {
        memberRepository.save(member);
    }

    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }
}
  • 설계 변경으로 MemberServiceImpl은 MemoryMemberRepository을 의존하지 않는다.
  • 단지 MemberRepository 인터페이스만 의존한다.
  • MemberServiceImpl 입장에서 생성자를 통해 어떤 구현 객체가 들어올지(주입될지)는 알 수 없다.
  • MemberServiceImpl 의 생성자를 통해서 어떤 구현 객체를 주입할지는 오직 외부(AppConfig)에서 결정된다.
  • MemberServiceImpl은 이제부터 의존관계에 대한 고민은 외부에 맡기고 실행에만 집중하면 된다.


클래스 다이어그램

  • 객체의 생성과 연결은 AppConfig가 담당한다.
  • DIP 준수: MemberServiceImpl은 MemberRepository인 추상에만 의존하면 된다. 이제 구체 클래스를 몰라도 된다.
  • OCP 준수: 할인 정책을 변경해도 클라이언트 코드(OrderServiceImpl)를 수정할 필요가 없다.
  • 관심사의 분리: 객체를 생성하고 연결하는 역할과 실행하는 역할이 명확히 분리되었다.


회원 객체 인스턴스 다이어그램

  • appConfig 객체는 memoryMemberRepository 객체를 생성하고 그 참조값을 memberServiceImpl을 생성하면서 생성자로 전달한다.
  • 클라이언트인 memberServiceImpl 입장에서 보면 의존관계를 마치 외부에서 주입해주는 것 같다고 해서 DI(Dependency Injection) 우리말로 의존관계 주입 또는 의존성 주입이라 한다.


3.2 AppConfig 실행

사용 클래스 - MemberApp

기존엔 MemberServiceImpl을 메인 메서드에서 직접 생성을 해줬지만, 이제는 appConfig를 사용하여 생성을 하면 된다.

package hello.core;

import hello.core.member.*;

public class MemberApp {
    public static void main(String[] args) {
        AppConfig appConfig = new AppConfig();

        MemberService memberService = appConfig.memberService();

        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 hello.core;

import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.order.Order;
import hello.core.order.OrderService;

public class OrderApp {
    public static void main(String[] args) {
        AppConfig appConfig = new AppConfig();
        MemberService memberService = appConfig.memberService();
        OrderService orderService = appConfig.orderService();

        long memberId = 1L;
        Member member = new Member(memberId, "memberA", Grade.VIP);
        memberService.join(member);
        Order order = orderService.createOrder(memberId, "itemA", 10000);

        System.out.println("order = " + order);
    }
}


테스트 코드 오류 수정

public class MemberServiceTest {

    MemberService memberService;


    @BeforeEach
    public void beforeEach() {
        AppConfig appConfig = new AppConfig();
        memberService = appConfig.memberService();
    }
}

public class OrderServiceTest {
    MemberService memberService;
    OrderService orderService;

    @BeforeEach
    public void beforeEach() {
        AppConfig appConfig = new AppConfig();
        memberService = appConfig.memberService();
        orderService = appConfig.orderService();
    }
}



4. AppConfig 리팩터링

기존의 AppConfig 코드에는 중복이 존재하고, 역할에 따른 구현이 명확히 드러나지 않는 문제가 있다。

package hello.core;
import hello.core.discount.FixDiscountPolicy;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;

public class AppConfig {
    public MemberService memberService() {
        return new MemberServiceImpl(new MemoryMemberRepository());
    }

    public OrderService orderService() {
        return new OrderServiceImpl(
            new MemoryMemberRepository(),
            new FixDiscountPolicy()
        );
    }
}
  • 중복 문제: new MemoryMemberRepository()가 반복되고 있다.
  • 확장성 문제: 할인 정책 변경 시 여러 곳을 수정해야 한다.


리팩터링 후 코드

MemoryMemberRepository 인스턴스를 별도의 메서드로 추출하여 중복을 제거하여 리팩토링 한다.

package hello.core;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.member.MemberRepository;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;

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)만 수정하면 된다.



5. 새로운 구조와 할인 정책 적용

할인 정책 변경 시 영향

기존의 FixDiscountPolicy에서 RateDiscountPolicy로 변경하는 경우

변경 전 코드

public DiscountPolicy discountPolicy() {
    return new FixDiscountPolicy();
}


변경 후 코드

public DiscountPolicy discountPolicy() {
    return new FixDiscountPolicy();
}

이렇게 변경해도 구성 영역만 영향을 받으며, 사용 영역(e.g., OrderServiceImpl)에는 전혀 변경이 필요 없다.


구성과 사용의 분리

  • 구성 영역(AppConfig): 객체 생성 및 연결 담당
  • 사용 영역(OrderServiceImpl 등): 실제 비즈니스 로직 담당

구성과 사용을 분리함으로써 할인 정책이나 저장소 등의 구현체를 변경할 때 사용 영역의 코드를 건드리지 않고도 변경할 수 있다.


결론

  • AppConfig를 공연의 기획자에 비유하면, 기획자는 각 역할(인터페이스)에 어떤 배우(구현 클래스)가 참여할지 미리 설정하고 관리하는 역할을 담당한다.
  • 이 구조 덕분에, 배우(구현체)를 바꾸더라도 공연(비즈니스 로직)은 그대로 진행될 수 있다.

리팩터링을 통해 확장성, 유지보수성이 높아지고, 변경에 유연하게 대응할 수 있는 구조가 완성된다.


카테고리:

업데이트:

댓글남기기