제육's 휘발성 코딩
반응형

스프링 핵심 원리 이해 - 예제

프로젝트 생성

image

비즈니스 요구사항

  • 회원
    • 회원 가입 및 조회
    • 회원 등급 (일반, VIP)
    • 자체 DB를 사용하며, 외부 DB 연동 가능성 존재
  • 주문과 할인 정책
    • 회원은 상품 주문 가능
    • 회원 등급에 따라 할인 정책 적용
    • 모든 VIP는 1000원 할인해주는 고정 할인 금액 적용 (할인 내용 변경 가능성 존재)

요구사항을 보면 회원 데이터, 할인 정책 등 당장 결정하기 어려운 부분이 있다. 앞에서 배운 객체지향 설계 방법을 통해 인터페이스만 만들고 구현체를 갈아끼울 수 있도록 설계해보자. 우선 스프링의 기능을 사용하지 않고 이후에 변경해보자.

도메인 설계

image

  • 회원 도메인 협력 관계는 위의 그림과 같다. 회원 저장소는 DB 선택이 결정되지 않았으므로 교체가 가능하도록 설계한다.

image

  • 클래스 다이어그램을 보면 Service의 구현체가 MemberRepository 인터페이스를 의존하도록 설계한다.

회원 도메인 개발

회원 등급

package hello.core.member;

public enum Grade {
    BASIC,
    VIP
}

회원 엔티티

package hello.core.member;

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

회원 저장소

회원 저장소 인터페이스

package hello.core.member;

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);
    }
}
  • DB가 확정된 상태가 아니니 가장 단순한 메모리 회원 저장소를 구현해서 우선 개발을 진행하자.

회원 서비스

회원 서비스 인터페이스

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

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

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

현재 설계 방식은 의존 관계가 인터페이스 뿐만 아니라 구현까지 모두 의존하는 문제점이 있다. 주문까지 만들고 문제점과 해결 방안에 대해 알아보자.

주문과 할인 도메인 설계

image

  • 역할과 구현을 분리해서 객체를 조립할 수 있도록 설계 해보자. 회원 저장소와 할인 정책 모두 유연하게 변경할 수 있다.

할인 정책 인터페이스

package hello.core.discount;

import hello.core.member.Member;

public interface DiscountPolicy {
    int discount(Member member, int price); 
}

정액 할인 정책 구현체

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;
        }
        return 0;
    }
}
  • VIP면 1000원 할인, 아니면 0원으로 정액 할인 구현체 생성

주문 엔티티

package hello.core.discount;

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 void setMemberId(Long memberId) {
        this.memberId = memberId;
    }

    public String getItemName() {
        return itemName;
    }

    public void setItemName(String itemName) {
        this.itemName = itemName;
    }

    public int getItemPrice() {
        return itemPrice;
    }

    public void setItemPrice(int itemPrice) {
        this.itemPrice = itemPrice;
    }

    public int getDiscountPrice() {
        return discountPrice;
    }

    public void setDiscountPrice(int discountPrice) {
        this.discountPrice = discountPrice;
    }

    @Override
    public String toString() {
        return "Order{" +
                "memberId=" + memberId +
                ", itemName='" + itemName + '\'' +
                ", itemPrice=" + itemPrice +
                ", discountPrice=" + discountPrice +
                '}';
    }
}

주문 서비스 인터페이스

package hello.core.order;

import hello.core.discount.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.discount.Order;
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);
    }
}
  • 주문 생성 요청이 오면, 회원 정보를 조회하고 할인 정책을 적용한 다음 주문 객체를 생성해서 반환한다. 여기서 사용한 DB는 메모리 방식이고, 고정 금액 할인 정책을 구현체로 사용했다.

주문과 할인 정책 테스트

package hello.core.order;

import hello.core.discount.Order;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

class OrderServiceTest {
    MemberService memberService = new MemberServiceImpl();
    OrderService orderService = new OrderServiceImpl();

    @Test
    void createOrder() {
        long memberId = 1L;
        Member member = new Member(memberId, "memberA", Grade.VIP);
        memberService.join(member);

        Order order = orderService.createOrder(memberId, "itemA", 10000);
        Assertions.assertThat(order.getDiscountPrice()).isEqualTo(1000);
    }
}
  • 회원 가입한 회원의 정보로 생성한 주문의 할인 가격을 테스트한다.

새로운 할인 정책 개발, 리팩토링 등 객체지향의 원리를 적용해보자.

새로운 할인 정책 개발

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;
    }
}
  • VIP이면 1000원 고정할인이 아닌 10프로로 정률 할인을 사용하는 구현체를 생성하자.

새로운 할인 정책 테스트

package hello.core.discount;

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

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

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

새로운 할인 정책 추가 시 문제점

다형성을 활용하여 인터페이스와 구현 객체를 분리하고 객체지향 설계 원칙을 준수해가며, 테스트까지 정상 작동하는 것을 확인했다. 하지만 다음과 같은 문제점이 발생한다.

public class OrderServiceImpl implements OrderService {

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

    @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);
    }
}
  • Service 구현체에서 Repository 인터페이스에만 의존하는 것이 아닌 구현체에도 의존하고 있다. 즉, 확장을 위해 직접 변경했으므로 OCP를 위반하였고 구현체에도 의존하고 있으므로 DIP도 위반하고 있다.

AppConfig 등장

문제점을 해결하기 위해 관심사를 분리해야 한다. 즉 외부에서 구현 객체를 생성하고 연결하는 책임을 갖고있으면 문제를 해결할 수 있다. 별도의 설정 클래스인 AppConfig 클래스를 만들어보자.

기존 코드 수정

package hello.core.order;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.Order;
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;
    }
}
  • 기존의 의존 관계를 생성자를 통해서 주입 받도록 한다. 즉, 구현체에 의존하지 않고 오로지 인터페이스만 의존할 수 있도록 설계가 되었다.
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 에서 객체 생성과 동시에 의존관계 객체도 생성하여 주입해준다. 즉, 이것을 DI(의존 관계 주입)이라고 한다.
package hello.core.order;

import hello.core.AppConfig;
import hello.core.discount.FixDiscountPolicy;
import hello.core.member.*;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class OrderServiceTest {
    MemberService memberService ;
    OrderService orderService ;

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

    @Test
    void createOrder() {
        long memberId = 1L;
        Member member = new Member(memberId, "memberA", Grade.VIP);
        memberService.join(member);

        Order order = orderService.createOrder(memberId, "itemA", 10000);
        Assertions.assertThat(order.getDiscountPrice()).isEqualTo(1000);
    }
}
  • 테스트코드 또한 @BeforeEach를 통해 테스트 실행 전에 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());
    }
}
  • 기존의 방식은 AppConfig안에서 구조에 대한 가독성이 좋지 않다. 예를 들어, 할인 정책이 변경되면 할인 정책 내부에서 변경을 하는 것이 좋으므로 세부적으로 나누어서 리팩토링하는 것이 좋다.
package hello.core;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
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 RateDiscountPolicy();
    }
}
  • 다음과 같이 나누어서 설정해두는 것이 올바르다. 해당 경우 원하는 기능에만 변경을 통해 설정 할 수 있다. 이러한 별도의 설정을 담당하는 역할을 하는 부분은 스프링에서 @Configuration으로 사용한다.

AppConfig를 사용하므로 클라이언트 객체들은 실행에만 책임을 갖고, 구현체에 의존하지 않고 인터페이스에만 의존하게 되어서 스프링의 기능을 사용하지 않고 SRP, OCP, DIP 원칙을 모두 지킬 수 있었다.

IoC

IoC란 Inversion of Control (제어의 역전)이라는 뜻으로 스프링 컨테이너가 지원해주는 기능이다. 방금까지 진행했던 던 AppConfig는 프로그램의 제어 흐름을 모두 관리해서 구현 객체들은 자신의 로직만 실행하는 역할만 담당했다. 이처럼 외부에서 프로그램의 제어를 관리하는 것을 IoC라고 한다.

DI

DI란 Dependency Injection (의존관계 주입)이라는 뜻으로 현재 구현 객체는 의존하는 것이 인터페이스여서 어떤 구현객체가 올지 모르도록 의존 관계를 설정하는 것을 의미한다. 이러한 관계 설정을 하는 것을 외부에서 처리하는 것이 IoC이다.

스프링으로 전환하기

package hello.core;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.FixDiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
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;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();
    }
}
  • 외부 설정 파일은 @Configuration 으로 등록하면 스프링 컨테이너에서 빈 등록을 하는 클래스로 인지한다. 또한 이 안에서 빈 등록을하면 해당 빈 정보를 스프링컨테이너가 관리해준다.
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;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class OrderApp {

    public static void main(String[] args) {
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
        MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
        OrderService orderService = applicationContext.getBean("orderService", OrderService.class);
        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);
    }
}
  • AnnotationConfigApplicationContext를 통해 설정파일을 등록하고 빈을 꺼내와서 주입한다. 참고로 빈의 이름은 자동으로 메서드 이름의 첫글자는 소문자, 나머지는 동일한 이름으로 생성된다.

본 포스팅은 인프런 - 김영한님의 '스프링 핵심 원리 - 기본편'을 참고하여 정리하였습니다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^