SOLID 원칙은 객체 지향 프로그래밍에서 사용되는 설계 원칙으로, 다섯 가지의 단일 책임 원칙(Single Responsibility Principle), 개방-폐쇄 원칙(Open-Closed Principle), 리스코프 치환 원칙(Liskov Substitution Principle), 인터페이스 분리 원칙(Interface Segregation Principle), 의존 역전 원칙(Dependency Inversion Principle)의 약자이다.

단일책임원칙

단일책임원칙(Single Responsibility Principle, SRP)은 클래스나 모듈은 하나의 책임만 가져야 한다는 원칙이다. 이렇게 하면 각 클래스나 모듈은 자신의 일에 집중할 수 있기 때문에 유지보수성과 확장성이 좋아진다.

아래는 단일책임원칙을 적용하지 않은 코드의 예시다.

public class Employee {
    private String name;
    private String address;
    private double salary;
    
    public Employee(String name, String address, double salary) {
        this.name = name;
        this.address = address;
        this.salary = salary;
    }
    
    public void printPaycheck() {
        // 급여명세서를 출력하는 코드
    }
    
    public void saveToDatabase() {
        // 데이터베이스에 저장하는 코드
    }
    
    public void sendEmail() {
        // 이메일을 보내는 코드
    }
}

위 코드에서 Employee 클래스는 세 가지 책임을 가지고 있다. printPaycheck() 메소드는 급여명세서를 출력하고, saveToDatabase() 메소드는 데이터베이스에 저장하며, sendEmail() 메소드는 이메일을 보낸다. 이렇게 하나의 클래스에 여러 책임을 부여하면 클래스가 복잡해지고 유지보수하기 어려워진다.

단일책임원칙을 적용하면 Employee 클래스를 다음과 같이 분리할 수 있다.

public class Employee {
    private String name;
    private String address;
    private double salary;
    
    public Employee(String name, String address, double salary) {
        this.name = name;
        this.address = address;
        this.salary = salary;
    }
    
    // 게터와 세터 생략
    
    // 급여명세서를 출력하는 책임을 가진 클래스
    public class PaycheckPrinter {
        public void print(Employee employee) {
            // 급여명세서를 출력하는 코드
        }
    }
    
    // 데이터베이스에 저장하는 책임을 가진 클래스
    public class DatabaseSaver {
        public void save(Employee employee) {
            // 데이터베이스에 저장하는 코드
        }
    }
    
    // 이메일을 보내는 책임을 가진 클래스
    public class EmailSender {
        public void send(Employee employee) {
            // 이메일을 보내는 코드
        }
    }
}

개방폐쇄원칙

위 코드에서 Employee 클래스는 자신의 정보를 가지고 있는 역할만 수행한다. PaycheckPrinter, DatabaseSaver, EmailSender 클래스는 각각 하나의 책임만 가지고 있다. 이렇게 클래스나 모듈을 단일책임으로 유지하면 코드의 응집성과 결합도를 높일 수 있다.

개방 폐쇄 원칙(Open/Closed Principle, OCP)은 소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에는 열려있어야 하지만 변경에는 닫혀있어야 한다는 원칙이다. 이는 새로운 기능을 추가할 때 기존 코드를 수정하지 않고도 확장이 가능하도록 설계하라는 것을 의미한다.

다음은 OCP를 지키기 위한 예시 코드이다.

// 잘못된 예시

class Order {
  constructor() {
    this.items = [];
    this.totalPrice = 0;
  }
  
  addItem(item) {
    this.items.push(item);
    this.totalPrice += item.price;
  }
  
  calculateDiscount() {
    if (this.totalPrice > 1000) {
      return 100;
    } else {
      return 0;
    }
  }
  
  calculateTax() {
    return this.totalPrice * 0.1;
  }
  
  calculateTotal() {
    const discount = this.calculateDiscount();
    const tax = this.calculateTax();
    return this.totalPrice - discount + tax;
  }
}

// 이 코드에서는 Order 클래스가 주문에 관련된 기능과 할인, 세금 계산과 같은 다른 기능을 모두 처리하고 있다.
// 만약 새로운 할인 기능을 추가하고 싶다면 Order 클래스를 수정해야 한다.
// 이는 OCP를 위반하는 것이다.


// 올바른 예시

class Order {
  constructor() {
    this.items = [];
  }
  
  addItem(item) {
    this.items.push(item);
  }
}

class DiscountCalculator {
  calculateDiscount(order) {
    if (order.totalPrice > 1000) {
      return 100;
    } else {
      return 0;
    }
  }
}

class TaxCalculator {
  calculateTax(order) {
    return order.totalPrice * 0.1;
  }
}

// 이제 Order 클래스는 주문과 관련된 기능만을 처리한다.
// 할인 계산과 세금 계산은 각각 별도의 클래스로 분리되어 있다.
// 만약 새로운 할인 기능을 추가하고 싶다면 DiscountCalculator 클래스를 수정하면 된다.
// 이는 OCP를 준수하는 것이다.

위 코드에서 Order 클래스는 주문과 관련된 기능만을 처리하고 있으며, 할인 계산과 세금 계산은 각각 별도의 클래스로 분리되어 있다. 이를 통해 새로운 할인 기능을 추가할 때 DiscountCalculator 클래스만을 수정하면 되므로, 기존 코드를 변경하지 않고도 확장이 가능하다. 이는 OCP를 준수한 예시이다.

리스코프치환원칙

리스코프 치환 원칙은 객체지향 프로그래밍에서 서브타입(자식 클래스)은 언제나 자신의 슈퍼타입(부모 클래스)으로 교체할 수 있어야 한다는 원칙이다. 이 원칙을 따르면, 자식 클래스는 부모 클래스가 갖고 있는 속성과 메서드를 포함하여 최소한의 동작을 보장해야 한다.

아래는 자바스크립트 코드 예시이다.

class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }
  
  getArea() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(size) {
    super(size, size);
  }
  
  getArea() {
    return super.getArea();
  }
}

function printArea(rectangle) {
  console.log(rectangle.getArea());
}

let rectangle = new Rectangle(4, 5);
let square = new Square(4);

printArea(rectangle); // 20
printArea(square); // 16

위 예시 코드에서, Rectangle 클래스는 widthheight 속성을 갖고 있으며 getArea 메서드를 구현한다. Square 클래스는 Rectangle 클래스를 상속받아 getArea 메서드를 구현한다.

printArea 함수는 Rectangle 클래스를 인자로 받아서 해당 사각형의 면적을 출력한다. 이 함수에 rectangle 객체를 전달하면 올바른 결과인 20이 출력되고, square 객체를 전달하면 부모 클래스의 메서드인 Rectangle.getArea()를 호출하면서 올바른 결과인 16이 출력된다.

이 예시에서 Square 클래스는 Rectangle 클래스의 서브타입이다. 따라서 Square 객체는 Rectangle 객체가 필요한 자리에 대체될 수 있다. 이를 통해 리스코프 치환 원칙을 따르는 객체지향 코드를 작성할 수 있다.

인터페이스분리원칙

인터페이스 분리 원칙은 객체 지향 설계 원칙 중 하나로, 클라이언트가 자신이 사용하지 않는 메소드에 의존하지 않도록 인터페이스를 작게 분리해야 한다는 원칙이다.

예를 들어, 다음과 같이 하나의 인터페이스를 가진 객체가 있다고 가정해보자.

interface Vehicle {
  startEngine();
  stopEngine();
  accelerate();
  brake();
  turn();
}

위 인터페이스는 다양한 종류의 차량이나 비행기 등 여러가지 탈 것들을 나타내기 위한 것으로, 모든 탈 것들이 갖추어야 할 기능들을 포함하고 있다.

하지만 이 인터페이스는 너무 방대하며, 차량과 비행기는 각각 자신만의 고유한 기능을 가지고 있다. 그렇기 때문에 인터페이스를 분리하여 각각의 차량과 비행기 객체가 필요한 메소드만 가지도록 수정할 수 있다.

interface Vehicle {
  startEngine();
  stopEngine();
}

interface Car extends Vehicle {
  accelerate();
  brake();
  turn();
}

interface Airplane extends Vehicle {
  takeoff();
  fly();
  land();
}

위와 같이 인터페이스를 분리하면, 자동차와 비행기 객체에서는 각각 자신이 필요로 하는 메소드만 구현하면 된다. 이렇게 인터페이스를 작게 분리하면, 클라이언트는 자신이 필요로 하는 기능에만 의존하게 되어 코드 유지보수가 용이해진다.

의존역전원칙

의존 역전 원칙(Dependency Inversion Principle)은 상위 모듈은 하위 모듈에 의존해서는 안되며, 인터페이스나 추상 클래스와 같은 추상화된 것에 의존해야 한다는 원칙이다.

아래 코드는 특정 유저 정보를 출력하는 함수이다. 이 함수는 내부에서 데이터베이스에 직접 접근하고 있다.

function printUserInfo(userId) {
  const userData = getUserDataFromDatabase(userId);
  console.log(userData);
}

function getUserDataFromDatabase(userId) {
  // 데이터베이스에 접근해서 유저 데이터를 가져옴
  return { id: userId, name: "John Doe", age: 30 };
}

printUserInfo(1); // { id: 1, name: "John Doe", age: 30 }

위 코드는 의존 역전 원칙을 따르지 않는다. printUserInfo 함수가 직접 데이터베이스에 접근하기 때문이다. 만약 데이터베이스를 변경해야 한다면, printUserInfo 함수를 수정해야 한다. 이는 유지 보수와 확장에 좋지 않다.

의존 역전 원칙을 적용하여 개선해보자. 먼저, 데이터베이스에 접근하는 코드를 추상화한다. 이를 위해 getUserDataFromDatabase 함수를 인터페이스 형태로 변경한다.

class Database {
  getUserData(userId) {
    // 데이터베이스에 접근해서 유저 데이터를 가져옴
    return { id: userId, name: "John Doe", age: 30 };
  }
}

다음으로, printUserInfo 함수가 Database 클래스에 의존하도록 변경한다.

function printUserInfo(userId, database) {
  const userData = database.getUserData(userId);
  console.log(userData);
}

const database = new Database();
printUserInfo(1, database); // { id: 1, name: "John Doe", age: 30 }

이제 printUserInfo 함수는 데이터베이스에 직접 의존하지 않는다. 대신, Database 클래스에 의존한다. 이를 통해 Database 클래스가 변경되더라도 printUserInfo 함수는 수정할 필요가 없다. 이는 유지 보수와 확장에 좋은 코드이다.

결론

  • 단일책임원칙: 하나의 클래스는 하나의 역할만 가지도록 하자.
  • 개방폐쇄원칙: 새로운 기능을 추가할때 기존 코드를 수정 할 필요가 없도록 하자. 확장에는 열려있게, 기존 코드 수정에는 닫혀있게.
  • 리스코프치환원칙: 자식클래스의 코드만으로 부모클래스의 코드를 대체 할 수 있도록 하자. 자식클래스는 부모클래스의 한 예가 될 수 있도록 하자.
  • 인터페이스분리원칙: 인터페이스를 세부적으로 분리해서 자식클래스가 부모클래스의 필요없는 메소드까지도 구현하지 않도록 하자.
  • 의존역전원칙: 상위모듈이 하위모듈에 의존하지 않도록 하자. 추상화된 것에 의존하도록해서 하위모듈 기능이 수정될 때 상위모듈까지 수정되는 일이 없도록 하자.
profile
Frontend Developer

0개의 댓글