Unknownpgr

Dependency Inversion

2023-04-07 14:12:02 | Korean

이번에 '클린 아키텍처' 책을 읽었습니다. 그러면서 의존성 역전이 왜 중요하고 어떤 목적을 가지는지 깨닫게 됐습니다. 그래서 이를 간단히 정리해두려고 합니다.

의존성 역전(Dependency Inversion)이란?

의존성 역전은 소프트웨어 설계 원칙 중 하나이며 다음과 같이 정의됩니다.

상위 수준의 컴포넌트는 하위 수준의 컴포넌트에 의존하면 안된다.

먼저 이 글에서 컴포넌트라는 말은 한 가지 기능을 수행하는 클래스나 함수들의 모임을 의미합니다. 즉, 모듈이나 패키지와 상통하는 의미로 사용하겠습니다.

상위 수준의 컴포넌트는 순수 논리, 비즈니스 로직에 가까운 컴포넌트를 말하며 하위 수준의 컴포넌트란 구체적인 기술, 예컨대 데이터베이스, 파일 시스템, 웹 서버 등을 말합니다. 의존이란 말 그대로 코드 상에서 그 컴포넌트를 참조하는 것을 말합니다.

이러한 원칙이 '의존성 역전'이라 불리는 이유는 보통 상위 컴포넌트가 하위 컴포넌트를 사용하므로 제어 흐름은 상위 컴포넌트에서 하위 컴포넌트로 향하는 반면 의존성은 반대로 하위 컴포넌트에서 상위 컴포넌트로 향하기 때문입니다.

여기서 중요한 것은 '제어 흐름'과 '의존성' 이 서로 같은 방향을 가리키는 것이 자연스럽지만 오히려 의존성이 제어 흐름과 반대로 되어야 한다는 것입니다.

의존성 역전의 목적

의존성 역전이 필요한 가장 중요한 이유는 코드의 유지보수성을 높이기 위함입니다.

의존성 역전이 코드의 유지보수성을 높이는 이유는 간단합니다.

  1. 변경이 쉬운 / 빈번한 컴포넌트에 변경하기 어려운 컴포넌트가 의존하는 경우
  2. 변경하기 쉬운 컴포넌트의 수정이 어려운 컴포넌트의 수정을 수반하므로
  3. 대단히 작업량이 많아지기 때문입니다.

특히 어떤 컴포넌트가 변경이 어려운 이유는 보통 다른 많은 컴포넌트들이 거기에 의존하기 때문입니다. 그렇기 때문에 작은 업데이트를 위해 코드베이스 전체를 수정하게 될 수도 있습니다.

예를 들어 어떤 시스템에서 유저에게 알림을 보내는 것이 대단히 중심적이고 이곳저곳에서 자주 사용되는 기능이라고 가정해보겠습니다. 이 시스템은 문자 메시지를 사용하므로 개발자는 SmsSender라는 클래스를 만들어 기능을 구현했습니다. 이 기능은 수백 군데에서 사용되었습니다.

그런데 갑자기 회사 대표가 알림 시스템에 메일을 추가하기로 결정했습니다.

개발자: 어...

이 경우 '알림 보내기' 라는 변경이 어렵고 (많은 곳에서 사용되므로) 논리적인, 즉 고수준의 기능이 '문자 메시지 보내기' 라는 변경하기 쉽고 저수준인 기능에 의존하였으므로 이러한 문제가 발생했습니다.

만약 알림 보내기 기능은 문자 메시지와 완전히 독립적으로 존재하고 반대로 문자 메시지를 보내는 기능이 알림 기능에 의존하였다면 이메일을 보내는 가능을 추가하거나 문자 메시지를 보내는 기능을 이메일까지 함께 보내도록 업데이트만 하면 되었을 것입니다.

의존성 역전의 구현

의존성 역전을 구현하는 다양한 방법이 있습니다. 객체지향 프로그래밍에서는 인터페이스를 사용하여 이를 구현할 수 있습니다. 이에 앞서, 먼저 기존의 구조를 분석해보겠습니다.

아래 다이어그램에서 의존성은 점선, 제어 흐름은 실선으로 표시하였습니다.

위 예시의 경우, 비즈니스 로직을 담당하는 컴포넌트에서 직접 SmsSender를 참조하고 있습니다. 그러므로 제어 흐름과 의존성이 같은 방향을 향합니다. 그러나 의존성 역전을 적용하면 오히려 SmsSender에서 비즈니스 로직 컴포넌트를 참조해야 합니다.

이를 위해서

이렇게 하면 비즈니스 로직 컴포넌트는 자기 자신에게 포함된 AlertSender만을 참조하므로 외부 의존성이 없습니다. 그러나 반대로 SmsSender는 비즈니스 로직 컴포넌트에 의존성을 가집니다. 그러므로 제어 흐름은 비즈니스 로직 컴포넌트에서 SmsSender쪽으로 향하지만 의존성은 반대로 SmsSender에서 비즈니스 로직 컴포넌트로 향합니다.

이 경우 메일을 사용하는 새로운 알림 방식을 아래와 같이 구현할 수 있습니다. 이중 어떤 방법을 선택하든 비즈니스 로직에는 전혀 변함이 없습니다.

물론 아래와 같이 두 가지 방법을 모두 적용하도록 알림을 보내는 컴포넌트를 수정할 수 있습니다. 그러나 어떻게 되었든 그 수정은 비즈니스 로직에 전혀 영향을 미치지 않습니다.

이로부터 자연스럽게 Open-Closed 원칙도 달성됩니다.

의존성 주입 (Dependency Injection)

이 경우 한 가지 문제가 생깁니다. 비즈니스 로직 컴포넌트는 어떤 식으로든 SmsSender의 객체를 받아야 합니다. 그런데 비즈니스 로직 컴포넌트에서는 SmsSender에 대한 의존성이 전혀 없으므로 이를 생성할 수 없습니다. 따라서 외부에서 SmsSender 객체를 생성한 후 비즈니스 로직 컴포넌트에 주입해주어야 합니다. 이를 의존성 주입이라 합니다.

의존성 주입을 하는 가장 간단한 방법은 그냥 메인 메소드에서 SmsSender의 객체를 실제로 생성하고 비즈니스 로직 컴포넌트에 주입하는 것입니다. BusinessLogic 컴포넌트는 이를 setter 함수 혹은 생성자 파라매터로 주입받을 수 있습니다.

그러므로 메인 메소드는 모든 클래스에 의존성을 가지는 가장 '더러운' 메소드가 됩니다. 그러나 메인 메소드에 의존성을 가지는 그 어떤 메소드, 클래스도 존재하지 않으므로 메인 메소드는 대단히 수정하기 쉽습니다. 따라서 메인 메소드에 의존성을 전부 몰아주어도 문제가 되지 않습니다.

그러나 컴포넌트가 많아지면 이를 직접 수행하기가 번거로울 수 있습니다. 그래서 이 작업을 자동화해주는 것이 Spring FrameworkTypeDi같은 프레임워크입니다.

Spring framework에서 Auto wired annotation을 쓰게 되면 코드가 스프링 프레임워크 자체(injector)와 강하게 결합하여 스프링 프레임워크 없이 클래스의 사용이 불가능합니다. 이로부터 독립적인 테스트 실행 등이 어려워지므로 사용을 지양해야 합니다. 특히 private field에 auto wired annotation을 사용하면 이는 Spring framework없이는 사용이 불가능하게 되며 겉으로 드러나지 않는 숨겨진 의존성을 만들게 됩니다.

DI가 필요없는 의존 관계

당연히 모든 의존 관계에서 제어 흐름과 의존성이 반대여야 하는 것은 아닙니다. 제어 흐름과 의존성이 같아도 전혀 문제가 없는 부분이 있는데, 당연하게도 하위 컴포넌트가 원래부터 상위 컴포넌트를 의존하는 경우입니다. 이는 인터럽트 핸들러, main 함수, 웹 서비스의 entrypoint를 포함한 제어 진입점과 주 서비스 로직 사이에 있는 컴포넌트들에게 해당됩니다. 이러한 컴포넌트들은 제어 흐름 자체가 하위 컴포넌트에서 상위 컴포넌트로 향하므로 그 방향을 바꾸면 안 됩니다.

결론


- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -