SOLID 원칙이란
SOLID 원칙은 객체 지향 프로그래밍에서 지켜야 할 5가지 핵심 원칙을 의미합니다. 이 원칙들을 따르면 유지보수가 쉽고, 유연하며, 확장이 용이한 소프트웨어를 만들 수 있습니다.
1) 단일 책임 원칙 (Single Responsibility Principle, SRP)
단일 책임 원칙의 핵심은 "한 객체는 하나의 책임만 가져야 한다" 여기서 말하는 책임은 곧 변경의 이유를 의미 합니다.
// 나쁜 예: 여러 책임이 혼재
class Order {
calculateTotalPrice() { /* 가격 계산 */ }
saveToDatabase() { /* DB 저장 */ }
sendConfirmationEmail() { /* 이메일 발송 */ }
}
// 좋은 예: 책임 분리
class Order {
calculateTotalPrice() { /* 가격 계산만 담당 */ }
}
class OrderRepository {
save(order: Order) { /* DB 저장만 담당 */ }
}
class OrderNotification {
sendConfirmation(order: Order) { /* 알림 발송만 담당 */ }
}
현실적 고려해야 할 사항은 객체가 너무 많아질 수 있어 상황에 따라 유연하게 적용해야 합니다.
객체가 너무 많아지므로 지키지 않는 경우도 많기 때문에 단일 책임 원칙을 지키는 것에 너무 신경을 쓰면 디자인패턴를 설계하는 것에 있어 어려움이 있을 수 있으니 이를 주의해야합니다.
단일 책임 원칙을 통해 각 클래스가 하나의 책임만 가지므로 코드 변경이 필요할 때 해당 책임을 가진 클래스만 수정하면 됩니다.
2) 개방-폐쇄 원칙 (Open-Closed Principle, OCP)
개방-폐쇄 원칙은 "확장에 대해서는 열려 있고, 변경에 대해서는 닫혀 있어야 한다" 이는 새로운 기능을 추가할 때 기존 코드가 수정되면 안 된다 라는 것을 의미합니다.
// 나쁜 예: 새로운 결제 수단 추가시 기존 코드 수정 필요
class PaymentProcessor {
process(type: string) {
if (type === 'credit') {/* 신용카드 결제 */}
else if (type === 'debit') {/* 직불카드 결제 */}
}
}
// 좋은 예: 새로운 결제 수단은 인터페이스 구현만 하면 됨
interface PaymentMethod {
process(): void;
}
class CreditCardPayment implements PaymentMethod {
process() {/* 신용카드 결제 */}
}
class DebitCardPayment implements PaymentMethod {
process() {/* 직불카드 결제 */}
}
개방-폐쇄 원칙(OCP)을 따르면 새로운 기능 추가가 기존 코드 수정 없이 가능하며 기존 코드의 안정성을 해치지 않으면서 새로운 요구사항 반영 가능합니다. 또한, 기존 코드가 변경되지 않으므로 기존 테스트 케이스 유지 가능하며 새로운 기능에 대한 테스트만 추가하면 됩니다.
3) 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
리스코프 치환 원칙은 자식 클래스는 부모 클래스의 역할을 완벽히 대체 가능해야 함을 의미합니다.
그렇기 때문에 부모 클래스 자리에 자식 클래스를 넣었을 때 타입 에러가 없어야 합니다.
// 나쁜 예: Rectangle의 역할을 Square가 완벽히 대체하지 못함
class Rectangle {
protected width: number;
protected height: number;
setWidth(width: number) {
this.width = width;
}
setHeight(height: number) {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width: number) {
// 정사각형은 가로=세로가 항상 같아야 하므로
this.width = width;
this.height = width; // 여기서 문제 발생!
}
setHeight(height: number) {
this.height = height;
this.width = height; // 여기서도 문제 발생!
}
}
// 이제 이 코드를 사용하는 클라이언트 코드를 봅시다
function increaseRectangleWidth(rectangle: Rectangle) {
rectangle.setWidth(5);
rectangle.setHeight(4);
// 직사각형이라면 면적이 20이어야 합니다
console.log(rectangle.getArea()); // Rectangle: 20, Square: 16 (!)
}
// Rectangle을 사용할 때
const rectangle = new Rectangle();
increaseRectangleWidth(rectangle); // 예상대로 면적 20
// Square를 Rectangle 대신 사용할 때
const square = new Square();
increaseRectangleWidth(square); // 예상과 다르게 면적 16
LSP 위반이 발생하는 이유는 다음과 같습니다.
- Rectangle을 사용하는 코드는 width와 height가 독립적으로 변경될 수 있다고 가정합니다.
- 하지만 Square는 이 가정을 깨뜨립니다. width를 변경하면 height도 따라 변경됩니다.
- 이로 인해 Rectangle을 사용하는 클라이언트 코드에 Square를 대체 투입하면 예상치 못한 결과가 발생합니다.
이처럼 Square를 Rectangle 대신 사용했을 때 예상과 다른 결과가 나오므로 LSP 위반이 발생하는 것입니다. 수학적으로는 "정사각형은 직사각형의 특수한 형태"가 맞지만, 객체지향 설계에서는 이런 행위(behavior)의 차이 때문에 상속 관계가 적절하지 않습니다.
이를 해결하기 위한 좋은 설계는
interface Shape {
calculateArea(): number;
}
class Rectangle implements Shape {
constructor(
private width: number,
private height: number
) {}
calculateArea(): number {
return this.width * this.height;
}
}
class Square implements Shape {
constructor(
private sideLength: number
) {}
calculateArea(): number {
return this.sideLength * this.sideLength;
}
}
이렇게 설계하게 되면
- Rectangle과 Square는 독립적인 클래스가 됩니다.
- 둘 다 Shape라는 공통 인터페이스를 구현합니다.
- 각자의 특성에 맞게 구현되어 있어 대체 가능성 문제가 발생하지 않습니다.
- 클라이언트 코드는 Shape 인터페이스에만 의존하므로 어떤 구현체가 오더라도 예상대로 동작합니다.
따라서 LSP 위반의 핵심은 "타입을 대체할 수 있다"는 것이 단순히 문법적인 것이 아니라, 기대되는 행위(behavior)까지 포함한다는 것입니다.
리스코프 치환 원칙을 통해 코드의 신뢰성 향상시키며 상위 타입의 객체를 하위 타입의 객체로 대체해도 프로그램의 정확성이 보장됩니다.
4) 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
인터페이스 분리 원칙은 클래스는 사용하지 않는 인터페이스는 구현하지 말아야 함을 의미하는데, 이는 인터페이스도 단일 책임 원칙을 따라야 함을 말합니다. 그렇기 때문에 필요한 인터페이스만 골라서 구현하면 해당 원칙을 지킬 수 있습니다.
// 나쁜 예: 하나의 큰 인터페이스
interface Animal {
fly(): void;
swim(): void;
run(): void;
}
// 좋은 예: 분리된 인터페이스
interface Flyable {
fly(): void;
}
interface Swimmable {
swim(): void;
}
interface Runnable {
run(): void;
}
class Bird implements Flyable, Runnable {
fly() {/* 구현 */}
run() {/* 구현 */}
}
인터페이스 분리 원칙을 통해 불필요한 의존성 제거하여 클라이언트가 자신이 필요한 메서드만 가진 인터페이스에 의존하게 되어 결합도가 낮아집니다.
5) 의존성 역전 원칙 (Dependency Inversion Principle, DIP)
의존성 역전 원칙은 추상화(인터페이스, 추상 클래스)에 의존해야 함을 의미하는데, 상속보다는 합성을 선호하며 매개변수로 인터페이스나 추상 클래스를 받아야 한다.
// 나쁜 예: 구체 클래스에 직접 의존
class OrderService {
private repository = new MySQLRepository(); // 직접적인 의존
}
// 좋은 예: 추상화에 의존, 합성 사용
interface Repository {
save(data: any): void;
}
class OrderService {
constructor(private repository: Repository) {} // 의존성 주입
}
의존성 역전 원칙을 통해 모듈 간 결합도 감소하며 구체적인 구현이 아닌 추상화에 의존하므로 코드의 유연성이 높아지고 변경이 용이해집니다.
이러한 SOLID 원칙들은 코드의 유지보수성, 재사용성, 유연성을 높이는 데 도움을 주지만, 프로젝트의 규모와 상황에 따라 적절히 적용하는 것이 중요합니다.
'디자인패턴' 카테고리의 다른 글
디자인패턴 : 빌더 패턴(Builder Pattern) (0) | 2025.01.10 |
---|---|
디자인패턴 : 추상 팩토리 패턴(Abstract Factory Pattern) (0) | 2025.01.09 |
디자인패턴 : 팩토리 메소드 패턴(Factory method pattern) (1) | 2025.01.09 |
디자인패턴 : 심플 팩토리 패턴(Simple Factory Pattern) (0) | 2025.01.08 |
디자인패턴 : 싱글톤 패턴 (0) | 2025.01.03 |