포스트

스프링 핵심 원리 (2) - 관심사의 분리

스프링 핵심 원리 (2) - 관심사의 분리

1. 개요

이전 글에서 좋은 객체 지향 설계의 핵심은 다형성이고, 이를 실제로 잘 활용하기 위한 지침이 SOLID 원칙이라는 것을 정리했다. 이번 글에서는 애플리케이션에 새로운 할인 정책을 추가해보면서 다형성만으로는 OCP와 DIP를 지킬 수 없다는 것을 직접 확인하고, 이 문제를 해결하는 관심사의 분리AppConfig라는 개념을 통해 스프링 DI 컨테이너가 왜 필요한지 이해해본다.


2. 새로운 할인 정책 추가

기존에는 VIP 회원에게 항상 1000원을 고정으로 할인해주는 FixDiscountPolicy만 존재했다. 여기에 주문 금액의 %만큼 할인해주는 RateDiscountPolicy를 새로 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RateDiscountPolicy implements DiscountPolicy {

    private int discountPercent = 10; //10% 할인

    @Override
    public int discount(Member member, int price) {
        if (member.getGrade() == Grade.VIP) {
            return price * discountPercent / 100;
        } else {
            return 0;
        }
    }
}

DiscountPolicy 인터페이스를 구현하는 클래스를 하나 추가하는 것 자체는 다형성 덕분에 아무 문제 없이 가능하다. 문제는 이 정책을 실제로 “적용”하는 순간부터 시작된다.


3. 새로운 정책 적용과 문제점

정책을 바꾸려면 클라이언트인 OrderServiceImpl의 코드를 직접 고쳐야 한다.

1
2
3
4
public class OrderServiceImpl implements OrderService {
    // private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
    private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
}

겉으로는 OrderServiceImplDiscountPolicy 인터페이스에만 의존하는 것처럼 보이지만, 실제 클래스 의존관계를 분석해보면 그렇지 않다.

  • 추상(인터페이스) 의존: DiscountPolicy
  • 구체(구현) 클래스 의존: FixDiscountPolicy, RateDiscountPolicy

OrderServiceImpl은 인터페이스뿐 아니라 구현 클래스에도 동시에 의존하고 있다. 이 상태에서 할인 정책을 확장(추가)했을 뿐인데도 기존 클라이언트 코드까지 변경해야 하므로 다음 두 원칙이 깨진다.

  • DIP 위반: 추상화뿐 아니라 구체 클래스에도 의존한다.
  • OCP 위반: 기능을 확장했는데 기존 코드(클라이언트)를 변경(수정)해야 한다.

이 문제를 해결하려면 클라이언트가 인터페이스에만 의존하도록 바꿔야 한다.

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

하지만 이렇게 인터페이스에만 의존하도록 바꾸면 구현 객체가 없어 실행 시 NPE(NullPointerException)가 발생한다. 결국 누군가가 클라이언트 대신 구현 객체를 생성하고 주입해주어야 한다.


4. 관심사의 분리

애플리케이션을 하나의 공연이라고 생각해보자. 각 인터페이스는 배역이고, 그 배역을 실제로 연기하는 구현체는 배우다. 그런데 지금까지의 코드는 로미오 역할(인터페이스)을 맡은 배우가 상대역인 줄리엣 역할(구현체)까지 직접 초빙하는 것과 같다. 배우는 자신의 배역을 연기하는 데만 집중해야 하고, 공연을 기획하고 배우를 섭외하는 일은 별도의 공연 기획자가 맡아야 한다.

이 공연 기획자 역할을 하는 것이 바로 AppConfig다. 애플리케이션의 전체 동작 방식을 구성(config)하기 위해, 구현 객체를 생성하고 연결하는 책임을 별도의 클래스로 분리한다.

1
2
3
4
5
6
7
8
9
10
11
12
public class AppConfig {

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

    public OrderService orderService() {
        return new OrderServiceImpl(
                new MemoryMemberRepository(),
                new FixDiscountPolicy());
    }
}

MemberServiceImplOrderServiceImpl은 이제 생성자를 통해 필요한 구현체를 외부에서 전달받는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }

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

이제 MemberServiceImplOrderServiceImpl은 어떤 구현 객체가 주입될지 스스로 알 수 없다. 어떤 구현체를 사용할지는 오직 외부(AppConfig)에서 결정한다. 클라이언트인 memberServiceImpl 입장에서 보면 의존관계를 외부에서 대신 주입해주는 셈인데, 이를 DI(Dependency Injection, 의존관계 주입) 라 한다.


5. AppConfig 리팩터링

처음 작성한 AppConfig에는 new MemoryMemberRepository()가 여러 번 중복되고, 역할과 구현이 한눈에 드러나지 않는다. 각 구현체 생성을 메서드로 분리해서 중복을 제거하고 역할을 명확히 드러낼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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만 보면 애플리케이션이 어떤 역할에 어떤 구현체를 사용하는지 한눈에 파악할 수 있다.


6. 사용 영역과 구성 영역의 분리

AppConfig의 등장으로 애플리케이션은 크게 사용 영역(구현 객체를 실행하는 클라이언트 코드)과 구성 영역(객체를 생성하고 연결하는 AppConfig)으로 나뉜다. 이제 할인 정책을 FixDiscountPolicy에서 RateDiscountPolicy로 바꾸려면 구성 영역인 AppConfig의 코드 한 줄만 바꾸면 된다.

1
2
3
4
5
6
7
8
9
public class AppConfig {

    // 생략...

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

클라이언트 코드인 OrderServiceImpl을 포함해서 사용 영역의 어떤 코드도 변경할 필요가 없다. 소프트웨어 요소를 새롭게 확장해도 사용 영역은 변경에 닫혀 있는 것이다.


7. 좋은 객체 지향 설계 원칙의 적용

지금까지의 리팩터링 과정을 SOLID 원칙 관점에서 정리하면 다음과 같다.

원칙적용 내용
SRP구현 객체를 생성·연결하는 책임은 AppConfig가, 클라이언트는 실행하는 책임만 담당하도록 분리했다
DIP클라이언트는 인터페이스에만 의존하고, AppConfig가 구현 객체를 대신 생성해서 주입해준다
OCPAppConfig가 의존관계를 FixDiscountPolicyRateDiscountPolicy로 바꿔 주입하므로, 클라이언트 코드는 변경하지 않아도 기능이 확장된다

다형성만으로는 부족했던 OCP와 DIP를, 객체를 생성·연결하는 책임을 별도로 분리함으로써 비로소 지킬 수 있게 되었다.


8. 정리

새로운 할인 정책을 추가하고 적용하는 과정에서, 다형성만으로는 OCP와 DIP를 완전히 지킬 수 없다는 것을 확인했다. OrderServiceImplDiscountPolicy 인터페이스뿐 아니라 FixDiscountPolicy 구현 클래스에도 동시에 의존하고 있었기 때문에, 할인 정책을 확장할 때마다 클라이언트 코드까지 함께 고쳐야 했다.

이 문제는 클라이언트를 인터페이스에만 의존하도록 바꾸는 것만으로는 해결되지 않았다. 대신 객체를 생성하고 연결하는 책임을 AppConfig라는 별도의 구성 영역으로 분리하고, 클라이언트에는 생성자를 통해 필요한 구현체를 주입해주는 방식으로 해결했다. 그 결과 애플리케이션은 실행에만 집중하는 사용 영역과, 구현체를 선택하고 조립하는 구성 영역으로 명확히 나뉘었고, 할인 정책을 변경할 때도 AppConfig의 코드 한 줄만 고치면 되었다.

정리하면, 구현 객체를 생성·연결하는 책임과 실행하는 책임을 분리한 것이 SRP를, 클라이언트가 인터페이스에만 의존하게 된 것이 DIP를, 클라이언트 코드의 변경 없이 기능을 확장할 수 있게 된 것이 OCP를 만족시킨다. 다형성만으로는 부족했던 부분을 AppConfig라는 조립 책임의 분리로 채운 것이 이번 리팩터링의 핵심이다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.