Ayden's journal

SOLID

SOLID 원칙은 로버트 C. 마틴이 제창한 객체 지향 프로그래밍의 설계 원칙이다. 이 원칙은 상속, 캡슐화, 다형성, 추상화라는 네 가지 기둥을 실제 코드에 어떻게 적용할지에 대해 설명한다. SOLID라는 용어는 각 원칙의 첫 글자를 따서 만든 약어로, 다섯 가지 원칙은 각각 단일 책임 원칙(SRP), 개방-폐쇄 원칙(OCP), 리스코프 치환 원칙(LSP), 인터페이스 분리 원칙(ISP), 의존성 역전 원칙(DIP)을 의미한다. 이는 객체 지향을 설계를 할 때 코드의 확장성, 유지보수성, 재사용성, 유연성 등을 향상시키기 위해 고려해야 할 중요한 가이드라인을 제공한다. 각각의 원칙은 독립된 개별적인 개념이 아니라, 하나의 목적을 위해 서로 유기적으로 연관되어있다.

 

단일 책임 원칙(SRP)

단일 책임 원칙은 하나의 클래스는 하나의 책임만 가져야 한다는 원칙이다. 즉, 클래스는 하나의 기능에만 집중하고, 그 기능을 변경해야 할 이유가 하나만 있어야 한다는 뜻이다. 이 원칙을 따르면 클래스가 더 이해하기 쉽고, 변경이 필요한 경우 그 영향을 최소화할 수 있다. 또한, SRP는 유지보수성과 확장성을 높여 코드의 재사용성을 향상시키고, 시스템이 복잡해져도 각 컴포넌트가 독립적으로 동작할 수 있도록 한다. 아래와 같이 어떤 클래스가 너무 많은 책임을 가지고 있는 경우를 God Class라고 부르는 듯하다.

class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  // 사용자 정보 저장
  saveUser(user) {
    this.userRepository.save(user);
  }

  // 사용자 정보를 파일로 저장
  exportUserToFile(user) {
    const fs = require('fs');
    const userData = JSON.stringify(user);
    fs.writeFileSync('user.json', userData);
  }
}

// 사용 예시
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
const user = { name: 'John', age: 30 };

userService.saveUser(user);
userService.exportUserToFile(user);

위 코드에서 UserService 클래스는 사용자 정보를 저장하는 기능뿐만 아니라, 사용자 정보를 파일로 내보내는 기능도 함께 담당하고 있다. 이 두 기능은 서로 다른 책임을 가지고 있기 때문에, 하나의 클래스가 두 가지 책임을 맡고 있어 단일 책임 원칙을 위반하고 있는 상태다.

class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  // 사용자 정보 저장
  saveUser(user) {
    this.userRepository.save(user);
  }
}

class UserExporter {
  // 사용자 정보를 파일로 저장
  exportToFile(user) {
    const fs = require('fs');
    const userData = JSON.stringify(user);
    fs.writeFileSync('user.json', userData);
  }
}

// 사용 예시
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
const userExporter = new UserExporter();
const user = { name: 'John', age: 30 };

userService.saveUser(user);
userExporter.exportToFile(user);

위 코드에서는 UserService가 오직 사용자 정보를 저장하는 역할만 담당하고, UserExporter가 사용자 정보를 파일로 내보내는 역할만 담당하도록 분리했다. 이렇게 클래스가 하나의 책임만 가지도록 설계함으로써, 코드가 더 명확해지고, 각 클래스는 독립적으로 변경할 수 있다.

 

개방-폐쇄 원칙(OCP)

개방-폐쇄 원칙은 "소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다"는 원칙이다. 즉, 기존 코드를 수정하지 않고도 새로운 기능을 추가할 수 있도록 설계해야 한다는 것이다. 이 원칙을 따르면 코드의 안정성을 유지하면서도 새로운 요구사항을 반영하거나 기능을 추가할 때 변경의 위험을 최소화할 수 있다. 이를 위해 추상화와 상속, 인터페이스 등을 활용해 확장 가능한 구조를 만드는 것이 중요하다.

class AreaCalculator {
  calculateRectangleArea(length, width) {
    return length * width;
  }

  calculateCircleArea(radius) {
    return Math.PI * radius * radius;
  }

  calculate(shape) {
    if (shape.type === "rectangle") {
      return this.calculateRectangleArea(shape.length, shape.width);
    } else if (shape.type === "circle") {
      return this.calculateCircleArea(shape.radius);
    }
  }
}

// 사용 예시
const calculator = new AreaCalculator();
const rectangle = { type: "rectangle", length: 5, width: 3 };
const circle = { type: "circle", radius: 4 };

console.log(calculator.calculate(rectangle));  // 15
console.log(calculator.calculate(circle));     // 50.26548245743669

위 코드에서 새로운 도형을 추가하려면 AreaCalculator 클래스 내부의 calculate 메서드를 수정해야 하는데, 이는 개방-폐쇄 원칙을 위반하는 구조이다.

class Shape {
  area() {
    throw "This method should be implemented by subclasses";
  }
}

class Rectangle extends Shape {
  constructor(length, width) {
    super();
    this.length = length;
    this.width = width;
  }

  area() {
    return this.length * this.width;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  area() {
    return Math.PI * this.radius * this.radius;
  }
}

class AreaCalculator {
  calculate(shape) {
    return shape.area();
  }
}

// 사용 예시
const calculator = new AreaCalculator();
const rectangle = new Rectangle(5, 3);
const circle = new Circle(4);

console.log(calculator.calculate(rectangle));  // 15
console.log(calculator.calculate(circle));     // 50.26548245743669

위 코드에서는 Shape라는 추상 클래스를 만들고, Rectangle과 Circle 클래스를 이를 상속하여 각자의 area() 메서드를 구현했다. AreaCalculator 클래스는 Shape의 서브클래스들이 제공하는 area() 메서드를 호출하기만 하며, 새로운 도형을 추가할 때 기존 코드를 수정할 필요가 없다.

 

리스코프 치환 원칙(LSP)

리스코프 치환 원칙은 "자식 클래스는 부모 클래스를 대체할 수 있어야 한다"는 원칙이다. 따라서 자식 클래스는 부모 클래스의 모든 동작을 그대로 또는 적절하게 확장해야 하며, 이를 통해서만 상속 관계가 올바르게 구성되었음을 보장할 수 있다. 만약 자식 클래스가 부모 클래스의 행동을 예상치 못하게 변경하거나 예외를 발생시키면, 프로그램이 예기치 않게 동작할 수 있고, 이는 코드의 일관성과 안정성을 해칠 수 있다.

class Bird {
  fly() {
    console.log("Flying");
  }
}

class Sparrow extends Bird {
  // 스페로우는 새이므로 비행이 가능
  fly() {
    console.log("Sparrow flying");
  }
}

class Ostrich extends Bird {
  // 타조는 날지 못하는 새이므로, 부모 클래스의 fly()를 덮어써야 함
  fly() {
    throw new Error("Ostriches can't fly");
  }
}

function makeBirdFly(bird) {
  bird.fly();  // 모든 Bird 객체는 fly() 메서드를 가질 것으로 예상됨
}

const sparrow = new Sparrow();
const ostrich = new Ostrich();

makeBirdFly(sparrow);  // 정상 작동: Sparrow flying
makeBirdFly(ostrich);  // 예외 발생: Ostriches can't fly

위 코드에서는 Bird 클래스가 기본적으로 fly() 메서드를 가지고 있고, 이를 상속받은 참새Sparrow는 정상적으로 fly() 메서드를 구현하고 있다. 하지만 타조Ostrich는 날지 못하는 새이므로, fly() 메서드를 오버라이드하여 예외를 던진다. 이로 인해 makeBirdFly 함수에서 Ostrich 객체를 사용할 때 예외가 발생한다. Ostrich 클래스는 부모 클래스인 Bird를 대체할 수 없기 때문에, 이 관계는 적절하지 않은 것이다.

객체 지향 프로그래밍에서 상속은 기반 클래스와 서브 클래스 사이에 IS-A 관계가 있을 경우로만 제한 되어야 한다. 따라서 위와 같은 상황에서는 상속 대신 합성을 사용하는 것이 더 나은 선택지일 수도 있다.

 

인터페이스 분리 원칙(ISP)

인터페이스 분리 원칙은 하나의 커다란 인터페이스를 여러 개의 작은 인터페이스로 분리하여, 각 클라이언트가 필요한 기능만을 구현하도록 해야 다는 뜻이다. 이를 통해 클래스가 자신이 사용하지 않는 메서드들에 대해 의존하거나 불필요한 메서드를 구현하는 것을 방지하고, 코드의 유연성과 유지보수성을 높일 수 있다. 이 원칙은 "큰 인터페이스보다는 작은 인터페이스를 선호하라"는 점에서 특히 유용하다.

// 인터페이스 분리 원칙을 위반한 예시
class Worker {
  work(): void {
    console.log("Working...");
  }

  eat(): void {
    console.log("Eating...");
  }
}

// Worker 클래스는 일만 하는 게 아니라, eat 메서드까지 구현해야 하므로 불필요한 메서드를 가지고 있음
const worker = new Worker();
worker.work(); // Working...
worker.eat();  // Eating...

Worker 클래스는 work()와 eat() 메서드를 모두 구현하고 있지만 모든 클라이언트가 eat() 기능을 필요로 하지 않을 수도 있다. 가령, Robot은 work()만 필요하고 eat() 메서드는 필요하지 않지만, Worker 클래스에서는 이를 모두 구현해야 하기에 자칫 잘못하면 리스코프 치환 원칙 등을 위반하게 될 수도 있다.

// 인터페이스 분리 원칙을 따른 예시
interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

class Worker implements Workable, Eatable {
  work(): void {
    console.log("Working...");
  }

  eat(): void {
    console.log("Eating...");
  }
}

class Robot implements Workable {
  work() {
    console.log("Robot working...");
  }
}

const worker1 = new Worker();
const robot = new Robot();

worker1.work();  // Working...
worker1.eat();   // Eating...
robot.work();    // Robot working...

Workable과 Eatable 인터페이스를 분리하여, 각 클라이언트는 자신이 필요한 기능만 구현하도록 했습니다. Worker 클래스는 두 인터페이스를 모두 구현하고, Robot은 Workable 인터페이스만 구현하여 불필요한 메서드가 포함되는 상황을 피할 수 있다. 이렇게 하면 각 클래스가 자신에게 필요한 기능만을 구현하고 의존하게 되어, 코드의 유연성과 재사용성이 향상된다.

 

의존성 역전 원칙(DIP)

의존성 역전 원칙(DIP)은 고수준 모듈(클래스, 컴포넌트 등)은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화(인터페이스 등)에 의존해야 한다는 원칙이다. 또한, 추상화는 세부 구현에 의존해서는 안 되고, 세부 구현이 추상화에 의존해야 한다. 이를 통해 고수준 모듈과 저수준 모듈 간의 결합도를 낮추고, 코드의 유연성과 확장성을 높일 수 있다.

// 추상화된 인터페이스 정의
interface DB {
  connect(): void;
  disconnect(): void;
}

// 저수준 모듈: MySQL 데이터베이스 구현
class MySQL implements Database {
  connect(): void {
    console.log("Connecting to MySQL...");
  }

  disconnect(): void {
    console.log("Disconnecting from MySQL...");
  }
}

// 저수준 모듈: PostgreSQL 데이터베이스 구현
class PostgreSQL implements Database {
  connect(): void {
    console.log("Connecting to PostgreSQL...");
  }

  disconnect(): void {
    console.log("Disconnecting from PostgreSQL...");
  }
}

// 고수준 모듈: DatabaseManager는 Database 인터페이스에 의존
class DBManager {
  private db: DB;

  // 생성자를 통해 의존성 주입
  constructor(db: DB) {
    this.db = DB;
  }

  startConnection(): void {
    this.db.connect();
  }

  closeConnection(): void {
    this.db.disconnect();
  }
}

// 의존성 주입을 통해 데이터베이스 변경이 가능
const mySQLDatabase = new MySQL();
const databaseManager = new DBManager(mySQLDatabase);

databaseManager.startConnection(); // Connecting to MySQL...
databaseManager.closeConnection(); // Disconnecting from MySQL...

// PostgreSQL로 변경 가능
const postgreSQLDatabase = new PostgreSQL();
const databaseManager2 = new DBManager(postgreSQLDatabase);

databaseManager2.startConnection(); // Connecting to PostgreSQL...
databaseManager2.closeConnection(); // Disconnecting from PostgreSQL...

위의 코드에서 DB 인터페이스는 connect()와 disconnect() 메서드를 정의하여 데이터베이스와의 상호작용을 추상화하고 있다. MySQL와 PostgreSQL 클래스는 각각 이 인터페이스를 구현하여, 실제 데이터베이스 연결과 해제 로직을 담고 있다. 또한, DatabaseManager 클래스는 구체적인 데이터베이스 구현체에 의존하지 않고, Database 인터페이스에 의존하고 있다.

이처럼 고수준 모듈과 저수준 모듈이 둘 다 인터페이스에 의존하고 있기에 데이터베이스 구현을 변경하거나 새로운 구현체를 추가할 때 DatabaseManager는 변경 없이 동작할 수 있다. 이는 코드의 결합도를 낮추고, 새로운 기능을 추가할 때에도 기존 코드에 미치는 영향을 최소화하여 유지보수성을 높여준다.

 

마무리

SOLID 원칙은 객체 지향 설계에서 중요한 다섯 가지 규칙으로, 코드의 유지보수성, 확장성, 유연성을 높이는 데 큰 역할을 한다. 각 원칙은 단독으로도 중요한 의미를 가지지만, 함께 적용될 때 더욱 효과적이다. 예를 들어, 단일 책임 원칙(SRP)을 통해 각 클래스의 책임을 명확히 하고, 개방-폐쇄 원칙(OCP)을 통해 기존 코드를 변경하지 않고도 기능을 확장할 수 있다. 또한, 리스코프 치환 원칙(LSP)과 인터페이스 분리 원칙(ISP)을 통해 코드의 결합도를 낮추고, 의존성 역전 원칙(DIP)을 통해 고수준 모듈과 저수준 모듈 간의 의존성을 줄일 수 있다. 이 모든 원칙들이 서로 보완적으로 작용하여 더 나은 설계를 가능하게 만든다.


이러한 SOLID 원칙을 실제 개발에 적용하면, 코드의 품질을 높이고, 팀 내 협업을 원활하게 하며, 변화하는 요구 사항에 빠르게 대응할 수 있는 구조를 만든다. 원칙들을 익히고 실천하는 것은 시간이 걸리지만, 장기적으로 보았을 때 소프트웨어의 유지보수와 확장에 큰 도움이 된다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기