Clover
article thumbnail
Published 2023. 4. 10. 23:11
[객체 지향] SOLID 원칙 CS & Engineering

 

 

개발을 혼자서 할 때 가장 막막한 부분은 아무래도 설계에 대한 부분인 것 같다.

"~~ 하는 기능을 만드는 방법"은 구글링이나 GPT 선생님에게 질문을 통해서 충분히 해답을 얻어낼 수 있지만, 그 해답들을 내 코드에 어떻게 녹여내는 것이 가장 좋은 방법인가에 대한 해답을 구하는 것은 구글에 대고 물어본다고 될 일이 아니니, 오롯이 내 몫이다.

 

설계에 대한 고민을 많이 하면 할수록 나는 객체 지향 프로그래밍에 대한 기본 지식이나 소프트웨어 공학적인 기반이 너무 약하다는 생각이 계속 든다.

클린 아키텍쳐, MVC, MVVM 같은 유명한 키워드로 검색하여 나오는 글들을 제대로 이해하는 것이 너무 어렵거나, 훑듯이 짚고 넘어가는 데에도 시간이 정말 오래 걸린다.

그래서 좀 더 근본적인 내용부터 자세하게 천천히 공부해야 할 필요성을 피부로 느끼는 요즘이고, 이번엔 그 시작인 SOLID 원칙에 대해서 공부를 해보았다.

 

 

우선 노션AI에 SOLID 원칙에 대해 설명해 달라고 물어봤다.

 

- 나 : SOLID 원칙에 대해 설명해 줘.

- 노션 AI :


SOLID 원칙은 객체 지향 프로그래밍에서 좋은 소프트웨어 설계를 위해 나온 원칙입니다. SOLID의 약자는 다음과 같습니다.

S: Single Responsibility Principle (단일 책임 원칙)
O: Open-Closed Principle (개방-폐쇄 원칙)
L: Liskov Substitution Principle (리스코프 치환 원칙)
I: Interface Segregation Principle (인터페이스 분리 원칙)
D: Dependency Inversion Principle (의존 역전 원칙)

각각의 원칙은 다음과 같이 설명됩니다.

단일 책임 원칙: 하나의 클래스는 하나의 책임만 가져야 합니다.
개방-폐쇄 원칙: 소프트웨어 개체는 확장 가능하지만 수정 가능성은 없어야 합니다.
리스코프 치환 원칙: 상위 타입의 객체를 하위 타입의 객체로 대체해도 프로그램은 일관성 있게 동작해야 합니다.
인터페이스 분리 원칙: 클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 됩니다.
의존 역전 원칙: 추상화된 것은 구체적인 것에 의존하면 안 됩니다. 구체적인 것이 추상화된 것에 의존해야 합니다.

이러한 원칙을 잘 따르면 코드의 가독성과 유지보수성이 높아지며, 확장성과 유연성도 높아집니다. SOLID 원칙을 기반으로 한 좋은 소프트웨어 설계는 오랜 시간에 걸쳐 유지보수와 확장에 대한 비용을 줄여줄 수 있습니다.

위키에서 긁어온 것 같은 대답을 줬다.

 

어쨌든, SOLID 각각의 원칙에 대한 깊은 이해를 위해 부가적인 설명을 직접 달아보고,

필요할 땐 Swift로 예시 코드를 만들어보면서 짚고 넘어가 보겠다.

 


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

<하나의 클래스(또는 함수, 구조체 등)는 하나의 책임만 가져야 합니다.>

 

여기서 말하는 책임"변경을 해야 하는 이유" 라고 바꿔 생각하는 게 더 이해하기 쉽다.

그러니까, 어떤 클래스를 변경해야 하는 이유를 생각해 봤을 때 이유가 두 가지 이상 생각난다면

그건 그 클래스에 책임이 많다는 의미인 것이다.

 

실생활에서, 책임이 많은 객체라고 말할 수 있는 게 뭐가 있을까.

나는 "다용도" 라는 단어가 먼저 떠올랐다. 다용도 공구, 다용도 계산기 등등..

 

Windows의 내장 계산기 앱은 [표준], [프로그래머용], [공학용] 등 여러 가지 계산기를 한 번에 지원하는 일종의 "다용도 계산기"이다.

사용자 지향적 관점에서는 앱 하나로 여러 가지 계산기 기능을 한 번에 사용할 수 있는 건 정말 편리하겠지만, SRP의 관점에서 바라보면 그 계산기 앱 하나에 여러 가지 계산기 기능이 몰려있으니 책임이 많아지게 된다는 것이다.

이는 프로그램의 유지보수 리소스를 증가시키는 요소가 될 수도 있고, 사이드 이펙트를 불러오는 원인이 되기도 할 것이다.

예를 들어, (이런 일이 벌어질 가능성은 없겠지만) 표준 계산기의 더하기 기능을 수정하다가 잘못 건드려서, 공학용 계산기의 계산 결과가 달라진다던지 하는 경우가 있을 수도 있다는 것이다.

 

추억의 바람의나라 계산기


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

<소프트웨어 개체는 확장 가능하지만 수정 가능성은 없어야 합니다.>

 

개방과 폐쇄라는 단어를 사용해서 다시 말하면, 확장에는 개방되어 있지만 수정에는 폐쇄되어 있어야 한다는 말이다.

객체 지향에서 말하는 추상화와 연관이 깊은 내용이다. 여러 클래스의 공통된 기능을 추상화하여서 상위 클래스나 프로토콜로 분리시킬 수 있고, 이는 앞으로 그 추상화된 클래스를 상속받는 하위 클래스를 보다 유연하게 관리할 수 있게 해 준다.

여기서 유연하다는 건, 하위 클래스에 관련한 어떤 변경사항이 생겼을 때 상위 클래스와 또 다른 하위 클래스의 코드를 수정하는 일 없이 변동 사항이 있는 클래스만 수정할 수 있다는 말이다.

 

추상화가 되어있지 않은 경우를 설명하기 위해,

상위 클래스인 IPhone 클래스와 IPhone 클래스를 상속받는 하위 클래스들을 만들어보겠다.

 

class IPhone {
    public var name: String?
    public var unlockCase: UnlockCase?
    
    init() { }
    
    enum UnlockCase {
        case fingerPrint
        case faceID
    }
    
    // 잠금해제
    public func unlock() {
        print("\(name!)의 잠금을 해제 합니다.")
        
        switch unlockCase! {
        case .fingerPrint:
            print("지문으로 잠금 해제.")
        case .faceID:
            print("Face ID로 잠금 해제.")
        }
    }
}

class IPhone8: IPhone {
    override init() {
        super.init()
        name = "iPhone 8"
        unlockCase = .fingerPrint
    }
}

class IPhone14: IPhone {
    override init() {
        super.init()
        name = "iPhone 14"
        unlockCase = .faceID
    }
}

var iPhone8 = IPhone8()
var iPhone14 = IPhone14()

iPhone8.unlock()
// iPhone 8의 잠금을 해제 합니다.
// 지문으로 잠금 해제.

iPhone14.unlock()
// iPhone 14의 잠금을 해제 합니다.
// Face ID로 잠금 해제.

 

구형 iPhone 시리즈는 잠금 해제를 할 때 지문 인식을 사용했지만, iPhone X 모델 이후부터는 Face ID 방식을 사용하고 있다.

어쨌든 모든 iPhone은 잠금 해제라는 동작을 공통적으로 수행해야 하니까,

위처럼 unlock 함수를 iPhone 클래스에 구현할 수도 있을 것이다.

 

하지만 미래 언젠가에, iPhone에도 홍채 인식 방식이 도입될 수도 있다고 가정해 보자.

그럼 아래처럼 IPhone 클래스를 수정해야 할 것이다.

 

class IPhone {
    public var name: String?
    public var unlockCase: UnlockCase?
    
    init() { }
    
    enum UnlockCase {
        case fingerPrint
        case faceID
        case iris  // 홍채 인식 case 추가.
    }
    
    // 잠금해제
    public func unlock() {
        print("\(name!)의 잠금을 해제 합니다.")
        
        switch unlockCase! {
        case .fingerPrint:
            print("지문으로 잠금 해제.")
        case .faceID:
            print("Face ID로 잠금 해제.")
        case .iris:
            print("홍채 인식으로 잠금 해제.")  // 홍채 인식 case 추가.
        }
    }
}

...
...

 

첫 번째로 "상위 클래스"인 IPhone 클래스에 수정 사항이 생겼다.

enum을 추가했고, unlock 함수에서도 추가 사항에 대한 대응을 하였다.

이는 홍채 인식 기능 추가라는 확장에 의해서, 상위 클래스를 수정으로부터 폐쇄시키지 못한 경우라고 볼 수 있다.

 

또한, IPhone8Plus 라는 지문 인식을 사용하는 새로운 객체가 추가되었을 때,

지문 인식 방식을 단독으로 채택하고 있었던 기존 IPhone8 클래스와 다른 부분이 생기게 되면

각각의 클래스에서 unlock 함수를 override 하여 각각 코드를 작성해주어야 하는 경우도 생길 수 있다.

 

class IPhone8: IPhone {
    override init() {
        super.init()
        name = "iPhone 8"
        unlockCase = .fingerPrint
    }
    
    // 추가 사항.
    override func unlock() {
        super.unlock()
        print("IPhone 8 에서만 동작해야 하는 지문 인식 모듈 호출")
    }
}

// 새로운 클래스
class IPhone8Plus: IPhone {
    override init() {
        super.init()
        name = "iPhone 8 Plus"
        unlockCase = .fingerPrint
    }
    
    override func unlock() {
        super.unlock()
        print("IPhone 8 Plus 에서만 동작해야 하는 지문 인식 모듈 호출")
    }
}

이렇게 기능적인 변동 사항이 발생했을 때, 상위 클래스의 코드 수정이 불가피한 경우는 OCP 원칙을 지키지 못했다고 볼 수 있다.

 

Swift는 이러한 문제를 피해 OCP 원칙을 준수하기 위해 protocol과 적절한 상속을 사용할 수 있다.

 

// MARK: IPhone 객체를 protocol로 추상화.
protocol IPhone {
    var name: String { get }
    var unlocker: Unlocker { get }
    func unlock()
}

// MARK: 잠금 해제 모듈의 공통 기능을 Unlocker 클래스로 추상화.
class Unlocker {
    func callUnlocker() {
        print("잠금 해제 공통 기능 호출")
    }
}

// MARK: Unlocker 클래스를 상속받는 각각의 잠금 해제 모듈들.
final class FingerPrint: Unlocker {
    override func callUnlocker() {
        super.callUnlocker()
        print("지문 인식 모듈 호출")
    }
}

final class FaceID: Unlocker {
    override func callUnlocker() {
        super.callUnlocker()
        print("Face ID 모듈 호출")
    }
}

final class Iris: Unlocker {
    override func callUnlocker() {
        super.callUnlocker()
        print("홍채 인식 모듈 호출")
    }
}

// MARK: IPhone protocol을 준수하는 클래스들.
class IPhone8: IPhone {
    var name: String
    var unlocker: Unlocker
    
    init() {
        self.name = "iPhone 8"
        unlocker = FingerPrint()  // 지문 인식 사용
    }
    
    func unlock() {
        print("\(name)의 잠금을 해제 합니다.")
        unlocker.callUnlocker()
    }
}

class IPhone14: IPhone {
    var name: String
    var unlocker: Unlocker
    
    init() {
        self.name = "iPhone 14"
        unlocker = FaceID()  // Face ID 사용
    }
    
    func unlock() {
        print("\(name)의 잠금을 해제 합니다.")
        unlocker.callUnlocker()
    }
}

var iPhone8: IPhone = IPhone8()
var iPhone14: IPhone = IPhone14()

iPhone8.unlock()
// iPhone 8의 잠금을 해제 합니다.
// 잠금 해제 공통 기능 호출
// 지문 인식 모듈 호출

iPhone14.unlock()
// iPhone 14의 잠금을 해제 합니다.
// 잠금 해제 공통 기능 호출
// Face ID 모듈 호출

 

위처럼 구현하면 앞으로 새로운 iPhone 모델이 출시되어도, IPhone protocol을 건드려야 하는 일은 거~의 없을 것이다.

(반드시 모든 iPhone에 공통으로 적용되어야 하는 것이 발생하지 않는다면..)

그저 IPhone protocol을 준수하는 새로운 IPhone~~ 클래스를 생성하기만 하면 될 뿐이다.

 

그리고 만약 또 다른 잠금 해제 방식이 추가된다 하더라도,

Unlocker 클래스를 상속받는 새로운 잠금 해제 Class를 생성하기만 하면 될 뿐이다.

 

이처럼 상위 객체들을 추상화시켜서 확장이 가능하지만 수정에는 폐쇄되어 있는 구조를 구현하여 OCP 원칙을 따를 수 있게 된다.

 

 


 

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

바바라 리스코프 라는 사람이 발표한 원칙이라서 리스코프 치환 원칙이다.

올바른 상속 관계는 하위 클래스가 언제나 상위 클래스로 교체될 수 있어야 한다는 것을 말한다.

 


q(x)를 자료형 T의 객체 x에 대해 증명할 수 있는 속성이라 하자.
그렇다면 S가 T의 하위형이라면, q(y)는 자료형 S의 객체 y에 대해 증명할 수 있어야 한다.

(출처: 위키백과)

 

무슨 말인지 도통 모르겠는데 한국말로 한번 풀어보자면,

 

하위(자식) 클래스는 상위(부모) 클래스를 상속받았기 때문에, 최소한 부모 클래스에서 구현한 기능은 온전한 수행을 보장해야 한다는 의미이고, 이는 부모 인스턴스에 자식 클래스를 할당하여도 정상적인 수행이 가능해야 한다는 말이기도 하다.

 

다시 한번 더 풀어서 한 문장으로 표현하자면, 부모 인스턴스에 자식 타입을 할당해도 프로그램이 정상 동작 하도록 하면 된다는 말이다.

 

그리고 바로 위의 OCP 설명란에서 작성한 IPhone 클래스가 이 원칙을 준수했다고 볼 수 있다.

 

// LSP 원칙 준수
var iPhone8: IPhone = IPhone8()
var iPhone14: IPhone = IPhone14()

iPhone8.unlock()
iPhone14.unlock()

변수 iPhone8과 iPhone14는 둘 다 IPhone 클래스 타입으로 선언되었다.

하지만 인스턴스를 할당 받을때는 각각 상속받아 구현한 자식 클래스를 받았다.

그럼에도 두 변수는 동일하게 unlock() 함수를 실행할 수 있고, 정상적인 동작을 수행한다.


Interface Sergregation Principle (ISP, 인터페이스 분리 원칙)

 

ISP, 인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다.

 

큰 덩어리의 인터페이스가 있다면, 이 인터페이스의 내부 기능들을 추상화 시켜 작은 단위로 쪼개 나가면서 이 원칙을 준수하도록 프로그램을 Refactoring 하는것이 ISP 원칙을 준수하는 방법이라고 할 수 있을 것 같다.

여기서 작은 단위는, 어떠한 행동 또는 역할과 같이 현실 세상에서 존재하는 모든 객체들의 행동 양식을 참고하여 설정할 수 있다.

다시 말하면, 클라이언트가 이용하지 않는 메서드가 상위 인터페이스에 존재하는 경우, 그 메서드를 하나의 행동으로 간주하고 이를 별도의 인터페이스로 분리 시키는 것이다.

 

대표적인 예제로는 도서 [Head First Design Patterns]에서 맨 처음에 전략 패턴을 설명할 때 등장하는

오리 시뮬레이션 프로그램에 대한 디자인 예제가 있다.

 

 

 

 

[오리] 라는 최상위 클래스를 먼저 구현하고,

이를 상속받는 여러 종류의 오리들을 구현하면 코드의 중복을 줄일 수 있을 것이라는 아이디어에서 시작한 설계이다.

 

그런데 위의 그림에 표시해놓은 메서드 들을 보면,

- 고무 오리는 날 수 없지만 fly() 메서드를 상속받고 있고

- 나무 오리는 나는건 커녕 소리도 못내고, 수영도 할 수 없지만 quack() 메서드와 swim() 메서드를 상속받고 있다.

 

위와 같은 상속 구조를 사용하게 되면, 하위 클래스(클라이언트)에 불필요한 동작이 강제로 상속이 되어버린다.

그리고 이는 ISP 원칙을 위반하게 되었다고 말할 수 있다.

 

이런 상황에 유연하게 대처하기 위해 클래스의 행동 단위를 작게 쪼개어 이를 별도의 Interface로 분리하고,

하위 클래스에서 각자 필요한 인터페이스를 구현해 나가는 방식으로 설계하면 결과적으로 자신이 이용하지 않는 메서드를 강제로 상속받으며 의존하게 되는 일이 사라지게 되는 것이다.

정신없음


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

DIP, 의존 역전 원칙은 추상화된 클래스(또는 인터페이스)가 구체화된 객체에 의존하지 않아야 하고, 구체화된 객체가 추상화된 클래스에 의존해야만 한다. 라는 말이라고 한다.

 

다른 4가지 원칙들은 정의에 대한 설명을 한 두번 정도 눈으로만 읽어도 대충 무슨 말을 하는건지는 감이 잡혔는데, DIP 원칙에 대한 설명은 한번에 와닿지가 않는다. 이해가 안된다기 보다는, DIP를 설명하는 예제들을 살펴보면, 위에서 설명한 OCP와 LSP에서 말하는 개념과 다른 점이 별로 없어 보여 그 차이가 느껴지지 않아서 와닿지가 않는다는 느낌이다.

 

어쨌든, SOLID 원칙의 각각 요소들은 서로 닮아있고 결국은 같은 지점을 향하고 있다는 것을 알고 넘어가면 좋을 것 같다.

 

아무튼, DIP는 아래와 같은 구현 방식을 피하라는 말이다.

class FingerPrintUnlocker: Unlocker {
    init() { }
    public func unlock() {
        print("지문 인식 모듈 호출")
    }
}

class IPhone8 {
    public var name: String?
		
    // has-a 관계. 결합도 상승.
    public var fingerPrintUnlocker = FingerPrintUnlocker()

    init(name: String) {
        self.name = name
    }
    
    // 잠금해제
    public func unlock() {
        print("\(name!)의 잠금을 해제 합니다.")

        fingerPrintUnlocker.unlock()  // 구체적인 객체에 강하게 의존하게 됨.
    }
}

위에서 OCP를 설명할 때 들었던 예시인 아이폰 잠금 해제 방식을 구현하는 코드를 약간 재탕해봤다.

 

지문 인식 모듈 호출을 위해 설계한 FingerPrintUnlocker 클래스가 존재하고, IPhone8 클래스가 이를 아주 강하게 의존하고 있다. 멤버 변수로 이 클래스 인스턴스를 갖고 있고, 또 내부 코드에서 이 인스턴스의 메서드를 직접 호출하고 있기 때문이다.

여기서 FingerPrintUnlocker 클래스는 “구체화된 객체” 인 것이다. 이를 사용하는 상위 객체인 IPhone8 클래스가 직접 이를 사용하기 때문에, 말 그대로 의존성이 역전되는 상황이 되어버린 것이다. 잠금 인식 모듈이 나중에 바뀌게 되면, 멤버 변수부터 메서드 구현까지 코드 수정을 피할 수 없게 된다.

 

그러면 이 문제를 해소하려면?

OCP를 준수할 때 처럼 추상화된 Protocol(또는 Interface)를 멤버로 갖게 하고 이를 사용하게 하면 된다.

protocol Unlocker {
    func unlock()
}

class IPhone8 {
    public var name: String?
		
    // 추상화된 개념으로 변경.
    public var unlocker: Unlocker?

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

    public func setUnlocker(unlocker: Unlocker) {
        self.unlocker = unlocker
    }
    
    // 잠금해제
    public func unlock() {
        print("\(name!)의 잠금을 해제 합니다.")

        unlocker.unlock()  // OCP, LSP 준수
    }
}

Unlocker 라는 추상화된 개념을 protocol로 정의하고 이를 멤버로 받아서 처리할 수 있게 설계하면, IPhone8 클래스는 구체적인 객체에 대한 의존을 끊어낼 수 있고 추후 코드 수정에 대해 보다 유연하게 대처할 수 있게 된다.

 


 

 

 

자료 및 내용을 참고한 사이트 :

https://ko.wikipedia.org/wiki/SOLID_(객체_지향_설계)

https://johngrib.github.io/wiki/jargon/solid/#fnref:responsibility

https://devlog-wjdrbs96.tistory.com/380

https://inpa.tistory.com/entry/OOP-💠-아주-쉽게-이해하는-LSP-리스코프-치환-원칙

https://ko.wikipedia.org/wiki/리스코프_치환_원칙