Dart로 알아보는 SOLID 원칙
이전에 면접 준비를 하면서 CS 공부를 할 때 SOLID에 대해서 작성했던 적이 있다.
그때 당시에는 SOLID를 공부하면서도 Swift만 적용하고 현재는 내용을 많이 잃고 지내면서 SOLID원칙을 크게 생각하지 않는 것 같다.
근데 Dart도 객체지향 언어인데 이걸 모르면 안되겠다 라는 생각이 들어 다시 공부하면서 알아보려고 한다.
간단하게 요약하면
SOLID는 객체 지향 프로그래밍을 하면서 지켜야 하는 5대 원칙을 의미한다.
- SRP(단일 책임 원칙)
- OCP(개방 - 폐쇄 원칙)
- LSP(리스코프 치환 원칙)
- ISP(인터페이스 분리 원칙)
- DIP(의존관계 역전 원칙)
그래서 SOLID가 왜 필요한거야? 그냥 동작만 하면 되지
놀랍게도 한때 나의 생각이다.
그냥 기능을 잘 만들고 잘 돌아가기만 하면 장땡 아닌가?라고 생각 했던 꼬꼬마 대학생 시절도 있었다.
SOLID의 기반인 객체지향 프로그래밍은 먼저 작은 문제들을 해결할 수 있도록 객체를 만든 뒤에 이 객체들을 조합해서 큰 문제를 해결하는 방식이다.
좋은 객체지향 설계를 하면 개발 기간을 단축하고 비용도 줄일 수 있다.
이게 객체지향 설계를 하면 얻게 되는 큰 이점이다.
항상 코드는 유연하고, 확장할 수 있고, 유지보수가 용이하고, 재사용할 수 있어야한다.
안 그러면 조금만 달라져도 만들었던 내용을 또 만들어서 호출하고 또 호출하고 또 호출하고 엄청난 코드 낭비 시간 낭비 노력 낭비다.
이렇게 계속 만들어서 호출하면서 점점 스파게티 코드가 될 거라고 생각된다.
이러한 문제를 해결하기 위해서 OOP(객체지향프로그래밍)이라는 방법론이 제안되었고 OOP 방식을 잘 준수하기 위한 원칙으로 Robert C Martin이 고안한 SOLID 원칙이 제안되었다.
SRP(단일 책임 원칙)
- 클래스는 단 한개의 책임만 가져야 한다.
- 클래스를 변경하는 이유는 당 하나여야 한다.
- 이를 지키지 않으면 한 책임의 변경에 의해 다른 책임과 관련된 코드에 영향을 미칠 수 있다.
- 이렇게 되면 유지보수가 매우 비효율적으로 변한다.
책임? 무슨 책임일까?
그림이 되게 잘 설명해 준다.
왼쪽의 로봇의 경우
요리사 이면서, 정원사 이면서, 화가 이면서, 운전사 이다.
오른쪽 로봇의 경우
요리사, 정원사, 화가, 운전사 각각 존재한다.
만약 왼쪽의 로봇 고장나서 수리를 보내야 한다.
그러면 요리도 멈추고, 정원관리도 멈추고, 그림 그리기도 멈추고, 운전도 멈춰진다.
하지만 오른쪽과 같이 각각 존재하게 된다면
요리사 로봇이 고장나면 요리사 로봇만 수리를 보내면 된다.
간단하게 작성된 코드를 보자
robots라는 클래스는 요리도하고 정원관리도하고 그림도그리고 운전까지한다.
이렇게 되면 단일 책임 원칙에 위배되는 행위를 하고 있다고 생각하면 된다.
단일 책임 원칙에 위배
class robots {
void cook() {
// 요리하기
}
void manageGarden() {
// 정원관리하기
}
void drawing() {
// 그림그리기
}
void driving() {
// 운전하기
}
}
각각의 역할별로 구문하게 되면 다음과 같다.
만약 요리하는 부분에서 수정해야 될 부분이 있으면 해당 부분만 수정하게되면 된다.
단일 책임 원칙 적용
class cookRobot {
void cook() {
// 요리하기
}
}
class gardenRobot {
void manageGarden() {
// 정원관리하기
}
}
class paintRobot {
void drawing() {
// 그림그리기
}
}
class driveRobot {
void driving() {
// 운전하기
}
}
OCP(개방 - 폐쇄 원칙)
- 확장에 대해서는 열려있고 (확장은 자유롭고), 변경에 대해서는 닫혀있다.
- 수정 없이 확장 가능하도록 하자.
- 즉 기존의 코드를 변경하지 않고 기능을 수정하거나 추가할 수 있도록 설계해야 한다.
어떤 모듈의 기능을 하나 수정할 때, 그 모듈을 이용하는 다른 모듈들 역시 줄줄이 고쳐야 한다면 유지보수가 복잡할 것이다.
따라서 개방 폐쇄 원칙을 잘 적용하여 기존 코드를 변경하지 않아도 기능을 새롭게 만들거나 변경할 수 있도록 해야 한다.
그렇지 않으면 객체지향 프로그래밍의 가장 큰 장점인 유연성, 재사용성, 유지보수성 등을 모두 잃어버리고 OOP를 사용하는 의미가 없어진다.
왼쪽의 로봇의 경우
기존에 자르는게 가능했다.
그림그리기 라는 기능을 추가하기 위해 로봇을 수정한다.
그럼 원래 자르기 기능을 호출하고 사용하던 부분 어디에서 오류가 발생할 지 모른다.
즉 기존에 자르기 코드를 수정해서 새로운 기능을 추가하는 것은 개방 - 폐쇄 원칙의 위반이다.
오른쪽의 로봇 경우
자르기, 그림그리기 둘다 모듈화 되어있고, 확장 가능한 접근 방식으로 설계 되었다고 하자.
처음에 자르는 기능을 할 수 있고, 그림 그리는 기능을 추가해야 할 경우 기존의 자르는 코드를 수정하지 않고 그림그리는 기능을 추가할 수 있다. 이렇게 하게 되면 변경에서는 닫혀있지만, 확장에대해서는 열려있게 된다.
코드를 살펴보자
개방 - 폐쇄 원칙의 위배
만약 처음에 work 라는 함수에
cut만 존재했다면 paint를 위해서 기존의 work까지 수정하게 된 경우이다.
class Robot {
void work(String task) {
if (task == 'cut') {
print('I can cut');
} else if (task == 'paint') {
print('I can paint');
} else {
throw Exception('Unknown task');
}
}
}
void main() {
Robot robot = Robot();
robot.work('cut');
robot.work('paint');
}
개방 - 폐쇄 원칙의 적용
맨 처음에 cut 을 사용하다가 paint를 추가하려고해도
기존의 Robot 이라는 class에서는 전혀 수정이 이루어지지 않고 기능의 확장이 가능하다.
// 작업에 대한 인터페이스 정의
abstract class Task {
void execute();
}
// 작업별 클래스
class CutTask implements Task {
@override
void execute() {
print('I can cut');
}
}
class PaintTask implements Task {
@override
void execute() {
print('I can paint');
}
}
// Robot 클래스는 Task를 의존함
class Robot {
void performTask(Task task) {
task.execute();
}
}
void main() {
Robot robot = Robot();
Task cutTask = CutTask();
Task paintTask = PaintTask();
robot.performTask(cutTask);
robot.performTask(paintTask);
}
LSP(리스코프 치환 원칙)
- 자식 class는 언제나 자신의 부모 class를 교체할 수 있다는 원칙이다.
- 하위 타입 객체는 상위 타입 객체에서 가능한 행위를 수행할 수 있어야 함
→ 즉, 상위 타입 객체를 하위 타입 객체로 치환해도 정상적으로 동작해야 함 - 상속관계에서는 꼭 일반화 관계 (IS-A) 가 성립해야 한다는 의미 (일관성 있는 관계인지)
- 상속관계가 아닌 클래스들을 상속관계로 설정하면, 이 원칙이 위배됨 (재사용 목적으로 사용하는 경우)
즉 리스코프 치환 원칙을 지키지 않으면 개방 - 폐쇄 원칙을 위반하게 되는 것이다.
그림상에 샘과 에덴 이라는 두 로봇이 있다고 가정해보자.
샘은 커피를 만들수 있는 로봇이다.
에덴은 샘의 자식(하위 클래스)라고 말한다.
왼쪽의 경우를 보자
샘이 없고 누군가 에덴에게 커피를 만들어 달라고 한다.
하지만 에덴은 커피를 만들어 줄수 없고 물을 준다
오른쪽의 경우를 보자
샘이 역시 없고 누군가 에덴에게 커피를 만들어 달라고 한다.
샘의 자식(하위 클래스)인 에덴은 커피를 만들어 준다.
왼쪽의 경우에는 리스코프치환 원칙을 위반 하는 경우라고 할 수 있다.
자식 클래스는 항상 부모클래스를 대체할 수 있어야 하는데
샘이 없어서 에덴이 커피를 주지 못한다.
리스코프 치환원칙에 따르는 경우는 오른쪽의 경우라고 볼 수 있다.
그럼 이제 코드를 보자
리스코프 치환원칙 위배
CoffeRobot 클래스에서 makeCoffe 만 정의 했기 때문에 Eden이 커피를 만들 수 없는 상황이다.
abstract class CoffeeRobot {
void makeCoffee();
}
class Sam extends CoffeeRobot {
@override
void makeCoffee() {
print("Here's your coffee");
}
}
// LSP 위반: Eden은 부모의 기능을 제대로 수행하지 못함
class Eden extends CoffeeRobot {
@override
void makeCoffee() {
throw Exception("I can't make coffee, only water!");
}
void serveWater() {
print("Here's water");
}
}
void main() {
CoffeeRobot sam = Sam();
CoffeeRobot eden = Eden();
sam.makeCoffee();
eden.makeCoffee();
}
리스코프 치환원칙 적용
BeverageRobot 이라는 추상화를 사용해서 serveBeverage 라는 메서드를 정의해서 모든 자식 클래스가 이를 사용할 수 있게 해준다.
abstract class BeverageRobot {
void serveBeverage();
}
class CoffeeRobot extends BeverageRobot {
@override
void serveBeverage() {
print("Here's your coffee");
}
void makeSpecificCoffee(String type) {
print("Here's your $type");
}
}
class MultiBeverageRobot extends BeverageRobot {
@override
void serveBeverage() {
print("Here's your beverage");
}
void makeCappuccino() {
print("Here's your cappuccino");
}
void makeEspresso() {
print("Here's your espresso");
}
}
void main() {
BeverageRobot sam = CoffeeRobot();
BeverageRobot eden = MultiBeverageRobot();
sam.serveBeverage();
eden.serveBeverage();
}
ISP(인터페이스 분리 원칙)
- 객체는 자신이 사용하는 메서드에만 의존해야 한다
- 객체가 사용하지 않는 메서드를 의존해서는 안된다
왼쪽 로봇의 경우
모든 로봇이 해야 할 동작 목록을 보고 있는 두 로봇이 있다.
목록에는 회전, 팔회전, 안테나 회전 같은 작업이 있다고한다.
근데 로봇 하나가 안테나가 없다고 한다면
로봇이 수행할 수 없는 동작을 구현해야 하기 때문에 인터페이스 분리 원칙을 위반한 것이다.
모든 로봇이 목록에 잇는 모든 동작에 수행할 필요가 없기 때문에 불필요한 복잡성으로 이어질 수 있다.
오른쪽 로봇의 경우
각각의 로봇에 맞는 동작을 보여준다.
하나는 회전 할 수 있는 로봇
하나는 팔을 회전 할 수 있는 로봇
나머지는 안테나를 회전할 수 있는 로봇
이렇게 각 로봇 유형에 맞게 적용되어있다.
이는 각 로봇이 해당 기능과 관련된 지침만 수신하면 되기 때문에 인터페이스 분리 원칙을 준수하는 것이다.
즉 인터페이스 분리 원칙은 로봇에게 관련 없는 작업을 포함하는 목록을 따르도록 강요하는 대신, 로봇의 능력에 맞는 특정 동작 루틴을 제공하는 상황이라고 이해할 수 있다.
인터페이스 분리 원칙 위배
모든 로봇이 ExerciseRobot의 인터페이스의 모든 메서드를 구현해야 한다.
안테나가 없는 로봇도 waveAntenna 메서드를 구현해야하는 불필요성이 생기게된다.
abstract class ExerciseRobot {
void spinAround();
void raiseArms();
void waveAntenna();
}
// 안테나가 없는 로봇은 waveAntenna를 구현할 수 없지만 강제로 구현해야 함
class BasicRobot implements ExerciseRobot {
@override
void spinAround() {
print("Spinning around!");
}
@override
void raiseArms() {
print("Raising arms!");
}
@override
void waveAntenna() {
throw Exception("Can't wave antenna - I don't have one!"); // 문제 발생
}
}
void main() {
ExerciseRobot basicRobot = BasicRobot();
basicRobot.spinAround();
try {
basicRobot.waveAntenna();
} catch (e) {
print("Error: $e");
}
}
인터페이스 분리 원칙 적용
BaseExercise와 안테너 관련 기능을 분리 하면서 각 로봇이 자신의 능력에 맞는 인터페이스만 구현하면 된다.
abstract class BaseExercise {
void spinAround();
void raiseArms();
}
abstract class AntennaExercise {
void waveAntenna();
}
// 기본 로봇은 필요한 인터페이스만 구현
class SimpleRobot implements BaseExercise {
@override
void spinAround() {
print("Spinning around!");
}
@override
void raiseArms() {
print("Raising arms!");
}
}
// 안테나가 있는 고급 로봇은 추가 기능을 구현
class AdvancedRobot implements BaseExercise, AntennaExercise {
@override
void spinAround() {
print("Spinning around!");
}
@override
void raiseArms() {
print("Raising arms!");
}
@override
void waveAntenna() {
print("Waving antenna!");
}
}
void main() {
SimpleRobot simpleRobot = SimpleRobot();
simpleRobot.spinAround();
AdvancedRobot advancedRobot = AdvancedRobot();
advancedRobot.spinAround();
advancedRobot.waveAntenna();
}
DIP(의존 관계 역전 원칙)
- 추상화에 의존해야지 구체화에 의존해서는 안된다.
- 변화하기 쉬운 것 또는 자주 변하는 것에 의존하기 보다 변화하기 어려운 것, 거의 변화가 없는 것에 의존해라.
- 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안된다.
왼쪽의 로봇의 경우
피자 커터 암과 같은 특정 도구만 사용할 수 있는 로봇이다.
도구 하나로만 피자를 자를 수 있기 때문에 피자 커터가 부러지거나 다른 작업을 하려고 해도 쉽지않다.
저수준 도구(피자 커터 암)에 너무 많이 의존하기 때문에 설계가 좋지 않은 것을 나타낸다.
오른쪽의 로봇의 경우
피자 커터뿐만 아니라 여러 도구를 교체해서 사용 할 수 있는 로봇을 보여주고 있다.
하나의 도구에 국한되지 않았기 때문에 가능하다.
즉 의존 관계 역전 원칙은 요리사가 하나의 주방 도구만 사용하지않고 모든 주방 도구를 사용할 수 있는 것과 같다.
의존 관계 역전 원칙 위배
class PizzaCutterArm {
void cut() {
print('Cutting pizza with the built-in pizza cutter arm');
}
}
class BadPizzaRobot {
final PizzaCutterArm cutter = PizzaCutterArm(); // 직접적인 의존성
void cutPizza() {
cutter.cut(); // 특정 도구에 강하게 결합됨
}
}
void main() {
var badRobot = BadPizzaRobot();
badRobot.cutPizza(); // 항상 PizzaCutterArm만 사용 가능
}
의존 관계 역전 원칙 적용
abstract class CuttingTool {
void cut();
}
class PizzaCutter implements CuttingTool {
@override
void cut() {
print('Cutting pizza with pizza cutter');
}
}
class Knife implements CuttingTool {
@override
void cut() {
print('Cutting pizza with knife');
}
}
class CircularBlade implements CuttingTool {
@override
void cut() {
print('Cutting pizza with circular blade');
}
}
class GoodPizzaRobot {
final CuttingTool cutter; // 추상화에 의존
// 의존성 주입을 통해 어떤 도구든 사용 가능
GoodPizzaRobot(this.cutter);
void cutPizza() {
cutter.cut();
}
}
void main() {
var goodRobot1 = GoodPizzaRobot(PizzaCutter());
goodRobot1.cutPizza();
var goodRobot2 = GoodPizzaRobot(Knife());
goodRobot2.cutPizza();
var goodRobot3 = GoodPizzaRobot(CircularBlade());
goodRobot3.cutPizza();
}
결론
이렇게 SOLID 원칙에 대해서 다시 공부해봤는데
은연중에 사용하는 원칙들도 있고 고심해야 적용할 수 있는 방법도 있는것 같다.
특히 추상화..! 이거는 iOS 시절부터 너무나도 어렵다.
하지만 계속 시도해보면서 도전해 봐야겠다.
참고
https://medium.com/nerd-for-tech/solid-principles-in-a-flutter-32eaf7218476
https://velog.io/@jw221356/Flutter-Study-Day-15-Dart-SOLID-principles