IoC/DI 이전에 고충과 이후의 개념

Updated:

Categories:

IoC를 탄생 이전에 고충

과거 직접적으로 개발자가 객체를 관리할 때는 여러 가지로 불편함과 문제점들이 한 두가지가 아니었다. 지금부터 그 문제들을 살펴보자

전통방식의 문제점

1. 강한 결합(Tight Coupling)

public class OrderService {
    private PaymentService paymentService = new PaymentService();
}

이 코드를 단순히 봤을 때는 아무런 문제가 없겠지만, 구조적 관점에서 보자면 얘기가 달라진다.

코드 구조를 보면 클래스 간 결합도가 높다. 그러므로, 변경에 약하고 유연성이 떨어진다.

OrderService는 PaymentService에 직접 의존하고 있고, 그리고 PaymentService 구현이 바뀌면 OrderService 코드도 반드시 수정해야 한다.

2. 유닛 테스트 어려움

public class OrderService {
    private PaymentService paymentService = new PaymentService();

    public void order() {
        paymentService.pay();
    }
}

이 코드는 테스트할 때 PaymentService를 가짜(Mock) 객체로 대체할 수 없다.

내부에서 직접적으로 paymentService를 동적으로 생성하고 있기 때문에, 테스트 시 의존 객체를 바꿔 끼우기가 어렵고, 사실상 Mocking이 불가능하다.

3. 객체 재사용성/확장성 부족

public class NotificationService {
    private EmailSender emailSender = new EmailSender();
}

이메일이 아닌 문자나 알림 객체로 바꾸고 싶다면 가능할까? 정답은 아니다. 매번 NotificationService를 수정해야 하기 때문이다.

이 코드의 문제점은 유연하지 않고, OCP(개방/폐쇄 원칙)에 위배된다.

개방 폐쇄 원칙이란?
소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다. 즉, 기존 코드를 변경하지 않고 새로운 기능을 추가할 수 있어야 한다는 의미

4. 객체 생명주기 관리 어려움

개발자가 직접적으로 동적할당한 객체의 책임은 개발자에게 있다. 즉, 생명주기(생성/소멸)를 관리해야 한다. 또한, 어떤 스코프(싱글턴, 프로토타입 등) 전략을 취해야할지 그리고 이를 관리하는 비용도 만만치가 않을 것이다.

관리해야 할 객체가 많아지면 코드가 복잡해지고 에러/버그 발생할 가능성이 높아지므로 개발자는 제어권을 상실하게 된다.

5. 중복 코드 증가

public class App1 {
    public static void main(String[] args) {
        Engine engine = new Engine();         // 중복
        Car car = new Car(engine);           // 중복
        car.start();
    }
}

public class App2 {
    public static void main(String[] args) {
        Engine engine = new Engine();         // 중복
        Car car = new Car(engine);           // 중복
        car.start();
    }
}

여러 군데에서 Engine, Car 객체를 매번 새로 생성해야 하기 때문에 반복적이고 비효율적인 코드가 대거 발생하게 된다. 때문에, 유지보수가 힘들어진다.


팩토리 패턴은 해결책일까?

지금까지 전통적인 방식으로 코드를 관리하게 됐을 때 발생하는 여러 문제점들을 살펴보았다.

이 문제점들을 해결할 수 있는 방법은 없을까?

음..? 당장 떠오르는 방법은 디자인패턴을 사용하는 것이다. 이를테면 팩토리 패턴을 사용해서 참조 객체로서 활용하는 것이다.

public class ObjectFactory {
    private static final Engine engine = new Engine();
    private static final Car car = new Car(engine);

    public static Car getCar() {
        return car;
    }
}
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
public class App1 {
    public static void main(String[] args) {
        Car car = ObjectFactory.getCar();
        car.start();
    }
}

코드를 보면 객체 생성을 하나의 ObjectFactory 클래스로 분리해서 중복성을 제거했을 뿐만 아니라, 전역 객체로서 팩토리 패턴에 의존하게 만들었다.

이로서, 문제가 해결됐다고 생각이 들겠지만 여전히 단정짓기엔 문제점들이 많다.

1. 의존성 설정이 코드에 고정됨

public class ObjectFactory {
    public static Car getCar() {
        Engine engine = new GasEngine();  // 여기에 고정
        return new Car(engine);
    }
}

여전히 OCP(개방/폐쇄 원칙)를 위반하고 있다. 왜냐하면, 새로운 의존성을 추가하거나 변경할 때마다 기존 코드를 수정해야 하기 때문이다.

코드를 보면 설정이 하드코딩되어 있다. 따라서 환경이나 조건에 따라 다르게 구성하기 어렵다. 예를 들어 테스트 환경에서는 FakeEngine 객체를, 운영 환경에서는 GasEngine 객체를 바꿔가며 사용할 수 없다.

2. 객체 생명주기(스코프(싱글턴, 프로토타입 등)) 수동 관리

public class ObjectFactory {
    private static Engine engine;

    public static Engine getEngine() {
        if (engine == null) {
            engine = new Engine();
        }
        return engine;
    }
}

위와 같이 싱글톤 객체를 생성하고 관리하게 된다면, 몇 가지 문제점들이 발생하게 된다.

생명주기(초기화/종료, 삭제) 처리가 누락될 수 있거나, 아니면 객체가 너무 오래 잔존하는 메모리 누수 현상이나, GC에 의한 예기치 않은 삭제 버그가 발생할 수도 있다.

또한, 멀티 스레드 환경에서는 동기화 고려도 필요할 것이다. 그러므로 매우 복잡하다.

3. 테스트가 어렵다

@Test
void testCar() {
    Car car = ObjectFactory.getCar(); // 내부에 어떤 Engine인지 모름
    // Car 내부에서 어떤 Engine을 쓰는지 고정됨 → Mock으로 바꿀 수 없음
    car.start();
}

테스트할 때 Engine을 Mock 또는 Stub으로 바꿔 끼우고 싶어도 그럴 수가 없다. 왜냐하면 ObjectFactory가 new Engine()을 직접 호출하고 있어서, 테스트할 경우에 의존 객체를 주입할 수 있는 여지가 없기 때문이다.

4. 의존관계가 많아지면 ObjectFactory가 비대해진다.

public class ObjectFactory {
    public static Car getCar() {
        Engine engine = new Engine();
        Transmission transmission = new Transmission();
        GPS gps = new GPS();
        return new Car(engine, transmission, gps);
    }

    public static UserService getUserService() {
        UserRepository repo = new UserRepository();
        EmailSender emailSender = new EmailSender();
        return new UserService(repo, emailSender);
    }

    public static OrderService getOrderService() {
        PaymentService payment = new PaymentService();
        InventoryService inventory = new InventoryService();
        return new OrderService(payment, inventory);
    }

    // 수십 개가 늘어남...
}

초기엔 분명 간략하게 중복 코드를 줄여준 해결책이었지만, 이후에 ObjectFactory가 점점 커져서 거대한 의존성 생성 공장이 돼버렸다. 이로써 하나만 바뀌어도 다른 생성 로직에 영향을 줄 수 있을 뿐만 아니라, 객체 간 관계가 복잡해지면 실수로 또 다른 중복 코드를 생성하거나 아니면, 최악의 경우 순환 참조 문제가 발생하게 되는 구조로 바뀌어버릴지도 모른다.


지금까지 개발자가 전통방식으로 객체를 관리했을 경우에 문제점들과 그걸 해결하기 위한 하나의 방법인 팩토리 패턴 활용법, 그리고 이로 인한 문제점들을 다루어보았다.

결론은 이거다. “거인의 어깨 위에 올라서서 더 넓은 세상을 바라보는 것” 이다. 그러니까, 이미 만들어진 발명품들을 잘 활용해서 좋은 성과를 내면 될 뿐이다.


IoC(Inversion of Control)

좋은 발명품은 무엇일까? 나는 이미 잘 짜여진 원래의 이야기나 내용을 바탕으로 새롭고 완성된 작품을 만드는 것이라고 생각한다.

즉, 프레임워크에서 제공하는 시스템을 이해하고 잘 활용만 한다면 되는 것이다.

IoC는 어떤 객체가 자신의 동작에 필요한 의존 객체를 스스로 생성하거나 제어하지 않고, 외부에서 주입받는 구조를 말한다.

IoC는 객체 생성과 의존성 관리의 제어권을 개발자가 아닌 프레임워크(Spring)이 가지는 구조를 말한다.

IoC는 더 이상 개발자가 아닌 Spring이 대신 주입해 주기 때문에 이를, 제어의 역전(Inversion of Control)이라고 한다.


우리는 IoC를 사용하면 수동적 주입을 더는 할 필요가 없다는 것을 알게 되었다. 그리고, 더 나아가서 이 주입된 객체에 대해서 알아보고, 마지막으로 이 주입을 할 수 있게 하는 방식인 DI에 대해서 알아보고자 한다.

Bean이란?

Bean은 Spring IoC 컨테이너에 의해 생성되고, 조립되고, 관리되는 객체. 즉, 그러니까 외부 주입에서 사용되는 객체들은 모두가 빈이다.

Bean은 어떻게 생성되고 관리되는 걸까?

empty

Bean이 사용되기까지는 몇 가지 과정을 거쳐야 한다.

ApplicationContext
Spring IoC 컨테이너 핵심 인터페이스로서, 빈을 생성, 설정, 조립(서로 간 의존성 주입 및 연결) 역할을 수행한다.

Spring IoC는 설정 메타데이터(configuration metadata)를 읽고 빈을 만들고 설정한다. 그리고 이 설정들을 기반으로 컨테이너가 애플리케이션 구성요소들(Bean)과 그들의 관계를 설정한다.

대표 구현체는 AnnotationConfigApplicationContext(자바 어노테이션 기반 설정용), ClassPathXmlApplicationContext: XML 설정용 두 가지가 있다.

Configuration Metadata
개발자가 의도/설정한 데이터(Configuration Metadata)를 바탕으로 Spring IoC 컨테이너가 어떤 Bean을 어떻게 생성하고, 설정하고, 연결(조립)할지를 설정하는 단계를 말한다.

Pojo 객체를 IoC Container에 Bean 객체로서 관리하도록 지정하는 방법은 크게 두 가지 방식으로 분류할 수 있다.

  • Annotation 기반 설정
    • Annotation 기반 설정 클래스에 @Component, @Service, @Repository 등 어노테이션을 붙여 빈으로 등록하는 방식
  • Bean 등록
    • Java 기반 설정
      • @Configuration 클래스 안에 @Bean 메서드들로 Bean을 수동 등록
    • XML 설정
      • 태그로 Bean 정의
    • Groovy 스크립트
      • Spring이 지원하는 스크립트 설정 방식

BeanDefinition
생성된 Bean은 컨테이너 내부에서 메타데이터 설정 기반으로 BeanDefinition 객체로 변환되어 저장된다. 이 객체는 Bean의 정의와 설정 정보를 담은 Spring 내부 객체이다.


Scope와 Singleton

Spring은 애플리케이션 전반에서 객체들을 관리하고, 의존성도 주입해주는 IoC 컨테이너이다. 그런데, 이 객체들(Bean)을 언제, 어떻게, 얼마나 자주 생성할지는 상황에 따라 달라지게 된다. 예를 들어

  • 어떤 객체는 애플리케이션 시작부터 끝까지 한 개만 있으면 되고,
  • 어떤 객체는 사용자의 요청마다 새로 만들어져야 하고,
  • 어떤 객체는 로그인한 사용자마다 하나씩 있어야 한다.

때문에, Bean의 생명주기를 제어할 필요성이 생기게 된다. 그래서 등장한 개념이 바로 Scope(범위)다.

Scope는 빈 객체의 생명 주기와 생성 전략을 정의하는 개념인데, 일반적으로 많이 쓰이는 스코프는 singleton이며, Spring의 기본 스코프이기도 한다.

Spring의 Bean Scope 종류

  • singleton
    • (기본값) Spring 컨테이너당 1개 인스턴스만 생성. 모든 요청에서 같은 Bean을 공유
  • prototype
    • 요청할 때마다 매번 새로운 인스턴스를 생성
  • request
    • HTTP 요청당 하나의 Bean 인스턴스 생성 (웹 애플리케이션에서 사용)
  • session
    • HTTP 세션당 하나의 Bean 인스턴스 생성
  • application
    • 서블릿 컨텍스트당 하나의 Bean 인스턴스 생성
  • websocket
    • 웹소켓 세션당 하나의 Bean 생성


왜 기본값이 singleton일까?
Spring은 기본적으로 비즈니스 로직 중심의 서비스 클래스를 관리하고 있다. 그런데 이런 서비스 로직들은 객체 상태를 내부에 저장하지 않고(stateless), 단순히 기능만 제공하기 때문에, 동시에 여러 요청이 와도 내부 상태가 충돌하지 않는다. 즉, 굳이 새로 만들 필요 없이, 하나만 공유해서 써도 문제가 되지 않기 때문이다.

그래서 메모리/성능/관리에 이점이 있어서 Spring은 기본적으로 singleton을 선택했다.


DI(Dependency Injection) 그리고 대표적인 구현 방식

DI란? IoC의 주입을 실현하는 방법 중 하나로서, 객체가 자신이 사용할 다른 객체를 직접 생성하지 않고 외부에서 주입(Injection)받는 방식을 말한다.

DI는 대표적으로 3가지의 주입 방식을 지원하고 있다.

생성자 주입(Constructor-based DI)

public class OrderService {
    private final PaymentService paymentService;

    // 생성자를 통한 의존성 주입
    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public void processOrder() {
        paymentService.pay();
    }
}

생성자 주입은 의존성 주입 방식 중 하나로, 객체를 생성할 때 필요한 의존성을 생성자를 통해 전달하는 방법이다.

그렇다면 일반적으로 생성자 주입을 사용하는 이유는 무엇일까? 그 이유는 아래와 같다.

  • 객체의 불변성(Immutable) 보장
    생성자 주입은 의존 객체를 final로 선언할 수 있어서, 주입 이후에는 절대 바뀌지 않도록 보장할 수 있다. 그리고 3가지의 이점이 생긴다.
    1. 코드 안정성 증가 -> 객체의 상태가 예측 가능함
    2. 동시성 문제 예방 -> 멀티 스레드 환경에서도 공유 객체의 상태가 바뀌지 않으므로 Thread-safe
    3. 리팩토링 시 안전 의존성이 고정되어 있으니, 코드를 바꾸다 잘못 수정할 확률이 줄어든다.
  • 테스트 코드 작성 용이
    @Test
    void testProcessOrder() {
      PaymentService mockPayment = mock(PaymentService.class); // Mockito 사용
      OrderService orderService = new OrderService(mockPayment);
    
      orderService.processOrder();
    
      verify(mockPayment).pay(); // 호출 여부 확인
    }
    

    생성자 주입이 테스트에 용이한 이유는 내가 테스트할 객체를 만들면서, 필요한 객체들을 직접 넣어줄 수 있기 때문이다.

  • NPE(Null Pointer Exception) 예방
    생성자 주입은 null일 수 있는 상황 자체를 원천 차단함으로써 NPE를 예방한다.
    1. 필수 의존성을 빠뜨리지 않고 주입한다. -> 의존성(paymentService)을 반드시 생성할 때 넣어야만 하기 때문이다.
    2. final로 선언하면 객체가 null이 될 가능성 없음
  • 객체 간의 순환 참조 방지
    class A {
      private final B b;
    
      public A(B b) {
          this.b = b;
      }
    }
    class B {
      private final A a;
    
      public B(A a) {
          this.a = a;
      }
    }
    
    • 설명하기 이전에 순환 참조 문제가 무엇인지부터 알아야 한다.
    • 순환 참조란 클래스 A가 B를 의존하고, 클래스 B가 다시 A를 의존하는 구조를 말한다.
    • 이 상태에서 A와 B를 동시에 생성하는 건 불가능할 것이다. 왜냐하면 A를 만들려면 B가 필요하고, B를 만들려면 다시 A가 필요하기 때문이다. 즉, 서로가 서로를 기다리다가 멈춰버리는 현상을 말한다.
    • 생성자 주입을 사용하면 빈을 만들 때 객체 생성이 일어나게 된다. 이때 서로 순환 참조가 있으면, Spring이 애플리케이션 실행 시 바로 예외를 던지게 된다.
      org.springframework.beans.factory.BeanCurrentlyInCreationException: 
      Requested bean is currently in creation: Is there an unresolvable circular reference?
      
    • 때문에 런타임 초기에 문제를 빠르게 발견할 수 있다.

장점

  • 불변성(Immutable) 보장
    • final로 선언해서 한 번만 설정 가능 → 나중에 변경될 위험이 없다.
    • 안정적인 객체 상태 유지 가능하다.
  • 필수 의존성 강제 가능
    • 객체를 생성할 때 반드시 필요한 의존성만 생성자에 넣기 때문에 누락된 의존성 없이 객체 생성 보장한다.
  • 테스트 용이
    • 생성자를 통해 직접 mock 객체, stub 등을 넘겨줄 수 있어 DI 컨테이너 없이도 테스트 가능하다.
  • 순환 참조 문제 조기 감지
    • A → B → A 같은 순환 구조는 생성자 주입 시 즉시 예외가 발생한다.
    • 디버깅이 쉽다.

단점

  • 의존 객체가 많아지면 생성자 매개변수가 많아져서 복잡해질 수 있다.
    • @RequiredArgsConstructor(Lombok)로 해결 가능하다.
  • 순환 참조를 해결하기 어렵다.
    • A → B → A 같은 순환 참조는 생성자 주입으로는 깨기 힘들기 때문에 구조 재설계가 필요하다.
  • 꼭 필요하지 않은 의존성까지 생성자에 넣어야 하므로 유연성이 떨어질 수 있다.

생성자 주입은 가장 안전하고, 명확하고, 테스트하기 좋은 방식이다. 단점도 있지만 대부분은 Lombok, 구조 설계 등으로 보완 가능해서 Spring에서 가장 권장되는 주입 방식이다.


세터 주입(Setter-based DI)

public class OrderService {

    private PaymentService paymentService;

    public void setPaymentService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

의존 객체를 setter 메서드(혹은 일반 메서드)를 통해 주입하는 방식을 말한다.

장점

  • 의존성을 선택적으로 주입할 수 있고, 런타임 중에 변경이 가능하다.
  • 테스트 시 일부만 주입이 가능하다.
  • 생성자에서 순환 참조가 생기는 경우, 세터 주입으로 순환 참조 해결에 도움이 될 수 있다.

단점

  • 필수 의존성 누락 가능성이 있다.
    • 실수로 Setter 호출을 빠뜨리면 NullPointerException 발생할 수 있다.
    • 주입을 안 해도 컴파일 에러가 없다.
  • 불변성(Immutable) 보장이 안 된다.
    • Setter가 public이면 외부에서 언제든지 의존성을 바꿀 수 있다.
  • 객체가 완전히 초기화되지 않은 상태로 사용될 수 있다.

세터 주입은 유연하지만 덜 안전한 방식이다.


필드 주입(Field-based Dependency Injection)

public class OrderService {

    @Autowired
    private PaymentService paymentService;

    public void processOrder() {
        paymentService.pay();
    }
}

필드 주입은 클래스의 멤버 변수(필드)에 직접 의존성을 주입하는 방식을 말한다. 보통 Spring에서는 @Autowired 어노테이션을 사용해서 주입한다.

장점

  • 코드가 가장 간단하고 짧다.
  • Spring이 자동으로 처리해주기 때문에 편하다.

단점

  • 의존 관계가 외부에서 보이지 않는다.
    • 생성자/세터 없이 내부에서 자동으로 주입되므로, 어떤 의존성이 필요한지 코드만 보고 알기 어렵다.
  • 테스트하기 어렵다
    • 필드가 private이기 때문에, 테스트에서 직접 의존 객체를 주입하려면 리플렉션을 써야 한다.
  • 불변성(Immutable) 보장이 되지 않는다.
    • 언제든지 변경 가능하며, null로 설정될 수도 있다.
  • 순환 참조 문제 발생 시 디버깅이 어렵다.
    • 순환 참조가 생겨도 명확한 예외가 발생하지 않아 문제를 찾기 힘들 수 있다.
  • 순수 자바 환경에서는 필드 주입 방식으로 객체를 만들 수 없다.

필드 주입은 간편하지만 테스트, 유지보수, 안정성 측면에서 불리한 방식이다.


참고

댓글남기기