[Spring] 역할과 구현의 분리, 스프링 컨테이너의 필요성

박상민·2025년 3월 28일

Spring

목록 보기
9/12

회원 도메인 설계

회원 도메인 설계

  • 회원을 가입하고 조회 가능
  • 회원은BASIC, VIP 두 가지 등급이 존재
  • 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동 가능 - 미확정


Interface?

자바에서 클래스들이 구현해야 하는 모든 기능을 추상화로 정의만 하고 구현은 하지 않은 자료형
1. 클래스 설계시 표준화를 유도할 수 있다.
2. 유지보수 효율이 좋고, 다형성을 극대화 가능

Service, Repository의 역할

Repository: DB 접근 및 CRUD 처리 담당. 도메인 객체를 데이터베이스에 저장하고 조회하는 역할을 수행한다. 비즈니스 로직은 절대 포함하지 않고, "데이터에 접근하는 로직"만 포함한다.
Service(Business Layer): 비즈니스 로직 처리 중심, 여허 Repository를 조합하거나, 도메인 규칙을 구현한다. 트랜잭션 처리와 데이터 흐름을 제어한다.

회원 도메인 개발

회원 등급

package hello.core.member;
public enum Grade {
	BASIC,
	VIP
}

Enum?

Enumeration을 정의할 수 있는 클래스
1. 의미 있는 이름으로 상수 관리가 가능
2. 잘못된 값 사용을 방지할 수 있어 String 타입보다 안정적
3. 비교 연산이 빠르다.

회원 엔티티

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

회원 저장소 인터페이스

package hello.core.member;

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

Repository를 interface로 구축한 이유

요구사항 중 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동 가능 - 미확정 때문이다.
아직 Repository 관련 요구사항이 확정되지 않았기 때문에 interface를 사용해 모든 기능을 추상화로 정의해야한다.

메모리 회원 저장소 구현체

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

현재 도메인 설계의 문제점

의존관계가 인터페이스 뿐만 아니라 구현까지 모두 의존하고 있다.

주문과 할인 도메인 설계

  • 요구사항
    • 회원은 상품을 주문할 수 있다.
    • 회원 등급(BASIC, VIP)에 따라 할인 정책을 적용할 수 있다.
    • 모든 VIP는 1000원을 할인해주는 고정 금액 할인을 적용 (추후 변경 가능성 존재)
    • 할인 정책은 변경 가능성이 높다. 할인을 적용하지 않을 수도 있다 -미확정


1. 주문 생성: 클라이언트는 주문 서비스(Business Layer)에 주문 생성을 요청
2. 회원 조회: 할인을 위해서는 회원 등급이 필요, 주문 서비스는 회원 저장소(Repository)에서 회원을 조회
3. 할인 적용: 회원 등급에 따른 할인 여부를 할인 정책에 위임
4. 주문 결과 반환: 주문 서비스는 할인 결과를 포함한 주문 결과를 반환

할인 정책은 변경 가능성이 높다는 요구사항이 존재했다.
따라서 할인 정책 또한 Repository와 마찬가지로 interface로 구축해야한다.

역할과 구현을 분리해서 자유롭게 구현 객체를 조립할 수 있게 되었다.
회원 저장소, 할인 정책을 유연하게 변경할 수 있다.

할인 정책 인터페이스

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;
		} else {
			return 0;
		}
	}
}

주문 엔티티

package hello.core.order;
public class Order {
	private Long memberId;
	private String itemName;
	private int itemPrice;
	private int discountPrice;
    
    ...
}

주문 서비스 구현체

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 = new
MemoryMemberRepository();
private final DiscountPolicy discountPolicy = new FixDiscountPolicy();

회원 저장소와 할인 정책을 interface로 구축해서 기능을 추상화했다.
현재는 메모리 저장소정액 할인 정책을 사용하고 있다.

추후

  • 메모리 저장소 -> 외부 시스템 저장소 (MySQL, PostgreSQL ...)
  • 정액 할인 정책 -> 정률 할인 정책

으로 변경한다고 해도 인터페이스로 기능을 추상화했기 때문에 OrderServiceImpl에서는 구현체만 변경하면 된다.

추후 나오겠지만 Spring을 적용하면 구현체 또한 변경하지 않아도 된다.

추가 요구사항

  • 정액 할인 정책에서 정률 할인 정책으로 변경
  • 고정 금액이 아닌 주문 금액의 10% 할인

정률 할인 정책

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

할인 정책을 변경하려면 OrderServiceImpl 코드의 구현 구분을 수정하면 된다.

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

인터페이스를 사용한 덕분에 할인 정책을 변경했음에도 구현 부분만 변경하면 된다. 하지만 이게 정말 최선일까?

  • 역할과 구현을 충실하게 분리
  • 다형성 활용, 인터페이스와 구현 객체 분리

그러나 OCP, DIP 등 객체지향 설계 원칙을 준수하지 못했다.

OCP(개방-폐쇄 원칙): 확장에는 열려 있고, 변경에는 닫혀 있어야 한다.
DIP(의존관계 역전 원칙): 상위 모듈은 하위 모듈에 의존하지 않고, 추상화에 의존해야 한다.

DIP 관점
얼핏 보면 OrderServiceImpl이 DiscountPolicy 인터페이스에 의존하면서 DIP를 지킨 것 같다. 그러나 추상 클래스(interface) 뿐만 아니라 구체(구현) 클래스에도 의존하고 있다.

추상 의존: DiscountPolicy
구현 클래스: FixDiscountPolicy, RateDiscountPolicy

OCP 관점
지금 코드는 기능을 확장해서 변경하면, 클라이언트 코드에 영향을 준다.


의도했던 의존관계는 이와 같다. OrderServiceImpl이 추상 클래스인 DiscountPolicy에만 의존하는 구조이다.



그러나 현재 구조는 OrderServiceImplDiscountPolicy 인터페이스 뿐만 아니라 구현 클래스도 함께 의존하고 있다.

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

OCP 위반
따라서 할인 정책을 변경하는 순간 OrderServiceImpl의 코드도 변경해야 한다.

문제 해결법

  • OrderServiceImpl이 추상 클래스(인터페이스)만 의존하도록 변경

interface에만 의존하도록 코드 변경

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

인터페이스에만 의존하고 있다. 그러나 구현체가 없기 때문에 코드가 실행되지 않는다.

  • OrderServiceImpl이 하던 구현 객체 생성/주입 역할을 별도의 클래스가 맡아야 한다. 이를 관심사의 분리라고 한다.

관심사의 분리

AppConfig

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

AppConfig를 통해 구현 객체를 주입하기 때문에, 각 ServiceImpl은 interface만 의존한다.
또한, ServiceImpl는 어떤 구현 객체가 주입될지 알 수 없다.

이처럼 외부 객체가 구현 객체를 생성하고 생성자로 전달하는 과정을 의존관계를 외부에서 주입하는 것과 같다고 하여 DI(Dependency Injection), 의존관계 주입이라 한다.

역할이 잘 분리됐다. ServiceImpl은 비즈니스 로직만 처리하고 의존관계에 대한 고민은 외부(AppConfig)에서 처리한다.

  • AppConfig: 객체 생성/연결 담당
  • DIP: MemberServiceImplMemberRepository 추상에만 의존한다. DIP를 완벽하게 지킨다.
  • 관심사의 분리: 객체를 생성하고 연결하는 역할과 실행하는 역할이 명확히 분리

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

다시 할인 정책을 변경해보자

  • 정액 할인 정책 -> 정률 할인 정책

지금까지는 OrderServiceImpl의 코드를 수정했다. 그러나, AppConfig를 생성한 지금은 어떤 부분을 변경해야 할까?

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

AppConfig에서 할인 정책 역할을 담당하는 구현을

  • FixDiscountPolicy -> RateDiscountPolicy로 변경

이전과 다르게 할인 정책을 변경해도, 구성 역할을 담당하는 AppConfig만 변경하면 된다.

지금까지의 내용 중 크게 중요한 부분은 아래와 같다고 생각한다.

  • 추상 클래스, 구현 클래스의 분리
  • 관심사의 분리
    • 사용 영역과 구성 영역의 분리

Spring으로 전환

AppConfig

@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: 설정 클래스
  • @Bean: 스프링 컨테이너에 스프링 빈으로 등록

Spring 컨테이너의 bean을 사용하는 방법

ApplicationContext applicationContext = new
AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService =
applicationContext.getBean("memberService", MemberService.class);

스프링 컨테이너

  • ApplicationContext = 스프링 컨테이너
  • Spring 사용하기 전까지는 직접 객체를 생성하고 의존관계 주입을 했지만, 이 역할을 스프링 컨테이너가 한다.
  • @Configuration이 붙은 AppConfig를 설정 클래스로 사용한다. @Bean이 붙은 메서드를 호출해서 반환된 객체를 스프링 컨테이너에 등록한다. 이렇게 등록된 객체를 Spring Bean이라 한다.
    • @Bean이 붙은 메서드 명을 스프링 빈의 이름으로 사용한다.

Spring 적용 전에는 필요한 객체를 AppConfig를 사용해서 직접 조회했지만, 스프링 컨테이너를 통해 필요한 스프링 빈(객체)를 찾을 수 있다.
즉, 개발자가 수동으로 하던 역할을 스프링 컨테이너가 자동으로 해준다.

솔직히 여기까지만 보면 코드만 복잡해진 것 같다. 스프링 컨테이너를 굳이 사용해야 할까?

스프링 컨테이너를 사용하는 이유는 객체 생성과 의존관계 주입을 자동으로 관리하기 때문이다. 현재는 코드가 단순하기 때문에 코드가 복잡해지는 것 같지만, 규모가 조금만 커져도 코드를 단순해지도록 돕는 역할을 한다.


출처
김영한-스프링 핵심 원리 기본편

0개의 댓글