SOLID원칙이란, 객체 지향 설계에서 지켜줘야 할 5개의 소프트웨엇 개발 원칙(SRP, OCP, LSP, ISP, DIP)를 말한다.
💠 SRP(Single Responsibility Principle) : 단일 책임 원칙
💠 OCP(Open Closed Principle) : 개방 패쇄 원칙
💠 LSP(Listov Substitution Principle) : 리스코프 치환 원칙
💠 ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
💠 DIP(Dependency Inversion Principle) : 의존 역전 원칙
SOLID 객체 지향 원칙을 개발에 적용하면 코드를 확장과 유지 보수 관리가 더 쉬워지고, 불필요한 복잡성을 제거해 리팩토링에 소요되는 시간을 줄일 수 있어 프로젝트 개발의 생산성을 높일 수 있다.
클래스(객체)는 단 하나의 책임만 가져야 한다.
여기에서 책임은 '하나의 기능'이라고 생각하면 된다.
즉, 하나의 클래스는 하나의 기능만을 담당하여 하나의 책임을 수행하는데 집중되도록 클래스를 따로따로 여러개 설계한다는 원칙.
만일 하나의 클래스에 기능(책임)이 여러개 있을 경우, 기능 변경(수정)을 해야할 때 수정해야 할 코드가 많아지게 된다.
A를 고쳤더니 B를 수정해야 하고, 또 C를 수정해야 하고... C를 수정했더니 다시 A를 수정해야 하는,,,, 그런 문제가 생길 수 있다.
따라서 SRP원칙을 따름으로써 한 책임의 변경으로부터 다른 책임의 변경으로의 연쇄작용을 극복할 수 있게 된다.
최종적으로 프로그램의 유지보수성을 높이기 위한 설계 기법인 것!
class Employee {
String name;
String positon;
Employee(String name, String position) {
this.name = name;
this.positon = position;
}
// * 초과 근무 시간을 계산하는 메서드 (두 팀에서 공유하여 사용)
void calculateExtraHour() {
// ...
}
// * 급여를 계산하는 메서드 (회계팀에서 사용)
void calculatePay() {
// ...
this.calculateExtraHour();
// ...
}
// * 근무시간을 계산하는 메서드 (인사팀에서 사용)
void reportHours() {
// ...
this.calculateExtraHour();
// ...
}
// * 변경된 정보를 DB에 저장하는 메서드 (기술팀에서 사용)
void saveDababase() {
// ...
}
}
위 코드에 대해 예시 상황을 들어보자면,
- 회계팀에서 급여를 계산하는 기존 방식을 업데이트하여, 초과 근무 시간을 계산하는
calculateExtraHour()
를 수정해야 하는 상황 발생calculateExtraHour()
를 수정하니, 의도치 않게 인사팀에서 사용하는reportHours()
메서드에 영향을 주게 됨reprotHours()
값의 오류 발생!!!
위와 같은 상황이 SRP에 위배되는 상황인 것....
Employee 클래스에서 회계팀, 인사팀, 기술팀 이렇게 3개의 액터에 대한 책임을 한꺼번게 가지고 있기 때문,,,,,
// * 통합 사용 클래스
class EmployeeFacade {
private String name;
private String positon;
EmployeeFacade(String name, String position) {
this.name = name;
this.positon = position;
}
// * 급여를 계산하는 메서드 (회계팀 클래스를 불러와 에서 사용)
void calculatePay() {
// ...
new PayCalculator().calculatePay();
// ...
}
// * 근무시간을 계산하는 메서드 (인사팀 클래스를 불러와 에서 사용)
void reportHours() {
// ...
new HourReporter().reportHours();
// ...
}
// * 변경된 정보를 DB에 저장하는 메서드 (기술팀 클래스를 불러와 에서 사용)
void EmployeeSaver() {
new EmployeeSaver().saveDatabase();
}
}
// * 회계팀에서 사용되는 전용 클래스
class PayCalculator {
// * 초과 근무 시간을 계산하는 메서드
void calculateExtraHour() {
// ...
}
void calculatePay() {
// ...
this.calculateExtraHour();
// ...
}
}
// * 인사팀에서 사용되는 전용 클래스
class HourReporter {
// * 초과 근무 시간을 계산하는 메서드
void calculateExtraHour() {
// ...
}
void reportHours() {
// ...
this.calculateExtraHour();
// ...
}
}
// * 기술팀에서 사용되는 전용 클래스
class EmployeeSaver {
void saveDatabase() {
// ...
}
}
이를 해결하기 위해서는 각 책임(기능)에 맞게 클래스를 분리해 구성하면 된다.
회계팀, 인사팀, 기술팀의 기능 담당은 PayCalculator, HourReporter, EmployeeSaver라는 각기 클래스로 분리하고, 이를 통합적으로 사용하는 EmployeeFacade라는 클래스를 만든다.
사실상 EmployeeFacade 클래스 메서드에는 아무런 로직이 들어가 있지 않고, 생성자로 인스턴스를 생성하고 각 클래스의 메서드를 사용하는 역할 정도만 하게 된다.
이렇게 되면 변경 사항이 생기게 되더라도 각각의 분리된 클래스만 수정하면 되기 때문에 문제가 발생하지 않는다.
Facade 패턴
위와 같은 구성의 디자인 패턴을 Facade 패턴이라고 한다.
Facade란 건물의 정면을 의미하는 것으로, Facade Pattern은 건물의 뒷면이 어떻게 생겼는지는 보여주지 않고 건물의 정면만 보여주는 패턴이다.
EmployeeFacade 클래스는 메서드의 구현(건물의 뒷면)이 어떻게 되어있는지 보여주지 않고, 어떤 메서드(건물의 정면)가 있는지만 보여준다.
구체적인 메서드의 구현은 EmployeeFacade 클래스 안의 각 클래스들에 위임하기 때문이다.
클래스가 하나의 책임을 가지고 있다는 것을 나타내기 위해, 클래스가 어떠한 기능을 담당하는지 알 수 있게 클래스 명을 작명하는 것이 좋다.
✅ 응집도 - 한 프로그램 요소가 얼마나 뭉쳐있는가
✅ 결합도 - 프로그램 구성 요소들 사이가 얼마나 의존적인가
좋은 프로그램이란 응집도는 높게, 결합도는 낮게 설계하는 것이다.
클래스는 확장에 열려있어야 하며, 수정에는 닫혀있어야 한다.
기능 추가 요청이 오면 클래스 확장을 통해 손쉽게 구현하고, 확장에 따른 클래스 수정은 최소화 할 수 있도록 프로그램을 작성해야 하는 설계 기법
✅ 확장에 열려있다 -> 새로운 변경 사항이 발생했을 때, 유연하게 코드를 추가함으로써 큰 힘을 들이지 않고 애플리케이션의 기능을 확장할 수 있다.
✅ 변경에 닫혀있다 -> 새로운 변경 사항이 발생했을 때 객체를 직접적으로 수정하는 것을 제한한다.
이는 추상화 사용을 통한 관계 구축을 권장한다는 의미이다.
즉, 다형성과 확장을 가능하게 하는 객체지향의 장점을 극대화 시키자는 것!!
class Animal {
String type;
Animal(String type) {
this.type = type;
}
}
// 동물 타입을 받아 각 동물에 맞춰 울음소리를 내게 하는 클래스 모듈
class HelloAnimal {
void hello(Animal animal) {
if(animal.type.equals("Cat")) {
System.out.println("냐옹");
} else if(animal.type.equals("Dog")) {
System.out.println("멍멍");
}
}
}
public class Main {
public static void main(String[] args) {
HelloAnimal hello = new HelloAnimal();
Animal cat = new Animal("Cat");
Animal dog = new Animal("Dog");
hello.hello(cat); // 냐옹
hello.hello(dog); // 멍멍
}
}
Animal 클래스가 있고, Animal 타입을 받아 각 동물의 소리에 맞춰 출력을 하는 Hello Animal 클래스가 있다.
작동은 잘 되나, 기능추가가 매우 어려워 보인다....
public class Main {
public static void main(String[] args) {
HelloAnimal hello = new HelloAnimal();
Animal cat = new Animal("Cat");
Animal dog = new Animal("Dog");
Animal sheep = new Animal("Sheep");
Animal lion = new Animal("Lion");
hello.hello(cat); // 냐옹
hello.hello(dog); // 멍멍
hello.hello(sheep);
hello.hello(lion);
}
}
class HelloAnimal {
// 기능을 확장하기 위해서는 클래스 내부 구성을 일일히 수정해야 하는 번거로움이 생긴다.
void hello(Animal animal) {
if (animal.type.equals("Cat")) {
System.out.println("냐옹");
} else if (animal.type.equals("Dog")) {
System.out.println("멍멍");
} else if (animal.type.equals("Sheep")) {
System.out.println("메에에");
} else if (animal.type.equals("Lion")) {
System.out.println("어흥");
}
// ...
}
}
이런 방식으로 동물을 추가할 때마다 일일히 코드를 바꿔줘야 하는 작업이 이루어져야 한다....
이를 해결하기 위해서는 적절한 추상화 클래스를 구성하고, 이를 상속하여 확장시키는 관계로 구성해야 한다.
// 추상클래스를 상속만 하면 메소드 강제 구현 규칙으로 규격화만 하면 확장에 제한 없다 (opened)
// 추상화
abstract class Animal {
abstract void speak();
}
class Sheep extends Animal {
void speak() {
System.out.println("매에에");
}
}
class Lion extends Animal {
void speak() {
System.out.println("어흥");
}
}
// 기능 확장으로 인한 클래스가 추가되어도, 더이상 수정할 필요가 없어진다 (closed)
class HelloAnimal {
void hello(Animal animal) {
animal.speak();
}
}
public class Main {
public static void main(String[] args) {
HelloAnimal hello = new HelloAnimal();
Animal cat = new Cat();
Animal dog = new Dog();
Animal sheep = new Sheep();
Animal lion = new Lion();
hello.hello(cat); // 냐옹
hello.hello(dog); // 멍멍
hello.hello(sheep); // 매에에
hello.hello(lion); // 어흥
}
}
확장에는 열려있고 변경에는 닫히게 하기 위해서는 추상화를 잘 설계해야 한다.
그리고 추상화(추상 클래스, 인터페이스)를 정의할 때에는 여러 경우의 수에 대한 고려와 예측이 필요하다.
추상화란, 다른 모든 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징이다.
즉, 추상 메서드 설계에서 적당한 추상화 레벨을 선택함으로써, 어떠한 행위에 대한 본질적인 정의를 서브 클래스에 전파함으로써 관계를 성립되게 하는 것이다.
서브 타입은 언제나 기반(부모) 타입으로 교체할 수 있어야 한다.
쉽게 말하면 다형성 원리를 이용하기 위한 원칙으로 보면 된다.
다형성의 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면, 업캐스팅 된 상태에서 부모의 메서드를 사용해도 동작이 의도대로 흘러가야 하는 것을 의미한다.
❔
따라서, 기본적으로 LSP원칙은 부모 메서드의 오버라이딩을 조심스럽게 따져가며 해야 한다.
왜냐하면 부모 클래스와 동일한 수준의 선행 조건을 기대하고 사용하는 프로그램 코드에서 예상치 못한 문제가 발생할 수도 있기 때문이다.
class Animal {
int speed = 100;
int go(int distance) {
return speed * distance;
}
}
class Eagle extends Animal {
String go(int distance, boolean flying) {
if (flying)
return distance + "만큼 날아서 갔습니다.";
else
return distance + "만큼 걸어서 갔습니다.";
}
}
public class Main {
public static void main(String[] args) {
Animal eagle = new Eagle();
eagle.go(10, true);
}
}
위 코드에서는 Animal 클래스를 상속하는 Eagle 자식 클래스가 부모 클래스의 go()
메서드를 자기 멋대로 코드를 재사용해 메서드 타입을 바꾸고 매개변수 개수도 바꿔버렸다.
이는 오버로딩도 아닌...오버라이딩도 아닌... 그냥 오류가 발생해버린다.
위 문제를 해결하기 위해서는 go()
메서드의 형태를 부모 클래스와 똑같이 구현해 오버라이딩을 하던지, Main 클래스에서 Eagle 타입으로 선언해 오버로딩을 하던지 해야 한다.
class NaturalType {
String type;
NaturalType(Animal animal) {
// 생성자로 동물 이름이 들어오면, 정규표현식으로 매칭된 동물 타입을 설정한다.
if(animal instanceof Cat) {
type = "포유류";
} else {
// ...
}
}
String print() {
return "이 동물의 종류는 " + type + " 입니다.";
}
}
class Animal {
NaturalType getType() {
NaturalType n = new NaturalType(this);
return n;
}
}
class Cat extends Animal {
}
public class Main {
public static void main(String[] args) {
Animal cat = new Cat();
String result = cat.getType().print();
System.out.println(result); // "이 동물의 종류는 포유류 입니다."
}
}
이 코드는 Animal 클래스에 확장되는 동물들을 다형성을 이용해 업캐스팅으로 인스턴스화 해주고, getType()
메서드를 통해 NautralType 객체 인스턴스를 만들어 NautralType의 print()
메서드를 출력하는 형태이다.
class Cat extends Animal {
@Override
NaturalType getType() {
return null;
}
String getName() {
return "이 동물의 종류는 포유류 입니다.";
}
}
그런데 협업하는 다른 개발자가 자기 멋대로 자식 클래스에 부모 메서드인 getType()
의 반환값을 null로 오버라이딩 설정해 메서드를 사용하지 못하게 설정하고, 대신 getName()
이라는 메서드를 만들어 한번에 출력하도록 설정해버린다면.....
Animal cat = new Cat();
String result = cat.getType().print();
System.out.println(result);
기존의 코드는 NullPointerException 예외가 발생하게 된다.
자식 클래스로 부모 클래스의 내용을 상속하는데, 기존 코드에서 보장하던 조건을 수정하거나 적용시키지 않아서, 기존 부모 클래스를 사용하는 코드에서 예상하지 않은 오류가 발생한 것이다.
따라서, 사전에 약속한 기획대로 구현하고 상속 시 부모에서 구현한 원칙을 따라야 한다가 이 원칙의 핵심이다.
abstract class Animal {
void speak() {}
}
class Cat extends Animal {
void speak() {
System.out.println("냐옹");
}
}
class Dog extends Animal {
void speak() {
System.out.println("멍멍");
}
}
Animal이라는 추상 클래스를 정의하고 동물은 대부분 목소리를 낼 수 있기 때문에 추상 메서드 speak()
을 통해 메서드 구현을 강제하도록 규칙을 정한 코드이다.
하지만 물고기 클래스를 추가해야 하는 상황에서, 물고기는 speak()
를 수행할 수 없는데 어떻게 해야할까?
class Fish extends Animal {
void speak() {
try {
throw new Exception("물고기는 말할 수 없음");
} catch (Exception e) {
e.printStackTrace();
}
}
}
이 때 Fish 클래스의 speak()
메서드를 동작 하지 못하게 하고 예외처리를 하면 동작이 되기는 한다.
List<Animal> list = new ArrayList<>();
list.add(new Cat());
list.add(new Dog());
list.add(new Fish());
for(Animal a : list) {
a.speak();
}
하지만, 다른 개발자와 협업하고 있을 경우 다른 개발자가 제대로 된 스펙 문서를 전달받지 못하고 남이 만들어놓은 클래스를 사용하려고 할 때 위와 같이 코드를 작성한다면, 잘 동작하던 코드가 갑자기 예외처리가 될 수도 있다.
따라서 코드를 수정하게 된다면,
abstract class Animal {
// 상속받는 자식 클래스들에게 공통적인 동물 개념만 부여
}
interface Speakable {
// 이 인터페이스를 구현하는 클래스는 speak()메서드를 반드시 구현해야 함
void speak();
}
class Cat extends Animal implements Speakable {
// Animal을 상속받고, Speakable 인터페이스를 구현
public void speak() {
System.out.println("냐옹");
}
}
class dog extends Animal implements Speakable {
// Animal을 상속받고, Speakable 인터페이스를 구현
public void speak() {
System.out.println("멍멍");
}
}
class Fish extends Animal {
// Animal을 상속받지만, Speakable 인터페이스를 구현하지 않기 때문에 speak()메서드를 구현하지 않음
// 물고기는 말을 하지 않는다를 표현
}
인터페이스와 추상 클래스의 차이점
✅ 추상클래스 - 상태(멤버 변수)를 가질 수 있고, 일부 메서드를 구현할 수 있으며, 상속을 통해 자식 클래스에게 기능을 물려줌
✅ 인터페이스 - 오직 메서드 시그니처만 정의하고, 다중 구현을 허용해 여러 클래스에서 특정 행동을 구현하도록 강제함
LSP 원칙은 다형성의 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언해 하위 클래스의 인스턴스를 받으면, 업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 의도대로만 흘러가도록 구성하면 된다.
그리고 LSP 원칙의 핵심은 상속이다.
하지만, 객체 지향 프로그래밍에서 상속은 기반 클래스와 서브 클래스 사이에 IS-A 관계가 있을 경우로만 제한되어야 한다.
그 외의 경우에는 합성을 이용하는게 좋다.
=> 다형성을 이용하고 싶다면 extends
대신 인터페이스로 implements
하여 인터페이스 타입으로 사용하는게 좋고, 상위 클래스의 기능을 이용하거나 재사용하고 싶다면 상속보다는 합성을 권장한다.
업캐스팅이란?
객체 지향 프로그래밍에서 자식 클래스의 인스턴스를 부모 클래스 타입으로 참조하는 것
즉, 자식 객체를 부모 클래스 타입으로 변환하는 것class Animal { void sound() { System.out.println("동물이 소리를 냅니다."); } } class Dog extends Animal { @Override void sound() { System.out.println("멍멍"); } void wagTail() { System.out.println("강아지가 꼬리를 흔듭니다."); } } public class Main { public static void main(String[] args) { Animal animal = new Dog(); // 업캐스팅 animal.sound(); // Dog 클래스의 sound() 메서드가 호출됨 // animal.wagTail(); // 오류: Animal 타입에서는 wagTail() 메서드에 접근할 수 없음 } }
업캐스팅을 사용하는 이유?
1. 다형성
상위 클래스 타입으로 다양한 하위 클래스 객체를 처리할 수 있기 때문이다.
예를 들면, 여러 종류의 동물 객체를 Animal 타입 하나로 처리 가능하다
2. 코드의 유연성
코드 작성 시 특정한 자식 클래스에 종속되지 않고 상위 클래스에 의존하는 코드 구조를 만들 수 있어 유지보수가 용이하다.
인터페이스를 각각 사용에 맞게 끔 잘게 분리해야 한다.
SRP 원칙이 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조한다.
즉, SRP 원칙의 목표는 클래스 분리를 통해 이루어진다면, ISP 원칙은 인터페이스 분리를 통해 설계하는 원칙
인터페이스를 사용하는 클라이언트를 기준으로 분리함으로써, 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공하는 것이 목표이다.
다만, 한번 인터페이스를 분리해 구성해놓고 나중에 무언가 수정사항이 생겨서 또 인터페이스들을 분리하는 행위는 하면 안된다. (인터페이스는 한번 구성했으면 변하면 안되는 정책 개념이기 때문)
interface ISmartPhone {
void call(String number); // 통화 기능
void message(String number, String text); // 문제 메세지 전송 기능
void wirelessCharge(); // 무선 충전 기능
void AR(); // 증강 현실(AR) 기능
void biometrics(); // 생체 인식 기능
}
위 인터페이스는 스마트폰을 추상화 한 것이다.
class S20 implements ISmartPhone {
public void call(String number) {
}
public void message(String number, String text) {
}
public void wirelessCharge() {
}
public void AR() {
}
public void biometrics() {
}
}
class S21 implements ISmartPhone {
public void call(String number) {
}
public void message(String number, String text) {
}
public void wirelessCharge() {
}
public void AR() {
}
public void biometrics() {
}
}
만약 위와 같이 클래스를 구현한다면, 스마트폰 인터페이스의 모든 동작들이 필요하므로 ISP 원칙을 만족하게 된다.
class S3 implements ISmartPhone {
public void call(String number) {
}
public void message(String number, String text) {
}
public void wirelessCharge() {
System.out.println("지원 하지 않는 기능 입니다.");
}
public void AR() {
System.out.println("지원 하지 않는 기능 입니다.");
}
public void biometrics() {
System.out.println("지원 하지 않는 기능 입니다.");
}
}
그러나 위의 클래스처럼 스마트폰 인터페이스에 추상화 해놓은 기능들을 모두 사용하지 못하는 경우가 있다.
이런 경우에는 추상 메서드 구현 규칙 상 오버라이딩은 하되, 메서드 내부는 빈공간으로 두거나 예외를 발생하도록 해야한다.
=> 필요하지 않은 기능을 구현하는 낭비
interface IPhone {
void call(String number); // 통화 기능
void message(String number, String text); // 문제 메세지 전송 기능
}
interface WirelessChargable {
void wirelessCharge(); // 무선 충전 기능
}
interface ARable {
void AR(); // 증강 현실(AR) 기능
}
interface Biometricsable {
void biometrics(); // 생체 인식 기능
}
class S21 implements IPhone, WirelessChargable, ARable, Biometricsable {
public void call(String number) {
}
public void message(String number, String text) {
}
public void wirelessCharge() {
}
public void AR() {
}
public void biometrics() {
}
}
class S3 implements IPhone {
public void call(String number) {
}
public void message(String number, String text) {
}
}
때문에 각각의 기능에 맞게 인터페이스를 잘게 분리해야 한다.
그리고 잘게 분리된 인터페이스를 클래스가 지원되는 기능만을 선별해 implements를 하면 ISP 원칙이 지켜진다.
한번 인터페이스를 분리해 구성해놓고 나중에 수정사항이 생겨 또 인터페이스를 분리하는 행위는 좋지 않다.
이미 구현되어있는 프로젝트에 또 인터페이스를 분리하면 이미 해당 인터페이스를 구현하고 있는 클래스들과 이를 사용하고 있는 클라이언트에서 문제가 일어날 수 있기 때문이다.
따라서 처음 설계부터 기능의 변화를 염두에 두고 인터페이스를 설계하는 것이 좋다.
어떤 Class를 참조해 사용해야 하는 상황이 생긴다면, 그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스 or 인터페이스)로 참조한다.
쉽게 말하면 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻이다.
의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는, 변화하기 어려운 것이나 변화가 거의 없는 것에 의존하는 것이 좋다.
=> 각 클래스 간의 결합도를 낮춰라
class OneHandSword {
final String NAME;
final int DAMAGE;
OneHandSword(String name, int damage) {
NAME = name;
DAMAGE = damage;
}
int attack() {
return DAMAGE;
}
}
class TwoHandSword {
// ...
}
class BatteAxe {
// ...
}
class WarHammer {
// ...
}
어떤 게임을 만든다고 할 때, 캐릭터가 장착할 수 있는 다양한 무기들이 있다.
class Character {
final String NAME; // final 키워드는 변수의 값이 한 번 초기화 된 후에는 변경될 수 없도록 한다.
int health;
OneHandSword weapon; // 의존 저수준 객체
Character(String name, int health, OneHandSword weapon) {
this.NAME = name;
this.health = health;
this.weapon = weapon;
}
int attack() {
return weapon.attack(); // 의존 객체에서 메서드를 실행
}
void chageWeapon(OneHandSword weapon) {
this.weapon = weapon;
}
void getInfo() {
System.out.println("이름: " + NAME);
System.out.println("체력: " + health);
System.out.println("무기: " + weapon);
}
}
그리고 이 무기들을 장착할 Character 클래스가 있다.
이 캐릭터 클래스는 인스턴스화될 때 캐릭터 이름과 체력 그리고 장착하고 있는 무기를 입력값으로 받아 초기화 한다.
검이라는 무기도 굉장히 종류가 다양하기 때문에 캐릭터 클래스 내에 필드 변수로 OneHandSword 클래스 타입의 변수를 놓고, attack()
메서드를 수행하면 OneHandSword 클래스이 메서드가 실행되어 데미지가 가해지는 형태로 구현되어 있다.
즉, Character의 인스턴스 생성 시 OneHandSword에 의존성을 가지게 되어, 공격 동작을 담당하는 attack()
메서드 역시 OneHandSword에 의존성을 가지게 된다.
또 무기에 검만 있는게 아니라 도끼, 망치 등 다양한 무기들이 존재한다.
다양한 타입의 무기들을 장착하게 하려면, 아예 캐릭터 클래스의 클래스 필드 변수 타입을 교체해주어야 한다.
하지만 위 코드는 이미 완전하게 구현된 하위 모듈을 의존하고 있는 큰 문제를 가지고 있다.
즉, 구체 모듈을 의존하는 것이 나닌 추상적인 고수준 모듈을 의존하도록 리팩토링 해야 한다.
// 고수준 모듈
interface Weaponable {
int attack();
}
class OneHandSword implements Weaponable {
final String NAME;
final int DAMAGE;
OneHandSword(String name, int damage) {
NAME = name;
DAMAGE = damage;
}
public int attack() {
return DAMAGE;
}
}
class TwoHandSword implements Weaponable {
// ...
}
class BatteAxe implements Weaponable {
// ...
}
class WarHammer implements Weaponable {
// ...
}
우선 모든 무기들을 포함할 수 있는 고수준 모듈인 Weaponable 인터페이스를 생성한다.
그리고 공격 가능한 모든 무기 객체는 이 인터페이스를 implements
하게 한다.
class Character {
final String NAME;
int health;
Weaponable weapon; // 의존을 고수준의 모듈로
Character(String name, int health, Weaponable weapon) {
this.NAME = name;
this.health = health;
this.weapon = weapon;
}
int attack() {
return weapon.attack();
}
void chageWeapon(Weaponable weapon) {
this.weapon = weapon;
}
void getInfo() {
System.out.println("이름: " + NAME);
System.out.println("체력: " + health);
System.out.println("무기: " + weapon);
}
}
그리고 Character 클래스의 기존 OneHandSword 타입의 필드 변수를 좀 더 고수준 모듈인 Weaponable 인터페이스 타입으로 변경한다.
게임 시스템 내부적으로 모든 공격 가능한 무기는 Weaponable을 구현하기로 가정했기 때문에, 공격 가능한 모든 무기를 할당받을 수 있다.
이렇게 수정하게 되면 DIP 원칙을 따름으로써, 무기의 변경에 따라 Character의 코드를 수정할 필요가 없고 다른 타입의 무기 확장에도 무리가 없게 된다.