[OOP] SOLID Princple

gaeng2y
13 min readSep 15, 2023

--

와 안녕하세요? 진짜 오랜만이네요… 감금 블로그단도 휴무 상태이고 그 동안 너무 개같이 놀아서 이제 진짜 인간다운 삶을 살기 위해 일을 벌려 보았습니다…

  1. 물리미 업데이트
  2. 스코클 퀴즈 프로젝트
  3. 디자인 패턴 스터디
  4. Swift Concurrency 스터디

이렇게 네 개나 일을 벌려버리고 말았습니다… (그 동안 놀았으니까),,, 이제 해야죠,,,

그래서 오늘은 3번 디자인 패턴 스터디를 진행하면서 Foundation 격인 OOP의 SOLID 원칙에 대해 공부해본 내용을 적어보려고 합니다…

SOLID란?

제가 아는 솔리드는 이건데…

롸…?

틀니 압수하고,,,

이제 진짜 시작해보겠습니다.

SOLID 원칙이란 로버트 C 마틴(밥아저씨 또 당신이야…?)이 2000년대 초반에 명명한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙을 마이클 페더스가 두문자 기억술로 소개한 것입니다.

그래서 그 다섯 가지가 뭐냐?

  1. SRP: 단일 책임 원칙(Single Responsibility Principle)
  2. OCP: 개방 폐쇄 원칙(Open-Closed Principle)
  3. LSP: 리스코프 치환 원칙(Liskov substitution principle)
  4. ISP: 인터페이스 분리 원칙(Interface Segregation Principle)
  5. DIP: 의존관계 역전 원칙(Dependency Inversion Principle)

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

💡 클래스를 변경해야 하는 이유는 단 하나여야 한다.

위키를 살펴보면 모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 함을 일컫는다고 되어있다.

하나의 책임이라고 하니 관심사 분리(SoC: Separation of Concerns)가 생각이 나는데요.

관심사 분리란 컴퓨터 프로그램을 별개의 섹션으로 분리하기 위한 설계 원칙입니다.

여기서 제가 생각하는 두 원칙의 차이점은 생각하는 시점이라고 생각합니다.

SOC는 새로운 것을 시작하거나 리팩토링을 할 때 생각하는 원칙이라면

SRP는 사후에 적용되는 원칙이다. 클래스를 작성한 후 “이게 너무 많은 일을 하나?”라는 생각을 하게되죠.

간단한 예를 살펴본다면

class Animal {
var name: String

func getAnimalName() { }
func saveAnimal(with animal: Animal) { }
}

위의 구조체에서 살펴보면 Animal 구조체는 SRP 원칙에 위배되었습니다.

어떻게 위배되었는지 살펴보면

  1. Animal의 name을 가져오는 역할
  2. Animal을 DB에 저장하는 역할

두 가지의 역할을 가지고있습니다.

그렇다면 위배되지 않게 변경한다면

class Animal {
var name: String

init(name: String) {}
func getAnimalName() { }
}

class AnimalDB {
func getAnimal(name: String) -> Animal {}
func saveAnimal(with animal: Animal) {}
}

와 같이 DB에 저장하는 역할을 하는 구조체를 새로 만들어줘서 각 클래스가 단 하나의 책임을 갖도록 만들었습니다.

2. OCP: 개방 폐쇄 원칙(Open-Closed Principle)

💡 클래스는 확장에 대해서는 개방적이고, 수정에 대해서는 폐쇄적이어야 한다.

다형성과 확장을 가능케 하는 객체지향의 장점을 극대화하는 설계 원칙으로 객체를 추상화함으로써, 확장엔 열려있고, 변경엔 닫혀있는 유연한 구조를 만드는 것이다.

class Animal {
var name: String

init(name: String) {}
func getAnimalName() { }
}

class MakeSound {
func makeSound(with animal: Animal) {
if animal.name == "Cat" {
print("야옹")
} else if animal.name == "Dog" {
print("멍멍")
} else if animal.name == "Lion" {
print("어흥")
}
}
}

func main() {
let dog = Animal(name: "Dog")
let cat = Animal(name: "Cat")
let lion = Animal(name: "Lion")

let makeSound = MakeSound()
makeSound.makeSound(with: dog)
makeSound.makeSound(with: cat)
makeSound.makeSound(with: lion)
}

위와 같은 코드에서 양이 추가된다면 makeSound(with:) 메소드에서 분기문을 추가적으로 구성해줘야한다.

이런 식으로 코드를 구성하면, 동물이 추가될 때마다 코드를 변경해줘야하는 번거로움이 생긴다.

이는 설계가 잘못되었기 때문에 일어나는 현상이다.

OCP 설계 원칙에 따라 적절한 추상화 클래스(프로토콜)을 구성하고 이를 채택하여 구현하는 관계로 구성하면 변경에는 닫히고 확장에는 열려있는 클래스가 된다.

  1. 먼저 변경(확장)될 것과 변하지 않을 것을 엄격히 구분한다.
  2. 이 두 모듈이 만나는 지점에 추상화를 정의한다.
  3. 구현체에 의존하기보다 정의한 추상화에 의존하도록 코드를 작성 한다.
protocol Animal {
func makeSound()
}

class Dog: Animal {
func makeSound() { print("멍멍") }
}

class Cat: Animal {
func makeSound() { print("야옹") }
}

class Lion: Animal {
func makeSound() { print("어흥") }
}

class MakeSounder() {
func playSound(with animal: Animal) { animal.makeSound() }
}

let dog = Dog()
let cat = Cat()
let lion = Lion()

let sounder = MakeSounder()
sounder.playSound(with: dog) // 멍멍
sounder.playSound(with: cat) // 야옹
sounder.playSound(with: lion) // 어흥

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

💡 자식 타입은 부모 타입으로 교체할 수 있어야 한다.

리스코프 치환 원칙은 1988년 바바라 리스코프(Barbara Liskov)가 올바른 상속 관계의 특징을 정의하기 위해 발표한 것으로, 서브 타입은 언제나 기반 타입으로 교체할 수 있어야 한다는 것을 뜻한다.

교체할 수 있다는 말은, 자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위는 수행이 보장되어야 한다는 의미이다.

이것을 부모 클래스와 자식 클래스 사이의 행위가 일관성이 있다고 말한다. (다형성)

import Foundation

class Shape {
func doSomething() {}
}

class Square: Shape {
func drawSquare() {}
}

class Circle: Shape {
func drawCircle() {}
}

func draw(shape: Shape) {
if let square = shape as? Square {
square.drawSquare()
} else if let circle = shape as? Circle {
circle.drawCircle()
}
}

위 예시는 LSP를 위반한 대표적인 예시다.

Squre, Circle 객체가 파라미터로 받은 상위 객체 Shape 와 전혀 다르게 동작하고 있기 때문이다.

그렇다면 고쳐보자.

import Foundation

protocol Shape {
func draw() {}
}

class Square: Shape {
func draw() {}
}

class Circle: Shape {
func draw() {}
}

func draw(shape: Shape) {
shape.draw()
}

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

💡 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.

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

import Foundation

class Document { }


protocol Machine {
func print(d: Document)
func scan(d: Document)
func fax(d: Document)
}

class MFP : Machine {
func print(d: Document)
//
}

enum NoRequiredFunctionality : Error {
case doesNotFax
}

class OldFashionedPrinter: Machine {
func print(d: Document) { }
func fax(d: Document) throws {
throw NoRequiredFunctionality.doesNotFax
}
}

위 예제에서는 Machineprint(d:) scan(d:) fax(d:) 이 모두 있기 때문에 MFP, OldFashionedPrinter 에서는 준수하는 메소드를 사용할 수 없기 때문에 ISP에 위배 되기 때문에

protocol Printer {
func print(d: Document)
}

protocol Scanner {
func scan(d: Document)
}

protocol Fax {
func fax(d: Document)
}

class OrdinaryPrinter : Printer {
func print(d: Document) { }
}

class Photocopier : Printer, Scanner {
func print(d: Document) { }

func scan(d: Document) {}
}

protocol MultiFunctionDevice : Printer, Scanner, Fax { }

class MultiFunctionMachine : MultiFunctionDevice {
let printer: Printer
let scanner: Scanner

init(printer: Printer, scanner: Scanner) {
self.printer = printer
self.scanner = scanner
}

func print(d: Document) {
printer.print(d: d) // Decorator
}
}

위와 같이 출력하는 역할을 가진 프로토콜, 스캔하는 역할을 가진 프로토콜, 팩스를 보내는 역할을 하는 프로토콜로 나누면서 역할 인터페이스로 만든다.

5. DIP: 의존관계 역전 원칙(Dependency Inversion Principle)

DIP는 다음과 같은 정의를 가지고 있다.

  1. 상위 모듈은 하위 모듈에 의존해서는 안된다.
  2. 추상화는 세부 사항에 의존해서는 안된다.

OCP와 LSP의 합체라고 해야되나…?

클라이언트가 상속 관계로 이루어진 모듈을 가져다 사용할 때, 하위 모듈을 직접 인스턴스를 가져다 쓰지 말라는 뜻이다. 하위 모듈에 변경이 있을 때마다 클라이언트나 상위 모듈의 코드를 자주 수정해야 하기 때문이다.

상위의 인터페이스 타입의 객체로 통신하라는 원칙이다/

import Foundation

// hl modules should not depend on low-level; both should depend on abstractions
// abstractions should not depend on details; details should depend on abstractions

enum Relationship {
case parent
case child
case sibling
}

class Person {
var name = ""
// dob, etc
init(_ name: String) {
self.name = name
}
}

protocol RelationshipBrowser {
func findAllChildrenOf(_ name: String) -> [Person]
}

class Relationships : RelationshipBrowser {
var relations = [(Person, Relationship, Person)]()

func addParentAndChild(_ p: Person, _ c: Person) {
relations.append((p, Relationship.parent, c))
relations.append((c, Relationship.child, p))
}

func findAllChildrenOf(_ name: String) -> [Person] {
return relations
.filter({$0.name == name && $1 == Relationship.parent && $2 != nil})
.map({$2})
}
}

class Research {
init(_ relationships: Relationships) {
// high-level: find all of job's children
let relations = relationships.relations
for r in relations where r.0.name == "John" && r.1 == Relationship.parent {
print("John has a child called \(r.2.name)")
}
}

init(_ browser: RelationshipBrowser) {
for p in browser.findAllChildrenOf("John") {
print("John has a child called \(p.name)")
}
}
}

func main() {
let parent = Person("John")
let child1 = Person("Chris")
let child2 = Person("Matt")

var relationships = Relationships()
relationships.addParentAndChild(parent, child1)
relationships.addParentAndChild(parent, child2)

let _ = Research(relationships)
}

main()

--

--