본문 바로가기

카테고리 없음

[OOP] 객체 지향 프로그래밍은 왜 생겨났을까?

개발자 구인 공고글을 보면 심심치 않게 OOP라는 단어들이 자주 보이곤 한다.

OOP에 대한 깊은 이해.. OOP기반의 프로그램 설계 능력..같은 글이 보이는데, 대학 시절부터 지겹게 들어왔던 이 객체 지향의 의미와 탄생 배경 그리고 또다른 패러다임을 알아보고자 한다.

 

프로그래밍 패러다임이란?

프로그래밍은 프로그램 만드는 일이라고 해석하면 되겠고, 그럼 패러다임은 어떤뜻일까?

어떤 한 시대 사람들의 견해나 사고를 근본적으로 규정하고 있는 테두리로서의 인식의 체계, 또는 사물에 대한 이론적인 틀이나 체계를 의미하는 개념이다.
- 표준국어대사전-

즉, 예전 어떤 이가 설계한 아키텍처를 우리가 코드를 작성할 때 약속된 규약에 맞춰 개발하는 기법이다.

 

코드의 문법만 아는 코린이들에게 프로젝트 주제를 하나 던져주고 독방에 가둬둔다 했을 때, 그리고 그 결과물을 다른 개발자가 넘겨받게 되고 유지보수를 하게 된다면 해당 개발자는 차리리 처음부터 다시 시작할 마음이 넘쳐날 것이다. 이를 위해 우리는 어느정도 약속된 규약에 맞춰 개발을 수행할 필요가 있다.

 

하지만 독방에 있는 코린이들에게 유지보수를 맡긴다면 제한된 환경에서 자기들끼리 해답을 찾고 점점 코드 작성의 적절한 방향을 찾아 나갈 것이다.

 

위의 코린이는 소포트웨어 탄생 시절의 개발자들, 독방은 그 시절로 비유된다. 과연 코린이들이 어떤 생각을 가지고 방향을 찾아갔을까?

프로그래밍 패러다임의 역사

사실 프로그래밍 패러다임은 굉장히 많다. 구조적, 비구조적, 명령형, 선언형, 절자척, 함수형, 값수준, 흐름처리, 규칙 기반 등등 아마 위의 코린이들 말고 다른 독방 또한 많았나보다.

 

이 중 절차적 프로그래밍에서 객체 지향 프로그래밍으로 넘어간 배경을 살펴보자.

절차적 프로그래밍에서 객체 지향 프로그래밍

A 기능을 할 수 있는 프로그램을 개발한다. 그럼 이 프로그램은 기능을 수행하기 위해 여러 코드 구문을 거치게 된다. 해당 구문을은 cpu가 연산할 수 있게 메모리에 올라가게 되고, 메모리에 올라간 코드들은 큐 방식(FIFO)으로 cpu에게 연산되게 된다. 이처럼 코드를 작성한 시점부터 마지막 단계까지 절차적으로 연산되는 방식, 절차적 프로그래밍이라고 한다.

근데 의문이 든다. 다른 프로그래밍 언어들은 그럼 랜덤으로 코드가 읽히나?

 

적어도 내가 봐봤던 언어들은 다 차례로 컴파일이 된다. 그럼 왜 절차적 프로그래밍은 이렇게 소개가 되었을까? 아마 초창기 등장했던 프로그래밍들과 그 이후 탄생했던 프로그래밍들을 구분하기 위해 해당 용어를 사용하지 않았나 싶다.

 

이렇게 프로그램을 개발하다 보면 아마 특정 구문들은 반복적으로 실행되는 시점이 분명 오게된다. 이러한 반복적인 구문을 재사용하기 위해 함수가 나오게 된다. 그리고 함수를 여러개 생성하다 보면 각각의 함수끼리의 관계가 발생하게 되는데 이는 서로 연관된 함수일 수도, 아니면 아예 다른 성질일 수도 있다.

 

개발을 마쳤다. 옛날에는 이렇게 한 파일만으로도 동작할 수 있는 프로그램들이 대부분이었다. 예전 게임같은 것들은 생각해보면 매우 단순했던 기억이 난다. 또한 저장장치나 메모리의 크기 한계 때문에 오는 압박도 필히 존재했을 것이다.

 

점차 하드웨어의 성능이 높아지면서 소프트웨어의 기능 또한 추가가 되었다. 그래서 A 프로그램에게도 다양한 기능들이 추가가 되고, 기능별로 파일을 나눠 개발을 진행한다. 이 또한 기능 당 파일끼리 또한 관계가 발생하게 된다.

뭔가 느껴진다.. 객체 지향이 나올 순간이..

위처럼 하나의 기능을 담당하고 있는 파일을 객체라 부르며 이 객체끼리의 관계를 논하는 것을 객체 지향 프로그래밍이라고 할 수 있다.

 

사실 위처럼 이미 절차적 프로그래밍(C언어)에서도 이미 객체 지향 비슷하게 모듈화 프로그래밍을 해왔었다. 하지만 객체 지향 프로그래밍에서는 이를 파일단이 아닌 코드적으로 지원하는 클래스가 나오게 된 것이다.

객체 지향 프로그래밍의 특징

위에 언급했듯, 객제 지향은 객체들간의 관계를 논하는 패러다임이다. 여기서 관계의 종류에는 집합, 의존, 상속 등이 있고 이에 따라오는 은닉, 추상화 등이 있다. 모두가 아는 객체 재향의 대표적인 특징 4가지를 보자

상속

상속은 객체 지향의 대표적인 특징이다. 상속이란 다른 객체의 특징을 또 다른 객체가 물려받는 것을 의미한다. Android에선 컴포넌트를 사용하기 위해 Activity, Service, BroadcastReceiver, ContentProvider를 상속하게 된다. 이 상속은 어떤한 장점이 있을까? 바로 내가 코드를 작성하지 않아도 부모 객체(컴포넌트)의 기능을 물려받아 사용할 수 있게 된다. 흔히 사용되는 BaseActivity또한 Activity를 활용하기 위해 반복적인 구문들을 모아놓은 클래스로 사용되곤 한다.

class ViewModel {
    private val aUseCase: AUseCase
    fun run() {
        aUseCase.run()
    }
}

class ViewModel: AUseCase {
    fun run() {
        run()
    }
}

위 차이를 보면 첫번째 클래스는 다른 클래스의 참조를 통해 구문을 실행한다. 그에 반해 두번째 클래스는 참조가 필요없이 바로 해당 구문을 실행할 수 있게된다. 또한 외부의 객체가 접근을 할 때도 AUseCase의 메서드를 바로 호출할 수 있다.

상속을 하게되면 마치 상속한 객체 그 자체로 된다고도 볼 수 있다. 그럼 2개 이상의 상속을 하게 되면 어떻게 될까?

class ViewModel {
    private val aUseCase: AUseCase
    private val bUseCase: BUseCase
    fun run() {
        aUseCase.run()
        bUseCase.run()
    }
}

class ViewModel: AUseCase, BUseCase {
    fun run() {
        run()
        run()
    }
}

보는거와 같이 사람또한 어떤 객체의 메서드가 호출되는지를 모른다. 흔히 다중상속의 문제점이라고도 말하는데, 이와같이 혼란을 야기할 수 있기에 자바와 코틀린에서 금지된 문법이다.

 

그에 반해 추상 메서드는 가능하다. 이유는 부모 클래스에서 구현된 메서드를 호출하는 것이 아닌 구현할 클래스에서 강제로 구현을 해야하기 때문에 겹치든 말든 어떤 동작이 실행될 지 명확하기 때문이다.

 

라고 다른글들에서 다이아몬드 문제점이라 말하는데, 사실 언어의 버전이 높아가면서 추상 메서드또한 디폴트 접근이 가능해졌다.

class B: A(), IA, IB {
    override fun abc(): String {
        return if (0 > 1) super<IA>.abc()
        else super<IB>.abc()
    }
}

interface IA {
    fun abc() : String = "IA"
}

interface IB {
    fun abc() : String = "IB"
}

코틀린에서도 다중 상속이 되지 않아 interface를 통해 구현했다. 위처럼 타입을 명시하게 되면 어느 객체의 메서드를 호출할건지 선택할 수 있다. 하지만 언어에서 하지 말라면 이유가 있는법.. 상속의 문제점은 다음과 같다.

 

- 강한 결합: 상속은 부모 클래스와 자식 클래스 간의 긴밀한 결합을 만들어 향후 코드를 수정하거나 유지하기 어렵게 만든다.

- 취약한 부모 클래스 문제: 부모 클래스가 변경되면 파생된 모든 클래스에서 의도하지 않은 부작용이 발생하여 설계가 취약해질 수 있다.

- 유연하지 않은 계층: 일단 클래스 계층이 생성되면 변경하기가 어려움으로 새 기능을 추가하거나 기존 기능을 변경하면 클래스 계층이 복잡해질 수 있다.

- 제한된 재사용 가능성: 상속은 "is-a" 관계만 지원하므로 경우에 따라 재사용 가능성이 제한될 수 있다.

 

가장 핵심적인 말은 is-a라는 용어다. 필히 상속을 설계하려 할때는 정말 핵심적이고 변하지 않을 공통적인 부분만 추상화하여 부모 클래스를 만들어야 추후에 의도치 않을 버그가 발생되지 않는다.

추상화

위에서 is-a라는 용어를 언급했다. 다양한 객체에서 공통적이거나 핵심적인 부분을 뽑아내는 것을 추상화라고 하는데 뽑아낸 추상화 개념과 뽑아진 객체의 비교를 is-a에 의해 성립이 되는지를 검토해야한다.

 

만약 주유소에 대한 객체를 생성한다고 치자. 주유소에 종류에는 경유, 휘발유, 등유, 요즘엔 전기 및 수소 등 다양한 것들이 있다. 이때 이들을 추상화한다고 하면 주유소의 이름, 위치, 기기 개수정도를 뽑아낼 수 있다.

 

부모 객체에서 is-a관계를 생각한다면 "과연 이후에 파생될 객체에서도 이 속성들을 무조건 포함할 수 있을까?"를 봐야하고 자식 객체에서는 "상속받은 속성들이 정말 나에게 필요한걸까?"를 봐야한다.

 

가령 주유소인 부모 객체에 휘발유, 경유, 등유만 있다고 쳐서 리터 당 가격을 속성으로 넣어놨으면 추후에 추가될 전기, 수소는 이 속성과는 무관하지만 어쩔 수 없이 물려받아 오버라이딩을 해야하는 상황이 오게된다. 또 이 잘못된 현상을 모르는 개발자는 리터 당 가격에 맞지 않은 값을 넣게 되면 버그로 이어질 가능성이 농후하다.

캡슐화

속성을 감춤으로써 외부에선 접근이 불가하게 만드는 기법을 말한다.

 

우리는 왜 필요한 속성만 외부로 노출되게 해야하고 그 외에 것들은 감춰야 할까?

첫번째는 버그 발생 시 추적 용이함에 있어서이다. 객체에 접근할 수 있는 진입점이 많아질수록 통제하기 어려워지고 버그가 발생되면 대응할 비용이 높아지게 된다. 

 

이는 코드로 볼 것도 없이 코로나 유행 시 공항을 생각해보자. 공항에 게이트가 A, B, C가 있다고 하고 방역 검문소는 게이트 별로 있다고 치자. 그리고 나중에 이 공항에서 검사 결과가 양성이 뜬 승객이 있다고 했을 때 검토해야할 관문은 A, B, C게이트이다. 근데 만약 게이트가 10개다? 그럼 10개의 진입점에서 검토를 해야한다. 코딩도 이와 같이 한 객체의 담당 기능에서(공항) 에러가 났을 때(양성) 검토해야 할 개수는 public 제어자(게이트)이다. 이유는 내부 에러에 의해 외부에도 영향이 가면 안되기 때문이다.

 

두번째로는 자주 겪는 버그인데 주소값을 넘겨주는 상황일 때다.

class AUseCase {
    private val list = mutableListOf<Int>()
    
    fun run(): MutableList<Int> {
    	// 로직 수행
    	list
    }
}

class ViewModel {
    private val aUseCase = AUseCase()
    
    fun run() {
        val list = aUseCase.run()
        list.add(1)
    }
}

위처럼 aUseCase에서 필요 메서드를 호출하게 되면 특정한 로직이 수행된 후 list를 반환한다. 근데 받은 ViewModel쪽에서 자기가 필요한거 한다고 list에다 add를 시켜버렸다. 하지만 list는 전역변수다. GC에 의해 수거되지 않는한 list도 살아있다. 추후에 또다시 해당 메서드에 접근하게 되면, add로 인해 데이터가 오염된 상태로 나오게 되는 상황이 발생한다.

 

우리가 흔히 아는 캡슐화 기법의 getter, setter는 단순히 메서드로 값을 저장 및 반환하는게 아니라 데이터의 오염을 방지하기 위해 특정 로직또한 수행할 수 있어야 한다. 

class AUseCase {
    private val list = mutableListOf<Int>()
    
    fun run(): MutableList<Int> {
    	// 로직 수행
    	list.toMutableList()
    }
}

class ViewModel {
    private val aUseCase = AUseCase()
    
    fun run() {
        val list = aUseCase.run()
        list.add(1)
    }
}

List를 캡슐화하는 방법은 여러가지다. 불변의 형태(Immutable)로 타입을 변경하거나 copy를 통해 내부 값과 일체 관련없는 새로운 객체를 생성하여 반환하는 방법이 있다.

 

위 list예시 말고도 내부에서 고유하게 사용되는 데이터들을 외부에서 접근 허용하게 된다면 예상치 못한 버그를 맞이한다. 근데 어떤 개발자가 일부러 데이터를 조작하겠나, 우리는 1명이서 개발하는게 아니다.. 이 말은 즉슨 내가 아는 것을 다른 사람도 안다는 보장이 없기 때문이다.

 

캡슐화엔 접근 제어자 말고도 많은 방법들이 존재한다.

다형성

서로 다른 클래스의 객체가 같은 동작 수행 명령을 받았을 때, 각자의 특성에 맞는 방식으로 동작하는 기법을 방한다. 풀어쓰면 "야 너 카톡 프로질좀 줘봐. 대신 뭐 어떻게 해도 상관 없으니 결과물만 만족하면 돼"라고 할 수 있다. 위임한 객체가 서버에서 가져오든, 내부 DB에서 가져오든, 직접 그려서 주던 상관 없으니 요청한 동작에 맞으면 된다는 의미다. 이래서 다형성이라 불린다.

 

다형성의 예로는 오버라이딩과 오버로딩이 있다.

override, 오버라이딩

위의 예시처럼 추상 메서드나 재정의 할 수 있는 메서드를 자신의 색깔에 맞게 재정의 하는것이다. 오버라이딩을 잘 활용하면 분기문을 없애는 깔끔한 코드를 작성할 수 있다.

    fun run() {
        val korea = Korea()
        korea.setMoney(10000)
        val usa = USA()
        usa.setMoney(10000)
        println(getFormattingMoney(korea))
    }
    
    private fun getFormattingMoney(money: Any) {
        when (money) {
            is Korea -> DecimalFormat("#,###").format(money.getMoney()) + "원"
            is USA -> DecimalFormat("#,###").format(money.getMoney()) + "달러"
        }
    }

위는 각 나라별 화폐 단위를 출력한다. 보다시피 객체의 타입을 추론하여 그에 맞는 데이터를 반환하는데, 개수가 늘어나면 날 수록 코드가 더러워질것이 뻔해보인다. 이를 다형성으로 해결해보자

    fun run() {
        val korea = Korea()
        korea.setMoney(10000)
        println(korea.getFormattingMoney())
    }

    interface MoneyFormat {
        fun getFormattingMoney(): String
    }

    class Korea: MoneyFormat {
        private var money: Int = 0

        fun setMoney(money: Int) {
            this.money = money
        }

        fun getMoney() = money

        override fun getFormattingMoney(): String =
            DecimalFormat("#,###").format(money) + "원"

    }

    class USA: MoneyFormat {
        private var money: Int = 0

        fun setMoney(money: Int) {
            this.money = money
        }

        fun getMoney() = money

        override fun getFormattingMoney(): String =
            DecimalFormat("#,###").format(money) + "달러"

    }

다형성을 활용하게 되면 우선 타입 자체를 한가지로 지정할 수 있게 된다. 또한 구현부를 Caller가 아닌 Callee가 담당하게 되어 분기문이 필요 없어지게 된다. 이로써 동작에 따른 결과물만 지정하고 내부 구현부는 객체의 색에 맞게 지정하는 다형성이 완성되었다.

overload, 오버로딩

잘 알다싶이 오버로딩은 같은 이름의 메서드를 타입과 파라미터를 다르게 하여 여러 개 생성하는 것이다. 메서드의 이름이 만일 getTime으로 설정해두고 우리나라의 시간을 반환하는 역할을 하는데, 다른 나라의 시간을 줘야할때가 있다고 치자. 그래도 이름은 getTime으로 사용하고 싶을 땐 파라미터로 나라를 받아 로직을 구성하는 형태로 변경할 수 있다.