SOLID 원칙
포스트
취소

SOLID 원칙

객체지향 설계에서 사용되는 5가지 원칙인 SOLID 원칙에 대해 정리해본다.

SOLID 원칙

SOLID 는 위키에 따르면 로버트 마틴이 2000년대 초반 명명한 객체 지향 프로그래밍 및 설계의 5가지 기본 원칙을 소개한 것으로 개발자가 시간이 지나도 유지 보수확장이 쉬운 시스템을 만들고자 할 때 이 원칙들을 적용할 수 있다고 소개한다.

그럼 바로 SOLID 원칙에 대해 살펴보자.

SRP(단일책임의 원칙: Single Responsibility Principle)

한 클래스는 하나의 책임만 가져야 한다.

로버트 마틴은 책임변경하려는 이유로 정의하고 어떤 클래스나 모듈을 변경하려는 단 하나의 이유만을 가져야 한다고 결론을 짓는다.

예시 (feat. chat gpt)

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
36
37
38
39
40
41
// Bad Example
class User {
  private name: string;
  private email: string;

  constructor(name: string, email: string) {
    this.name = name;
    this.email = email;
  }

  // 사용자 정보를 반환하는 메서드
  getInfo() {  
    return `Name: ${this.name}, Email: ${this.email}`;
  }

  // 데이터베이스에 사용자 정보를 저장하는 메서드
  saveToDatabase() {
    console.log('Saving to database:', this.getInfo());
  }
}

// Good Example
class User {
  private name: string;
  private email: string;

  constructor(name: string, email: string) {
    this.name = name;
    this.email = email;
  }

  getInfo() {
    return `Name: ${this.name}, Email: ${this.email}`;
  }
}
// 사용자 정보를 저장하는 책임을 UserRepository Class 로 분리해주었다.
class UserRepository {
  saveToDatabase(user: User) {
    console.log('Saving to database:', user.getInfo());
  }
}

Bad Example 에선 User 클래스에 사용자 정보 반환과 데이터 베이스에 저장하는 함수가 같은 클래스에 포함되어 있다. 이를 UserRepository Class를 만들어 각 클래스가 하나의 책임을 가지도록 하여 SRP를 지킬 수 있는 코드로 수정할 수 있다.

OCP(개방-폐쇄 원칙: Open/Closed principle)

소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.

개발 중 하나를 수정할 때 그 모듈을 이용하는 다른 모듈을 줄줄이 고쳐야 한다면 이와 같은 프로그램은 수정하기가 어렵다. 개방-폐쇄원칙이 잘 적용된다면, 기능을 추가하거나 변경해야 할 때 이미 동작하고 있던 원래 코드를 변경하지 않아도 기존 코드에 새로운 코드를 추가함으로써 기능의 추가나 변경이 가능하다.

예시 (feat. chat gpt)

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// Bad Example
class Rectangle {
  private width: number;
  private height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  getWidth(): number {
    return this.width;
  }

  getHeight(): number {
    return this.height;
  }
}

// 정사각형의 경우 가로, 세로 길이가 같아야 함
class Square extends Rectangle {
  constructor(side: number) {
    super(side, side);
  }
}

// Good Example
interface Shape {
  area(): number;
}

class Rectangle implements Shape {
  private width: number;
  private height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  area(): number {
    return this.width * this.height;
  }
}

class Square implements Shape {
  private side: number;

  constructor(side: number) {
    this.side = side;
  }

  area(): number {
    return this.side * this.side;
  }
}

OCP를 위반한 예시에서는 Square 클래스가 Rectangle 클래스를 상속하면서 setWidthsetHeight 메서드를 오버라이딩하여 정사각형의 특성에 맞게 변경해야 하는데, Square(정사각형)의 경우 두 변이 같다는 특성이 있기 때문에 Rectangle을 그에 맞게 변경해야 합니다. 이는 확장에 열려있지만 수정에는 닫혀 있지 않다고 볼 수 있습니다.

반면 OCP를 준수한 예시에서는 Shape 인터페이스를 도입하여 각 도형이 area 메서드를 구현하도록 하여, 새로운 도형이 추가되더라도 해당 도형에 대한 확장이 가능하고, RectangleSquare 클래스는 수정되지 않습니다.

LSP(리스코프 치환 원칙: Liskov substitution principle)

프로그램에서 자료형 S가 자료형 T의 서브 타입이라면 필요한 프로그램의 속성의 변경 없이 자료형 T의 객체를 자료형 S의 객체로 교체할 수 있어야 한다. 즉 상위 타입의 객체를 하위 타입의 객체로 교체해도 프로그램의 의미나 동작이 변하지 않아야 한다는 원칙.

이는 바로 예시를 통해 살펴 보자

예시 (feat. chat gpt)

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
// Bad Example
class Bird {
  fly() {
    console.log('Flying');
  }
}

class Penguin extends Bird {
  // 펭귄은 날지 못함
  fly() {
    throw new Error('Penguins cannot fly');
  }
}

// Good Example
interface Flyable {
  fly(): void;
}

class Bird implements Flyable {
  fly() {
    console.log('Flying');
  }
}

class Penguin implements Flyable {
  // 펭귄은 날지 못하지만 LSP를 준수하기 위해 메서드를 구현
  fly() {
    console.log('Sorry, I cannot fly');
  }
}

LSP를 준수한 예시에서는 Bird 클래스와 Penguin 클래스가 Flyable 인터페이스를 구현한다. 이로써 Penguinfly 메서드를 구현하면서 예외를 던지지 않고, 상위 타입인 Bird의 메서드를 대체할 수 있습니다. 프로그램의 의미나 동작이 변하지 않으면서도 하위 타입인 Penguin이 상위 타입인 Bird로 교체될 수 있게 된다.

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

특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.

인터페이스 분리 원칙은 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 하는 원칙이다.

예시 (feat. chat gpt)

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
36
37
38
39
40
41
// Bad Example
interface Worker {
  work(): void;
  eat(): void;
}

class SuperWorker implements Worker {
  // SuperWorker는 일하는 동안에도 먹을 수 있음
  work() {
    console.log('SuperWorker is working');
  }

  eat() {
    console.log('SuperWorker is eating');
  }
}

// Good Example
interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

class Worker implements Workable {
  work() {
    console.log('Worker is working');
  }
}

class SuperWorker implements Workable, Eatable {
  work() {
    console.log('SuperWorker is working');
  }

  eat() {
    console.log('SuperWorker is eating');
  }
}

ISP를 준수한 예시에서 Workable 인터페이스와 Eatable 인터페이스를 분리함에 따라 일하면서 먹지 않는 Worker 클래스와 먹으면서 일할 수 있는 SuperWorker 클래스로 좀 더 명확하게 나눌 수 있게 된다. 이로써 클라이언트는 사용하지 않을 수 있는 eat 메서드에 의존할 필요가 없게 되며, 필요한 기능에만 의존할 수 있게 된다.

DIP(의존 역전 원칙: Dependency Inversion Principle)

개발자는 추상화에 의존해야지, 구체화에 의존하면 안된다.

이는 바로 예시를 통해 살펴 보자

예시 (feat. chat gpt)

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// Bad Example
class LightBulb {
  turnOn() {
    console.log('LightBulb is ON');
  }

  turnOff() {
    console.log('LightBulb is OFF');
  }
}

class Switch {
  private lightBulb: LightBulb;

  constructor(lightBulb: LightBulb) {
    this.lightBulb = lightBulb;
  }

  operate() {
    if (this.lightBulb.isOn()) {
      this.lightBulb.turnOff();
    } else {
      this.lightBulb.turnOn();
    }
  }
}


// Good Example
interface Switchable {
  turnOn(): void;
  turnOff(): void;
}

class LightBulb implements Switchable {
  turnOn() {
    console.log('LightBulb is ON');
  }

  turnOff() {
    console.log('LightBulb is OFF');
  }
}

class Switch {
  private switchable: Switchable;

  constructor(switchable: Switchable) {
    this.switchable = switchable;
  }

  operate() {
    if (this.switchable.isOn()) {
      this.switchable.turnOff();
    } else {
      this.switchable.turnOn();
    }
  }
}

Bad Example 에서 Switch 클래스는 LightBulb 클래스에 직접 의존하고 있다. 만약 다른 종류의 스위치 가능한(Switchable) 장치가 추가된다면, Switch 클래스는 수정되어야 할 것이다.

Good Example 에서 Switch 클래스는 Switchable 인터페이스에 의존하고 있다. 이 인터페이스는 turnOnturnOff 메서드를 선언하고 있어서 어떤 장치라도 이 인터페이스를 구현하면 된다. 이로써 Switch 클래스는 구체적인 구현에 의존하지 않고, 추상화된 인터페이스에만 의존하게 되고 따라서 새로운 Switchable 장치가 추가되어도 Switch 클래스를 수정할 필요가 없다.

마치며

면접 준비를 하면서 봤던 키워드 중 하나인 SOLID 원칙에 대해 정리해보았다. 지금까지 프론트엔드 개발을 하면서 React의 함수 컴포넌트를 사용했기 때문에 객체 지향 프로그래밍에 대해 크게 생각을 안해봤던 거 같은데 이번 기회에 정리함으로써 개념 정도는 파악이 된 것 같다. 객체 지향 프로그래밍 경험이 많지 않아 chat gpt 의 도움을 받아 예시를 만들었지만, 이 원칙을 잘 기억해두고 있다가 개인 프로젝트든 업무를 하게 될 떄든 써먹게 된다면 좀 더 높은 수준의 예시로 수정을 하고 싶다.

그리고 SOLID 원칙이 객체 지향 프로그램에서 나온 원칙이지만 이를 프론트엔드 개발자의 관점으로 바라보는 글들도 꽤 많이 보였다. 예를 들어 SRP 같은 경우 클린 코드에서 본 거 같던 “함수는 한 가지 작업을 수행해야 한다” 와도 크게 다르지 않을 거 같고 React 에서 컴포넌트를 설계할 때도 적용할 수 있을 거 같았다.

그래서 SOLID 원칙을 프론트엔드 관점에서 보는 것을 따로 포스팅 주제로 삼아도 좋을 거 같다는 생각이 들었다.

참고

위키: SOLID (객체 지향 설계)

인기 태그
바로가기
인기 태그