티스토리 뷰

객체지향 프로그래밍 패러다임

대표 프로그래밍 패러다임으로

  • 절차지향 프로그래밍
  • 객체지향 프로그래밍
  • 함수형 프로그래밍

3가지가 있다. 이 중 절차지향은 시대에 뒤떨어져 있고, 함수형은 일부 비지니스에서 활용되고 있으나 객체지향을 대체하기에는  한계가 있다. 객체지향 프로그래밍이 현시점에 가장 대중적인 프로그래밍 패러다임인 것이 확실하다.

2.1 객체지향이란 무엇인가?

개발자라면 캡슐화, 추상화, 상속, 다형성 이라는 객체지향의 4가지 특성에 대해 알고 있을 것이다. 하지만 객체지향의 개념은 그 이상을 포함한다. 이제부터 각 개념에 대해 자세히 알아보겠다.

2.1.1 객체지향 프로그래밍과 객체지향 프로그래밍 언어

객체지향 프로그래밍에는 클래스와 객체라는 기본적이지만 매우 중요한 두 가지 개념이 있다.

 

Java, C++, Go, Python, C#, Ruby, JavaScript, Objective-C, Scala, PHP, Perl 등 대부분의 프로그래밍 언어는
"너무 엄격한 정의로 구분하지 않는 경우"라면 객체지향 프로그래밍 언어에 속한다. 대부분의 프로젝트는 객체지향 프로그래밍 언어로 개발된다.

 

객체지향 프로그래밍 개발 과정을 소개할 때 객체지향 프로그래밍객체지향 프로그래밍 언어 두 가지 개념을 언급했다.
여기서 객체지향 프로그래밍이란 정확히 무엇이고, 객체지향 프로그래밍 언어란 어떤 프로그래밍 언어를 의미하는지 정의해 보자.

  1. 객체지향 프로그래밍이란.
    프로그래밍 패러다임 또는 프로그래밍 스타일을 의미한다. 코드를 구성하는 기본 단위로 클래스 또는 객체를 사용하고,
    코드 설계와 구현의 초석으로 캡슐화, 추상화, 상속, 다형성의 4가지 특성을 사용한다.
  2. 객체지향 프로그래밍 언어란.
    클래스 또는 객체 문법을 지원하며, 이 문법은 객체지향 프로그래밍의 4가지 특성인 캡슐화, 추상화, 상속, 다형성을 쉽게 구현할 수 있다.

참고로

  • 객체지향 프로그래밍은 객체지향 프로그래밍 언어를 사용하지만 꼭 객체지향 프로그래밍 언어를 사용하지 않더라도 객체지향 프로그래밍을 할 수 있으며,
    객체지향 프로그래밍 언어를 사용하더라도 해당 언어로 작성된 코드가 반드시 객체지향 프로그래밍 스타일이라는 것은 아니다.

 

객체지향 프로그래밍과 객체지향 프로그래밍 언어를 이해하는 열쇠는 객체지향 프로그래밍의 4가지 특성인 캡슐화, 추상화, 상속, 다형성을 이해하는 것이다.

여기서 추상화를 객체지향 프로그래밍 특성에서 배제할 수 있다는 의견도 있는데 2.2절에서 다루겠다.

 

어찌 되었건 특성의 개수가 중요한 것은 아니다. 각각의 특성이 가지는 내용, 존재 의미, 해결할 수 있는 문제를 파악하는 것이 관건이다.

앞으로 위 4가지 특성을 4대 특성이라 지칭하겠다.

2.1.2 엄격하게 정의되지 않은 객체지향 프로그래밍 언어

대부분의 프로그래밍 언어는 "너무 엄격한 정의로 구분하지 않는 경우"라면 객체지향 프로그래밍 언어에 속한다.

 

위 문장을 다시 봐보자. 여기서 "너무 엄격한 정의로 구분하지 않는 경우"라는 전제는 왜 달렸을까?

엄격한 객체지향 프로그래밍 언어의 정의에 따르면 위에서 나열한 프로그래밍 언어 중 일부는 객체지향 프로그래밍 언어가 아니다. 
예를 들어, 자바스크립트의 경우 캡슐화와 상속 기능을 제공하지 않지만 그럼에도 객체지향 프로그래밍 언어라고 할 수 있다.

 

왜 그렇게 할 수 있을까? 어떤 프로그래밍 언어가 객체지향 프로그래밍 언어인지 어떻게 판단할 수 있을까?

객체지향 프로그래밍 언어의 정의를 다시 읽어보자. 

 

단순하고 원시적인 방식으로 이해해 보면, 객체지향 프로그래밍은 객체 또는 클래스를 코드 구성의 기본 단위로 사용하는 프로그래밍 패러다임 또는 프로그래밍 스타일로 꼭 4대 특성을 갖출 필요는 없다.

 

단지 객체지향 프로그래밍 과정에서 개발자들이 이러한 특성을 이용해 다양한 객체지향 코드 설계 사상을 구현하는 것이 더 쉽다는 것을 발견하고 그렇게 결론지어 내려온 것이다.

 

예를 들어, 상속은 객체지향 프로그래밍의 특성 중 하나이지만, 프로그래밍 언어의 변화와 진화로 인해 개발자는 상속이 불명확한 수준과 혼란스러운 코드를 쉽게 야기할 수 있음을 알게 되었고, Go 언어와 같은 많은 프로그래밍 언어는 상속 기능을 포기했다. 그렇지만 언어의 문법이 상속 기능을 포기했다고 그것이 객체지향 프로그래밍 언어가 아니라는 의미는 아니다.

 

프로그래밍 언어가 클래스 또는 객체의 문법적 개념을 지원하고, 이를 코드 구성의 기본 단위로 사용하는 한 단순히 객체지향 프로그래밍 언어로 간주될 수 있다는 것이다.

2.1.3 객체지향 분석과 객체지향 설계

  • 객체지향 분석 Object Oriented Analysis, OOA
  • 객체지향 설계 Object Oriented Design, OOD

두 가지 다른 개념을 알아보자. 객체지향 분석, 객체지향 설계, 객체지향 프로그래밍은 객체지향 소프트웨어 개발의 3단계에 해당한다.

 

분석과 설계 앞에 객체지향이라는 수식어가 붙은 이유가 뭘까. 

그 이유는 객체나 클래스에 대한 요구 사항 분석을 하고 설계하기 때문이다.

 

분석과 설계라는 두 가지 단계를 거치면 프로그램이 어떤 클래스로 분해 구성되는지, 각각의 클래스가 어떤 속성과 메서드를 가지는지, 클래스끼리 상호 작용하는 인터페이스를 포함한 클래스 설계를 도출하게 된다.

이러한 방식의 분석과 설계는 매우 구체적이며 실제 코딩에 가까우므로 구현이 용이하고 객체지향 프로그래밍으로의 전환이 쉬워진다.

 

각 단계에서 하는 일은 무엇일까?

  • 객체지향 분석: 무엇을 해야 하는지 알아내는 것.
  • 객체지향 설계: 그 일을 어떻게 해야 하는지를 정의하는 것.
  • 객체지향 프로그래밍: 앞에서 진행한 분석과 설계의 결과를 코드로 구체화하는 것.

2.1.4 UML에 대한 참고 사항

객체지향이나 디자인 패턴을 설명할 때, 설계 사상을 표현하기 위해 UML을 활용한다. UML에는 클래스 다이어그램뿐만 아니라, 유스케이스 다이어그램, 시퀀스 다이어그램, 상태 다이어그램 등 많은 구성 요소를 갖는 복잡한 도구다.

이를 배우는 것은 어려운 일이며 클래스 간의 관계를 완전히 파악하고 능숙하게 사용하여 UML 클래스 다이어그램을 그리는 데는 많은 학습이 필요하다.

 

사실 UML은 인터넷 회사의 프로젝트 개발에 그다지 유용하지 않다.

소프트웨어 설계를 문서화하거나 소프트웨어 설계에 대한 논의를 쉽게 하기 위해서는 간단하게 스케치하는 것만으로도 충분하기 때문이다.

또한 완벽하게 UML로 표준화된 문서를 작성하고 의사소통할 수 있더라도 실제 그로 인해 얻을 수 있는 이익이 크지 않은 경우가 많다.

 

이 책에 수록된 클래스 다이어그램은 UML 사양을 단순화했으며, 제공하는 이유는 설계에 대한 명확한 이해를 제공하기 위함 때문이다.

2.2 캡슐화, 추상화, 상속, 다형성이 등장한 이유

캡슐화, 추상화, 상속, 다형성 4가지 특성에 대해 정의하는 것만으로 완전히 이해했다고 할 수 없다.

그 특성들이 존재하는 의미와 그 특성들을 이용해야만 해결할 수 있는 프로그래밍 문제도 함께 생각해내야 한다.

 

실제 코드를 이용해서 각 특성에 대해 문제를 파악하는 방법을 살펴본다.

2.2.1 캡슐화 Encapsulation

캡슐화는 정보 은닉 또는 데이터 액세스 보호라고 한다. 접근 가능한 인터페이스를 제한하여 클래스가 제공하는 메서드를 통해서만 내부 정보나 데이터에 대한 외부 접근을 허가하는 것을 뜻한다.

 

금융 시스템에서 사용자의 가상 통화 금액을 기록하기 위해 사용자마다 가상 지갑을 생성한다고 가정해 보자.

아래는 단순한 형태의 금융 시스템 가상 지갑을 캡슐화를 통해 구현한 코드이다.
class Wallet {
    private val id: String = IdGenerator.getInstacne().generate()
    private val createTime: Long = System.currentTimeMillis()
    private var balance: BigDecimal = BigDecimal.ZERO
    private var balanceLastModifiedTime: Long = 0

    constructor() {
        this.balance = BigDecimal.ZERO
        this.balanceLastModifiedTime = System.currentTimeMillis()
    }

    fun getId(): String = id
    fun getCreateTime(): Long = createTime
    fun getBalance(): BigDecimal = balance
    fun getBalanceLastModifiedTime(): Long = balanceLastModifiedTime

    fun increaseBalance(increasedAmount: BigDecimal) {
        if (increasedAmount < BigDecimal.ZERO) {
            throw InvalidAmountException("The amount to increase must be positive")
        }

        balance = balance.add(increasedAmount)
        balanceLastModifiedTime = System.currentTimeMillis()
    }

    fun decreaseBalance(decreasedAmount: BigDecimal) {
        if (decreasedAmount < BigDecimal.ZERO) {
            throw InvalidAmountException("The amount to decrease must be positive")
        }

        if (decreasedAmount > balance) {
            throw InsufficientBalanceException("The amount to decrease must be less than current balance")
        }

        balance = balance.subtract(decreasedAmount)
        balanceLastModifiedTime = System.currentTimeMillis()
    }
}

 

코드에서 Wallet 클래스는 멤버 변수로

  • id: 지갑 고유 번호
  • createTime: 지갑 생성 시간
  • balance: 지갑 잔액
  • balanceLastModifiedTime: 지갑 잔액이 마지막으로 변경된 시각

을 갖고 있다.

 

캡슐화 특성을 이용해서 Wallet 클래스는 데이터에 직접 접근하는 것을 제한하고 있다. 따라서 메서드를 통해서만 데이터에 접근하거나 값을 변경할 수 있게 된다.

이를 통해

  • 가상지갑을 생성하고 id, createTime에 대한 setter를 제공하지 않으므로서 변경을 방지한다. 
  • 지갑 잔액 속성은 증가/감소 경우만 존재하며 재설정되면 안 되므로 관련 함수만 제공한다.
  • 지갑 잔액 변경 시각은 지갑 잔액이 업데이트 될 때만 같이 수정되도록 한다.

위와 같이 캡슐화를 통해 잔액과 잔액 변경 시각이 같은 시기에 변경되었음을 보장하고 제공되는 함수 외의 작업을 제한할 수 있다.

 

이러한 캡슐화의 특성은 프로그래밍 언어 자체에서 이를 지원하는 문법을 제공해야 하며, 이를 접근제어라고 한다. 코드의 private, pulbic은 접근제어자이다.

 

만약 Java가 접근제어자를 제공하지 않고 모든 속성, 함수, 클래스가 public이라면

정보를 은닉하고 보호하는 목적을 달성할 수 없게 되며, Java 언어는 캡슐화를 지원하지 않는 언어가 되는 것이다.

 

2.2.1 추상화

캡슐화가 주로 정보를 숨기고 데이터를 보호하는 반면, 추상화는 메서드의 내부 구현을 숨기는 것을 의미한다.

따라서 클래스를 사용할 때 기능의 구현 방식에 대해 고민하지 않고 메서드가 제공하는 기능에만 집중할 수 있다.

 

객체지향 프로그래밍 언어의 경우 Java의 interface, abstract 키워드 처럼 프로그래밍 언어에서 제공하는 인터페이스와 추상화 클래스의 두가지 문법을 통해 추상화 특성을 구현하게 된다.

 

아래는 추상화 예제 코드이다.
interface IPictureStorage {
    fun getPicture(pictureId: String): Image
    fun savePicture(picture: Picture)
    fun deletePicture(pictureId: String)
    fun modifyMetaInfo(pictureId: String, metaInfo: PictureMetaInfo)
}

class PictureStorage : IPictureStorage {
    override fun getPicture(pictureId: String): Image {
        TODO("Not yet implemented")
    }

    override fun savePicture(picture: Picture) {
        TODO("Not yet implemented")
    }

    override fun deletePicture(pictureId: String) {
        TODO("Not yet implemented")
    }

    override fun modifyMetaInfo(pictureId: String, metaInfo: PictureMetaInfo) {
        TODO("Not yet implemented")
    }
}

위 코드에서 이미지 저장 기능을 사용할 때는 IPictureStorage의 savePicture 메소드만 알면 되며, PictureStorage 클래스의 내부 구현에 대해 확인할 필요가 없게 된다.

 

그런데 추상화 특성은 인터페이스나 추상화 클래스와 같은 특별한 문법에 의존하지 않아도 구현할 수 있다. 
=> 즉, 굳이 구현 클래스인 PictureStorage에 대한 인터페이스 IPictureStorage를 추상화할 필요가 없다는 뜻이다.

=> 즉, IPictureStorage를 작성하지 않아도 PictureStorage 클래스 자체가 이미 추상화 특성을 갖고 있다는 뜻이다.

 

이렇게 말할 수 있는 이유는, 클래스의 메서드가 프로그래밍 언어에서  함수라는 문법을 통해 구현되기 때문이다.

 

실제로 코드의 구현 내용은 그 자체로 추상화되는 함수의 내부에 포함된다. 우리가 함수를 사용할 때 해당 함수가 내부적으로 어떻게 동작하는지 알지 않아도 함수의 이름, 인자, 반환값 등을 주석/문서를 통해 확인할 수 있다면 바로 사용이 가능하다.

우리가 C언어의 malloc() 함수를 사용할 때 내부적으로 어떻게 메모리가 할당하는지 알 필요가 없다는 뜻이다.

 

이러한 이유로 추상화 특성을 구현하기 위해 프로그래밍 언어가 특별한 문법을 제공할 필요가 없으며, 함수라는 기본 문법만 제공해도 충분한 것이다. 따라서 추상화는 특이성이 높지 않고, 객체지향 프로그래밍 언어의 특성으로 간주되지 않는 경우가 생기는 것이다.

 

클래스의 메서드를 정의하거나 이름을 붙일 때 추상화를 위한 사고가 필요하다.

추상화가 제대로 되지 않은 함수명이라면 메서드의 정의를 수정할 필요가 없을 때 메서드의 정의를 변경해야 한다.

예를 들어, getNaverCloudPictureUrl()은 추상적인 사고의 결과로 지어진 이름이 아니다. 추후 네이버 클라우드 대신 다른 곳에 사진을 저장하게 되면 이 메서드의 이름도 같이 수정되어 하기 때문이다.

따라서, 메서드의 이름을 지을 때 getPictureUrl()과 같이 보다 추상적인 이름을 사용하는게 좋다.

2.2.1 상속

상속은 '고양이는 포유류의 일종이다'처럼 클래스 사이의 'is - a' 관계를 나타내는 데 사용된다.

상속은 단일 상속과 다중 상속으로 나뉠 수 있고, 상속은 추상화와 다르게 프로그래밍 언어가 상속을 표현하는 문법을 별도로 지원해야 한다.

  • Java: extends 키워드 --- class Cat extends Animal
  • Kotlin: '콜론(:)' --- class Cat: Animal()
  • C++: '콜론(:)' --- class Cat: public Animal
  • Python: '()' --- class Cat(Animal)
  • Ruby: '<' --- class Cat < Animal

이때 주의할 점은 Java, PHP, C#, Ruby 등은 단일 상속만 지원하며 다중 상속은 지원하지 않는다.

반면 C++, Python, Perl과 같은 언어는 단일 상속, 다중 상속을 모두 지원한다.

 

상속의 특성은 이해하기도 쉽고 사용하기도 쉽다. 하지만 과도하게 사용하는 경우, 즉 상속 계층 구조가 너무 깊고 복잡하면 코드의 가독성과 유지 관리성이 떨어진다.

 

상속은 논란의 여지가 있는 특성이기도 하다. 상속은 적게 사용해야 하며, 심지어는 사용하면 안되는 안티 패턴으로 간주되기도 한다. 이와 관련해서는 2.9절 '상속보다는 합성' 설계 사상에서 더 자세히 이야기한다.

2.2.1 다형성

다형성이란 코드를 실행하는 과정에서 하위 클래스를 상위 클래스 대신 사용하고, 하위 클래스의 메서드를 호출할 수 있는 특성을 말한다.

아래는 상속과 메서드 오버라이딩을 통한 다형성 예제 코드이다.
open class DynamicArray {
    companion object {
        const val DEFAULT_CAPACITY = 10
    }

    protected var size: Int = 0
    protected var capacity: Int = DEFAULT_CAPACITY
    protected var elements: Array<Int?> = arrayOfNulls(DEFAULT_CAPACITY)

    fun size(): Int {
        return size
    }

    fun get(index: Int): Int = elements[index]!!

    open fun add(e: Int) {
        ensureCapacity()
        elements[size++] = e
    }

    protected fun ensureCapacity() {
        // TODO(배열이 가득 찼을 때 배열의 크기를 확장하는 코드)
    }
}

class SortedDynamicArray : DynamicArray() {
    override fun add(e: Int) {
        super.ensureCapacity()
        var index = 0
        for (i in size - 1 downTo 0) {
            if (elements[i]!! > e) {
                elements[i + 1] = elements[i]
            } else {
                index = i
                break
            }
        }
        elements[index + 1] = e
        size++
    }
}

fun test(dynamicArray: DynamicArray) {
    dynamicArray.add(3)
    dynamicArray.add(1)
    dynamicArray.add(2)
    dynamicArray.add(5)
    dynamicArray.add(4)

    for (i in 0..dynamicArray.size()) {
        println(dynamicArray.get(i)) // 출력 결과: 1, 2, 3, 4, 5
    }
}

fun main() {
    val dynamicArray: DynamicArray = SortedDynamicArray()
    test(dynamicArray)
}

다형성의 특성을 구현하기 위해서는 프로그래밍에서 제공하는 특별한 문법이 필요하다.

  1. 상위 클래스 객체가 하위 클래스 객체를 참조할 수 있어야 한다.
    • SortedDynamicArray 클래스 객체를 DynamicArray 클래스 객체에 전달할 수 있어야 한다.
  2. 상속을 지원해야 한다.
    • SortedDynamicArray 클래스는 DynamicArray 클래스를 상속받으므로, SortedDynamicArray 클래스 객체가 DynamicArray 클래스 객체에 전달될 수 있다.
  3. 상위 클래스의 메서드를 재정의하는 하위 클래스를 지원해야 한다.
    • SortedDynamicArray 클래스는 DynamicArray 클래스의 add() 메서드를 재정의한다.

이 3가지 문법 구조를 통해서 하위 클래스인 SortedDynamicArray 클래스를 구현하여, 상위 클래스인 DynamicArray 클래스를 대체하고 하위 클래스인 SortedDynamicArray 클래스의 add() 메서드를 실행하여 다형성을 실현하고 있다.

 

상속과 메서드 재정의 방식을 통한 다형성 외에도 인터페이스 문법 방식, duck-typing 문법 방식을 통한 구현 방법이 일반적으로 사용된다.

하지만 모든 프로그래밍 언어에서 인터페이스 문법과 duck-typing 방식을 지원하지는 않는다.

예를 들어 C++에서는 인터페이스를 미지원하고 duck-typing 문법은 python, javascript 같은 동적 언어에서만 지원한다.

 

아래는 인터페이스 문법을 이용한 다형성 예제 코드이다.
interface Iterator {
    fun hasNext(): Boolean
    fun next(): String
    fun remove(): String
}

class Array : Iterator {
    val data = arrayOf<String>()

    override fun hasNext(): Boolean {
        TODO("Not yet implemented")
    }

    override fun next(): String {
        TODO("Not yet implemented")
    }

    override fun remove(): String {
        TODO("Not yet implemented")
    }
}

class LinkedList : Iterator {
    val data = arrayOf<String>()

    override fun hasNext(): Boolean {
        TODO("Not yet implemented")
    }

    override fun next(): String {
        TODO("Not yet implemented")
    }

    override fun remove(): String {
        TODO("Not yet implemented")
    }
}

class Demo {
    private fun print(iterator: Iterator) {
        while (iterator.hasNext()) {
            println(iterator.next())
        }
    }

    fun main() {
        val array: Iterator = Array()
        val linkedList: Iterator = LinkedList()

        print(array)
        print(linkedList)
    }
}

위 코드에서 Iterator는 컬렉션 데이터를 순회할 수 있는 반복자를 정의하는 인터페이스이며,

Array 클래스, LinkedList 클래스는 모두 Iterator 인터페이스를 구현하고 있다.

 

이때 Array, LinkedList 클래스는 서로 다른 유형의 클래스이지만 print(iterator: Iterator) 메서드에 클래스의 객체를 전달해서 서로 다르게 오버라이딩 된 hasNext(), next() 메서드를 호출하고 있다.

 

아래는 duck-typing 방식의 다형성 예제 코드이다.
class Logger: 
    def record(self):
        print("i write a log into file.")
        

class DB:
    def record(self):
        print("i insrt data into db.")
        
def test(recorder):
    recorder.record()
    
def demo():
    logger = Logger()
    db = DB()
    test(logger)
    test(db)
    
demo()

위 코드를 보면 알 수 있듯이 duck-typing 방식을 사용하면 다형성을 훨씬 유연하게 구현할 수 있다.

Logger 클래스와 DB 클래스는 서로 아무런 관련이 없다. (공통 상위 클래스도, 공통 인터페이스를 상속하지도 않고 있음)

그럼에도 두 클래스 모두 record() 메서드를 정의만 해주면 test() 메서드에 객체를 전달할 수 있고, 적합한 record() 메서드를 찾아서 실행할 수 있다.

 

즉, 두 클래스가 동일한 메서드를 갖고 있드면 두 클래스 간의 관계를 요구하지 않고도 다형성을 달성할 수 있는 방식인 것이다.

 

다형성은 많은 디자인 패턴, 설계 원칙, 프로그래밍 테크닉 코드 구현의 기초이다. 뒤에서 더 자세히 다뤄보기로 한다.

 

End.

반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday