객체 지향 프로그래밍(OOP)으로 프로젝트를 개발할 때, 지켜야할 원칙이 있다!
그것이 바로 "SOLID 원칙"
SOLID 원칙은 유지보수성과 확장성이 뛰어난 코드를 설계하기 위한 핵심 철학이기 때문에 이 원칙들을 잘 지켜가며 개발하면 깔끔하고 안정적인 구조의 프로젝트를 만들 수 있을 것이다.
고로 이 포스트에선 SOLID 원칙을 지켜야하는 이유와 SOLID 원칙에 대해서 정리하고자 한다.
SOLID는 객체 지향 설계의 5가지 핵심 원칙을 의미하는 약자다!
S - 단일 책임 원칙 (Single Responsibility Principle)
O - 개방/폐쇄 원칙 (Open-Close Principle)
L - 리스코프 치환 원칙 (Liskov Substitution Principle)
I - 인터페이스 분리 원칙 (Interface Segregation Principle)
D - 의존 역전 원칙 (Dependency Inversion Principle)
클래스는 하나의 책임(기능)만 가져야 한다.
자동차 클래스가 운전도 하고, 정비 일정 관리도 한다면? 너무 많은 일을 하게 되는 것!
운전은 Car, 정비는 MaintenanceManager가 각각 맡아야한다!
class Vehicle {
drive() { /* 주행 */ }
scheduleMaintenance() { /* 정비 일정 관리 */ } // 책임 과다!
}
// 탈 것 클래스
class Vehicle {
drive() { /* 주행 */ }
}
// 유지관리 클래스
class MaintenanceManager {
scheduleMaintenance() { /* 정비 일정 관리 */ }
}
확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다.
새로운 자동차 종류(예: 전기차)를 만들기 위해 기존
Car클래스를 수정하면, 기존 기능이 깨질 위험이 있다.
대신 확장을 통해 새로운 클래스를 만든다.
class Vehicle {
startEngine(type: string) {
if (type === 'electric') console.log("전기차 엔진 ON");
else if (type === 'gas') console.log("가솔린 엔진 ON");
}
}
interface Engine {
start(): void;
}
// 전기차 엔진 클래스 생성
class ElectricEngine implements Engine {
start() { console.log("전기차 엔진 ON"); }
}
// 가솔린 엔진 클래스 생성
class GasEngine implements Engine {
start() { console.log("가솔린 엔진 ON"); }
}
class Vehicle {
// 생성자: 사용하는 엔진 클래스로 객체 생성
constructor(private engine: Engine) {}
startEngine() {
this.engine.start();
}
}
부모 클래스의 객체를 사용하는 곳에 자식 클래스 객체를 넣어도 문제가 없어야 한다.
탈 것(Vehicle)을 사용하는 코드에 Truck이나 ElectricCar를 넣어도 정상 동작 해야한다.
근데 ElectricCar인데 기름 넣는refule()을 필수로 구현하라고 하면 안된다!
// Vehicle을 사용하는 곳에 ElectricCar를 사용할 경우 refuel에서 에러가 발생!
class Vehicle {
refuel() { console.log("기름 충전"); }
}
class ElectricCar extends Vehicle {
refuel() { throw new Error("전기차는 기름을 넣을 수 없습니다."); } // 규약 위반
}
// refuel() 메서드를 자식 클래스에서 정의하여 Vehicle을 사용하는 곳에
// FuelVehicle, ElectricCar을 넣어도 아무런 문제가 발생하지 않음!
class Vehicle {}
class FuelVehicle extends Vehicle {
refuel() { console.log("기름 충전"); }
}
class ElectricCar extends Vehicle {
charge() { console.log("전기 충전"); }
}
클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.
CarService인터페이스에chargeElectric()이 있으면, 휘발유차(Car)도 구현해야한다.
즉, 인터페이스는 각 클래스에 맞춰서 작게 나누는 것이 좋다.
// GasCar는 사용하지 않는 메서드를 구현해야하므로 ISP를 위반한다
interface VehicleService {
refuel(): void;
chargeElectric(): void;
}
class GasCar implements VehicleService {
refuel() {}
chargeElectric() {} // 휘발유차에 필요 없는 기능!
}
// 인터페이스를 기능 단위로 분리하고, 각 클래스는 필요한 기능만 구현한다
interface Refuelable {
refuel(): void;
}
interface Chargeable {
chargeElectric(): void;
}
class GasCar implements Refuelable {
refuel() {}
}
class ElectricCar implements Chargeable {
chargeElectric() {}
}
// 두 가지 모두 가능한 차는 다중 상속을 받으면 된다.
class HybridCar implements Refuelable, Chargeable {
refuel() {}
chargeElectric() {}
}
추상화에 의존해야지, 구현체에 의존하면 안 된다.
운전자가 어떤 엔진을 쓰는지는 모르고, Engine 인터페이스만 필요하다.
구체적인GasEngine,ElectricEngine은 바껴도 문제 없어야 한다.
// GasEngine()을 적접 생성하고 있어 고수준 모듈이 저수준 모듈에 의존해서는 안되는 원칙 위반
class GasEngine {
start() { console.log("가솔린 엔진 ON"); }
}
class Car {
engine = new GasEngine(); // 구체적인 구현에 직접 의존
}
// Car 객체 생성 시 사용하는 엔진 클래스를 주입하여 사용하면 되기 때문에 저수준 모듈에 의존하지 않는다.
interface Engine {
start(): void;
}
class GasEngine implements Engine {
start() { console.log("가솔린 엔진 ON"); }
}
class Car {
constructor(private engine: Engine) {}
startCar() {
this.engine.start();
}
}