SOLID는 <클린코드> <클린 아키텍처>의 저자인 로버트 C. 마틴이 인터넷 게시판에서 오랜기간 토론을 통해 좋은 설계원칙을 모아 2000년대 초반에 발표한 원칙을 최초로 탄생하게 되었다.
SOLID 원칙이란 객체지향 설계에서 지켜줘야 할 5개의 소프트웨어 개발 원칙( SRP, OCP, LSP, ISP, DIP )을 말한다.
SOLID 객체 지향 원칙을 적용하면 코드를 확장하고 유지 보수 관리하기가 더 쉬워지며, 불필요한 복잡성을 제거해 리팩토링에 소요되는 시간을 줄임으로써 프로젝트 개발의 생산성을 높일 수 있다.
단일 모듈(module)은 변경의 이유가 하나, 오직 하나뿐 이어야 한다.
이말은 하나의 모듈은 하나의 사용자또는 하나의 이해관계자에 대해서만 책임을 진다는 의미이다.
class UserService {
createUser(user) {
console.log("User created:", user);
}
sendWelcomeEmail(user) {
console.log("Welcome email sent to:", user.email);
}
}
위에서 UserService에서는 user에 관해서만 책임을 져야한다.
1) 이메일 서비스가 변경시
2) 유저구조 변경시
위의 예제에서는 이렇게 2가지 경우일때 변경이 일어나게 되기에 SRP원칙에 위배된다.
class UserService {
createUser(user) {
console.log("User created:", user);
}
}
class EmailService {
sendWelcomeEmail(user) {
console.log("Welcome email sent to:", user.email);
}
}
확장에는 열려있고, 수정에는 닫혀 있어야 한다.
class PaymentProcessor {
pay(type: string) {
if (type === "card") console.log("Pay with card");
else if (type === "paypal") console.log("Pay with PayPal");
}
}
위의 클래스에서 새로운 결제 방식을 추가할경우 pay메서드를 수정해야한다.
interface Payment {
pay(): void;
}
class CardPayment implements Payment {
pay() { console.log("Pay with card"); }
}
class PayPalPayment implements Payment {
pay() { console.log("Pay with PayPal"); }
}
class PaymentProcessor {
process(payment: Payment) {
payment.pay();
}
}
위와 같이 변경시엔 의존성을 주입하는 방식으로, PaymentProcessor는 수정없이 새로운 결제 클래스만 추가하면 된다.
부모 클래스 객체는 자식 클래스로 대체 가능해야 한다.
즉, 자식 클래스가 부모 클래스의 행위를 깨뜨리면 안 된다.
class Rectangle {
constructor(width, height) {
this._width = width;
this._height = height;
}
set width(value) {
this._width = value;
}
set height(value) {
this._height = value;
}
get width() {
return this._width;
}
get height() {
return this._height;
}
area() {
return this.width * this.height;
}
}
class Square extends Rectangle {
constructor(width) {
super(width, width);
}
// width와 height를 바꾸면 항상 정사각형 유지하도록 오버라이드
set width(value) {
this._width = this._height = value;
}
set height(value) {
this._width = this._height = value;
}
}
function printArea(rect) {
rect.width = 5;
rect.height = 10;
console.log(rect.area());
}
const r = new Rectangle(2, 3);
const s = new Square(5);
printArea(r); // 50
printArea(s); // 100
위의 예제에서 Rectangle과 Square에 동일한 width, height 값을 넣었을때 예상과는 다른 동작이 다르게 나오는것을 알 수 있다. 즉, 정사각형과 직사각형이 올바른 상속관계가 아니며, 자식객체가 부모 객체의 역할을 대체하기 못한다는 의미이다.
class Shape {
area() { throw new Error('must implement'); }
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
class Square extends Shape {
constructor(size) {
super();
this.size = size;
}
area() {
return this.size * this.size;
}
}
const r2 = new Rectangle(5, 10);
const s2 = new Square(7);
r2.area() // 50
s2.area(); // 49
위의 예제에서는 더이상 Rectangle2과 Square2는 상속관계가 아니게 되며, 공통규약(area를 계산할 수 있다)만 공유하고 서로의 동작 계약을 깨지 않으므로 LSP를 만족하게 된다.
클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.
기능별로 인터페이스를 나눈다고 하더라도 만약 사용하지 않는 함수가 존재한다면, 인터페이스를 분리해야 한다는 뜻
interface Worker {
work(): void;
eat(): void;
}
class Robot implements Worker {
work() { console.log("Working"); }
}
class Human implements Workable, Eatable {
work() { console.log("Working"); }
eat() { console.log("Eating"); }
}
위의 코드에서 Robot은 eat 메서드를 사용하지 않는다. 모든 Worker가 wrok와 eat을 하지 않는다면 아래의 코드처럼 work, eat 메서드를 분리해서 인터페이스를 만들어야 한다.
interface Workable { work(): void; }
interface Eatable { eat(): void; }
class Human implements Workable, Eatable {
work() { console.log("Working"); }
eat() { console.log("Eating"); }
}
class Robot implements Workable {
work() { console.log("Working"); }
}
고수준 모듈(비지니스 로직을 담은 상위 개념)은 저수준 모듈(실제 구현 세부사항)에 의존하지 말고, 추상화에 의존해야 한다.
구현체는 변동성이 크기 때문에, 안정된 인터페이스를 참조해야한다.
class MySQLDatabase {
save(data: string) {
console.log("Saving to MySQL:", data);
}
}
class UserRepository {
private db = new MySQLDatabase(); // ❌ 직접 의존
saveUser(user: string) {
this.db.save(user);
}
}
위의 예제에서 db를 MySQLDatabase에서 가져온다. 하지만 db는 localStorage에서도 가져올수있고,MongoDB에서도 가져와질수 있다. 이를 DIP 원칙을 적용해 수정하면 아래와 같다.
interface Database {
save(data: string): void;
}
class MySQLDatabase implements Database {
save(data: string) { console.log("Saving to MySQL:", data); }
}
class MongoDatabase implements Database {
save(data: string) { console.log("Saving to MongoDB:", data); }
}
class UserRepository {
constructor(private db: Database) {}
saveUser(user: string) {
this.db.save(user);
}
}
Database라는 추상체에서 db를 가져오며 어떤 database에서 가져올지는 외부에서 의존성을 주입해주는 방식으로 수정한다.