Post

SOLID (객체 지향 설계)

SOLID

SOLID는 객체 지향 프로그래밍에서 좋은 코드 구조를 만들기 위해 따라야 할 원칙들의 집합이다. SOLID 원칙은 코드의 유지보수성, 확장성, 재사용성을 높이고, 의존성을 줄여 프로그램 개발 과정을 더 쉽고 효율적으로 만들어주는 것을 목표로 한다. SOLID는 다음 다섯 가지 원칙이 존재한다. 😉

Single Responsibility Principle (단일 책임 원칙)

한 클래스는 하나의 책임만 가져야 한다는 원칙이다. 이를 통해 클래스가 너무 많은 역할을 담당하지 않도록 하여 코드의 복잡성을 줄이고, 유지보수를 용이하게 한다.

  • AS-IS
    • 단일 책임 원칙을 적용하기 전에는, 한 클래스(또는 객체)가 여러 책임을 갖는 경우가 많다. 예를 들어, User 클래스가 사용자 정보를 관리하는 동시에 사용자 데이터를 데이터베이스에 저장하고, 로깅하는 책임을 가질 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  save() {
    console.log("Saving a user to a database:", this);
    // 여기에 사용자를 데이터베이스에 저장하는 로직이 포함됩니다.
  }

  log() {
    console.log("Logging user data:", this);
    // 여기에 로깅 로직이 포함됩니다.
  }
}

const user = new User("zwoong", "zwoong@gmail.com");
user.save();
user.log();
  • TO-BE
    • 단일 책임 원칙을 적용하면, 각 클래스는 하나의 책임만을 가지도록 분리된다. 예를 들어, 사용자 정보 관리, 데이터베이스 저장, 로깅 기능을 분리할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
}

class UserDataBase {
  static save(user) {
    console.log("Saving a user to a database:", user);
    // 여기에 사용자를 데이터베이스에 저장하는 로직이 포함됩니다.
  }
}

class UserLogger {
  static log(user) {
    console.log("Logging user data:", user);
    // 여기에 로깅 로직이 포함됩니다.
  }
}

const user = new User("zwoong", "zwoong@gmail.com");
UserDataBase.save(user);
UserLogger.log(user);

Open/Closed Principle (개방-폐쇄 원칙)

엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다는 원칙이다. 즉, 기존의 코드를 변경하지 않으면서 기능을 추가할 수 있어야 한다. 이를 통해 기존 코드의 변경 없이 시스템을 확장할 수 있다.

예를 들어, 다양한 종류의 쉐이프(도형)를 그리는 시스템이 있다고 가정해 보자.

  • AS-IS
    • 해당 코드에서 새로운 쉐이프를 추가하기 위해서는 ShapeDrawer 클래스의 drawShape 메소드를 수정해야 한다. 이는 OCP 원칙에 위배된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Shape {
  constructor(type) {
    this.type = type;
  }
}

class ShapeDrawer {
  drawShape(shape) {
    if (shape.type === "circle") {
      console.log("Drawing a circle");
      // 원 그리기 로직
    } else if (shape.type === "square") {
      console.log("Drawing a square");
      // 정사각형 그리기 로직
    }
    // 새로운 쉐이프 타입이 추가될 때마다 여기에 else if문을 추가해야 함
  }
}

const circle = new Shape("circle");
const square = new Shape("square");
const drawer = new ShapeDrawer();
drawer.drawShape(circle);
drawer.drawShape(square);
  • TO-BE
    • OCP 적용 후의 코드에서는 ShapeDrawer 클래스를 수정하지 않고도 새로운 쉐이프를 추가할 수 있다. 새로운 쉐이프 클래스를 Shape 클래스를 상속받아 구현하고, draw 메소드를 오버라이딩함으로써 확장성을 확보할 수 있다. 이렇게 함으로써, 시스템은 확장에는 열려 있지만, 수정에는 닫혀 있는 구조를 갖게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Shape {
  draw() {
    throw new Error("This method should be overridden");
  }
}

class Circle extends Shape {
  draw() {
    console.log("Drawing a circle");
    // 원 그리기 로직
  }
}

class Square extends Shape {
  draw() {
    console.log("Drawing a square");
    // 정사각형 그리기 로직
  }
}

class ShapeDrawer {
  drawShape(shape) {
    shape.draw();
  }
}

const circle = new Circle();
const square = new Square();
const drawer = new ShapeDrawer();
drawer.drawShape(circle);
drawer.drawShape(square);

Liskov Substitution Principle (리스코프 치환 원칙)

프로그램에서 부모 클래스의 인스턴스를 자식 클래스의 인스턴스로 대체(치환)할 수 있어야 한다는 원칙이다. 이 원칙은 상속을 사용할 때, 자식 클래스가 부모 클래스의 행위를 예상할 수 있고 일관된 방식으로 수행해야 함을 의미한다.

  • AS-IS
    • 해당 코드는 새(Bird) 클래스와 그 서브클래스인 비행하는 새(FlyingBird)를 가지고 있다. 원래 모든 새는 날 수 있다고 가정하여 Bird 클래스에 fly 메소드를 구현했다. 그러나 나중에 날지 못하는 새(NonFlyingBird)를 추가하게 되면서 문제가 발생한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Bird {
  fly() {
    console.log("The bird flies!");
  }
}

class FlyingBird extends Bird {
  // FlyingBird는 fly 메소드를 그대로 사용
}

class NonFlyingBird extends Bird {
  // 날지 못하는 새지만, fly 메소드를 상속받음
}

const penguin = new NonFlyingBird();
penguin.fly(); // 문제 발생: 날지 못하는 새가 날 수 있다고 표시됨
  • TO-BE
    • FlyingBird와 NonFlyingBird가 각자의 특성에 맞는 행위를 가지고 있으며, Bird 클래스를 상속받는 것은 그들의 기본적인 속성을 공유하기 위함이다. 이렇게 함으로써, 어떤 종류의 새(Bird) 객체라도 기대하는 대로 동작하게 하며, LSP를 준수하는 구조를 가지게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Bird {
  // Bird 클래스는 더 일반적인 행위나 속성만 포함
}

class FlyingBird extends Bird {
  fly() {
    console.log("The bird flies!");
  }
}

class NonFlyingBird extends Bird {
  // fly 메소드가 없음, 날지 못함을 표현
}

const eagle = new FlyingBird();
eagle.fly(); // "The bird flies!" 출력, 문제 없음

const penguin = new NonFlyingBird();
// penguin.fly(); 는 호출할 수 없음, LSP를 준수

Interface Segregation Principle (인터페이스 분리 원칙)

한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 않아야 한다는 원칙이다. 즉, 하나의 일반적인 인터페이스보다는 여러 개의 구체적인 인터페이스가 낫다는 의미이다. 이를 통해 클래스가 불필요하게 너무 많은 역할을 갖지 않도록 한다.

  • AS-IS
    • 한 개의 큰 인터페이스 IMachine이 있고, 이 인터페이스는 다양한 기능(프린트, 스캔, 팩스)을 정의한다. 그러나 일부 기계는 이 모든 기능을 지원하지 않을 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
interface IMachine {
  print();
  scan();
  fax();
}

class MultiFunctionPrinter implements IMachine {
  print() {
    // 프린트 기능 구현
  }

  scan() {
    // 스캔 기능 구현
  }

  fax() {
    // 팩스 기능 구현
  }
}

class OldFashionedPrinter implements IMachine {
  print() {
    // 프린트 기능 구현
  }

  scan() {
    // 지원하지 않음
    throw new Error("This function is not supported");
  }

  fax() {
    // 지원하지 않음
    throw new Error("This function is not supported");
  }
}
  • TO-BE
    • MultiFunctionPrinter가 모든 기능을 지원하는 반면, OldFashionedPrinter는 프린트 기능만 지원한다. 각 클래스는 자신이 필요로 하는 인터페이스만 구현하므로, 클라이언트가 사용하지 않는 메소드에 의존하지 않게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
interface IPrinter {
  print();
}

interface IScanner {
  scan();
}

interface IFax {
  fax();
}

class MultiFunctionPrinter implements IPrinter, IScanner, IFax {
  print() {
    // 프린트 기능 구현
  }

  scan() {
    // 스캔 기능 구현
  }

  fax() {
    // 팩스 기능 구현
  }
}

class OldFashionedPrinter implements IPrinter {
  print() {
    // 프린트 기능만 구현
  }
}

Dependency Inversion Principle (의존성 역전 원칙)

고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다는 원칙이다. 또한, 추상화는 세부 사항에 의존해서는 안 되며, 세부 사항은 추상화에 의존해야 한다. 이 원칙은 의존성의 방향을 역전시켜 느슨한 결합을 유도한다.

  • AS-IS
    • 해당 코드에서 Computer 클래스(고수준 모듈)는 HardDrive 클래스(저수준 모듈)에 직접 의존하고 있다. 이는 Computer 클래스가 HardDrive 클래스의 변경에 직접적으로 영향을 받게 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 저수준 모듈
class HardDrive {
  read(lba, size) {
    // 하드 드라이브에서 데이터를 읽는 로직
    return "data";
  }
}

// 고수준 모듈
class Computer {
  constructor() {
    this.hardDrive = new HardDrive();
  }

  boot() {
    // 하드 드라이브에서 부트 섹터를 읽음
    const data = this.hardDrive.read(0, 1024);
    // 부트 로직
  }
}
  • TO-BE
    • Computer 클래스는 StorageDevice라는 추상화에 의존하며, HardDrive 클래스는 이 추상화의 구현체이다. 나중에 다른 스토리지 장치(예: SSD)를 도입하더라도 Computer 클래스를 변경하지 않고, 새로운 스토리지 장치 클래스를 StorageDevice 인터페이스를 구현하게 하여서 쉽게 교체할 수 있다. 이 방식으로 의존성 역전 원칙을 준수하여 시스템의 유연성과 확장성을 높일 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 추상화
interface StorageDevice {
  read(lba, size);
}

// 저수준 모듈이 추상화를 구현
class HardDrive implements StorageDevice {
  read(lba, size) {
    // 하드 드라이브에서 데이터를 읽는 로직
    return "data";
  }
}

// 고수준 모듈
class Computer {
  constructor(storageDevice) {
    this.storageDevice = storageDevice;
  }

  boot() {
    // 스토리지 장치에서 부트 섹터를 읽음
    const data = this.storageDevice.read(0, 1024);
    // 부트 로직
  }
}

// 사용 예
const hardDrive = new HardDrive();
const computer = new Computer(hardDrive); // 의존성 주입
This post is licensed under CC BY 4.0 by the author.

© zwoong. Some rights reserved.