개발에서 의존성의 중요성: 왜 알아야 하고, 어떻게 관리할까?

ClydeHan·2024년 9월 1일
4

의존성과 의존성 주입

이미지 출처: dev.to/elabftw

📌 의존성(Dependency)이란 무엇인가?

의존성(Dependency)은 소프트웨어 개발에서 중요한 개념으로, 한 요소(예: 모듈, 클래스, 함수)가 다른 요소에 의존하여 동작하는 상황을 의미한다. 쉽게 말해, 어떤 코드가 제대로 동작하기 위해 다른 코드나 라이브러리에 기대는 상황을 의존성이라고 한다. 예를 들어, 사용자 입력을 받아 처리하는 기능이 있다고 가정할 때, 이 기능이 데이터를 서버에 전송하거나 API를 호출해야 한다면, 이 코드는 API에 의존성이 있다고 할 수 있다.


📌 의존성의 종류

💡 코드 간 의존성

  • 모듈 간 의존성 자바스크립트로 웹 애플리케이션을 개발할 때, 다양한 기능을 모듈로 분리하여 작성하는 것이 일반적이다. 예를 들어, 데이터 처리 모듈이 다른 모듈의 기능(예: 유틸리티 함수)을 사용해야 한다면, 데이터 처리 모듈은 해당 유틸리티 모듈에 의존하게 된다. 이런 의존성은 모듈 간 결합도를 높일 수 있다.
  • 컴포넌트 간 의존성 리액트(React)와 같은 프레임워크를 사용할 때, 컴포넌트 간의 의존성은 매우 흔하다. 예를 들어, 부모 컴포넌트가 자식 컴포넌트에게 데이터를 전달하거나, 자식 컴포넌트가 부모 컴포넌트의 메서드를 호출해야 할 때 의존성이 생긴다. 이러한 의존성은 컴포넌트 간의 데이터 흐름과 이벤트 처리를 복잡하게 만들 수 있다.

💡 외부 라이브러리 및 패키지 의존성

  • 라이브러리 의존성 프론트엔드 개발에서, 프로젝트에 다양한 외부 라이브러리(예: jQuery, Lodash, Axios 등)를 사용하는 경우가 많다. 이러한 라이브러리에 의존성이 생기면, 라이브러리 버전에 따라 프로젝트의 동작이 영향을 받을 수 있다. 예를 들어, jQuery의 특정 버전에 의존하는 코드가 있을 경우, jQuery 버전이 변경되면 해당 코드가 제대로 작동하지 않을 수 있다.
  • 패키지 의존성 자바스크립트 생태계에서는 npm과 같은 패키지 매니저를 사용하여 외부 패키지를 설치하고 관리한다. 이 과정에서 발생하는 의존성 문제를 패키지 의존성이라고 한다. 예를 들어, 프로젝트가 A 패키지와 B 패키지를 사용하고, 이들 패키지가 서로 다른 버전의 C 패키지에 의존할 때, 패키지 간 충돌이 발생할 수 있다. 이를 의존성 지옥(Dependency Hell)이라고 하며, 이런 경우 충돌을 해결하기 위해 다양한 노력이 필요하다.

📌 의존성의 문제점

💡 의존성 지옥(Dependency Hell)

의존성 지옥은 여러 패키지나 라이브러리가 서로 다른 버전의 동일한 라이브러리에 의존할 때 발생하는 문제다. 예를 들어, A 패키지가 C 라이브러리의 1.0 버전에 의존하고, B 패키지가 C 라이브러리의 2.0 버전에 의존한다면, 이 두 버전 간의 충돌로 인해 프로젝트에서 예기치 않은 문제가 발생할 수 있다. 이러한 상황을 해결하기 위해 개발자는 종종 특정 버전의 라이브러리를 선택하거나, 별도의 폴리필(polyfill)을 사용하는 등의 방법을 고려해야 한다.

  • 폴리필(polyfill): 기본적으로 지원하지 않는 이전 브라우저에서 최신 기능을 제공하는 데 필요한 코드. 즉, 브라우저 호환성 문제를 해결하기 위해 일종의 코드 조각을 추가하는 것.

💡 높은 결합도(Coupling)

모듈 간 의존성이 많아지면 코드의 결합도가 높아진다. 결합도는 한 모듈이 다른 모듈과 얼마나 밀접하게 연결되어 있는지를 나타내는 지표다. 결합도가 높으면 한 모듈에서 발생한 변화가 다른 모듈에 광범위하게 영향을 미칠 수 있다. 예를 들어, 데이터 처리 모듈이 로깅 모듈에 강하게 결합되어 있다면, 로깅 모듈의 변경이 데이터 처리 모듈에 예기치 않은 영향을 줄 수 있다. 이런 상황은 유지보수를 어렵게 만든다.


💡 테스트 어려움

의존성이 많은 코드에서는 단위 테스트(Unit Test)를 수행하기가 어려워진다. 단위 테스트는 특정 모듈이나 컴포넌트의 동작을 독립적으로 검증하는 것이 목표인데, 의존성이 많은 경우에는 다른 모듈이나 컴포넌트가 함께 동작해야 하므로 테스트가 복잡해진다. 이를 해결하기 위해 모의 객체(Mock Object)를 사용하여 테스트 대상 외의 의존성을 가상으로 대체하는 방법이 있다. 예를 들어, API 호출을 테스트할 때 실제 네트워크 요청을 보내는 대신, 네트워크 요청을 모의 객체로 대체하여 테스트할 수 있다.


📌 의존성 주입(Dependency Injection)

💡 의존성 주입의 기본 개념

의존성 주입(Dependency Injection, DI)IoC(Inversion of Control)를 구현하기 위한 디자인 패턴이다. 즉, 소프트웨어 디자인 패턴 중 하나이다. 이 패턴은 프로그램의 한 객체가 다른 객체와 협력하기 위해 직접 그 객체를 생성하거나 찾아서 사용하는 대신, 외부에서 필요한 객체(의존성)를 주입해 주는 방식을 말한다. 이 방법은 프로그램을 더 유연하고 테스트하기 쉽게 만든다.

의존성 주입의 기본 아이디어는 객체가 직접 필요한 의존성을 생성하지 않고, 외부에서 전달받는다는 것이다. 이를 통해 객체는 의존하는 구체적인 구현체에 대해 알 필요가 없으며, 이로 인해 객체 간 결합도가 낮아진다.

예를 들어, 다음은 의존성 주입을 사용하지 않은 코드다.

class Car {
  constructor() {
    this.engine = new Engine(); // 직접 객체를 생성
  }

  start() {
    this.engine.start();
  }
}

const myCar = new Car();
myCar.start();

위 코드에서 Car 클래스는 Engine 클래스에 강하게 결합되어 있다. Car 클래스 내부에서 Engine 객체를 직접 생성하기 때문에, Engine의 구현이 변경되면 Car 클래스도 변경되어야 한다.

의존성 주입을 사용하면 다음과 같이 코드를 수정할 수 있다.

class Car {
  constructor(engine) {
    this.engine = engine; // 외부에서 주입
  }

  start() {
    this.engine.start();
  }
}

const myEngine = new Engine();
const myCar = new Car(myEngine); // 의존성 주입
myCar.start();

이제 Car 클래스는 Engine의 구체적인 구현에 의존하지 않는다. Engine 객체는 외부에서 생성되어 Car에 주입되며, 이로 인해 Engine의 구현이 변경되어도 Car 클래스를 수정할 필요가 없다.


💡 의존성 주입의 필요성

기본적으로 객체가 필요한 의존성을 직접 생성하거나 관리하게 되면, 그 객체는 특정 클래스에 강하게 결합되게 된다. 이는 프로그램의 유연성을 떨어뜨리고, 유지보수와 테스트를 어렵게 만든다. 예를 들어, 클래스 A가 클래스 B를 직접 생성한다면, 클래스 B를 다른 클래스로 대체하거나 수정하는 것이 매우 어려워진다. 의존성 주입은 이러한 문제를 해결하기 위해 도입된 개념이다.


💡 의존성 주입의 이점

의존성 주입은 여러 가지 중요한 이점을 제공한다.

  • 결합도 감소: 객체가 다른 객체의 구체적인 구현에 의존하지 않기 때문에, 결합도가 낮아져 코드의 유연성이 증가한다.
  • 유지보수 용이: 결합도가 낮아짐에 따라, 코드 변경 시 수정 범위가 줄어들어 유지보수가 용이해진다.
  • 테스트 용이: 의존성을 주입받기 때문에, 테스트 시에는 실제 객체 대신 모의 객체를 사용하여 테스트할 수 있다. 이는 단위 테스트를 더욱 쉽게 만든다.

💡 의존성 주입의 다양한 방식

의존성 주입은 주입 방법에 따라 여러 가지 방식으로 나눌 수 있다.

  • 생성자 주입(Constructor Injection): 의존성을 생성자 매개변수로 전달받는 방식이다. 앞서 본 예제에서 Car 클래스는 생성자를 통해 Engine 객체를 주입받는다.
  • 세터 주입(Setter Injection): 객체가 생성된 이후에 세터 메서드를 통해 의존성을 주입하는 방법이다. 이 방법은 의존성을 선택적으로 설정할 수 있도록 한다.
    class Car {
        private Engine engine;
    
        // 세터 주입
        public void setEngine(Engine engine) {
            this.engine = engine;
        }
    
        public void drive() {
            engine.start();
        }
    }
  • 인터페이스 주입(Interface Injection): 의존성을 주입할 수 있는 인터페이스를 정의하고, 그 인터페이스를 통해 의존성을 주입받는 방법이다. 이 방법은 덜 일반적이며, 주로 특정 프레임워크에서 사용된다.
    interface EngineInjector {
        void injectEngine(Car car);
    }
    
    class Car implements EngineInjector {
        private Engine engine;
    
        @Override
        public void injectEngine(Engine engine) {
            this.engine = engine;
        }
    
        public void drive() {
            engine.start();
        }
    }

💡 가장 선호되는 방식

의존성 주입(Dependency Injection) 방식 중에서 가장 선호되는 방식은 생성자 주입(Constructor Injection)이다. 이 방식이 선호되는 이유는 다음과 같다.

  • 불변성(Immutable)을 보장 생성자 주입은 객체가 생성될 때 의존성을 모두 설정하기 때문에, 이후에 의존성이 변경될 수 없다. 이는 객체의 상태가 불변성을 가지게 되어 예기치 않은 변경이나 사이드 이펙트를 방지할 수 있다.
  • 강제성(Required Dependencies) 생성자 주입을 사용하면 의존성을 반드시 설정하도록 강제할 수 있다. 객체가 생성될 때 의존성이 반드시 필요하다는 것을 명확히 드러내며, 의존성이 없이는 객체를 생성할 수 없도록 하여, 프로그램의 안정성을 높인다.
  • 단순한 코드 구조 생성자 주입은 의존성을 명확하게 전달받기 때문에 코드의 가독성이 높아진다. 객체의 생성과 동시에 의존성이 설정되기 때문에, 어떤 의존성이 필요한지 파악하기 쉽고, 코드의 구조가 단순해진다.
  • 테스트 용이성 생성자 주입은 테스트 코드 작성 시 모의 객체(Mock Object)를 쉽게 주입할 수 있다. 이로 인해 객체의 동작을 독립적으로 테스트하기가 용이하다.
  • 순환 의존성 방지 생성자 주입을 사용하면 순환 의존성(Circular Dependency)을 쉽게 감지할 수 있다. 만약 두 객체가 서로를 필요로 하는 경우, 컴파일 단계에서 이러한 문제가 발생하므로 개발자가 쉽게 인지하고 수정할 수 있다.
  • 의존성 명시화 생성자 주입은 객체가 어떤 의존성을 필요로 하는지 명확하게 드러낸다. 이는 코드를 읽는 사람에게 객체의 의존 관계를 명확하게 전달하며, 의존성 관리가 수월해진다.
  • 일관성 객체가 생성될 때 모든 의존성이 초기화되므로, 객체의 상태가 일관성을 유지할 수 있다. 이는 프로그램의 예측 가능성을 높이고, 오류 발생 가능성을 줄인다.

📌 제어의 역전(Inversion of Control, IoC)

💡 제어의 역전(Inversion of Control, IoC)

제어의 역전(Inversion of Control, IoC)은 객체 지향 프로그래밍에서 프로그램의 제어 흐름을 전통적인 방식에서 벗어나게 하는 설계 원칙이다. 일반적으로 객체는 자신이 필요로 하는 의존성을 직접 생성하거나 관리하지만, IoC를 적용하면 이러한 제어권을 외부로 넘겨서 프레임워크나 외부 코드를 통해 관리하도록 한다. 이로 인해, 객체가 스스로의 동작을 완전히 통제하는 대신, 외부에 의존하여 동작을 제어하게 된다.


💡 의존성 주입(Dependency Injection, DI)과 제어의 역전(Inversion of Control, IoC)

의존성 주입(Dependency Injection)은 제어의 역전을 구현하는 구체적인 방법 중 하나이다. 의존성 주입은 객체가 자신의 의존성을 스스로 생성하지 않고, 외부에서 주입받는 방식으로 IoC를 구현한다. 예를 들어, 클래스 A가 클래스 B의 기능을 필요로 할 때, A가 B를 직접 생성하는 것이 아니라, 외부에서 B를 주입받는다.

이러한 외부 주입은 코드의 결합도를 낮추고, 테스트와 유지보수성을 높인다. IoC는 객체의 생성을 제어하는 흐름을 외부로 넘기는 큰 틀을 제공하고, DI는 이를 실현하는 방식이다.


// 예시: 의존성 주입 없이 직접 객체 생성
class Service {
    private Repository repository = new Repository();  // 직접 객체 생성
}

이 방식은 Service 클래스가 Repository에 강하게 결합되어 있어 테스트나 유지보수에 어려움이 있다. 반면 DI를 사용하면 객체의 생성이 외부에서 관리되므로 유연성이 높아진다.


// 예시: 의존성 주입을 사용한 객체 생성
class Service {
    private Repository repository;

    // 외부에서 Repository 객체를 주입받음
    public Service(Repository repository) {
        this.repository = repository;
    }
}

💡 외부에서 주입받는 방식으로 IoC를 구현하는 방법

의존성 주입은 크게 세 가지 방식으로 구현할 수 있다.

  1. 생성자 주입: 객체의 생성자에서 필요한 의존성을 주입받는다.

    
    class Service {
        private Repository repository;
    
        public Service(Repository repository) {  // 생성자에서 주입
            this.repository = repository;
        }
    }
    
  2. 세터(setter) 주입: 세터 메서드를 통해 의존성을 주입받는다.

    
    class Service {
        private Repository repository;
    
        public void setRepository(Repository repository) {  // 세터에서 주입
            this.repository = repository;
        }
    }
    
  3. 필드 주입: 프레임워크에 의해 필드에 직접 주입되는 방식이다. Spring에서는 @Autowired를 사용한다.

    
    class Service {
        @Autowired
        private Repository repository;  // 필드에 주입
    }
    

💡 의존성 주입과 IoC 컨테이너

IoC 컨테이너는 의존성 주입을 관리하는 프레임워크 또는 라이브러리를 말한다. IoC 컨테이너는 객체의 생명주기와 의존성을 관리하며, 필요한 의존성을 객체에 주입해준다. Spring Framework의 IoC 컨테이너가 대표적인 예이다.

IoC 컨테이너는 다음과 같은 역할을 한다.

  • 객체 생성: 필요한 객체를 생성한다.
  • 의존성 관리: 객체 간의 의존성을 주입해준다.
  • 객체 생명주기 관리: 객체의 생성, 초기화, 소멸 등 생명주기를 관리한다.

Spring Framework에서 IoC 컨테이너는 @Component, @Autowired, @Bean 등의 어노테이션을 통해 객체를 자동으로 생성하고 의존성을 주입하는 역할을 한다.

🤔 어노테이션(annotation)은 자바와 같은 프로그래밍 언어에서 메타데이터(metadata)를 코드에 추가하는 방법이다. 메타데이터란, 코드 자체에 대한 정보를 설명하는 데이터로, 컴파일러에게 특정한 지시사항을 전달하거나 런타임에 특정 동작을 수행하게 할 수 있다.


참고문헌

0개의 댓글