SOILD란 코드 작성 후 시간이 지나도 유지보수에 유리하고 확장에 용이한 시스템을 만들 때 적용하는 객체지향 설계원칙이다.
아래 설명은 제 토이프로젝트의 체스게임의 코드 혹은 Java API의 코드를 참고해서 설명합니다.
하나의 클래스는 하나의 책임만 가져아한다.
체스게임에서 기물의 위치는 체스보드에서 "행"은 파일(File), "열"은 랭크(Rank)로 표현하며, 체스게임의 UI에서는 해당 용어를 사용하지만 내부 로직에서는 2차원 배열에 저장하기 때문에 행은 X좌표, 열은 Y좌표로 표현하고 있습니다.
아래 코드는 체스게임에서 기물의 위치를 표현하는 Position클래스입니다.
public final class Position {
private final int x;
private final int y;
private Position(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public static Position of(int x, int y) {
return new Position(x, y);
}
public static Position copy(Position position) {
return new Position(position.getX(), position.getY());
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Position)) return false;
Position position = (Position) o;
return getX() == position.getX() && getY() == position.getY();
}
@Override
public int hashCode() {
return Objects.hash(getX(), getY());
}
@Override
public String toString() {
return "Position{" +
"x=" + x +
", y=" + y +
'}';
}
}
Position클래스는 기물의 위치를 표현하는 하나의 책임을 가지고 있습니다.
기물의 위치는 private으로 보호되어 있고 값을 반환하는 get()만 존재합니다.
외부에서 사용하는 API는 객체를 생성해서 반환하는 of()와 위치를 복사하는 copy()가 있습니다. 그외에도 비교대상의 위치와 같은지 비교하는 equals(), hashcode()와 객체의 정보를 반환하는 toString()가 구현되어 있습니다.
해당 객체의 기능을 정리해보면 기물의 위치를 저장하고 반환하는 기능을 가지고 있고 위치를 생성하거나 복사하는 기능이 있다고 볼 수 있습니다.
즉 기물의 위치 라는 책임에 부합하는 기능만 모아놨습니다.
기물의 위치를 표현하기 위해서는 먼저 생성해야하고, 값을 얻을 수 있어야 하며 값을 비교하는 기능이 제공될 수 있습니다.
반면에 아래코드는 위치가 체스판의 범위에 맞는지 확인하는 메서드입니다.
boolean validPiecePosition(Position position) {
return (0 <= position.getX() && position.getX() < MAX_NUM_OF_LINE) && (0 <= position.getY() && position.getY() < MAX_NUM_OF_LINE);
}
만약 이 메서드가 위치클래스에 들어간다면 단일 책임 원칙을 위반한다고 볼 수 있습니다. 왜냐하면 Position의 역할은 기물의 위치를 알고 있는 것이지 체스판의 정보를 알고 있는게 아니기 때문입니다.
사실 단일한 하나의 책임이라는건 어느 정도 모호함이 있을 수 있습니다. 어느정도까지 작게 만들어야하는지 감이 안올 수 있습니다.
그렇다 하더라도 클래스나 메서드를 좀 더 작은 단위로 쪼개서 만들어보고 지속적인 연습이 필요합니다. 그래야 객체지향적이고 좀 더 안정적인 코드를 만들 수 있습니다.
객체는 확장에는 열려 있지만 변경에는 닫혀야 한다.
디자인 패턴 중 전략패턴, 어댑터패턴, 프록시패턴 등 많은 패턴이 해당 원칙을 준수합니다.
대표적인 OCP가 적용된 사례 로 JDBC가 있습니다.
자바 어플리케이션에서 DBMS와 연동이 필요할 때 JDBC(Java Database Connectivity) 기술을 사용하게 됩니다.
자바에서는 JDBC인터페이스를 정의하고 DBMS개발사가 해당 인터페이스를 구현한 라이브러리를 제공합니다. 그래서 개발자는 JDBC API에 맞춰 코딩하고, 사용하는 DBMS에 맞는 라이브러리를 사용하면 됩니다.
만약 다른 DBMS로 변경하더라도 자바 코드는 거의 변경없이 JDBC라이브러리만 교체해서 사용할 수 있습니다.
개방-폐쇄 원칙에 적용해보면 JDBC API를 변경하지 않고 계속 확장이 가능한 구조라고 볼 수 있습니다.
객체가 하위 타입의 인스턴스로 바꿔도 프로그램의 정확성을 깨드리지 않아야 한다.
체스게임의 기물에는 킹, 퀸, 룩, 비숍, 나이트, 폰 6개의 기물이 있습니다. 6개의 기물은 공통적인 특징을 가지며 이를 추상화할 수 있습니다.
다음은 체스게임 기물의 상속구조입니다.
모든 기물은 최상위 Piece인터페이스를 구현하고 NullPiece를 제외한 나머지 기물은 AbstractPiece추상클래스를 상속받습니다.
만약 기물클래스가 AbstractPiece타입이나 Piece타입으로 참조해도 전혀 문제없이 사용할 수 있습니다.
왜냐하면 Piece인터페이스를 구현한다는 건 Piece의 모든 추상메서드를 구현한다는 전제를 가지고 있습니다.
또한 상속구조를 AS-IS 관계로 설명한다면
"퀸은 기물이다"
"룩은 기물이다"
이렇게 상속구조가 자연스럽게 표현되기 때문입니다.
하나의 범용적인 인터페이스보다는 용도를 세분화한 여러개의 인터페이스가 낫다.
좀 더 자세히 이야기하면 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙으로 만약 어떤 기능을 수정할 때 다른 기능에 영향을 주어서는 안된다.
해결책은 간단합니다. 다소 역할이 많은 인터페이스를 좀 더 구체적이고 작은 인터페이스로 분리시키면 된니다.
구체화 보다는 추상화에 의존해야 한다.
해당 원칙의 주요내용은 다음과 같습니다.
상위 클래스는 하위 클래스에 의존해서는 안된다. 상위클래스와 하위클래스 모두 추상화에 의존해야 한다.
추상화는 세부사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.
한마디로 요약하면 추상화에 의존하라 입니다.
구체클래스를 상속받거나 구체함수를 오버라이딩 할 경우 문제점이 많습니다. 불필요한 의존성이 생겨버리고 추후 변경하기도 쉽지 않습니다.
따라서 구체클래스보다는 추상클래스를 상속받거나 인터페이스를 구현하고 구체함수를 오버라이딩 하지 말아야 합니다.