알고 보면 재밌는 객체 지향 프로그래밍, OOP 흝어보기

    알고 보면 재밌는 객체 지향 프로그래밍, OOP 흝어보기


    이번 포스팅에서는 객체 지향 프로그래밍(Object-Oriented Programming), 줄여서 흔히들 OOP라고 부르는 설계 방법론에 대해서 이야기해보려고 한다. OOP는 프로그래밍의 설계 패러다임 중 하나로, 현실 세계를 프로그램 설계에 반영한다는 개념을 기반으로 접근하는 방법이다. OOP는 90년대 초반부터 유명해지기 시작했지만 아직까지도 전 세계의 많은 개발자들이 사용하고 있는 설계 패턴 중 하나이기 때문에 알아둬서 나쁠 건 없다.

    객체 지향 프로그래밍을 왜 알아야 하나요?

    사실 OOP가 오랜 기간동안 전 세계에서 사랑받고있는 설계 패턴인 것은 맞지만 최근에는 OOP의 단점을 이야기하며 함수형 프로그래밍과 같은 새로운 설계 패러다임이 각광받기도 했다. (함수형 프로그래밍도 사실 꽤 오래된 패러다임이다) 사실 OOP함수형 프로그래밍이니 하는 이런 것들은 결국 프로그램을 어떻게 설계할 것인가?에 대한 방법이기 때문에 당연히 장단점 또한 존재하기 마련이고 시대나 용도에 맞게 개선된 패러다임이 제시되는 것은 자연스러운 흐름이다.

    필자는 개인적으로 아직까지 OOP가 괜찮은 설계 패턴이라고 생각하고 있지만, 여러분은 함수형 프로그래밍이 OOP보다 더 효율적이고 괜찮다고 생각할 수도 있다.

    당연히 어떤 패러다임을 선호하는지는 개인의 자유기 때문에 다르게 생각할 수 있지만, 어떤 기술을 선택할 때는 해당 기술의 장단점과 그 기술을 선택했을 때 얻을 수 있는 것과 잃을 수 있는 것을 제대로 파악하고 있어야 올바른 선택을 할 수 있기 때문에 여러분이 함수형 프로그래밍을 선택한다고 하더라도 OOP가 무엇인지 알고 있어야 하는 것은 마찬가지다.

    또한 OOP는 1990년대 초반부터 2019년인 현재까지도 모던 프로그래밍 설계에 중요한 역할을 하고 있는 개념이다. 아무리 함수형 프로그래밍과 같은 새로운 패러다임이 주목받기는 했지만 아직까지는 OOP가 대부분의 프로그램 설계에 사용되고 있다는 사실은 부정할 수 없는 현실이며, 이게 바로 우리가 OOP를 좋은 싫든 알고 있어야 하는 현실적인 이유 중의 하나이다. (참고로 Java, Python, C++ 등 메이저 언어들도 전부 OOP를 지원하는 언어이다.)

    그래서 이번 포스팅에서는 OOP가 추구하는 것이 무엇인지, 또 OOP를 이루고 있는 개념들은 무엇이 있는지 간략하게 살펴보려고 한다.

    객체 지향이라는 것은 무엇을 의미하나요?

    OOP의 의미인 Object-Oriented Programming의 Object-Oriented를 한국말로 그대로 직역하면 객체 지향이다. 여기서 말하는 객체는 현실 세계에 존재하는 하나 하나의 독립된 무언가를 의미한다. 보통 OOP를 배울 때 가장 처음 접하는 개념이 바로 이 객체라는 개념인데, 사실 한번 이해하고나면 꽤 간단한 개념이지만 우리가 평소에 살면서 잘 생각해보지 않는 개념이기 때문에 잘 이해가 되지 않을 수도 있다.

    객체를 설명하기 위해서는 클래스라는 개념을 함께 설명해야하는데, 용어가 직관적이지 않아서 그렇지 조금만 생각해보면 누구나 다 이해할 수 있는 개념이다. 일반적으로 이걸 설명할 때 붕어빵과 붕어빵 틀과 같은 비유를 들며 설명하지만 필자는 일반적인 설명과 다르게 클래스는 무엇이고, 객체는 무엇이다라는 방식으로 접근하기보다는 일단 OOP의 포괄적인 설계 개념을 먼저 설명하는 방식으로 접근하도록 하겠다.

    재미없고 복잡한 용어는 일단 제쳐두고 일단 예시를 보면서 의식의 흐름대로 따라와보자.

    클래스와 객체

    필자는 이 포스팅의 서두에서 OOP란 현실 세계를 프로그램의 설계에 반영하는 것이라고 이야기했다. 이 말이 뜻하는 의미를 먼저 이해하고 나면 클래스나 객체 같은 것은 자연스럽게 이해할 수 있으니 먼저 OOP가 왜 현실 세계를 반영한 설계 방식이라고 하는 지를 먼저 알아보도록 하자.

    뭐 여러가지 예시가 있겠지만 우리가 일상적으로 사용하고 있는 물건을 예로 드는 것이 좀 더 와닿을테니 필자는 스마트폰을 예로 들어서 설명을 진행하려고 한다. 필자는 애플에서 만든 아이폰7이라는 기종을 사용하고 있기 때문에 아이폰7을 예시로 설명을 시작하겠다.

    iphone7

    먼저, 우리가 아이폰7이라는 것을 프로그램으로 구현하고 싶다면 제일 먼저 아이폰7이 무엇인지부터 정의해야한다. 너무 어렵게 생각할 필요없다. 진짜로 프로그램을 짜는 것이 아니기 때문에 대충 정의해도 된다.

    필자가 지금 바로 생각해낸 아이폰7은 약간 동글동글한 바디를 가지고 있고 햅틱 엔진이 내장된 홈 버튼을 가지고 있으며, 시리즈 최초로 3.5mm 이어폰 단자가 없어진 아이폰 시리즈라는 것이다. (개인적으로 이어폰 단자 좀 다시 넣어줬으면 한다…)

    우리는 여기서 한발짝 더 나아가서 아이폰7의 상위 개념인 아이폰에 대해서도 정의해볼 수 있다. 결국 아이폰7은 아이폰이라는 개념을 기반으로 확장된 개념이기 때문이다.

    그럼 아이폰은 무엇일까? 아이폰은 애플에서 제조한 스마트폰으로, iOS를 사용하고 있는 스마트폰 시리즈의 명칭이다. 이때 아이폰은 아이폰7 외에도 아이폰X, 아이폰8, 아이폰 SE 등 수많은 아이폰 시리즈의 제품들을 포함하는 좀 더 포괄적인 개념이다.

    일상 속에서 우리가 친구한테 너 핸드폰 뭐 써?라고 물어봤을 때 친구가 아이폰 또는 갤럭시라고 대답하는 경우를 생각해보자. 이때 친구는 자신이 사용하는 스마트폰이 아이폰X갤럭시 S10이든 간에 무의식적으로 아이폰이나 갤럭시라는 좀 더 포괄적인 개념을 떠올리고 하위 개념들을 그룹핑한 것이다. 그 정도로 이런 접근 방법은 우리에게 이미 일상적이고 익숙한 방법이다. 어렵게 생각하지 말자.

    iphones 아이폰7의 상위 개념인 아이폰은 모든 아이폰을 포괄할 수 있는 개념이 된다.

    여기서 가장 중요한 점은 하위 개념인 아이폰7은 상위 개념인 아이폰의 특징을 모두 가지고 있다는 것이다. 마찬가지로 아이폰의 다른 하위 개념인 아이폰X이나 아이폰 SE와 같은 아이폰 시리즈들도 아이폰의 모든 특징을 가지고 있을 것이다. 여기서 끝내면 아쉬우니 한번만 더 해보도록 하자.

    아이폰의 상위 개념은 무엇일까? 아이폰은 애플에서 제조하고 iOS를 사용하는 스마트폰의 명칭이다. 즉, 아이폰의 상위 개념은 스마트폰이라고 할 수 있다. 이때 스마트폰이라는 개념은 아이폰 뿐만 아니라 갤럭시, 샤오미, 베가와 같은 다른 스마트폰들까지 모두 포괄하는 개념일테고, 마찬가지로 이 스마트폰이라는 개념의 하위 개념들은 모두 스마트폰의 특징을 그대로 가지며 자신들만의 고유한 특징을 추가적으로 가질 수 있을 것이다.

    smartphones 스마트폰이라는 개념은 아이폰, 갤럭시, 샤오미 등 모든 스마트폰을 포괄할 수 있는 개념이 된다.

    이런 식으로 우리는 아이폰7이라는 개념에서 출발하여 계속해서 상위 개념을 정의해나갈 수 있다.

    아이폰7 -> 아이폰 -> 스마트폰 -> 휴대전화 -> 무선 전화기 -> 전화기 -> 통신 기기 -> 기계…

    결국 이렇게 상위 개념을 추적해나가면서 설계하는 것이 OOP의 기초이고, 이때 아이폰7, 아이폰과 같은 개념들을 클래스(Class)라고 부르는 것이다. 그리고 방금 했던 것처럼 상위 개념을 만들어나가는 행위 자체를 추상화(Abstraction)라고 한다. 추상화는 밑에서 다시 한번 설명할테니 일단 지금은 클래스라는 개념만 기억하도록 하자.

    그럼 객체(Object)는 무엇일까? 필자는 방금 클래스를 설명하면서 개념이라는 단어를 굉장히 많이 사용했다. 말 그대로 클래스의 역할은 어떠한 개념을 의미하는 것이다. 하지만 개념이라는 것 그 자체 만으로는 현실의 물건이 될 수는 없는 법이다.

    잘 생각해보면 아이폰7이라는 것 또한 그냥 어떠한 제품 라인의 이름이다. 어떤 고유한 물건의 이름이 아니라는 것이다. 여기서 필자가 말하는 고유하다라는 의미는 전 세계에 단 한개만 존재하는 수준의 고유함이다. 당장 내 아이폰7과 친구의 아이폰7만 봐도 실제로는 다른 아이폰7이지 않은가?

    즉, 아이폰7이라는 클래스는 어떠한 실체가 있는 게 아니라는 것이다. 아이폰7 클래스에는 CPU, 디스플레이 해상도, 메모리와 같은 사양이 정의되어 있을 것이고 이를 기반으로 공장에서 실제 아이폰7을 찍어내고 일련번호를 부여한 후 출고하고나면 그제서야 우리 손에 잡을 수 있는 물건인 아이폰7이 되는 것이다. 이때 생산된 아이폰7에는 고유한 ID인 일련번호가 부여되었기 때문에 우리는 전 세계에 일련번호가 1234인 아이폰7은 단 하나밖에 없다는 사실을 알 수 있다.

    이때 이렇게 생산된 아이폰7들을 객체라고 할 수 있다.

    즉, 클래스는 일종의 설계도이고 이것을 사용하여 우리가 사용할 수 있는 실제 물건으로 만들어내는 행위가 반드시 필요하다. 그리고 객체는 클래스를 사용하여 생성한 실제 물건이다.

    이러한 OOP의 설계 접근 방식으로 우리의 일상 속에 보이는 대부분의 개념들을 추상화할 수 있는데, 그냥 평소에 보이는 모든 것들을 이렇게 추상화해보는 연습을 하면 나름 재미도 있다. 몇가지 예를 들어보겠다.


    • 소나타 -> 중형 세단 -> 세단 -> 자동차 -> 이동수단
    • 문동욱 -> 남자 -> 인간 -> 영장류 -> 포유류 -> 동물
    • 오버워치 -> 블리자드가 만든 FPS 게임 -> FPS 게임 -> 게임

    실제로 우리 일상 속에 존재하는 거의 대부분의 개념은 이런 추상화 기법으로 어느 정도 정리할 수 있다. 눈에 보이는 생활 속의 물건들을 추상화 해보는 것은 따로 시간을 내지 않아도 일상 속에서 할 수 있는 좋은 연습 방법이니 한번 해보기를 추천한다. 이 방법이 익숙해지면 카페에 가서 커피를 마시면서도 머릿 속에서 작은 카페를 만들어 볼 수도 있다.

    결국 객체 지향이라는 말의 의미는 이렇게 클래스를 사용하여 추상적인 개념들을 정의하고, 그 클래스를 사용하여 실제로 사용할 객체를 만들어냄으로써 현실 세계의 모든 것을 표현할 수 있다는 것에서 출발하는 것이다.

    추상화에 대해서 조금 더 깊이 생각해보자

    방금 우리는 아이폰7부터 시작해서 상위 개념을 이끌어내는 간단한 추상화를 경험해보았다. 하지만 우리가 방금 저 예시를 진행할 때는 그렇게까지 깊은 고민이 없었을 것이다. 왜냐면 아이폰이나 스마트폰 같은 개념은 이미 우리에게 상당히 친숙한 개념이기 때문에 깊이 고민할 필요없이 이미 여러분의 머릿속에 어느 정도 추상화가 되어 정리된 상태였기 때문이다.

    하지만 실제로 프로그램 설계에 OOP를 사용할 때에는 우리에게 친숙한 아이폰과 같은 개념을 사용하는 것이 아니라 개발자가 이 개념 자체부터 정의해야하는 경우가 많다. 이때 추상화가 어떤 것인지 정확히 이해하고 있지 않다면 자칫 이상한 방향으로 클래스를 설계할 수 있기 때문에 정확히 추상화가 무엇인지 짚고 넘어가도록 하겠다.

    추상이라는 단어의 뜻부터 한번 생각해보자. 추상은 어떠한 존재가 가지고 있는 여러가지의 속성 중에서 특정한 속성을 가려내어 포착하는 것을 의미한다. 대표적인 추상파 화가 중 한명인 피카소가 소를 점점 추상화하며 그려가는 과정을 한번 살펴보면 추상화가 어떤 것인지 조금 더 이해가 된다.

    picasso bull 피카소가 소를 추상화하는 과정

    이렇듯, 추상화라는 것은 그 존재가 가지고 있는 가장 특징적인 속성들을 파악해나가는 것을 의미한다.

    우리가 방금 전 아이폰7의 상위 개념인 아이폰을 떠올리게 되는 과정은 꽤나 직관적으로 진행되었지만 사실 추상화를 그렇게 직관적으로 접근하려고 하면 더 방향을 잡기가 힘들다. 원래대로라면 아이폰이라는 상위 개념을 만들고자 했을 때 아이폰7 뿐만이 아니라 다른 아이폰 시리즈들까지 모두 포함할 수 있는 아이폰들의 공통된 특성을 먼저 찾는 것이 올바른 순서이다. 이렇게 만들어진 올바른 상위 개념의 속성은 그 개념의 하위 개념들에게 공통적으로 적용할 수 있는 속성이 된다.

    상위 개념
    아이폰: 애플에서 만든 iOS 기반의 스마트폰

    아이폰 클래스 기반의 하위 개념
    아이폰X: 애플에서 만든 iOS 기반의 스마트폰이며, 홈 버튼이 없고 베젤리스 디자인이 적용된 아이폰
    아이폰7: 애플에서 만든 iOS 기반의 스마트폰이며, 햅틱 엔진이 내장된 홈 버튼을 가지고 있는 아이폰.
    아이폰 SE: 애플에서 만든 iOS 기반의 스마트폰이며, 사이즈가 작아서 한 손에 잡을 수 있는 아이폰.

    이 예시에서 볼 수 있듯이 하위 개념들은 상위 개념이 가지고 있는 모든 속성을 그대로 물려받는데, 그래서 이 과정을 상속(Inheritance)이라고 한다. 이 상속에 관해서는 밑에서 다시 자세하게 살펴보도록 하겠다.

    객체 지향 프로그래밍의 3대장

    방금까지 설명한 클래스, 객체, 추상화는 OOP를 이루는 근본적인 개념들이다. 필자는 여기서 좀 더 나아가서 OOP를 지원하는 언어들이 기본적으로 갖추고 있는 몇가지 개념을 더 설명하려고 한다. OOP는 그 특성 상 클래스와 객체를 기반으로 조립하는 형태로 프로그램을 설계하게 되는데 이때 이 조립을 더 원활하게 하기 위해서 나온 유용한 몇가지 개념들이 있다.

    하지만 이 개념들은 JavaScript에는 구현되지 않은 개념도 있으므로 이번에는 Java를 사용해서 예제를 진행하도록 하겠다. 단편적인 문법만 보면 그렇게 이질감 느껴질 정도로 차이가 크지 않기 때문에 JavaScript만 하셨던 분들도 아마 금방 이해할 수 있을 것이다. 참고로 TypeScript도 OOP를 지원하기는 하지만 이거 세팅하는 것보다 그냥 Java 컴파일하는게 편하기 때문에 Java로 간다.

    그럼 이제 객체 지향의 3대장이라고 불리는 상속캡슐화, 그리고 다형성에 대해서 간단하게 알아보도록 하자.

    상속

    상속(Inheritance)은 방금 전 추상화에 대한 설명을 진행하면서 한번 짚고 넘어갔던 개념이다. OOP를 제공하는 많은 프로그래밍 언어에서 상속은 extends라는 예약어로 표현되는데, 하위 개념 입장에서 보면 상위 개념의 속성을 물려받는 것이지만 반대로 상위 개념 입장에서 보면 자신의 속성들이 하위 개념으로 넘어가면서 확장되는 것이므로 이 말도 맞다. 그럼 이제 상속이 어떻게 이루어지는지 코드로 살펴보도록 하자.

    class IPhone {
        String manufacturer = "apple";
        String os = "iOS";
    }
      
    class IPhone7 extends IPhone {
        int version = 7;
    }
      
    class Main {
        public static void main (String[] args) {
            IPhone7 myIPhone7 = new IPhone7();
    
            System.out.println(myIPhone7.manufacturer);
            System.out.println(myIPhone7.os);
            System.out.println(myIPhone7.version);
        }
    }
    apple
    iOS
    7

    IPhone7 클래스를 생성할 때 extends 예약어를 사용하여 IPhone 클래스를 상속받았다. IPhone7 클래스에는 manufactureros 속성이 명시적으로 선언되지 않았지만 부모 클래스인 IPhone 클래스의 속성을 그대로 물려받은 것을 볼 수 있다.

    마찬가지로 이 상황에서 IPhoneX 클래스를 새로 만들어야 할때도 IPhone 클래스를 그대로 다시 사용할 수 있다.

    class IPhoneX extends IPhone {
        int version = 10;
    }

    즉, 추상화가 잘된 클래스를 하나만 만들어놓는다면 그와 비슷한 속성이 필요한 다른 클래스를 생성할 때 그대로 재사용할 수 있다는 말이다. 그리고 만약 아이폰 시리즈 전체에 걸친 변경사항이 생겼을 때도 IPhone7, IPhoneX와 같은 클래스는 건드릴 필요없이 IPhone 클래스 하나만 고치면 이 클래스를 상속받은 모든 하위 클래스에도 자동으로 적용되므로 개발 기간도 단축시킬 수 있고 휴먼 에러가 발생할 확률도 줄일 수 있다.

    하지만 여기서 만약 요구사항이 변경되어서 갤럭시 시리즈를 만들어야한다면 어떻게 될까? 갤럭시 시리즈는 iOS가 아니라 Android를 사용하고, 제조사도 애플이 아니라 삼성이기 때문에 우리가 방금 만든 IPhone 클래스를 사용할 수는 없다. 이때 우리는 IPhone 클래스를 그대로 냅두고 그냥 Galaxy 클래스를 새로 만들 수도 있지만 SmartPhone이라는 한단계 더 상위 개념을 만드는 방향으로 가닥을 잡을 수도 있다.

    class SmartPhone {
        SmartPhone (String manufacturer, String os) {
            this.manufacturer = manufacturer;
            this.os = os;
        }
    }
    
    class IPhone extends SmartPhone {
        IPhone () {
            super("apple", "iOS");
        }
    }
    class Galaxy extends SmartPhone {
        Galaxy () {
            super("samsung", "android");
        }
    }
      
    class IPhone7 extends IPhone {
        int version = 7;
    }
    class GalaxyS10 extends Galaxy {
        String version = "s10";
    }

    위의 코드에서 super 메소드는 부모 클래스의 생성자를 호출하는 메소드이다. 부모 클래스를 Super Class, 자식 클래스를 Sub Class라고 부르기도 하기 때문에 부모와 관련된 키워드 역시 super를 사용하는 것이다.

    그리고 이때 자식 클래스인 IPhone7이나 GalaxyS10 클래스가 부모 클래스의 manufactureros 속성을 덮어쓰게 할 수도 있는데, 이러한 작업을 오버라이딩(Overriding)이라고 한다. 안드로이드 개발을 하다보면 밥먹듯이 쓰는 @Override 데코레이터도 부모 클래스의 메소드를 덮어쓰는 방식으로 세부 구현을 진행하는 것이다.

    이러한 OOP의 클래스 의존관계는 클래스의 재사용성을 높혀주는 방법이기도 하지만, 너무 클래스의 상속 관계가 복잡해지게 되면 개발자가 전체 구조를 파악하기가 힘들다는 단점도 가지고 있으므로 개발자가 확실한 의도를 가지고 적당한 선에서 상속 관계를 설계하는 것이 중요하다. (근데 이 적당한 선의 기준이 개발자마다 다 다르다는 게 함정)

    캡슐화

    캡슐화(Encapsulation)는 어떠한 클래스를 사용할 때 내부 동작이 어떻게 돌아가는지 모르더라도 사용법만 알면 쓸 수 있도록 클래스 내부를 감추는 기법이다. 클래스를 캡슐화 함으로써 클래스를 사용하는 쪽에서는 머리 아프게 해당 클래스의 내부 로직을 파악할 필요가 없어진다. 또한 클래스 내에서 사용되는 변수나 메소드를 원하는 대로 감출 수 있기 때문에 필요 이상의 변수나 메소드가 클래스 외부로 노출되는 것을 방어햐여 보안도 챙길 수 있다.

    이렇게 클래스 내부의 데이터를 감추는 것을 정보 은닉(Information Hiding)이라고 하며, 보통 public, private, protected 같은 접근제한자를 사용하여 원하는 정보를 감추거나 노출시킬 수 있다.

    // Capsulation.java
    class Person {
        public String name;
        private int age;
        protected String address;
    
        public Person (String name, int age, String address) {
            this.name = name;
            this.age = age;
            this.address = address;
        }
    }

    자 이렇게 간단한 클래스를 하나 만들어보았다. Person 클래스는 생성자의 인자로 들어온 값들을 자신의 멤버 변수에 할당하는데, 이 멤버 변수들은 각각 public, private, protected의 접근제한자를 가지고 있는 친구들이다. 그럼 한번 객체를 생성해보고 이 친구들의 멤버 변수에 접근이 가능한지를 알아보자.

    // Capsulation.java
    class CapsulationTest {
        public static void main (String[] args) {
            Person evan = new Person("Evan", 29, "Seoul");
            System.out.println(evan.name);
            System.out.println(evan.age);
            System.out.println(evan.address);
        }
    }

    자, 여기까지 직접 작성해보면 알겠지만 Java는 컴파일 언어이기 때문에 굳이 실행시켜보지 않더라도 IDE에서 이미 알아서 다 분석을 끝내고 빨간줄을 쫙쫙 그어주었을 것이다.

    private error

    에러가 난 부분은 private 접근제한자를 사용한 멤버변수인 age이다. 이처럼 private 접근제한자를 사용하여 선언된 멤버 변수나 메소드는 클래스 내부에서만 사용될 수 있고 외부로는 아예 노출 자체가 되지 않는다. publicprotected를 사용하여 선언한 멤버 변수인 nameaddress는 정상적으로 접근이 가능한 상태이다.

    public 같은 경우는 이름만 봐도 클래스 외부에서 마음대로 접근할 수 있도록 열어주는 접근제한자라는 것을 알 수 있지만, protected가 접근이 가능한 것은 조금 이상하다. 이름만 보면 왠지 이 친구도 private처럼 접근이 막혀야할 것 같은데 왜 외부에서 접근이 가능한 것일까?

    protected 접근제한자는 해당 클래스를 상속받은 클래스와 같은 패키지 안에 있는 클래스가 아니면 모두 접근을 막는 접근제한자인데, 위의 예시의 경우 필자는 Person 클래스와 CapsulationTest 클래스를 같은 파일에 선언했으므로 같은 패키지로 인식되어 접근이 가능했던 것이다.

    그럼 Person 클래스를 다른 패키지로 분리해내면 어떻게 될까? 테스트 해보기 위해 먼저 MyPacks라는 디렉토리를 생성하고 그 안에 Person.java 파일을 따로 분리하여 별도의 패키지로 선언해주겠다.

    // MyPacks/Person.java
    package MyPacks;
    
    public class Person {
        public String name;
        private int age;
        protected String address;
    
        public Person (String name, int age, String address) {
            this.name = name;
            this.age = age;
            this.address = address;
        }
    }
    // Capsulation.java
    import MyPacks.Person;
    
    class CapsulationTest {
        public static void main (String[] args) {
            Person evan = new Person("Evan", 29, "Seoul");
            System.out.println(evan.name);
            System.out.println(evan.address);
        }
    }

    이렇게 Person 클래스를 별도의 패키지로 분리하면 이제 evan.address에도 빨간 줄이 쫙 그어진다.

    protected error

    이렇게 외부 패키지로 불러온 클래스 내부 내의 protected 멤버 변수나 메소드에는 바로 접근할 수 없다. 그러나 Person 클래스를 상속한다면 외부 패키지인지 아닌지 여부와 상관 없이 자식 클래스 내에서는 protected 멤버에 접근이 가능하다.

    // Capsulation.java
    import MyPacks.Person;
    
    class CapsulationTest {
        public static void main (String[] args) {
            Evan evan = new Evan();
        }
    }
    
    class Evan extends Person {
        Evan () {
            super("Evan", 29, "Seoul");
            System.out.println(this.address);
            System.out.println(super.address);
        }
    }
    Seoul
    Seoul

    접근제한자는 Java 뿐만 아니라 TypeScript, Ruby, C++ 등과 같이 OOP를 지원하는 많은 프로그래밍 언어들도 가지고 있는 기능이므로 이 개념을 잘 알아두면 클래스를 설계할 때 원하는 정보만 노출시키고 원하지 않는 정보는 감추는 방법을 사용하여 보안도 지킬 수 있고 클래스를 가져다 쓰는 사용자로 하여금 쓸데없는 고민을 안하게 해줄 수도 있다.

    다형성

    다형성(Polymorphism)은 어떤 하나의 변수명이나 함수명이 상황에 따라서 다르게 해석될 수 있는 것을 의미한다. 다형성은 어떤 한가지 기능을 의미하는 것이 아니라 개념이기 때문에 여러가지 방법으로 표현할 수 있다.

    Java에서 다형성을 위한 대표적인 기능은 바로 추상 클래스(Abstract Class)인터페이스(Interface), 그리고 Overloading이 있다. 추상 클래스와 인터페이스는 사실 그 용도가 조금 다르지만 필자가 예로 들 간단한 예시에서는 그 차이를 크게 느끼기 힘들기도 하고 무엇보다 이 포스팅은 Java 포스팅이 아니라 단순히 다형성을 설명하기 위함이므로 필자는 이 중 추상 클래스만을 사용할 것이다.

    그럼 이 기능들이 어떤 역할을 하는 지 살펴보면서 다형성이 무엇인가를 좀 더 자세히 알아보도록 하자. 먼저, 추상 클래스를 사용하여 다형성을 만족시키는 예시를 먼저 설명할텐데, 사실 다형성이라는 단어를 모르고 있던 분들이라도 자신도 모르게 이런 설계 패턴을 사용하고 있었을 수도 있을 정도로 기본적인 예시이다.

    추상 클래스를 사용한 다형성 구현

    추상 클래스는 Java에서 다형성을 만족시키기 위해 자주 사용되는 대표적인 기능이다. 말로만 설명하면 재미가 없으니 한번 코드를 직접 눈으로 보는 것이 좋은데, 필자는 오버워치를 좋아하기 때문에 추상 클래스에 대한 예시도 오버워치를 가져와서 설명하겠다.

    overwatch 갓겜 고오급 시계

    자, 필자는 이제 오버워치의 여러 영웅들을 클래스로 만드려고 한다. 오버워치의 영웅들은 공통적으로 “궁극기 게이지가 찼을 때 Q 버튼을 누르면 궁극기가 발동된다”라는 기능을 가지고 있다. 하지만 오버워치의 영웅들은 각자 특색에 맞게 다른 궁극기를 가지고 있는데, 라인하르트는 망치를 내리치며 다른 영웅들을 기절시키고 맥크리는 시야에 보이는 여러 영웅에게 동시에 헤드샷을 날릴 수 있으며 메이는 로봇을 던져서 일정 범위 안의 영웅들을 얼린다.

    이런 경우 다형성을 가지지 않은 오버워치 영웅 클래스는 다음과 같은 모습을 보일 것이다.

    class Hero {
        public String name;
        Hero (String name) {
            this.name = name;
        }
    }
    
    class Reinhardt extends Hero {
        Reinhardt () {
            super("reinhardt");
        }
    
        public void attackHammer () {
            System.out.println("망치 나가신다!");
        }
    }
    
    class McCree extends Hero {
        McCree () {
            super("mccree");
        }
        public void attackGun () {
            System.out.println("석양이 진다. 빵야빵야");
        }
    }
    
    class Mei extends Hero {
        Mei () {
            super("mei");
        }
        public void throwRobot () {
            System.out.println("꼼짝 마! 움직이지 마세요!");
        }
    }

    이때 만약 우리가 Hero 클래스를 상속받은 영웅 클래스들의 궁극기를 발동시키고 싶다면 어떻게 해야할까? 안봐도 뻔하겠지만 눈물나는 if문 또는 switch문의 향연이 펼쳐질 것이다.

    모든 영웅들의 궁극기 발동 메소드의 이름이 다르기 때문에 달리 방도가 없다. 그리고 추가적으로 Hero 클래스에는 궁극기 발동 메소드가 없기 때문에 객체를 해당 영웅의 클래스로 형변환 해줘야하는 불편한 작업도 해야한다.

    class Main {
        public static void main (String[] args) {
            Mei myMei = new Mei();
            Reinhardt myReinhardt = new Reinhardt();
            McCree myMcCree = new McCree();
    
            Main.doUltimate(myMei);
            Main.doUltimate(myReinhardt);
            Main.doUltimate(myMcCree);
        }
    
        public static void doUltimate (Hero hero) {
            if (hero instanceof Reinhardt) {
                Reinhardt myHero = (Reinhardt)hero;
                myHero.attackHammer();
            }
            else if (hero instanceof McCree) {
                McCree myHero = (McCree)hero;
                myHero.attackGun();
            }
            else if (hero instanceof Mei) {
                Mei myHero = (Mei)hero;
                myHero.throwRobot();
            }
        }
    }
    꼼짝 마! 움직이지 마세요!
    망치 나가신다!
    석양이 진다. 빵야빵야

    여기에 영웅이 더 추가된다면 영웅의 종류 만큼 분기의 개수도 늘어날 것이고, 무엇보다 Mei myHero = (Mei)hero처럼 굳이 새로운 변수를 선언하면서 사용하고 있는 걸 보자니 마음이 한켠이 먹먹해져온다. 다형성은 바로 이럴 때 우리를 행복하게 만들어 줄 수 있는 단비와 같은 개념이다.

    자, 아까 위에서 필자는 다형성의 개념을 어떤 하나의 변수명이나 함수명이 상황에 따라서 다르게 해석될 수 있는 것이라고 했다. 그렇다면 이 경우 우리는 영웅들의 궁극기 호출 메소드명을 ultimate로 통일하되, 이 메소드를 호출했을 때 실행되는 코드는 영웅에 따라 달라지도록 만들면 다형성을 만족시킬 수 있는 것이다.

    이런 경우 그냥 Hero 클래스를 상속받은 영웅 클래스들에게 직접 하나하나 ultimate라는 메소드를 선언할 수도 있지만, 그렇게 되면 개발자가 실수할 확률이 존재한다.(특히 오타로 인한 실수가 가장 많을 것이다) 그래서 Java는 개발자가 특정 메소드를 강제로 구현하도록 만들어주는 기능을 제공한다.

    그 기능이 바로 추상 클래스(Abstract Class)인터페이스(Interface)인 것이다. 필자는 위에서 한번 이야기 했듯이 이 중 추상 클래스만을 사용하여 예제를 진행할 것이다.

    그래도 혹시 이 두 기능이 뭐가 다른지 궁금하신 분이 있을 것 같으니 최대한 간단히만 설명하고 넘어가자면, 추상 클래스는 어떤 클래스의 기능을 그대로 사용하면서 그 기능을 확장하고 싶을 때 사용하는 것이고 인터페이스는 아무런 구현체가 없는 그냥 껍데기만 구현하는 것이다. 그렇기 때문에 인터페이스에는 자세한 메소드의 구현체가 들어갈 수 없지만 추상 클래스는 자체적인 메소드의 구현체를 가질 수도 있다. (Java 8부터는 default 키워드를 사용하여 인터페이스에도 메소드 구현체를 넣을 수 있게 변경되긴했다. 덕분에 구분이 더 애매해짐.)

    이 예제의 Hero 클래스는 name 멤버 변수를 생성자로부터 받아서 자신의 멤버 변수로 추가하는 기능을 가지고 있기 때문에 추상 클래스를 사용하는 것이 더 적절하다. 그럼 이제 추상 클래스를 사용하여 ultimate 메소드의 구현을 강제해보도록 하자.

    abstract class Hero {
        public String name;
        Hero (String name) {
            this.name = name;
        }
    
        // 내부 구현체가 없는 추상 메소드를 선언한다.
        public abstract void ultimate ();
    }
    
    class Reinhardt extends Hero {
        Reinhardt () {
            super("reinhardt");
        }
    
        public void ultimate () {
            System.out.println("망치 나가신다!");
        }
    }
    
    class McCree extends Hero {
        McCree () {
            super("mccree");
        }
        public void ultimate () {
            System.out.println("석양이 진다. 빵야빵야");
        }
    }
    
    class Mei extends Hero {
        Mei () {
            super("mei");
        }
        public void ultimate () {
            System.out.println("꼼짝 마! 움직이지 마세요!");
        }
    }

    이렇게 추상 클래스인 Hero를 상속받은 영웅 클래스들은 무조건 ultimate 메소드를 구현해야한다. 이렇게 메소드명이 통일되면 영웅 클래스를 가져다 쓰는 입장에서는 궁극기를 발동시키고 싶을 때 어떤 메소드를 호출해야할지 이제 더 이상 고민할 필요가 없다.

    class Main {
        public static void main (String[] args) {
            Mei myMei = new Mei();
            Reinhardt myReinhardt = new Reinhardt();
            McCree myMcCree = new McCree();
    
            Main.doUltimate(myMei);
            Main.doUltimate(myReinhardt);
            Main.doUltimate(myMcCree);
        }
    
        public static void doUltimate (Hero hero) {
            // Hero 클래스를 상속받은 클래스는
            // 무조건 ultimate 메소드를 가지고 있다는 것이 보장된다.
            hero.ultimate();
        }
    }

    어떤가? 코드가 훨씬 심플해지지 않았는가? 추상 메소드를 사용하여 클래스 내부의 ultimate라는 메소드의 구현을 강제했기 때문에 Hero 클래스를 상속받은 영웅 클래스에 해당 메소드가 없을 확률은 전혀 없다. 그렇기 때문에 사용하는 입장에서는 깊은 고민없이 안심하고 ultimate 메소드를 호출할 수 있다.

    또한 ultimate 메소드는 모든 영웅 클래스들이 가지고 있는 메소드이지만 내부 구현은 전부 다르기 때문에 발동하는 스킬 또한 영웅 별로 다르게 나올 것이다. 이런 것을 바로 다형성이라고 하는 것이다.

    오버로딩을 사용한 다형성 구현

    이번에는 오버로딩(Overloading)을 사용한 다형성의 예시를 한번 살펴보도록 하자. 위의 상속 챕터에서 잠깐 언급하고 넘어간 오버라이딩(Overriding)과 헷갈리지 말자.

    오버라이딩은 부모 클래스의 멤버 변수나 메소드를 덮어 씌우는 것이고, 오버로딩은 같은 이름의 메소드를 상황에 따라 다르게 사용할 수 있게 해주는 다형성을 위한 기능이다.(필자는 학교에서 시험볼 때 자주 헷갈렸다)

    오버로딩은 생각보다 단순한 개념이지만, 만약 오버로딩을 지원하지 않는 언어인 JavaScipt나 Python을 주로 사용하는 개발자들에게는 나름 충공깽일 수 있다. 그 이유는 바로 오버로딩이 “메소드의 인자로 어떤 것을 넘기냐에 따라서 이름만 같은 다른 메소드가 호출되는 기능”이기 때문이다.


    이게 뭔 개소리야?

    어떤 클래스가 sum이라는 메소드를 가지고 있다고 생각해보자. 이때 sum은 두 개의 인자를 받은 후 이 두 값을 합쳐서 리턴하는 내부 구조를 가지고 있다. 근데 만약 3개를 합치고 싶다면 어떻게 해야할까? 이런 경우에 JavaScript와 같이 오버로딩을 지원하지 않는 언어에서는 편법을 사용할 수 밖에 없다.

    class Calculator {
      sum (...args) {
        return args.reduce((prev, current) => prev + current);
      }
    }
    const c = new Calculator();
    c.sum(1, 2, 3, 4, 5);
    15

    뭐 어쨌든 되긴 되니까 상관없다고 생각할 수 있지만, 이건 객체의 다형성이라기보다 그냥 JavaScript의 언어적인 특성을 사용하여 우회한 것에 불과하다. 이렇게 작성하면 “두 개의 인자를 더해서 반환하는 메소드”에서 “n개의 인자를 더해서 반환하는 메소드”로는 만들 수 있지만 객체의 다형성을 만족할 수는 없다. 이 메소드의 더한다라는 기능 자체도 변경할 수 있어야 그제서야 다형성을 만족한다고 할 수 있는 것이다.

    반면, Java나 C++과 같은 언어에서는 제대로 다형성을 만족시킬 수 있는 오버로딩을 지원한다.

    class Overloading {
        public int sum (int a, int b) {
            return a + b;
        }
        public int sum (int a, int b, int c) {
            return a + b + c;
        }
        public String sum (String a, String b) {
            return a + b + "입니다.";
        }
    }

    쨘, 간단한 클래스를 하나 선언하고 sum이라는 메소드를 여러 개 선언했다. 만약 JavaScript에서 이렇게 선언했다가는 위에 선언된 두개의 sum은 무시되고 맨 아래의 sum 메소드로 덮어씌워지기 때문에 오버로딩을 할 수가 없다.

    그리고 문자열을 인자로 받는 sum 메소드의 경우에는 문자열 맨 뒤에 입니다도 붙히는 센스를 발휘하도록 만들어주었다. JavaScript에서는 이 동작을 구현하려면 반드시 타입을 체크하는 조건 분기문이 필요하지만 Java는 오버로딩을 지원하기 때문에 그럴 필요가 없다.

    그럼 이제 한번 이 메소드들이 잘 작동하나 호출해보도록 하자.

    class Main {
        public static void main (String[] args) {
            Overloading o = new Overloading();
            System.out.println(o.sum(1, 2));
            System.out.println(o.sum(1, 2, 3));
            System.out.println(o.sum("자", "바"));
        }
    }
    3
    6
    자바입니다.

    위의 예시에서 볼 수 있듯이 Overloading 클래스는 여러 개의 sum 메소드를 가지고 있고, 메소드의 인자가 무엇인지에 따라서 이름만 동일한 다른 메소드들을 호출해주고 있다. 이것이 오버로딩이며, Java에서 제공해주는 대표적인 다형성 지원 기능 중 하나이다. (오버라이딩이랑 헷갈리지 말자!)

    마치며

    사실 이 포스팅을 작성할 때 생각했던 타겟 독자층은 컴퓨터 공학을 전공한 개발자들이 아니였다. 애초에 컴퓨터 공학을 전공하거나, 타 과라도 컴퓨터 공학 전공 수업을 들었던 사람들은 대부분 학교에서 객체 지향 프로그래밍이라는 수업을 들어보았을 것이기 때문에 이 개념에 대해서 어느 정도 알고 있을 것이다.

    필자가 이 포스팅의 타겟으로 한 독자 층은 바로 학원이나 부트캠프에서 코딩을 처음 배우신지 얼마 안된 분들이다. 학원이나 부트캠프에서는 Java를 가르치는 경우가 아니라면 OOP에 대한 내용을 거의 언급하지 않고 넘어가는 경우가 많은 것으로 알고 있다.

    사실 학교와 다르게 학원은 짧은 기간 안에 실무를 할 수 있는 인재를 양성하여 취업시키는 것이 목적인 기관이라는 점을 생각해보면 이해가 안가는 것도 아니지만, OOP는 Java에만 국한된 개념이 아니라 어떤 언어를 사용하더라도 적용할 수 있는 범용적인 프로그래밍 패러다임이기 때문에 이에 대한 내용을 가르치지 않는 것이 안타깝긴 하다.

    참고로 필자는 OOP가 좋은 패러다임이니까 배워야 한다라고 이야기하는 것이 아니다. 이 포스팅의 서두에서 한번 언급했듯이 전 세계에서 상당한 점유율을 차지하고 있는 메이저 언어인 Java, Python, C++과 같은 언어들이 대부분 OOP를 기반으로 설계되었거나 OOP를 지원하기 때문에 2019년에 프로그래밍을 하는 개발자라면 좋든 싫든 OOP를 알고는 있어야 한다고 생각하기 때문에 OOP를 추천하는 것이다.

    language index TIOBE의 2019년 8월 전 세계 언어 순위
    C와 JavaScript, SQL을 제외한 모든 언어가 OOP를 사용한다.

    어차피 프로그래밍 패러다임에는 정답이 없다. 선언적 프로그래밍이 좋은 것이냐, 명령적 프로그래밍이 좋은 것이냐라고 물어보면 쉽사리 대답할 수 없는 것 처럼 말이다. 그냥 우리는 어떤 패러다임이 어떤 방향을 추구하는지, 거기서 파생된 개념은 어떤 것들이 있는 지를 학습하고 각기 상황에 맞는 패러다임을 도입해서 사용하면 되는 것이다.

    어쨌든 이 포스팅을 통해 혹시나 OOP를 모르고 있었거나, 아니면 너무 어렵게 느끼고 있던 분들이 좀 더 OOP를 친숙하게 받아들일 수 있기를 바라는 마음이다.

    이상으로 알고 보면 재밌는 객체 지향 프로그래밍, OOP 흝어보기 포스팅을 마친다.

    Evan Moon

    🐢 거북이처럼 살자

    개발을 잘하기 위해서가 아닌 개발을 즐기기 위해 노력하는 개발자입니다. 사소한 생각 정리부터 튜토리얼, 삽질기 정도를 주로 끄적이고 있습니다.