Java/객체지향

객체 지향과 디자인 패턴 Chapter 05 - 개방 폐쇄 원칙(SOLID)

_Jin_ 2024. 11. 5.

더보기
'개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴'을 읽고 학습합니다. ( 최범균 저 )

 

개방 폐쇄 원칙(Open-closed principle), ' 일명 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다. ' 는 말로 들으면 무슨 소리인지 알 수 없는 이 원칙에 대해서 알아보자.

 

개방 폐쇄 원칙(Open-close principle)

→ ' 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다. '

 

이를 좀 더 구체적인 말로 표현하자면 다음과 같이 풀이할 수 있다. 

기능을 변경하거나 확장할 수 있으면서, 그 기능을 사용하는 코드는 수정하지 않는다.

 

기능을 변경하면서도 그 기능을 사용하는 코드를 사용하는 코드는 수정하지 않는다?

말로는 전혀 와닿지 않는다. 이를 이해하기 위해서는 앞서 학습한 추상화 부분의 이해가 필수적이다. (아래의 글을 읽고 만약 이해가 안된다면, 앞의 내용을 다시 읽거나 구글링 통해 학습하자.)

 

인터페이스를 통한 상속과 구현 상속(오버라이딩)을 통해 다형성을 이용하여 변경에 유연하면서 확장성 있는 설계가 가능했다. 
이에 ' 인터페이스에 대고 프로그래밍하기 '와 같은 객체 지향의 규칙이 존재하며 클라이언트 입장(기능을 사용하는 코드)에서 구현체에 의존하지 않음으로 추상화는 책임을 분리할 수 있는 촉매제 역할임을 확인했었다.

 

그리고 추상화를 이용한 경우 아래와 같은 모습을 보였다.

새로운 요구사항에 따른 구현체 추가 ( <<Annotation>> 이 아니라 <<Interface>> 이다. )

 

 

만약 위의 상태에서 메모리를 이용하여 Byte를 읽어오는 기능을 추가해야 할 경우, 인터페이스를 상속받은 클래스 구현체를 구현하면 기능 추가가 가능하다. 기능을 추가하였지만, 이를 사용하는 클라이언트( FlowController ) 는 변경되지 않는다. 

 

즉, 위에서 말한 기능을 변경하거나 확장할 수 있으면서도, 그 기능을 사용하는 코드는 수정하지 않는다는 것이 이런 모습이다.

이를 개방 폐쇄 원칙은 ( 사용되는 기능의 ) 확장에는 열려 있고 ( 기능을 사용하는 코드의 ) 변경에는 닫혀 있다고 표현하는 것이다.

개방 폐쇄 원칙을 구현할 수 있는 이유는 확장( 변화되는 부분 )을 추상화해서 사용하기 때문이다.

 


 

개방 폐쇄 원칙을 구현하는 또 다른 방법은 상속을 이용하는 것이다.

상속은 상위 클래스의 기능을 사용하면서 하위 클래스에서 오버라이딩 할 수 있는 방법을 제공하기 때문이다.

예를 들어, 클라리언트 요청이 왔을 때, HTTP 응답 프로토콜에 맞춰 데이터를 전송해주는 클래스가 있다고 해보자.

 

public class ResponseSender {

    private Data data;
    
    public ResponseSender(Data data) {
    	this.data = data;
    }
    
    public Daata getData() {
    	return data;
    }
    
    public void send() {
    	sendHeader();
        sendBody();
    }
    
    protected void sendHeader() {
    	// 헤더 데이터 전송 로직
    }
    
    protected void sendBody() {
    	// 텍스트로 데이터 전송 로직
    }
}

 

위의 코드를 보면, 메세지를 전송하는 두 메서드는 protected 접근 제한자를 지니고 있다.

따라서 상속을 통한 오버라이딩의 의미를 담고 있는 것으로 파악할 수 있다.

 

그리고 만약 압축을 통한 데이터 전송의 기능을 추가하고 싶다면??

상속을 사용해보자.

public class ZippedResponseSender extends ResponseSender {
	
    public ZippedResponseSender(Data data) {
    	super(data);
    }
    
    @Override
    protected void sendBody() {
    	// 데이터 압축 처리
    }
}

 

이처럼 기존 코드에서 압축 기능을 추가하는데, 이 기능을 추가하기 위해 ResponseSender 클래스의 코드는 변경하지 않았다.

확장에는 열려 있으면서 변경에는 닫혀 있는 것이다.

 


 

개방 폐쇄 원칙이 깨질 때의 주요 증상

 

개방 폐쇄 원칙은 추상화와 다형성을 사용해서 구현한다.

따라서 추상화를 통한 다형성의 목적이 제대로 지켜지지 않으면 개방 폐쇄 원칙이 깨지는 데, 이러한 코드의 전형적인 특징은 아래와 같다.

 

다운 캐스팅을 한다.

예를 들어, 슈팅 게임을 개발하는 경우 플레이어, 적, 미사일 등이 필요하여 아래와 같은 상속 관계를 지니게 된다.

출처 : https://koseungbin.gitbook.io/wiki/books/undefined/part-2.-di/solid/open-closed-principle

 

그리고 화면에 각 캐릭터를 표시하는 코드가 아래와 같이 다운 캐스팅을 통해서 구현되었다고 고려해보자.

 

public void drawCharacter(Character character) {
	
    if(character instanceof Missile) { // 타입 확인
    	Missile missile = (Missile) character; // 타입 다운 캐스팅
    	missile.drawSpecific();
    } else {
    	character.draw();
    }
}

 

Missile 타입인 경우 다운 캐스팅하여 해당 메서드를 사용한다. 

이런 경우, 특정 타입에 따라 화면에 표시하기 위한 메서드인 drawSpecific()는 Character 클래스를 상속받는 객체가 많아지면( 확장되는 경우 ) 함께 수정이 필요하다.

즉, 해당 기능을 사용하려는 클라이언트에게 변경이 닫혀있지 않은 모습이다.

 

instanceof와 같이 타입 확인 연산자를 사용하는 것은 개방 폐쇄 원칙을 지키지 않을 가능성이 높다.

이런 경우에는 타입 캐스팅 후에 실행하는 메서드가 변화 대상인지 확인하는 것이 좋다.

 

예를 들어, 위의 코드에서 drawSpecific() 메서드가 구현체마다 다르게 동작할 수 있는 변화 대상이라면 메서드를 알맞은 추상화를 통해서 Charater 타입에 추가하자.

 

비슷한 IF-ELSE 블록이 존재한다.

앞의 게임 캐릭터를 이용해서 예를 들어보자.

Emeny 캐릭터의 움직이는 경로를 몇 가지 패턴으로 정한다고 고려해보자.

그리고 이를 코드로 구현하면 아래와 같은 모습일 수 있다. 

 

public class Enemy extends Character {
	
    private int pathPattern;
    
    public Enemy(int pathPattern) {
    	this.pathPattern = pathPattern;
    }
    
    public void draw() {
    	if(pathPattern == 1) {
        	x += 4;
        } else if(pathPattern == 2) {
        	y += 10;
        } else id(pathPattern ==4 {
        	x += 4;
            y += 10;
        )
        
        
        ...;  
    }
}

 

지금의 코드 형태를 Enemy 클래스가 경로 패턴의 기능을 필요로하는 클라이언트로 바라보자.

만약 새로운 경로를 추가할 필요가 생긴다면, 새로운 if 블록을 작성하게 된다. 

경로 패턴을 지정하는 기능을 필요로하는 클라이언트(Enemy)가 변경에 닫혀있지 않은 것이다.

 

이를 개방 폐쇄 원칙을 따르도록 변경하면, 추상화와 다형성을 통해 아래와 같은 구조로 리펙토링할 수 있을 것이다.

출처 : https://koseungbin.gitbook.io/wiki/books/undefined/part-2.-di/solid/open-closed-principle 그

 

그리고 이를 코드로 표현하면 아래와 같이 변경될 수 있다. 

 

public class Enemy extends Character {

	private PathPattern pathPattern;
    
    public Enemy(PathPattern pathPattern) {
    	this.pathPattern = pathPattern;
    }
    
    public void draw() {
    	int x = pathPattern.nextX();
        int y = pathPattern.nextY();
        
        ...;
    }
    
}

 

이제 새로운 패턴이 추가되어도 이 기능을 필요로하는 Enemy 클래스의 draw() 메서드는 변경되지 않으며, 해당 책임을 지니고 있는 PathPattern를 상속받는 구현 클래스를 추가해 주면 해결된다.

 


 

개방 폐쇄 원칙은 유연함에 대한 것

개방 폐쇄 원칙은 변경의 유연함과 관련된 원칙이다. 

만약 기능의 확장을 위해서 기존 코드를 지속적으로 수정해준다면, 이는 점점 갈수록 어려워진다. 

즉, 기능 확장에는 닫히면서 기능을 사용하는 클라이언트에게는 변경에 열리는 반대 상황이 연출되는 것이다. 

(마치 절차지향적으로 코드를 작성하다보면, 생기는 현상과 같이)

 

추상화와 다형성을 이용하여 인터페이스나 상속(단, 상속을 통한 재사용의 단점은 유의하는 것이 좋을 듯하다 - < IS-A 관계에서 사용하기 >)을 통한 개방 폐쇄 원칙을 지키는 모습을 위에서 학습하였다.

 

보다 확장성 있고 유연한 설계를 위해서 변화 관련된 구현을 추상화하여 개방 폐쇄 원칙에 맞게 수정할 수 있는 습관을 갖도록 하자.

댓글