Post

유연한 설계

9. 유연한 설계

9.1 개방-폐쇄 원칙

개방-폐쇄 원칙(Open-Closed Principle, OCP): 소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.

  • 확장에 대해 열려 있다: 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 ‘동작’을 추구해서 애플리케이션의 기능을 확장할 수 있다.
  • 수정에 대해 닫혀 있다: 기존의 ‘코드’를 수정하지 않고도 애플리케이션의 동작을 추가하거나 변경할 수 있다.

개방-폐쇄 원칙은 유연한 설계란 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 확장할 수 있는 설계라고 이야기함

컴파일타임 의존성을 고정시키고 런타임 의존성을 변경하라

사실 개방-폐쇄 원칙은 런타임 의존성과 컴파일타임 의존성에 관한 이야기

런타임 의존성은 실행 시에 협력에 참여하는 객체들 사이의 관계

컴파일타임 의존성은 코드에서 드러나는 클래스들 사이의 관계

유연하고 재사용 가능한 설계에서 런타임 의존성과 컴파일 타임 의존성은 서로 다른 구조를 가짐

개방-폐쇄 원칙을 수용하는 코드는 컴파일타임 의존성을 수정하지 않고도 런타임 의존성을 쉽게 변경할 수 있다.

추상화가 핵심이다

개방-폐쇄 원칙의 핵심은 추상화에 의존하는 것

추상화란 핵심적인 부분만 남기고 불필요한 부분은 생략함으로써 복잡성을 극복하는 기법

추상화 과정을 거치면 문맥이 바뀌더라도 변하지 않는 부분만 남게 되고 문맥에 따라 변하는 부분은 생략

추상화를 사용하면 생략된 부분을 문맥에 적합한 내용으로 채워넣음으로써 각 문맥에 적합하게 기능을 구체화가능

개방-폐쇄 원칙의 관점에서 생략되지 않고 남겨지는 부분은 다양한 상황에서의 공통점을 반영한 추상화의 결과물

따라서 추상화 부분은 수정에 대해 닫혀 있다.

추상화를 통해 생략된 부분은 확장의 여지를 남긴다.

이것이 추상화가 개방-폐쇄 원칙을 가능하게 만드는 이유

언제라도 추상화의 생략된 부분을 채워넣음으로써 새로운 문맥에 맞게 기능을 확장할 수 있다.

따라서 추상화는 셜계의 확장을 가능하게 함

단순히 어떤 개념을 추상화했다고 해서 수정에 대해 닫혀 있는 설계를 만들 수 있는 것은 아니다.

개방-폐쇄 원칙에서 폐쇄를 가능하게 하는 것은 의존성의 방향

수정에 대한 영향을 최소화하기 위해서는 모든 요소가 추상화에 의존해야 함

명시적 의존성과 의존성 해결 방법을 통해 컴파일타임 의존성을 런타임 의존성으로 대체함으로써 실행 시에 객체의 행동을 확장할 수 있다.

추상화를 했다고 해서 모든 수정에 대해 설계가 폐쇄되는 것은 아님

변경에 의한 파급효과를 최대한 피하기 위해서는 변하는 것과 변하지 않는 것이 무엇인지를 이해하고 이를 추상화의 목적으로 삼아야만 한다.

추상화가 수정에 대해 닫혀 있을 수 있는 이유는 변경되지 않을 부분을 신중하게 결정하고 올바른 추상화를 주의 깊게 선택했기 때문이라는 사실

9.2 생성 사용 분리

결합도가 높아질수록 개방-폐쇄 원칙을 따르는 구조를 설계하기가 어려워짐

알아야 하는 지식이 많으면 결합도가 높아짐

특히 객체 생성에 대한 지식은 과도한 결합도를 초래하는 경향

객체의 타입과 생성자에 전달해야 하는 인자에 대한 과도한 지식은 코드를 특정한 컨텍스트에 강하게 결합시킴

물론 객체 생성을 피할 수는 없다.

부적절한 곳에서 객체를 생성한다는 것이 문제

동일한 클래스 안에서 객체 생성과 사용이라는 두 가지 이질적인 목적을 가진 코드가 공존하는 것이 문제

유연하고 재사용 가능한 설계를 원한다면 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리해야 함

하나는 객체를 생성하는 것, 다른 하나는 객체를 사용하는 것 -> 생성과 사용을 분리(separating use from creation)

사용으로부터 생성을 분리하는 데 사용되는 가장 보편적인 방법은 객체를 생성할 책임을 클라이언트로 옮기는 것

FACTORY 추가하기

Client도 특정한 컨텍스트에 묶이지 않기를 바란다면?

객체 생성과 관련된 책임만 전담하는 별도의 객체를 추가하고 Client는 이 객체를 사용하도록 만들 수 있다.

-> 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 FACTORY라고 부른다

FACTORY를 사용하면 생성하는 책임 모드를 FACTORY로 이동 가능

1
2
3
4
5
6
7
8
9
10
11
public class Factory {
    public Movie createAvatarMovie() {
        return new Movie("아바타",
                Duration.ofMinutes(120),
                Money.wons(10000),
                new AmountDiscountPolicy(
                    Money.wons(800),
                    new SequenceCondition(1),
                    new SequenceCondition(10)));
    }
}

순수한 가공물에게 책임 할당하기

책임 할당의 가장 기본이 되는 원칙은 책임을 수행하는 데 필요한 정보를 가장 많이 알고 있는 INFORMATION EXPERT에게 책임을 할당하는 것

도메인 모델은 INFORMATION EXPERT를 찾기 위해 참조할 수 있는 일차적인 재료

FACTORY는 도메인 모델에 속하지 않는다. 순수하게 기술적인 결정

시스템을 객체로 분해하는 데는 크게 두 가지 방식이 존재한다.

하나는 표현적 분해(representational decomposition)이고 다른 하나는 행위적 분해(behavioral decomposition)

표현적 분해는 도메인에 존재하는 사물 또는 개념을 표현하는 객체들을 이용해 시스템을 분해하는 것

표현적 분해는 도메인 모델에 담겨 있는 개념과 관계를 따르며 도메인과 소프트웨어 사이의 표현적 차이를 최소화하는 것을 목적으로 함

따라서 표현적 분해는 객체지향 설계를 위한 가장 기본적인 접근법

그러나 종종 도메인 개념을 표현하는 객체에게 책임을 할당하는 것만으로는 부족한 경우가 발생

모든 책임을 도메인 객체에게 할당하면 낮은 응집도, 높은 결합도, 재사용성 저하와 같은 심각한 문제점에 봉착하게 될 가능성이 높아짐

이 경우 도메인 개념을 포현한 객체가 아닌 설계자가 편의를 위해 임의로 만들어낸 가공의 객체에게 책임을 할당해서 문제를 해결해야 함

책임을 할당하기 위해 창조되는 도메인과 무관한 인공적인 객체를 PURE FABRICATION(순수한 가공물)

PURE FABRICATION은 보통 특정한 행동을 표현하는 것이 일반적

PURE FABRICATION은 표현적 분해보다는 행위적 분해에 의해 생성되는 것이 일반적

객체지향 애플리케이션은 도메인 개념뿐만 아니라 설계자들이 임의적으로 창조한 인공적인 추상화들을 포함하고 있다.

객체지행 애플리케이션의 대부분은 실제 도메인에서 발견할 수 없는 순수한 인공물로 가득 차 있다.

설계자로서 역할은 도메인 추상화를 기반으로 애플리케이션 로직을 설계하는 동시에 품질의 측면에서 균형을 맞추는 데 필요한 객체들을 창조하는 것

도메인 개념을 표현하는 객체와 순수하게 창조된 가공의 객체들이 모여 자신의 역할과 책임을 다하고 조화롭게 협력하는 애플리케이션을 설계하는 것이 목표

먼저 도메인의 본질적인 개념을 표현하는 추상화를 이용해 애플리케이션을 구축

만약 도메인 개념이 만족스럽지 못하다면 주저하지 말고 인공적인 객체 창조

도메인을 반영하는 애플리케이션의 구조라는 제약 안에서 실용적인 창조성을 발휘할 수 있는 능력은 훌륭한 설계자가 갖춰야 할 기본적인 자질

PURE FABRICATION 패턴

PURE FABRICATION은 INFORMATION EXPERT 패턴에 따라 책임을 할당한 결과가 바람직하지 않을 경우 대안으로 사용됨

9.3 의존성 주입

사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법을 의존성 주입(dependency Injection)

의존성 주입은 의존성을 해결하기 위해 의존성을 객체의 퍼블릭 인터페이스에 명시적으로 드러내서 외부에서 필요한 런타임 의존성을 전달할 수 있도록 만드는 방법을 포괄하는 명칭

  • 생성자 주입(constructor injection): 객체를 생성하는 시점에 생성자를 통한 의존성 해결

    생성자의 인자를 통해 컴파일타임 의존성을 런타임 의존성으로 대체

  • setter 주입(setter injection): 객체 생성 후 setter 메서드를 통한 의존성 해결

    언제라도 의존 대상 교체 가능

    객체가 올바로 생성되기 위해 어떤 의존성이 필수적인지를 명시적으로 표현할 수 없다

  • 메서드 주입(method injection): 메서드 실행 시 인자를 이용한 의존성 해결

    주입된 의존성이 한 두개의 메서드에서만 사용된다면 각 메서드의 인자 전달하는 것이 더 나은 방법일 수 있음

숨겨진 의존성은 나쁘다

의존성 주입 외에도 의존성을 해결할 수 있는 다양한 방법이 존재

가장 널리 사용되는 대표적인 방법은 SERVICE LOCATOR 패턴

SERVICE LOCATOR는 의존성을 해결할 객체들을 보관하는 일종의 저장소

외부에서 객체에게 의존성을 전달하는 의존성 주입과 달리 SERVICE LOCATOR의 경우 객체가 직접 SERVICE LOCATOR에게 의존성을 해결해줄 것을 요청

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ServiceLocator {
    private static ServiceLocator soleInstance = new ServiceLocator();
    private DiscountPolicy discountPolicy;

    public static DiscountPolicy discountPolicy() {
        return soleInstance.discountPolicy;
    }

    public static void provide(DiscountPolicy discountPolicy) {
        soleInstance.discountPolicy = discountPolicy;
    }

    private ServiceLocator() {
    }
}
1
2
3
ServiceLocator.provide(new AmountDiscountPolicy(...));

Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000))

SERVICE LOCATOR 패턴은 의존성을 해결할 수 있는 가장 쉽고 간단한 도구인 것처럼 보임

하지만 SERVICE LOCATOR 패턴은 의존성을 감춘다는 큰 단점 존재

의존성을 구현 내부로 감출 경우 의존성과 관련된 문제가 컴파일타임이 아닌 런타임에 가서야 발견된다

숨겨진 의존성이 이해하기 어렵고 디버깅하기 어려운 이유는 문제점을 발견할 수 있는 시점을 코드 작성 시점이 아니라 실행 시점으로 미루기 때문

의존성을 숨기는 코드는 단위 테스트 작성도 어려움

servicelocator를 공유하기 때문에 각 단위 테스트는 서로 고립돼야 한다는 단위 테스트의 기본 원칙을 위반한 것

문제의 원인은 숨겨진 의존성이 캡슐화를 위반했기 때문

단순히 인스턴스 변수의 가시성을 private으로 선언하고 변경되는 내용을 숨겼다고 해서 캡슐화가 지켜지는 것은 아니다.

캡슐화는 코드를 읽고 이해하는 행위와 관련이 있다.

클래스의 퍼블릭 인터페이스로만 사용 방법을 이해할 수 있는 코드가 캡슐화의 관점에서 훌륭한 코드

숨겨진 의존성이 가지는 가장 큰 문제점은 의존성을 이해하기 위해 코드의 내부 구현을 이해할 것을 강요한다는 것

따라서 숨겨진 의존성은 캡슐화를 위반

숨겨진 의존성은 의존성의 대상을 설정하는 시점과 의존성이 해결되는 시점을 멀리 떨어뜨러 놓음

이것은 코드를 이해하고 디버깅하기 어렵게 만듬

의존성 주입은 이 문제를 깔끔하게 해결

필요한 의존성은 클래스의 퍼블릭 인터페이스에 명시적으로 드러난다.

가급적 의존성을 객체의 퍼블릭 인터페이스에 노출하라

어쩔 수 없이 SERVICE LOCATOR 패턴을 사용해야 하는 경우도 있다.

의존성 주입을 지원하는 프레임워크를 사용하지 못하는 경우나 깊은 호출 계층에 걸쳐 동일한 객체를 계속해서 전달해야 하는 고통을 견디기 어려운 경우에는 어쩔 수 없이 SERVICE LOCATOR 페턴을 사용하는 것을 고려해라

요점은 명시적인 의존성에 초점을 맞추는 것

9.4 의존성 역전 원칙

추상화와 의존성 역전

객체 사이의 협력이 존재할 때 그 협력의 본질을 담고 있는 것은 상위 수준의 정책

그러나 상위 수준의 클래스가 하위 수준의 클래스에 의존한다면 하위 수준의 변경에 의해 상위 수준 클래스가 영항을 받게 됨

의존성은 변경의 전파와 관련된 것이기 때문에 설계는 변경의 영향을 최소화하도록 의존성을 관리해야 한다.

상위 수준의 클래스는 어떤 식으로는 하위 수준의 클래스에 의존해서는 안 된다.

이러한 설계는 재사용성에도 문제가 있다.

상위 수준의 클래스가 하위 수준의 클래스에 의존하면 상위 수준의 클래스를 재사용할 때 하위 수준의 클래스도 필요하기 때문에 재사용하기가 어려워짐

중요한 것은 상위 수준의 클래스

상위 수준의 변경에 의해 하위 수준이 변경되는 것은 납득 가능/ 하위 수준의 변경으로 인해 상위 수준이 변경돼서는 곤란

해결사는 추상화

유연하고 재사용 가능한 설계를 원한다면 모든 의존성의 방향이 추상 클래스나 인터페이스와 같은 추상화를 따라야 함

구체 클래스는 의존성의 시작점이어야 함/ 의존성의 목적지가 돼서는 안 된다.

  1. 상위 수준의 모듈은 하위 수준의 모둘에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다.
  2. 추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항은 추상화에 의존해야 한다.

이를 의존성 역전 원칙(Dependency Inversion Principle)

의존성 역전 원칙을 따르는 설계의 의존성의 방향이 전통적인 절차형 프로그래밍과는 반대 방향으로 나타나기 때문

의존성 역전 원칙과 패키지

역전은 의존성의 방향뿐만 아니라 인터페이스의 소유권에도 적용된다는 것

객체지향 프로그래밍 언어에서 어떤 구성 요소의 소유권을 결정하는 것은 모듈

해당 구체 클래스가 포함된 클래스가 수정되면 패키지가 재컴파일되야 함

불필요한 클래스들을 같은 패키지에 두는 것은 전체적인 빌드 시간을 가파르게 상승시킴

추상화를 별도의 독립적인 패키지가 아니라 클라이언트가 속한 패키지에 포함시켜야 함

그리고 함께 재사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모아야 함

-> SEPARATED INTERFACE 패턴

의존성 역전 원칙에 따라 상위 수준의 협력 흐름을 재사용하기 위해서는 추상화가 제공하는 인터페이스의 소유권 역시 역전시켜야 함

유연하고 재사용 가능하며 컨텍스트에 독립적인 설계는 전통적인 패러다임이 고수하는 의존성의 방향을 역전시킴

객체지향 패러다임에서는 상위 수준 모듈과 하위 수준 모듈이 모두 추상화에 의존한다.

객체지향 패러다임에서는 인터페이스가 상위 수준 모듈에 속함

9.5 유연성에 대한 조언

유연한 설계는 유연성이 필요할 때만 옳다

유연하고 재사용 가능한 설계가 항상 좋은 것은 아님

설계의 미덕은 단순함과 명확함으로부터 나온다.

변경하기 쉽고 확장하기 쉬운 구조를 만들기 위해서는 단순함과 명확함의 미덕을 버리게 될 가능성이 높다.

유연한 설계의 이런 양면성을 객관적으로 설계를 판단하기 어렵게 만든다.

미래애 변경이 일어날지도 모른다는 막연하 불안감은 불필요하게 복잡한 설계를 낳는다.

유연성은 항상 복잡성을 수반한다.

설계가 유연할수록 클래스 구조와 객체 구조 사이의 거리는 점점 멀어진다.

따라서 유연함은 단순성과 명확성의 희생 위에서 자라남

유연한 설계를 단순하고 명확하게 만드는 유일한 방법은 사람들 간의 긴밀한 커뮤니케이션뿐

불필요한 유연성을 불필요한 복잡성을 낳는다.

단순하고 명확한 해법이 그런대로 만족스럽다면 유연성을 제거하라

협력과 책임이 중요하다

설계를 유연하게 만들기 위해서는 협력에 참여하는 객체가 다른 객체에게 어떤 메시지를 전송하는지가 중요

설계를 유연하게 만들기 위해서는 먼저 역할, 책임, 협력에 초점을 맞춰야 함

초보자가 자주 저지르는 실수 중 하나는 객체의 역할과 책임이 자리를 잡기 전에 너무 성급하게 객체 생성에 집중하는 것

중요한 비즈니스 로직을 처리하기 위해 책임을 할당하고 협력의 균형을 맞추는 것이 객체 생성에 관한 책임을 할당하는 것보다 우선

책임의 불균형이 심화되고 있는 상태에서 객체의 생성 책임을 지우는 것은 설계를 하부의 특정한 메커니즘에 종속적으로 만들 확률이 높다.

불필요한 SINGLETON 패턴은 객체 생성에 관해 너무 이른 시기에 고민하고 결정할 때 도입되는 경향이 있다.

참조

  1. 오브젝트
This post is licensed under CC BY 4.0 by the author.