스프링 프레임워크가 필요한 이유

    앞의 글에서 이어서 작성하겠다. 앞의 글은 순수하게 자바 언어만 사용해 객체 지향 프로그래밍을 구현했다. 구현했을 때, 문제가 된 내용은 아래 코드에서 바로 확인이 가능하다. 구현체가 어떤 객체를 사용할지 선택하고 있다는 것이다. 구현체는 추상화에만 충실해야한다. 예를 들어 배우는 대본에만 충실해야하는데, 대본도 외우고 상대 배우도 고르고 있는 상황인 것이다. 

    이런 문제를 해결하기 위해 프레임워크가 필요해진다. 공연 기획자와 유사한 역할을 하는 것을 프레임워크라고 생각하고, 이를 구성하는 방식으로 아래의 SOLID 원칙에서 벗어난 것들을 개선할 수 있다. 

    public class OrderServiceImpl implements OrderService{
        DiscountPolicy discountPolicy = new FixDiscountPolicy();
        MemberRepository memberRepository = new MemoryMemberRepository();
    
        @Override
        public Order createOrder(Long memberId, String itemName, int itemPrice) {
            // 멤버이름으로 검색해서, 멤버를 가져온다
            Member member = memberRepository.findById(memberId);
            // 멤버를 넘겨주고, 할인된 디스카운트 금액을 가져온다.
            int discountPriceAmount = discountPolicy.discount(member, itemName, itemPrice);
            // 디스카운트 금액을 Order에 반영해서 넘겨준다.
            return new Order(memberId, itemName, itemPrice, discountPriceAmount);
        }
    }

     

    관심사의 분리 (객체 생성 + 연결 역할 // 구현의 분리), 생성자 주입, 의존관계 주입(DI)

    앞서 말한 것처럼 구현체는 너무 많은 방면으로 관심을 가지고 있다. 배우 역할도 하고 싶고, 기획자 역할도 하고 싶은 것이다. 이것 때문에 발생한 문제이기 때문에 이걸 해결해주면 자연스레 해결된다. 구현체는 구현에만 충실하도록 해준다.

    앞서 말한 것처럼 공연 기획자를 하나 만들어주고, 그 공연 기획자가 각 구현체에서 어떤 객체를 사용할지 선택하게 해줄 수 있다. 아래와 같은 AppConfig 클래스를 하나 추가한다. 

    public class AppConfig {
        public MemberRepository MemberRepository(){
            return new MemoryMemberRepository();
        }
        public DiscountPolicy DiscountPolicy(){
            return new FixDiscountPolicy();
        }
    }

    AppConfig 클래스는 각 인터페이스의 생성자를 관리하는 클래스다. 인터페이스를 생성했을 때, 어떤 구현체가 들어갈지를 결정해준다. 이를 '생성자 주입'이라고 한다. AppConfig를 생성자 주입용으로 사용하게 되면, 앞서 문제가 되었던 SOLID 규칙을 위반한 코드를 아래와 같이 규칙을 만족하도록 개선이 가능하다. 

    public class OrderServiceImpl implements OrderService{
    
    // 기존 코드
    //    DiscountPolicy discountPolicy = new FixDiscountPolicy();
    //    MemberRepository memberRepository = new MemoryMemberRepository();
    
    //appConfig 인스턴스를 활용한 객체지향
        AppConfig appConfig = new AppConfig();
        DiscountPolicy discountPolicy = appConfig.DiscountPolicy();
        MemberRepository memberRepository = appConfig.MemberRepository();
    }

    위 코드를 보면 더 이상 구현체는 추상화에만 의존하게 된다. 어떤 구현체를 사용할지 구현체는 더 이상 고민하지 않는다. 어떤 구현체를 사용할지는 외부에서 생성자 주입을 통해 들어오게 되는 녀석으로 쓴다. 당하는 구현체 입장에서는 의존관계가 주입되는 것으로 볼 수 있다. 이 개념을 DI(Dependency Injection, 의존관계 주입)이라고 한다.

     

    AppConfig의 리팩토링

    AppConfig는 역할과 구현에 충실할 수 있도록 리팩토링이 되어야 한다. 그래야 코드의 가독성이 좋아지기 때문이다. 아래 코드블럭처럼 AppConfig를 리팩토링 할 수 있다. 아래 코드블럭은 각 생성자를 만드는 것을 메서드화 해서, 생성자 주입을 좀 더 명확히 볼 수 있도록 했다. 추후, 정책의 변경이 있다고 하면 discountPolicy 생성자 내용만 수정해주면 대응이 가능해진다.

    public class AppConfig {
        public MemberRepository memberRepository(){
            return new MemoryMemberRepository();
        }
        public DiscountPolicy discountPolicy(){
            return new FixDiscountPolicy();
        }
        public OrderService orderService(){
            return new OrderServiceImpl(discountPolicy(), memberRepository());
        }
        public MemberService memberService(){
            return new MemberServiceImpl(memberRepository());
        }
    }

    위의 상황을 클래스 다이어그램으로 그려보면 아래와 같다. 사용 영역과 구성 영역이 철저히 분리된 것으로 이해할 수 있다. 사용 영역의 코드는 최대한 건드리지 않고, 변경점이 있을 경우 구성 영역에만 변경을 주어 코드를 처리할 수 있다. 

     

     

     

     


    AppConfig의 Test영역에서의 적용

    AppConfig는 Test 영역에서 따로 만들 수는 없다고 한다. 따라서 BeforeEach 기능을 써서 각 Test 기능을 구현해주어야 한다. BeforeEach는 각 테스트가 실행하기 전에 반드시 실행되는 메서드이다. Before Each에 AppConfig가 생성되도록 해준 후에 각 테스트를 진행하게 한다. 다음과 같은 순서로 테스트를 구현할 수 있다.

    멤버변수를 선언만 해둔다.

    BeforeEach에서 AppConfig를 생성한 후, appConfig 인스턴스를 활용해 각 멤버변수에 의존관계 주입을 한다.

    테스트 한다

    public class OrderServiceTest {
        MemberService memberService;
        OrderService orderService;
    
        @BeforeEach
        void BeforeEach(){
            AppConfig appConfig = new AppConfig();
            memberService = appConfig.memberService();
            orderService = appConfig.orderService();
        }
    
        @Test
        void createOrderTEst(){
            Long memberId = 1L;
            int itemPrice = 10000;
            Member member = new Member(memberId, "memberA", Grade.VIP);
    
            memberService.save(memberId, member);
            Order order = orderService.createOrder(memberId, "itemA", itemPrice);
            assertThat(order.calculateDiscount()).isEqualTo(9000);
    
        }
    }

     

    새로운 클래스 추가 + AppConfig의 변경을 통한 간단한 적용

    1. 정률 할인 클래스를 추가한다. VIP인 경우 구매금액의 10%를 할인해준다.
    2. 정률 할인 클래스를 적용하도록 AllConfig를 셋팅한다.
    // 정률 할인 클래스 구현
    package myname.core2.discount;
    import myname.core2.member.Grade;
    import myname.core2.member.Member;
    public class RateDiscountPolicy implements DiscountPolicy{
        private int discountRate = 10;
        @Override
        public int discount(Member member, String itemName, int itemPrice) {
            if (member.getGrade() == Grade.VIP){
                return (int) (itemPrice * discountRate / 100);
            }else{
                return 0;
            }
        }
    }
    
    // AppConfig 생성자 변경
    public class AppConfig {
        public MemberRepository memberRepository(){
            return new MemoryMemberRepository();
        }
        public DiscountPolicy discountPolicy(){
    //정률 할인으로 생성자 변경해둠.
    		  return new RateDiscountPolicy();
    //        return new FixDiscountPolicy();
        }
        public OrderService orderService(){
            return new OrderServiceImpl(discountPolicy(), memberRepository());
        }
        public MemberService memberService(){
            return new MemberServiceImpl(memberRepository());
        }
    }

     

    제어의 역전 IoC(Inversion Of Control)

    처음 객체 지향으로 짰던 코드를 살펴보면 구현 객체가 필요한 서버 객체를 생성하고, 연결하고, 실행했다. 개발자 입장에서 이것은 아주 자연스러운 흐름이다. 코드를 짜다보니 이게 필요하고, 이게 필요하니 만들고, 연결하고, 실행하는 것이기 때문이다. 그렇지만 아래처럼 기준정보설정(AppConfig)가 등장하면서 그 개념은 바뀌게 되었다.

    프로그램의 흐름 제어는 개발자가 아닌 AppConfig가 하게 되었다. 이처럼 개발자가 직접하는 것이 아닌, 외부에서 관리하는 것을 제어의 역전이라고 한다. 

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

     

    DI 컨테이너 ( = IOC 컨테이너)

    먼저 DI는 앞서 이야기한 것처럼 의존관계 주입(Dependency Injection)이다. 의존관계 주입은 동적 객체 의존관계에서 주로 발생한다. 의존관계는 두 가지가 있다. 정적인 클래스 의존 관계와 동적인 객체 의존 관계다. 정적인 클래스 의존관계는 Import에 어떤 것들이 되어있는지만을 봐도 알 수 있다. 코드가 실제로 실행되기 전에 각 클래스가 어떤 관계를 가지고 있는지를 볼 수 있다.

    동적인 객체 의존 관계는 이를 테면 멤버변수와 참조값이 연결되는 것이다. 프로그램 시작 전까지는 단순히 참조변수만 선언이 되어있다. 그리고 프로그램이 실행되면 외부 설정에 의해 참조변수에 어떤 참조값이 들어오게 된다. 프로그램이 실행되기 전까지는 정확히 어떤 관계를 가지는지 알 수 없다. 

    정적인 클래스 의존 관계와 동적인 객체 의존 관계에 최소한의 변화를 주면서 원하는 목적을 달성하는 방법은 무엇일까? 그것은 바로 의존관계 주입이다. 정적인 클래스 의존관계에서는 코드의 변경이 필요없고, 변경점이 있는 부분에는 의존관계 주입을 통해서 클라이언트의 코드를 바꾸지 않고 클라이언트가 사용하는 인스턴스 객체를 바꿀 수 있다. 즉, 클래스 다이어그램이 전혀 변하지 않는다.

    AppConfig처럼 객체를 생성해서 주입해주는 것을 IoC 컨테이너, 혹은 DI 컨테이너라고 부른다. 

     

    스프링을 활용한 DI 컨테이너 활용

    앞서 SRP, DIP, OCP를 지키기 위해 설정 정보를 만들고 의존관계 주입(DI)를 구현했다. 이것은 사용자가 직접 의존관계를 주입해주는 것이고, 이미 만들어진 설정 정보를 Spring을 활용해서 사용할 수 있다. 굳이 Spring을 활용하는 이유는 Spring이 설정정보들을 싱글톤 패턴 형식으로 관리해주기 때문입니다.

    기존에 사용하던 DI 컨테이너를 Spring 컨테이너 내에 넣는 방법은 다음과 같다.

    설정 정보 Class에 @Configuration 어노테이션을 달아준다.

    의존관계 주입할 메서드들에 @Bean 어노테이션을 달아준다.

    그렇다면 아래처럼 코드를 변경할 수 있다. 스프링 부트는 시작할 때, @Configuration이라고 붙은 클래스에서 @Bean이라는 메서드들을 모두 읽어와 Key, Value 형식으로 스프링 빈 컨테이너에 저장해준다.

     

     

    @Configuration
    public class AppConfig {
        @Bean
        public MemberRepository memberRepository(){
            return new MemoryMemberRepository();
        }
        @Bean
        public DiscountPolicy discountPolicy(){
            return new RateDiscountPolicy();
    //        return new FixDiscountPolicy();
        }
        @Bean
        public OrderService orderService(){
            return new OrderServiceImpl(discountPolicy(), memberRepository());
        }
        @Bean
        public MemberService memberService(){
            return new MemberServiceImpl(memberRepository());
        }
    }

    설정 정보를 바꿔줬으면, 실제 Application 부분에서도 코드의 변경이 필요하다. 변경해야 할 부분은 기존에는 직접 설정 정보를 불러왔었다면, 이번에는 설정정보를 담은 빈 객체를 하나 만들고, 그 빈 객체에서 빈 값들을 불러와야한다. 아래 코드를 지우고, 추가 하면 된다.

            //스프링 컨테이너를 활용한 방법
            ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
            MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);
    
            //직접 DI 컨테이너를 만들어 활용한 방법
            AppConfig appConfig = new AppConfig();
            MemberRepository memberRepository = appConfig.memberRepository();
            
            // OCP, DIP 위반
            MemberRepository memberRepository = new MemoryMemberRepository();

    위 코드는 AppConfig의 설정을 담은 Annotation 기반의 스프링 빈 컨테이너 객체를 하나 생성하고, 그 컨테이너에 있는 "MemberRepository"라는 빈 객체를 불러오고, 그걸 memberRepository에서 참조하게 하는 것이다. 

    위의 코드로 변경 후, 실행을 하게 되면 실행 결과에 아래와 같이 싱글톤 빈으로 appConfig와 appConfig 안에 있던 메서드들이 생성되는 것을 확인할 수 있다. 

    근데 아래 코드를 살펴보면 의아한 생각이 들 것이다. 스프링을 쓰면 좋다고 해서, 스프링을 사용했는데 오히려 코드가 더 길어지고 쳐야할 게 많아진 것처럼 보인다. 이러면 스프링 쓰는 이점이 없지 않을까? 라는 생각이 들것이다. 그렇다면 왜 스프링을 쓰면 장점이 있다고 하는 것일까?

            //스프링 컨테이너를 활용한 방법
            ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
            MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);
    
            //직접 DI 컨테이너를 만들어 활용한 방법
            AppConfig appConfig = new AppConfig();
            MemberRepository memberRepository = appConfig.memberRepository();

     

     

     

     

     

     

     

     

    댓글

    Designed by JB FACTORY