[Java 21] (7) - Object-oriented Programming 2

상속(inheritance)

 

상속이란, 기존의 클래스를 재사용하여 새로운 클래스를 작성하는 것입니다. 상속을 통해서 클래스를 작성하면 보다 적은 양의 코드로 새로운 클래스를 작성할 수 있고 코드를 공통적으로 관리할 수 있기 때문에 코드의 추가 및 변경이 매우 용이합니다. 이러한 특징은 코드의 재사용성을 높이고 코드의 중복을 제거하여 프로그램의 생산성과 유지보수에 크게 기여합니다.

 

자바에서 상속을 위해서는 새로 작성하고자 하는 클래스의 이름 뒤에 상속받을 클래스의 이름을 키워드 'extends'와 함께 써주면 됩니다.

class Child extends Parent {...}

이 두 클래스는 서로 상속 관계에 있다고 하며, 상속해주는 클래스를 '조상 클래스'라 하고, 상속 받는 클래스를 '자손 클래스'라 합니다.

클래스는 타원으로 표현했고 클래스간의 상속 관계는 화살표로 표시했습니다. 이와 같이 클래스간의 상속 관계를 그림으로 표현한 것을 '상속 계층도(class hierarchy)'라고 합니다.

 

자손 클래스는 조상 클래스의 모든 멤버를 상속받기 때문에, Child클래스는 Parent클래스의 멤버들을 포함한다고 할 수 있습니다. 클래스는 멤버들의 집합이므로 클래스 Parent와 Child의 관계를 다음과 같이 표현할 수 있습니다.

자손 클래스는 조상 클래스의 모든 멤버를 상속 받으므로 항상 조상 클래스보다 같거나 많은 멤버를 갖습니다. 즉, 상속에 상속을 거듭할수록 상속받는 클래스의 멤버 개수는 점점 늘어나게 됩니다.

그래서 상속을 받는다는 것은 조상 클래스를 확장(extend)한다는 의미로 해석할 수도 있으며 이것이 상속에 사용되는 키워드가 'extends'인 이유이기도 합니다.

 

여기에 Child2 클래스와 GrandChild 클래스를 추가해보겠습니다.

class Parent { int age; }
class Child extends Parent { }
class Child2 extends Parent { }
class GrandChild extends Child { }

클래스 Child와 Child2가 모두 Parent클래스를 상속받고 있어 서로 상속 관계에 있지만, Child와 Child2간에는 아무런 관계가 없습니다. 클래스간의 관계에서 형제 관계 같은 것은 없고, 부모와 자식의 관계(상속 관계)만 존재합니다.

 

자손 클래스는 조상 클래스읨 모든 멤버를 물려받으므로 GrandChild클래스는 Child클래스의 모든 멤버, Child클래스의 조상인 Parent클래스로부터 상속받은 멤버까지 상속받게 됩니다. 조금 더 정확히 말하자면, Child클래스는 GrandChild클래스의 직접 조상이고, Parent클래스는 GrandChild의 간접 조상이 됩니다.

Parent클래스는 클래스 Child, Child2, GrandChild의 조상이므로 Parent클래스에 추가된 멤버변수 age는 Parent클래스의 모든 자손에 추가됩니다. 반대로 Parent클래스에서 멤버변수 age를 제거한다면, Parent의 자손 클래스인 Child, Child2, GrandChild에서도 제거됩니다.

 

이처럼 조상 클래스만 변경해도 모든 자손 클래스에, 자손의 자손 클래스에까지 영향을 미치기 때문에, 클래스간의 상속관계를 맺어 주면 자손 클래스들의 공통적인 부분은 조상 클래스에서 관리하고 자손 클래스는 자신에 정의된 멤버들만 관리하면 되므로 각 클래스의 코드가 적어져서 관리가 쉬워집니다.

 

포함 관계

 

상속이외에도 클래스를 재사용하는 또 다른 방법은 클래스간에 '포함' 관계를 맺어주는 것입니다. 클래스간에 포함 관계를 맺어 주는 것은 한 클래스의 멤버 변수로 다른 클래스 타입의 참조 변수를 선언하는 것을 뜻합니다.

 

원을 표현하기 위한 Circle이라는 클래스와 좌표상의 한 점을 다루기 위한 Point클래스가 있다고 가정해보겠습니다.

class Point {
    int x;
    int y;
}

이와 같이 한 클래스를 작성하는 데 다른 클래스를 멤버 변수로 선언하여 포함시키는 것은 좋은 생각입니다. 하나의 거대한 클래스를 작성하는 것보다 단위별로 여러 개의 클래스를 작성한 다음, 이 단위 클래스들을 포함 관계로 재사용하면 보다 간결하고 손쉽게 클래스를 작성할 수 있습니다. 또한 작성된 단위 클래스들은 다른 클래스를 작성하는데 재사용될 수 있습니다.

 

클래스간의 관계 결정하기

 

클래스를 작성하는데 있어서 상속 관계를 맺을 것인지 포함 관계를 맺을 것인지 정하는 것은 혼란스러울 수 있습니다. 앞서 예를 들었던 Circle 클래스의 경우에도 상속 관계를 맺어줄 수도 있었습니다.

 

그럴때에는 '~은 ~이다(is -a)'와 '~은 ~을 가지고 있다(has -a)'를 넣어서 문장을 만들어 보면 클래스 간의 관계가 보다 명확해집니다. 원은 원점과 반지름으로 구성되므로 위 두 문장을 비교해보면 첫 번째 보다는 두 번째가 더 옳다는 것을 알 수 있습니다.

 

이처럼 클래스를 가지고 문장을 만들었을 때 'is -a'가 성립한다면 상속 관계를 맺어 주고, 'has -a'가 성립한다면 포함 관계를 맺어 주면 됩니다.

 

단일 상속

 

다른 객체지향언어인 C++에서는 여러 조상 클래스로부터 상속받는 것이 가능한 '다중 상속(multiple inhreitance)'을 허용하지만 자바에서는 오직 단일 상속만 허용합니다. 그래서 둘 이상의 클래스로부터 상속을 받을 수 없습니다.

 

다중 상속을 허용하면 여러 클래스로부터 상속을 받을 수 있기 때문에 복합적인 기능을 가진 클래스를 쉽게 작성할 수 있다는 장점이 있지만, 클래스간의 관계가 매우 복잡해진다는 것과 서로 다른 클래스로부터 상속받은 멤버간의 이름이 같은 경우 구별할 수 있는 방법이 없다는 단점을 갖고 있습니다. 자바에서는 다중 상속의 단점을 해결하기 위해 다중 상속의 장점을 포기하고 단일 상속만을 허용합니다.

 

다중 상속은 포함 관계를 활용하여 비슷하게 구현할 수 있습니다.
즉, 하나의 클래스는 상속받고, 다른 하나는 참조 변수로 포함시켜 사용합니다. 이후 포함된 클래스의 메서드와 동일한 선언부를 가진 메서드를 작성한 뒤, 내부에서 해당 포함 객체의 메서드를 호출하도록 구현합니다. 이렇게 하면 다중 상속과 유사한 효과를 내면서도 언어 차원에서 제공하지 않는 다중 상속을 안전하게 대체할 수 있습니다.

 

Object 클래스

 

Object클래스는 클래스 상속 계층도의 최상위에 있는 조상클래스입니다. 다른 클래스로부터 상속 받지 않는 모든 클래스들은 자동적으로 Object클래스로부터 상속받게 합니다.

위와 같이 상속 받지 않는 Parent클래스를 정의하였을 때, 위 코드를 컴파일하면 컴파일러는 자동적으로 'extends Object'를 추가합니다. 이렇게 함으로써 Object클래스가 모든 클래스의 조상이 되게 합니다. 다른 클래스를 상속받는다 하여도 상속 계층도를 따라 조상, 조상의 조상을 찾아 올라가다 보면 결국 마지막 조상은 Object클래스일 것입니다.

 

위와 같이 Parent클래스가 있고, Parent클래스를 상속받는 Child클래스가 있을 때 상속 계층도는 다음과 같습니다.

이처럼 모든 상속 계층도의 최상위에는 Object클래스가 위치합니다. 그래서 자바의 모든 클래스는 Object클래스의 멤버들을 상속 받기 때문에 Object클래스에 정의된 멤버들을 사용할 수 있습니다. toString()이나 equals()와 같은 메서드를 따로 정의하지 않고도 사용할 수 있었던 이유는 이 메서드들이 Object클래스에 정의되었기 때문입니다.

 

 

오버라이딩(overriding)

 

조상 클래스로부터 상속받은 메서드의 내용을 변경하는 것을 오버라이딩이라고 합니다. 상속받은 메서드를 그대로 사용하기도 하지만, 자손 클래스 자신에 맞게 변경해야하는 경우가 많습니다. 이럴 때 조상의 메서드를 오버라이딩(overriding)합니다.

 

2차원 좌표계의 한 점을 표현하기 위한 Point클래스가 있을 때, 이를 조상으로 하는 3차원 좌표계의 한 점을 표현하는 Point3D클래스를 작성해보겠습니다.

class Point {
    int x;
    int y;

    String getLocation() {
        return "x :" + x + ", y :" + y;
    }
}

class Point3D extends Point {
    int z;

    String getLocation() {
        return "x :" + x + ", y :" + y + ", z :" + z;
    }
}

Point클래스의 getLocation()은 한 점의 x, y 좌표를 문자열로 반환하도록 작성되었습니다. Point3D클래스는 Point클래스로부터 getLocation()을 상속받았지만, Point3D클래스에 맞지 않아 메서드를 Point3D클래스에 맞게 z축의 좌표값도 포함하여 반환하도록 오버라이딩하였습니다.

 

오버라이딩의 조건

 

오버라이딩은 메서드의 내용만을 새로 작성하는 것이므로 메서드의 선언부는 조상의 것과 완전히 일치해야 합니다. 그래서 오버라이딩이 성립하기 위해서는 다음과 같은 조건을 만족해야합니다.

  • 메서드의 이름이 같아야 합니다.
  • 메서드의 매개변수가 같아야 합니다.
  • 메서드의 반환타입이 같아야 합니다.
JDK 5부터 공변 반환타입(covariant return type)이 추가되어, 반환타입을 자손 클래스의 타입으로 변경하는 것은 가능해졌습니다.

 

한 마디로 요약하면 메서드의 선언부가 서로 일치해야 한다는 것입니다.

1. 조상의 메서드와 선언부가 일치해야 합니다.

다만 접근 제어자(access modifier)예외(exception)는 제한된 조건 하에서 다르게 변경할 수 있습니다.

2. 접근 제어자는 조상 클래스의 메서드보다 좁은 범위로 변경할 수 없습니다.

대부분의 경우 같은 범위의 접근 제어자를 사용합니다.

3. 조상 클래스의 메서드보다 많은 수의 예외를 선언할 수 없습니다.

여기서 많은 수의 예외란 단순히 개수의 문제가 아닙니다. 조상 클래스에서 IOException 예외를 선언했을 때, 자손 클래스에서 Exception을 선언하면 더 많은 예외를 선언한 것으로 잘못된 오버라이딩이 됩니다.

 

Q. 조상 클래스에서 정의된 static메서드를 자손 클래스에서 똑같은 이름의 static메서드로 정의가 가능한가요?

가능합니다. 그러나 이것은 각 클래스에 별개의 static메서드를 정의한 것일 뿐 오버라이딩은 아닙니다.

 

오버로딩  vs.  오버라이딩

 

오버로딩과 오버라이딩은 서로 혼동하기 쉽지만 사실 그 차이는 명백합니다. 오버로딩은 기존에 없는 새로운 메서드를 추가하는 것이고, 오버라이딩은 조상으로부터 상속받은 메서드의 내용을 변경하는 것입니다.

 

super

 

super는 자손 클래스에서 조상 클래스로부터 상속받은 멤버를 참조하는데 사용되는 참조 변수입니다. 멤버 변수와 지역 변수의 이름이 같을 때 this를 붙여서 구별했듯이 상속받은 멤버와 자신의 멤버가 이름이 같을 때는 super를 붙여서 구별할 수 있습니다. 물론 조상 클래스로부터 상속받은 멤버도 자손 클래스 자신의 멤버이므로,  같은 이름의 멤버가 없을 때는 super대신 this를 사용할 수도 있습니다.

 

조상의 멤버와 자신의 멤버를 구별하는데 사용된다는 점을 제외하고는 super와 this는 근본적으로 같습니다. 모든 인스턴스메서드에는 자신이 속한 인스턴스의 주소가 지역 변수로 저장되는데, 이것이 참조 변수인 this와 super의 값이 됩니다.

 

static메서드는 인스턴스와 관련이 없습니다. 그래서 this와 마찬가지로 super역시 static메서드에서 사용할 수 없습니다.

class Parent {
    int x = 10;
}

class Child extends Parent {
    void method() {
        System.out.println("x=" + x);			// 10
        System.out.println("this.x=" + this.x);		// 10
        System.out.println("super.x=" + this.x);	// 10
    }
}
class Parent {
    int x = 10;
}

class Child extends Parent {
    int x = 20;
    
    void method() {
        System.out.println("x=" + x);			// 20
        System.out.println("this.x=" + this.x);		// 20
        System.out.println("super.x=" + this.x);	// 10
    }
}

위 두 예제에서처럼 조상 클래스에 선언된 멤버변수와 같은 이름의 멤버 변수를 자손 클래스에서 중복해서 정의하는 것이 가능하며 참조 변수 super를 사용하여 서로 구별할 수 있다. 또한 변수뿐만아니라 메서드 역시 super를 써서 호출할 수 있습니다.

 

super()

 

this()와 마찬가지로 super() 역시 생성자입니다. this()는 같은 클래스의 다른 생성자를 호출하는 데 사용되고, super()는 조상 클래스의 생성자를 호출하는데 사용됩니다. 자손 클래스의 인스턴스를 생성하면, 자손 멤버와 조상의 멤버가 모두 합쳐진 하나의 인스턴스가 생성됩니다. 이때 조상 클래스 멤버의 초기화 작업이 수행되어야 하기 때문에 자손 클래스의 생성자에서 조상 클래스의 생성자가 호출되어야 합니다.

 

생성자 첫 줄에서 조상 클래스의 생성자가 호출되어야 하는데, 이는 자손 클래스의 멤버가 조상 클래스의 멤버를 사용할 수도 있으므로 조상의 멤버들이 먼저 초기화되어 있어야 하기 때문입니다. 이와 같은 조상 클래스의 생성자 호출은 클래스의 상속 관계를 거슬러 올라가면서 계속 반복되어 마지막으로 모든 클래스의 최고 조상인 Object클래스의 생성자까지 가서야 끝이 납니다.

 

만약 조상 클래스에서 매개변수가 있는 생성자를 사용한다면, 자손 클래스의 생성자는 첫 줄에서 조상의 생성자를 명시적으로 호출해야 합니다. 그렇지 않으면 컴파일러는 생성자의 첫 줄에 `super();`를 자동으로 추가하는데, 조상 클래스에서는 매개변수가 있는 생성자를 사용하기 때문에 컴파일에러가 발생하게 됩니다.

`super();`는 조상 클래스의 무인자 생성자를 호출하는 코드입니다.

 

 

package와 import

 

패키지(package)

 

패키지란, 클래스의 묶음입니다. 패키지는 여러 클래스 또는 인터페이스를 포함할 수 있으며, 서로 관련된 클래스를 그룹 단위로 묶어 놓음으로써 효율적으로 관리할 수 있습니다. 같은 이름의 클래스라도 서로 다른 패키지에 존재하는 것이 가능하므로, 자신만의 패키지 체계를 유지함으로써 다른 개발자가 개발한 클래스 라이브러리의 클래스와 이름이 충돌하는 것을 피할 수 있습니다.

 

클래스의 실제 이름(full name)은 패키지 이름을 포함한 것입니다. String클래스의 실제 이름은 java.lang.String이며, java.lang패키지에 속한 String클래스라는 의미입니다. 그래서 같은 이름의 클래스도 서로 다른 패키지에 속하면 패키지 이름으로 구별이 가능합니다. 클래스가 물리적으로 하나의 파일(.class)인 것과 같이 패키지는 물리적으로 하나의 디렉터리(폴더)입니다. 디렉터리가 하위 디렉터리를 가질 수 있는 것처럼, 패키지도 다른 패키지를 포함할 수 있으며 점(.)으로 구분합니다.

 

패키지의 선언

 

패키지를 선언하는 방법은 클래스나 인터페이스의 소스 파일(.java)의 맨 위에 다음과 같이 한 줄만 적어주면 됩니다.

package 패키지이름;

위와 같은 패키지 선언문은 반드시 소스파일에서 주석과 공백을 제외한 첫 번째 문장이어야 하며, 하나의 소스파일에 단 한 번만 선언될 수 있습니다. 해당 소스파일에 포함된 모든 클래스나 인터페이스는 서언된 패키지에 속하게 됩니다.

 

패키지 이름은 대소문자를 모두 허용하지만, 클래스명과 쉽게 구분하기 위해서 소문자로 하는 것을 원칙으로 하고 있습니다. 모든 클래스는 반드시 하나의 패키지에 포함되어야함에도 앞에서 패키지를 선언하지 않고도 아무런 문제가 없었던 이유는 자바에서 기본적으로 제공하는 '이름 없는 패키지(unnamed package)' 때문입니다. 소스파일에 자신이 속할 패키지를 지정하지 않은 클래스는 자동으로 '이름 없는 패키지'에 속하게 됩니다. 결국 패키지를 지정하지 않은 모든 클래스는 같은 패키지에 속하는 셈입니다.

 

간단한 크로그램은 패키지를 지정하지 않아도 별 문제 없지만, 큰 프로젝트나 Java API와 같은 라이브러리를 작성하는 경우에는 미리 패키지를 구성하여 적용해야 합니다.

 

CLASSPATH 지정

package com.example.book;

class PackageTest {
    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}

위의 예제를 작성한 뒤 다음과 같이 '-d' 옵션을 추가하여 컴파일을 합니다.

$javac -d . PackageTest.java

'-d' 옵션은 소스파일에 지정된 경로를 통해 패키지의 위치를 찾아서 클래스 파일을 생성합니다. 만일 지정된 패키지와 일치하는 디렉터리가 존재하지 않으면 자동으로 생성합니다. '-d' 옵션 뒤에는 해당 패키지의 루트(root)디렉터리의 경로를 적어줍니다. 위에서는 현재 디렉터리(.)를 지정했기 때문에 현재 터미널의 경로가 '/Users/mailo' 라면, 클래스파일은 '/Users/mailo/com/example/book'에 생성됩니다.

 

기존에 디렉터리가 존재하지 않았으므로 컴파일러가 패키지의 계층 구조에 맞게 새로 디렉터리를 생성하고 컴파일된 클래스파일을 넣었습니다. 이제 패키지(com.example.book)의 루트 디렉터리(/User/mailo)를 클래스패스(classpath)에 포함시켜야 합니다. 클래스패스를 지정해주지 않으면 기본적으로 현재 디렉터리(.)가 클래스패스로 지정됩니다.

클래스패스(classpath)는 컴파일러(javac)나 JVM 등이 클래스의 위치를 찾는데 사용되는 경로입니다.

 

루트 디렉터리를 클래스패스로 지정하면, 클래스 파일이 위치한 디렉터리가 아니더라도 JVM이 해당 클래스 파일을 찾을 수 있으므로 다음과 같이 실행할 수 있습니다.

$ java com.example.book.PackageTest

 

만약 JDK에 기본적으로 설정되어 있는 클래스패스를 이용하면 위와 같이 클래스패스를 따로 지정해줄 필요가 없습니다. 클래스파일의 경우 'JDK설치디렉터리\jre\classes' 디렉터리에, jar파일인 경우에는 'JDK설치디렉터리\jre\lib\ext' 디렉터리에 넣기만 하면 됩니다.

 

 

import 문

 

소스코드를 작성할 때 다른 패키지의 클래스를 사용하려면 패키지의 이름이 포함된 클래스 이름을 사용해야 합니다. 하지만, 매번 패키지 이름을 붙여서 작성하는 것은 불편합니다. 소스코드를 작성하기 전에 import문으로 사용하고자 하는 클래스의 패키지를 미리 명시해주면 소스코드에 사용되는 클래스 이름에서 패키지 이름은 생략할 수 있습니다.

import문의 역할은 컴파일러에게 소스파일에 사용된 클래스의 패키지에 대한 정보를 제공하는 것으로, 컴파일 시 컴파일러는 import문을 통해 소스파일에 사용된 클래스들의 패키지를 알아 낸 다음, 모든 클래스 이름 앞에 패키지 이름을 붙여줍니다.

 

모든 소스파일(.java)에서 import문은 package문 다음에, 그리고 클래스 선언문 이전에 위치해야 합니다. import문은 package문과 달리 한 소스파일에 여러 번 선언할 수 있습니다. import문을 선언하는 방법은 다음과 같습니다.

import 패키지명.클래스명;

import 패키지명.*;

같은 패키지에서 여러 개의 클래스가 사용될 때, import문을 여러 번 사용하는 대신, '*'을 사용해서 지정된 패키지에 속하는 모든 클래스를 한 번에 import 할 수 있습니다. '*'을 사용해도 컴파일 시에는 사용된 클래스만 import처리가 되기 때문에 필요한 클래스만 메모리에 올려 실행 시 성능상의 차이는 전혀 없습니다.

 

System클래스나 String클래스는 java.lang 패키지에 속해 있는데, java.lang 패키지는 매우 빈번히 사용되는 중요한 클래스들이 속한 패키지이기 때문에 따로 소스파일에 선언하지 않아도 컴파일러가 자동으로 추가해줍니다.

 

static import 문

 

import문을 사용하면 클래스의 패키지 이름을 생략할 수 있는 것과 같이 static import문을 사용하면 static멤버를 호출할 때 클래스 이름을 생략할 수 있습니다.

위와 같이 static import문을 선언하면, 오른쪽과 같이 간략히 할 수 있습니다.

 

 

제어자(modifier)

 

제어자(modifier)는 클래스, 변수 또는 메서드의 선언부에 함께 사용되어 부가적인 의미를 부여합니다. 제어자의 종류는 크게 접근 제어자와 그외의 제어자로 나눌 수 있습니다.

  • 접근 제어자: public, protected, default, private
  • 그 외: static, final, abstract, native, trasient, synchronized, volatile, strictfp

제어자는 클래스나 멤버 변수와 메서드에 주로 사용되며, 하나의 대상에 대하여 여러 제어자를 조합하여 사용하는 것이 가능합니다.

단, 접근 제어자는 한 번에 네 가지 중 하나만 선택해서 사용할 수 있습니다.

 

static

 

static은 '클래스의' 또는 '공통적인'의 의미를 갖고 있습니다. 인스턴스 변수는 하나의 클래스로부터 생성되었더라도 각기 다른 값을 유지하지만, 클래스 변수(static 변수)는 인스턴스에 관계 없이 같은 값을 갖습니다. 그 이유는 하나의 변수를 모든 인스턴스가 공유하기 때문입니다.

static이 붙은 멤버 변수와 메서드, 그리고 초기화 블럭은 인스턴스가 아닌 클래스에 관계된 것이기 때문에 인스턴스를 생성하지 않고도 사용할 수 있습니다. 인스턴스 메서드와 static메서드의 근본적인 차이는 메서드 내에서 인스턴스 멤버를 사용하는가의 여부에 있습니다.

 

인스턴스 멤버를 사용하지 않는 메서드는 static을 붙여서 static 메서드로 선언하는 것이 인스턴스를 생성하지 않고도 호출이 가능해서 더 편리하고 속도도 빠릅니다.

 

final

 

final은 '마지막의' 또는 '변경될 수 없는'의 의미를 갖고 있으며 거의 모든 대상에 사용될 수 있습니다.

  • 변수: 값 변경 불가(상수)
  • 메서드: 오버라이딩 불가
  • 클래스: 자손 클래스 정의 불가

대표적인 final 클래스로는 String과 Math가 있습니다.

 

final 인스턴스 변수

final이 붙은 변수는 상수이므로 일반적으로 선언과 동시에 초기화를 하지만, 인스턴스 변수의 경우 생성자에서 초기화 되도록 할 수 있습니다. 이 기능을 활용하면 각 인스턴스마다 final이 붙은 멤버변수가 다른 값을 갖도록 하는 것이 가능합니다. 만약 final이 붙은 인스턴스 변수의 생성자 초기화가 불가능했다면, final이 붙은 모든 인스턴스 변수는 같은 값을 가져야만 할 것입니다.

 

abstract

 

abstract는 '미완성'의 의미를 갖고 있습니다. 메서드의 선언부만 작성하고 실제 수행 내용은 구현하지 않은 추상 메서드를 선언하는데 사용됩니다. 그리고 클래스에 사용되어, 클래스 내에 추상 메서드가 존재한다는 것을 쉽게 알 수 있게 합니다.

abstract class AbstractTest {
    abstract void move();
}

추상 클래스는 아직 완성되지 않은 메서드가 존재하는 '미완성 설계도'이므로 인스턴스를 생성할 수 없습니다.

 

꽤 드물지만 추상 메서드가 없는 클래스, 즉 완성된 클래스도 abstract를 붙여서 추상 클래스로 만드는 경우도 있습니다. 예를 들어 java.awt.event.WindowAdapter는 아래와 같이 아무런 내용이 없는 메서드들만 정의되어 있습니다. 이런 클래스는 인스턴스를 생성해봐야 할 수 있는 것이 아무것도 없습니다. 그래서 인스턴스를 생성하지 못하게 클래스 앞에 제어자 'abstract'를 붙여 놓은 것입니다.

public abstract class WindowAdapter
    	implements WindowListener, WindowStateListener, WindowFocusListener {
    public void windowOpened(WindowEvent e) { }
    public void windowClosing(WindowEvent e) { }
    public void windowClosed(WindowEvent e) { }
    ...
}

이 클래스 자체로는 쓸모가 없지만, 다른 클래스가 이 클래스를 상속받아서 일부의 원하는 메서드만 오버라이딩해도 된다는 장점이 있습니다. 만약 모든 메서드를 추상 메서드로 선언했다면 모든 메서드를 오버라이딩해야 합니다.

 

접근 제어자

 

접근 제어자는 멤버 또는 클래스에 사용되어, 해당하는 멤버 또는 클래스를 외부에서 접근하지 못하도록 제한하는 역할을 합니다. 접근 제어가 default임을 알리기 위해 실제로 default를 붙이지는 않습니다. 클래스나 멤버변수, 메서드, 생성자에 접근 제어자가 지정되어 있지 않다면, 접근 제어자가 default임을 뜻합니다.

  • public: 접근 제한 없음
  • protected: 같은 패키지 내에서 또는 다른 패키지의 자손 클래스에서 접근 가능
  • (default): 같은 패키지 내에서만 접근 가능
  • private: 같은 클래스에서만 접근 가능

클래스나 멤버, 주로 멤버에 접근 제어자를 사용하는 이유는 클래스의 내부에 선언된 데이터를 보호하기 위해서입니다. 데이터가 유효한 값을 유지하도록, 또는 비밀번화 같은 데이터를 외부에서 함부로 변경하지 못하도록 하기 위해서는 외부로부터의 접근을 제한하는 것이 필요합니다. 이것을 데이터 감추기(data hiding)라고 하며, 객체지향개념의 캡슐화(encapsulation)에 해당하는 내용입니다.

또 다른 이유는 클래스 내에서만 사용되는, 내부 작업을 위해 임시로 사용되는 멤버변수나 부분 작업을 처리하기 위한 메서드 등의 멤버들을 클래스 내부에 감추기 위해서입니다. 외부에서 접근할 필요가 없는 멤버들을 private로 지정하여 외부에 노출시키지 않음으로써 복잡성을 줄일 수 있습니다. 이것 역시 캡슐화에 해당합니다.

 

만일 메서드 하나를 변경해야 한다고 가정했을 때, 이 메서드의 접근 제어자가 public이라면, 메서드를 변경한 후에 오류가 없는지 테스트할 범위가 넓습니다. 그러나 접근 제어자가 default라면 패키지 내부만 확인해보면 되고, private이라면 클래스 하나만 살펴보면 됩니다.

구체적인 예시를 통해 자세히 살펴보겠습니다.

public class Time {
    private int hour;
    private int minute;
    private int second;

    public int getHour() { return hour; }
    public void setHour() {
        if (hour < 0 || hour > 23} return;
        this.hour = hour;
    }
    
    ...
}

get으로 시작하는 메서드는 단순히 멤버 변수의 값을 반환하는 일을 하고, set으로 시작하는 메서드는 매개변수에 지정된 값을 검사하여 조건에 맞는 값을 때만 멤버 변수의 값을 변경하도록 작성되어 있습니다. 이 메서드들을 각각 getter, setter 라고 부릅니다.

 

생성자의 접근 제어자

생성자에 접근 제어자를 사용함으로써 인스턴스의 생성을 제한할 수 있습니다. 보통 생성자의 접근 제어자는 클래스의 접근 제어자와 같지만, 다르게 설정할 수도 있습니다. 생성자의 접근 제어자를 private으로 지정하면, 외부에서 생성자에 접근할 수 없으므로 인스턴스를 생성할 수 없게 됩니다. 그래도 클래스 내부에서는 인스턴스를 생성할 수 있습니다.

 

대신 인스턴스를 생성해서 반환해주는 public 메서드를 제공함으로써 외부에서 이 클래스의 인스턴스를 사용하도록 할 수 있습니다. 이 메서드는 public인 동시에 static이어야 합니다.

final class Singleton {
    private static Singleton s = new Singleton();
    
    private Singleton() {
        ...
    }

    public static Singleton getInstance() {
        return s;
    }
}

이처럼 생성자를 통해 직접 인스턴스를 생성하지 못하게 하고 public 메서드를 통해 인스턴스에 접근하게 함으로써 사용할 수 있는 인스턴스의 개수를 제한할 수 있습니다. 또한, 생성자가 private인 클래스는 다른 클래스의 조상이 될 수 없습니다. 왜냐하면, 자손 클래스의 인스턴스를 생성할 때 조상 클래스의 생성자를 호출해야 하는데, 생성자의 접근 제어자가 private이므로 자손에서 호출하는 것이 불가능합니다.

그래서 위와 같이 class 앞에 final을 명시해줌으로서 상속할 수 없는 클래스라는 것을 알리는 것이 좋습니다.

 

제어자의 조합

제어자를 조합해서 사용할 때 주의해야 할 사항에 대해 정리해보겠습니다.

  1. 메서드에 static과 abstract를 함께 사용할 수 없습니다.
    -> static 메서드는 몸통이 있는 메서드에만 사용할 수 있습니다.
  2. 클래스에 abstract와 final을 함께 사용할 수 없습니다.
    -> 클래스에 사용되는 final은 클래스를 확장할 수 없다는 의미고, abstract는 상속을 통해서 완성되어야 한다는 의미로 모순됩니다.
  3. 메서드에 abstract와 private을 함께 사용할 수 없습니다.
    -> abstract메서드는 자손클래스에서 구현해주어야 하는데 접근 제어자가 private이면 자손 클래스에서 접근할 수 없습니다.
  4. 메서드에 private과 final을 함께 사용할 필요는 없습니다.
    -> 접근 제어자가 private인 메서드는 오버라이딩될 수 없기 때문에 둘 중 하나만 사용해도 의미가 충분합니다.

 

 

다형성(polymorphism)

 

객체지향개념에서 다형성이란 '여러 가지 형태를 가질 수 있는 능력'을 의미하며, 자바에서는 한 타입의 참조 변수로 여러 타입의 객체를 참조할 수 있도록 함으로써 다형성을 프로그램적으로 구현하였습니다.

이를 좀 더 구체적으로 말하자면, 조상 타입의 참조 변수로 자손의 인스턴스를 참조할 수 있도록 하였다는 것입니다.

class Tv {
    boolean power;
    int channel;
}

class CaptionTv extends Tv {
    String text;
    void caption() {...}
}

위와 같이 Tv클래스와 그 자손 클래스인 CaptionTv클래스가 있다고 가정하겠습니다.

 

지금까지는 생성된 인스턴스를 다루기 위해서, 인스턴스의 타입과 일치하는 타입의 참조 변수만을 사용했습니다. 이처럼 인스턴스의 타입과 참조 변수의 타입이 일치하는 것이 보통이지만, 서로 상속 관계에 있을 경우 다음과 같이 조상 클래스 타입의 참조 변수로 자손 클래스의 인스턴스를 참조할 수 있습니다.

CaptionTv c = new CaptionTv();	

Tv t = new CaptionTv();		// 조상 타입의 참조 변수로 자손 인스턴스 참조

위 두 인스턴스 모두 실제로는 CaptionTv타입이지만, 조상 타입의 참조 변수 t로는 CaptionTv인스턴스의 모든 멤버를 사용할 수 없습니다. 즉, 같은 타입의 인스턴스라도 참조 변수 타입에 따라 사용할 수 있는 멤버의 개수가 달라집니다.

 

반대로 자손 타입의 참조 변수로 조상 타입의 인스턴스를 참조하는 것은 불가능합니다. 이 경우 실제 인스턴스인 Tv의 멤버 개수보다 참조 변수 CaptionTv c가 사용할 수 있는 멤버 개수가 더 많기 때문입니다. 그래서 자손 타입의 참조 변수로 조상 타입의 인스턴스를 참조하는 것은 존재하지 않는 멤버를 사용하고자 할 가능성이 있으므로 허용하지 않습니다. 

 

참조변수의 형변환

 

기본형 변수와 같이 참조 변수도 형변환이 가능합니다. 단, 서로 상속 관계에 있는 클래스사이에서만 가능하기 때문에 자손 타입의 참조 변수를 조상 타입의 참조 변수로, 조상 타입의 참조 변수를 자손 타입의 참조 변수로의 형변환만 가능합니다.

 

기본형 변수의 형변환에서도 작은 자료형에서 큰 자료형의 형변환은 생략이 가능하듯이, 참조형 변수의 형변환에서는 자손 타입의 참조변수를 조상 타입으로 형변환하는 경우에는 형변환을 생략할 수 있습니다. 또한 참조 변수간 형변환 역시 캐스트 연산자를 사용하며, 괄호( )안에 변환하고자 하는 타입의 이름(클래스명)을 적어주면 됩니다.

 

조상 타입의 참조 변수를 자손 타입의 참조 변수로 변환하는 것을 '다운 캐스팅(down-cating)'이라고 하며, 자손 타입의 참조 변수를 조상 타입의 참조 변수로 변환하는 것을 '업 캐스팅(up cating)'이라고 합니다. 

 

Tv t = null;
CaptionTv c = new CaptionTv();
CaptionTv c2 = null;

t = c;			// (Tv) 형변환 생략
c2 = (CaptionTv)t;	// 형변환 명시

형변환은 참조 변수의 타입을 변환하는 것이지 인스턴스를 변환하는 것은 아니기 때문에 참조 변수의 형변환은 인스턴스에 아무런 영향을 미치지 않습니다. 단지 참조 변수의 형변환을 통해서 참조하고 있는 인스턴에서 사용할 수 있는 멤버의 범위(개수)를 조절하는 것뿐입니다.

 

instanceof 연산자

 

참조 변수가 참조하고 있는 인스턴스의 실제 타입을 알아보기 위해 instanceof 연산자를 사용합니다. instanceof의 왼쪽에는 참조 변수를 오른쪽에는 타입이 피연산자로 위치합니다. 그리고 연산의 결과로 boolean값인 true와 false 중의 하나를 반환합니다.

instanceof를 이용한 연산 결과로 true를 얻었다는 것은 참조변수가 검사한 타입으로 형변환이 가능하다는 것을 뜻합니다.

void doWork(Car c) {
    if (c instanceof FireEngine) {
        FireEngine fe = (FireEngine)c;
        fe.water();
    } else if (c instanceof Ambulance) {
        Ambulance a = (Ambulance)c;
        a.siren()
    }
...
}

위의 코드는 Car타입의 참조 변수 c를 매개변수로 하는 메서드로, instanceof 연산자를 이용해서 참조 변수 c가 가리키고 있는 인스턴스의 타입을 체크하고, 적절히 형변환합니다.

 

조상 타입의 참조 변수로 자손 타입의 인스턴스를 참조할 수 있지만, 이 경우 실제 인스턴스가 가진 모든 멤버를 사용할 수는 없습니다. 따라서 인스턴스와 동일한 타입의 참조 변수로 형변환해야만 해당 인스턴스의 모든 멤버를 사용할 수 있습니다.

 

모든 인스턴스는 Object 타입에 대하여 instanceof 연산을 수행하면 true를 반환합니다. 그 이유는 모든 클래스는 Object 클래스의 자손 클래스이므로 조상의 멤버들을 상속받았기 때문에 Object 인스턴스를 포함하고 있는 셈이기 때문입니다.

 

요약하면, 실제 인스턴스와 같은 타입의 instanceof 연산 이외에 조상 타입의 instanceof 연산에도 true를 결과로 얻으며, instanceof 연산의 결과가 true라는 것은 검사한 타입으로의 형변환을 해도 아무런 문제가 없다는 뜻입니다.

 

instanceof 패턴 매칭

 

많은 경우에 instanceof는 참조변수의 형변환과 같이 사용되기 때문에 instanceof의 다음에 오는 형변환을 생략할 수 있는 문법이 추가되었습니다. 그래서 아래와 같이 간단히 할 수 있습니다.

위 오른쪽 코드에서 선언된 참조변수 fe는 instanceof 조건식이 참인 경우에만 사용할 수 있는 임시 변수이므로 다음과 같이 블럭{ } 밖에서 는 사용할 수 없습니다.

 

하지만 다음과 같이 이를 반대로 블럭{ } 밖에서 instanceof 연산이 참이 되게 하면 블럭{ } 밖에서 사용이 가능합니다.

 

 

그리고 instanceof패턴 매칭과 조건식을 결합이 가능한 경우는 '&&' 연산자로 할 때만 가능합니다. '||' 연산자의 경우 왼쪽 피연산자가 false이어도 오른쪽 피연산자를 평가하기 때문입니다.

 

 

 

참조변수와 인스턴스의 연결

 

조상 클래스에 선언된 멤버 변수와 같은 이름의 인스턴스 변수를 자손 클래스에 중복으로 정의했을 때, 조상 타입의 참조 변수로 자손 인스턴스를 참조하는 경우와 자손 타입의 참조 변수로  자손 인스턴스를 참조하는 경우는 서로 다른 결과를 얻습니다.

class BindingEx {
    public static void main(Strng[] args) {
        Parent p = new Child();
        Child c = new Child();

        System.out.println(p.x);	// 100
        p.method();		// Child Method

        System.out.println(c.x);	// 200
        c.method();		// Child Method
    }
}

class Parent {
    int x = 100;

    void method() {
    	System.out.println("Parent Method");
    }
}

class Child extends Parent {
    int x = 200;

    void method() {
    	System.out.println("Child Method");
    }
}

메서드의 경우 조상 클래스의 메서드를 자손의 클래스에서 오버라이딩하면 참조변수의 타입의 관계없이 항상 오버라이딩된 메서드가 호출되지만, 멤버 변수의 경우 참조변수의 타입에 따라 달라집니다.

 

class Child extend Parent {
    int x = 200;

    void method() {
    	System.out.println(x);		// 200
        System.out.println(this.x);	// 200
        System.out.println(super.x);	// 100
    }
}

자손 Child에 선언된 인스턴스 변수 x와 조상 Parent로부터 상속받은 인스턴스 변수 x를 구분하때는 참조 변수 super와 this를 사용합니다.

 

여러 종류의 객체를 배열로 다루기

 

조상 타입의 참조 변수로 자손 타입의 객체를 참조하는 것이 가능하므로, Product가 Tv, Computer, Audio의 조상일 때, 다음과 같이 할 수 있습니다.

Product[] p = new Product[3];
p[0] = new Tv();
p[1] = new Computer();
p[2] = new Audio();

이처럼 조상 타입의 참조 변수 배열을 사용하면, 공통의 조상을 가진 서로 다른 종류의 객체를 배열로 묶어서 다룰 수 있습니다.

 

이러한 특징을 이용해서 Buyer 클래스를 정의해보겠습니다.

class Buyer {
    int money = 1000;
    int bonusPoint = 0;
    Product[] time = new Product[10];
    int i = 0;

    void buy {
        if (money < p.price) {
            System.out.println("잔액 부족");
            return;
        }

        money -= p.price;
        bonusPoint += p.bonusPoint;
        item[i++] = p;
    }
}

위 예제에서는 배열의 크기를 10개로 지정했지만, 동적 배열을 사용하고 싶은 경우에는 Vector 클래스를 사용할 수 있습니다.

Vector 클래스는 JDK 1.0부터 지원되는 동기화(synchronized)된 동적 배열 클래스입니다. 내부적으로는 ArrayList와 구조가 거의 동일하지만 모든 메서드가 synchronized로 감싸져 있어 Thread-safe 합니다.

 

추상 클래스(abstract class)

 

클래스를 설계도에 비유한다면, 추상 클래스를 미완성 설계도에 비유할 수 있습니다. 미완성이라는 의미는 단지 미완성 메서드인 추상 메서드를 포함하고 있다는 뜻입니다. 이러한 추상 클래스로는 인스턴스를 생성할 수 없으며, 추상 클래스의 상속을 통한 자손 클래스에 의해서만 완성될 수 있습니다. 추상 클래스는 키워드 'abstract'를 붙이기만 하면 됩니다.

abstract class 클래스명 {
    ...
}

 

추상 메서드

 

메서드는 선언부와 구현부로 구성되어 있는데, 추상 메서드는 선언부만 작성하고 구현부는 작성하지 않은 채로 남겨 둔 것이 추상 메서드입니다. 즉, 설계만 해 놓고 실제 수행될 내용을 작성하지 않았기 때문에 미완성 메서드인 것입니다.

 

메서드를 이와 같이 미완성 상태로 남겨 놓는 이유는 메서드의 내용이 상속받는 클래스에 따라 달라질 수 있기 때문에 조상에는 선언부만 작성하고, 실제 내용은 상속받는 자손에서 구현하도록 비워두는 것입니다. 추상 메서드 역시 키워드 'abstract'를 앞에 붙여 주고, 추상 메서드는 구현부가 없으므로 괄호{ } 대신 문장의 끝을 알리는 ';'을 적어줍니다.

abstract 리턴타입 메서드명();

추상 클래스로부터 상속받는 자손 클래스는 오버라이딩을 통해 조상인 추상 클래스의 추상 메서드를 모두 구현해야 합니다. 만일 조상으로부터 상속받은 추상메서드 중 하나라도 구현하지 않는다면, 자손 클래스 역시 추상 클래스로 지정해야 합니다.

 

추상 클래스의 작성

 

여러 클래스에 공통적으로 사용될 수 있는 클래스를 바로 작성하기도 하고, 기존의 클래스의 공통적인 부분을 뽑아서 추상클래스로 만들어 상속하도록 하는 경우도 있습니다. 추상의 사전적 정의는 '낱낱의 구체적 표상이나 개념에서 공통된 성질을 뽑아 이를 일반적인 개념으로 파악하는 정신 작용' 입니다. 

 

상속이 자손 클래스를 만드는데 조상 클래스를 사용하는 것이라면, 이와 반대로 추상화는 기존의 클래스의 공통 부분을 뽑아서 조상 클래스를 만드는 것이라고 할 수 있습니다. 추상화를 구체화와 반대 의미로 이해하면 보다 쉽게 이해할 수 있습니다.

 

상속 계층도를 따라 내려갈수록 클래스는 점점 기능이 추가되어 구체화의 정도가 심해지며, 상속 계층도를 따라 올라갈수록 클래스는 추상화의 정도가 심해진다고 할 수 있습니다. 즉, 상속 계층도를 따라 내려 갈수록 세분화되며, 올라올수록 공통 요소만 남게 됩니다.

 

abstract class Player {
    abstract void play(int pos);
    abstract void stop();
}

위 코드는 2개의 추상 메서드를 갖고 있는 추상 클래스입니다. 사실 Player 클래스의 메서드를 다음과 같이 추상 메서드가 아닌, 아무 내용도 없는 메서드로 작성할 수도 있습니다.

class Player {
    void play(int pos) { }
    void stop() { }
}

어차피 자손 클래스에서 오버라이딩하여 자신의 클래스에 맞게 구현할테니 추상 메서드로 선언하는 것과 빈 구현부를 만들어 놓는 것과 별 차이가 없어 보일 수 있습니다. 그래도 굳이 abstract를 붙여서 추상 메서드로 선언하는 이유는 자손에서 추상메서드를 반드시 구현하도록 강요하기 위해서입니다. 만일 빈 구현부를 가지도록 구현되어 있다면, 상속받는 자손 클래스에서 이미 구현된 것으로 인식하고 오버라이딩을 통해 자신의 클래스에 맞도록 구현하지 않을 수도 있기 때문입니다.

 

인터페이스(interface)

 

인터페이스는 일종의 추상 클래스입니다. 인터페이스는 추상 클래스처럼 추상 메서드를 갖지만 추상 클래스보다 추상화 정도가 높아서 추상 클래스와 달리 일반 메서드 또는 멤버 변수를 구성원으로 가질 수 없습니다. 오직 추상 메서드와 상수만을 멤버로 가질 수 있습니다.

 

추상 클래스를 부분적으로만 완성된 '미완성 설계도'라고 한다면, 인터페이스는 구현된 것은 아무 것도 없고 밑그림만 그려져 있는 '기본 설계도'라 할 수 있습니다.

 

인터페이스의 작성

 

인터페이스를 작성하는 것은 클래스를 작성하는 것과 비슷합니다. 다만 키워드를 class 대신 interface를 사용하고, 접근 제어자로 public과 (default)만 사용할 수 있습니다.

interface 인터페이스명 {
    ...
}

일반적인 클래스의 멤버와 달리 인터페이스의 멤버는 다음과 같은 제약사항이 있습니다.

  • 모든 멤버변수는 public static final 이며 생략할 수 있습니다.
  • 모든 메서드는 public abstract 이며 생략할 수 있습니다.
위 제약사항에 따라 원래는 인터페이스의 모든 메서드는 추상 메서드여야 하는데,
JDK 8부터 static method, default method를 그리고 JDK 9부터 private method의 추가를 허용하였습니다.

 

인터페이스의 상속

 

인터페이스는 인터페이스로부터만 상속받을 수 있으며, 클래스와는 달리 다중 상속, 즉 여러 개의 인터페이스로부터 상속을 받는 것이 가능합니다. 클래스의 상속과 마찬가지로 자손 인터페이스는 조상 인터페이스에 정의된 멤버를 모두 상속받습니다.

인터페이스는 클래스와 달리 Object 클래스와 같은 최고 조상이 없습니다.

 

인터페이스의 구현

 

인터페이스도 추상 클래스처럼 그 자체로는 인스턴스를 생성할 수 없으며, 추상 클래스가 상속을 통해 추상 메서드를 완성하는 것처럼, 인터페이스도 자신에 정의된 추상 메서드의 몸통을 만들어주는 클래스를 작성해야 하는데, 그 방법은 추상 클래스가 자신을 상속받는 클래스를 정의하는 것과 다르지 않습니다. 다만 클래스는 확장한다는 의미의 키워드 'extends'를 사용하지만 인터페이스는 구현한다는 의미의 키워드 'implements'를 사용합니다.

 

만일 구현하는 인터페이스의 메서드 중 일부만 구현한다면, 다음과 같이 abstract를 붙여서 추상 클래스로 선언해야 합니다.

abstract class Fighter implements Fightable {
    ...
}

 

그리고 다음과 같이 상속과 구현을 동시에 할 수도 있습니다.

class Fighter extends Unit implements Fightable {
    ...
}
인터페이스의 이름에는 주로 '~를 할 수 있는'의 의미인 'able'로 끝나는 것들이 많은데, 그 이유는 어떠한 기능 또는 행위를 하는데 필요한 메서드를 제공한다는 의미입니다.

 

인터페이스를 이용한 다중 상속

 

두 조상으로부터 상속받는 멤버 중에서 멤버 변수의 이름이 같거나 메서드의 선언부가 일치하고 구현 내용이 다르다면 이 두 조상으로부터 상속받는 자손 클래스는 어느 조상의 것으 상속받게 되는 것인지 알 수 없습니다. 어느 한 쪽으로부터의 상속을 포기하던가, 이름이 충돌하지 않도록 조상 클래스를 변경하는 수밖에 없습니다.

 

그래서 다중 상속은 장점도 있지만 단점이 더 크다고 판단하였기 때문에 자바에서는 다중 상속을 허용하지 않습니다. 그러나 또 다른 객체지향언어인 C++에서는 다중 상속을 허용하기 때문에 자바는 다중 상속을 허용하지 않는다는 것이 단점으로 부작되는 것에 대한 대응으로 '자바도 인터페이스를 이용하면 다중 상속이 가능하다'라고 하는 것일 뿐 자바에서 인터페이스로 다중 상속을 구현하는 경우는 거의 없습니다.

 

인터페이스는 static 상수만 정의할 수 있으므로 조상 클래스의 멤버 변수와 충돌하는 경우는 거의 없고 충돌된다 하더라도 클래스 이름을 붙여서 구분이 가능합니다. 그리고 추상메서드는 구현 내용이 전혀 없으므로 조상 클래스의 메서드와 선언부가 일치하는 경우에는 당연히 조상 클래스 쪽의 메서드를 상속받으면 되므로 문제되지 않습니다.

 

그러나 이렇게 하면 상속받는 멤버의 충돌은 피할 수 있지만, 다중 상속의 장점을 잃게 됩니다. 만일 두 개의 클래스로부터 상속을 받아야 할 상황이라면, 두 조상클래스 중에서 비중이 높은 쪽을 선택하고 다른 한쪽은 클래스 내부에 멤버로 포함시키는 방식으로 처리할 수 있습니다.

public class TvDVD extends Tv implements IDVD {
    DVD dvd = new DVD();	// 포함
    
    public void play() {
    	dvd.play();
    }
    
    public void stop() {
    	dvd.stop();
    }
}

 

사실 위 코드 예제와 같이 DVD 클래스에 대응하는 인터페이스인 IDVD를 새로 작성하지 않고, 클래스 내부에 멤버로 포함시키는 것만으로도 충분하지만, 인터페이스를 이용하면 다형적 특정을 이용할 수 있다는 장점이 있습니다.

 

인터페이스를 이용한 다형성

 

다형성을 이용하면 자손 클래스의 인스턴스를 조상 타입의 참조변수로 참조하는 것이 가능합니다. 인터페이스 역시 이를 구현한 클래스의 조상이라 할 수 있으므로 해당 인터페이스 타입의 참조 변수로 이를 구현한 클래스의 인스턴스를 참조할 수 있으며, 인터페이스 타입으로의 형변환도 가능합니다.

Fightable f = new Fighter();

 

따라서 인터페이스는 다음과 같이 메서드의 매개변수 타입으로도 사용될 수 있고, 메서드의 리턴타입으로도 사용될 수 있습니다.

void attack (Fightable f) { ... }

Fightable method() { ... }
  • 메서드에 해당 인터페이스를 구현한 클래스의 인스턴스를 매개변수로 제공
  • 메서드에서 해당 인터페이스를 구현한 클래스의 인스턴스를 반환

 

인터페이스의 장점

 

1. 표준화가 가능합니다.

프로젝트에 사용되는 기본 틀을 인터페이스로 작성한 다음, 개발자들에게 인터페이스를 구현하여 프로그램을 작성하도록 함으로써 보다 일관되고 정형화된 프로그램의 개발이 가능합니다.

 

2. 서로 관계없는 클래스들에게 관계를 맺어 줄 수 있습니다.

아래와 같이 빈 인터페이스를 만들어 구현하게 하면, 단지 타입 체크에만 사용되는 공통 인터페이스로 사용할 수 있습니다.

interface Ridable {}

class Car extends FourWheels implements Ridable { ... }

class Truck extends FourWheels implements Ridable { ... }

class Scooter extends TwoWheels implements Ridable { ... }

 

3. 독립적인 프로그래밍이 가능합니다.

예를 들어 프로그램을 만들 때 특정 데이터베이스를 사용하는 클래스를 작성하여 개발했다면, 이 프로그램은 다른 종류의 데이터베이스를 사용하기 위해서는 전체 프로그램 중에서 데이터베이스 관련된 부분은 모두 변경해야할 것입니다.

 

그러나 데이터베이스 관련 인터페이스를 정의하고 이를 이용해서 프로그램을 작성하면 데이터베이스의 종류가 변경되더라도 프로그램을 변경하지 않도록 할 수 있습니다. 단, 데이터베이스 클래스도 구현해서 제공해야 합니다. 실제로 자바에서는 다수의 데이터베이스와 관련된 인터페이스를 제공하고 있으며, 프로그래머는 이 인터페이스를 이용해서 프로그래밍하면 특정 데이터베이스에 종속되지 않는 프로그램을 작성할 수 있습니다. 

 

인터페이스의 이해

 

인터페이스의 규칙이나 활용이 아닌, 본질적인 측면에 대해 알아보겠습니다. 먼저 인터페이스를 이해하기 위해서는 다음의 두 가지 사항을 반드시 염두에 두고 있어야 합니다.

  1. 클래스를 사용하는 쪽(User)와 클래스를 제공하는 쪽(Provider)가 있습니다.
  2. 메서드를 사용(호출)하는 쪽(User)에서는 사용하려는 메서드(Provider)의 선언부만 알면 됩니다.
class A {
    public void methodA(B b) {
    	b.methodB();
    }
}

class B {
    public void mehtodB() {
    	System.out.println("methodB()");
    }
}

class InterfaceEx {
    public static void main(String[] args) {
        A a = new A();
        a.methodA(new B());
    }
}

위 코드를 보면 클래스 A(User)는 클래스 B(Provider)의 인스턴스를 생성하고 메서드를 호출합니다. 

이 두 클래스는 서로 직접적인 관계에 있기 때문에 클래스 B의 methodB()의 선언부가 변경되면, 이를 사용하는 클래스 A도 변경되어야 합니다. 이와 같이 직접적인 관계의 두 클래스는 한 쪽(Provider)가 변경되면 다른 한 쪽(User)도 변경되어야 한다는 단점이 있습니다.

 

 

interface I {
    public abstract void methodB();
}

class B implements I {
    public void methodB() {
    	System.out.println("methodB in B class");
    }
}

class A {
    public void methodA(I i) {
    	i.methodB();
    }
}

그러나 위 코드와 같이 인터페이스를 사용해서 클래스 A가 클래스 B를 직접 호출하지 않고 인터페이스를 매개체로 해서 클래스 B의 메서드에 접근하도록 하면, 클래스 B에 변경사항이 생기거나 클래스 B와 같은 기능의 다른 클래스로 대체 되어도 클래스 A는 전혀 영향을 받지 않도록 하는 것이 가능합니다.

이제 클래스 A와 클래스 B는 직접적인 관계에서 간접적인 관계로 바뀌었습니다. 클래스 A는 인터페이스를 통해 실제로 사용하는 클래스의 이름을 몰라도 되고, 심지어는 실제로 구현된 클래스가 존재하지 않아도 문제되지 않습니다. 클래스 A는 오직 직접적인 관계에 있는 인터페이스 I의 영향만 받습니다.

 

 

클래스 A에서 인터페이스 I를 매개변수를 통해 동적으로 제공받지 않고, 다음과 같이 제3의 클래스를 통해서 제공받을 수도 있습니다. JDBC의 DriverManager 클래스가 이런 방식으로 되어 있습니다.

class A {
    void methodA() {
        I i = InstanceManager.getInstance();
        i.methodB();
    }
}

 

디폴드 메서드, static메서드, private메서드

 

원래 인터페이스에 추상 메서드만 선언할 수 있었는데, JDK 8부터 디폴트 메서드와 static메서드, JDK 9부터는 private메서드도 추가할 수 있게 되었습니다.

 

static메서드

애초에 static메서드는 인스턴스와 관계가 없는 독립적인 메서드이기 때문에 예전부터 인터페이스에 추가하지 못할 이유가 없었으나, 규칙을 단순화 할 필요가 있어 예외로 두지 않았었습니다. 가장 대표적인 것으로 java.util.Collection 인터페이스가 있는데, 이 인터페이스와 관련된 static메서드들이 인터페이스에는 추상 메서드만 선언할 수 있다는 원칙 때문에 별도의 클래스 Collections에 들어가게 되었습니다.

 

물론 인터페이스의 static메서드 역시 접근 제어자가 항상 public이며, 생략가능합니다.

 

디폴트 메서드

조상 클래스에 새로운 메서드를 추가하는 것은 별 일이 아니지만, 인터페이스의 경우는 보통 큰 일이 아닙니다. 인터페이스에 메서드를 추가한다는 것은, 추상 메서드를 추가한다는 것이고, 이 인터페이스를 구현한 기존의 모든 클래스들이 새로 추가된 메서드를 구현해야하기 때문입니다.

 

물론 인터페이스가 변경되지 않으면 제일 좋겟지만, 아무리 설계를 잘해도 언젠가 변경은 발생하기 마련입니다. 그래서 JDK의 설계자들은 고심 끝에 디폴트 메서드(default method)라는 것을 고안해 내었습니다. 디폴트 메서드는 추상 메서드의 기본적인 구현을 제공하는 메서드로, 추상 메서드가 아니기 때문에 디폴트 메서드가 새로 추가되어도 해당 인터페이스를 구현한 클래스르 변경하지 않아도 됩니다.

 

디폴트 메서드는 앞에 키워드 default를 붙이며, 추상 메서드와 달리 일반 메서드처럼 구현부{ }가 있어야 합니다. 또한 디폴트 메서드 역시 접근 제어자가 public이며, 생략가능합니다.

 

디폴트 메서드가 기존의 메서드와 이름이 중복되어 충돌하는 경우가 발생하면, 다음과 같이 충돌을 해결합니다.

  • 여러 인터페이스의 디폴트 메서드 간의 충돌은 구현 클래스에서 디폴트 메서드를 오버라이딩합니다.
  • 디폴트 메서드와 조상 클래스의 메서드 간의 충돌 디폴트 메서드가 무시됩니다.

 

private메서드

JDK 8부터 디폴트 메서드와 static메서드가 인터페이스에 추가되면서 자연스럽게 private메서드의 필요성이 대두되었습니다. static메서드처럼 private메서드는 자손들에게 영향을 미치지 않기 때문에 인터페이스에 추가되어도 아무런 문제가 없기 때문입니다.

 

private메서드는 단지 접근 제어자가 private인 일반 메서드이고, 디폴드 메서드 또는 static메서드의 중복 코드를 제거하거나 코드를 작업별로 분리하는 과정에서 자연스럽게 추가하게 됩니다.

 

private메서드를 작성할 때 지켜야하는 규칙은 다음과 같습니다.

  1. 반드시 구현부{ }가 있어야 합니다.
  2. 접근 제어자는 private이며, static도 붙일 수 있습니다.

 

내부 클래스(inner class)

 

내부 클래스는 클래스 내에 선언된 클래스입니다. 클래스에 다른 클래스를 선언하는 이유는 두 클래스가 긴밀한 관계에 있기 때문입니다. 한 클래스를 다른 클래스의 내부 클래스로 선언하면 두 클래스의 멤버들 간에 서로 쉽게 접근할 수 있다는 장점과 외부에는 불필요한 클래스를 감춤으로써 코드의 복잡성을 줄일 수 있다는 장점이 있습니다. 

class A {		// 외부 클래스
    ...
    class B {	// 내부 클래스
        ...
    }
}

이 때 내부 클래스인 B는 외부 클래스인 A를 제외하고는 다른 클래스에서 잘 사용되지 않는 것이어야 합니다.

 

내부 클래스의 종류와 특징

 

내부 클래스의 종류는 변수의 선언 위치에 따른 종류와 같습니다. 내부 클래스는 마치 변수를 선언하는 것과 같은 위치에 선언할 수 있으며, 변수의 선언 위치에 따라 인스턴스 변수, 클래스 변수(static변수), 지역 변수로 구분되는 것과 같이 내부 클래스도 선언위치에 따라 다음과 같이 구분됩니다.

  • 인스턴스 클래스(instance class): 외부 클래스의 멤버 변수 선언 위치에 선언하며, 외부 클래스의 인스턴스 멤버처럼 다뤄집니다.
  • 스태틱 클래스(static class): 외부 클래스의 멤버 변수 선언 위치에 선언하며, 외부 클래스의 static멤버처럼 다뤄집니다.
  • 지역 클래스(local class): 외부 클래스의 메서드나 초기화 블럭 안에 선언하며, 선언된 영역 내부에서만 사용될 수 있습니다.
  • 익명 클래스(anonymous class): 클래스의 선언과 객체의 생성을 동시에 하는 이름없는 클래스(일회용)

 

 

내부 클래스의 제어자와 접근성

 

인스턴스 클래스와 스태틱 클래스는 외부 클래스의 멤버 변수와 같은 위치에 선언되며, 또한 멤버 변수와 같은 성질을 갖습니다. 따라서 내부 클래스가 외부 클래스의 멤버와 같이 간주되고, 인스턴스 멤버와 static 멤버 간의 규칙이 내부 클래스에도 똑같이 적용됩니다.

 

그리고 내부 클래스도 클래스이기 때문에 abstract나 final과 같은 제어자를 사용할 수 있을 뿐만 아니라, 멤버 변수들처럼 private, protected 접근 제어자도 사용이 가능합니다.

내부 클래스 중에서 스태틱 클래스만 static멤버를 가질 수 있었는데, JDK 16부터 인스턴스 클래스도 static멤버를 가질 수 있게 변경되었습니다. 

 

인스턴스 멤버는 같은 클래스에 있는 인스턴스멤버와 static멤버 모두 직접 호출이 가능하지만, static멤버는 인스턴스 멤버를 직접 호출할 수 없는 것처럼, 인스턴스 클래스는 외부 클래스의 인스턴스 멤버를 객체생성 없이 바로 사용할 수 있지만, 스태틱 클래스는 외부 클래스의 인스턴스 멤버를 객체생성 없이 바로 사용할 수 없습니다. 

 

마찬가지로 인스턴스 클래스는 스태틱 클래스의 멤버들을 객체생성 없이 사용할 수 있지만, 스태틱 클래스에서는 인스턴스 클래스의 멤버들을 객체생성 없이 사용할 수 없습니다.

 

class Outer {
    private int outerIv = 0;
    static int outerCv = 0;

    class InstanceInner {
        int iiv = outerIv;
        int iiv2 = outerCv;
    }

    static class StaticInner {
        int siv = new Outer().outerIv;
        static int scv = outerCv;
    }

    void myMethod() {
        int lv = 0;
        final int LV = 0;		// JDK 8부터 final 생략 가능

        class LocalInner {
            int liv = outerIv;
            int liv2 = outerCv;
            int liv3 = lv;		// JDK 8부터 가능
            int liv4 = LV;
        }
    }
}

지역 클래스는 외부 클래스의 인스턴스멤버와 static멤버를 모두 사용할 수 있으며, 지역 클래스가 포함된 메서드에 정의된 지역변수도 사용할 수 있습니다. 그러나 지역 클래스가 메서드 안의 지역 변수에 접근하려면, 그 지역 변수는 final 이어야 합니다.

 

그 이유는 객체와 지역 변수의 생명주기 차이 때문입니다. 메서드가 끝나면 지역 변수는 스택에서 사라지지만, 지역 클래스의 인스턴스는 힙에 남아있을 수 있습니다. 그러면 클래스 안에서 참조한 지역 변수가 사라져버리므로, 컴파일러가 지역 변수의 복사본을 지역 클래스 안에 저장하는데, 이때 이 복사본이 바뀌지 않아야 일관성이 유지되므로 final 제약이 생긴 것입니다.

JDK 8부터 지역 클래스에서 접근하는 지역 변수 앞에 final을 생략할 수 있게 바뀌었습니다. 생략하면 컴파일러가 대신 자동으로 붙여줍니다. 즉, 편의상 final을 생략할 수 있게 한 것일 뿐 해당 변수의 값이 바뀌는 문장이 있으면 컴파일 에러가 발생합니다.

 

 

class Outer {
    int value = 10;

    class Inner {
    	int value = 20;
    }

    void method() {
        int value = 30;
        System.out.println("value :" + value);			// 30
        System.out.println("this.value :" + value);		// 20
        System.out.println("Outer.this.value :" + value);	// 10
    }
}

내부 클래스와 외부 클래스에 선언된 변수의 이름이 같을 때 변수 앞에 'this' 또는 '외부 클래스명.this'를 붙여서 서로 구별할 수 있습니다.

 

Outer oc = new Outer();
Outer.InstanceInner ii = oc.new InstanceInner();

System.out.println("ii.iv : " + ii.iv);

만약 위와 같이 외부 클래스가 아닌 다른 클래스에서 내부 클래스를 생성하고 내부 클래스의 멤버에 접근한다면, 내부 클래스로 선언해서는 안되는 클래스를 내부 클래스로 선언했다는 의미입니다.

 

익명 클래스

 

익명 클래스는 특이하게도 다른 내부 클래스들과 달리 이름이 없습니다. 클래스의 선언과 객체의 생성을 동시에 하기 때문에 단 한 번만 사용될 수 있고 오직 하나의 객체만을 생성할 수 있는 일회용 클래스입니다.

 

new 조상 클래스명() {
    ...
}

new 인터페이스명() {
    ...
}

이름이 없기 때문에 생성자도 가질 수 없으며, 조상 클래스의 이름이나 구현하고자 하는 인터페이스의 이름을 사용해서 정의하기 때문에 하나의 클래스로 상속받는 동시에 인터페이스를 구현하거나 둘 이상의 인터페이스를 구현할 수 없습니다. 오로지 단 하나의 클래스를 상속받거나 단 하나의 인터페이스만을 구현할 수 있습니다.

 

위 예제의 왼쪽 코드는 익명 클래스의 간단한 예제입니다. 이 코드를 컴파일러가 내부적으로 오른쪽과 같은 클래스를 자동 생성합니다.

익명 클래스는 이름이 없기 때문에 '외부 클래스명$숫자.class'의 형식으로 클래스 파일의 이름이 결정됩니다.

 

익명 클래스의 접근성

익명 클래스는 지역 클래스의 특별한 형태로 간주되기 때문에, 지역 클래스와 동일한 접근성을 갖습니다. 익명 클래스에서 접근한 변수는 final이거나 effectively final로 취급합니다.

effectively final은 final 키워드를 명시하지 않았지만, 실제로 값이 한 번만 할당되고 이후 변경되지 않는 변수를 말합니다

'Lang > Java' 카테고리의 다른 글

[Java 21] (9) - java.lang package & util classes  (0) 2025.10.24
[Java 21] (8) - exception handling  (0) 2025.10.20
[Java 21] (6) - Object-oriented Programming 1  (0) 2025.09.29
[Java 21] (5) - array  (1) 2025.09.25
[Java 21] (4) - statement  (0) 2025.09.22