Java/객체지향

객체 지향과 디자인 패턴 Chapter 05 - 인터페이스 분리 원칙(SOLID)

_Jin_ 2024. 11. 14.

 

더보기
'개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴'을 읽고 학습합니다. ( 최범균 저 )

 

이번 장에서 다룰 인터페이스 분리 원칙(ISP)을 표현하자면 다음과 같다.

' 인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리해야 한다. '

 

본래 원칙 정의는 ' 클라이언트는 자신이 사용하는 메서드에만 의존해야 한다 '지만, 저자는 이해와 기억을 위해 나름 문장을 바꿔서 표현하였다.

 

 

해당, 내용에서 저자는 C++ 언어를 사용하여 인터페이스 분리 원칙을 설명하였다. 

하지만, 필자는 C++에 능숙하지 않고 책에 적힌 내용만으로 이해되지 않는 부분이 있어, 자료 조사를 더하여 정리해보겠다.

 

인터페이스 분리 원칙을 다루기에 앞서

 

인터페이스와 클래스( 특히, 추상 클래스 )는 설계와 관련이 깊다.

인터페이스와 추상 클래스를 구현, 상속한 클래스는 모두 추상 메소드의 구현을 강제 받는다. 

 

즉, 특정 클래스( 클라이언트 )가 필요로 하는 동작을 미리 명세하는 기능을 가지고 있는 것이다. 그리고 두 방식 모두 추상화와 다형성을 활용한 객체 지향적 설계를 가능하게 만들어준다. 

한편, 이번 내용은 인터페이스 분리 원칙으로 인터페이스를 사용함에 필요한 원칙에 대해서 다룬다. 

앞서 다룬 내용 중에 이렇게 특정 문법에 대해서 다룬 적이 없었다.

 

' 단일 책임 원칙 ' 부터 ' 개방-폐쇄 원칙 ', ' 리스코프 치환 원칙 '까지 객체 지향적 설계와 사용을 위한 범용적인 개념이자 원칙이었다.

그런데 이번에는 인터페이스라는 범주로 다루고 있는만큼, 해당 원칙을 이해하기 위해선 인터페이스의 사용에 대한 특수성을 이해할 필요가 있다고 고려되었다.

따라서 인터페이스의 사용에 대해 이해하고 이와 관련하여 해당 원칙이 지향하는 바가 무엇인지 이해하는 하는 내용으로 정리해보겠다. 

 

인터페이스의 특수성과 사용

 

추상 클래스와 인터페이스 모두 설계에서 미리 선언하여 개발에는 기능 구현에만 집중할 수 있도록 도와준다.

따라서 두 기능 모두 구체적 구현에 앞서 설계를 위해 사용한다는 공통점을 지녔는데, 가장 큰 차이는 ' 다중 상속 '이 가능한가에 대한 여부이다. 

 

인터페이스는 추상 클래스와 달리 다중 상속이 가능하다.

이 차이를 어떻게 바라보고 왜 나눴는지에 대한 의문이 든다. 분명 두 기능 모두 설계를 위한 것인데 ?

 

이를 파악하기 위해 인터페이스와 추상 클래스를 사용할 때, 객체지향적 설계에 따라 다음과 같은 부분들에 대해서 고려하며 파악해보겠다.

 

 추상화와 다형성의 측면
 다중 상속의 필요와 설계 유연성 (인터페이스의 다중 상속)

 

추상화와 다형성의 측면

 

인터페이스와 추상 클래스는 객체 지향 프로그래밍의 다형성을 구현하는 중요한 요소이다.

따라서 클래스 타입을 통합한다는 취지의 기능은 둘이 똑같다. 

그럼에도 둘의 사용을 구분한다면, 필자는 ' 상속의 목적 ' 이라고 생각한다. ( 구현의 목적 / 재사용의 목적  )

 

상속의 목적 ?

 

먼저 인터페이스는 사용할 때, < 클래스 네임 + implements + 인터페이스 네임 > 의 형식으로 사용한다.

implements( 구현하다 )라는 의미로 파악할 수 있듯, 인터페이스는 구현해야하는 명세를 의미한다.

인터페이스를 상속받는 클래스는 인터페이스의 명세에 따라 구현의 책임을 받게 되는 것이다. 따라서 인터페이스를 상속에 있어 상위 타입으로 사용하는 경우 구현의 강제에 목적이 있다.

 

한편, 추상 클래스의 extends는 말 그대로 보통의 클래스처럼 부모-자식 관계로 상속받는 것이며, 다만 자식 클래스가  추상 클래스에서 정의한 추상 메서드를 구현해야하는 강제성이 추가된 것이다.

즉, 보통의 클래스처럼 코드의 재사용적 측면에서 상속을 사용함과 동시에 구현의 강제적 기능을 함께 지닌 것이다.

이는 인터페이스와 마찬가지로 분명 구현의 강제적 목적을 지니고 있지만, 인터페이스에 비하면 코드의 재사용적 목적이 강하다.

 

이렇게 보면, 코드의 재사용성 이점을 지닌 추상 클래스를 활용한 설계가 더 좋아 보일 수 있다. 

하지만 상속을 재사용과 확장이 ' 상위 클래스 변경의 어려움 / 클래스의 불필요한 증가 / 상속의 오용 ' 등의 문제를 유발했던 점을 유의하자.

명확한 IS-A 관계가 아니라면 상속을 통한 재사용과 확장보단 조립 및 조합을 통한 코드의 재사용을 지향하자고 학습하였다.

 

( 설계 단계에서 이런 관계를 명확하게 가지는 것은 쉽지 않을 것 같다... ) 

 

다중 상속의 필요와 설계 유연성 (인터페이스의 다중 상속)

 

그리고 이를 다중 상속 기능적 관점에서 보자면, 추상 클래스는 다중 상속이 불가능하기에 부모 클래스에서 정의한 기능과 명세에 따른 명확한 계층 구조하에 상속 계층이 깊어질수록 설계가 복잡해지고 코드가 유연성을 잃기 쉽다.

 

하지만, 인터페이스에서는 이야기가 달라진다.

인터페이스는 다중 상속이 가능하기 때문에 클라이언트는 각 인터페이스에 따라 필요한 기능들을 조합하여 객체를 설계할 수 있다.

 

아래의 코드들을 통해 파악해보자.

 

// 추상 클래스를 사용한 경우
abstract class Animal {
    abstract void move();
    abstract void eat();
}

abstract class FlyingAnimal extends Animal {
    abstract void fly();
}

// Bird는 FlyingAnimal만 상속 가능
class Bird extends FlyingAnimal {
    void move() { /* 구현 */ }
    void eat() { /* 구현 */ }
    void fly() { /* 구현 */ }
}

// Duck이 수영도 하고 싶다면?
// SwimmingAnimal을 또 만들어야 하나?
// 다중 상속이 불가능하므로 코드 중복이 발생하거나 
// 억지로 계층 구조를 만들어야 함
abstract class SwimmingAnimal extends Animal {
    abstract void swim();
}

 

이처럼 추상 클래스를 사용해서 설계를 진행하면, 단일 상속만 가능하기에 새로운 기능의 추가에 따라 계층 구조가 필연적으로 복잡해진다.

이후 만들어질 Duck 객체가 있다고하면, Animal 클래스를 상속받은 SwimmingAnimal를 상속받는 설계가 진행될 것이다.

즉 유연한 기능 조합이 어렵고, 불필요한 기능까지 상속받는 경우가 생길 수 있는 것이며 이는 코드 재사용을 위한 억지의 상속 관계 모습이다.

 

반면에, 인터페이스를 사용하는 경우를 살펴보자.

 

// 인터페이스를 사용한 경우
interface Movable {
    void move();
}

interface Eatable {
    void eat();
}

interface Flyable {
    void fly();
}

interface Swimmable {
    void swim();
}

// 필요한 기능만 조합하여 구현
class Bird implements Movable, Eatable, Flyable {
    public void move() { /* 구현 */ }
    public void eat() { /* 구현 */ }
    public void fly() { /* 구현 */ }
}

// Duck은 수영도 가능
class Duck implements Movable, Eatable, Flyable, Swimmable {
    public void move() { /* 구현 */ }
    public void eat() { /* 구현 */ }
    public void fly() { /* 구현 */ }
    public void swim() { /* 구현 */ }
}

 

객체( 클라이언트 )가 필요한 기능에 따른 명세를 인터페이스로 정의하고, 다중 상속을 통해 객체의 기능을 조립하고 있다. 

추상 클래스를 사용한 설계와 달리 필요한 기능만 선택적으로 구현하고, 새로운 기능 추가에 용이한 모습이다. 

그리고 클라이언트의 필요에 따른 유연한 조합이 가능해졌다.

 

이와 같은 인터페이스의 활용적 측면에서 고려했을 때, 인터페이스 분리 원칙이 지향하는 바는 아래의 코드와 같다.

 

// ISP를 위반한 경우
interface Worker {
    void work();
    void eat();
    void sleep();
}

// ISP를 적용한 경우
interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

interface Sleepable {
    void sleep();
}

// 로봇은 일만 하면 됨
class Robot implements Workable {
    public void work() { /* 구현 */ }
}

// 사람은 모든 기능이 필요
class Human implements Workable, Eatable, Sleepable {
    public void work() { /* 구현 */ }
    public void eat() { /* 구현 */ }
    public void sleep() { /* 구현 */ }
}

 

Worker를 상속받을 경우 work(), eat(), sleep() 메서드를 모두 구현해야하는 책임을 지니게 된다. 그런데, 만약 Robot 객체처럼 일만하면 되는 경우, 나머지 메서드는 필요가 없어진다. 

이는 앞서 살펴본 추상 클래스를 사용하는 경우와 다를 것이 없다. 

 

따라서, 각 클라이언트가 필요로하는 기능을 다중 상속을 통해 조합하여 설계할 수 있도록 메소드마다 인터페이스를 분리하였고, 객체( 클라이언트 )는 필요에 따른 기능을 조합할 수 있게되었다.

 

지금까지의 내용으로 객체 지향 설계 측면에서 ISP가 지향하는 ' 인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리해야 한다. '( =' 클라이언트는 자신이 사용하는 메서드에만 의존해야 한다 ' )는 의미가 무엇인지 파악할 수 있었다.

설계적 관점에서 인터페이스의 기능과 활용에 대해 전보다 뚜렷하게 이해할 수 있던 학습이었다.

댓글