Skip to content

SOLID - Open-Closed Principle #66

@simoniful

Description

@simoniful

정의

SOLID라 불리는 아키텍쳐 원칙 중 두 번째 글자에 해당하는 원칙
Bertrand Meyer의 Object-Oriented Software Construction 저서에서 언급하며 시작되었고
그러다, 1996년 Robert C. Martin이 “The Open-Closed Principle”에서 이 접근 방법에 관해 설명하면서 더 유명해졌다

소프트웨어 개체(artifact, entity) - 클래스 / 모듈 / 함수는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다

어떤 개체(entity)의 일부분을 변경한 것이, 개체에 의존하는 모듈들의 단계적인 변경을 불러일으킨다면
설계가 경직성을 가지는 걸 인지해야한다
이는 사이드 이펙트가 일어나기 쉬운 상태다

OCP가 잘 적용된 경우에는 동일한 종류의 변경이 필요할 때,
잘 동작하던 기존 코드를 수정하는 것이 아니라 새로운 코드를 덧붙이는 것으로 작업을 완료할 수 있게 된다

이를 위해 추상화된 인터페이스(protocol)를 선언하고, 구체적인 concrete를 구현하며
해당 모듈(컴포넌트)를 사용하는 코드는 구체화된 클래스 인스턴스에 의존하지 않고 protocol만 사용하여 동작

변경을 최소화하려면?

  • 서로 다른 목적으로 변경되는 요소를 적절하게 분리한다 (SRP, 단일 책임 원칙)
    • 목적 : 변경의 이유, Actor의 요구사항
    • 요소 : 클래스의 내용
  • 변경되는 요소 사이의 의존성을 체계화한다 (DIP, 의존성 역전 원칙)

어떤 부분을 open 시키고 어떤 부분을 close 시킬 것인가?

어떤 부분을 추상화 시키고, 어떤 부분을 구체화시킬 것인가?

명확하게 판단되지 않을 때는 변경이 없다고 가정하고 개발 - 유연성 < 단순성
변경이 발생할 때, 원인과 이에 대한 결과(사이드이펙트)를 고려하고
변경이 잦게 유발된다고 하면 enum에서 protocol로 확장하도록 구성

종류의 추가가 아닌 인터페이스 변화가 잦아서 해당 메서드의 추가가 엄청나다면
close 시켜야 하는 위치를 잘못 잡은 경우이기에 책임의 분리를 다시 고려
수행할 인터페이스들을 구체화시키고 분리하고 다시금 묶는 과정을 생각해보아야 한다

사고 실험

image

SRP로 변경을 최소화하기 위하여
보고서 생성을 두 개의 책임으로 분리할 수 있다
하나는 보고서용 데이터를 계산
나머지는 웹, 종이로 데이터를 보여주기 적합한 형태로 표현

DIP를 통하여 두 개의 책임 중 하나에서 변경 발생 시, 다른 하나는 변경되지 않도록 의존성 조직화
OCP를 준수하기 위해 새로 조직한 구조에서는 행위 확장에 대해서 다른 부분으로 변경이 발생하지 않도록 보장
처리 과정을 클래스 단위로 분할하고,
클래스는 컴포넌트 단위로 분리한다

image

  • ﹦ (이중선) : 컴포넌트 단위
    • 화살표와 오직 한 방향으로만 교차한다 (컴포넌트 관계는 단방향으로만 이루어진다.)
  • → (열린 화살표) : 사용
    • A → B: A가 B를 호출한다
  • ⇾ (닫힌 화살표) : 구현 혹은 상속
    • A ⇾ B: A가 B를 상속(혹은 인터페이스를 채택)하여 수직(혹은 수평)확장을 한다

화살표는 변경으로부터 보호하려는 컴포넌트를 향하도록 그려진다
View에서 발생한 변경으로부터 Presenter를 보호하고자 한다
Presenter에서 발생한 변경으로부터 Controller를 보호하고자 한다
Interactor는 다른 모든 것에서 발생한 변경으로부터 보호하고자 한다

image

컴포넌트 계층구조를 이와 같이 조직화하면 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있다

인터페이스의 목적

1) 방향성 제어 (의존성 역전)

FinancialReportGenerator → FinancialDataGateway<I> ⇽ FinancialDataMapper
만약 FinancialDataGateway<I>가 없었다면
의존성이 Interactor 컴포넌트에서 Database 컴포넌트로 바로 향하게 된다

FinancialReportController → FinancialReportPresenter<I> ⇽ ScreenPresenter / PrintPresenter
만약 FinancialReportPresenter<I>가 없었다면
의존성이 Controller 컴포넌트에서 ScreenPresenter / PrintPresenter 컴포넌트로 바로 향하게 된다

2) 정보 은닉

FinancialReportRequester
Interactor 내부에 대해 너무 많이 알지 못하도록 막기 위해서 존재한다
없을 경우, Controller는 FinancialEntities에 대해 Transitive Dependency 를 가지게 된다

Transitive Dependency는
클래스 A가 클래스 B에 의존성을 가지고, 다시 클래스 B가 클래스 C에 의존성을 가지는 경우
클래스 A가 클래스 C에 의존성을 가지게 되는 계층적 의존성을 뜻한다

이럴 경우 소프트웨어 엔티티는 ‘자신이 직접 사용하지 않는 요소에는 절대로 의존해서는 안 된다’는 소프트웨어 원칙을 위반하게 된다

SOLID Principles Applied To Swift에서 예시

아래 예시는 간소화된 형태의 데이터 형식 추가의 상황을 가정하여
if / switch를 극도로 제한하는 코딩을 위한 예시를 보여준다
먼저 Logger 클래스는 Cars 배열을 순회하며 각각의 세부 사항을 출력한다

class Logger {
    func printData() {
        let cars = [
            Car(name: "Batmobile", color: "Black"),
            Car(name: "SuperCar", color: "Gold"),
            Car(name: "FamilyCar", color: "Grey")
        ]

        cars.forEach { car in
            print(car.printDetails())
        }
    }
}

class Car {
    let name: String
    let color: String

    init(name: String, color: String) {
        self.name = name
        self.color = color
    }

    func printDetails() -> String {
        return "I'm \(name) and my color is \(color)"
    }
}

새로운 클래스의 세부 정보도 인쇄할 수 있는 가능성을 추가하려면
새 클래스를 기록할 때마다 printData 구현을 변경해야 하는 매우 경직된 구현이다

class Logger {
    func printData() {
        let cars = [
            Car(name: "Batmobile", color: "Black"),
            Car(name: "SuperCar", color: "Gold"),
            Car(name: "FamilyCar", color: "Grey")
        ]

        cars.forEach { car in
            print(car.printDetails())
        }

        let bicycles = [
            Bicycle(type: "BMX"),
            Bicycle(type: "Tandem")
        ]

        bicycles.forEach { bicycles in
            print(bicycles.printDetails())
        }
    }
}

class Car {
    let name: String
    let color: String

    init(name: String, color: String) {
        self.name = name
        self.color = color
    }

    func printDetails() -> String {
        return "I'm \(name) and my color is \(color)"
    }
}

class Bicycle {
    let type: String

    init(type: String) {
        self.type = type
    }

    func printDetails() -> String {
        return "I'm a \(type)"
    }
}

새로운 프로토콜 Printable을 생성하여 문제를 해결할 수 있으며,
이것은 기록을 필요로 하는 각각의 클래스에 의해 구현
마지막으로 printData는 Printable 배열을 인쇄하도록 한다

이러한 방식으로, 우리는 printData와 기록할 클래스 사이에 새로운 추상 레이어를 생성하여,
printData 구현을 변경하지 않고 Bicycle 같은 다른 클래스를 인쇄하도록 만들 수 있다

protocol Printable {
    func printDetails() -> String
}

class Logger {
    func printData() {
        let cars: [Printable] = [
            Car(name: "Batmobile", color: "Black"),
            Car(name: "SuperCar", color: "Gold"),
            Car(name: "FamilyCar", color: "Grey"),
            Bicycle(type: "BMX"),
            Bicycle(type: "Tandem")
        ]

        cars.forEach { car in
            print(car.printDetails())
        }
    }
}

class Car: Printable {
    let name: String
    let color: String

    init(name: String, color: String) {
        self.name = name
        self.color = color
    }

    func printDetails() -> String {
        return "I'm \(name) and my color is \(color)"
    }
}

class Bicycle: Printable {
    let type: String

    init(type: String) {
        self.type = type
    }

    func printDetails() -> String {
        return "I'm a \(type)"
    }
}

베어코드 예시

enum Vehicle {
    case sedan
    case sportUtility
}

let type = Vehicle.sedan
var text = "이 자동차는 "

if type == .sedan {
	text += "세단"
} else if type == .sportUtility {
	text += "SUV"
}

text += "이므로 요금은 "

if type == .sedan {
	text += "만원"
} else if type == .sportUtility {
	text += "만오천원"
}

text += "입니다."
print(text)

분기문에 의존한 로직 분리

enum Vehicel {
    case sedan
    case hatchback
    case sportUtility
    
    var readableName: String {
    	switch self {
        case .sedan:
            return "세단"
        case .hatchback:
            return "해치백"
        case .sportUtility:
            return "SUV"
        }
    }
    
    var chargeText: String {
    	switch self {
        case .sedan:
            return "만원"
        case .hatchback:
            return "칠천원"
        case .sportUtility:
            return "만오천원"
        }
    }

    func run() {
    	switch self {
        case .sedan:
        	print("여기에서 세단에 대한 무엇인가 복잡한 동작 코드가 실행됨")
        case .hatchback:
        	print("여기에서 해치백에 대한 무엇인가 복잡한 동작 코드가 실행됨")
        case .sportUtility:
        	print("여기에서 SUV에 대한 무엇인가 복잡한 동작 코드가 실행됨")
        }
    }
}

let type = Vehicle.hatchback
var text = "이 자동차는 " + type.readableName + "이므로 요금은" + type.chargeText + "입니다."
print(text)
type.run()

case 추가와 switch 문으로 인하여 점점 더 비대해지는 로직 분량

// protocol 혹은 super class 활용 
protocol Vehicle { 
    var readableName: String { get }
    var chargeText: String { get }
    func run()
}

class Sedan: Vehicle {
    var readableName: String { get }
    var chargeText = "만원"
    func run() {
    	print("여기에서 세단에 대한 무엇인가 복잡한 동작 코드가 실행됨"0
    }
}

class Hatchback: Vehicle {
    var readableName = "해치백"
    var chargeText = "칠천원"
    func run() {
    	print("여기에서 해치백에 대한 무엇인가 복잡한 동작 코드가 실행됨")
    }
}

class sportUtility: Vehicle {
    var readableName = "SUV"
    var chargeText = "만오천원"
    func run() {
    	print("여기에서 SUV에 대한 무엇인가 복잡한 동작 코드가 실행됨")
    }
}

let type: Vehicle = SportUtility()
var text = "이 자동차는 " + type.readableName + "이므로 요금은" + type.chargeText + "입니다."
print(text)
type.run()

하지만, 실제로 Swift에서 구성하는 MVVM, VIPER 등 보다 복잡한 아키텍쳐 단위에서의 구성에 있어서 고민해보아야 한다
먼저, SRP에 의거한 역할 분리를 도식화가 필요하다
가장 우선순위가 높은 Entity의 Model struct 작업 및 Interactor 컴포넌트를 구성한다

처리 과정을 useCase, service, repository 등 프로토콜 기반의 추상적인 인터페이스를 활용하고
구체화한 concrete 들을 사용하여 클래스 단위로 분할하고, 다시금 컴포넌트 단위로 구분 한다
컴포넌트 우선순위의 고려와 단방향성 유지하며
각 컴포넌트 간의 연결과 보호를 위한 의존성 조직화와 은닉을 적절히 활용하도록 설계를 구성해보아야 한다

또한, 너무 상속 기반의 여러 구현체를 구성하는 경우 불필요한 복잡성을 증가시키는 경우가 발생하기에
상황에 맞는 케이스 정도와 구현을 생각하면서 적용을 선택해야한다

결론

많은 디자인 패턴을 통해서 OCP를 지키는 클래스들의 모습을 확인 가능하다

  • Abstract Factory: 새로운 factory를 추가하기 위한 abstract class를 상속 받아 Concrete를 하나 추가
  • Command: 새로운 동작을 abstract class를 상속 받아 Concrete를 하나 추가
  • State: 새로운 state를 상속 받아 하나 추가
  • Strategy: 새로운 구현체를 하나 만들어서 알고리즘을 작성
    나머지 SOLID의 경우도 OCP로 회귀되는 경우가 많다

시스템의 아키텍쳐를 떠받치는 원동력 중 하나다
시스템을 확장은 쉽게, 변경으로인한 너무 많은 영향은 받지 않도록 구성하는 데 목적을 둔다
목적 달성을 위한 컴포넌트 단위의 분리와
저수준 컴포넌트에서 발생한 변경에서 고수준의 컴포넌트를 보호할 수 있는 형태의 의존성 계층구조 구성이 중요하다

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions