Spring/SpringCore - basic

스프링 핵심 원리 - 예제를 통해 이해하는 스프링의 핵심 원리

_Jin_ 2024. 11. 7.

 

Repeat is the best medicine for memory

스프링 기술에 대한 이야기를 하면 항상 다루는 것이 객체 지향적 코드를 지원해준다는 점이다. 

결론적으로 말하자면, 스프링은 다형성과 OCP, DIP를 쉽게 사용할 수 있도록 도와준다.

 

이를 DI 컨테이너를 제공하여 가능하도록 만들었는데, 이는 의존성 주입으로 클라이언트 측의 코드 변경 없이도 기능의 사용이 가능하도록 지원해주는 방법이다.

따라서 쉽게 부품을 교체하듯이 필요한 기능의 구현체에 직접적으로 의존하지 않으면서 개발이 가능하도록 스프링은 지원해준다. 

 

객체 지향의 원칙을 지키면서 개발을 진행하다보면, 결국 스프링과 같은 프레임워크를 만들게 된다.

그러나, 이렇게 말하면 무슨 의미인지 와닿지 않기에 직접 코드를 통해서 파악하며 스프링이 하는 역할에 대해 이해해보자.

다만, 앞으로 진행할 내용에 대해서 인지하면 좋은 점은 모든 코드로 설계를 꾸려나감에 역할과 구현을 분리한다는 점을 잊지말자.

( 인터페이스 - 구현체 관계 )

 

예를 들어, 애플리케이션을 만들어나가는 것을 공연의 설계에 비유한다면, 배역은 정해졌지만 배우(구현체)는 언제든지 유연하게 변경할 수 있도록 만드는 것이 객체 지향 설계의 중요한 일부분이며, 이런 배역의 책임을 맡는 자바의 가장 흔한 사용은 인터페이스이다. 

 

💣 주의할 점
인터페이스 도입에는 추상화라는 비용이 발생한다. 기능의 확장을 고려하여, 인터페이스를 미리 만드는 것은 좋은 객체지향적 설계로 바람직하지만, 분명 개발자가 구체적 기능을 변경함에 있어 인터페이스 코드는 직관적으로 구현체를 보여주지 않기 때문에 구현체를 변경하는 부분을 찾고 이를 파악하여 변경해야하는 수고로움을 초래할 수 있다.

따라서 확장 가능성이 높은 부분에 인터페이스를 도입하는 설계가 좋다.

 

순수한 자바로 객체 지향적 비즈니스 코드 만들기 

 

먼저 다음과 같은 비즈니스 요구가 있으며 이에 따른 설계를 보자.

 

 

 

요구사항을 보면 회원 데이터, 할인 정책과 같은 부분은 지금 결정하기 어려운 부분들이 있다. 그렇다고 이런 정책들이 결정될 때까지 개발을 무기한 기다릴 수 없다. 우리에게는 객체지향적 설계 방법이 있기 때문이다.

( 인터페이스를 만들고 구현체를 바꾸는 추상화와 다형성을 이용하겠다. )


회원 도메인 설계

 

 

위의 설계에 따라 코드를 만들어보자.

 

먼저 회원 Entity

 

< 회원 등급 >

package hello.core.member;

public enum Grade{
	BASIC,
	VIP
}

 

 

<회원 엔티티>

public class Member {
    private Long id;
    private String name;
    private Grade grade;
    public Member(Long id, String name, Grade grade) {
        this.id = id;
        this.name = name;
        this.grade = grade;
    }
    public Long getId() {
        return id;
    }
    public void setId(Long id) {
        this.id = id;
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public Grade getGrade() {
        return grade;
    }
    public void setGrade(Grade grade) {
        this.grade = grade;
    }
}

 

 

회원 저장소 

 

<회원 저장소 인터페이스>

public interface MemberRepository {
    void save(Member member);
    Member findById(Long memberId);
}

 

<메모리 회원 저장소 구현체>

package hello.core.member;
import java.util.HashMap;
import java.util.Map;

public class MemoryMemberRepository implements MemberRepository {
    private static Map<Long, Member> store = new HashMap<>();
    @Override
    public void save(Member member) {
        store.put(member.getId(), member);
    }
    @Override
    public Member findById(Long memberId) {
        return store.get(memberId);
    }
}

 

데이터 베이스가 아직 미확정이고, 일단 개발의 진행은 필요하니, 단순하게 메모리 회원 저장소를 구현하여 진행한다. 

이어서 회원 인터페이스를 설계하고 구현체를 만들어 보겠다.

 

<회원 서비스 인터페이스>

package hello.core.member;

public interface MemberService {
    void join(Member member);
    Member findMember(Long memberId);
}

 

<회원 서비스 구현체>

package hello.core.member;

public class MemberServiceImpl implements MemberService {
    private final MemberRepository memberRepository = new
            MemoryMemberRepository();
    public void join(Member member) {
        memberRepository.save(member);
    }
    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

 

이제 설계를 기준으로 만든 회원 객체를 사용하여, 회원가입이 제대로 작동하는 지 코드로 작성해보겠다.

package hello.core;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;

public class MemberApp {
    public static void main(String[] args) {
    	// 서비스 객체 생성
        MemberService memberService = new MemberServiceImpl();
        // 회원 객체 생성
        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());
    }
}

 

이제 위의 코드를 두고 이야기 해보자.

 

지금까지 작성한 코드는 객체 지향적으로 잘 작성되었는가?

 다른 저장소로 변경이 필요할 때, OCP 원칙을 잘 준수하는가?
 그리고 DIP 원칙을 잘 준수하는가?

 

지금 코드는 기능을 원하는 클라이언트에서 인터페이스와 더불어 구현체까지 모두 의존하는 모습을 보이고 있다.

 

예를 들어 아래의 코드는 서비스 구현체인데 이를 클라이언트로 바라보았을 때, 필요한 레퍼지토리로 new를 통해 생성한 구현체와 인터페이스인 추상체 모두에 의존하고 있다. 따라서 향후 변경이 필요하다면 이 기능을 사용하는 클라이언트인 서비스 구현체 내부에서 변경이 필요하다.

 

일단, 문제점을 파악하고 계속해서 요구사항에 맞춰 주문과 할인 측의 개발도 진행한 뒤에 개선해보자.

 


주문과 할인 도메인 설계

 

이는 역할에 대해서 간략히 표현하며, 주문 도메인 전체의 역할과 구성은 아래와 같다.

 

 

 

 

 

주문과 할인 도메인 개발

 

<할인 정책 인터페이스>

package hello.core.discount;
import hello.core.member.Member;

public interface DiscountPolicy {
    /**
     * @return 할인 대상 금액
     */
    int discount(Member member, int price);
}

 

<정액 할인 정책 구현체 (1000원 할인)>

package hello.core.discount;
import hello.core.member.Grade;
import hello.core.member.Member;
public class FixDiscountPolicy implements DiscountPolicy {
    private int discountFixAmount = 1000; //1000원 할인
    @Override
    public int discount(Member member, int price) {
        if (member.getGrade() == Grade.VIP) {
            return discountFixAmount;
        } else {
            return 0;
        }
    }
}

 

<주문 Entity>

package hello.core.order;

public class Order {
	 private Long memberId;
	 private String itemName;
	 private int itemPrice;
	 private int discountPrice;
	
	public Order(Long memberId, String itemName, int itemPrice, int discountPrice) {
		 this.memberId = memberId;
		 this.itemName = itemName;
		 this.itemPrice = itemPrice;
		 this.discountPrice = discountPrice;
	 }
	 public int calculatePrice() {
		 return itemPrice - discountPrice;
	 }
	 public Long getMemberId() {
		 return memberId;
	 }
	 public String getItemName() {
		 return itemName;
	 }
	 public int getItemPrice() {
		 return itemPrice;
	 }
	 public int getDiscountPrice() {
		 return discountPrice;
	 }
	 
       @Override
       public String toString() {
	       return "Order{" +
       "memberId=" + memberId +
       ", itemName='" + itemName + '\'' +
       ", itemPrice=" + itemPrice +
       ", discountPrice=" + discountPrice +
		 '}';
	 }
}

 

<주문 서비스 인터페이스>

package hello.core.order;

public interface OrderService {
    Order createOrder(Long memberId, String itemName, int itemPrice);
}

 

<주문 서비스 구현체>

package hello.core.order;
import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.member.Member;
import hello.core.member.MemberRepository;
import hello.core.member.MemoryMemberRepository;

public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();
    private final DiscountPolicy discountPolicy = new FixDiscountPolicy();

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

 

주문 생성 요청이 오면, 회원 정보를 조회하고, 할인 정책을 적용한 다음 주문 객체를 생성해서 반환한다. 메모리 회원 레포지토리와, 고정 금액 할인 정책을 구현체로 생성한다.

 

private final MemberRepository memberRepository 또는 private final DiscountPolicy discountPolicy 는

인터페이스로 필요한 객체를 불러오는 역할에 충실하고 있다. 그리고 new로 객체들을 필요에 따라 갈아끼우면서 필요한 객체들을 사용하고 있다.

 

여기까지 생각해보면, 역할과 구현이라는 각각의 기능에 충실하여 변화에 유연한 코드로 고려할 수 있다. 그러나 아직 그렇지 않다. 계속 내용을 파악해보자.

 

새로운 요구사항

 

갑자기 또 기획자가 새로운 할인 정책을 가져왔다.

 

이에 따라 아래와 같이 새로운 할인 정책 구현체를 만들자. 

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

 

요구 사항에 맞춰서 할인 정책을 변경하여 애플리케이션에 적용해보겠다

public class OrderServiceImpl implements OrderService {

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

}

 

앞서 만들었던, 고정 비율 할인 정책 대신에 변동 비율 할인 정책으로 구현체를 변경하였다. 하지만 같은 인터페이스를 사용하기에 하나의 부모 타입에서 구현체만 변경하는 것이 가능하다.

 

문제점

 

여기까지 보면 역할(인터페이스)과 구현 객체를 분리하였고,

다형성을 사용한 잘 설계된 것처럼 보인다. 그러나 사실 OCP, DIP 원칙이 위반되었다. 

왜 그럴까??

지금까지 설계는 의존관계를 아래와 같이 설계햇다고 고려하여 작성했다.

 

그러나 실제 의존 관계는 아래와 같다. 

 

해당 코드를 다시보자.

 

public class OrderServiceImpl implements OrderService {

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

}

 

잘보면 클라이언트인 OrderServiceImpl 이 DiscountPolicy 인터페이스 뿐만 아니라 FixDiscountPolicy 인 구체 클래스도 함께 의존하고 있다. 실제 코드를 보면 의존하고 있다.

 

DIP와 OCP가 깨지고 있는 것이다. 

그래서 만약 정책을 변경하면 의존 관계는 아래처럼 변경된다. 

 

그래서 FixDiscountPolicy 를 RateDiscountPolicy 로 변경하는 순간 OrderServiceImpl 의 소스 코드도 함께 변경해야 한다

(OCP 위반)

 

해결 방법 1( 인터페이스에 의존 - 해결X )

 

그럼 좋은 객체 지향적 설계 원칙을 지키면서 문제를 해결하려면 어떻게 할 수 있을까?

인터페이스에만 의존하도록 설계를 변경하자.

 

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

이렇게 변경하면 인터페이스에만 의존한다. 

그런데 구현체가 없으니 당연하게도 NPE가 발생한다.

 

구현체에 대한 의존관계를 끊어서 객체 지향 설계 원칙에 따라 만들었지만,,, 의존하던 구현체를 끊어버리니 기능이 작동하지 않는다.

 

해결 방법 2( 객체 주입 클래스 - 해결 O )

 

그럼 대체 어떻게 해결해야할까?

 외부에서 누군가 구현 객체를 대신 생성하고 주입해주면 되지 않을까?

 

이를 위해 AppConfig 클래스를 통해서 구현해보자.

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

 

이 AppConfig는 애플리케이션의 실제 동작에 필요한 구현 객체를 생성한다.

그리고 코드를 통해서 구현 객체 생성과 주입을 연결지으려면 생성되는 코드 부분에서도 약간의 변화가 필요하다 .

 

먼저 위에서 리턴하는 MemberServiceImpl 클래스에서 생성하는 형태를 생성자로 주입받도록 해야한다.

 

package hello.core.member;
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();

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

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

본래 레포지토리 기능을 사용하려면, new를 통해 직접 구현체를 불러오는 형태였다. 

 

그러나 이제 AppConfig이 이 역할을 대신 해주니깐, OCP와 DIP가 깨지지 않는 형태로 인터페이스에만 의존하면서 생성자 주입으로 레포지토리 객체를 주입받는 형태로 변경하자. 

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가 하는 역할이다. 

 

이어서 OrderServiceImpl도 생성자 주입으로 다시 설계해보자.

import hello.core.discount.DiscountPolicy;
import hello.core.member.Member;
import hello.core.member.MemberRepository;

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

설계 변경으로 OrderServiceImpl은 특정 레포지토리 구현체나 할인 정책 구현체에 의존하지 않게된다. 그리고 역시 OrderServiceImpl입장에서 생성자를 통해 어떤 구현 객체가 들어올지는 알 수 없다.

 

OrderServiceImpl 의 생성자를 통해서 어떤 구현 객체을 주입할지는 오직 외부( AppConfig )에서 결정한다.

OrderServiceImpl 은 이제부터 실행에만 집중하면 된다

 

지금까지의 내용을 바탕으로

AppConfig 실행하는 코드를 보자.

 

< 회원 가입 로직 >

package hello.core;

import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;

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

 

<주문 서비스 로직>

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

 

스프링과 객체지향의 관계

 

이제 AppConfig가 대신 필요한 객체를 생성해주고 주입하여 역할과 구현을 분리한 코드 작성이 가능하도록 만들어주었다.

 

그리고 스프링의 핵심 기술을 이와 관련지어 고려해보면, 객체지향적 설계를 위해서 자바코드로 구현하다보면 도달하는 AppConfig와 같은 존재이다.

객체 지향적 구현을 수월하게 사용할 수 있도록 도와주는 프레임워크인 것이다.

 

그리고 AppConfig의 등장으로 어플리케이션의 사용 영역과 구성 영역이 분할되면서 사용 영역은 구성의 변화에도 변경의 여파를 받지 않는 것이다. 

 

이제 변경이 필요하면 AppConfig 클래스에서 주입 객체를 변경만 해주면 되는 형태이다. 

 

 

 

+ a) 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());
	 }
}​


현재 상태는 각자 서비스 객체에 필요한 기능을 대신 주입해주는 형태로 그 객체 자체가 지닌 책임을 다하고 있지만, 
좀 더 엄밀하게 보자면, 서비스 객체 주입 목적과 더불어 레포지토리 객체 주입인 두 개의 책임을 가지고 있다.

따라서 레포지토리 주입을 위한 메소드를 따로 분리하여 따로 관리하도록 리펙토링하자.


< 리펙토링 >
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();
    }
}​

 

댓글