단 하나의 책임 원칙(SRP)
어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이여야 한다.
예시로 작성한 Employee 클래스 입니다. 이 클래스는 너무 많은 것을 알고 있습니다. Tax와 Pay를 계산하는 방법도 알고, 데이터베이스에 데이터를 저장하는 방법도 알고, XML로 만드는 방법도 알고 있습니다. 위와 같은 경우에 XML에서 JSON으로 파일 포맷 형식을 변경하게 되면, Employee 클래스가 변경되어야 하고, 데이터베이스를 MySQL 에서 Oracle 로 변경한다 하더라도, Employee 클래스가 변경되어야 합니다. 또한 Tax와 Pay의 계산 방식이 변경되면, Employee 클래스가 변경되어야 합니다.
따라서 각각의 책임을 다른 클래스로 분리하여, 클래스마다 변경해야 할 이유가 한가지만 존재하도록 만드는것이 바람직합니다. Employee 클래스는 세금과 임금만 다루고, XML 관련 클래스는 Employee 인스턴스를 XML로 바꾸는 방식으로 작동하고, EmployeeDatabase 클래스는 EMployee 인스턴스를 데이터베이스에 저장하거나 읽어들이는 역할을 담당하게 될 것 입니다.
SRP를 준수하는 코드 구조를 만들게 되면 위 클래스 다이어 그램과 같이 작성될 것 입니다.
각각의 클래스가 자신이 맡은 한가지 책임에만 변경될 수 있도록 분리했습니다.
개방-폐쇄 원칙(OCP)
소프트웨어 엔티티(클래스, 모듈, 함수)는 확장에 대해서는 개방되어야 하지만, 변경에 대해서는 폐쇄되어야 한다.
이 원칙의 의미는 보기에 어려울 수도 있지만, 간단히 말하자면 모듈 자체를 변경하지 않고도 그 모듈을 둘러싼 환경을 바꿀수 있어야 한다는 것을 의미합니다.
이 그림은, EmployeeDB의 구현이 변경되면 Employee 클래스도 다시 빌드해야하는 상황이 생길수도 있는 것을 보여줍니다.
또한 Employee 객체를 단위 테스트를 진행한다고 생각해봤을 때, Employee 객체는 데이터베이스를 변경할 수있습니다. 하지만 테스트 환경에서 실제 데이터베이스를 변경하고 싶지는 않을 것 입니다.
그렇다고 해서 단위 테스트를 하기 위해 테스트용 Dummy 데이터베이스를 만들고 싶지도 않을 것 입니다. 그렇다면, 테스트 환경으로 환경을 변경해서 테스트할 때 Employee가 데이터베이스에 하는 모든 호출을 가로챈 다음 이 호출들이 올바른지 검증하면 좋을 것 입니다
그림과 같이 Employee가 EmployeeDB 인터페이스에 의존하도록 변경하면, 데이터베이스 호출이 올바른지 검증할 수 있습니다. 이 인터페이스에서 파생한 두 가지 구현을 만들 되, 하나는 진짜 데이터베이스를 호출하도록 하고, 다른 하나는 우리 테스트를 지원하도록 하면 됩니다. 이렇게 인터페이스에 의존을 하게 되면 데이터베이스 API와 Employee를 분리할 수 있고, Employee를 손대지 않고도 Employee를 둘러싼 데이터베이스 환경을 변경할 수 있습니다.
리스코프 교체 원칙(LSP)
서브 타입은 언제나 자신의 기반 타입(base type)으로 교체할 수 있어야 한다.
LSP에 따르면, 기반 클래스(base class)의 사용자는 그 가빈 클래스에서 유도된 클래스를 기반 클래스로써 사용할 때, 특별한 것을 할 필요 없이(가령 instance of로 비교를 한다던지, 다운캐스트를 한다던지) 마치 원래 기반 클래스를 사용하는 것 처럼 그대로 사용할 수 있어야 한다. 사용자는 파생 클래스에 대해서 아무것도 알 필요가 없어야 합니다.
위 그림에서 Employee 클래스는 추상클래스 이며, calcPay라는 추상 메서드를 가집니다. Employee 클래스를 구현한 SalariedEmployee 클래스와 HourlyEmployee 클래스는 각각 월급을 받는 직원과, 시급을 받는 직원에 대해 임금을 지급하는 방식을 구현하게 될 것 입니다.
그럼 VolunteerEmployee(자원 봉사 직원)을 추가하기로 하면 어떤일이 생기게 될까요? 어떻게 calcPay를 구현해야 할까요?
public class VolunteerEmployee extends Employee {
@Override
public double calcPay(){
return 0;
}
}
위와 같이 구현을 하면 될까요? 이렇게 구현을 하게 되면 만약 각각의 직원별로 월급 총계를 메일로 발송하게 된다고 하면 월급 총계에 0원이 찍힌 임금 명세서가 발송되는 등의 당황스러운 상황에 처할 수도 있습니다.
return 0 을 하는 것 처럼, 상위 클래스에서 유도된 메서드를 아무것도 안하는 메서드로 구현하는 것도 LSP를 어기는 것 입니다. 이로 인해 예외를 사용하게 되거나 instanceof 검사를 할 수 밖에 없도록 만들지도 모르기 때문 입니다
따라서 위와 같이 VolunteerEmployee 클래스를 만들어야 한다면 자원 봉사자는 직원이 아니기에, 애초에 Employee 클래스에서 파생될 필요가 없습니다.
의존 관계 역전 원칙(DIP)
A. 고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화 된 것에 의존해야 한다.
B. 추상화된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다.
위의 말을 더 쉽게 말하자면 ‘자주 변경되는 콘크리트 클래스(concrete class)에 의존하지 마라’
라고 할 수 있습니다. 만약 어떤 클래스에서 상속을 받아야 한다면, 기반 클래스를 추상 클래스로 만들고, 어떤 클래스의 참조(reference)를 가져야 한다면, 참조 대상이 되는 클래스를 추상 클래스로 만드는게 좋습니다. 또한 어떤 함수를 호출해야 한다면 호출되는 함수를 추상 함수(abstract)로 만드는게 좋습니다.
추상 클래스와 인터페이스는 구체화된 클래스보다 훨씬 덜 변하기 때문에, 추상 클래스와 인터페이스에 의존하게 되면 시스템에 변화가 일어났을 때, 시스템에 미치는 영향을 줄일 수 있기 때문에 그렇습니다.
그예시로 현재 활발하게 개발 중인 컨크리트 클래스나, 변할 가능성이 높은 비즈니스 규칙(정책)을 담은 클래스가 여기에 속합니다. 이런 클래스의 인터페이스를 만든 다음, 이 인터페이스에 의존하는 것이 바람직합니다.
(위와 관련된 내용이 제가 진행한 프로젝트 Goods-For-You 에 담겨있습니다. 관련한 내용은 굿즈포유 프로젝트의 xxxValidator 라는 이름을 가진 객체를 참고해주세요)
인터페이스 격리 원칙(ISP)
클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안된다.
앞서 SRP때 설명했던 Employee 클래스 처럼 여러 메서드를 몇십 몇백개를 가진 클래스를 비대한 클래스(fat class)라고 합니다.
이렇게 메서드를 몇십개 선언한 클래스에서 해당 클래스를 사용하는 사용자(client)는 단지 두 세개의 메서드만 호출할 수도 있습니다. 하지만 해당 클래스를 사용하는 사용자는 ‘호출하지도 않는’ 메서드에 생긴 변화에서 영향을 받게 됩니다.
public interface Vehicle {
void drive();
void stop();
void refuel();
void changeGear();
void turn();
}
public class Car implements Vehicle {
@Override
public void drive() {
System.out.println("Car is being driven");
}
@Override
public void stop() {
System.out.println("Car has stopped");
}
@Override
public void refuel() {
System.out.println("Car is being refueled");
}
@Override
public void changeGear() {
System.out.println("Car gear is changed");
}
@Override
public void turn() {
System.out.println("Car is turning");
}
}
public class Driver {
private Vehicle vehicle;
public Driver(Vehicle vehicle) {
this.vehicle = vehicle;
}
public void operateVehicle() {
vehicle.drive();
vehicle.stop();
vehicle.refuel();
vehicle.changeGear();
vehicle.turn();
}
}
위 예시코드에서, Drive 클래스는 드라이버의 기능에 필요하지 않은 refuel() 메서드에 의존하고 있습니다. 또한 새 메서드가 Vehicle 인터페이스에 추가되면 새 메서드가 해당 기능과 관련이 없더라도 Driver 클래스를 업데이트해야 할 수도 있습니다.
public interface Vehicle {
void drive();
void stop();
void changeGear();
void turn();
}
public interface Refuelable {
void refuel();
}
public class Car implements Vehicle, Refuelable {
@Override
public void drive() {
System.out.println("Car is being driven");
}
@Override
public void stop() {
System.out.println("Car has stopped");
}
@Override
public void changeGear() {
System.out.println("Car gear is changed");
}
@Override
public void turn() {
System.out.println("Car is turning");
}
@Override
public void refuel() {
System.out.println("Car is being refueled");
}
}
public class Driver {
private Vehicle vehicle;
public Driver(Vehicle vehicle) {
this.vehicle = vehicle;
}
public void operateVehicle() {
vehicle.drive();
vehicle.stop();
vehicle.changeGear();
vehicle.turn();
}
public void refuelVehicle(Refuelable vehicle) {
vehicle.refuel();
}
}
위와 같이 Refuelable 로 refuel() 메서드를 분리함으로써, Driver 클래스는 더이상 refuel()메서드에 대해 알 필요가 없습니다. 이렇게 코드를 작성하면 Driver 클래스는 해당 기능과 관련된 메서드만 사용하고 필요하지 않은 메서드에 의존하지 않게 될 수 있습니다.
그렇다면 위와 같은 SOLID 원칙들을 언제 적용하는게 좋을까요? 개발을 진행하면서 문제가 생겼을 때 그에 대한 ‘반응으로써 적용’하는 것이 가장 좋다고 생각합니다. 코드의 구조적인 문제를 처음 발견했거나, 어떤 모듈이 다른 모듈에서 생긴 변화에 영향을 받음을 처음 깨달았을 때 그때 SOLID 원칙 가운데 하나 또는 여러 개를 써서 이 문제를 해결할 수 있는지 알아보는 방식으로 진행하는 것이 좋다고 생각합니다.
참고 자료
UML 실전에서는 이것만 쓴다(6장)
'프로그래밍' 카테고리의 다른 글
대칭키 비대칭키 암호화 (4) | 2023.06.17 |
---|---|
테스트 커버리지에 대한 생각(SLASH 21 영상 참고) (2) | 2023.06.17 |
프로세스와 쓰레드의 차이? (0) | 2022.11.05 |
6장 연습문제 풀이 (0) | 2021.07.07 |
FireBase 프로젝트를 다수의 PC에서 사용시 참고 (0) | 2021.07.06 |