Spring 핵심 원리 학습노트: IoC·DI·컨테이너와 SOLID

반응형

2022년 4월에 Spring 기본 원리를 공부하며 남긴 메모를 다시 정리했다. 당시 글에는 EJB의 역사부터 SOLID, AppConfig와 테스트 팁까지 한 번에 들어 있었다. 지금 다시 읽어 보니 핵심은 객체가 사용할 구현을 직접 만들지 않게 하고, 조립 책임을 바깥으로 옮기는 과정이었다.

Spring을 ‘POJO를 돕는 컨테이너’로 이해하기

Spring은 Java 애플리케이션의 객체 생성, 구성과 생명주기를 관리하는 IoC container를 제공한다. POJO(Plain Old Java Object)는 “낡은 Java 방식”이라는 뜻이 아니라, 특정 무거운 컴포넌트 모델의 상속이나 실행 환경에 강하게 묶이지 않은 평범한 Java 객체를 강조하는 표현이다.

Spring이 등장한 역사에는 EJB 중심 엔터프라이즈 개발의 복잡성을 낮추려는 흐름이 있었다. 그렇다고 EJB를 Java·JSP·Servlet의 합이나 단순히 느린 기술로 요약하면 정확하지 않다. 당시의 컴포넌트 모델과 배포 환경, 현재의 Jakarta EE도 구분해야 한다.

Hibernate는 대표적인 ORM 구현이고, JPA의 후신인 Jakarta Persistence는 persistence API 표준이다. Spring, ORM 구현과 persistence 표준은 서로 대체 관계 하나로 줄일 수 없다.

역할과 구현을 나누는 이유

다형성을 사용하면 클라이언트가 구체 클래스 대신 interface에 의존할 수 있다.

public interface DiscountPolicy {
    int discount(int price);
}

public final class OrderService {
    private final DiscountPolicy discountPolicy;

    public OrderService(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }

    public int finalPrice(int price) {
        return price - discountPolicy.discount(price);
    }
}

OrderService는 어떤 할인 정책 객체를 받을지 결정하지 않는다. 생성자 인자로 받은 계약만 사용한다. 구현 선택을 바꾸기 위해 서비스 내부의 new 코드를 고치지 않아도 된다.

AppConfig에서 조립 책임 분리하기

순수 Java로는 다음처럼 객체 생성을 별도 구성에 모을 수 있다.

public final class AppConfig {
    public OrderService orderService() {
        return new OrderService(discountPolicy());
    }

    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy(10);
    }
}

Spring에서는 configuration metadata를 읽은 container가 bean을 만들고 의존성을 연결한다.

@Configuration
public class AppConfig {
    @Bean
    OrderService orderService(DiscountPolicy discountPolicy) {
        return new OrderService(discountPolicy);
    }

    @Bean
    DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy(10);
    }
}

이렇게 생성과 사용의 책임이 갈리면 OrderService는 주문 계산에 집중하고, 구성은 어떤 구현을 연결할지 담당한다.

IoC와 DI를 구분해서 적기

  • IoC(Inversion of Control): 객체 생명주기와 호출 흐름의 일부를 애플리케이션 코드 밖의 container나 framework가 제어한다는 넓은 개념
  • DI(Dependency Injection): 객체가 필요로 하는 dependency를 외부에서 전달하는 구체적인 구성 방식
  • DI container: 객체 정의를 바탕으로 인스턴스를 만들고 dependency를 연결·관리하는 구성 요소

“AppConfig가 모든 프로그램 제어를 가져간다”기보다, 객체 생성과 연결에 대한 제어를 분리했다고 표현하는 편이 정확하다. 프레임워크와 라이브러리의 차이도 한 문장으로 완전히 갈리기보다 누가 lifecycle과 호출 규약을 주도하는지를 보는 것이 낫다.

SOLID는 체크리스트가 아니라 변경 방향을 보는 렌즈

SRP: Single Responsibility Principle

클래스가 하나의 이유로 변경되도록 책임을 응집한다. 단순히 메서드 수가 적다는 뜻은 아니다.

OCP: Open/Closed Principle

기존 핵심 코드를 반복 수정하지 않고 새로운 동작을 확장할 수 있는 구조를 지향한다. interface 하나를 만들었다고 자동으로 달성되지는 않는다.

LSP: Liskov Substitution Principle

하위 타입이 상위 타입이 약속한 동작과 불변 조건을 깨지 않아야 한다. 메서드 signature만 맞추는 것보다 강한 계약이다.

ISP: Interface Segregation Principle

클라이언트가 사용하지 않는 기능에 의존하지 않도록 interface를 역할별로 나눈다.

DIP: Dependency Inversion Principle

고수준 정책과 저수준 구현 모두 안정적인 추상화에 의존하도록 관계를 설계한다. DI container는 구현을 연결하는 데 도움을 주지만, 잘못 설계된 추상화를 자동으로 고쳐 주지는 않는다.

당시 메모에서 걷어낸 일반화

  • 모든 Map을 ConcurrentHashMap으로 바꿀 필요는 없다. 공유 mutable state에 실제 동시 접근이 있을 때 동시성 요구부터 분석한다.
  • 구현체 이름에 Impl을 붙이는 것은 규칙이 아니라 팀 naming 선택이다. 역할을 드러내는 이름이 더 나을 수 있다.
  • test source가 배포 산출물에서 제외되는지는 build 도구와 설정에 달려 있다.
  • given-when-then은 테스트를 읽기 쉽게 만드는 구조 중 하나이지 모든 테스트에 강제되는 문법은 아니다.
  • interface와 추상화는 비용이 있다. 변경 축과 대체 가능성이 실제로 있을 때 도입한다.

이 날 공부에서 남은 기준

  1. 클라이언트가 구체 구현을 직접 생성하고 있는가?
  2. 객체 생성과 업무 실행 책임을 분리할 이유가 있는가?
  3. interface가 실제 변경 축을 나타내는가?
  4. 의존성 방향이 핵심 정책을 향하고 있는가?
  5. container를 사용한다는 사실과 좋은 설계를 동일시하고 있지는 않은가?

다음 날의 학습 내용은 Spring 기본 원리 공부 2편으로 이어진다.

참고 문서

반응형
KEEP READING
카테고리 전체 보기 →

댓글