티스토리 뷰

IoC? DI?

Spring 프레임워크를 직접적으로 사용하지 않더라도, 개발을 하다보면 IoC(Inversion of Control), 혹은 DI(Dependency Injection)라는 용어에 대해서 듣게 되는 경우가 많습니다.
Spring에서만 사용하는 개념들이 아니기 때문인데요.
그때마다 IoC나 DI가 궁금해서 용어에 대한 정의를 보며 공부를 해도 이 개념들이 정확하게 무엇인지, 왜 필요한지에 대해 처음에는 크게 와닿지 않는 것이 사실입니다.

이번 포스팅에서는 간단한 자바 예제를 통해 Spring의 IoC와 DI에 대해서 이해해보는 시간을 가져보도록 하겠습니다.


커피 한 잔

카페에서 손님이 커피를

강릉 여행가서 마신 커피입니다 :)

커피를 전문적으로 취급하는 어떤 카페가 있습니다.
이 곳 카페의 바리스타는 본인이 만드는 커피에 높은 자부심을 가지고 있는데요.
이 카페에서 판매하는 대표 메뉴는 바리스타의 자부심이 한껏 들어간, 좋은 원두로 만든 아메리카노입니다.

public class Americano {

    private String name = "아메리카노";
    private int price = 4000;

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}

바리스타가 취급하는 커피의 질이 좋아서 꽤 많은 손님들이 카페에 방문합니다.

카페에 방문하는 손님은 주로 다음과 같은 행동을 합니다.

public class Customer {

    private Americano americano;

    public Customer() {
        americano = new Americano();
    }

    public void drink() {
        System.out.println(americano.getName() + "을(를) 마십니다.");
    }

    public void pay() {
        System.out.println(americano.getPrice() + "원을 결제합니다.");
    }
}

바로 커피를 마시는 일과, 마신 커피에 대해 돈을 지불하는 일입니다.

아메리카노를 다 마신 손님이 아메리카노의 가격 4000원을 지불하고 가게를 떠납니다.

public class Cafe {

    public static void main(String[] args) {
        Customer customer = new Customer();

        customer.drink(); // 아메리카노을(를) 마십니다.
        customer.pay(); // 4000원을 결제합니다.
    }
}

아메리카노 밖에 못 마시나?

그런데 손님인 Customer 코드는 무엇인가 이상합니다.
손님이 마시는 커피의 종류를 클래스의 field에서 Americano로 한정하고 있네요. 커피를 마실 때도, 계산할 때도 현재의 코드를 기반으로 한다면 항상 아메리카노에 의존적인 로직일 수 밖에 없습니다.
만약에 손님이 마시는 커피의 종류가 바뀌면, field의 내용과 각 메소드의 내용들을 전부 수정해야만 합니다.

따라서 다음과 같이 간단하게 이름과 가격을 얻을 수 있는 메소드 명세를 정의하는 인터페이스 Coffee를 만들고, Americano에도 적용해 보겠습니다.

public interface Coffee {

    String getName();

    int getPrice();
}
public class Americano implements Coffee {

    private String name = "아메리카노";
    private int price = 4000;

    @Override
    public String getName() {
        return name;
    }

    @Override
    public int getPrice() {
        return price;
    }
}

그리고 Customer에서 해당 인터페이스 Coffee의 메소드를 사용하여 코드를 리팩토링 해보겠습니다.

public class Customer {

    private Coffee coffee;

    public Customer() {
        coffee = new Americano();
    }

    public void drink() {
        System.out.println(coffee.getName() + "을(를) 마십니다.");
    }

    public void pay() {
        System.out.println(coffee.getPrice() + "원을 결제합니다.");
    }
}

이제 Customer가 마시는 음료의 종류가 바뀌면, 일일히 모든 부분을 수정할 필요 없이 Customer의 생성자에 있는 Americano만 바꿔주면 됩니다.
인터페이스 Coffee의 구현체만 변경해주면 되는 것이죠.
덕분에 유지보수가 좀 더 수월한 코드가 되었습니다.

아직도 아메리카노 밖에 못 마시나?

그런데 위에 작성한 코드는 아직도 뭔가 좀 이상합니다.
손님이 마시고 싶은 음료를 변경해야 할 때, 여전히 Customer의 생성자에서 Coffee 인터페이스의 구현체인 Americano를 직접적으로 바꾸어주어야 합니다.
요구사항이 바뀌면 코드를 변경할 수 밖에 없다는 문제가 여전히 남아있었습니다. 비록 인터페이스로 추상화를 시킨 덕에 변경해야 할 부분이 한 군데로 줄었지만요.

사실 곰곰히 생각해보면 new Americano(); 라는 코드 조각은 Customer에서 가질 책임이 아닙니다.
쉽게 말해 어떤 음료를 마셔야 할지가 처음부터 정해져 버리는 코드입니다.
Customer 입장에서 내가 어떤 음료를 마실 것인지 인터페이스의 구현체를 미리 알고 있는 상황인 것이죠.
Customer와 Americano가 코드 레벨에서 직접적으로 서로를 알게 되는데, 이와 같은 두 클래스의 상황을 클래스 사이의 관계가 있다고 표현합니다.

Customer의 코드가 Americano를 모르는 상황에서, Americano의 인터페이스인 Coffee에 정의된 기능들만 사용할 수 있다면 매우 바람직한 상황일 것입니다.
어떻게 보면 손님 입장에서는 아메리카노를 마실지, 카페라떼를 마실지보다 무언가 마시면서 사람들하고 이야기를 하거나, 개인적인 할 일을 하는 것이 더 중요할 수도 있는 것이죠.
손님에게 아메리카노 인스턴스를 생성할 책임이 있는 것은 아닙니다.

따라서 다음과 같이 코드를 리팩토링 해보겠습니다.
Customer의 생성자에서 Coffee 인터페이스를 받아서 사용하도록 합니다.

public class Customer {

    private Coffee coffee;

    public Customer(Coffee coffee) {
        this.coffee = coffee;
    }

    public void drink() {
        System.out.println(coffee.getName() + "을(를) 마십니다.");
    }

    public void pay() {
        System.out.println(coffee.getPrice() + "원을 결제합니다.");
    }
}

Customer는 이 인터페이스의 구현체가 어떤 생김새인지, 언제 생성되었는지를 알 수가 없습니다.
아니, 애초에 관심이 없습니다.
어떤 Coffee가 오든 그 Coffee를 구현한 구현체의 기능만 사용할 수 있으면 되는 것이니까요.

조금 전에 정의한 클래스 사이의 관계와 달리, 이를 런타임 시에 오브젝트 사이의 관계를 갖는다고 표현합니다.
Customer의 코드 레벨에서는 어떤 구현체가 오는지 전혀 알 수가 없고, 런타임 시점에 되어서야 동적으로 어떤 구현체가 들어오는지가 결정됩니다.
Customer 오브젝트와 Coffee 구현체 오브젝트(Americano)의 관계가 맺어지는 것이죠.

그리고 조금 뜬금없지만 Customer에게 Coffee의 구현체를 공급해주고, 외부로 그 Customer를 만들어서 내보내주는 어떤 클래스를 하나 새로 만들어 보겠습니다.
이름은 CustomerFactory라고 해볼까요.

public class CustomerFactory {

    public Customer customerWithAmericano() {
        return new Customer(new Americano());
    }

    public Customer customerWithCafeLatte() {
        return new Customer(new CafeLatte());
    }
}

이 CustomerFactory라는 친구는 기존에 Customer가 가지고 있던 Coffee의 구현체를 생성하는 책임을 담당하면서, Customer에게 필요한 해당 구현체를 공급해주는 역할을 가지고 있습니다.

실제로 이들을 사용하게 되는 main 메소드가 있는 Cafe 클래스를 보면 다음과 같습니다.

public class Cafe {

    public static void main(String[] args) {
        CustomerFactory customerFactory = new CustomerFactory();
        Customer customer = customerFactory.customerWithAmericano();

        customer.drink(); // 아메리카노을(를) 마십니다.
        customer.pay(); // 4000원을 결제합니다.
    }
}

직접 Customer를 생성하던 기존의 로직과 달리, Customer의 생성을 담당하는 CustomerFactory를 통해 customer를 받은 뒤에, 필요한 작업들을 수행하고 있네요.


IoC와 DI

관심사의 분리로 바라본 리팩토링 과정

관심사의 분리라는 개념이 있습니다.
관심이 같은 것끼리는 하나의 객체 혹은 모듈 안으로 모으고, 관심이 다른 것은 가능한 한 따로 분리되어 서로 적은 영향만을 주도록 해야한다는 프로그래밍의 개념 중 하나입니다.

처음에는 직접적인 구현체인 Americano를 알고있던 모든 사용처에 인터페이스라는 도구를 사용함으로써 사용하고자 하는 기능에 대한 관심과, 그 기능을 구현한 구현체에 대한 관심을 분리했습니다.
그 다음 단계로는 인터페이스 Coffee의 구현체를 결정하는 관심사를 Customer의 생성자에서 담당하는 것이 아니라 외부로 분리하여 인터페이스를 주입을 받는 형태로 진화시켰습니다.
다르게 말하면, new Americano()라는 짧은 코드 조각에 오브젝트의 생성이라는 관심사가 있다고 판단하고, Customer에서 가질 관심사는 아니라고 생각해 외부로 분리해준 것이죠.

IoC, 제어의 역전

제어의 역전이라는 것은 바로 이런 상황을 말합니다.
역전이라는 단어는 어떤 흐름의 순방향이 존재하고, 그를 뒤집어 놓았다는 것을 의미합니다.
역방향을 알아보기 전에, 제어 흐름의 원래 방향인 순방향은 어떤 상황을 의미할까요?

일반적인 프로그램의 흐름은 모든 오브젝트가 능동적으로 자신이 사용할 클래스를 생성하고, 사용하고, 폐기하는 과정을 담당하는 것입니다.
한 마디로 자신이 사용할 오브젝트의 생명주기를 자기 자신이 담당하는 것입니다.

제어의 역전은 이런 관점이 뒤집어져서, 이제는 더 이상 자신이 사용할 오브젝트의 생명주기를 자기 자신이 담당하지 않는 것을 의미합니다.
자기 자신이 가지고 있었던 오브젝트의 생명 주기에 대한 제어 권한을 외부로 넘겨주는 것이죠.
Customer는 인터페이스 Coffee가 실제로 어떤 구현체인지, 언제 생성되었는지는 알 수도 없고 관심도 없습니다.
심지어 자기 자신도 언제 생성되었는지 알 수 없습니다.
사용되는 오브젝트도, 그를 사용하는 오브젝트도 제 3의 주체가 생성하고 둘의 관계를 맺어주게 됩니다. CustomerFactory가 그 역할을 하고 있네요.

스프링에서는 스프링이 제어권을 가지고 생성 및 관계를 부여하는 오브젝트를 빈(Bean) 이라고 부르고, 빈들의 생성과 관계설정을 담당하는 IoC 오브젝트를 빈 팩토리 혹은 애플리케이션 컨텍스트라 부릅니다.
빈을 생성하고 제어하는 관점에서 정의하면 빈 팩토리라는 용어를 사용하고, 좀 더 크고 넓은 관점에서 애플리케이션 전체를 관리한다는 의미로 정의하면 애플리케이션 컨텍스트라는 용어를 사용합니다.
위 예제로 보자면 Customer, Americano 등을 빈이라 할 수 있고, 그 둘을 생성하고 관계설정해주는 CustomerFactory가 빈 팩토리, 애플리케이션 컨텍스트의 역할을 하고 있다고 볼 수 있겠네요.

DI, 의존관계 주입

IoC라는 개념이 꽤 광범위하고 추상적인 개념이다 보니, 스프링 진영에서는 스프링이 제공하는 IoC의 방식을 특정지어 부르기 위해 의존관계 주입이라는 용어를 사용하기 시작했습니다.

의존 관계란 무엇일까요? 클래스 혹은 모듈 A가 B에 의존한다는 말은, 방향성을 내포하고 있습니다.
의존한다는 말은 B가 변하면 그것이 A에 영향을 미친다는 것을 의미합니다.
반대로 B는 A를 모르기 때문에, 다시 말해 의존하고 있지 않기 때문에, A가 변하더라도 B에는 영향을 미치지 않습니다. 방향성이 있는 개념인 것이죠.

리팩토링하기 전의 Customer는 Americano와 강한 의존 관계를 가지고 있었습니다.
Americano가 변경되면 Customer도 꼼짝없이 영항을 받아야 하는 구조였습니다.
그러나 Coffee라는 인터페이스를 사용해서, Customer와 Americano의 의존관계를 느슨하게 만들어주니, 오브젝트 간에 영향을 덜 받는 구조로 변경할 수 있었습니다.
인터페이스를 통해 의존관계를 제한해 변경에서 자유로울 수 있도록 해준 것입니다.
그리고 런타임 시점에 실제 구현체를 Customer에게 주입해 줌으로써 의존관계를 동적으로 부여해줄 수 있었습니다.

정리하면, 의존관계 주입은 다음과 같은 조건을 충족하는 작업을 말합니다. (참고 : 토비의 스프링 3.1 Vol.1 /p.114)

  • 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않고, 인터페이스에만 의존관계가 있다.
  • 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제 3의 존재가 결정한다.
  • 의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공(주입)해붐으로써 만들어진다.

정리

이렇게 해서 간단한 예제를 통해 스프링의 IoC와 DI 개념에 대해서 정리해 보았습니다.
스프링은 IoC/DI 프레임워크라 불릴 만큼 많은 곳에서 이미 해당 개념들을 사용하고 있고, 또 개발자가 DI를 활용하여 애플리케이션을 개발할 수 있도록 알맞은 환경도 제공해주고 있습니다.
(DI는 스프링이 있어야만 적용할 수 있는 개념이 아닙니다. 오히려 스프링의 도움이 없이도 DI를 만들어내고 적용할 수 있어야 합니다.)

DI가 필요한 곳에 적절하게 DI를 적용하기 위해서는 그만큼 시야를 기르기 위해 많은 의식적인 훈련이 필요합니다.
무엇보다도 중요한 것은 IoC와 DI가 왜 필요한지에 대해 꾸준히 고민하고 학습하는 자세가 아닐까 싶네요. :)

그런 의미에서 마지막으로 제가 생각하는 IoC/DI의 장점들을 정리하고 글을 마무리하도록 하겠습니다.

  • DI를 받는 오브젝트는 코드 상에서 런타임 클래스에 대한 의존관계가 드러나지 않는다. 결과적으로 결합도가 낮아지기 때문에 사용 의존관계에 있는 구현 클래스가 바뀌더라도 자기 자신은 변경될 필요가 없다. 동시에 다른 시각으로 보면 인터페이스 구현체의 변경을 통한 다양한 확장 방법에는 자유롭다. OCP(Open-Closed Principle)를 지킬 수 있다.
  • DI를 받는 오브젝트 입장에서는 사용하는 오브젝트의 생성 과정이나 생성 시점에는 관심을 가질 필요가 없다. 철저하게 오브젝트의 기능 사용에만 관심사를 둘 수 있다. SRP(Single Responsibility Principle)를 지킬 수 있다.
  • DI는 외부에 내가 어떤 인터페이스(기능)를 필요로 하는지 알릴 수 있는 지표의 역할도 할 수 있다. 생성자의 파라미터에 해당 정보가 나타난다.
  • DI를 사용해 테스트하기 어려운 부분을 외부로 분리하고, 테스트가 필요한 부분에 집중하여 테스트를 진행할 수 있다. 테스트 시 테스트에 필요한 모양으로 인터페이스의 구현체를 결정하고, 내부 로직을 테스트할 수 있다.
  • IoC 컨테이너는 빈들의 생성과 관계설정의 책임을 담당하면서, 동시에 오브젝트의 생성방법, 후처리 작업 등의 필요한 추가 기능들을 일괄적으로 담당해줄 수 있다.
  • IoC를 적용하면 메인 로직은 설계에 좀 더 집중할 수 있는 구조가 된다. 또한 유연성과 확장성이 증가한다.

'Spring' 카테고리의 다른 글

Logback으로 로그 관리하기  (0) 2020.11.14
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday