클린 소프트웨어 - 개방 폐쇄 원칙

개방 폐쇄 원칙

변화를 겪으면서도 안정적이고, 첫 번째 버전보다 오래 남는 설계를 만들기

개방 폐쇄 원칙

Open-Closed Principle

  • 소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.

프로그램의 한 군데를 변경한 것이 이것에 의존하는 모듈에서 단계적인 변경을 불러일으킬 때 이 설계는 경직성의 악취를 풍기고 OCP를 위반하게 된다. OCP는 이러한 코드를 리팩토링하여 나중에 이와 같은 변경이 더 이상의 수정을 유발하지 않도록 한다.

OCP를 잘 적용하면 제대로 동작하고 있던 원래 코드를 변경하는 대신, 새로운 코드를 덧붙이는 것으로 변경을 피할 수 있게 된다.

상세 설명

확장에 대해 열려 있다

모듈의 행위는 확장될 수 있다. 요구사항이 변경될 때 이에 맞게 새로운 행위를 추가하여 모듈을 확장할 수 있다.

즉, 모듈이 하는 일을 변경할 수 있다.

수정에 대해 닫혀 있다

하지만 새로운 행위를 추가하는 것이 그 모듈의 소스 코드나 바이너리 코드의 변경을 초래하지 않는다.


일반적으로 어떠한 모듈의 행위를 확장하는 방법은 그 모듈의 소스 코드를 변경하는 것이다. 변경할 수 없는 모듈은 보통 고정된 행위를 하는 것으로 여겨진다.

어떻게 소스 코드를 수정하지 않고도 새로운 기능을 추가하여 모듈의 행위를 변경할 수 있을까?

해결책은 추상화다

추상화는 모든 가능한 파생 클래스를 대표하는, 고정되기는 하지만 가능한 행위의 제한되지 않은 묶음이다. 행위가 제한되지 않다는 것에 주목하자.

모듈은 추상화를 조작할 수 있으며, 이 경우 고정된 추상화에 의존하므로 수정에 대해 닫혀 있을 수 있다. 새로운 행위는 추상화의 새로운 파생 클래스를 만드는 것으로 확장할 수 있다.

[Client] -> [Server]

위의 의존 관계에서 클라이언트는 서버를 사용한다. 클라이언트가 서버에 의존하는 것이다. 클라이언트 객체가 다른 서버 객체를 사용하게 하려면, 클라이언트 클래스가 새로운 서버 클래스를 지정하도록 변경해야 할 것이다.

[Client] -> [Client Interface] <|- [Server]

위의 의존 관계에서 클라이언트는 클라이언트 인터페이스에 의존하고, 서버는 클라이언트 인터페이스를 구현한다. 클라이언트 클래스는 추상화를 사용하지만, 클라이언트 객체는 파생 서버 클래스의 객체를 사용한다. 클라이언트 객체가 다른 서버 객체를 사용하게 하려면, 클라이언트 인터페이스 클래스의 새로운 파생 클래스를 생성하면 되며, 클라이언트 클래스는 변경되지 않는다.

클라이언트는, 클라이언트 인터페이스가 제공하는 추상 인터페이스의 형식으로 행위를 설명할 수 있다. 클라이언트 인터페이스의 서브타입은 원하는 방식대로 그 인터페이스를 구현할 수 있다. 그러므로 클라이언트에 명시된 행위는 클라이언트 인터페이스의 새로운 서브타입을 생성하는 것으로 확장되고 수정될 수 있다.

서버 추상화(ServerAbstraction) 대신 클라이언트 인터페이스(ClientInterface)라고 이름지어진 이유는, 추상 클래스는 자신을 구현하는 클래스보다도 클라이언트에 더 밀접하게 관련되어 있기 때문이다.

[Policy] <|- [Implementation]

위의 의존 관계에서 Policy 내부에 명시된 행위는 Policy 클래스의 새로운 파생 클래스를 생성하는 것으로 확장되거나 수정될 수 있다.

이 패턴들이 OCP를 따르는 가장 흔한 수단이다. 기능의 구체적인 구현으로부터 일반적인 기능을 깔끔하게 분리해낸다.

Shape 애플리케이션

OCP 위반

enum ShapeType {
case circle
case square
}

protocol Shape {
var type: ShapeType { get }
}

struct Circle: Shape {
let type: ShapeType = .circle
...
}

struct Square: Shape {
let type: ShapeType = .square
...
}

func drawCircle(_ circle: Circle) { ... }

func drawSquare(_ square: Square) { ... }

func drawAllShapes(_ shapes: [Shape]) {
for shape in shapes {
switch shape.type {
case .circle:
drawCircle(shape as! Circle)
case .square:
drawSquare(shape as! Square)
}
}
}

위의 코드는 OCP를 위반한다.

일단 drawAllShapes(_:) 함수는 새로운 도형 종류에 대해 닫혀 있지 않으므로 OCP를 위반한다. 새로운 도형 종류가 생기면 drawAllShapes(_:)의 구현은 계속 수정될 것이다.

또한 drawAllShapes(_:)처럼 모든 도형에 대해 특정 동작을 하는 여러 개의 함수가 추가된다면, 그 모든 함수에서 새롭게 추가된 도형 종류를 추가하여 코드를 수정해야 한다. 확실히 간단한 일이 아니게 된다.

ShapeType 열거형의 케이스도 도형 종류가 추가될 때마다 추가된다. 특히 이 열거형 변수의 선언에 모든 도형이 의존하기 때문에 케이스가 추가될 때마다 도형 종류와 관련된 코드를 모두 다시 컴파일해야 하며, Shape에 의존하는 모든 모듈도 다시 컴파일해야 한다.

따라서 단순히 소스 코드 상에서 수많은 코드를 수정해야 하는 것뿐만 아니라, 해당 모듈을 사용하는 모든 모듈의 바이너리 파일도 재컴파일을 통해 변경해야 한다.

새로운 도형을 추가하는 것이 이렇게나 많은 변경을 불러일으키고 있다.

나쁜 설계

모듈을 재컴파일해야 하므로 융통성이 없고, switch문의 새로운 케이스에 대응해야 하므로 취약하며, drawAllShapes(_:)를 다른 프로그램에서 사용하려 해도 필요 없는 SquareCircle을 가지고 가야 하기 때문에 부동성을 갖고 있다. 나쁜 설계의 악취 중 상당수를 풍기고 있다.

OCP 따르기

protocol Shape {
func draw()
}

struct Square: Shape {
func draw() { ... }
}

struct Circle: Shape {
func draw() { ... }
}

func drawAllShapes(_ shapes: [Shape]) {
for shape in shapes {
shape.draw()
}
}

도형을 추상화한 Shape 인터페이스를 만들고 구체 도형이 이를 구현하도록 한다. 이렇게 하면 새로운 도형을 추가하는 것은 코드의 수정을 불러일으키지 않는다. Shape 인터페이스를 구현하는 코드를 추가하여 새로운 도형을 추가할 수 있고, drawAllShapes(_:)의 구현부도 수정할 필요가 없다. 도형을 추가하는 동작은 이 모듈에 대하여 아무런 영향도 주지 않는다.

도형을 추가할 때 프로그램 전체를 뒤지면서 코드를 수정하지 않아도 되니 취약하지 않고, 소스를 추가하는 것으로 행위를 추가하여 재컴파일과 같은 일을 필요로 하지 않기 때문에 변경이 쉬워져 경직성이 없게 된다. 또한 drawAllShapes(_:)는 구체 도형을 알지 못하므로 다른 프로그램에서 그대로 가져다 사용할 수 있으니 부동성이 없게 된다.

결과적으로 위의 코드는 OCP를 따른다. 기존 코드를 변경하기보다는 새로운 코드를 추가하는 것으로 프로그램을 변경한다. 그러므로 일련의 단계적 변경 과정이 없게 된다.

필요한 변경 사항은, 새로운 모듈을 추가하고, 새로운 객체의 인스턴스를 만드는 코드가 위치하는 main과 관련된 변경 뿐이다. 이는 현재 모듈에 영향을 끼치지 않는다.

이전 요구사항 되짚어보기

요구사항에 도형의 그리기 순서를 언급한 부분이 있었다. 이것을 구현하려면 drawAllShapes(_:)에서 Circle을 검색한 다음 Square에 대해 다시 검색해야 한다. 다시 말해서 drawAllShapes(_:)는 변경에 대해 닫혀 있지 않게 되었다.

예상과 ‘자연스러운’ 구조

도형 그리기 순서와 관련된 요구사항이 추가되면서, 이전 단계에서 수행한 추상화가 도움이 되는 대신 변경에 대해 장애가 되어버렸다. 도형을 추상화한다는 생각은 매우 자연스럽지만, 이것이 새로운 요구사항에 대응하는 것에 방해가 될 것이라는 생각은 쉽게 하기 힘들 것이다.

새로운 요구사항은 도형의 순서에 대한 것이고, 이전에 행했던 추상화는 도형의 종류에 대한 것이다. 다시 말해서 순서가 종류보다 더 중요한 시스템에서는 위와 같은 모델은 자연스러운 것이 아니게 된다. 이처럼 모든 상황에서 자연스러운 모델은 없다.

폐쇄는 완벽할 수 없기에 전략적이어야 한다. 가장 많이 변동될 것 같은 개념에 대해 닫혀 있도록 하기 위해 추상화하고, 다른 개념에 대해서는 어쩔 수 없이 변경에 대해 열려 있음을 안고 간다. 가장 많이 변동될 것 같은 개념을 생각해내는 것은 매우 어렵다. 이는 다년간의 경험과 통찰력에 의해 얻어지는 것이다.

OCP를 과도하게 따르면 비용이 많이 들게 된다. 사람이 감당할 수 있는 추상화를 넘어섰다면 소프트웨어 설계의 복잡성을 높일 수 있다.

어떠한 변경이 있을 법한지 알기 위해서는 일단 적절한 연구와 통찰력을 활용하여 판단하고 구현한 후, 변경이 일어날 때까지 기다리는 수밖에 없다.

‘올가미’ 놓기

올가미를 놓는 것은, 향후 일어날 법한 변경에 대응하기 위해 미리 소프트웨어의 설계를 유연하게 가져가는 것이다.

하지만 종종 이것은 효율적으로 사용되기보다는 불필요한 복잡성의 악취를 풍기게 한다. 지나치고 불필요한 추상화로 설계에 부하를 주는 것보다는, 추상화가 실제로 필요할 때까지 기다렸다가 변경이 일어난 후 추상화를 통해 문제를 해결하는 것이 낫다.

‘한 번 속지 두 번 속냐’

소프트웨어를 불필요한 복잡성의 부하에서 구하려면, 일단 한 번은 당할 각오를 해야 한다. 다시 말해서 변경이 일어날 때까지 기다렸다가, 변경이 일어나면 설계를 변경하여 나중에 이런 일이 다시는 오지 않도록 해야 한다는 것이다.

변경 시뮬레이션하기

한 번 당하기로 결정했다면, 이 당하는 시점을 최대한 앞당기는 것이 좋다. 때문에 변경을 시뮬레이션할 필요가 있으며, 이는 다음과 같은 방법으로 실현할 수 있다.

  • 테스트 먼저 작성하기 : 테스트는 시스템을 사용하는 방법 중 하나이며, 테스트를 먼저 작성함으로서 시스템을 테스트 가능하게 만들 수 있다. 이 과정에서 변경이 발생할 수 있고, 변경을 해결하는 추상화를 수행했을 것이므로, 나중에 실제로 변경이 발생했을 때 놀라지 않고 대응할 수 있다.

  • 아주 짧은 주기로(주보다는 일 단위) 개발하기

  • 기반구조보다 기능 요소를 먼저 개발하고, 이를 자주 이해당사자에게 보여주기

  • 가장 중요한 기능을 먼저 개발하기

  • 소프트웨어를 자주, 빨리 릴리즈하기. 가능한 한 빠르게, 가능한 한 자주 고객에게 소프트웨어를 시연하기

명시적인 폐쇄를 위해 추상화 사용하기

폐쇄는 추상화에 기반을 둔다. 그러므로 도형을 그리는 순서에 대해 닫기 원한다면 순서에 대해 추상화해야 한다.

protocol Shape {
func draw()
func precedes(from: Shape) -> Bool
}

struct Square: Shape {
func draw() { ... }
func precedes(from other: Shape) -> Bool {
if other is Square {
return true
} else {
return false
}
}
}

위의 Squareprecedes(from:) 메소드 구현은 OCP를 따르지 않는다. Shape의 새로운 파생 클래스가 생성될 때마다 해당 메소드의 구현이 변경되어야 하기 때문이다.

물론 더 이상 파생 클래스가 생성되지 않는다는 것이 명백하면 문제되지 않지만, 그렇지 않다면 위에서 언급한 문제를 일으킬 것이다.

폐쇄를 위해 ‘데이터 주도적’ 접근 방식 사용하기

static var typeOrderTable: [String] { get }

도형의 종류를 추상화하는 것으로 도형의 종류에 대한 변경을 닫았고, 데이터 테이블을 사용하여 순서에 대한 변경을 닫았다.

테이블 자체는 닫혀 있지 않으나 다른 모듈에 위치할 수 있으므로 이것에 대한 변경은 다른 모듈에 아무런 영향을 주지 않는다.

결론

OCP는 객체 지향 설계의 심장이다. 이를 준수하는 것으로 유연성, 재사용성, 유지보수성 등 객체 지향 기술에서 당연하게 요구하는 최상의 효용을 가져올 수 있다.

하지만 프로그램에 추상화를 마구 적용하는 것도 좋은 생각은 아니다. 자주 변경되는 부분에만 추상화를 적용하는 것이 좋다.

어설픈 추상화를 피하는 일은 추상화 자체만큼이나 중요하다.