Skip to content

Chapter 10: Evolving Frameworks #1508

Description

@jongfeel

10 발전하는 프레임워크

구현 패턴은 코드를 이해하고 커뮤니케이션하는 데 드는 비용에 비해 코드 수정에 드는 비용이 훨씬 저렴하다고 가정했다.
대부분은 사실이지만 프레임워크 개발의 경우 프레임워크 개발자가 클라이언트 코드를 수정할 수 없으므로 이 가정이 성립하지 않는다.
호환성을 깨뜨리는 업데이트에 대한 비용은 너무 비싸므로 그런 업데이트는 최대한 피해야 한다.

애플리케이션 수정 없이 프레임워크 수정하기

프레임워크 개발의 근본적인 딜레마는 프레임워크를 지속적으로 발전시키면서 기존 클라이언트 코드는 계속해서 동작하도록 해야 한다는 것이다.
호환성을 유지하는 업그레이드가 항상 가능한 것은 아니므로 유지하기 위한 프레임워크의 복잡도가 올라가기도 한다.
어떤 경우에는 완벽한 호환성을 유지하기 위한 비용이 클라이언트에 제공하는 가치보다 큰 경우도 있다.
프레임워크 개발에서는 기존 클라이언트 코드의 동작을 보장하면서 프레임워크를 발전시키기 위해 복잡도를 어느 정도 높이는 것이 좀더 효율적인 경우가 많다.

프레임워크를 개발할 때는 복잡도를 낮출 만한 발전의 여지를 남겨 두면서도 충분히 단순해서 당장 사용할 수 있어야 한다.
적용 범위를 확대할 여지를 남겨두면서 당장 사용할 수 있는 적용성도 가져야 한다.
이런 제약으로 인해 프레임워크 개발은 애플리케이션 개발에 비해 위험성이 높고 비용이 많이 든다.
다양한 구현 패턴을 사용해 유용하면서도 변화에 잘 대응할 수 있도록 프레임워크를 개발하고 설치하고 수정할 수 있다.

호환성 없는 업그레이드

클라이언트 입장에서 업그레이드를 여러 단계로 나누면 앞으로의 변화를 준비할 수 있어서 비용을 줄일 수 있는 여지가 있다.
프레임워크에서 어떤 기능을 더 이상 지원하지 않기로 해도 한 두 번의 릴리스에서 해당 기능을 계속 제공하면 클라이언트는 새 API를 적용하는 준비를 할 수 있게 된다.
병렬 아키텍처는 복잡도를 높이지만 업그레이드로 인한 문제점을 줄여준다.

점진적 업그레이드 전략으로 API와 구현을 바꾸되 한 번에 다 바꾸지 않는 것이다.
인터페이스를 바꾸거나 구현을 바꾼 중간 릴리스 코드는 프레임워크를 제공하는 쪽과 클라이이언트 쪽 모두 변화의 방향에 익숙해질 시간을 제공한다.
프레임워크 제공자라면 빠른 시일 내에 프레임워크를 발전시키는 것과 안정적인 버전을 사용하려고 하는 클라이언트의 필요성 사이에서 균형을 맞추도록 노력해야 한다.

클라이언트가 단순히 편집기의 replace 기능으로 기존 코드를 업그레이드할 수 있다면 코드 변경에 대한 비용을 절감할 수 있을 것이다.
메소드 이름이 바뀌는 경우라도 인자의 순서가 바뀌지 않는다면 쉽게 코드를 바꿀 수 있다.
당장 업그레이드 비용을 낮추기 위해 설계 옵션이 줄어드는 걸 감수해야 할 때도 있다.

호환성이 없는 업그레이드를 관리할 때 고려해야 할 다른 요소는 클라이언트 커뮤니티의 구성과 성장세이다.
최신 기능을 사용하고자 하는 클라이언트들은 업그레이드에 드는 노력을 감내해야 한다.
400명 고객의 불평 때문에 4000명의 고객 업그레이드를 포기하지는 못하지만
환상에 사로잡혀서 실제 고객과 멀어지는 실수를 하진 말아야 한다.

호환성을 유지하는 업그레이드

클라이언트 코드는 가급적 프레임워크의 세부 구현에 의존하지 않아야 한다.
하지만 어느 정도 프레임워크의 세부 구현에 의존할 수 밖에 없다.
아니라면 프레임워크 존재 의미가 없을 것이기 때문이다.
이상적으로 클라이언트는 프레임워크의 변하지 않는 부분invariants에만 의존적이어야 한다.
가급적 눈에 보이는 세부 사항을 숨기고 변하지 않는 부분을 노출시키면 나중에 유연한 설계 변경이 가능하다.

어떤 종류의 호환성을 제공할지 결정해야 하는데
후방 호환성backward compatibility 업그레이드는 프레임워크에 구형 메소드 호출과 구형 객체 전달 지원이고
전방 호환성forward compatibility 업그레이드는 신형 스타일의 격체를 클라이언트에 전달해도 동작하게 할 것이다.
JUnit의 경우 전방/후방 호환성을 모두 제공하기로 했기 때문에 개발 비용이 비쌌다.
하지만 지원해야 하는 기존 테스트의 수가 무척 많았고 업그레이드를 필요로 하는 사용자의 수가 많아서 두 가지 호환성을 모두 지원하는 건 맞는 작전이었다.

라이브러리 클래스

라이브러리 클래스를 사용하면 복잡도를 높이지 않으면서도 미래에 대비할 수 있다.
Collections 클래스는 라이브러리 클래스를 통해 API를 제공한 좋은 예이다.
라이브러리 클래스는 인스턴스로 사용하고 정적 메소드는 호출해서 사용한다.
새로운 버전의 라이브러리 클래스는 기존 기능의 호환성을 유지하고 새로운 정적 메소드만 추가한다.

객체

프레임워크를 객체로 나타내는 경우, 단순성과 복잡성 사이, 유연성과 구체성 사이에서 규현을 잡기 더 어려워진다.
이 방법은 아래 네 가지 이슈가 있다.

  • 사용 스타일: 인스턴스로 사용하는가? 상속으로 사용하는가?
  • 추상화: 클래스 수준의 세부 사항을 인터페이스로 나타낼 것인가? 안정적인 세부 사항의 가시성은 어떻게 할 것인가?
  • 생성: 어떻게 객체를 생성하는가?
  • 메소드: 클라이언트 쪽에는 유용성, 프레임워크 개발자 쪽에는 유연성을 제공하는 방법

사용 스타일

프레임워크는 인스턴스화instantiation, 설정configuration, 구현implementation의 세 가지 주요 스타일을 지원한다.
세 가지가 혼합된 프레임워크를 사용한다면 프레임워크 개발자를 위한 설계 자유도와 클라이언트를 위한 편의성 사이에서 균형을 맞출 수 있다.

인스턴스화는 단순하므로 클라이언트가 로직의 변형을 필요로 하지 않고 데이터의 변형만을 필요로 하는 경우 인스턴스화를 쓴다.

설정은 인스턴스화보다 복잡하지만 유연하다.
클라이언트는 프레임워크 객체를 생성하고 자신만의 객체를 프레임워크에 전달해서 프레임워크에서 사용하도록 한다.
아래 코드처럼 TreeSet은 클라이언트에서 정의하는 Comparator를 사용해서 원소 사이의 정렬을 수행한다.

Comparator<Author> byFirstName= new Comparator<Author>() {
    public int compare(Author book1, Author book2) {
        return book1.getFirstName().compareTo(book2.getFirstName());
    }
};
SortedSet<Author> sorted= new TreeSet<Author>(byFirstName);

설정은 데이터와 함께 로직의 변형도 지원하므로 인스턴스화보다 유연하다.
하지만 같은 인터페이스를 사용하므로 프레임워크를 발전시키는데 제한이 생기고 클라이언트 코드에 대한 호환성을 보장해주기 어려워진다.

클라이언트가 설정에서 제공하는 것 이상으로 많은 종류의 로직의 변형을 필요로 하면 구현에 의한 사용을 제공할 수 있다.
클라이언트가 클래스를 생성해서 프레임워크에 전달하고, 클라이언트 클래스가 프레임워크의 클래스나 인터페이스를 확장하거나 구현하는 한 클라이언트는 어떤 로직이라도 사용할 수 있다.

객체를 사용하는 마지막 스타일인 구현은 미래에 있을 설계 변경을 가장 크게 제한한다.
호환성을 위해 프레임워크에서 제공하는 상위클래스나 인터페이스를 모두 그대로 유지해야 하기 때문이다.
프레임워크가 세부사항을 노출하면 클라이언트는 자신의 코드를 사용할 수 있게 되지만 프레임워크 개발자는 기존 클라이언트 코드와의 호환성을 포기하지 않으면 같은 인터페이스를 계속 사용해야 한다.

구현 스타일은 설정에 비해 표현할 수 있는 범위가 훨씬 넓다.
필요한 경우 프레임워크에서 제공하는 메소드에 원하는 변형만을 구현하면 되기 때문이다.

JUnit의 경우 여태까지 살펴본 모든 스타일을 제공한다.

  • run(Class...) 정적 메소드를 통해 모든 클래스의 모든 테스트를 수행한다.
  • 각 인스턴스에 대해 테스트 수행과 결과 통보에 관한 세부 지시를 할 수 있다.
  • @test, @before, @after와 같은 표기를 통해 어떤 코드가 언제 수행되어야 하는지 설정할 수 있다.
  • @RunWith는 일종의 구현이다. 테스트 수행이 적합하지 않은 경우 자신만의 수행 절차를 구현할 수 있다.

추상화

구현 스타일을 사용하려면 추상화된 개념을 인터페이스로 전달해야 하는지 상위클래스로 전달해야 하는지 결정해야 한다.
각 방법에는 개발자 입장과 클라이언트 입장에서 장단점이 있다.
두 가지 기법이 서로 배타적인 것은 아니어서 프레임워크에서 인터페이스와 기본 구현을 모두 제공하는 것도 가능하다.

*인터페이스

인터페이스를 클라이언트에게 제공할 때 장점은 세부 사항을 적게 드러낼 수 있다는 것이다.
문제는 인터페이스가 바뀔 경우인데, 새로운 메소드가 추가된다면 기존 구현된 클라이언트가 동작하지 않게 된다.

인터페이스는 여러개를 동시에 구현할 수 있으므로 하나의 클래스에 구현한다면 명확하고 직접적인 커뮤니케이션을 돕는다.
반대로 관련 없는 인터페이스들을 구현한다면 목적을 전달하기 어렵게 된다.

인터페이스에 유연성을 부여한 형태로 버전 인터페이스가 있다.
하위 인터페이스를 만들어서 연산을 추가해도 기존 인터페이스에 영향을 주지 않는다.
하지만 이런 유연성에는 복잡도의 증가라는 비용이 따른다.
프레임워크는 새로운 인터페이스에 있는 연산을 호출한다면 명시적 타입 변환을 해줘야 한다.

버전 인터페이스는 클라이언트 코드에 영향을 주지 않고 기존 인터페이스에 새로운 연산을 추가할 때 사용하는 합리적인 기법이다.
하지만 복잡도를 증가시키므로 자주 변경되는 추상화에 오랜 기간 사용하긴 어렵다.
자주 변경되는 추상화라면 상위클래스를 사용하는 것이 좋다.

*상위클래스

이 방법은 인터페이스 방법의 장단점과 반대이다.
클래스는 세부사항 표현이 가능하면서 상위클래스에 새로운 메소드를 추가해도 호환성 문제가 일어나지 않는다.
하지만 클라이언트 클래스는 단 하나의 프레임워크 클래스만을 상속할 수 있다.

상위클래스의 세부 사항은 너무 많이 공개하게 되면 향후 설계 수정을 하는 데 있어서 제약이 따른다.
그래서 세부 사항의 노출 범위를 줄여서 인터페이스에서 노출하는 수준에 가깝게 하면 좋다.

abstract 키워드는 클라이언트에게 어떤 로직을 제공해야 하는지 알려줄 수 있다.
문법적으로 상위 클래스에 abstract 메소드를 추가하면 하위 클래스에 반드시 새로운 메소드를 구현해야 하는 호환성 문제가 발생하므로 주의해야 한다.

final 키워드는 하위클래스를 생성할 수 없으므로 인스턴스화나 설정 중 하나의 방법으로 프레임워크를 사용하게 제한할 수 있다.
final 메소드는 언제나 어떤 메소드가 수행될지 보장해주는 걸 뜻한다.

생성

인스턴스하는 방법을 정할 때는 일반성, 복잡도, 사용의 용이성, 발전의 용이성 등을 모두 고려해서 균형 잡힌 결정을 해야 한다.

*생성 금지

클라이언트가 프레임워크 객체를 직접 생성하는 걸 금지하는 것이다.
이건 단순하지만 유용하지 않다.
반대로 프레임워크 내부에서 생성해서 클라이언트에 전달해 주는 식이므로 프레임워크 쪽에서는 복잡도를 낮출 수 있다.
복잡도를 줄이는 방향으로는 좋은 선택이지만 프레임워크 개발자가 예측하지 못한 클래스를 사용하지 못하는 단점도 있다.
따라서 객체 생성을 금지한다면 프레임워크의 가치를 높일 수 있는 기회를 놓치게 된다.

*생성자

생성자를 공개하는 건 클래스 이름, 객체 생성에 필요한 파라미터, 클래스가 속한 패키지 등 반환되는 구상 클래스의 대부분의 요소를 수정하지 않겠다고 선언하는 것과 같다.
이 방법의 장점은 단순 명확하다는 것이다.
클라이언트가 사용하기 쉬운 인터페이스를 원하고 프레임워크의 클래스 패키지, 이름, 구상 클래스를 바꿀 일이 없다면 생성자를 공개하는 것이 합리적인 선택이다.

*정적 공장static factories

클라이언트는 객체를 생성하는 복잡도가 상당히 증가하지만, 프레임워크는 향후 설계 수정 방향의 유연성을 얻게 된다.
보통 ArrayList.create() 형태인데, 클라이언트 코드 변경 없이 객체 이름, 패키지, 구상 클래스 까지 변경 가능하다.
생성 과정이 추상화되어 있고 생성 메소드 이름이 통일되어 있지 않기에, 객체 생성이 명시적으로 어느 곳에서 일어나는지 알기 어려워진다.
대신 객체 생성의 변형을 클라이언트에게 더 명확하게 전달할 수 있다.
생성자 파라미터를 통해 생성한 경우 목적을 명확히 알기 어렵지만, 공장 메소드는 그 이름을 통해 객체 생성에 어떤 변형을 줄 수 있는지 알려줄 수 있다.

*공장 객체factory object

공장 객체에 메시지를 보내 인스턴스를 생성하는 방법도 있다.
Collections.factory().createArraysList()와 같은 메소드 호출을 통해 리스트를 생성하는 것이다.
정적 메소드 보다는 유연성을 얻겠지만 코드를 읽기 어려워진다.
이 역시도 어떤 객체가 생성되는지 알기 위해 코드 위치를 파악해가야 하기 때문이다.

공장 객체가 전역이라면 정적 메소드와 마찬가지가 된다.
공장 객체는 로컬에서 사용해야 좀더 효과적이다.

*생성 - 맺음말

프레임워크에서 객체 생성을 어떻게 하느냐는 프레임워크의 사용과 수정의 용이성에 영향을 준다.
한 가지 전략으로 변할 것 같은 객체에는 공장 메소드를 사용하고 안정적인 클래스에는 생성자를 사용한다.
일관성을 얻으려 한다면 모든 생성 방법을 공장 메소드나 공장 객체를 통해서만 하는 것도 나름의 장점이 생긴다.

메소드

메소드 역시 프레임워크의 사용과 수정에 영향을 미친다.
클라이언트의 문제 해결을 도와주면서 세부사항은 적게 노출하는 방향으로 한다.

메소드를 통해 내부 자료 구조를 수정하지 않는 방향이 좋다.
클라이언트가 내부 자료 구조에 의존한다면 프레임워크를 수정하는 건 어려워지기 때문이다.
먼저 클라이언트가 값을 설정해서 어떤 문제를 해결하려고 하는지 파악한 후에
설정 메소드를 공개하는 것 보다 클라이언트개 해결하려는 문제에 대한 메소드를 제공하는 것이 좋다.

종종 설정이나 취득 메소드를 공개해야 하는 경우는 있다.
프레임워크의 기능 중에 필드를 반환하거나 설정해서 구현해야 한다면 접근자를 공개해야 한다.
그렇다고 해도 메소드 이름을 내부 구현을 유추할 수 있게 하기 보다는 역할 수행의 의미를 줄 수 있게 한다.

이미 공개된 메소드라면 호환성을 유지하기 위해 기본 값을 제공하라.
새로운 메소드에 파라미터를 추가하면 기존 메소드를 사용한 코드를 모두 수정해야 하기 때문이다.

맺음말

프레임워크의 개발과 발전에는 애플리케이션 개발에서 사용하는 것과는 다른 구현 패턴이 필요하다.
프레임워크 개발의 경제성은 기존 클라이언트 코드를 업그레이드 하는 비용에서 결정되므로 기존 구현 패턴에서 사용하는 가치와 기법들 역시 변해야 한다.
애플리케이션 개발 - 단순성
프레임워크 개발 - 확장성
이므로 우선순위가 다르다.

프레임워크를 완벽한 상태로 배포하는 건 어려운 일이므로
지속적으로 클라이언트가 안정적으로 사용하면서 확장이 쉬운 프레임워크를 제공하는 방향을 생각해야 한다.

Metadata

Metadata

Assignees

Projects

Status
Done

Relationships

None yet

Development

No branches or pull requests

Issue actions