안정적인 시스템 구축을 위한 의존성 역전과 경계 관리
모듈러 디자인에서 가장 중요한 요소 중 하나는 모듈의 독립성입니다. 모듈의 독립성이 확보되면 운영의 차별화가 가능해지고, 그로 인한 효과 역시 기대할 수 있습니다. 모듈의 독립성은 결국 모듈 간의 인터페이스를 표준화하고 체계적으로 관리하는 데서 나오는데, 소프트웨어 관점에서는 이것을 모듈 간 의존성 관리라고 표현합니다.
의존성을 관리한다는 것은 모듈 간의 불필요한 의존 관계를 제거하거나 최소화하여, 모듈 내부의 응집도를 높이고 독립성을 확보하는 것을 의미합니다. 이번 장에서는 의존성이란 무엇인지, 그리고 그것을 어떻게 효과적으로 관리할 수 있는지를 살펴보겠습니다.
많은 독자가 막연하게 ‘모듈 간 인터페이스 표준화가 중요하다’고 인지하고 있지만, 모듈러 디자인 특히 소프트웨어 설계에서 이 말이 어떤 의미를 갖는지 명확히 이해하기는 쉽지 않습니다. 따라서 모듈러 디자인에서 의존성 관리가 갖는 핵심적인 의의와 실제 방법론을 이번 장에서 구체적으로 다루고자 합니다.
의존성이란 한 모듈이 다른 모듈에 기대어 동작하는 관계를 의미합니다. 모든 소프트웨어 시스템은 일정 수준의 의존성을 가지고 있지만, 그 관계가 지나치게 복잡하거나 양방향으로 얽혀 있다면 유지보수와 확장에 큰 장애가 됩니다.
의존성으로 인해 변경의 파급 효과가 발생하기도 합니다. 강하게 결합된, 즉 강한 의존성을 가진 두 개의 모듈 중 하나를 수정하거나 보완하면, 대체로 다른 모듈도 함께 변경되어야 하는 상황이 발생합니다. 이는 모듈별로 독립적으로 기획, 설계, 운영, 관리한다는 전제를 무너뜨리는 상황입니다.
따라서 의존성을 ‘어떻게 맺고, 어떻게 제어할 것인가’는 모듈러 아키텍처에서 매우 중요한 설계 과제라 할 수 있습니다.
먼저, 의존성의 유형에 대해 살펴보겠습니다. 모듈 간의 인터페이스는 의존성을 표현하는 방식이기 때문에, 인터페이스 타입은 곧 의존성의 유형이기도 합니다. 다만 여기서는 인터페이스 타입, 즉 무엇으로 결합되는가를 살펴보는 대신, 의존성이 발생하는 시점과 원인을 중심으로 유형을 구분하겠습니다.
의존성이 발생하는 시점에 따라 크게 두 가지로 나눌 수 있습니다. 컴파일 시점에 결정되는 정적 의존성과, 런타임에 생성되는 동적 의존성입니다.
정적 의존성은 소프트웨어 설계 과정에서 결정되는 라이브러리, 클래스, 패키지 간의 의존성을 말합니다.
반면에 동적 의존성은 설계 시점에 결정되지 않고, 실행 중 설정값이나 사용자 선택에 따라 생성되는 의존성을 의미합니다. 예를 들어, 소프트웨어 실행 중 입력받은 클래스 이름을 이용해 특정 객체를 생성하고, 해당 객체의 서비스를 활용하는 경우가 이에 해당합니다.
또한, 의존성을 일으키는 원인에 따라 구조적 의존성과 조직적 의존성으로 구분할 수 있습니다.
구조적 의존성은 모듈러 디자인에서 주로 다루는 인터페이스 유형과 동일하며, 물리적 연결 관계, 물질과 힘의 전달, 사용자 인터페이스, 신호 및 전원 전달 등과 같은 물리적, 논리적 인터페이스 기반의 의존성을 뜻합니다. 소프트웨어에서는 모듈 간 상호 호출 관계, 공유 데이터 관계 등이 여기에 포함됩니다.
조직적 의존성은 구조적 의존성을 근간으로 하여 설계 및 구현 주체가 소속된 조직, 협업해야 하는 팀 등에 따라 발생하는 의존성입니다. 이상적인 아키텍처는 조직적 의존성을 구조적 의존성에 반영해 설계되어야 하지만, 현실적으로는 기존 조직 구조에 맞춰 소프트웨어 아키텍처가 구현되는 경우가 많습니다. 조직 구조는 단기간에 변경하기 어렵기 때문에 이를 제약 조건으로 받아들이고, 최대한 구조적 의존성을 신중하게 설계해야 합니다.
마지막으로, 각 의존성 유형은 설계 수준, 구현 수준, 운영 수준에서 각각 다르게 고려되어야 함을 염두에 두어야 합니다.
이전 장에서 모듈러 아키텍처 설계 시 준수해야 할 설계 원칙들을 살펴보았습니다. 그 중, 소프트웨어 의존성 관리를 위해서는 SOLID 원칙 중 하나인 의존성 역전 원칙(Dependency Inversion Principle, DIP)을 반드시 고려해야 합니다.
의존성 역전 원칙은 상위 모듈이 하위 모듈에 직접 의존하지 않고, 대신 둘 다 추상화된 인터페이스에 의존하도록 설계하라는 뜻입니다. 이를 통해 모듈 간 결합도를 낮출 수 있으며, 교체 가능성과 테스트 용이성을 확보할 수 있습니다.
특히 동일한 계층 내 모듈 간 설계에서도, 모듈이 직접 다른 모듈에 의존하지 않고, 추상화된 인터페이스를 통해 의존성을 구현하는 것이 상호 독립성을 확보하는 데 필수적입니다.
실제 많은 개발 프레임워크들은 이 원칙에 따라 인터페이스 기반 설계와 의존성 주입(Dependency Injection, DI)을 통해 구현을 지원합니다. 이는 아키텍처와 구현체의 생성 시점이 서로 다르기 때문에 가능한 설계 방식입니다.
인터페이스 기반 설계와 DI를 통하면 구현체 교체, 모의(Mock) 객체 주입, 테스트 환경 설정 등이 훨씬 수월해집니다. 따라서 의존성 역전 원칙은 모듈러 아키텍처에서 매우 중요한 역할을 합니다.
예를 들어, 온라인 쇼핑몰 시스템에서 주문을 처리하는 모듈과 결제 처리 모듈이 있다고 가정해 보겠습니다.
비적용 시 문제 주문 처리 모듈이 결제 처리 모듈을 직접 참조하여 결제 기능을 호출한다면, 두 모듈은 강하게 결합됩니다. 만약 결제 방식이나 결제 구현을 변경하면 주문 처리 모듈도 함께 수정해야 하므로 유지보수가 어려워집니다.
DIP 적용 시 구조 주문 처리 모듈과 결제 처리 모듈 모두 IPaymentProcessor라는 추상화된 인터페이스에 의존하도록 설계합니다. 이 인터페이스에는 processPayment() 같은 결제 처리 메서드가 정의되어 있습니다.
결제 모듈은 IPaymentProcessor를 구현한 구체 클래스로 존재하고, 주문 처리 모듈은 IPaymentProcessor 인터페이스에만 의존합니다.
작동 방식 런타임 시점에 의존성 주입(Dependency Injection)으로 원하는 결제 처리 구현체 (예: CreditCardPayment, PaypalPayment)를 주문 처리 모듈에 주입합니다.
이렇게 하면 주문 처리 모듈은 결제 방식의 구체 구현과 독립적이므로, 새로운 결제 수단 추가나 변경이 자유롭고, 테스트 시에는 모의(Mock) 결제 처리 객체를 주입해 검증할 수 있어 매우 유연해집니다.
의존성 주입은 객체 지향 프로그래밍에서 객체가 필요로 하는 다른 객체와의 의존성을 외부에서 주입하는 디자인 패턴입니다. 프로그래밍 시점에 직접 객체 내부에서 다른 객체를 생성하는 것이 아니라, 외부에서 생성하여 전달하는 방식입니다.
의존성 주입을 사용하는 가장 큰 이유는 객체 간 결합도를 낮추기 위함입니다. 한 클래스가 다른 클래스를 직접 생성해서 사용하면, 두 클래스 간 의존성이 높아지고, 사용되는 클래스가 변경될 때마다 이를 사용하는 클래스도 함께 수정해야 할 가능성이 커집니다.
하지만 의존성 주입을 사용하면 외부로부터 필요한 타입을 받아서 사용하므로, 실제 구현이 변경되어도 사용하는 클래스의 코드는 바꾸지 않아도 됩니다. 이를 느슨한 결합(loose coupling)이라 하며, 코드의 유연성과 확장성을 크게 높여 줍니다.
의존성 주입 방식은 크게 다음 세 가지로 나뉩니다.
생성자 주입(Constructor Injection): 필요한 의존성을 생성자를 통해 주입하는 방식입니다. 가장 많이 사용되며, 의존성 불변성을 보장할 수 있습니다.
수정자 주입(Setter Injection): 의존성을 설정하는 메서드를 통해 주입하는 방식입니다. 선택적 의존성에 적합합니다.
필드 주입(Field Injection): 필드에 직접 의존성을 주입하는 방식입니다. 프레임워크에서 편리하게 지원하지만, 테스트하기 어렵다는 단점이 있습니다.
의존성 역전 원칙에 따라 의존성을 설계하는 것도 중요하지만, 무엇보다 모듈 간 경계를 최적으로 설정하는 것이 우선입니다. 모듈의 경계를 명확하고 효율적으로 설정하면 의존성 설계에 들어가는 부담을 크게 줄일 수 있습니다. 반대로 경계가 불명확하거나 부적절하면 의존성 설계 로드가 증가하고, 시간이 지남에 따라 잦은 변화와 이에 따른 비용 발생도 불가피하게 됩니다.
모듈 간 경계를 설정할 때는 다음 기준들이 유용합니다.
첫째, 모듈은 유사한 특성을 가진 구성 요소로 설계해야 합니다.
예를 들어, 변화가 잦은 부분은 별도의 모듈로 분리합니다. (변화의 응집)
비슷한 실행 주기를 가진 로직은 함께 묶어 나눕니다. (실행 주기 중심 분할)
변경 특성에 따른 모듈 구분은 업그레이드, 유지보수, 보완 시 발생하는 배포 부담을 최소화합니다.
둘째, 동적인 특성에 따라 구성 요소를 설계합니다.
Conway’s Law에 근거하여 조직 경계와 모듈 경계를 일치시키거나,
데이터 흐름 또는 이벤트 흐름 단위로 모듈을 나누어 비동기 시스템을 설계합니다. 이러한 접근은 모듈의 독립적인 설계, 테스트, 검증, 배포를 가능하게 합니다.
모듈 경계는 논리적 경계뿐 아니라 코드 구조 및 배포 단위에서도 명확히 유지되어야 하며, 이를 위해 인터페이스 명세, API 게이트웨이, 이벤트 메시지 정의 등이 적극 활용됩니다.
모듈들이 모여 하나의 시스템을 구현하기 때문에, 모듈 간 의존성은 피할 수 없습니다. 기본적으로는 불필요한 의존성을 최소화하는 방향으로 설계해야 하지만, 그 과정에서 가장 피해야 할 구조 중 하나는 순환 의존성(circular dependency)입니다.
만약 모듈 A가 모듈 B에 의존하고, 다시 모듈 B가 모듈 A에 의존한다면, 두 모듈 모두 독립성을 잃게 됩니다. 순환 의존성은 모듈의 변화가 서로 강화되어 시스템의 복잡성과 불안정을 초래하므로 반드시 방지해야 합니다.
이 문제를 해결하기 위해 다음과 같은 설계 기법을 활용할 수 있습니다.
첫째, 의존성 방향을 명확하게 단방향으로 설정합니다. 의존 관계를 시각적으로 파악하기 위해 의존성 구조 행렬(Dependency Structure Matrix, DSM)을 활용하면 문제의 방향성을 쉽게 확인하고 개선할 부분을 찾을 수 있습니다.
둘째, 구현체 간 직접 의존 대신 인터페이스를 통한 의존성 추상화를 적용하여 구체 구현체 간 의존성을 줄입니다.
셋째, 가능한 경우 동기적 의존 관계 대신 Observer 패턴이나 Event-Bus 패턴과 같은 이벤트 기반 비동기 통신 방식으로 대체합니다.
Observer 패턴은 상태 변화를 관리하는 Subject와 이를 감지하여 대응하는 Observer로 구성되며, 느슨한 결합(loose coupling)을 통해 시스템의 유연성과 확장성을 높입니다.
Event-Bus 패턴은 발행자(Publisher)가 이벤트를 발생시키면 이를 이벤트 버스가 구독자(Subscriber)에게 중계하는 방식으로, 발행자와 구독자가 직접 연결되지 않아 유지보수성과 확장성이 개선됩니다.
이 두 가지 패턴 모두 객체 간 느슨한 결합을 실현하여 의존성 수준을 낮추는 효과를 지닙니다.
마지막으로, 소프트웨어 설계는 한 번에 완성되는 것이 아니라 지속해서 변화를 점검하고 반영하는 과정임을 항상 기억해야 합니다.
예를 들어, 두 모듈인 사용자 관리(User Management) 모듈과 권한 관리(Permission Management) 모듈이 있다고 가정해봅시다.
문제 상황 (순환 의존성 발생) 사용자 관리 모듈이 권한 관리 모듈의 기능을 직접 호출하고, 권한 관리 모듈도 사용자 관리 모듈의 정보를 직접 참조한다면, 두 모듈은 서로 강하게 결합되고 순환 의존성이 발생합니다. 이 경우 어느 한쪽을 변경하면 다른 쪽도 수정해야 하므로 유지보수가 어렵고, 시스템 확장성에도 악영향을 미칩니다.
해결 방안: 의존성 역전과 추상화 두 모듈 모두 직접 상대 모듈에 의존하지 않고, 각각 IUserService와 IPermissionService라는 추상 인터페이스에만 의존하도록 설계합니다. 그리고 실제 구현체는 이 인터페이스를 구현합니다.
비동기 이벤트 기반 통신 적용 또 다른 방법으로는 권한 변동과 같은 이벤트를 발생시키고, 구독하는 모듈이 이를 비동기적으로 처리하도록 설계할 수 있습니다. 예를 들어, 사용자 관리 모듈이 ‘사용자 권한 변경’ 이벤트를 이벤트 버스에 발행하면, 권한 관리 모듈이 이를 구독하여 적절히 처리합니다. 이렇게 하면 두 모듈 간 직접 의존성을 제거하고 느슨한 결합을 실현할 수 있습니다.
이 사례는 인터페이스 추상화를 통한 의존성 역전 원칙 적용과 이벤트 기반 비동기 통신을 통해 순환 의존성을 효과적으로 회피하는 방법을 잘 보여줍니다. 이를 통해 유지보수와 확장성 측면에서 뛰어난 유연성을 확보할 수 있습니다.
의존성 관리와 경계 설정: 모듈러 디자인의 핵심은 모듈의 독립성에 있으며, 이를 위해 모듈 간 인터페이스 표준화와 의존성 관리를 체계적으로 수행해야 합니다. 의존성은 불필요하게 복잡하거나 양방향이 되면 유지보수와 확장을 어렵게 하므로 효율적인 경계 설정이 필수적입니다.
의존성의 개념과 유형: 의존성은 한 모듈이 다른 모듈에 기대어 동작하는 관계이며, 정적(컴파일 시 결정)과 동적(런타임에 생성)으로 나눌 수 있습니다. 또한, 구조적 의존성과 조직적 의존성으로 구분되며, 설계 과정에서 각각 적절히 고려되어야 합니다.
의존성 역전 원칙(DIP): 상위 모듈이 하위 모듈에 직접 의존하는 대신, 둘 다 추상화된 인터페이스에 의존하도록 설계하는 원칙입니다. 이를 통해 결합도를 낮추고, 모듈 교체 및 테스트 용이성을 확보할 수 있으며, 의존성 주입(Dependency Injection)을 통해 구현하는 것이 일반적입니다.
경계 설정 원칙: 모듈 간 경계를 명확하고 최적으로 설정하는 것이 설계의 우선 과제입니다. 경계는 모듈의 유사한 특성, 변화 주기, 조직 구조, 데이터 및 이벤트 흐름 등을 고려하여 설정하며, 인터페이스 명세, API 게이트웨이, 이벤트 메시지 등을 활용해 경계를 명확히 유지해야 합니다.
순환 의존성의 위험과 회피: 순환 의존성은 모듈의 독립성을 해치고 시스템의 복잡성과 불안정을 초래하므로 반드시 피해야 합니다. 단방향 의존성 명확화, 인터페이스 추상화, 이벤트 기반 비동기 통신(Observer 패턴, Event-Bus 패턴) 등을 통해 순환 의존성을 효과적으로 회피할 수 있습니다.
#소프트웨어아키텍처 #모듈러디자인 #의존성역전원칙 #DependencyInjection #모듈독립성 #설계원칙 #소프트웨어설계 #클린코드 #유지보수성 #확장성