초짜코딩의 잡동사니

객체 지향 프로그래밍, OOP 본문

JAVA

객체 지향 프로그래밍, OOP

초짜코딩 2022. 9. 16. 17:14

객체 지향 프로그래밍

 객체 지향 프로그래밍(Object-Oriented Programming) , 줄여서 흔히들 OOP라고 부르는 설계 방법론에 대해서 이야기해보려고 한다.

OOP는 프로그래밍의 설계 패러다임 중 하나로, 현실 세계를 프로그램 설계에 반영한다는 개념을 기반으로 접근하는 방법이다.

 

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

사실 OOP가 오랜 기간동안 전 세계에서 사랑받고있는 설계 패턴인 것은 맞지만 최근에는 OOP의 단점을 이야기하며 함수형 프로그래밍과 같은 새로운 설계 패러다임이 각광받기도 했다. (함수형 프로그래밍도 사실 꽤 오래된 패러다임이다) 

사실 OOP니 함수형 프로그래밍이니 하는 이런 것들은 결국 프로그램을 어떻게 설계할 것인가?에 대한 방법이기 때문에 당연히 장단점 또한 존재하기 마련이고 시대나 용도에 맞게 개선된 패러다임이 제시되는 것은 자연스러운 흐름이다.

 

OOP는 1990년대 초반부터 2022년인 현재까지도 모던 프로그래밍 설계에 중요한 역할을 하고 있는 개념이다.

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

 

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

OOP의 의미인 Object-Oriented Programming의 Object-Oriented를 한국말로 그대로 직역하면 객체 지향이다.

보통 OOP를 배울 때 가장 처음 접하는 개념이 바로 이 객체라는 개념인데,

객체를 설명하기 위해서는 클래스라는 개념을 함께 설명해야하는데, 용어가 직관적이지 않아서 그렇지 조금만 생각해보면 누구나 다 이해할 수 있는 개념이다.

 

클래스는 무엇이고, 객체는 무엇이다라는 방식으로 접근하기보다는 일단 OOP의 포괄적인 설계 개념을 먼저 설명하는 방식으로 접근하도록 하겠다.

 

클래스와 객체

OOP란 현실 세계를 프로그램의 설계에 반영하는 개념이  기반이라고 이야기했다.

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

 

먼저, 우리가 아이폰12이라는 것을 프로그램으로 구현하고 싶다면 제일 먼저 아이폰12가 무엇인지부터 정의해야한다.

지금 바로 생각해낸 아이폰12은  각진 바디를 가지고 있고  홈 버튼을 가지고 있지 않으며 애플 최초 5G폰인 아이폰 시리즈라는 것이다.

 

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

 

그럼 아이폰은 무엇일까? 아이폰은 애플에서 제조한 스마트폰으로, iOS를 사용하고 있는 스마트폰 시리즈의 명칭이다.

이때 아이폰은 아이폰12 외에도 아이폰X, 아이폰8, 아이폰 SE 등 수많은 아이폰 시리즈의 제품들을 포함하는 좀 더 포괄적인 개념이다.

 

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

 

여기서 가장 중요한 점은 하위 개념인 아이폰12는 상위 개념인 아이폰의 특징을 모두 가지고 있다는 것이다. 마찬가지로 아이폰의 다른 하위 개념인 아이폰X이나 아이폰 SE와 같은 아이폰 시리즈들도 아이폰의 모든 특징을 가지고 있을 것이다.

 

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

 

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

 

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

 

결국 이렇게 상위 개념을 추적해나가면서 설계하는 것이 OOP의 기초이고, 이때 아이폰12, 아이폰과 같은 개념들을 클래스(Class)라고 부르는 것이다. 그리고 방금 했던 것처럼 상위 개념을 만들어나가는 행위 자체를 추상화(Abstraction)라고 한다.

 

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

 

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

이때 생산된 아이폰12에는 고유한 ID인 일련번호가 부여되었기 때문에 우리는 전 세계에 일련번호가 1234인 아이폰12은 단 하나밖에 없다는 사실을 알 수 있다.

 

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

 

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

 

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

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

 

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

 

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

 

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

 

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

 

 

이 예시에서 볼 수 있듯이 하위 개념들은 상위 개념이 가지고 있는 모든 속성을 그대로 물려받는데, 그래서 이 과정을 상속(Inheritance)이라고 한다.

 

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

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

 

상속

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

//클래스 생성
class IPhone {
    String manufacturer = "apple";
    String os = "iOS";
}

// 클래스 생성  
class IPhone12 extends IPhone {
    int version = 12;
}
  
class Main {
    public static void main (String[] args) {
        IPhone12 myIPhone12 = new IPhone12(); // 인스턴스화

        System.out.println(myIPhone12.manufacturer);
        System.out.println(myIPhone12.os);
        System.out.println(myIPhone12.version);
    }
}
apple
iOS
12

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

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

class IPhoneX extends IPhone {
    int version = 10;
}

즉, 추상화가 잘된 클래스를 하나만 만들어놓는다면 그와 비슷한 속성이 필요한 다른 클래스를 생성할 때 그대로 재사용할 수 있다는 말이다. 그리고 만약 아이폰 시리즈 전체에 걸친 변경사항이 생겼을 때도 IPhone12IPhoneX와 같은 클래스는 건드릴 필요없이 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 IPhone12 extends IPhone {
    int version = 12;
}
class GalaxyS10 extends Galaxy {
    String version = "s10";
}

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

 

그리고 이때 자식 클래스인 IPhone12이나 GalaxyS10 클래스가 부모 클래스의 manufacturer나 os 속성을 덮어쓰게 할 수도 있는데, 이러한 작업을 오버라이딩(Overriding)이라고 한다.

 

오버라이딩(Overriding)이란 상위 클래스가 가지고 있는 멤버변수가 하위 클래스로 상위 클래스가 가지고 있는 메서드도 하위 클래스로 상속되어 하위 클래스에 사용할 수 있다. 또한, 하위 클래스에서 메서드를 재정의 해서도 사용 할 수 있다.

 

쉽게 말해 메서드의 이름이 서로 같고, 매개변수가 같고, 반환형이 같을 경우에 상속받은 메서드를 덮어쓴다고 생각하면 된다. '부모 클래스의 메서드는 무시하고, 자식 클래스의 메서드 기능을 사용하겠다'와 같다.

class Woman{ //부모클래스
    public String name;
    public int age;
    
    //info 메서드
    public void info(){
        System.out.println("여자의 이름은 "+name+", 나이는 "+age+"살입니다.");
    }
    
}
 
class Job extends Woman{ //Woman클래스(부모클래스)를 상속받음 : 
 
    String job;
    
    public void info() {//부모(Woman)클래스에 있는 info()메서드를 재정의
        System.out.println("여자의 직업은 "+job+"입니다.");
    }
}
 
public class OverTest {
 
    public static void main(String[] args) {
        
        //Job 객체 생성
        Job job = new Job();
        
        //변수 설정
        job.name = "유리";
        job.age = 30;
        job.job = "프로그래머";
        
        //호출
        job.info();
        
    }
}
여자의 직업은 프로그래머입니다.

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

 

캡슐화

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

 

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

 

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

// 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;
    }
}

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 접근제한자를 사용한 멤버변수인 age이다. 이처럼 private 접근제한자를 사용하여 선언된 멤버 변수나 메소드는 클래스 내부에서만 사용될 수 있고 외부로는 아예 노출 자체가 되지 않는다. public과 protected를 사용하여 선언한 멤버 변수인 name과 address는 정상적으로 접근이 가능한 상태이다.

 

그럼 private 을 사용한 변수에 접근하려면 어떻게 접근해야할? 다음의 예시를 보겠다.

public class EncapsulationClass {
    private int id;
    private String name;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

public class Main {

    public static void main(String[] args) {

        EncapsulationClass cap = new EncapsulationClass();

        cap.setId(123);
        cap.setName("jay");

        System.out.println(cap.getId());
        System.out.println(cap.getName());
    }
}
123
jay

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 멤버 변수나 메소드에는 바로 접근할 수 없다. 그러나 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

이 개념을 잘 알아두면 클래스를 설계할 때 원하는 정보만 노출시키고 원하지 않는 정보는 감추는 방법을 사용하여 보안도 지킬 수 있고 클래스를 가져다 쓰는 사용자로 하여금 쓸데없는 고민을 안하게 해줄 수도 있다.

 

다형성

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

 

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

 

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

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

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라는 메소드를 선언할 수도 있지만, 그렇게 되면 개발자가 실수할 확률이 존재한다.

 

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

 

이 예제의 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 메소드로 덮어씌워지기 때문에 오버로딩을 할 수가 없다.

 

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에서 제공해주는 대표적인 다형성 지원 기능 중 하나이다.