[소프트웨어] 의존성, 종속성

의존성 또는 종속성이라는 용어가 많이 사용되고 있다. 의존성 주입, 의존성 역전 법칙이라는 용어가 널리 사용되는 것으로 보아 종속성 보다는 의존성이 더 많이 사용되는 듯하다. 어떤 것에 의존한다는 것, 어떤 것에 종속되었다는 표현은 두 대상이 얼마나 서로 연관되어 있냐는 관점에서 서로의 관계에 대해 논하는 것이다. 당연해 보이는 이야기들을 조금 더 일반적으로 정리해보려 한다.

어떤 것이 변화하는 것에 의존하면 변화하는 대상이 변할 때 자신도 변해야 한다. 의존성의 존재 여부는 의존 관계에 있는 두 대상 중 하나가 변할 때 다른 하나도 변하는 지를 결정한다. 의존 관계가 없다면 하나가 변하더라도 다른 하나는 아무런 영향을 받지 않는다. 반면 의존 관계에 있다면 하나가 변하면 다른 하나는 반드시 변하게 된다. 의존성은 변화와 관련이 있다.

의존성의 정도는 의존 관계에 있는 두 대상 중 하나가 변할 때 다른 하나가 얼마나 변할지(변화의 정도)를 결정한다. 변화 대상에 대한 의존성이 높다면 대상의 변경이 클수록 자신의 변화도 클 것이다. 의존성이 높을 때 두 대상의 결합도(coupling)가 높다고 표현하기도 한다. 반면 의존성이 낮다면 대상의 변화가 크더라도 자신의 변화는 작거나 없을 수도 있다. 두 대상의 의존 관계가 반드시 필요할 수도 있고, 필요하지 않을 수도 있다. 또한 두 대상의 의존 관계가 잘못 설정되어 불필요한 의존성이 존재할 수도 있다.

의존 관계에 있는 대상이 변하면 나도 변해야 한다라는 말은 받아들이기 나름이다. 다양하고 많은 대상들에 의존하고 있는 상황에서 주변 환경이 변하니까 자신도 변하는 것이 당연하다고 생각한다면 의존 관계에 대한 변화를 따르는, 보다 수동적인 주체가 된다. 반면 주변 환경이 변하는 데 내가 왜 변해야 되는지에 대해 의문이 생기고 불편함을 느낀다면 이런 의존 관계가 정말 필요한 것인지(의존성의 존재 여부에 대한 고려), 자신의 변화 정도를 조금 더 줄일 수는 없는지(의존성의 정도에 대한 고려), 그리고 의존 대상이 나에게 의존하도록 하면 어떨지(의존성 방향에 대한 고려)에 대한 고민이 생기게 된다.

객체지향 프로그램은 객체들이 서로 의존하고 다양한 메시지를 서로 주고 받는 구현을 기반으로 하기 때문에 좋은 프로그램 설계 및 구현을 위해 반드시 객체 간 의존성에 대한 고민을 해야한다.


객체 간 의존 관계와 의존성 주입

프로그램이 동작하는 동안 한 객체는 다른 객체의 데이터와 기능을 사용한다. 객체의 런타임 관계를 설정하는 것을 객체 간 의존 관계 설정이라고 한다. 이 경우 두 객체는 의존 관계에 있으며 사용하는 객체는 사용되는 객체(의존 객체)에 대한 의존성(dependency)을 주입 받는다. 의존성 주입(dependency injection)은 클래스의 인스턴스 변수가 다른 객체를 참조하는 합성(composition)이라는 구현 방법에 기반한다. 스프링 프레임워크는 합성을 통한 의존성 주입 방식으로 객체 간 의존 관계와 의존성을 관리한다.

일반적으로 new 연산자를 사용하거나 팩토리 메서드를 통해 객체의 인스턴스를 생성할 수 있다. 제어의 역전(inversion of control, IoC) 접근 방식을 사용하면 런타임에 외부 프로세스에 의해 객체의 인스턴스(또는 하위 클래스)를 다른 객체에게 제공할 수 있으며 이렇게 런타임에 객체의 의존성을 주입하는 동작을 의존성 주입(dependency injection)이라고 한다. 의존성 주입은 의존 관계에 대해 접근하는 여러 방식 중 하나이며 프로그램의 구성 요소인 객체 간의 의존 관계가 소스 코드 내부가 아닌 외부(프레임워크 동작)에 의해 정의되는 디자인 패턴이다.

의존성 주입이라는 패턴 자체는 용어 그대로 객체 간 의존성과 관련이 깊다. 의존성 주입은 객체 간 결합도를 느슨하게 만들며 의존성 역전 원칙(DIP)과 단일 책임 원칙(SRP)을 따르도록 의존 객체 생성에 대한 의존성을 클라이언트 객체의 행위로부터 분리하는 것이다.

실제 코드로 설명하면 한 클래스에 의존 객체 형 객체 변수를 필드(인스턴스 변수)로 정의한 후 적절한 시점에 인스턴스화 된 의존 객체를 필드에 할당하는 것이 바로 의존성 주입이다. 의존성 주입이 수행되면 객체는 의존 객체의 데이터와 기능을 사용할 수 있다.

class 클래스명 {
  의존객체필드;
  
  // 생성자를 통한 의존성 주입
  public 클래스명(의존객체) {
    this.의존객체필드 = 의존객체;
  }
  
  // setter 메서드를 통한 의존성 주입
  public set의존객체명(의존객체) {
    this.의존객체필드 = 의존객체;
  }
  
  // 의존 객체의 기능 사용
  ...
}


객체의 의존성 주입 방법에는 여러 가지가 있다. 그 중 생성자를 사용한 방법이 있다. 프로그래밍에서 생성자는 객체의 인스턴스를 생성하고 초기 속성을 정의하는 역할을 한다. 생성자의 파라미터는 인스턴스 초기화에 필요한 정보를 전달하도록 강제하는 방법이며 올바른 상태를 가진 객체의 생성을 보장할 수 있도록 한다. 따라서 생성자를 사용한 의존성 주입은 객체가 초기화되는 시점에 올바른 의존 객체를 주입하는, 의존성 주입의 좋은 방법이라고 볼 수 있다. 스프링 프레임워크에서는 생성자를 사용한 의존성 주입이 권장된다. 생성자를 통한 의존성 주입 외에 수정자(setter)(또는 설정자) 메서드나 인터페이스를 사용한 의존성 주입 방법이 존재한다.

객체와 의존 객체는 의존 관계에 있다. 만약 의존 객체에 변경이 발생하는 경우 객체의 코드도 수정이 필요할 수 있다. 의존 객체의 메서드 명세 변경 시 호출 메서드 교체가 필요하며, 의존 객체의 속성 변경 시 참조하는 변수 교체가 필요하다. 이때 객체와 의존 객체 간 의존성 방향은 객체 -> 의존 객체이다.

의존성 주입은 new 키워드를 사용하여 객체의 인스턴스를 생성하거나 팩토리 메서드를 사용하는 코드를 단순히 대체하는 것 뿐만 아니라 인터페이스 기반의 유연한 애플리케이션 설계를 적용하고 테스트를 용이하게 하게 한다. 또한 프록시(proxy) 패턴을 적용하여 AOP 기능을 도입하는 등의 장점을 부여할 수 있다.


스프링의 의존성 주입

스프링 프레임워크의 코어는 제어의 역전 원칙을 기반으로 한다. 제어의 역전은 런타임 시 객체에 의존 객체의 참조를 제공함으로써 컴포넌트 간의 의존성 생성과 관리를 외부에서 수행하는 기법이다. 스프링이 지원하는 의존성 주입 방법은 오토 와이어링(autowiring)이라 불리는 필드 주입, 생성자 주입, 수정자 주입이 있다. 인터페이스 주입은 지원하지 않는다.

스프링 프레임워크에서 인터페이스를 사용하면 컨테이너를 통한 의존성 주입을 손쉽게 할 수 있으며 JDK의 동적 프록시를 통해 프록시 패턴을 적용하여 횡단 관심사(crosscutting concerns)에 대한 AOP 기능을 사용할 수도 있다.


응집도와 의존성

SOLID 원칙 중 단일 책임 원칙(single responsibility principle, SRP)은 소프트웨어 모듈은 변경의 이유가 단 하나여야만 한다는 것이다. 여기서 모듈이란 소스 파일 또는 단순히 함수와 데이터 구조로 구성된, 코드의 응집된 집합을 말한다. 따라서 단일 책임 원칙은 메서드와 클래스 수준의 원칙으로 볼 수 있다. 하나의 모듈은 오직 하나의 클라이언트(또는 액터)에 대해서만 책임져야 한다.

모듈이 하나 이상의 변경 이유를 갖게 된다면 해당 모듈을 사용하는 여러 클라이언트 중 한 클라이언트의 요구사항이 변경되어 모듈이 변경되는 경우 다른 클라이언트에게 변경에 따른 의도치 않은 영향을 미칠 수 있다. 따라서 응집도가 높은 모듈을 사용하는 클라이언트는 해당 모듈을 사용하는 다른 클라이언트에 대한 의존성이 높다고 볼 수 있다. 하나의 클래스에 서로 다른 클라이언트를 위한 메서드가 정의되어 있는 경우 하나의 클래스가 서로 다른 클라이언트를 책임지게 되어 단일 책임 원칙을 위반하며 서로 다른 클라이언트가 서로 결합된 상태가 된다.

응집도란 서로 연관성이 있는 데이터나 기능이 하나의 모듈에 존재하는 정도를 말한다. 응집도가 낮은 모듈은 서로 연관성이 없는 데이터 및 기능을 갖고 있다. 하나 이상의 변경의 이유를 갖는 모듈은 응집도가 낮다. 모듈을 사용하는 클라이언트 별로 모듈 변경의 이유가 다를 것이므로 응집도는 하나의 클라이언트를 책임지는 정도라고도 볼 수 있다. 단일 책임 원칙을 위반하여 응집도가 높은 모듈이 문제가 되는 것은 서로 다른 목적으로 동일한 모듈을 변경하는 경우 때문이다.

한 모듈에 다양하고 많은 메서드를 포함하면 각각의 메서드에 대한 변경사항 발생 시 서로 다른 변경사항에 대한 충돌을 해결하기 위해 병합이 자주 발생하게 된다는 문제점도 존재한다.

변경에 따른 영향 및 병합과 같은 문제들을 해결하기 위한 방법은 서로 다른 변경의 이유를 가지는 코드, 서로 다른 클라이언트에 의해 사용되는 코드를 서로 분리하는 것이다. 이를 위해 하나의 클래스에 정의되어 있던, 서로 다른 목적의 기능을 수행하는 메서드들을 각각 새로운 클래스로 이동시킨다. 이렇게 함으로써 하나의 클래스는 변경의 이유가 하나만 되도록 만들 수 있다.

하지만 서로 다른 기능의 메서드만 클래스 별로 분리한다고 해서 낮은 응집도로 인한 문제가 해결되지 않을 수도 있는데 이 경우 기능과 데이터를 분리하는 방법을 적용할 수 있다. 서로 다른 변경의 이유로 분리한 클래스에 서로 공유하는 데이터가 있는 경우 메서드가 없는 간단한 데이터 구조인 클래스를 만들고 각각의 클래스가 공유하도록 함으로써 각 클래스의 코드 중복을 없앨 수 있다.


퍼사드 패턴

퍼사드(Facade) 패턴이란 라이브러리, 프레임워크 또는 기타 복잡한 클래스 집합에 대한 단순화된 인터페이스를 제공하는 구조적 설계 패턴이다.

앞서 한 클래스의 변경의 이유가 하나만 되도록 만들기 위해 각 클래스는 자신의 책임별로 반드시 필요한 소스 코드만을 포함해야 한다고 하였다. 이 경우 클래스의 코드를 사용하는 클라이언트는 어떤 클래스를 사용할지 직접 결정한 후 해당 클래스를 인스턴스화해야 한다. 하나의 클래스에 존재하던 서로 다른 기능이 서로 다른 클래스에 위치하게 되어 클라이언트가 여러 클래스 중 어떤 클래스를 선택하여 사용할지 결정하는 과정이 생겨나게 되었다.

또다른 고려사항은 특정 클래스에 대한 의존성이 생겼으므로 변경에 따른 영향도를 항상 고려해야 한다는 것이다. 클라이언트의 비즈니스 로직은 클래스의 구현 세부 사항과 긴밀하게 결합되어 버린다.

하나의 클래스를 서로 다른 변경의 이유로 클래스를 분리하였지만 특정 클라이언트가 원하는 기능을 사용하기 위해 어떤 클래스를 사용해야 하는지 결정이 필요한 점, 의존하는 클래스의 변경에 취약한 점은 여전히 해결해야 할 부분으로 남아 있다.

위 문제를 해결하기 위해 퍼사드 패턴을 사용하면 클래스의 인스턴스 생성과 인스턴스 메서드 호출을 담당하는 별도의 클래스를 위치시킴으로써 클라이언트와 클래스 간 결합도를 낮출 수 있다. 퍼사드 객체는 클래스의 객체를 생성하고 클라이언트의 요청을 적절한 메서드를 가지는 객체로 위임하는 일을 책임진다.

퍼사드 패턴은 기존 객체에 대한 새로운 인터페이스를 정의하는 반면 어댑터 패턴은 기존 인터페이스를 사용할 수 있도록 한다. 어댑터 패턴은 보통 하나의 객체만 감싸는 반면, 퍼사드 패턴은 객체의 전체 서브 시스템과 함께 동작한다.


캡슐화와 의존성

객체 지향 프로그래밍에서 캡슐화(encapsulation)란 데이터(상태)와 기능(행동)을 하나로 묶고 실제 구현 내용은 외부로부터 감추는(은닉하는) 것을 말한다. 캡슐화를 통해 한 객체가 다른 객체의 데이터와 기능에 아무런 제약 없이 접근하고 변경하는 것을 막음으로써 객체 간 결합도와 의존성을 낮출 수 있다.

객체 지향 프로그래밍 언어는 캡슐화를 쉽고 효과적으로 수행하는 방법을 제공한다. 이를 통해 데이터와 기능을 응집력 있게 구성하고 서로 구분할 수 있다. 캡슐화를 통해 데이터는 은닉되고, 일부 기능만 외부에 노출된다. 데이터 은닉은 클래스의 private 멤버 데이터(또는 필드), 기능 노출은 클래스의 public 멤버 함수(또는 메서드)로 표현된다. 외부에서는 객체의 내부 private 속성(상태)에 직접 접근할 수 없도록 막고 적절한 public 메서드를 통해서만 내부 속성을 변경할 수 있도록 한다. privatepublic을 접근 지정자(또는 접근 제한자)(access modifier)라고 한다. 접근 지정자는 언어 수준에서 데이터와 기능을 은닉하는 구현 방법이다. 은닉의 정도를 접근 지정자로 기술하고 해당 데이터나 기능에 대한 접근을 제한한다. 객체의 속성의 접근 지정자가 private이고 접근 가능한 pubic 메서드가 존재하지 않는다면 해당 속성에 직접 접근할 수 없다. 접근 지정자에 의해 제한된 속성은 컴파일러에 의해 판단된다. 접근 지정자에 의해 정의된 해당 속성에 대해 접근 방식을 위반한 코드를 작성하면 컴파일러는 컴파일 에러를 발생시킨다.

완벽한 캡슐화는 데이터와 기능이 어떻게 구현되었는지에 대해서 조금도 알지 못하게 하는 것을 말한다. 그러나 실제 언어에서는 접근 지정자에 의해 접근이 제한된 멤버 변수에 접근하는 일은 컴파일러에 의해 불가능하지만, 멤버 변수가 존재한다는 사실 자체를 알 수 있다는 한계가 있다. 따라서 언어 수준에서 캡슐화를 완벽하게 강제할 수는 없으며 사용자는 캡슐화가 의도된 데이터와 기능을 우회해서 사용하지 않아야 한다.

캡슐화를 통해 객체 내부로의 접근을 제한하면 객체 간 의존성을 낮출 수 있다. 한 객체가 다른 객체의 멤버에 자유롭게 접근함으로써 객체의 상태를 변경하고 기능을 사용하는 코드는 변경에 취약하며 객체 간 의존성 및 결합도를 높인다. 단일 책임 원칙에 의해 책임과 역할이 분리된 객체들은 캡슐화를 통해 자신이 가지고 있는 데이터는 자기 자신만 접근 가능하도록 만들 수 있다. 데이터를 외부에서 접근 가능하게 만들거나 외부로부터 변경 가능하게 만드려면 적절한 기능으로 만들고 외부에 노출한다.

객체 지향 프로그램 구현은 요구사항에 따라 도메인 개념을 정의하고 도메인 개념을 클래스로 구현하는 것이다. 이 과정에서 클래스가 어떤 데이터와 기능을 갖도록 할 것인지 결정한다. 그리고 도메인 간 관계를 결정하여 클래스 간 관계와 경계를 설정한다. 클래스의 인스턴스들은 서로 원하는 데이터를 주고 받기 위해 서로의 기능을 사용하며 상호작용한다.

캡슐화는 한 객체가 다른 객체의 데이터에 아무런 제약없이 접근하고 변경하는 것을 막음으로써 객체 간 결합도와 의존성을 낮출 수 있다. 캡슐화에 의해 한 객체가 다른 객체에 대해 직접적인 접근이 불가능하게 되지만 간접적으로 데이터를 주고 받는 기능을 사용하는 경우 객체 간 의존 관계 설정은 필요하다.


자바 플랫폼 모듈 시스템

자바 플랫폼 모듈 시스템(Java Platform Moduel System, JPMS)은 자바 9에 도입된 모듈화(modularization) 기능이다. 자바 9 이전에는 부족한 캡슐화 및 정보 은닉 기능 때문에 모듈화에 대한 지원이 충분하지 않았다. 클래스 및 인터페이스에 대해서는 접근 제한자를 통해 캡슐화를 지원하지만 패키지와 jar 수준에서는 캡슐화를 거의 지원하지 않았다. 이 때문에 한 패키지의 클래스와 인터페이스를 다른 패키지에 공개하려면 public으로 선언해야 한다. 결과적으로 이들 클래스와 인터페이스는 접근을 허용하려는 패키지 뿐만 아니라 모든 클래스, 패키지, jar에게 공개된다. 보통 패키지 내부의 접근자가 public이므로 사용자가 이 내부 구현을 마음대로게 보고 사용할 수 있다는 문제점이 있다.

이러한 문제를 해결하기 위해서는 클래스 뿐만 아니라 클래스 보다 높은 수준인 패키지 수준, 모듈 수준의 캡슐화가 필요하다. 자바 9는 자바 플랫폼 모듈 시스템을 통해 모듈을 사용하여 클래스가 어떤 다른 클래스를 볼 수 있는지를 컴파일 시간에 제어할 수 있다. 기존 자바의 패키지는 모듈성을 지원하지 않는다.

스프링 프레임워크의 경우 리플렉션으로 인해 JPMS 사용에 한계가 있으며 관련 기능은 Modulith 프로젝트로 진행 중이다.(https://github.com/spring-projects/spring-framework/issues/18079)


추상화와 다형성

클래스가 아닌 인터페이스에 의존하기

캡슐화에 의해 객체는 의존 객체의 내부적인 비공개 속성 및 상태를 알 수 없다. 의존 객체의 속성을 알아내거나 변경시키고 싶은 경우에는 의존 객체의 접근 가능한 메서드를 통해서만 가능하다. 객체는 의존 객체의 접근 가능한 메서드를 호출함으로써 특정 기능이 동작하도록 요청한 후 원하는 응답을 받아 처리한다. 이때 의존 객체에 정의된 접근 가능한 메서드의 시그니처(파라미터 및 반환 타입)와 구현 내용이 변경되면 객체의 메서드를 호출하는 코드에도 변경이 필요하게 된다. 대신 객체가 의존 객체의 메서드의 구체적인 구현에 대해서는 알지 못하게 하고 인터페이스를 사용하도록 하면 메서드의 구체적인 구현이 변경되더라도 객체는 메서드를 그대로 사용할 수 있다(시그니처가 변경되어 인터페이스가 변경된다면 객체의 코드 변경은 필요하다). 객체의 내부 속성에 직접 접근함으로써 발생하는 변경에 따른 의존성을 캡슐화를 통해 제거할 수 있다면 불가피하게 노출되는 접근 가능한 메서드의 변경에 따른 의존성은 인터페이스를 통해 최소화할 수 있다.

인터페이스는 객체의 공통적인 것을 추상화하고 구체적인 것은 구현하는 객체(구현체)에 위임하는 역할을 한다. 클래스가 인터페이스를 구현한다는 것은 인터페이스에 정의된 추상(abstraction)을 구체화하는 것으로 볼 수 있다. 인터페이스는 객체 간 의존 관계와 변경에 따른 영향도 관점에서 매우 중요한 역할을 한다. 의존 객체가 변경되더라도 객체는 변경되지 않게 해주는 것이 바로 인터페이스이다. 인터페이스는 의존성 주입을 더 쉽게 활용할 수 있도록 도와준다.

인터페이스는 객체의 기능적 명세 또는 규약이다. 객체가 어떻게 기능할 수 있는지(방법)만 추상적으로 정의하고 구체적인 기능은 구현하는 객체에 맡긴다. 코드 수준에서 인터페이스를 사용하면 객체 간 의존 관계에 유연성을 부여할 수 있다. 객체의 의존 객체를 인터페이스 타입으로 선언하고 의존성 주입 시 인터페이스를 구현하는 의존 객체를 주입하도록 코드를 작성하면 필요에 의해 의존 객체의 완전한 교체(구현 객체의 교체)가 일어나더라도 객체는 변경되지 않는다. 하지만 인터페이스가 변경(객체의 기능 명세 또는 규약 자체가 변경)하게 될 경우 객체도 변경이 필요하다.

객체의 런타임 관계를 설정하는 것을 객체 간 의존 관계 설정이라고 했다. 인터페이스를 사용하면 의존 관계 설정 시 의존 객체의 구체적인 타입을 알 필요가 없어진다. 구체적인 기능은 의존성을 주입할 의존 객체의 타입에 의해 정해진다. 따라서 인터페이스를 사용할 경우 객체가 어떤 의존 객체를 주입 받을 것인가에 따라 애플리케이션의 구체적인 기능이 달라지게 된다.

인터페이스는 테스트를 더 쉽게 만들기도 한다. 객체에 대한 테스트 시 구체적인 기능이 정의된 의존 객체 구현체 대신 인터페이스 타입의 목(mock) 의존 객체를 주입하여 객체의 기능 테스트가 가능하다.

위 내용을 통해 다음과 같은 정리를 해볼 수 있다.

  • 객체의 의존 객체에 변경이 필요한 상황이 존재하면 객체의 변경은 불가피하다. 이 경우 의존성의 방향 교체를 고민해본다.
  • 객체의 의존 객체 교체가 필요한 상황이 존재하면 인터페이스를 사용한다.


인터페이스는 소프트웨어 모듈이 변경에 용이하도록 만들어 준다. 인터페이스를 통해 객체 간 의존 관계를 그대로 유지하고 기존 코드를 그대로 유지한채로 소프트웨어의 기능을 변경하거나 확장할 수 있다. 모듈이 변경에 취약한 것은 객체 간 높은 의존성 때문이며 인터페이스를 사용하는 것은 객체 간 의존성을 낮추는 방법 중 하나이다.


상속과 추상 클래스

추상 클래스(abstract class)는 인터페이스와 동일하게 구체적인 구현에 대한 추상을 제공하는 역할을 한다. 추상 클래스는 인터페이스와 마찬가지로 인스턴스화할 수는 없지만 인터페이스와 다르게 상속이 가능하므로 공통적인 속성(필드)과 메서드를 하위 클래스 간에 공유하기 위해 사용할 수 있다. 서로 다르게 정의할 필요가 있는 메서드만 재정의(오버라이드)하여 구체적인 구현을 정의하면 된다.

템플릿 메서드 패턴(template method pattern)은 상속을 통해 구체적인 기능을 변경하는 디자인 패턴이다. 변하는 기능을 상위 클래스(주로 추상 클래스)의 추상 메서드로 선언하고 구체적인 기능을 하위 클래스에서 정의한다. 변하는 기능은 구현 내용이 없는 추상 메서드로 선언하고 변하지 않는 작업의 순서(일련의 메서드 호출)는 추상 클래스의 변하지 않는 템플릿 메서드에 정의한다. 추상 클래스의 구현 클래스(구체 클래스)에서는 템플릿 메서드 자체가 아닌 템플릿 메서드 내에서 호출하는 메서드들 중 변하는(구현을 달리할) 메서드(추상 메서드)만 재정의함으로써 기능을 정의한다.

추상 클래스의 목적은 여러 하위 클래스에서 공유할 수 있는 기본 클래스의 공통적인 정의를 제공하는 것이다. 추상 클래스를 정의한 다음 해당 추상 클래스의 하위 클래스를 정의하여 클래스의 고유의 구체적인 구현을 제공하도록 할 수 있다.

추상 클래스는 하위 클래스들이 공통적인 로직을 갖도록 하기 위해 사용할 수 있다. 이때 하위 클래스 간 공유되어야 할 공통 로직이 하위 클래스가 구현할 로직을 대신 수행하고, 해당 로직이 변경될 가능성이 있다면 추상 클래스는 변경에 취약해진다. 따라서 구현에 대한 변경이 필요한 상황이 발생할 수 있다면 해당 구현을 추상 클래스의 공통 로직에서 제거하고 하위 클래스에서 직접 구현하도록 하거나 인터페이스를 사용하는 것이 낫다. 추상화가 필요한 기능은 해당 책임을 모든 하위 클래스가 갖고 구체적인 구현을 달리 제공하도록 해야 하며 공통화가 필요한 기능은 하위 클래스 대신 추상 클래스가 그 책임을 갖도록 한다.


의존성과 다형성

인터페이스나 추상 클래스를 통해 구체적인 기능의 추상화를 달성하여 객체 간 의존성을 낮출 수 있다. 추상을 구체화한 객체 중 어떤 객체를 선택(인터페이스의 경우 구현체를 선택하고, 추상 클래스의 경우 하위 클래스를 선택)할 것인지에 따라 구체적인 기능이 달라지게 된다.

객체 지향 프로그래밍 언어는 클래스가 다양한 데이터 타입에 속하는 것이 가능한 특성인 다형성(polymorphism)을 제공한다. 다형성은 기능을 구체화한 구현체(또는 하위 클래스)의 교체를 가능하게 하여 프로그램에 플러그인 아키텍처(plugin architecture)를 적용할 수 있도록 해준다.

다형성을 구현하는 방법에는 인터페이스를 사용하는 방법과 상속을 사용하는 방법이 있다. 다형성에 의해 동일한 인터페이스의 모든 구현체들은 서로 같은 타입으로 간주될 수 있으며, 동일한 클래스의 모든 하위 클래스들은 서로 같은 타입으로 간주될 수 있다. 다형성을 활용하기 위해 상위 클래스가 반드시 추상 클래스일 필요는 없으며 상속만으로도 코드에 다형성을 부여할 수 있지만 단순히 코드 재사용을 위한 상속이라면 다형성을 통한 추상화를 활용하지 못하는 코드가 된다. 반면 코드 재사용뿐만 아니라 기능의 추상화(인터페이스화)를 위한 상속이라면 다형성을 활용하여 객체 간 의존성을 낮출 수 있는 코드를 작성할 수 있다. 즉, 추상 클래스를 사용하는 목적은 코드에 다형성을 부여함과 동시에 추상화를 통해 객체 간 의존성을 낮추는 역할을 수행하는 것이다.

인터페이스 또는 추상 클래스 타입의 의존 객체를 의존성 주입하려면 인터페이스나 추상 클래스를 정의하고 객체는 인터페이스 또는 추상 클래스 타입의 객체를 의존성 주입받도록 코드를 작성한다. 생성자를 통한 의존성 주입 코드는 다음과 같다.

class 클래스명 {
  인터페이스형 인터페이스형의존객체필드;
  추상클래스형 추상클래스형의존객체필드;
  
  // 생성자를 통한 의존성 주입
  public 클래스명(인터페이스형 의존객체) {
    this.인터페이스형의존객체필드 = 의존객체;
  }
  
  // 생성자를 통한 의존성 주입
  public 클래스명(추상클래스형 의존객체) {
    this.추상클래스형의존객체필드 = 의존객체;
  }
} 


클래스의 코드를 작성할 때는 객체가 의존하는 의존 객체 타입은 추상화되어 있다. 즉, 클래스 코드의 컴파일 타임 의존성(의존 관계)은 객체 -> 인터페이스 또는 객체 -> 추상 클래스이다. 하지만 프로그램 실행 시(런타임) 객체가 의존하는 의존 객체 타입은 의존 객체의 인스턴스화 방법에 의해 결정된다. 런타임에 객체를 인스턴스화하는 코드가 실행될 때(생성자가 실행될 때) 어떤 종류의 의존 객체를 인스턴스화할 지가 결정되며 따라서 런타임 의존성은 객체 -> 인터페이스 구현체 또는 객체 -> 서브 클래스의 인스턴스가 된다.

다형성을 통해 컴파일 의존성과 런타임 의존성이 다르게 만들 수 있다. 기존 코드의 객체 간 의존성을 새로운 의존성으로 변경하려면 다형성을 도입하면 된다. 객체 간 의존성 방향을 역전시키기 위해 다형성을 사용할 수도 있다.


인터페이스와 추상 클래스

클래스들이 공통으로 가질 수 있는 기능에 대해 인터페이스를 정의할 수 있다. 추상 클래스는 하위 클래스 간 공통적인 구현은 서로 공유하도록 하고 나머지 구현의 일부는 하위 클래스 각각이 구현하도록 한다. 인터페이스는 모든 구현을 구현체 각각이 구현하도록 한다.

공통적인 구현 내용이 있어 코드 중복을 제거하려면 추상 클래스를 사용하고 그렇지 않다면 인터페이스를 사용하면 된다.

추상 클래스의 멤버와 메서드의 가시성은 제어할 수 있지만, 인터페이스의 모든 메서드는 public으로 정의되어야 한다. 인터페이스 정의 시 접근 지정자를 지정하지 않으면 기본적으로 public으로 정의된다.

객체 지향 언어 마다 추상 클래스 및 인터페이스의 구체적인 특성은 다를 수 있으므로 언어 별 명세를 살펴봐야 한다. 자바의 경우 8 버전 부터 인터페이스에 default 키워드를 사용하여 메서드 정의 시 메서드 내용을 구현할 수 있으며 이를 통해 인터페이스 구현체들 간에 공통적인 로직을 공유하도록 할 수 있다. 또한 자바 9 버전 부터 인터페이스에 private 메서드를 정의할 수도 있다.

의존성 주입을 통한 의존 관계 설정의 예

비즈니스 로직을 수행하면서 데이터를 저장 및 조회하기 위해서는 비즈니스 객체(도메인 모델 객체)와 데이터 영속화 객체 간 의존 관계가 필요하다. 두 객체 중 한 객체에 변화가 일어날 경우를 살펴보자.

먼저 도메인 모델 객체가 데이터 영속 객체에 의존하는 경우이다. 이 경우

트랜잭션 경계설정

의존성 역전

테스트와 의존성

단위 테스트

통합 테스트

슬라이스 테스트

비즈니스와 의존성

비즈니스 규칙이나 정책, 로직은 어떤 특정 작업을 수행하기 위해 누군가에 의해 정의된 것이며 변화 가능한 것이다. 여기서 비즈니스란 업무 또는 도메인 등으로 일컫는 어떤 분야, 영역, 범위를 의미한다. 해당 비즈니스에 변경이 발생하면 비즈니스에 의존하고 있는 것들도 변화가 필요하게 된다. 변화에 유연하게 대처할 수 있는 아키텍처 및 소프츠웨어 설계가 중요한 이유는 비즈니스가 항상 변하기 때문이다.

소프트웨어는 비즈니스의 요구 사항에 따라 만들어지고 변경된다. 소프트웨어를 사용하는 사용자는 소프트웨어와 끊임없이 통신하며 특정 비즈니스에 따라 정해진 기능을 사용하는 것이 목적이고, 소프트웨어를 제공하는 서비스 관련 이해관계자들은 사용자의 요구를 충족시키고 만족도를 높여 서비스의 품질을 높이면서 필요 시 이익을 증가시키는 데 목적이 있다. 그럼 실질적으로 프로그래밍 언어와 여러 기술 스택으로 소프트웨어를 만드는 엔지니어(프로그래머, 아키텍트, 네트워크 엔지니어 등)의 목적은 무엇일까. 비즈니스 요구사항에 맞게 적절하게 동작하는 서비스를 만들고 이를 유지보수하는 것이다.

어떤 시점에서 어떤 이유로 인해 사용자의 요구가 변하거나 서비스의 기능이 변경되어야 한다면 비즈니스가 변화하거나 비즈니스를 위한 기술적 요소의 변화가 수반된다. 아키텍처의 설계 및 정의 원칙 중 하나인 SOLID 윈칙은 변경 및 의존 관계와 연관이 깊다. 비즈니스를 주체로 보고 비즈니스가 의존하는 대상들에 대한 의존 관계를 살펴보자.

도메인 모델 객체

도메인 모델링을 수행하고 나면 도메인 모델을 표현하는 객체(도메인 모델 객체)의 데이터(속성)와 데이터 처리 기능(메서드)이 정의될 것이다. 도메인 모델 객체는 비즈니스 개념 및 로직을 표현한다. 도메인 모델 내에서 수행되는 비즈니스 로직은 해당 도메인 모델 객체에 존재하게 함으로써 도메인 모델 객체 별로 특정 비즈니스에 대한 적절한 책임과 역할을 부여할 수 있다.

도메인 모델 객체의 책임에서 벗어나 직접 처리할 수 없는 처리(데이터 저장 처리, 다른 서비스와의 연계 처리) 등은 도메인 모델 객체 내에 관련 로직을 포함시키지 않도록 하고 서비스 구현체에서 처리하도록 구현한다.

도메인 모델 객체와 데이터 매핑 객체

데이터 영속화를 위해서 데이터 저장 기술(예: 데이터베이스)의 데이터 구조(예: 테이블)와 도메인 모델 객체를 매핑하는 과정이 필요하다. 도메인 모델 객체 자체에 매핑 처리를 하는 로직을 포함시킬 것인지, 별도의 매핑 객체(엔티티 객체)를 정의할 것인지 결정해야 한다.

전자는 비즈니스 영역에 기술 영역이 포함되므로 변경에 따른 영향도가 발생한다. 후자는 별도의 비즈니스 영역과 기술 영역을 분리하므로 비즈니스 영역과 기술 영역의 의존 관계는 없지만 도메인 모델 객체 변경 시 매핑 객체도 변경되어야 하는 또다른 의존 문제가 발생하게 된다.

도메인 모델과 데이터 조회 및 데이터 영속화

리포지토리 패턴

비즈니스 로직과 트랜잭션 경계 설정

도메인 모델과 로깅

빌드와 의존성

JVM 빌드 도구

빌드 도구를 사용하여 그룹화된 코드를 jar로 컴파일하고 컴파일된 jar 파일을 불러와 사용할 수 있다. 애플리케이션 실행 시 JVM은 동적으로 클래스 경로에 정의된 클래스(또는 jar) 파일을 읽는다.

jar 파일 형태의 다양한 라이브러리들을 중앙 저장소로부터 다운 받은 후 클래스 경로에 추가시킴으로써 코드를 사용할 수 있다.

하나의 프로젝트 내에서 코드를 별도로 분리하고 모듈로 포함시키는 방식으로 프로젝트 구조를 만들 수 있다. 이를 멀티 프로젝트 구조라고 한다. 멀티 프로젝트에서는 하나의 루트 프로젝트가 하나 이상의 서브 프로젝트를 라이브러리 형태로 포함하여 사용한다. 루트 프로젝트는 서브 프로젝트에 정의된 소스 코드를 컴파일 타임 또는 런타임에 사용할 수 있다. 서브 프로젝트를 jar 수준으로 코드 그룹화하고 루트 프로젝트는 컴파일된 jar 파일을 불러와 사용한다.

서브 프로젝트 뿐만 아니라 jar 파일 형태의 다양한 라이브러리들을 중앙 저장소로부터 다운 받은 후 클래스 경로에 추가시킴으로써 코드를 사용할 수 있다.

자바의 경우 클래스 수준에서 접근 제한자를 통한 캡슐화를 지원한다. 하지만 클래스 수준의 캡슐화를 사용하여 패키지와 jar 파일 수준에서 캡슐화를 할 수는 없다. 패키지의 가시성 제어 및 캡슐화를 위해 자바의 모듈 시스템(JPMS)을 이용할 수 있다.

시스템 및 애플리케이션 아키텍쳐와 의존성

디자인 패턴과 의존성

스프링 프레임워크와 의존성

스프링 프레임워크는 의존성 주입을 통해 객체 간 의존 관계 설정을 보다 쉽게 할 수 있게 해줄 뿐만 아니라 인터페이스를 통한 추상화를 사용하여 구체적인 기술에 종속적이지 않은 코드 구현을 가능하도록 해준다.

스프링의 추상화

의존성 주입과 캡슐화

참고

Comments