<Java> 객체지향프로그래밍 5

저번 포스팅을 통해 우리는 캡슐화와 다형성에 대해서 공부해보았습니다.잠깐 복습을 하자면 캡슐화란 접근 제어자를 통해 멤버 변수에 대한 접근 권한을 조절함으로써 외부에 멤버 변수가 노출되지 않도록 하는것입니다.다형성은 부모 클래스 타입의 참조변수로 자손 클래스의 인스턴스를 참조 할 수 있도록 한 것입니다.위와 같은 방식으로 왜 다형성을 이용하는지 이번 포스팅을 통해 알 수 있습니다.

추상 클래스

앞서 클래스라는 개념을 저희는 설계도에 비유하여 설명했습니다.이에 반해 추상 클래스는 미완성 설계도에 비유할 수 있습니다.조금 더 구체적으로 설명드리겠습니다.클래스는 구성요소는 크게 변수와 메서드로 나누어집니다.여기서 추상 클래스의 경우 메서드가 정의만 되어있고 구현은 되어있지 않은 클래스라고 생각하시면 됩니다.이러한 메서드 또한 추상 메서드라고 표현합니다.

미완성 설계도로 완성된 제품을 만들 수 없듯이 추상클래스로 인스턴스를 생성할수 없지만 미완성의 설계도는 상속을 통해 완성 될 수 있습니다.

그러면 왜 추상 클래스라는 개념을 만들어 반드시 상속을 통해서만 인스턴스를 만들 수 있게 하였을까요?이는 개발자가 새로운 클래스를 작성할 때 참고 할 만한 가이드라인의 역할을 하기 때문 입니다.또한 서로 비슷한 기능의 클래스를 작성할때도 공통부분을 추상 클래스가 가지고 있으면 자식 클래스를 작성할때도 수월하게 작성가능합니다.

그러면 추상 클래스는 어떻게 사용할까요?아래와 같이 class라는 키워드 앞에 abstract를 붙여주기만 하면 됩니다.

abstract class 클래스이름{
	//...
}

추상 메서드

메서드는 선언부(정의)와 구현부(몸통)로 나누어진다고 배웠습니다.앞서 언급된것처럼 메서드의 선언부만 작성하고 구현부는 작성되지 않은 것이 추상 메서드입니다.즉,정의만 된 상태이고 구체적인 구현은 되어있지 않다고 보시면 됩니다.

그러면 작성되지 않은 추상 메서드는 어떻게 구현될까요?이는 상속과 관련되어 있습니다.추상 클래스를 상속 받은 자식 클래스는 부모의 메서드를 오버라이딩을 통해 모두 구현해주어야합니다.즉,자식 클래스는 선언된 메서드의 기능과 목적을 보고 이에 알맞게 코드로 기능을 구현해주면 됩니다.만일 부모로부터 상속 받은 추상메서드 중 하나라도 구현하지 않는다면,자식 클래스 역시 추상 클래스로 지정해 주어야합니다.

추상 메서드 또한 마찬가지로 abstract 키워드를 메서드 선언부 앞에 붙여 주면 됩니다.

예제를 통해 추상 클래스와 메서드에 대해 알아보겠습니다.기존에 사용하던 ch7 패키지 내에 unit이라는 패키지를 하나 더 생성했습니다.

package ch7.unit;

abstract class Unit {
    int x,y;

    abstract void move(int x, int y);

    abstract void stop();
}

Unit의 경우 추상 클래스로 추상 메서드를 가지고 있는 클래스입니다.즉,구현해야 할 메서드가 존재한다는 의미입니다.

package ch7.unit;

public class SCV extends Unit {
    @Override
    void move(int x, int y) {
        System.out.println("SCV.move");
    }

    @Override
    void stop() {
        System.out.println("SCV.stop");
    }

    void getMineral() {
        System.out.println("SCV.getMineral");
    }
}

Unit을 상속받는 첫번째 자식 클래스입니다.

package ch7.unit;

public class Medic extends Unit {
    @Override
    void move(int x, int y) {
        System.out.println("Medic.move");
    }

    @Override
    void stop() {
        System.out.println("Medic.stop");
    }

    void cure() {
        System.out.println("Medic.cure");
    }
}

Unit을 상속받는 두번째 자식 클래스입니다.

package ch7.unit;

public class Marine extends Unit {
    @Override
    void move(int x, int y) {
        System.out.println("Marine.move");
    }

    @Override
    void stop() {
        System.out.println("Marine.stop");
    }

    void steamPack() {
        System.out.println("Marine.steamPack");
    }
}

마지막으로 Unit을 상속받는 클래스입니다.위에 나열된 3가지 종류의 클래스들이 가지고 있는 공통적인 부분이 있습니다.이는 바로 추상 클래스에서 구현되지 않은 추상 메서드를 오버라이딩하여서 구현해주고 있다는 점입니다.각각의 구현된 메서드들은 구현하는 코드는 모두 다르지만 공통적인 역할을 수행함을 알 수 있습니다.

인터페이스(interface)

이제부터 추상 클래스의 일종인 인터페이스에 대해 알아봅시다.인터페이스는 추상클래스처럼 추상메서드를 갖지만 추상화 정도가 추상 클래스보다 더 크기 때문에 일반 메서드 또는 멤버변수를 구성원으로 가질 수 없습니다.오직 추상메서드와 상수만을 멤버로 가질 수 있으며,그 외의 어떤 요소도 정의 될 수 없습니다.

앞서 배운 추상클래스는 미완성 설계도에 비유하였는데,이와 다르게 인터페이스의 경우 구현된 것은 아무것도 없고 그저 밑그림만 그려져 있는 “기본 설계도”라 할 수 있습니다.

하지만 인터페이스도 추상 클래스와 마찬가지로 다른 클래스를 작성할 때 참고할 수 있고 기본적인 설계의 틀을 제공해주는 역할을 수행합니다.

인터페이스 작성

인터페이스는 아래와 같은 방식으로 작성할 수 있습니다.

interface 인터페이스{
	public static final 변수명 = 값;
	public abstract 메서드이름(매개변수 목록);
}

인터페이스를 작성할경우 아래와 같은 제약 사항 두가지가 존재합니다.

  1. 모든 멤버변수는 public static final 이어야 하며,이를 생략가능하다.
  2. 모든 메서드는 public abstract 이어야 하며,이를 생략할 수 있다.

위의 두가지 조건은 인터페이스에 정의된 모든 멤버에 예외없이 적용되는 사항이기 때문에 제어자를 생략할 수 있는 것입니다.편의상 대부분은 생략합니다.

인터페이스의 상속 및 구현

인터페이스는 인터페이스로부터만 상속받을 수 있으며,클래스와 달리 다중상속,즉 여러개의 인터페이스로부터 상속을 받는것이 가능합니다.다중 상속의 경우 더욱 자세히 다룰 예정입니다.

인터페이스도 추상클래스와 마찬가지로 그 자체로는 인스턴스를 생성할 수 없으며,추상 클래스가 상속을 통해 추상 메서드를 완성하는 것 처럼,인터페이스도 자신에 정의된 추상메서드를 구현주는 클래스를 작성해야합니다.이는 클래스가 마치 상속을 받는것 처럼 extends라는 키워드 대신 implements라는 키워드를 사용하면 됩니다.

에제를 통해 인터페이스의 상속 및 구현을 알아봅시다.

public interface AttackAble {
    void attack(Unit unit);
}
public interface Movable {
    void move(int x, int y);
}
public interface FightAble extends Movable, AttackAble {
}
public class FighterTest {
    public static void main(String[] args) {
        Fighter fighter = new Fighter();
        System.out.println(fighter instanceof Unit);
        System.out.println(fighter instanceof FightAble);
        System.out.println(fighter instanceof Movable);
        System.out.println(fighter instanceof FightAble);
        System.out.println(fighter instanceof Object);
    }
}

class Fighter extends Unit implements FightAble {
    @Override
    public void attack(Unit unit) {
        System.out.println("Fighter.attack");
    }

    @Override
    public void move(int x, int y) {
        System.out.println("Fighter.move");
    }

    @Override
    void stop() {
        System.out.println("Fighter.stop");
    }
}

우선 실행 결과의 경우 전부다 true를 출력합니다.그럼 상속관계 하나하나씩 따져보도록 하겠습니다. Fighter 클래스는 Unit 클래스로부터 상속받고 FightAble인터페이스를 구현하고 있습니다.하지만 Unit클래스는 Object의 자손이고,FightAble인터페이스는 Movable,AttackAble을 다중 상속 받고 있으니 그들의 자식인 셈입니다.

여기서 눈여겨 볼만한 점이 있습니다.현재 Fighter클래스는 move메서드를 오버라이딩해서 구현하고 있습니다.또한 인터페이스에서 move메서드는 public의 범위를 가지고 있습니다.만약 이를 구현하려면 조상의 메서드보다 넓은 범위의 접근 제어자를 지정해줘야하는 오바라이딩의 조건을 만족해야합니다.그래서 Fighter의 move 메서드 구현의 접근 제어자가 public 범위인 것입니다.이는 반드시 지켜져야하는 문법이며 private으로 지정할 경우 compile에러가 발생함을 볼 수 있습니다.

인터페이스의 다형성

다형성의 활용을 진행하기 다중상속을 잠깐 언급하겠습니다.앞서 상속을 배울때 자바에서 클래스를 통한 다중 상속은 문법적으로 불가능하다는 것을 알았을 것입니다.이러한 자바의 단점을 극복하기 위해 인터페이스간의 다중상속을 허용했는데,이 또한 실상을 놓고 보면 그렇게 많이 사용되고 있지는 않습니다.그래서 인터페이스에서는 다중상속이 가능하다정도의 개념만 숙지하셔도 앞으로 새로운 프레임워크를 배우시는데 지장은 없을 것 같습니다.

그럼 이제 인터페이스의 다형성에 대해서 알아보겠습니다.다형성을 학습할 때 자손 클래스의 인스턴스를 조상타입의 참조변수로 참조하는것이 가능하다는것을 배웠습니다.인터페이스 역시 이를 구현한 클래스의 조상이기에 동일한 규칙이 적용됩니다.인터페이스 Parseable를 XMLParser라는 클래스가 구현 경우,아래와 같은 코드가 성립합니다.

Parseable paser = new XMLParser();

위 문장을 아래 예제를 통해 확장 시켜 보겠습니다.

interface ParseAble{
    void parse(String fileName);
}

class ParserManager{
    public static ParseAble getParser(String type) {
        if (type.equals("XML")) {
            return new XMLParser();
        } else {
            return new HTMLParser();
        }
    }
}

public class ParserTest {
    public static void main(String[] args) {
        ParseAble parser = ParserManager.getParser("XML");
        parser = ParserManager.getParser("HTML");
    }
}

class XMLParser implements ParseAble {
    @Override
    public void parse(String fileName) {
        System.out.println("XMLParser.parse");
        System.out.println("fileName = " + fileName);
    }
}

class HTMLParser implements ParseAble {
    @Override
    public void parse(String fileName) {
        System.out.println("HTMLParser.parse");
        System.out.println("fileName = " + fileName);
    }
}

ParseAble 인터페이스는 구문 분석을 수행하는 기능을 구현할 목적으로 추상메서드 “parse”를 정의했습니다.그리고 XMLParser, HTMLParser 클래스들을 통해서 해당 인터페이스를 구현하고 있습니다.

ParserManager 클래스의 getParser 메서드는 매개변수로 넘겨받는 타입에 따라 XML 또는 HTML Parser를 반환해주는 기능을 수행합니다.여기서 ParserManager가 수행하는 역할이 어떠한 부품을 목적에 맞게 갈아끼우는 느낌이 들지 않으신가요?혹시 그러한 느낌을 받았다면 객체지향을 한층 더 이해하셨다고 보셔도 됩니다.지금과 같이 하나의 기능을 수행하는 객체를 그 목적에 맞게 갈아끼우는 개념은 추후에 Spring의 의존성을 이해하는데도 중요한 역할을 합니다.

비유를 통해 설명하자면 드라마에서 배역이 있으면,해당 배역을 맡은 배우들이 존재할 것입니다.만약 어떠한 배역을 맡은 배우가 사정이 생겨 하차를 한다고 가정합시다.감독 입장에서 배우가 하차를 했다고 이 드라마의 제작이 중단을 할까요?아니면 해당 배역에 알맞은 배우를 다시 배정시킬까요? 아마 후자일 것입니다.이러한 비유를 인터페이스와 클래스로 옮겨와 생각을 하시면 객체지향을 이해하는데 좋은 예가 될듯 싶습니다.

Ref:자바의 정석 - 남궁성