개방 폐쇄 원칙(Open - Closed Principle)
Last updated
Last updated
확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
정의를 구체적으로 해석해보면, 아래와 같다.
기능 변경과 확장할 수 있으면서, 사용하는 코드는 수정하지 않는다.
개방 폐쇄 원칙은 추상화와 상속을 통해 지켜질 수 있다.
개방 폐쇄 원칙을 구현하는 첫 번째 방법은 추상화를 이용하는 것이다.
추상화는 앞서 아래 그림과 같이 구조를 만들었던 것을 보면 알 수 있다.
메모리에서 byte를 읽어 오는 기능을 추가해야 할 경우, ByteSource 인터페이스를 상속받은 MemoryByteSource 클래스를 구현함으로써 기능 추가가 가능하다. 그리고 새로운 기능이 추가되었지만, 이 새로운 기능을 사용할 FlowController 클래스의 코드는 변경되지 않는다. 즉, 기능을 확장 하면서도 기능을 사용하는 기존 코드는 변경되지 않는 것이다.
개방 폐쇄 원칙은 (사용되는 기능의) 확장에는 열려 있고 (기능을 사용하는 코드의) 변경에는 닫혀 있다고 표현한다.
위 그림과 같은 구조가 개방 폐쇄 원칙을 구현할 수 있는 이유는 확장되는 부분(즉, 변화되는 부분)을 추상화해서 표현했기 때문이다. 변화되는 부분은 byte 데이터를 읽어 오는 기능이었다. FlowController 클래스 입장에서 변화되는 부분을 ByteSource 인터페이스로 추상화함으로써 byte 읽기 기능을 고정시킬 수 있게 되었다.
따라서, byte 읽기 기능을 고정시켰기 때문에, 새로운 byte 읽기 기능 구현을 추가하면서도 FlowController 클래스를 수정하지 않아도 된 것이다.
개방 폐쇄 원칙을 구현하는 또 다른 방법은 상속을 이용하는 것이다.
상속은 상위 클래스의 기능을 그대로 사용하면서 하위 클래스에서 일부 구현을 오버라이딩 할 수 있는 방법을 제공한다.
예를 들어, 클라이언트의 요청이 왔을 때 데이터를 HTTP 응답 프로토콜에 맞춰 데이터를 전송해 주는 객체가 있다고 하자.
ResponseSender 클래스의 send() 메서드는 헤더와 뭄체 내용을 전송하기 위해 sendHeader() 메서드와 sendBody() 메서드를 차례대로 호출하며, 이 두 메서드는 알맞게 HTTP 응답 데이터를 생성한다. sendHeader() 메서드와 sendBody() 메서드는 protected 공개 범위를 갖고 있기 때문에, 하위 클래스에서 이 두메서드를 오버라이딩 할 수 있다.
만약 압축해서 데이터를 전송하는 기능을 추가하고 싶다면, ResponseSender 클래스를 상속받은 클래스에서 sendHeader() 메서드와 sendBody() 메서드를 오버라이딩하면 된다.
ZippedResponseSender 클래스는 기존 기능에 압축 기능을 추가해 주는데, 이 기능을 추가하기 위해 ResponseSender 클래스 코드는 바뀌지 않았다. 즉, ResponseSender 클래스는 확장에는 열려 있으면서 변경에는 닫혀 있는 것이다.
ResponseSender 클래스 예제는 템플릿 패턴을 사용한 것이다. 템플릿 메서드 패턴은 상위 클래스에서 실행할 기본 코드를 만들고 하위 클래스에서 필요에 따라 확장해 나가는 패턴이다.
추상화와 다형성을 이용해서 개방 폐쇄 원칙을 구현하기 때문에, 추상화와 다형성이 제대로 지켜지지 않은 코드는 개방 폐쇄 원칙을 어기게 된다. 개방 폐쇄 원칙을 어기는 코드의 전형적인 특징은 다음과 같다.
다운 캐스팅을 한다.
예를 들어, 슈팅 게임을 개발하는 경우 플레이어, 적, 미사일 등을 그리기 위해 아래와 같은 상속 관계를 사용할 수 있다.
화면에 이들 캐릭터를 표시해 주는 코드가 다음과 같을 수 있다.
위의 코드는 character 파라미터의 타입이 Missile인 경우 별도 처리를 하고 있다. 만약 위와 같이 특정 타입인 경우에 별도 처리를 하도록 drawCharacter() 메서드를 구현한다면 drawCharacter() 메서드는 Character 클래스가 확장될 때 함께 수정되어야 한다. 즉, 변경에 닫혀 있지 않은 것이다.
instanceof와 같은 타입 확인 연산자가 사용된다면 해당 코드는 개방 폐쇄 원칙을 지키지 않을 가능성이 높다. 이런 경우에는 타입 캐스팅 후 실행하는 메서드가 변화 대상인지 확인해 봐야 한다.
예를 들어, 위 코드의 경우 타입이 Missile이면 타입 변환 뒤에 drawCharacter() 메서드를 호출하는데, 이 drawCharacter() 메서드가 실제로 객체마다 다르게 동작할 수 있는 변화 대상인지 확인해 보는 것이다.
만약 향후에 객체들마다 다르게 동작할 가능성이 높다면 이 메서드를 알맞게 추상화해서 Character 타입에 추가해 주어야 한다.
개방 폐쇄 원칙을 깨뜨리는 코드의 또 다른 특징은 다음과 같다.
비슷한 if-else 블록이 존재한다.
앞의 게임 캐릭터를 이용해서 예를 들어 보자.
Enemy 캐릭터의 움직이는 경로를 몇 가지 패턴으로 정한다고 하자. 이 때, 정해진 패턴에 따라 경로를 이동하는 코드는 아래와 같이 작성할 수 있다.
Enemy 클래스에 새로운 경로 패턴을 추가해야 할 경우 Enemy 클래스의 draw() 메서드에는 새로운 if 블록이 추가된다. 즉, 경로를 추가하는데 Enemy 클래스가 닫혀 있지 않은 것이다. 이를 개방 폐쇄 원칙을 따르도록 변경하면, 아래 그림과 같이 경로 패턴을 추상화하고 Enemy에서 추상화 타입을 사용하는 구조로 바뀐다.
Enemy 코드는 PathPattern을 사용하도록 변경된다.
이제 새로운 이동 패턴이 생기더라도 Enemy 클래스의 draw() 메서드는 변경되지 않으며, 새로운 타입의 PathPattern 구현 클래스를 추가해 주기만 하면 된다.
개방 폐쇄 원칙은 변경의 유연함과 관련된 원칙이다. 만약 기존 기능을 확장하기 위해 기존 코드를 수정해 주어야 한다면, 새로운 기능을 추가하는 것이 점점 힘들어진다. 즉, 확장에는 당히고 변경에는 열리는 반대 상황이 발생하는 것이다.
앞서 FlowController와 ByteSource 예에서 보듯이, 개방 폐쇄 원칙은 변화되는 부분을 추상화(ByteSource 인터페이스)함으로써 사용자(FlowController) 입장에서 변화를 고정시킨다. 이를 통해 사용자가 ByteSource의 확장에 폐쇄적일 수 있도록 만들어 준다.
상속을 이용한 개방 폐쇄 원칙 구현 예에서도 ResponseSender 클래스는 변화되는 부분을 sendHeader() 메서드와 sendBody() 메서드로 고정시켰다. 하위 클래스에서는 이 두 메서드를 오버라이딩 함으로써 기존 기능을 확장할 수 있었고, 반면에 이 두 기능을 하위 클래스에서 변경하더라도 ResponseSender 클래스는 변경할 필요가 없었다.
개방 폐쇄 원칙은 변화가 예상되는 것을 추상화해서 변경의 유연함을 얻도록 해준다. 따라서 코드에 대한 변화 요구가 발생하면, 변화와 관련된 구현을 추상화해서 개방 폐쇄 원칙에 맞게 수정할 수 있는지 확인하는 습관을 가져야 한다.