본문 바로가기
그냥 개발공부 이야기

OOP의 추상화(Abstraction)와 다형성(Polymorphism)의 관계

by 토끼찌짐 2019. 11. 15.

최근에 OOP에 대해 공부를 하다 혼란에 빠졌었는데, 나름 결론이 나서 기록. '나름' 결론이기 때문에 주의해주세요. 의견은 자유롭게 코멘트로 달아주세요.

 

혼란을 불러일으킨 요소

- 존 호프만의 스위프트와 프로토콜지향 프로그래밍

- Robert C. Martin의 클린 아키텍처

- bearkode님의 OOP 고민 영상 (https://youtu.be/dhK0ZQes4Do , 'OOP에 대해서 고민해보기')

 

혼란스러웠던 (고민에 빠졌던) 주제

- OOP에서의 Abstraction과 Polymorphism의 관계

 

혼란의 배경

1

https://wlaxhrl.tistory.com/category/POP with Swift  

위 포스트에도 정리했듯, 최근 '스위프트와 프로토콜지향 프로그래밍' 책을 읽고 OOP와 POP에 대해서 다음과 같이 생각하게 되었다.

‘OOP와 POP는 둘 다 폴리몰피즘의 철학을 구현하고 있다. 각각 폴리몰피즘을 구체적으로 코드로 실현하는 방식은 다르고, 각각이 적용되면 좋은 상황이 다르다’

이 결론을 내기까지의 과정에서 다형성의 의미랑 그게 어떤 도움을 주는지 등을 다시한번 정리해보게 됐는데, 이미 알고 있던 개념이긴 했지만 저자가 전개하는 방식을 따라가다 보니까 OOP에서(POP에서도) 다형성은 정말 중요한 개념이고, 코드의 재사용성을 높일 뿐만 아니라, 설계하는 방식에도 많은 변화를 가져오게 된 개념이구나 라는 생각을 하게 되었다.

 

2

그 책을 읽고나서 사수님께 내가 너무 프로토콜에 집착했던 것 같다는 얘기를 했더니, 사수님께서도 그런 단어/개념에 집착하지 말고 다형성을 생각하면서 코딩을 해보라고 하셨다.

 

3

그러다 클린 아키텍처를 읽었는데, 초반부에 엉클 밥이 OOP에 대해 설명하며 이런 말을 했다.

‘OO의 핵심은 무엇인가? 실제 세계를 모델링하는 것도 아니고, 캡슐화도 아니고, 상속도 아니다. 핵심은 다형성이다. OO는 다형성을 안전하고 편리하게 적용할 수 있는 메커니즘을 제공한다. OOP란 다형성을 이용하여 전체 시스템의 모든 소스 코드 의존성에 대한 절대적인 제어권한을 획득할 수 있는 능력이다’

다형성의 간단한 예시를 보여주고, 의존성 역전 (Dependency Inversion) 개념을 끌어오기도 하면서 위의 결론을 내리고 있었다.

 

이렇게 3스택이 쌓이고 나니, 나는 OOP의 핵심은 다형성이 아닐까? 라고 생각하게 되었다. 이전의 절차적 프로그래밍에 비해서 객체지향 프로그램의 핵심적인 차별점은 무엇인가 에 대한 가장 가까운 답이 다형성처럼 느껴졌던 것이다.

그리고 이 생각을 뒷받침해줄 수 있는 근거들을 더 찾아다니기 시작했다. 아무래도 확신이 없었기에. 그러나 딱히 그런 말은 찾아볼 수 없었고 ... 그러던 와중 유튜브의 bearkode 님 영상을 보게 되었다. 그분은 OOP의 핵심을 Abstraction(추상)이라고 하시며, OOP의 기본 개념들은 일관되게 추상의 개념을 이용하고 있다고도 하셨다.

... 추상?

 

혼란의 시작

추상 이라는 단어와 마주한 순간 갑자기 내 머리에 객체지향의 4원칙이 번쩍 떠올랐다.

추상화, 캡슐화, 상속, 다형성.

캡슐화와 상속이 OOP의 핵심이 아니라고는 비교적 강하게 말할 수 있었다. 캡슐화의 경우에는 그전의 프로그래밍 방식에서도 개발자가 마음만 먹으면 구현할 수 있었고, 상속의 경우에는 추상과 다형성의 파워를 보여주는 특성임과 동시에 그 두 가지를 이룩하는 수단이기도 하다고 생각했기 때문이다.

그리고 다형성은 지금까지 내가 핵심이라고 믿(어가)고 있었고.

그런데, 추상에 대해서는 별로 생각해보지 않았던 것을 깨달았다. 또한 밥 아저씨도 OOP를 설명하는 부분에서 '캡슐화, 상속, 다형성'을 언급했지만 'Abstraction'이라는 용어는 사용하지 않았던 것을 발견했다. 그래서 '다형성' 이라는 단어 대신 '추상화' 라는 단어를 넣어보았는데, 많은 부분에서 그 역시 맞는 문장이 되었다. 특히 의존성 역전 개념을 사용하여 설명한 부분에서는, 이건 다형성 보다는 추상화의 이점이 아닌가 하는 생각이 들었고, 그래서 DIP 원칙을 자세히 들여다보니 역시나 이건 다형성이라기 보다는 추상과 구체화에 더 가까운 원칙이 아닌가 싶었다.

DIP 원칙(Dependency Inversion Principle)을 간단히 표현해보면, 추상화된 레벨을 이용해 코드를 유연하게 만드는 원칙이다. 쉬운 예를 들어보면, 복합기 모듈에서 프린터 모듈(출력을 담당하는 모듈)의 종류(이게 레이저 프린터냐, 잉크젯 프린터냐, ... 어떤 타입의 프린터냐?)나 상세 구현부(잉크를 분사하는지, 레이저를 쏘는지, 아니면 뭐 3D로 뽑아내는가? 출력 원리가 뭐야?)를 알 필요가 없다는 것이다. 복합기 모듈은 그냥 프린터 모듈에게 print() 를 부탁하면 된다. 이게 어떤 종류의 프린터고, 어떤 방식으로 출력을 해내고, 결과물이 어떤 모양새인지, 그런 건 복합기 모듈은 내 알 바가 아니라는 것.

이런 방식이 왜 좋은가 하면, 앞서 말했듯 '유연한 코드'이기 때문이다. 이것도 구체적인 예시를 들어보자.

  • (예시 1) 프린터 모듈의 출력 코드에서 급한 변경사항이 생기거나, 혹은 기기에 따라 시시각각 출력 옵션이 바뀌어야 할 때, 건드리는 곳은 오직 프린터 모듈의 코드 뿐이다. 그 외에, 프린터 모듈이 아직 만들어지지 않은 시점에도 복합기 모듈은 문제없이 작성할 수 있다(비록 출력 기능은 동작하지 않더라도).
  • (예시 2) 지금은 레이저 프린터를 사용하지만 다음 업데이트 시점에서는 잉크젯 프린터도 지원해주고 싶을 때, 개발자는 잉크젯 프린터 모듈을 작성하고 print 함수를 채워넣은 뒤, 복합기 모듈에 가서 프린터 타입을 선택하는 버튼의 액션 함수에서 switch case 를 딱 한 줄 추가하면 끝난다.

예시1에서는 추상과 구체화의 이점을 강조하였고, 예시2에서는 다형성의 이점을 강조해보았다. 이렇듯 DIP는 추상, 다형성의 개념이 함께 이해되어야 빛을 발할 수 있다.

...음?

여기서 나의 혼란이 다른 방향으로 나아갔다.

추상과 다형성은 따로 생각될 수 있는 건가? 그 둘은 어떤 관계지?

 

혼란의 본격화

추상과 다형성은 정확히 무엇인가?

두 가지는 항상 붙어다니지 않나?

두 가지 모두 중요한 것 아닌가?

사실 둘은 같은 개념일까? > 아님

하나가 하나의 Subset 인가? > 아님

하나가 하나의 수단인가? > 아님

그냥 아무 관계도 없는 건가? > 그것도 아닌듯함

처음으로 돌아와서, 그래서 OOP의 핵심은 뭐지?

둘 다 핵심인가?

둘 다 핵심이 아닌가?

핵심을 찾는 의미가 뭔가?

핵심이라는 단어에 대한 게슈탈트 붕괴

...

무한 지옥에 빠지고 말았다.

 

혼란의 해결 시도

추상과 다형성의 관계를 정의하기 전, 그 둘이 각각 어떤 개념인지를 확실히 하고 넘어가기로 했다.

- 추상화: 컴퓨터 과학에서 추상화는 복잡한 자료, 모듈, 시스템 등으로부터 핵심적인 개념 또는 기능을 간추려 내는 것을 말한다.

- 다형성: 프로그램 언어의 다형성은 그 프로그래밍 언어의 자료형 체계의 성질을 나타내는 것으로, 프로그램 언어의 각 요소들이 다양한 자료형에 속하는 것이 허가되는 성질을 가리킨다. 반댓말은 단형성으로, 프로그램 언어의 각 요소가 한가지 형태만 가지는 성질을 가리킨다.

위키백과의 항목 참조.

이렇게만 보면, 잘 감이 잡히지 않는다.

추상화와 다형성에 대해 내가 이해한 바를, 위 예제에서의 '프린터' 와 'func print()' 부분에 접목해보았다.

 

먼저, 추상과 다형성의 개념을 사용하지 않았을 때의 시뮬레이션:

추상과 다형성을 모르는 어떤 개발자가 다음과 같은 요구사항을 받았다.

🗣"복합기 모듈이 사용할 출력 모듈을 만들어주세요. 흑백 인쇄면 돼요."

🐰 "네, 알겠습니다."

개발자는 프린터 클래스를 만들었다. 클래스 내부에 paperWithBlackInk 함수를 만든 뒤 '흑백 잉크를 종이 위에 분사하여 종이를 return' 이라는 내용을 작성했다. 복합기 모듈에서는 이 출력 모듈을 사용하기 위해 paperWithBlackInk 라는 구체적인 함수를 호출했고, return 된 paper 객체를 printPaper(paper) 로 출력했다.

class Printer {
    
    func paperWithBlackInk() -> Paper {
        let result = Paper()
        // 흑백 잉크를 종이 위에 분사한다
        return result
    }
}

class 복합기 {
    
    func print() {
        let paper = self.printer.paperWithBlackInk()
        self.printPaper(paper)
    }
    
    private func printPaper(_ paper: Paper) {
        // 종이를 출력한다
    }
    
    private let printer = Printer()
}

모종의 이유로 회사가 얼마안가 파산했다면 이 코드는 별 문제가 되지 않았을 것이다(폐기처분 되므로, 변경할 필요가 없다). 그러나 회사가 망하지 않은 탓에, 갑자기 어느 날 새로운 요구사항이 내려왔다.

🗣"복합기의 출력을 흑백이 아닌 컬러로 바꿉시다."

🐰"네, 알겠습니다. (내 이럴 줄 알았지...)"

이제 코드를 변경해야 하는데, 귀찮은 과정이 필요하다... paperWithBlackInk 함수를 새로 만들어야 한다. 그냥 기존 함수를 고쳐버리고 싶었지만, paperWithBlackInk 라는 이름의 함수가 컬러 인쇄된 종이를 반환하는 건 이상했기 때문이다. 함수 이름을 바꾸든, 새로 추가하든, 어쨌든 복합기 모듈에서도 paperWithBlackInk 를 호출하고 있는 부분들을 전부 수정해주어야 했다.

class Printer {
    
    // NOTE: 2.0 버전부터 안 쓰이는 함수. 지울까?
    func paperWithBlackInk() -> Paper {
        let result = Paper()
        // 흑백 잉크를 종이 위에 분사한다
        return result
    }
    
    func paperWithColorInk() -> Paper {
        let result = Paper()
        // 컬러 잉크를 종이 위에 분사한다
        return result
    }
}

class 복합기 {
    
    func print() {
        let paper = self.printer.paperWithColorInk()
        self.printPaper(paper)
    }
    
    private func printPaper(_ paper: Paper) {
        // 종이를 출력한다
    }
    
    private let printer = Printer()
}

 

이제 다 끝난 줄 알았는데, 또 요구사항이 발생했다

🗣"3D 프린터가 대세라면서요? 외부메서 3D 출력 모듈을 구매해왔어요. 이걸 그냥 붙여주시기만 하면 돼요!"

🐰"네, 알겠습니다. (이게 무슨 스티커 붙이는 것처럼 쉬운 게 아닌데...)"

뭐 일단 해보자... 복합기 모듈 안의 프린터 클래스 정의부를 찾아, 3D프린터 클래스로 타입을 변경했다. 이제 코드 상의  paperWithColorInk 함수를 새로운 모듈이 제공하는 함수로 교체하면...어라? 개발자는 3D프린터 클래스가 제공하는 퍼블릭 함수가 꽤 많은 것을 발견한다. makePlasticOutput(), makeMetalOutput(), makeCeramicOutput(). 뭘 사용해야 할지 당황하다가 일단 플라스틱 모델을 선택해본다. 그러나 아직도 빌드가 실패한다. 함수가 반환한 plasticOutput 객체과 printPaper(_ paper: Paper) 함수와 호환되지 않기 때문이었다. 어 이거 ... 플라스틱은 종이가 아닌데요? 어떻게 출력하지? 개발자는 printPaper 함수를 전부 지우고 printPlastic 함수를 새로 작성한다. 완성된 플라스틱 Output을 내보내는 함수다.

// 외부 모듈의 인터페이스
public class ThreeDimensionalPrinter {
    public func makePlasticOutput() -> PlasticOutput
    public func makeMetalOutput() -> MetalOutput
    public func makeCeramicOutput() -> CeramicOutput
}

class 복합기 {
    
    func print() {
        let plastic = self.printer.makePlasticOutput()
        self.printPlastic(plastic)
    }
    
    private func printPlastic(_ plastic: PlasticOutput) {
        // 플라스틱을 내보낸다
    }
    
    private let printer = ThreeDimensionalPrinter()
}

 

이제 빌드가 성공한다. 빌드가 성공했으니 개발자는 일단 퇴근한다.

그런데 다음 날...

🗣"플라스틱 소재, 금속 소재, 세라믹 소재 전부 지원하게 해주세요. 그리고 컬러인쇄와 흑백인쇄도 선택할 수 있게 해주세요. 되겠죠?"

🐰"네, 알겠습니다. (퇴근하고 싶다.)"

일단 한숨부터 나온다. 개발자는 생각한다. 이거 코드가 너무 지저분해질 것 같은데... 아니나 다를까 완성된 복합기는 기존의 잉크젯 출력 모듈과 새로운 3D출력 모듈을 둘 다 변수로 들고 있으며, 출력이 필요한 부분마다 if 절이 5개씩 들어간다(또는 Switch case 문). 물론 return 해온 객체를 출력해주기 위한 함수도 4개나 된다.

class Printer {
    
    func paperWithBlackInk() -> Paper {
        let result = Paper()
        // 흑백 잉크를 종이 위에 분사한다
        return result
    }
    
    func paperWithColorInk() -> Paper {
        let result = Paper()
        // 컬러 잉크를 종이 위에 분사한다
        return result
    }
}

// 외부 모듈의 인터페이스
public class ThreeDimensionalPrinter {
    public func makePlasticOutput() -> PlasticOutput
    public func makeMetalOutput() -> MetalOutput
    public func makeCeramicOutput() -> CeramicOutput
}

class 복합기 {
    
    var printType: 복합기.PrintType = .colorInk
    
    enum PrintType {
        case blackInk
        case colorInk
        case plastic
        case metal
        case ceramic
    }
    
    func print() {
        switch self.printType {
        case .blackInk:
            let paper = self.printer.paperWithBlackInk()
            self.printPaper(paper)
            
        case .colorInk:
            let paper = self.printer.paperWithColorInk()
            self.printPaper(paper)
            
        case .plastic:
            let plastic = self.threeDimensionalPrinter.makePlasticOutput()
            self.printPlastic(plastic)
            
        case .metal:
            let metal = self.threeDimensionalPrinter.makeMetalOutput()
            self.printMetal(metal)
            
        case .ceramic:
            let ceramic = self.threeDimensionalPrinter.makeCeramicOutput()
            self.printCeramic(ceramic)
        }
    }
    
    private func printPaper(_ paper: Paper) {
        // 종이를 내보낸다
    }
    
    private func printPlastic(_ plastic: PlasticOutput) {
        // 플라스틱을 내보낸다
    }
    
    private func printMetal(_ metal: MetalOutput) {
        // 금속을 내보낸다
    }
    
    private func printCeramic(_ ceramic: CeramicOutput) {
        // 세라믹을 내보낸다
    }
    
    private let printer = Printer()
    private let threeDimensionalPrinter = ThreeDimensionalPrinter()
}

 

 

여기에 추상과 다형성을 도입해보면, 어떻게 될까?

DIP의 예시를 들었던 것과 중복 설명이긴 하지만, 그래도 설명해보겠다.

먼저 프린터 클래스를 만들고, 그 내부에 print 함수를 정의한다. '출력' 기능은 이제 이 클래스 안에서 전부 담당할 것이다. 외부에서 이 클래스를 이용하는 방법은 'call print() fuction' 딱 한 문장으로 요약할 수 있다.

class Printer {
    
    func print() {
        // 출력까지 알아서 전부 해드립니다
    }
}

class 복합기 {
    
    func print() {
        self.printer.print()
    }
    
    private let printer = Printer()
}

복합기 모듈이 출력 모듈과 커뮤니케이션 하던 방식에 다음과 같이 바뀔 것이다.

  • as-is: 아~ 이 프린터는 흑백인쇄를 해주는 프린터구나. 그럼 내가 잉크를 분사하는 함수를 호출하면 되겠구나. 어 반환해주는 건 잉크가 분사된 종이네? 그럼 이걸 받아와서 내가 출력처리를 하도록 할게!
  • to-be: 프린터야 출력해줘 (끝)

반대도 마찬가지다.

출력 모듈이 복합기 모듈과 커뮤니케이션 하던 방식이 어떻게 변했는지 보자.

  • as-is: 내가 해주는 건 잉크를 분사한 종이를 반환해주는 일이야. 그러니까 나는 잉크젯 프린터라고 할 수 있지. 나를 사용하려면 printWithInk 함수를 호출하고, 이 함수의 리턴값인 Paper 를 네가 출력 시키면 돼.
  • to-be: print 함수를 부르면 내가 알아서 출력할게 (끝)

이제 흑백 인쇄를 컬러 인쇄로 변경하는 것도 식은죽먹기다. 단순히 프린터 클래스의 print 함수 내 로직만 수정하면 된다. 출력 로직에 변경이 생길 때마다 함께 수정해야 했던 복합기 모듈은 이제 더이상 건드릴 필요가 없는 것이다.

사실 이런 방식은 실제 세계에서도 많이 사용되고 있다. 하나만 예를 들어보면, 학창시절 소위 일진들이 사용하던 말을 떠올려볼 수 있다. '오늘까지 만원 가져와라'. 이 말에는 '친구한테 빌리든, 용돈을 가져오든, 아니면 다른 애한테 받아오든, 그건 네가 알아서 하고' 라는 말이 생략되어 있다. 빠르고, 단순하며, 효율적인 커뮤니케이션 방식이다.

이런 추상화 레벨을 만들었기 때문에, 이 코드는 프린터의 종류가 다양하게 늘어났을 때에도 쉽게 대응할 수 있다. 출력 방식이 하나씩 늘어날 때마다, 기존의 프린터 클래스를 상속받는 서브클래스를 만들고 print 함수를 각자의 방식으로 구현하면 된다. 복합기에서는 유저가 출력 방식을 선택할 때 프린터의 타입을 변경하는 코드만 추가하면 된다. if/switch 문이 들어가는 것은 오직 그 한 곳이다.

class Printer {
    
    required init() {
        // initialized somthing...
    }
    
    func print() {
        // 출력까지 알아서 전부 해드립니다
    }
}

class BlackInkPrinter: Printer {
    
    override func print() {
        // 흑백 잉크를 종이 위에 분사하여 출력한다
    }
}

class ColorInkPrinter: Printer {
    
    override func print() {
        // 컬러 잉크를 종이 위에 분사하여 출력한다
    }
}

class PlasticModelPrinter: Printer {
    
    override func print() {
        // 인풋을 플라스틱 소재로 3D 프린팅
    }
}

// 이하 생략...

/*
 BlackInkPrinter 와 ColorInkPrinter 도
 하나의 InkPrinter 라는 부모를 가지게 하거나
 ink type interface 를 이용해 하나의 모듈로 만들 수 있지만
 그런 과정은 이 예제에서 생략합니다
*/

class 복합기 {
    
    var printType: PrintType = .colorInk {
        didSet { self.updatePrinter() }
    }
    
    func print() {
        self.printer.print()
    }
    
    private func updatePrinter() {
        self.printer = self.printType.supportedPrinterType.init()
    }
    
    private var printer = Printer()
}

private extension 복합기.PrintType {
    var supportedPrinterType: Printer.Type {
        switch self {
        case .blackInk: return BlackInkPrinter.self
        case .colorInk: return ColorInkPrinter.self
        case .plastic:  return PlasticModelPrinter.self
        case .metal:    return MetalModelPrinter.self
        case .ceramic:  return CeramicModelPrinter.self
        }
    }
}

이런 부분이 다형성이다. 같은 인터페이스로 여러 타입과 상호작용할 수 있다. 이제 복합기 코드는 프린터의 종류가 설령 백 가지가 더 추가되더라도 출력 모듈 타입을 선택하는 곳의 조건문만 한 줄씩 추가하면 다른 곳은 변경할 필요가 없다.

 

혼란의 해소

추상화와 다형성의 개념과 그 둘이 실제 코드에서 어떻게 녹아들 수 있는지를 이해하고 나니, 이제 둘 사이의 관계성도 어느 정도 눈에 들어온다. 내가 한 문장으로 정리한 둘의 관계성은 이렇다.

추상은 다형성과 함께 쓰일 때 극대화되고, 다형성은 추상이 있어야 유의미하다.

다형성은 추상과 구체화의 개념을 기반으로 동작하는 것이다. 'Printer', 'print function' 같은 추상을, 필요한 만큼 다양하게 구체화시키는 매커니즘이다. 이렇듯 다형성은 추상 없이는 존재할 수 없는 개념이다.

실제로 우리가 코드를 작성할 때는 오직 추상의 개념만으로는 부족하다. 다형성과 SOLID 법칙 등을 적용함으로써 코드는 보다 우아해질 수 있다.

주말 이틀을 반씩 거쳐 거의 하루를 끙끙대며 팠던 문제였는데, 내 안에서는 정리가 되어서 말끔해졌다. 학부 시절부터 공부해온 개념이고 실제로 업무에서도 많이 사용하는 개념이라 잘 알고 있다고 생각했는데, 그게 아니었다는 것을 이번에 깨달았다. 역시 공부는 제대로 해야 한다는 생각이 든다.