의존관계 역전 원칙

의존관계 역전 원칙은 객체 지향 설계의 다섯 가지 기본 원칙(SOLID) 중 하나(D)입니다. 이를 인터페이스 편의 설계적인 관점에 넣으려고 했으나 상당히 중요한 부분이기 때문에 이렇게 따로 빼놨습니다.

위키백과의 정의를 빌어오자면 의존관계 역전 원칙(dependency inversion principle)은 "추상화에 의존해야지, 구체화에 의존하면 안된다"는 것입니다. 풀어서 설명하면, 첫 번째는 고수준 모듈(혹은 클래스)이 저수준 모듈(혹은 클래스)에 의존하지 말아야 합니다. 즉, 둘 다 추상화에 의존해야 합니다. 이 이유는 잠시 후 같이 살펴볼 것입니다.

고수준(high level)과 저수준(low level)

저수준 클래스는 고수준 클래스의 작업을 돕는 작은 클래스라고 할 수 있습니다. 보통은 파일 입출력, 데이터베이스 통신 같은 기술적인 부분을 다루는 저수준 클래스를 먼저 작성하고, 이런 저수준 클래스들을 사용해 현실 세계에서 유용한 비즈니스적인 부분을 다루는 고수준 클래스를 작성합니다. 고수준 클래스가 저수준 클래스를 사용하므로 고수준 클래스가 저수준 클래스에 의존하는 것이 자연스러워 보이지만, 저수준 클래스는 빈번하게 변경되고 추가될 때마다 고수준 클래스가 영향을 받기 쉬우므로 의존관계를 역전시켜야 합니다. 의존이 무엇인지는 곧 이어서 살펴볼 것입니다.

그리고 두 번째는 "추상화는 세부사항(즉, 구현)에 의존해서는 안된다."입니다. 구현이 변경되더라도 추상화가 변경되면 안된다는 뜻입니다. 다시 말해서 특정 구현을 먼저 보고 추상화를 떠올리면 이와 같은 일이 벌어질 수 있으며, 향후에 새로운 구현을 추가하게 되더라도 추상화가 변경되지 말아야 합니다. 이어서 "세부사항이 추상화에 의존해야 한다."인데 휴대폰의 볼륨 업 버튼을 딸깍 눌렀더니 볼륨이 낮아지거나, 볼륨이 갑자기 한 칸이 아닌 세 칸이 올라간다던가, 휴대폰 전원이 꺼지는 예상치 못한 일이 일어나서는 안 된다는 말입니다.

의존(dependency)

여기서 A가 B를 사용하고 있다면, 다시 말해서 A를 실행하는데 B가 필요하다면 A는 B에 의존한다고 말합니다. 예를 들어서 클래스 B가 클래스 A에 의존한다고 한다면, 클래스 B가 클래스 A의 메서드를 호출하거나 매개변수로 클래스 A의 인스턴스를 받거나 혹은 반환하는 일들을 떠올려 볼 수 있을 것입니다.

class A {
	// ...
	public int foo() { ... }
}

class B {
	// ...
	public void bar(A a) { ... }

	public A baz() { ... }

	public int qux() {
		A a = new A();
		int data = a.foo();
		...
	}
}

위의 예제에서는 클래스 A는 클래스 B의 존재를 모릅니다. 따라서 클래스 B가 변경되어도 클래스 A에는 영향을 미치지 않습니다. 반대로 클래스 B는 클래스 A에 의존하고 있기 때문에 클래스 A에 변경이 일어나면 클래스 B에도 영향을 미치게 됩니다. 예를 들어서, 클래스 B 내에서 클래스 A의  foo() 메서드를 사용하고 있는데 foo()의 메서드 시그니처가 변경되면 클래스 B의 qux()에서 오류가 발생할 것입니다. 

추상화(abstraction)

추상화도 위키백과에서 정의를 빌어오자면 "복잡한 자료, 모듈, 시스템 등으로부터 핵심적인 개념 또는 기능을 간추려 내는 것"을 말합니다. 다시 말하면 복잡성을 줄이기 위해서 불필요한 구현(세부사항)을 감추고 핵심적이고 관련 있는 정보만을 나타내는 과정을 의미합니다. 자바에서는 일반적으로 추상화를 인터페이스나 추상 클래스를 사용하여 달성할 수 있습니다. 하지만 추상 클래스는 다중 상속을 허용하지 않으므로 가능하다면 인터페이스를 권장합니다.

예를 들어서 우리는 리모컨이 세부적으로 어떻게 동작하는지 몰라도 리모컨의 버튼을 누르면 채널을 돌릴 수 있다는 사실을 알고 있습니다. 그리고 자동차의 공통적인 특성을 알고 있으므로 처음 보는 자동차 모델을 보더라도 그게 어떻게 동작하는지 모르는 상태로 자동차를 조작할 수 있습니다. 우리는 이처럼 내부 동작을 몰라도 우리가 어떤 행동을 취하면 기대하는 결과가 나타날 것임을 알고 있습니다.

추상화의 예시

이해를 돕기 위해서 알람시계의 예를 생각해봅시다. 주크박스든 라디오든 CD 플레이어든 핵심 정보를 추리면 무언가 재생할 수 있는 기능을 생각해볼 수 있습니다. 이를 Playable로 추상화하면 아래와 같이 작성할 수 있을 것입니다. 물론 여기서 더 핵심적인 정보를 추려낼 수도 있으며, 저마다 중요하다고 생각하는 기능이 조금씩 다를 수는 있습니다.

interface Playable {  
    void play();  
}  
  
class Jukebox implements Playable {  
    public void play() { /* ... */}  
}  
  
class Radio implements Playable {  
    public void play() { /* ... */}  
}  
  
class CDPlayer implements Playable {  
    public void play() { /* ... */}  
}

이렇게 추상화를 하면 주크박스든 라디오든 내부적으로 어떻게 돌아가는지는 상관없이 사용자 입장에서는 재생 버튼을 누르면 노래든 말소리가 흘러나올 것입니다.

Playable[] list = { new Jukebox(), new Radio(), new CDPlayer() };
for (Playable p : list) {
	p.play();
}

예제 살펴보기

이번에는 램프와 그 램프를 끄거나 킬 수 있는 스위치와 관련된 예제를 살펴봅시다. 스위치에는 현재 스위치의 상태를 저장하고 버튼을 누르는 동작을 정의합니다. 램프에서는 켜졌을 시 동작과 꺼졌을 시 동작을 정의합니다.

class Lamp {
    public void activate() {
        System.out.println("램프가 켜졌습니다.");
    }

    public void deactivate() {
        System.out.println("램프가 꺼졌습니다.");
    }
}

class LampElectricPowerSwitch {
    private Lamp lamp;
    private boolean on;

    public LampElectricPowerSwitch(Lamp lamp) {
        this.lamp = lamp;
    }

    public boolean isOn() {
        return on;
    }

// 현재 스위치의 상태에 따라서 스위치와 연결된 램프가 켜지거나 꺼진다.
    public void press() {
        if (isOn()) {
            lamp.deactivate();
            on = false;
        } else {
            lamp.activate();
            on = true;
        }
    }
}

public class DIPExamples {
    public static void main(String[] args) {
        Lamp lamp = new Lamp();
        LampElectricPowerSwitch powerSwitch = new LampElectricPowerSwitch(lamp);
        powerSwitch.press();
        powerSwitch.press();
    }
}

예제를 실행해보면 기대하는 대로 동작하는 것을 확인할 수 있습니다. 그러면 여기서 예제를 좀 더 확장시켜 봅시다. 램프 뿐만이 아니라 전구나 팬 등과 같이 다른 기기에도 스위치를 연결하고 싶다면 어떻게 해야 할까요? LightBulbElectricPowerSwitch와 FanElectricPowerSwitch도 만들려고 하면 같은 기능이 중복되고 각각 LightBulb와 Fan 클래스에 강하게 결합되는 문제가 있습니다. 그리고 향후에 Computer 등이 추가되면 ComputerElectricPowerSwitch도 새로 만들어야 할 것입니다.

위 그림에서 보는 바와 같이 PowerSwitch는 연결된 기기를 끄거나 키는 동작에만 관심이 있고, 켜지거나 꺼질 때 어떻게 동작할 것인지와 같은 세부사항은 Lamp, LightBulb, Fan에 들어 있습니다. 그러면 이를 어떻게 해결할 수 있을까요? 위에서 말한 의존관계 역전 원칙을 떠올리면 구체화가 아닌 추상화에 의존하라고 했습니다. 따라서 중간에 추상화 계층을 도입하여 아래와 같이 의존하도록 만들어봅시다.

켜지거나(activate() 또는 turnOn() 등) 꺼지는(deactivate() 또는 turnOff()) 동작을 Switchable로 추상화하고, Lamp나 Fan이 이를 구현하도록 만들면 아래와 같이 작성할 수 있습니다. ElectricPowerSwitch는 자신과 연결된 기기가 무엇인지 알 필요 없이 그저 그 기기가 키고 끌 수 있다는 것만 알고 있습니다.

// Switch도 DetectorSwitch, SlideSwitch, PushSwitch, PowerSwitch, RotarySwitch 등 여러 종류의 스위치가 있을 수 있다. 이 예에서는 PowerSwitch만 살펴본다.
interface Switch {
    void press(); // 스위치를 누름
    boolean isOn(); // 스위치가 켜져있는지 여부를 반환함
}

// 켜지거나 꺼질 수 있는 것으로 램프(Lamp), 팬(Fan), 전구(LightBulb), 콘센트(Outlet) 등이 있을 수 있다.
interface Switchable {
    void activate();
    void deactivate();
}

class ElectricPowerSwitch implements Switch {
	// 구체화(Lamp, Fan 등)가 아닌 추상화(Switchable)에 의존한다.
    private Switchable device;
    private boolean on;

	// 켜지거나 꺼질 수 있는 것이라면 아무 것이든 받아들일 수 있다.
	// 이 예제에는 없는 LightBulb 클래스가 향후 추가된다면 ElectricPowerSwitch를 변경하는 일 없이, LightBulb가 Switchable 인터페이스를 구현하기만 하면 된다.
    public ElectricPowerSwitch(Switchable device) {
        this.device = device;
    }

	// 스위치를 누르면 현재 스위치의 상태(on)에 따라서 기기를 키거나 끈다.
    public void press() {
        if (isOn()) {
            device.deactivate();
            on = false;
        } else {
            device.activate();
            on = true;
        }
    }

    public boolean isOn() {
        return on;
    }
}

class Lamp implements Switchable {
    public void activate() {
        System.out.println("램프가 켜졌습니다.");
    }

    public void deactivate() {
        System.out.println("램프가 꺼졌습니다.");
    }
}

class Fan implements Switchable {
    public void activate() {
        System.out.println("팬이 켜졌습니다.");
    }

    public void deactivate() {
        System.out.println("팬이 꺼졌습니다.");
    }
}

public class DIPExamples {
    public static void main(String[] args) {
        Switchable lamp = new Lamp();
        Switch lampSwitch = new ElectricPowerSwitch(lamp);
        lampSwitch.press();
        lampSwitch.press();

        Switchable fan = new Fan();
        Switch fanSwitch = new ElectricPowerSwitch(fan);
        fanSwitch.press();
        fanSwitch.press();
    }
}

이렇게 만들면 향후에 다른 클래스를 추가하더라도 그 클래스가 Switchable을 구현하기만 하면 ElectricPowerSwitch를 변경할 필요 없이 그대로 재사용할 수 있게 됩니다.