interface IPerson {
wakeUp(): void;
bathroomActivity(): void;
}
class Person implements IPerson {
private bathroom: IBathroom;
constructor(bathroom: IBathroom) {
this.bathroom = bathroom;
}
wakeUp() {
console.log('아침에 일어난다.');
}
bathroomActivity() {
this.bathroom.goToBathroom();
this.bathroom.washFace();
this.bathroom.brushTeeth();
this.bathroom.washHair();
this.bathroom.dryOff();
this.bathroom.leaveBathroom();
}
}
interface IBathroom {
goToBathroom(): void;
washFace(): void;
brushTeeth(): void;
washHair(): void;
dryOff(): void;
leaveBathroom(): void;
}
class Bathroom implements IBathroom {
goToBathroom() {
console.log('화장실로 간다.');
}
washFace() {
console.log('얼굴을 씻는다.');
}
brushTeeth() {
console.log('양치를 한다.');
}
washHair() {
console.log('머리를 감는다.');
}
dryOff() {
console.log('물기를 닦는다.');
}
leaveBathroom() {
console.log('화장실을 나온다.');
}
}
class Morning {
private person: IPerson;
constructor(person: IPerson) {
this.person = person;
}
morningActivity(): void {
this.person.wakeUp();
this.person.bathroomActivity();
}
}
const morning = new Morning(new Person(new Bathroom()));
morning.morningActivity();
[객체지향] SOLID 예제(4) - 리스코프 치환의 원칙(LSP)
마지막 ISP를 적용시키기 전 코드의 모습이다.
현재 코드의 구성이 나쁘지 않지만 한 가지 문제가 여전히 남아있다.
만약, 누군가는 아침에 정해져 있는 일련의 과정(화장실로 간다 - 세수 - 양치 - 머리를 감는다 - 물을 닦는다 - 화장실을 나온다)중 일부만 수행하는 경우는 어떻게 하는가?
예를 들어 화장실에서 물을 사용할 수 없는 경우(흔치 않겠지만) 그 사람이 아침에 할 수 있는 일은 화장실로 간다 - 화장실을 나온다 밖에 할 수 없을 것이다.
이런 경우는 Person 객체의 bathroomAvtivity 메소드를 수정해야 한다.
그렇다고 물을 사용할 수 없는 화장실에 맞춰 washFace, brushTeeth 등의 메소드를 비워 두거나 오류를 발생시키는 것이 적절한 방법일까?
Person은 Bathroom에 대해 (물이 나오는 상황에서의) 화장실에서 할 수 있는 행동을 기대하고 있다.
만약, Bathroom의 특정 메소드가 비어있거나 기대와 다르게 작동한다면 (사용할 수 없는 메소드 이므로 에러를 반환하는 등) 이는 Person에도 변경이 필요하다는 의미가 된다.
이를 해결할 수 있는 방법이 ISP(인터페이스 분리의 법칙)이다.
인터페이스 분리의 법칙(ISP)
인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙이다.
쉽게 생각해서 자신이 사용하지 않는 인터페이스에 의존하지 않도록 구성하는 원칙이다.
앞선 글에서 상속의 두 목적(서브 클래싱과 서브 타이핑)에 대해 언급했었다.
그렇다면 상속은 언제 사용하는 것일까?
두 가지 경우가 있다.
상속 관계가 is-a 관계를 모델링하는 경우와 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한 경우이다.
is-a
어떤 타입 S가 다른 타입 T의 일종인 경우를 의미한다.
타입 계층을 나누며 주의해야 하는 것은 어휘적인 정의가 아니라 행동에 따라 타입 계층을 구성해야 한다는 것이다.
새는 날 수 있고 닭은 날 수 없다.
닭은 새를 상속했다고 볼 수 있지 않을까?
객체지향의 관점에서 새의 정의에 날 수 있다는 행동이 포함된다면 닭은 새의 서브타입이 될 수 없다.
하지만, 새의 정의에 날 수 있다는 행동이 포함되지 않는다면 닭은 새의 서브타입이 될 수 있다.
이러한 is-a 관계를 만족하더라도 상속을 사용할 수 있는 "후보"로 생각하는 것이 바람직하다.
뒤에 알아볼 행동 호환성이 더 중요하다고 볼 수 있기 떄문이다.
행동 호환성
두 타입 사이의 행동이 호환될 경우에만 타입 계층으로 묶어야 한다는 의미이다.
행동 호환성은 단순히 동일한 메소드를 구현하고 있는 것을 의미하지 않는다.
클라이언트가 두 타입이 동일하게 행동할 것이라 기대한다면 두 타입을 타입 계층으로 묶을 수 있다.
위의 새와 닭 예시에서 닭이 새를 상속할 수 없는 이유는 클라이언트 입장에서 모든 새가 날 수 있을 것이라 가정하기 때문이다.
이를 해결하기 위해 닭에서 "날다"와 관련된 메소드를 비워두거나 에러를 반환하도록 할 수 있다.
하지만 이는 클라이언트 입장에서 "새는 날 수 있다"에 대한 기대를 만족시키지 못한다.
또 다른 방법으로는 instanceof 등을 사용하여 닭이 아닌 경우만 실행시키는 방법이 있을 것이다.
하지만 이 방법 역시 instanceof를 사용하면서 클래스의 결합도를 높이게 된다. (new 키워드와 마찬가지)
(instanceof를 사용하면 객체의 타입을 확인하는 코드 중 하나로, 이와 같이 객체의 타입 등을 확인하는 코드는 새로운 타입을 추가할 때마다 코드 수정을 요구하기 때문에 개방-폐쇄의 원칙(OCP)를 위반한다.)
interface IPerson {
wakeUp(): void;
bathroomActivity(): void;
}
class Person implements IPerson {
private bathroom: IBathroom;
constructor(bathroom: IBathroom) {
this.bathroom = bathroom;
}
wakeUp() {
console.log('아침에 일어난다.');
}
bathroomActivity() {
this.bathroom.goToBathroom();
this.bathroom.washFace();
this.bathroom.brushTeeth();
this.bathroom.washHair();
this.bathroom.dryOff();
this.bathroom.leaveBathroom();
}
}
interface IBathroom {
goToBathroom(): void;
washFace(): void;
brushTeeth(): void;
washHair(): void;
dryOff(): void;
leaveBathroom(): void;
}
class Bathroom implements IBathroom {
goToBathroom() {
console.log('화장실로 간다.');
}
washFace() {
console.log('얼굴을 씻는다.');
}
brushTeeth() {
console.log('양치를 한다.');
}
washHair() {
console.log('머리를 감는다.');
}
dryOff() {
console.log('물기를 닦는다.');
}
leaveBathroom() {
console.log('화장실을 나온다.');
}
}
class Morning {
private person: IPerson;
constructor(person: IPerson) {
this.person = person;
}
morningActivity(): void {
this.person.wakeUp();
this.person.bathroomActivity();
}
}
const morning = new Morning(new Person(new Bathroom()));
morning.morningActivity();
처음의 예제로 돌아가보자.
위의 코드를 약간만 수정해서 아래와 같이 만들어보자.
(...)
class Person implements IPerson {
private bathroom: IBathroom;
constructor(bathroom: IBathroom) {
this.bathroom = bathroom;
}
wakeUp() {
console.log('아침에 일어난다.');
}
bathroomActivity() {
this.bathroom.bathroomActivity();
}
}
interface IBathroom {
goToBathroom(): void;
washFace(): void;
brushTeeth(): void;
washHair(): void;
dryOff(): void;
leaveBathroom(): void;
bathroomActivity(): void;
}
class Bathroom implements IBathroom {
(...)
bathroomActivity() {
this.goToBathroom();
this.washFace();
this.brushTeeth();
this.washHair();
this.dryOff();
this.leaveBathroom();
}
}
(...)
우선 Person 클래스의 bathroomActivity 메소드의 기능을 Bathroom 클래스 내부로 이동시켰다.
이 과정을 통해 Person은 Bathroom과 결합도를 낮출 수 있었고 Bathroom안에 어떤 메소드가 있는지 몰라도 자신의 기능을 수행할 수 있게 되었다.
만약, 처음 예를 들었던 대로 물을 사용할 수 없는 화장실(BathroomWithoutWater)이 있다면 어떻게 되는가?
IBathroom 인터페이스의 goToBathroom과 leaveBathroom, bathroomActivity 세 개의 메소드만 필요할 것이다.
BathroomWithoutWater 클래스는 IBathroom을 구현하게 될 것이고 나머지 필요없는 메소드도 구현해야 한다.
이를 해결하기 위해 물을 사용할 수 있는 경우와 사용할 수 없는 경우에 대해 인터페이스를 분리해보자.
(...)
class Person implements IPerson {
private bathroom: IBathroom;
constructor(bathroom: IBathroom) {
this.bathroom = bathroom;
}
wakeUp() {
console.log('아침에 일어난다.');
}
bathroomActivity() {
this.bathroom.bathroomActivity();
}
}
interface IBathroom {
goToBathroom(): void;
leaveBathroom(): void;
bathroomActivity(): void;
}
interface IBathroomWithWater extends IBathroom {
washFace(): void;
brushTeeth(): void;
washHair(): void;
dryOff(): void;
}
class BathroomWithWater implements IBathroomWithWater {
(...)
bathroomActivity() {
this.goToBathroom();
this.washFace();
this.brushTeeth();
this.washHair();
this.dryOff();
this.leaveBathroom();
}
}
class BathroomWithoutWater implements IBathroom {
(...)
bathroomActivity() {
this.goToBathroom();
this.leaveBathroom();
}
}
(...)
화장실에는 기본적으로 들어가거나 나가는 기능이 있을 것이다.
(들어가거나 나올 수 없는 곳이라면 화장실이 아니지 않을까?)
따라서, IBathroom은 기본적으로 화장실의 출입 메소드와 화장실에서 하는 행위를 수행하는 bathroomActivity 메소드를 갖도록 구성했다.
물을 사용할 수 있는 화장실의 경우 IBathroom을 확장하여 물과 관련된 다양한 활동을 할 수 있도록 수정했다.
이렇게 함으로써 화장실에서 물을 사용할 수 있던 없던 Person은 수정하지 않고 사용할 수 있으며, 메소드를 실행하는 부분을 각 화장실 클래스 내부로 캡슐화할 수 있었다.
Person이 사용하는 Bathroom의 메소드는 오직 bathroomActivity 하나이며 Person이 해당 메소드에 대해 기대하는 것은 "화장실에서 수행하는 일련의 과정을 순차적으로 실행"하는 것이며 화장실 물의 유무에 관계없이 기대를 충족할 수 있다.
(사용하지 않는 인터페이스에 의존해서는 안된다는 원칙도 충족한다)
결론적으로 위의 두 종류가 아닌 다른 화장실이 추가되더라도 Person의 수정 없이 해당 화장실을 사용할 수 있을 것이다.
결론
인터페이스 분리의 원칙(ISP)는 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙이다.
상속을 사용할 때는 is-a관계를 모델링 하는지, 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한지 확인해야 한다.
instanceof와 같이 객체의 타입을 확인하는 코드는 새로운 타입을 추가할 때마다 코드 수정을 요구하기 때문에 개방-폐쇄 원칙을 위반한다.
마무리
class Person {
wakeUp() {
console.log('아침에 일어난다.');
}
goToBathroom() {
console.log('화장실로 간다.');
}
washFace() {
console.log('얼굴을 씻는다.');
}
brushTeeth() {
console.log('양치를 한다.');
}
washHair() {
console.log('머리를 감는다.');
}
leaveBathroom() {
console.log('화장실을 나온다.');
}
dryOff() {
console.log('물기를 닦는다.');
}
}
const person = new Person();
person.wakeUp();
person.goToBathroom();
person.washFace();
person.washHair();
person.brushTeeth();
person.leaveBathroom();
person.dryOff();
interface IPerson {
wakeUp(): void;
bathroomActivity(): void;
}
class Person implements IPerson {
private bathroom: IBathroom;
constructor(bathroom: IBathroom) {
this.bathroom = bathroom;
}
wakeUp() {
console.log('아침에 일어난다.');
}
bathroomActivity() {
this.bathroom.bathroomActivity();
}
}
interface IBathroom {
goToBathroom(): void;
leaveBathroom(): void;
bathroomActivity(): void;
}
interface IBathroomWithWater extends IBathroom {
washFace(): void;
brushTeeth(): void;
washHair(): void;
dryOff(): void;
}
class BathroomWithWater implements IBathroomWithWater {
goToBathroom() {
console.log('화장실로 간다.');
}
washFace() {
console.log('얼굴을 씻는다.');
}
brushTeeth() {
console.log('양치를 한다.');
}
washHair() {
console.log('머리를 감는다.');
}
dryOff() {
console.log('물기를 닦는다.');
}
leaveBathroom() {
console.log('화장실을 나온다.');
}
bathroomActivity() {
this.goToBathroom();
this.washFace();
this.brushTeeth();
this.washHair();
this.dryOff();
this.leaveBathroom();
}
}
class BathroomWithoutWater implements IBathroom {
goToBathroom() {
console.log('화장실로 간다.');
}
leaveBathroom() {
console.log('화장실을 나온다.');
}
bathroomActivity() {
this.goToBathroom();
this.leaveBathroom();
}
}
class Morning {
private person: IPerson;
constructor(person: IPerson) {
this.person = person;
}
morningActivity(): void {
this.person.wakeUp();
this.person.bathroomActivity();
}
}
const morning = new Morning(new Person(new BathroomWithWater()));
morning.morningActivity();
위의 코드를 개선해서 밑의 코드로 만드는 과정을 통해 SOLID 원칙을 적용해봤다.
코드의 길이가 더 길어지고 비효율적인 작업처럼 보일 수 있다.
하지만 개선하는 과정을 통해 유지보수가 쉽고 확장에 용이하도록 다양한 원칙을 적용해볼 수 있었다.
SOLID 원칙은 반드시 지켜야 하는 규칙이라기 보다는 좋은 설계에 대한 "가이드"에 가깝다.
모든 상황에서 SOLID 원칙이 정답이 아님을 알고 상황에 맞게 유연하게 접근하는 능력이 더 중요하다는 생각을 갖게 되었다.
[참고자료]
위키북스, 오브젝트, 2019
'개발 > 개념' 카테고리의 다른 글
WebRTC란 무엇이며 어떤 과정을 갖는가? (0) | 2023.11.12 |
---|---|
Vite는 왜 빠를까? (0) | 2023.10.21 |
[객체지향] SOLID 예제(4) - 리스코프 치환의 원칙(LSP) (0) | 2023.09.29 |
[객체지향] SOLID 예제(3) - 개방-폐쇄의 원칙(OCP) (0) | 2023.09.26 |
[객체지향] SOLID 예제(2) - 의존성 역전의 원칙(DIP) (0) | 2023.09.25 |