[객체지향] SOLID 원칙

Hyebin Lee·2022년 2월 25일
0

JAVA

목록 보기
3/6
post-thumbnail

SOLID 원칙

1. 단일 책임 원칙 (Single Responsibility Principle)

클래스는 단 한 개의 책임을 가져야 한다.

클래스가 여러 책임을 갖게 되면 그 클래스는 책임마다 변경되는 이유가 발생하기 때문에 클래스가 한 개의 이유로만 변경되려면 클래스는 한 개의 책임만을 가져야 한다.
단일 책임 원칙을 어길 시에는 변경이 어렵다는 문제 뿐만 아니라 재사용을 어렵게 한다는 문제도 있다. 실제 사용하지 않는 기능까지 필요로 할 수 있다는 것이다.

책임이란 변화에 대한 것

책임의 단위는 변화와 관련되어 있다. 예를 들어 DataViewer 클래스에서 데이터를 읽어 오는 기능에 변화가 발생했는데, 이런 변화를 통해 데이터를 읽어 오는 기능이 별도로 분리되어야 할 책임이라는 것을 알 수 있다.

또한 클래스의 사용자들이 서로 다른 메서드들만을 사용한다면 그들 메서드는 각각 다른 책임에 속할 가능성이 높고 따라서 책임 분리 후보가 될 수 있다.

2. 개방 폐쇄의 원칙

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

이를 쉽게 말하면 기능을 확장하면서도 기능을 사용하는 기존 코드는 변경하지 않는 것이다. 이를 개방 폐쇄 원칙은 확장에는 열려있고 (사용될 기능 추가) 변경에는 닫혀있다 (코드의 변경) 라고 표현한다.

이러한 원칙을 구현할 수 있는 까닭은 확장되는 부분, 즉 변화되는 부분을 추상화해서 표현했기 때문이다.

개방 폐쇄의 원칙을 구현하는 또 다른 방법은 상속을 이용하는 것이다. 상속은 상위 클래스의 기능을 그대로 사용하면서 하위 클래스에서 일부 구현을 오버라이딩할 수 있는 방법을 제공한다.

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

  • 다운캐스팅
💡 예를 들어 달리기 게임을 개발하는 경우 플레이어가 장애물 돌,물,허들을 마주한다고 하자. 돌이나 물을 마주하면 해당 Object를 피하지만 허들의 경우 피하지 않고 허들은 넘는 것을 개발한다고 한다면 코드는 어떻게 구현하게 될까?

일단 상속의 관계를 생각해보면 장애물인 Obstacle 클래스를 Rock, Water, Huddle 클래스가 각각 상속받을 것이다. 그리고 코드는 아래와 같이 구현될 것이다.

public void runGame(Obstacle obstacle){
if(obstacle instanceof Huddle){ // 허들인지 확인
	Huddle huddle = (Huddle) obstacle;
	huddle.jump();}
else {
	obstacle.avoid();
}
}

위 코드는 obstacle 파라미터의 타입이 Huddle 인 경우 별도 처리를 하고 있다. 그러나 이 경우 jump 메서드는 Obstacle 클래스가 확장될 때 함께 수정된다. 즉, 변경에 닫혀있지 않다.

instanceof와 같은 타입 확인 연산자가 사용된다면 해당 코드는 개방 폐쇄 원칙을 지키지 않았을 가능성이 높다. 이런 경우 타입 캐스팅 후 실행하는 메서드가 객체마다 변화 대상인지 확인해 봐야한다. 만약 jump 메서드가 객체마다 다르게 동작할 가능성이 높다면 이 메서드는 Obstacle 타입으로 추상화 해야한다.

  • 비슷한 if -else 반복

개방 폐쇄 원칙을 깨뜨리는 코드의 또 다른 특징은 비슷한 if- else 문의 반복이다.

앞의 게임 캐릭터를 이용해서 예를 들어보자. Runner 캐릭터의 움직이는 경로를 몇 가지 패턴으로 정한다고 하자. 이 때, 정해진 패턴에 따라 경로를 이동하는 코드를 다음과 같이 작성할 수 있다.

public class Runner extends Character{

private int pathPattern;
public Runner(int pathPattern){
	this.pathPattern = pathPattern;
}

public void draw(){
if(pathPattern == 1) x + = 4;
else if (pathPattern == 2) y + = 10;
else if ...
}}

Runner 클래스에 새로운 경로 패턴을 추가해야 할 경우 Runner 클래스의 draw() 메서드에는 새로운 if 블록이 추가된다. 즉 경로를 추가하는데 Runner 클래스가 닫혀 있지 않은 것이다.

따라서 반복되는 주체의 중심이 되는 경로패턴을 추상화하여 Runner에서 추상 타입을 사용하도록 구조를 변경해야 한다.

public class Runner extends Character{

	private PathPattern pathPattern;

	public Runner(PathPattern pathPattern){
		this.pathPattern = pathPattern;
}

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

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

개방 폐쇄의 원칙은 변화가 예상되는 것을 추상화해서 변경의 유연함을 얻도록 해준다. 이 말은 변화되는 부분을 추상화하지 못하면 개방 폐쇄 원칙을 지킬 수 없게 되어 시간이 흐를수록 기능 변경이나 확장을 어렵게 만든다는 것을 뜻한다. 따라서 코드에 대한 변화 요구가 발생하면 변화와 관련된 구현을 추상화해서 개방 폐쇄 원칙에 맞게 수정할 수 있는지 확인하는 습관을 들여야 한다.

3. 리스코프 치환 원칙

상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

💡 **리스코프 원칙을 어긴 예시: Rectangle** Rectangle 클래는 가로 세로를 나타내는 변수 width, height 와 그에 대한 getter, setter를 갖고 있는 클래스이다. 이 때 정사각형은 직사각형의 일종이지만 가로세로의 길이가 같다는 점을 고려해서 정사각형의 `setWidth`와 `setHeight`를 오버라이드하여 가로와 세로의 길이가 같도록 조정하는 함수를 작성하였다.

여기서 만약 Rectangle 클래스에 가로 세로를 비교해서 세로를 더 길게 만들어주는 함수를 추가했다고 할 때 이는 Square 클래스에는 해당할 수 없다(정사각형은 늘 가로 세로가 같아야 하고 setter에서 애초에 가로와 세로가 같도록 코드를 짰기 때문에).

위의 예시에서 본 바와 같이 상위 타입인 Rectangle의 increaseHeight 메소드는 하위타입인 Square 객체도 사용할 수 있어야 하지만 그럴 수 없기 때문에 리스코프 치환 원칙을 어겼다고 할 수 있다.

이러한 경우에 Square 클래스는 Rectangle 클래스와는 별개 타입으로 구현해야 한다.

리스코프 치환 원칙을 어기는 또 다른 예시는 상위 타입에서 지정한 리턴 값의 범위나 값 자체에 해당되지 않는 값을 리턴하는 것이다. 예를 들어 상위 클래스에서 특정 메소드가 기능을 수행하면 -1을 리턴하도록 되어있었는데 그것의 하위 클래스가 기능을 수행하면 0을 리턴하도록 오버라이딩을 해버리면 코드에 문제가 생긴다.

리스코프 치환 원칙은 계약과 확정에 대한 것

변화되는 기능은 상위타입에 추가해서 하위타입마다 해당 기능을 변화시킬 수 있도록 구현해야 한다. 만약 특정 기능이 상위타입에서는 작동하지만 하위타입에서는 작동하면 안되는 기능 (Rectangle 예시에서 본 바와 같이)인 경우 그것은 변화되는 기능이 아니다. 따라서 해당 기능은 상위타입이 가지는 것이 아니라 서로 다른 타입이 서로 다르게 가져야 한다.

하위 타입은 상위 타입에서 정의한 명세를 벗어나지 않는 범위에서 구현한다.

또한 리스코프 치환 원칙은 확장에 대한 것이라 리스코프 치환 원칙을 어기면 개방 폐쇄 원칙을 어길 가능성이 높아진다.

4. 의존 역전 원칙

고수준 모듈은 저수준 모듈의 구현에 의존해서는 안된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.

  • 고수준 모듈: 어떤 의미가 있는 단일 기능을 제공하는 모듈. ex) 바이트 데이터를 읽어와 암호화하고 결과 바이트 데이터를 쓴다.
  • 저수준 모듈: 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현. ex) 파일에서 바이트 데이터를 읽어온다, AES 알고리즘으로 암호화한다, 파일에 바이트 데이터를 쓴다.

고수준 모듈이 저수준 모듈에 의존할 때의 문제

상위 수준에서의 기능은 한 번 안정화되면 쉽게 변하지 않지만, 하위 수준은 상황에 따라 다양한 종류와 기능이 추가될 수 있다. 따라서 우리가 원하는 것은 저수준 모듈이 변경되더라도 고수준 모듈은 변경되지 않는 것인데, 이를 위한 원칙이 바로 의존 역전 원칙이 된다.

의존 역전 원칙을 통한 변경의 유연함 확보

저수준 모듈이 고수준 모듈을 의존하게 만든다는 것은 추상화와 관련이 있다. 아래 예시를 생각해보자.

💡 FlowController 는 위의 고수준 모듈 예시처럼 바이트 데이터를 읽어와 암호화하고 결과 바이트 데이터를 쓰는 역할을 한다. FileDataReader는 위의 저수준 모듈 예시 중 하나로 파일에서 바이트 데이터를 읽어오는 역할을 한다. 이 때 FlowController는 FileDataReader에 직접적으로 의존을 하고 있었는데 **ByteSource** 인터페이스를 생성해서 FlowController와 FileDataReader가 모두 ByteSource 인터페이스 (추상 타입)에 의존하도록 구현하여 의존 역전 원칙을 지킬 수 있어진다.

이 때 추상타입의 소유도 고수준 모듈이 해야한다! 같은 패키지 안에 있어 필요한 저수준 모듈만 그때 그때 선택적으로 배포할 수 있다.

0개의 댓글