결국 좋은 객체 지향 설계
라는건 잘 만들어서 유지보수가 쉬운 설계
라고 생각한다.
강의에서는 다음 5 가지 원칙을 설명하고 있으며, 사실 이들의 내용은 그 명칭과 동치한다.
(SRP)
(OCP)
(LSP)
(ISP)
(DIP)
이들을 한번 내가 보기 쉽게 정리해 보도록 하겠다.
SRP
, OCP
: 유지보수 쉽게 만들어라
SRP
: 단일 책임 원칙클래스가 담당하는 책임을 하나로 제한하는 것이 좋다.
다음과 같은 상황을 생각해보자.
어떤 기능 구현을 위해 클래스를 설계한다. 기능은 크게 A
, B
, C
로 나뉘며 추후 유지보수가 용이하도록 만들고자 한다.
이 때 만약 SRP
를 지키지 않는다면, 다음처럼 아주 길고 유지보수하기 어려운
클래스를 만들 수 있을 것이다.
class ImproperPrinciple {
public void proceedTaskA() {
this.internalTaskA();
}
public void internalTaskA() {}
public void proceedTaskB() {
this.internalTaskB();
}
public void internalTaskB() {}
public void proceedTaskC() {
this.internalTaskC();
}
public void internalTaskC() {}
}
위 코드에서 어떤 기능 하나를 고치려 하면 해당 기능이 ImproperPrinciple
의 어디있는지 찾아 해당 부분만 올바르게 수정 해야 한다.
즉, 유지보수가 불편하고 코드가 더럽다.
이를 SRP
를 지켜 다시 설계하면 다음처럼 보일 수 있다.
class A {
public void proceedTaskA() {
this.internalTaskA();
}
private void internalTaskA() {}
}
class ProperPrinciple {
private A taskA;
public void doTaskA() {
taskA.proceedTaskA();
}
}
이를 통해 기능별 독립적인 변경 이 가능해졌고 유지보수에 더 용이해졌다.
OCP
: 개방 폐쇄 원칙소프트웨어 요소의 확장은 가능하나 변경은 불가능한 것이 좋다.
OCP
는 쉽게말해 적절한 추상화로 유지보수를 높이라
는 원칙이다.
앞선 예시에서 기능 A
가 이메일 알림 기능
이라 생각해보자.
class EmailNotif {
public void sendEmail() {
this.logAction();
}
private void logAction() {}
}
class ProperPrinciple {
private EmailNotif notif;
public void notif() {
notif.sendEmail();
}
}
그런데 만약 이것이 이메일 알림
에서 앱 푸시 알림
으로 교체되어야 한다면 어떨까?
EmailNotif
가 딱딱한 구현체이기 때문에 앱 푸시 알림 전용 클래스
를 또 만들어야 하고, 무엇보다 ProperPrinciple
의 코드에도 변경이 생긴다.
class PushNotif {
public void sendPush() {
this.logAction();
}
private void logAction() {}
}
class ProperPrinciple {
private PushNotif notif;
public void notif() {
notif.sendPush();
}
}
이를 위한 원칙이 바로 OCP
로, 해당 원칙을 통해 ProperPrinciple
의 변경을 최소한으로 바꿀 수 있다.
abstract class Notif {
abstract public void sendNotif();
abstract protected void logAction();
}
class EmailNotif extends Notif {
/* ... 생략 ... */
}
class PushNotif extends Notif {
/* ... 생략 ... */
}
class ProperPrinciple {
private Notif notif;
public void setNotif() {
notif.sendNotif();
}
}
위 코드를 보면 ProperPrinciple
의 알림 기능을 담당하는 요소
를 상위 타입인 Notif
로 변경된 것을 볼 수 있다.
이를 통해 EmailNotif
, PushNotif
두 기능을 모두 통합 가능하고, 무엇보다 다른 알림 기능이 추가되었을 때, ProperPrinciple
의 코드는 변경하지 않으면서 기능 추가가 가능하다.
LSP
: 구현 똑바로 만들어라
LSP
: 리스코프 치환 원칙하위 타입의 개체는 상위 타입의 것으로 온전히 대체 가능해야 한다.
LSP
의 정확한 내용은 "T 의 하위타입 S 가 있을 때, T 가 가능한 모든 행동은 S 에서도 동일해야 한다"
[1]
라는 내용이다.
다음 예시를 보자.
@Setter @Getter
class Sup {
protected char x, y;
public void echoVars() {
System.out.printf(
"%s - %s %s\n",
this.getClass().getSimpleName(),
x, y
);
}
}
class Sub extends Sup {
@Override
public void setX(char x) {
this.x = this.y = x;
}
@Override
public void setY(char y) {
this.x = this.y = y;
}
}
Sup sup = new Sup();
sup.setX('X');
sup.setY('Y');
sup.echoVars();
Sup sub = new Sub(); // 위에랑 동일하니까
sub.setX('X');
sub.setY('Y');
sub.echoVars(); // 결과도 동일해야 원칙 위배 X
Sup - X Y
Sub - Y Y // 근데 아님. 원칙 위배
위 코드는 문법상 문제는 없지만, 객체 지향의 대체성
(Substitutability)
에서 생각해보면 하위 타입 Sub
는 Sup
를 전혀 대체하지 못하고 있다.
Sup
은 X Y
로 보이지만 Sub
는 Y Y
로 보이기 때문이다.
LSP
는 이런 하위 타입의 행동이 상위 타입의 행동을 대체 가능해야 함을 지적하는 원칙으로 올바른 상속 관계와 구현까지 이루어져야 함을 시사한다.
한마디로 똑바로 만들라는 이야기
ISP
, DIP
: 적절한 추상화를 사용해라
ISP
: 인터페이스 분리 원칙너무 커다란 인터페이스는 나누는 것이 좋다.
아래 예시를 보자.
interface Huge {
void typeA1();
void typeA2();
void typeA3();
void typeB1();
void typeB2();
void typeB3();
}
위 Huge
를 보면 메서드의 종류가 A
, B
로 나뉘어 있음을 볼 수 있다.
ISP
는 이러한 경우 아래처럼 메서드를 한번 더 분리하는 것을 권유하는 원칙이다.
interface A {
void typeA1();
void typeA2();
void typeA3();
}
interface B {
void typeB1();
void typeB2();
void typeB3();
}
// ISP 가 아래처럼 사용하라는 것은 아니지만
// Huge 인터페이스에 전부 몰아넣는 것 보단 아래가 낫다.
interface Huge extends A, B {}
ISP
를 통해 인터페이스간 역할이 더욱 명확해지고 대체 가능성이 높아진다.
하지만 불필요한 분리는 오히려 인터페이스를 많게 하므로 적절한 분리가 중요하다.
DIP
: 의존관계 역전 원칙추상화된 개체를 속성으로 갖는게 좋다.
마지막으로 DIP
는 추상체에 의존하는 것을 권유하는 원칙이다.
class AImpl implements A {
/* ... 구현 ... */
}
class Feature {
private AImpl a;
}
위 코드를 보면 어느 기능 Feature
가 A
를 수행하기 위해 AImpl a
속성을 갖고 있다.
하지만 AImpl
는 인터페이스 A
의 구현체이므로, Feature
는 딱딱한 구현체에 의존 한다.
DIP
는 위 상황에서 아래처럼 추상화
에 의존하라 권유한다.
class Feature {
private A a;
public Feature(A a) {
this.a = a;
}
}
Feature feat = new Feature(
new AImpl()
);
이러한 경우 Feature
는 AImpl
구현체 의존에 벗어나 더 유연한 대처가 가능해진다.
SOLID
는 더 편하고 쉬운 코드
를 만드는데 의의가 있다 생각한다.
원칙들은 설계 과정에서 올바른 방향성을 제시하지만 그것이 실제 상황에선 정답이라 할 순 없기 때문이다.
그러니 SOLID
원칙에 너무 연연하지 않고 각자 상황에 따른 적절한 타협점을 찾았으면 한다.
Liskov substitution principle - Wikipedia
[1]
: Liskov's notion of a behavioural subtype defines a notion of substitutability for objects; that is, ifS
is a subtype ofT
, then objects of typeT
in a program may be replaced with objects of typeS
without altering any of the desirable properties of that program
solid에 대해 너무 잘 작성해주셨네요