객체 지향 설계 원칙 (SOLID)
SOLID
SOLID 원칙이란 객체 지향 프로그래밍(OOP) 설계를 위한 다섯 가지 기본 원칙을 말한다. 아래 5가지 원칙들의 맨 앞 글자를 순서대로 따서 SOLID 원칙이라고 부른다.
SOLID 원칙을 잘 준수하면 보다 좋은 개발을 할 수 있게 된다. 유지 보수가 쉬워지고 확장이 용이해지며 코드를 읽기도 수월해진다. 즉, SOLID 원칙은 객체 지향 프로그래밍 설계를 위한 지침이며 항상 염두에 두어야 하는 기본 소양이라고 말할 수 있을 것 같다.
이 글은 대표적인 객체 지향 언어인 자바를 사용할 때 어떻게 SOLID 원칙을 지킬 수 있을지를 다룬다.
단일 책임 원칙 (SRP, Single responsibility principle)
“한 클래스는 하나의 책임만 가져야 한다.” [1]
즉, 하나의 클래스는 한 가지 역할만을 수행해야 한다는 뜻이다. 왜 그래야 하는지는 반대로 생각해보면 쉽게 알 수 있다.
여기 간단한 오목 게임 시스템이 있다.
OmokConverter
클래스는 사용자의 입력을 매개변수로 받아 오목에서 사용하는 좌표, Point 객체로 변환하는 apply
메서드를 가지고 있다.
- OmokConverter 클래스
class OmokConverter implements Function<String, Point> {
private static final OmokConverter INSTANCE = new OmokConverter();
private final OmokPoint[][] OMOK_POINTS;
private OmokConverter() {
OMOK_POINTS = new OmokPoint[16][16];
for (int i = 1; i < OMOK_POINTS.length; i++)
for (int j = 1; j < OMOK_POINTS[i].length; j++)
OMOK_POINTS[i][j] = new OmokPoint(i, j);
}
static OmokConverter getInstance() {
return INSTANCE;
}
@Override
public Point apply(String input) {
if (!isValid(input)) throw new IllegalArgumentException();
String[] split = input.split(",");
String s1 = split[0].trim();
String s2 = split[1].trim();
int x = Integer.parseInt(s1);
int y = Integer.parseInt(s2);
return OMOK_POINTS[x][y];
}
private boolean isValid(String input) {
// 사용자 입력 검증 로직
}
}
apply
메서드는 사용자 입력을 검증한 후, 유효한 입력이면 적절한 Point
객체로 변환하여 반환한다.
위 클래스는 클래스 이름에서부터 알 수 있듯이 사용자 입력을 Point
객체로 변환하는 하나의 역할만을 가지고 있으니 나름 SRP 원칙을 잘 지킨 것 같다.
…고 생각했지만 사실 위 클래스는 두 가지 문제가 있었다.
OmokConverter
클래스는 사용자의 입력을 ‘검증’하는 로직을 포함하고 있다.apply
메서드는 불필요하게 예외를 던지고 있다.
첫 번째 문제는 검증 처리를 private
메서드로 클래스 내부적으로 처리하는 데에서 발생하는데,
이 같은 방법을 사용하면 검증 로직이 필요한 클래스마다 해당 메서드를 만들어 주어야 한다. 동일한 메서드를 클래스마다 만들어주는 것은 심각한 중복이다.
진짜 문제는 검증 로직을 변경할 때 발생한다. 하나의 검증 로직을 변경하는 데 해당 로직을 사용하는 클래스들을 일일이 들어가서 다시 전부 바꿔주어야 한다. ‘단순히 복사 붙여넣기로 해결되는 문제 아닌가?’ 라고 생각할 수 있지만 실수로 클래스 하나를 빼먹었다면? 그런 것들은 일일이 기억할 수도 없고 기록하기도 쉽지 않다.
두 번째 문제는 불필요한 예외 사용에 있다. 사용자 입력이 일정 형식에 맞지 않는 것은 개발자가 충분히 예측하고 처리할 수 있는 예외다.
하지만 apply
메서드는 위 예외를 적절하게 처리하지 못한다.
OmokConverter
객체는 단순히 좌표 변환을 위한 클래스로 설계하였기 때문에 사용자 입력을 다루는 상위 단계에서 사용되지 않기 때문이다.
사용자가 잘못된 입력을 했을 경우에는 일반적으로 사용자에게 입력이 잘못되었음을 고지하고, 적절한 형식이 무엇인지 알려준 다음 새로운 입력을 다시 받아야 할 것이다.
OmokConverter
클래스의 apply
메서드에서 이 같은 처리를 할 수 있는가?
혹은 이에 준하는 처리를 해서 OmokConverter
객체를 사용하고 있는 상위 객체에 이를 알릴 수 있나?
런타임 예외를 발생시키는 것은 불필요할 뿐더러, 예외를 던지자니 상위 객체에서 지저분하게 try-catch
구문을 사용하게 될 것이다.
그렇다고 당장의 문제 해결을 위해 apply
메서드에서 Scanner
객체를 사용하는 것은 정말 끔찍한 코드가 될 것이다.
그래서 검증 로직을 하나의 ‘책임’으로 간주하고 클래스를 분리해 보았다. 오목 게임 시스템 내 검증이 필요한 모든 부분을 OmokValidator
클래스에 담았다.
- OmokConverter 클래스
class OmokConverter implements Function<String, Point> {
// 생략
@Override
public Point apply(String input) {
// if (!isValid(input)) throw new IllegalArgumentException(); 검증 로직 삭제!
String[] split = input.split(",");
String s1 = split[0].trim();
String s2 = split[1].trim();
int x = Integer.parseInt(s1);
int y = Integer.parseInt(s2);
return OMOK_POINTS[x][y];
}
}
- OmokValidator 클래스
class OmokValidator implements Validator<String> {
private final OmokBoard board;
OmokValidator(OmokBoard omokBoard) {
board = omokBoard;
}
@Override
public boolean isValid(String input) {
String[] split = input.split(",");
if (split.length != 2) return false;
String s1 = split[0].trim();
String s2 = split[1].trim();
try {
int x = Integer.parseInt(s1);
int y = Integer.parseInt(s2);
return isValid(x, y);
} catch (NumberFormatException e) {
return false;
}
}
private boolean isValid(int x, int y) {
return isValidRange(x, y) && !board.isExist(x, y);
}
private boolean isValidRange(int x, int y) {
return isValidRange(x) && isValidRange(y);
}
private boolean isValidRange(int n) {
return n >= 1 && n <= 15;
}
// 이하 생략
}
기존의 OmokConverter
클래스는 이제 검증 로직을 아예 모르게 됐다! 검증에 대한 부분은 OmokValidator
클래스로 모두 위임했으니 말이다.
당연히 해당 검증 로직을 사용하는 다른 클래스들도 중복 코드를 갖지 않게 됐다.
이제 검증 로직에 변경이 발생하면 OmokValidator
클래스만 변경하면 된다.
애초에 클래스 이름부터 직관적으로 검증에 관한 클래스라는 것이 눈에 들어오니 찾는 것도 편하다.
OmokConverter
클래스 내에 검증 로직이 있다는 것을 코드를 보기 전에 쉽게 알 수 있을까?
역할을 하나만 갖는다는 것은 이름도 쉽게 지을 수 있다는 뜻이기 때문에 개발자가 코드를 더욱 읽기 쉽게 만들어준다.
또한, OmokValidator
클래스는 사용자의 입력이 잘못되었을 경우 적절하게 예외를 처리할 수 있다.
isValid(String)
메서드는 boolean
타입을 반환하기 때문에 사용자 입력에 문제가 있다면 false
값을 반환하여 상위 객체에 해당 입력이 잘못된 입력임을 알려줄 수 있다.
이를 통해 상위 객체는 반환 값을 보고 적절하게 대응할 수 있을 것이다.
이는 Point
객체를 반환해야 했던 OmokConverter
클래스의 apply
메서드에서는 할 수 없는 일이다.
클래스에 하나의 역할만을 부여했더니 파생되는 이점은 한두 가지가 아니었으며 나도 모르는 새에 이미 그 이점을 누리고 있었을 수도 있겠다. 조금 더 나아가서 하나의 메서드는 하나의 역할만을 수행하도록 한다면 더욱 튼튼하고 견고한 클래스를 만들 수 있을 것이다.
개방-폐쇄 원칙 (OCP, Open-close principle)
“소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.” [2]
단일 책임 원칙처럼 이름만 봐도 쉽게 유추할 수 있는 이름은 아닌 것 같다. OCP 원칙은 어떤 기능이든 쉽게 확장될 수 있어야 하고 변경에 대해서는 최소화해야 함을 얘기한다. OCP 원칙을 준수하지 않고 개발을 하면 새로운 기능을 추가해야 할 때 추가하기 어렵거나 시간이 많이 걸리게 될 수도 있고, 어떤 변경이 생겼을 때 그 변경으로 인해 발생하는 여러 문제점들을 추가적으로 줄줄이 변경해야 하는 불상사를 낳을 수 있다.
확장에 열려 있어야 한다.
다음은 게임 프로젝트에서 사용되는 사용자 입출력 처리 클래스 중 일부를 발췌한 것이다.
- IOProcessor 클래스
public class IOProcessor {
private static final Scanner SC = new Scanner(System.in);
private static final PrintStream PS = System.out;
private IOProcessor() {
}
public static String readLine() {
return SC.nextLine();
}
public static String readLine(Validator<String> validator) throws InvalidInputException {
String line = readLine();
if (validator.isValid(line)) return line;
throw new InvalidInputException();
}
// 이하 생략
}
readLine(Validator)
메서드의 매개변수 Validator
타입은 인터페이스이며 검증 객체를 뜻한다.
위 메서드는 검증 객체를 이용하여 사용자의 입력을 검증하고 검증에 성공하면 입력을 그대로 반환해주는 역할을 가지고 있다.
각각의 하위 게임 모듈 즉, 오목 모듈, 체스 모듈 등은 Validator
인터페이스를 구현하여 자신만의 검증 객체를 만든 후에 해당 메서드에 매개변수로 넘겨주기만 하면 된다.
만약에 위 메서드에 매개변수가 Validator
타입이 아니라 OmokValidator
타입이었다면 어땠을까?
해당 프로젝트는 초기에 오목 게임 하나만 있었으므로 사용에 문제가 없었을지 모른다.
하지만 체스가 추가된다면 어떻게 될까?
체스 검증 로직을 담고 있는 ChessValidator
클래스를 만들어야 할테고,
해당 객체는 위 메서드의 매개변수로 넣을 수 없기 때문에 ChessValidator
타입을 받을 수 있는 새로운 readLine
메서드를 만들어야 할 것이다.
게임이 추가되면 추가될수록 IOProcessor
클래스는 거대해지고 중복이 굉장히 많아지게 될 것이다.
readLine
메서드는 오목 게임 한정으로만 사용할 수 있을 것이고 이후 추가(확장)되는 게임에 대해서는 사용할 수 없는 메서드가 될 것이다.
변경에 닫혀 있어야 한다.
다시 위의 코드를 살펴보자. readLine
메서드의 매개변수 타입을 OmokValidator
클래스로만 바꿔보았다.
- IOProcessor 클래스
public class IOProcessor {
private static final Scanner SC = new Scanner(System.in);
private static final PrintStream PS = System.out;
private IOProcessor() {
}
public static String readLine() {
return SC.nextLine();
}
public static String readLine(OmokValidator<String> validator) throws InvalidInputException {
String line = readLine();
if (validator.isValid(line)) return line;
throw new InvalidInputException();
}
// 이하 생략
}
만약에 오목 검증 객체를 다른 클래스로 바꾸게 되었다면 어떻게 될까?
더 나은 알고리즘이나 방법을 고안해내서 새로운 검증 객체를 다시 작성했다고 생각해보자.
OmokBetterValidator
클래스는 새로 만든 클래스이며 기존의 OmokValidator
클래스보다 성능이 훨씬 뛰어났다!
하지만 OmokBetterValidator
클래스를 작성하는 것만으로는 기존 시스템에 곧장 적용할 수 없다.
컴파일 에러가 발생했기 때문! 이유는 바로 readLine
메서드에 있다.
매개변수 타입으로 구체 클래스를 작성하는 바람에 OmokBetterValidator
타입은 받아줄 수 없던 것이다.
그러면 매개변수 타입만 OmokBetterValidator
로 바꿔주면 됐을까?
두 검증 클래스는 애초에 다른 클래스이기 때문에, 메서드 명이 같다고 보장할 수 없다.
과연 OmokBetterValidator
클래스에도 isValid
라는 이름의 메서드가 있을까?
그런 것들을 모두 고려해서 클래스를 만들면 더할 나위 없이 좋겠지만 그런 것을 믿고 개발을 한다는 것은 정말 위험할 것이며 특히, 여럿이서 함께 개발을 하게 될 경우 더욱 그러할 것이다.
단순히 검증 클래스만 추가했을 뿐인데 변경해야 할 부분이 한두 가지가 아니다. 설계 자체가 변경에 닫혀 있도록 되어 있지 않기 때문이다. 해답은 의외로 간단한데, ‘추상화’를 통해서 이 문제를 해결할 수 있다.
매개변수 타입을 Validator
인터페이스로 정의하게 되면 이 모든게 해결된다!
새로운 검증 객체를 만든다면 Validator
인터페이스를 구현하도록 만들고 readLine
메서드에 넣어주기만 하면 된다.
인터페이스를 구현했기 때문에 기존에 작성했던 readLine
메서드도 사용할 수 있을 뿐더러 메서드 명도 강제로 같아 추가적으로 변경할 코드가 없다.
OCP 원칙은 추상화를 잘 사용한다면 어렵지 않게 적용할 수 있다. 위 두 문제 모두 추상화를 통해 해결할 수 있었다. 이렇게 개발된 코드는 유연할 것이며 재사용성은 크게 증가할 것이다.
리스코프 치환 원칙 (LSP, Liskov substitution principle)
“프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.” [3]
LSP 원칙은 바바라 리스코프가 제안한 원칙으로, 하위 타입 객체는 상위 타입 객체의 모든 규칙, 조건, 행동 등을 따라야 하며 하위 타입 객체는 상위 타입 객체의 역할을 완벽하게 수행할 수 있어야 한다. 즉, 상위 타입 객체를 아무 조건 없이 하위 타입 객체로 치환할 수 있어야 한다.
상속을 하게 되면 하위 객체는 상위 객체의 속성과 행동을 물려받게 된다. 하위 객체는 물려받은 요소들을 오버라이드 할 수 있게 되는데, 오버라이드 과정에서 상위 객체에서 허락되지 않는 속성의 변경이나 행동의 변화가 생긴다면 예상치 못한 문제가 발생할 수 있다. 또한, 하위 객체는 상위 객체에는 없는 메서드를 추가할 수 있기 때문에, 추가된 메서드를 통해 의도치 않은 변경을 초래할 수 있다.
때문에 상속은 정말 유의해서 사용해야 하는데, 다음 예시를 보면 좋을 것 같다.
- Rectangle 클래스
public class Rectangle {
private int width;
private int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public int getWidth() {
return this.width;
}
public int getHeight() {
return this.height;
}
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
}
- Square 클래스
public class Square extends Rectangle {
public Square(int length) {
super(length, length);
}
@Override
public void setWidth(int length) {
super.setWidth(length);
super.setHeight(length);
}
@Override
public void setHeight(int length) {
super.setHeight(length);
super.setWidth(length);
}
}
수학적으로 정사각형은 직사각형이라고 부를 수 있다.
직사각형은 네 각의 크기가 모두 같은 사각형을 의미하는데, 정사각형은 네 각의 크기가 모두 같고 네 변의 길이가 모두 같은 사각형이기 때문이다.
정사각형이 직사각형이 될 수 있다고 해서 Square
클래스가 Rectangle
클래스를 상속 받도록 작성 한다면 갑자기 문제가 생긴다.
Rectangle
클래스는 너비와 높이의 길이를 독립적으로 변경할 수 있도록 setter 메서드를 제공한다.
이 두 메서드를 통해 너비와 높이를 조절할 수 있는데, 당연히 Square
클래스도 이 메서드를 물려 받는다.
하지만 Square
객체는 너비와 높이가 항상 같아야 하므로 두 메서드를 위와 같이 오버라이드를 해야한다.
상위 타입인 Rectangle
클래스는 너비와 높이를 독립적으로 변경할 수 있도록 구현해 놓았지만 하위 타입인 Square
클래스에서 그 규칙을 깨버렸다!
다음 코드를 실행해보면 그 문제점이 드러난다.
public class Main {
public static void main(String[] args) {
Rectangle rectangle = new Square(5);
rectangle.setWidth(3);
System.out.println("rectangle.getWidth() = " + rectangle.getWidth()); // 3
System.out.println("rectangle.getHeight() = " + rectangle.getHeight()); // 3 ???
}
}
Square
객체는 Rectangle
타입으로 다룰 수 있다.
그러나 Square
객체는 기존 Rectangle
객체의 목적과는 달리 width
값만을 변경했을 뿐인데 느닷없이 height
값이 변경되는 결과를 초래한다.
위 코드는 Square
생성자는 통해서 코드에 명시적으로 객체가 드러나기 때문에 쉽게 문제점을 발견할 수 있지만,
Rectangle
객체가 메서드를 통해 복잡한 과정을 통해서 넘어온다면 디버깅 하기가 여간 쉽지 않을 것이다.
LSP 원칙은 주로 상속에 대해서 얘기하지만, 나는 인터페이스를 구현할 때에도 인터페이스에서 정의한 규칙을 잘 따라서 구현할 것을 강력하게 전하고 싶다. 하위 타입에서 상위 타입의 규칙을 하나라도 깨버린다면 그 객체는 상위 타입으로 다룰 수 없다. 그러면 객체 지향 프로그래밍의 강점인 다형성조차 다룰 수 없다는 뜻이 된다.
인터페이스 분리 원칙 (ISP, Interface segregation principle)
“특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.” [4]
이름에서도 명확하게 드러나듯이 인터페이스를 작게 분리해서 사용하라는 뜻이다. 즉, 인터페이스도 역할을 명확히 해서 해당 역할만을 수행하도록 설계하라는 원칙인데, SRP 원칙의 인터페이스 버전이 아닐까 싶다.
하나의 인터페이스에 여러 기능을 담게 되면, 그 인터페이스를 더 다양하게 활용할 수 없게 된다. 인터페이스에 5개의 메서드가 있는데 그 중 3개만 구현할 수 있는가? 나머지 2개 메서드를 제 구실을 못하도록 구현한다면 엄청난 버그가 발생할 것이다. 이런 경우에서는 3개의 메서드가 있는 인터페이스, 나머지 2개 메서드가 있는 인터페이스로 분리한다면, 훨씬 다양하고 명확하게 인터페이스들을 사용할 수 있다.
Java 기본 라이브러리는 ISP 원칙을 아주 잘 적용해서 인터페이스들을 역할게 맞게 잘게 분리해 놓았다. 그 중 한 가지 예시를 살펴보자.
- Reader, Writer 클래스
public abstract class Reader implements Readable, Closeable {
// 생략
}
public abstract class Writer implements Appendable, Closeable, Flushable {
// 생략
}
Reader
, Writer
클래스는 자바에서 사용하는 대표적인 추상 클래스로, 이름 그대로 읽고 쓰기 위한 객체이다.
자바에서는 읽고 쓰기 위한 수많은 객체들이 있는데 그 객체들의 최상위 타입이라고 볼 수 있다.
Reader
객체는 읽는 동작을 할 수 있는 동시에 닫는 동작을 할 수 있다.
그래서 최초로 설계한다고 생각한다면 Reader
객체가 구현하고 있는 Readable
인터페이스를 다음과 같이 정의할 수 있다.
public interface Readable {
int read(java.nio.CharBuffer cb) throws IOException;
void close() throws IOException;
}
이제 Readable
인터페이스를 구현하고 있는 Reader
객체 및 그 하위 객체는 모두 이 두 메서드를 구현해야 한다.
그렇다면 Writer
객체는 어떨까?
Writer
객체는 출력 동작을 할 수 있는 동시에 닫는 동작을 할 수 있다.
그렇다면 Writer
객체가 구현하고 있는 Flushable
인터페이스를 다음과 같이 정의해 볼 수 있다.
public interface Flushable {
void flush() throws IOException;
void close() throws IOException;
}
중복이 보이는가? Writer
객체인데 close()
메서드를 위해서 Readable
인터페이스를 구현할 수도 없는 노릇이니
어쩔 수 없이 Flushable
인터페이스에 똑같이 close()
메서드를 정의할 수밖에 없다.
이런 방식이 많아진다면 중복 코드가 점점 많아지게 될 것이고, 리팩토링이나 수정에서 많은 불편을 겪게 될 것이다.
그래서 자바는 다음과 같이 인터페이스를 분리해 놓았다.
- Readable, Flushable, Closeable 인터페이스
public interface Readable {
int read(java.nio.CharBuffer cb) throws IOException;
}
public interface Flushable {
void flush() throws IOException;
}
public interface Closeable {
void close() throws IOException;
}
명확하고 깔끔하다. 고작 메서드 하나만 있다고 안 좋은게 아니다.
자바를 사용하다 보면 알겠지만 Closeable
인터페이스는 단순히 IO 객체에서만 사용되지 않고 Stream
인터페이스에서도 사용되는 등 활용성이 아주 크다.
그게 가능한 이유는 인터페이스를 역할에 따라 작고 명확하게 분리해 놓았기 때문이다.
의존관계 역전 원칙 (DIP, Dependency inversion principle)
“프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안 된다.” [5]
구체적으로 위 원칙은 두 가지 내용을 담고 있다.
- 상위 모듈은 하위 모듈에 의존해서는 안 된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
- 추상화는 세부 사항에 의존해서는 안 된다. 세부사항이 추상화에 의존해야 한다.
간단하게 항상 추상화에 의존하라는 원칙이다. 추상화에 의존하는 것은 많은 비용이 드는 것도 아니고 그렇게 어렵지도 않다. 그러나 이 원칙만 잘 지켜도 은연중에 많은 이점을 누릴 수 있다. 앞서 OCP 원칙을 잘 지키려면 추상화를 잘 사용하면 된다고 했었는데, OCP 원칙 역시 DIP 원칙과 밀접한 관련이 있다. DIP 원칙을 지키지 않으면 OCP 원칙 역시 지킬 수 없기 때문이다.
구체화나 하위 모듈에 의존하는 것은 많은 불편을 초래한다. 앞서 살펴봤던 메서드를 다시 보자.
public class IOProcessor {
// 생략
public static String readLine(Validator<String> validator) throws InvalidInputException {
String line = readLine();
if (validator.isValid(line)) return line;
throw new InvalidInputException();
}
// 이하 생략
}
IOProcessor
클래스는 사용자 입출력을 담당하는 객체로, readLine
메서드는 사용자 입력을 받아서 반환해주는 메서드다.
IOProcessor
클래스는 공통 모듈에 속해 있어 어떤 하위 모듈이든 사용할 수 있는 유틸 클래스이다.
만약에 매개변수 타입이 OmokValidator
타입이었다면 어땠을까? 오목 모듈을 제외하고는 해당 메서드를 사용할 수 없을 것이다.
그렇다고 게임마다 메서드를 만들텐가? 중복이 엄청날 것이다!
위 내용은 OCP 원칙에서도 설명했던 부분이다. DIP 원칙대로 추상화에만 의존하면 OCP 원칙은 대부분 자연스럽게 따라온다. 사용법도 간단하다. 추상 클래스나 인터페이스 같은 추상화 된 타입으로 객체를 다루기만 하면 된다.
이전에 부트캠프에 다녔을 때, 이렇게 짜여진 코드를 많이 보았다.
public class Main {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>();
// 이하 생략
}
}
ArrayList
객체를 다룰 때 ArrayList
타입으로 다루는 것을 많이 보았다.
위 코드에서 리스트 객체를 LinkedList
객체로 바꾸려면 객체도 바꿔주어야 하고, list
변수의 타입도 바꿔주어야 한다.
만약에 List<String> list = new ArrayList<>();
처럼 인터페이스 타입으로 객체를 다루었다면, 객체만 변경하면 될 문제였다.
간단한 프로그램이면 아무 상관 없을지도 모르지만, 습관을 들이는 것은 아주 중요하다고 생각하기 때문에 꼭 추상화 타입으로 다루기를 권장한다.
각주
[1], [2], [3], [4], [5]: 위키백과 (SOLID)
참고
- 위키백과 (SOLID)
- 게임 프로젝트
- 이펙티브 자바, Joshua Bloch