[Java] 객체 지향 설계 5원칙 - SOLID
SOLID는 객체 지향 프로그램을 구성하는 속성, 메서드 클래스, 객체, 패키지, 모듈, 라이브러리, 프레임워크, 아키텍처 등 다양한 곳에 다양하게 적용되는 것이기에 막상 SOLID가 적용됐는지 아닌지 애매모호하거나 보는 사람의 관점에 따라 다르게 해석될 수 있는 소지가 있음을 밝혀둔다. SOLID 자체는 제품이 아닌 개념이기에 그렇다.
SOLID가 개념이긴 하지만 우리가 만드는 제품, 즉 소프트웨어에 녹여 내야 하는 개념이다. SOLID를 잘 녹여낸 소프트웨어는 그렇지 앟은 소프트웨어에 비해 상대적으로 이해하기 쉽고, 리팩토링과 유지보수가 수월할 뿐만 아니라 논리적으로 정연하다. SOLID는 객체 디자인 패턴의 뼈대이며, 스프링 프레임워크의 근간이 되기도 한다. SOLID를 녹여내는 소프트웨어 설계를 위해서는 결합도는 낮추고 응집도는 높이는 것이 바람직하다.
1. SRP (단일책임의 원칙: Single Responsibility Principle)
- 클래스의 역할과 책임에 따라 분리해서 각각 하나의 역할과 책임만 갖게 하자.
- 모든 클래스가 하나의 책임에 집중하기 때문에 다른 클래스가 변경되어도 연쇄적으로 변경이 일어나지 않는다. -> 사이드 이펙트가 발생할 여지가 적다.
아래 그림과 같이 남자라고 하는 클래스와 남자 클래스에 의존하는 다양한 클래스가 있다고 생각해 보자.
딱 봐도 남자는 참 피곤할 것 같다. 이러한 피곤함은 역할과 책임이 너무 많기 때문이다.
남자라는 하나의 클래스가 역할과 책임에 따라 네 개의 클래스로 쪼개진 것을 볼 수 있다. 그리고 역할과 클래스명도 딱 떨어지니 이해하기도 좋다. 아까 하나의 클래스에 다수의 역할과 책임이 몰려 있을 때는 냄새가 나더니, 클래스를 역할과 책임에 따라 분리해서 각각 하나의 역활과 책임만 갖게 하니 훨씬 보기가 좋아졌다.
코드로 살펴볼까?
class 강아지 {
final static Boolean 수컷 = true;
final static Boolean 암컷 = false;
Boolean 성별;
void 소변보다() {
if (this.성별 == 수컷) {
// 한쪽 다리를 들고 소변을 본다
} else {
// 뒷다리 두 개를 굽혀 앉은 자세로 소변을 본다.
}
}
}
위 예제는 강아지가 수컷이냐 암컷이냐에 따라 "소변보다()" 메서드에서 분기 처리가 진행되는 것을 볼 수 잇다. 강아지 클래스의 "소변보다()" 메서드가 수컷 강아지의 행위와 암컷 강아지의 행위를 모두 구현하라고 하기에 단일 책임(행위) 원칙을 위배하고 잇는 것이다. 메서드가 단일 책임 우너칙을 지키지 않을 경우 나타나는 대표적인 냄새가 바로 분기 처리를 위한 if 문이다. 이런 경우 단일 책임 원칙을 적용해 코드를 리팩토링하면 아래 예제 처럼 만들 수 있다.
abstract class 강아지 {
abstract void 소변보다();
}
class 수컷강아지 extends 강아지 {
void 소변보다() {
// 한쪽 다리를 들고 소변을 본다.
}
}
class 암컷강아지 extends 강아지 {
void 소변보다() {
// 뒷다리 두 개로 앉은 자세로 소변을 본다.
}
}
이외 데이터베이스 테이블을 설계할 때도 단일 책임 원칙을 고려해야한다. 데이터베이스 테이블을 설게할 때는 정규화라고 하는 과정을 거치게 되는데, 정규화 과정을 조금 더 확장해서 생각해보면 테이블과 필드에 대한 단일 책임 원칙의 적용이라고 할 수 있다. 우리는 애플리케이션의 경계를 정하고 추상화를 통해 클래스들을 선별하고 속성과 메서드를 설계할 때 반드시 단일 책임 원칙을 고려하는 습관을 들여야한다. 또한 리팩터링을 통해 코드를 개선할 때도 단일 책임 원칙을 적용할 곳이 있는지 꼼꼼히 살피자.
2. OCP (개방 폐쇄의 원칙: Open Close Principle)
- 확장에는 열려있고, 변화에는 닫혀 있어야 한다.
- 요구사항의 변경이나 추가 사항이 발생할 때, 기존의 코드 수정은 일어나지 않고 기존 구성요소를 쉽게 확장해서 재사용이 가능해야 한다.
- 추상화화 다형성이 OCP의 핵심 원리
어느날 한 운전자가 마티즈를 구입했다. 그리고 열심히 마티즈에 적응했다고 해보자. 그리고 훗날 그 운전자에게 쏘나타가 생겼다. 창문가 기어가 수동이던 마티즈에서 오른쪽 그림처럼 창문과 기어가 자동인 쏘나타로 차종을 바꾸니 운전자의 행동에도 수동 -> 자동의 변화가 온다. 운전자는 차량에 따라 운전하던 습관을 바꿔야만 하는 것일까?
위의 그림과 같이 상위 클래스 또는 인터페이스를 중간에 둠으로써 다양한 자동차가 생긴다고 해도 객체 지향 세계의 운전자는 운전 습관에 영향을 받지 않게 된다. 다양한 자동차가 생긴다고 하는 것은 자동차 입장에서는 자신의 확장에는 개방돼 있는 것이고, 운전자 입장에서는 주변의 변화의 폐쇄돼 있는 것이다.
코드로 살펴볼까?
class MusicPlayer {
public void play() {
System.out.println("play melon");
}
}
기존에 멜론으로 노래를 듣던 사용자가 애플뮤직을 추가로 구독하기로 했다. 위 코드에서 요구사항을 적용하려면 기존의 코드가 변경되어야 하고 확장과 재사용 또한 힘들다. 요구사항을 추가하기 전에 기존 코드를 OCP 원칙을 적용해 수정해보자.
interface MusicApp {
public void play();
}
class Melon implements MusicApp {
public void play() {
System.out.println("play melon");
}
}
class MusicPlayer {
private MusicApp musicApp;
public MusicPlayer(MusicApp musicApp) {
this.musicApp = musicApp;
}
public void play() {
this.musicApp.play();
}
}
확장과 재사용이 가능하도록 수정한 코드이다. 이전과는 달리 애플뮤직을 추가하기 위해 기존 코드는 수정할 필요가 없고 확장을 통해 추가해주면 된다.
interface MusicApp {
public void play();
}
class Melon implements MusicApp {
// 생략
}
class AppleMusic implements MusicApp {
public void play() {
System.out.println("play apple music");
}
}
class MusicPlayer {
// 생략
}
OCP를 무시하고 프로그램을 작성하면 객체 지향 프로그래밍의 가장 큰 장점인 유연성, 재사용성, 유지보수성 등을 얻을 수 없다. 따라서 객체 지향 프로그래밍에서 개방 폐쇄 원칙은 반드시 지켜야 할 원칙이다.
3. LSP (리스코브 치환의 원칙: The Liskov Substitution Principle)
- 하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는데 문제가 없어야 한다.
- 프로그램에서 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스로 대체해도 프로그램의 의미가 변하지 않는다.
- 이를 만족시키는 것은 재정의를 하지 않는 것이다.
위의 그림은 리스코프 치환 원칙을 지키지 않고 있다. 딸이 아버지, 할아버지 역할을 하는 것은 논리에 맞지 않음을 알 수 있다.
위의 그림을 보자. 고래가 포유류 또는 동물의 역할을 하는 것은 전혀 문제가 되지 않는다. 위의 그림은 리스코프 치환 원칙을 완벽하게 지원하는 경우다. 결국 리스코프의 치환 원칙은 객체 지향의 상속이라는 특성을 올바르게 활용하면 자연스럽게 얻게 되는 것이다.
- 하위형에서 선행 조건은 강화될 수 없다.
- 하위형에서 후행 조건은 약화될 수 없다.
- 하위형에서 상위형의 불변 조건은 반드시 유지돼야 한다.
코드로 살펴볼까?
class Rectangle {
private int width;
private int height;
public void setHeight(int height) {
this.height = height;
}
public void setWidth(int width) {
this.width = width;
}
public int area() {
return this.width * this.height;
}
}
class Square extends Rectangle {
@Override
public void setHeight(int value) {
this.height = value;
this.width = value;
}
@Override
public void setWidth(int value) {
this.height = value;
this.width = value;
}
}
Rectangle 클래스를 상속받은 Square 클래스는 setHeight와 setWidth를 Override하였다.
class Test {
static boolean checkAreaSize(Rectangle rectangle) {
rectangle.setHeight(4);
rectangle.setWidth(5);
return rectangle.area() == 20;
}
public static void main(String[] args){
Test.checkAreaSize(new Rectangle()); // true
Test.checkAreaSize(new Square()); // false
}
}
4. ISP (인터페이스 분리의 원칙: Interface Segregation Principle)
- 인터페이스를 통해 메서드를 외부에 제공할 때는 최소한의 메서드만 제공해라.
- 클라이언트는 자신이 이용하지 않는 기능에는 영향을 받지 않아야 한다.
- 인터페이스의 책임을 분리하여 클라이언트가 꼭 필요한 긴으만 사용할 수 있도록 한다.
- 인터페이스를 클라이언트에 특화되도록 분리시키는 설계 원칙
단일 책임 원칙(SRP) 예제를 다시 살표보자. 단일 책임 원칙을 적용하기 저 남자 클래스는 그림과 같았다.
단일 책임 원칙을 적용한 후에는 아래 그림처럼 단일 책임을 갖는 클래스로 나뉘었다.
단일 책임 원칙에서 제시한 해결책은 남자 클래스를 토막내서 하나의 역할만 하는 다수의 클래스로 분할하는 것이었다. 하지만 남자를 토막 내는 것이 너무 잔인하다는 생각이 들면 그때 선택할 수 있는 방법이 바로 ISP 즉, 인터페이스 분할 원칙이다.
남자 클래스를 토막 매는 것이 아니라 여자친구를 만날 때는 남자친구 역할만 수행할 수 있게 인터페이스로 제한하고, 어머니와 있을때는 아들 인터페이스로 제한하고, 직장 상사 앞에서는 사원 인터페이스로 제한하고, 소대장 앞에서는 소대원 인터페이스로 제한하는 것이 바로 인터페이스 분할 원칙의 핵심인 것이다.
결론적으로 단일 책임 원칙(SRP)와 인터페이스 분할 원칙(ISP)은 같은 문제에 대한 다른 두가지 해결책이라고 볼 수 있다. 프로젝트 요구사에 따라 둘 중 하나를 선택해서 설게해야 한다. 하지만 특별한 경우가 아니라면 단일 책임 원칙(SRP)를 적용하는 것이 더 좋은 해결책이라고 할 수 있다.
코드로 살펴볼까?
interface Animal {
public void eat();
public void sleep();
public void fly();
}
class Tiger implements Animal {
public void eat() { ... }
public void sleep() { ... }
public void fly() {} // 필요하지 않은 기능
}
Tiger 클래스는 불필요한 기능까지 구현해야하는 문제가 발생한다. 이러한 문제를 해결하기 위해 인터페이스의 기능을 분리
interface Animal {
public void eat();
public void sleep();
}
interface Bird {
public void fly();
}
class Tiger implements Animal {
public void eat() { ... }
public void sleep() { ... }
}
5. DIP (의존성 역전의 원칙: Dependency Inversion Principle)
- 의존관계를 맺을 때, 구체적인 클래스보다 인터페이스나 추상 클래스와 관게를 맺는다는 것을 의미.
- 상위 모듈은 하위 모듈의 구현에 의존해서는 안되고 하위 모듈은 상위 모듈의 추상에 의존해야 한다. 상위 모듈은 변경이 적게 일어나는 추상화된 인터페이스나 상위 클래스, 하위 모듈은 변경이 쉬운 구체 클래스를 의미한다.
- 하위 레벨에서의 구현이 변경되더라도 상위 레벨에 영향을 주지 않는다.
- 즉, 변경에 강하며 유지보수가 쉽다.
자동차와 스노우타이어는 아래 그림처럼 의존 관계가 잇다. 자동차가 스노우타이어에 의존한다.
그런데 자동차는 한 번 사면 몇년은 타야하는데 스노우타이어는 계절이 바뀌면 일반 타이어로 교체해야 한다. 이런 경우 스노우 타이어를 일반 타이어로 교체할 때 자동차는 그 영향에 노출돼 있음을 알 수 잇다. 바로 자동차 자신보다 더 자주 변하는 스노우타이어에 의존하기에 부서지기 쉬움이라는 나쁜 냄새를 풍기고 있는 것이다. 아래 그림처럼 개선해보자.
위의 그림처럼 자동차가 구체적인 타이어들이 아닌 추상화된 타이어 인터페이스에만 의존하게 함으로써 스노우타이어에서 일반타이어로, 또는 다른 구체적인 타이어로 변경해도 자동차는 이제 그 영향을 받지 않는 상태로 구성된다. 그런데 첫 번째 그림을 보면 기존에는 스노우 타이어가 그 무엇에도 의존하지 않는 클래스였는데 두번째 그림에서는 추상적인 것인 타이어 인터페이스에 의존하게 됐다. 바로 의존의 방향이 역전 된것이다. 그리고 자동차는 자신보다 변하기 쉬운 스노우타이어에 의존하던 관게를 중간에 추상화된 타이어 인터페이스를 추가해 두고 의존 관계를 역전시키고 있다. 이처럼 자신보다 변하기 쉬운 것에 의존하던 것을 추상화된 인터페이스나 상위 클래스를 두어 변하기 쉬운 것의 변화에 영향받지 않게 하는 것이 의존 역전의 원칙이다.
코드로 살펴볼까?
interface MusicApp {
public void play();
}
class Melon implements MusicApp {
public void play() {
System.out.println("play melon");
}
}
class MusicPlayer {
// 구체적인 클래스와 의존관계를 맺음
private Melon melon;
public MusicPlayer(Melon melon) {
this.melon = melon;
}
public void play() {
this.musicApp.play();
}
}
Melon 클래스는 구체적으로 구현되었기 때문에 변하기 쉽다. DIP 원칙은 자주 변하는 것보다, 변화가 거의 없는 것에 의존하라는 것이다.
// 생략
// 추상화 객체와 의존 관계를 맺음
private MusicApp musicApp;
public MusicPlayer(MusicApp musicApp) {
this.musicApp = musicApp;
}
// 생략
출처: