Swift OOP) OCP - 개방폐쇄원칙(Open Close Principle)

“확장에 열려있고, 변경에 닫혀있어야한다”

image

확장 그리고 변경? 기능을 늘리는 것이 확장이고.. 수정하는 것이 변경인가? 대략적으로 짐작을 해볼 수 있습니다.

그래서 우선 확장과 변경의 의미에 대해서 한번 생각해보겠습니다.

확장이란?

새로은 타입등을 추가함으로써 새로운 기능의 추가로 이어질수 있다. 즉 어떤 요구사항에 대해 새로운 기능을 구현하는 것을 말한다.

변경이란?

변경이란 기능적인 부분에서 수정이 일어나는 것을 말한다

확장과 변경의 의미를 정의해보았습니다. 그럼 확장에 열려있고, 변경에 닫혀있어야한다는 의미는 과연 무엇일까요?

“확장에 닫혀있고 변경에 열려있다”

확장에 열려있다는 것은 새로운 타입(클래스)를 추가함으로써 기능을 확장할 수 있다는 말입니다. 이때 새로운 클래스가 추가가 되어 확장이 발생했을때 다른 영역에 변경이 발생하지 않으면 그것을 변경에 대해 닫혀있다고 말할 수 있습니다.

간단한 예를 통해 이해를 좀더 해보겠습니다. 우리는 iOS 버전을 매번 업데이트 하면서 기능적인 추가가 일어나고 많은 변경들이 있었지만 어떤 기능 한가지는 공식문서를 읽어보지 않는 아이폰 사용자들도 알고있고 버전이 바뀌어도 기능은 추가되었지만 여전히 사용하고 있는 그런 기능이 있습니다.

바로 홈화면 편집 기능입니다. 버전이 바뀌어도 디바이스가 바뀌어도 long press터치 -> x 버튼 삭제 -> 상단 완료버튼 중단

그 사이에 기능적인 변경도 있었고 하드웨어 적으로 3D터치도 사라지는 등의 변경이 있었지만 사용자가 별 문제 사용할 수 있었던 이유는 바로 홈화면 편집 인터페이스 와 상호작용하기 있기 때문입니다. 마찬가지로 사용자가 기기가 아이패드, 아이팟등 변경이 일어나도 이런 홈화면 편집 기능을 사용할 수 있는 이유는 각 애플 디바이스들이 홈화면 편집 인터페이스 채택하여 기능들을 구현하고 있기 때문에 가능한 일입니다.

예제를 통해 OCP를 분석

서비스적인 측면에서 요구사항이 발생했을때 어떻게 개발이 이루어 지는지 한번 예제를 통해 하나씩 파악해보겠습니다.

요구사항

  • 고객들을 위해 종합 결제 시스템을 만들어야한다
  • 현재는 결재방식은 네이버페이,카카오페이,삼성페이 이 3가지다.
  • 앞으로 카드결제 방식은 더 추가 될 수 있다.
  • 할부와 적립 함수에서 요청

우선은 설계해보자

enum 결제방식 {
    case 네이버페이
    case 카카오페이
    case 삼성페이
}


func 할부(_ type: 결제방식) -> String {
    if type == .네이버페이 {
        return "A개월"
    } else if type == .카카오페이 {
        return "C개월"
    } else if type == .삼성페이 {
        return "D개월"
    } 
}

func 적립(_ type: 결제방식) -> String {
    if type == .네이버페이 {
        return "A%"
    } else if type == .카카오페이 {
        return "B%""
    } else if type == .삼성페이 {
        return "C%"
    }
}

이런식으로 할부와 적립 함수에서 각 결제방식에 해당하는 할부개월수와 적립률을 반환 해주고 있습니다.하지만 할부나 적립의 함수를 여러 곳에서 필요로 할 경우 Swift에서는 다음과 같이 변경이 가능합니다.

enum 결제방식 {
    case 네이버페이
    case 카카오페이
    case 삼성페이
    
    var 할부개월수: String {
        switch self {
        case .네이버페이:
            return "A개월"
        case .카카오페이:
            return "B개월"
        case .삼성페이:
            return "C개월"
        }
    }
    
    var 적립률: String {
        switch self {
        case .네이버페이:
            return "A%"
        case .카카오페이:
            return "B%"
        case .삼성페이:
            return "C%"
        }
    }
}

let 타입 = 결제방식.네이버페이
var 네이버페이할부와적립률 = 타입.할부개월수 + 타입.적립률     // A개월 A%

OCP를 준수하는가?

과연 그럼 OCP를 준수하는 구조일까요? 예를들어 카드제휴사할인 이라는 함수가 추가가 된다면 어떻게 될까요?

    func 카드제휴사할인() {
        switch self {
        case .네이버페이:
        // 네이버페이 만의 제휴사 할인을 위한 동작코드들이 실행
          ...
        case .카카오페이:
        // 카카오페이 만의 제휴사 할인을 위한 동작코드들이 실행
          ...
        case .삼성페이:
        // 삼성페이 만의 제휴사 할인을 위한 동작코드들이 실행
          ...
        }
    }

복잡한 코드들이 분기문마다 다르게 실행되어야한다면 위와 같이 카드제휴사할인 메서드의 타입별 분기문은 늘어나게 됩니다.

case 라인페이
case 페이코
case 올원페이
...

그리고 결정적으로 타입이 라인페이,페이코, 올원페이 .. 등등 늘어나게 되면 enum 내의 변수나 함수의 분기문 모두를 수정해야하고 뿐만 아니라 enum 외의 다른 곳에서 타입별 분기문을 다루고 있다면 그곳까지 찾아가서 수정을 해주어야 하는 상황이 발생합니다.

이 처럼 타입이 추가 될때 분기문 swift의 case나 if-else 문이 추가가 될때 변경이 발생한다고 합니다. 하지만 이런 반복적인 분기문이 추가될때 추후에 리팩토링 대상이 될 확률이 높게 됩니다. 결제방식이라는 enum 임에도 너무 많은 책임 을 갖게 되고, 확장에 어렵고, 변경에 취약한 구조가 됩니다.

그렇기에 Swift에서는 이를 인터페이스, 즉 Protocol을 이용하여 의존관계를 역전 시키면 OCP를 준수하게 만들 수 있습니다.

OCP 준수

protocol 결제방식 {
    var 할부개월수: String { get }
    var 적립률: String { get }
    func 카드제휴사할인()
}

class 네이버페이: 결제방식 {
    var 할부개월수 = "A개월"
    var 적립률 = "A%"
    func 카드제휴사할인() {
        // 네이버페이 만의 제휴사 할인을 위한 동작코드들이 실행
    }
}

class 카카오페이: 결제방식 {
    var 할부개월수 = "B개월"
    var 적립률 = "B%"
    func 카드제휴사할인() {
        // 카카오페이 만의 제휴사 할인을 위한 동작코드들이 실행
    }
}

class 삼성페이: 결제방식 {
    var 할부개월수 = "C개월"
    var 적립률 = "C%"
    func 카드제휴사할인() {
        // 삼성페이 만의 제휴사 할인을 위한 동작코드들이 실행
    }
}

class 페이코: 결제방식 {
    var 할부개월수 = "D개월"
    var 적립률 = "D%"
    func 카드제휴사할인() {
        // 페이코 만의 제휴사 할인을 위한 동작코드들이 실행
    }
}

let 타입: 결제방식 = 삼성페이()
var 삼성페이 = 타입.적립률 + 타입.할부개월수
타입.카드제휴사할인()   

위의 코드의 경우 결제방식enum에서 protocol 로 선언을 하였습니다. 그리고 각 enum 타입을 별도의 클래스로 정의를 하여 결제방식 protocol 에서 선언한 필요한 멤버변수, 함수를 직접 프로토콜을 채택하여 구현하고 있습니다. (꼭 protocol이 아니라 super class 형태도 가능합니다!)

그렇게 할 경우 복잡한 동작을하는 카드제휴사할인을 채택하여 구현 할 수 있고 , 새로운 결제방식이 추가(새로운 타입이 추가) 되더라도 각 카드제휴사할인을 호출하는 코드쪽에서 변경이 발생하지 않기 때문에 확장에 열려있고 변경에 닫혀있다 라고 할 수 있습니다.

  • 확장에 얼려있다 : 라인페이, 페이코 등등 새로운 결제방식이 추가가 가능하다
  • 변경에 닫혀있다 : 새로운 타입 추가, 즉 확장이 되었을때 카드제휴사할인 등 프로토콜을 통해 접근하기 때문에 호출하는 쪽의 코드의 변경이 발생하지 않는다.

결론적으로

새로운 결제방식이 추가가 되었을때 이 것을 새로운 타입으로 추가, 즉 클래스를 별도로 생성해서 결제방식 프로토콜만 채택해서 구현하면 되기 때문에 확장에는 열려있다라고 말할 수 있고, 또 그 코드를 호출하는 쪽은 결제방식 protocol 에 대응되어 함수나 변수를 접근하기 때문에 변경에는 닫혀 있는 구조가 됩니다.

그럼 OCP는 묘책? Silver Bullet?

베어코드-OCP(개방폐쇠원칙) 영상을 보고 생각을 정리해보겠습니다.

image

그럼 OCP는 과연 Silver Bullet(묘책, 비장의 무기) 일까요?

위의 에제에서 프로토콜(인터페이스)를 두는 것만 해도 클래스 간의 강한 결합 관계를 느슨한 관계 로 만들 수 있었습니다. 하지만 개발을 하다보면 모든 부분을 이렇게 만드는 것은 쉽지 않고 또 판단하는 것도 개발자 본인의 몫이 됩니다.

인터페이스로 어떤 부분을 추상화 해야하나? 아님 어떤 부분을 구체화 해야하나? 를 판단하는 것이 우선이고 또 어려움을 겪는 부분입니다.

우선적으로 모든 변경사항을 예측하는 것은 불가능하며 또한 개발 초기 부터 그것을 판단하는 것은 매우 비효율 적이라고 생각합니다. 위의 예제의 경우 결제방식 의 새로운 타입이 지속적으로 추가가 될 거 같다고 명확한 변경이 예상 되는 경우에는 OCP를 준수하게 추상화하여 구현하는 것이 좋다고 생각합니다.

하지만 이 모든 것 또한 예측가능하다는 전제기 때문에 영상에서는 해당 규칙을 따르기 전에 아래의 사항들을 검토해보고 효율적으로 선택하라고 언급하고 있습니다.

  • 가치에는 communication,유연성,단순성 이 있다.
  • OCP 유연성 에는 좋지만 단순성 에는 그렇게 simple하지 않다
  • 그렇기에 추상화 구체화 판단은 우성 변경이 없다고 가정하자 -> 즉 단순성을 위해 처음부터 복잡하게 구현할 필요 없다
  • 만약 변경이 발생된다면 어떤 곳에 영향을 미치는지 판단하고 리팩토링

방심하지마라

그리고 마지막으로 OCP를 준수한다 하더라도 타입의 추가 보다는 계속해서 인터페이스의 변화가 자주 일어나게 되는 경우가 있습니다. 추상화를 한 부분에서 계속 해서 함수나 변수가 추가가 될 경우 이는 추상화의 위치를 잘못 잡았기 때문에 발생하는 문제입니다.

예를들어 카드 할인의 방식이 여러개가 있다고 가정해보겠습니다.

protocol 결제방식 {
    var 할부개월수: String { get }
    var 적립률: String { get }
    func 카드제휴사할인1()
    func 카드제휴사할인2()
    func 카드제휴사할인3()
    func 카드제휴사할인4()
    ...
}

이 같은 경우 결제방식 protocol에 계속해서 할인의 함수가 추가가 되어 프로토콜 자체의 변경이 자주 일어나게 되므로 OCP설계를 잘못한 경우이기도 합니다.

protocol 할인 {
  ...
}

그렇기 때문에 다시 추상화를 설계하거나 해당 부분을 할인 protocol로 분리해서 설계해야하는 방법이 바람직합니다. 이 처럼 무조건 적으로 타입이 추가가 된다해서 OCP를 무턱대고 설계를 할경우 발생하는 문제에 대해서 정리를 해보았습니다.

Reference





© 2020. by Gaki

Powered by gaki