Spring/스프링 핵심 원리 - 기본편

스프링 핵심 원리 - 기본편 정리 (1)

다아빈 2025. 3. 9. 21:30
728x90
반응형

 

(인프런) 김영한님의 스프링 핵심 원리 - 기본편 강의를 보고 정리한 글입니다.

 

 

사실 이 강의는 처음 듣는 것이 아니라 두 번째 수강하는 것입니다.

처음 들었을 때는 스프링이 처음이라 이해하지 못한 부분이 많아도 그냥 넘어갔었는데, 지금 돌아보니 기본기가 부족하다는 느낌이 들어 다시 듣게 되었습니다.
강의를 들으며 중요하다고 생각되는 개념이나 새롭게 알게 된 사실이 있다면 그때마다 이 글에 기록할 예정입니다.

혹시 틀린 부분이 있다면 친절하게 알려주세요! 감사합니다.


객체 지향 설계의 5가지 원칙 (SOLID)

SRP (Single Responsibility Principle, 단일 책임 원칙)

  • 하나의 클래스는 하나의 책임만 가져야 한다.
  • 클래스를 변경하는 이유는 하나여야만 한다.

이때 책임은 하나의 '기능' 으로 생각하면 편합니다.

하나의 클래스에서 여러 기능을 담당할 때 생기는 문제점: 하나의 기능에 대해 변경사항이 생겼을 때, 다른 기능들도 영향을 받아 수정해야할 코드가 많아집니다.

 

OCP (Open-Closed Principle, 개방-폐쇄 원칙)

  • 코드는 확장에는 열려 있고, 변경에는 닫혀 있어야 한다.
  • 확장에 열려 있다: 새로운 요구사항이 생겼을 때, 혹은 요구사항이 변경됐을 때 코드를 추가하여 애플리케이션의 기능 확장 
  • 변경에 닫혀 있다: 새로운 요구사항이 생겼을 때, 기존 코드를 수정하지 않고 기능을 추가해야 한다.

새로운 기능을 추가할 때 기존 클래스를 수정하는 것이 아니라 새로운 클래스를 만들어 기존 시스템과 연동하는 방식으로 해야 합니다.

 

예를 들어, 다음과 같은 요구사항이 있다고 가정해보겠습니다.

  • 주문과 할인 정책
    • 회원은 상품을 주문할 수 있다.
    • 회원 등급에 따라 할인 정책을 적용할 수도 있다.
    • 할인 정책은 모든 VIP 는 1000원을 할인해주는 고정 금액 할인을 적용해달라. (나중에 변경될 수 있다.)
    • 할인 정책은 변경 가능성이 높으며, 최악의 경우 할인을 적용하지 않을 수도 있다.

위와 같은 상황에서 할인 정책이 결정될 때까지 개발을 기다릴 수는 없습니다. 그럼 어떻게 구현을 할까요?

역할과 구현을 분리해서 객체를 조립할 수 있도록 설계하면 됩니다. 이렇게 되면 할인 정책이 바뀌어도 유연하게 변경이 가능해집니다. 

// DiscountPolicy 인터페이스 (역할)
public interface DiscountPolicy {

    /**
     * @return 할인 금액
     */
    int discount(Member member, int price);
}

// FixDiscountPolicy 클래스 (구현)
public class FixDiscountPolicy implements DiscountPolicy{

    private int discountFixAmount = 1000; //1000원 할인으로 고정

    @Override
    public int discount(Member member, int price) {
        // 필요한 로직 추가
    }
}
 
// RateDiscountPolicy 클래스 (구현)
public class RateDiscountPolicy implements DiscountPolicy{

    private int discountPercent = 10;

    @Override
    public int discount(Member member, int price) {
        // 필요한 로직 추가
    }
}

위의 코드와 같이 DiscountPolicy 인터페이스를 두고 FixDiscountPolicy, RateDiscountPolicy 와 같은 구현 클래스를 만들었습니다.

 

그리고 다음과 같이 필요한 곳에서 호출해서 사용하면 됩니다.

다음 코드에서는 처음 요구사항과 같이 고정 금액 할인 정책을 호출하겠습니다.

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) {
    	
        // 필요한 로직 추가
    }
}

만약 기획자가 정률 할인 정책으로 바꾸고 싶다해도 문제가 없습니다.

다음 코드와 같이 교체만 하면 되니까요!

public class OrderServiceImpl implements OrderService {

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

 

위의 예시처럼 기존 코드를 수정하지 않고, 교체를 하는 것이 OCP 입니다.

(이때 기존 코드는 FixDiscountPolicy를 말하는 것입니다!)

 

하지만.. 위의 코드는 기능을 확장해서 변경하게 되면, 클라이언트 코드에도 영향을 주는 문제점이 생깁니다. 따라서 OCP를 위반하는 코드입니다.

이 문제에 대해서는 뒤에서 설명하도록 하겠습니다.

LSP (Liskov Substitution Principle, 리스코프 치환 원칙)

  • 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다.
  • 상속받은 하위 클래스가 상위 클래스의 기능을 해치지 않고 대체 가능해야 한다.

예를 들어, 새(Bird) 클래스를 만들고 하위 클래스로 참새 클래스와 펭귄 클래스를 만든다고 생각해보겠습니다.

일반적으로 새는 날 수 있으므로 Bird 클래스에 fly() 메서드를 추가할 것입니다.
그러나 펭귄은 날지 못하는 새이므로, fly() 메서드를 호출하면 예외를 발생시키거나 빈 메서드로 남아있게 되는데 이러한 경우, Penguin을 Bird 타입으로 사용하면 프로그램이 예상과 다르게 동작할 수 있으며, LSP를 위반하게 되는 것입니다.

ISP (Interface Segregation Principle, 인터페이스 분리 원칙)

  • 클라이언트는 자신이 사용하지 않는 인터페이스에 의존하면 안된다.
  • 즉, 큰 인터페이스를 여러 개의 작은 인터페이스로 나눠 클라이언트가 불필요한 메서드에 의존하지 않도록 해야 한다.

큰 인터페이스를 역할별로 여러 개의 작은 인터페이스로 나누게 된다면 인터페이스가 더욱 명확해지고, 유지보수가 쉬워집니다.

DIP (Dependency Inversion Principle, 의존 역전 원칙)

  • 상위 모듈은 하위 모듈에 의존하면 안 되고, 둘 다 추상화에 의존해야 한다.
  • 즉, 구체적인 구현보다는 인터페이스와 같은 추상화된 개념에 의존해야 한다.

OCP 에서 사용했던 OrderServiceImpl 클래스를 생각해봅시다.

public class OrderServiceImpl implements OrderService {

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

이때,

  • MemberRepository 와 DiscountPolicy: 인터페이스
  • MemoryMemberRepository, FixDiscountPolicy, RateDisCountPolicy: 구현 클래스

입니다.

 

OrderServiceImpl 클래스에서는 추상 인터페이스(DiscountPolicy, MemberRepository) 에도 의존하고 있지만, 구현 클래스(MemoryMemberRepository, FixDiscountPolicy) 에도 의존하는 것을 볼 수 있습니다. 

따라서 OrderServiceImpl은 위와 같은 의존관계를 갖게 되며, DIP를 위반하였습니다.

그러면 어떻게 해결할까요?

 

정답은 인터페이스에만 의존하도록 코드를 변경하는 것입니다.

public class OrderServiceImpl implements OrderService {

	private MemberRepository memberRepository;
	private DiscountPolicy discountPolicy;
}

하지만 이 코드 또한, 구현체가 없어서 NPE 가 발생합니다.

 

그래서 나온 해결책은 구현 객체를 생성하고, 연결을 담당하는 별도의 설정 클래스를 만드는 것입니다.

관심사의 분리

내 애플리케이션의 전체를 구성하는 클래스인 AppConfig 클래스를 작성해보겠습니다.

public class AppConfig { // 이때 AppConfig처럼 의존관계 역전을 일으키는 클래스를 IoC 컨테이너 혹은 DI 컨테이너라고 한다.

    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는 애플리케이션의 실제 동작에 필요한 구현 객체를 생성하게 되며, 생성한 객체 인스턴스의 참조를 생성자를 통해 주입해주면 됩니다! 그럼 구현 클래스에도 생성자를 만들어야겠지요?

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

 

구체적인 클래스는 AppConfig가 넣어주기 때문에, OrderServiceImpl 입장에서는 어떤 구현체가 들어올지 알 수 없게 됩니다.

 

만약 할인 정책을 바꾸게 된다면 AppConfig 에서 FixDiscountPolicy() -> RateDiscountPolicy() 로 수정하면 되므로 위에서 문제가 있었던 OCP, DIP 문제도 해결할 수 있게 됩니다. 

 

 

AppConfig 클래스를 만들면서 SOLID 에서 3가지를 적용할 수 있었습니다.

  • SRP 단일 책임 원칙
    • AppConfig 클래스가 없을 때: 클라이언트 객체(OrderSerivceImpl)가 객체를 직접 생성, 연결, 실행하는 다양한 책임을 가짐
    • AppConfig 클래스를 만든 후: 관심사를 분리함
      • AppConfig: 구현 객체를 생성 및 연결하는 책임 담당
      • 클라이언트 객체: 객체를 실행하는 책임만 담당 (AppConfig의 설정 정보를 보고, 생성자를 통해 주입해줌)
  • DIP 의존관계 역전 원칙
  • OCP 개방-폐쇄 원칙

DIP, OCP 적용은 위에 설명에서 적었으니, 생략하겠습니다.

IoC, DI

IoC (Inversion of Control)

IoC는 직역하면 '제어의 역전' 이란 뜻입니다.

위에서 들었던 예시를 생각해보면, AppConfig가 없었을 때는 구현 객체에서 필요한 서버 구현 객체를 생성, 연결, 실행을 했습니다. 이는 다른 말로 구현 객체가 프로그램의 제어 흐름을 스스로 조종했다고 할 수 있습니다.

//AppConfig가 없었을 때
public class OrderServiceImpl implements OrderService {

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

AppConfig가 등장한 이후에는 구현 객체는 자신의 로직을 실행하는 역할만 담당합니다.

public class OrderServiceImpl implements OrderService {

	private MemberRepository memberRepository;
	private DiscountPolicy discountPolicy;
}

실제로 OrderServiceImpl은 필요한 인터페이스를 호출하지만, 어떤 구현 객체가 실행될지 모르는 상황입니다.

주문서비스는 "회원은 상품을 주문할 수 있으며, 회원 등급에 따라 할인 정책을 적용할 수 있다." 라는 역할만 알고, 실제로 적용되는 할인 정책이 정액 할인 정책인지, 정률 할인 정책인지 알지 못하는 상황이 되는 것이지요.

 

그럼 프로그램에 대한 제어 흐름에 대한 권한은 누가 가져갈까요?

바로 AppConfig 가 가져가는 것입니다.  심지어 OrderServiceImpl 도 AppConfig에서 생성하게 됩니다.

생각해보면 OrderServiceImpl 는 OrderService의 구현체입니다. 따라서 나중에 기획자가 새로운 주문 정책을 만들게 되면, OrderService 인터페이스의 다른 구현 객체를 생성하여 교체당할 수도 있습니다!

 

이렇게 프로그램의 제어 흐름을 외부에서 관리하는 것을 제어의 역전이라고 합니다.

DI (Dependency Injection)

DI는 직역하면 '의존관계 주입' 이란 뜻입니다.

의존관계는 정적인 클래스 의존관 계와 실행 시점에 결정되는 동적인 객체 의존관 계로 나눠서 생각해야합니다.

  • 정적인 클래스 의존 관계: 정적인 의존 관계는 코드를 실행시키지 않아도, 코드에서 import만 보고 쉽게 판단할 수 있습니다. 

  • 동적인 객체 의존 관계: 동적인 의존 관계는 애플리케이션 실행 시점에 생성된 객체 인스턴스의 참조가 연결된 의존 관계입니다.
    • 애플리케이션 실행 시점에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해 클라이언트와 서버의 실제 의존관계가 연결되는 것을 의존관계 주입이라고 합니다.
    • 의존관계 주입을 사용하면 클라이언트 코드를 변경하지 않고, 클라이언트가 호출하는 대상의 타입 인스턴스를 변경할 있습니다. 왜냐면 외부에서 관리해주니까요!
    • 의존관계 주입을 사용하면 정적인 클래스 의존관계를 변경하지 않고, 동적인 객체 인스턴스 의존관계를 쉽게 변경할 수 있습니다.

AppConfig처럼 객체를 생성하고 관리하면서 의존관계를 연결해 주는 것을 IoC 컨테이너 또는 DI 컨테이너라고 합니다!

728x90
반응형