다향성(polymorphism)
자바 프로그래밍에서 다형성은 한 타입의 참조변수를 통해 여러 타입의 객체를 참조할 수 있도록 만든 것을 의미한다.
좀 더 구체적으로 이야기하면, 상위 클래스 타입의 참조변수를 통해서 하위 클래스의 객체를 참조할 수 있도록 허용한 것이라 할 수 있다.
//참조변수의 다형성 예시
class Friend {
public void friendInfo() {
System.out.println("나는 당신의 친구입니다.");
}
}
class BoyFriend extends Friend {
public void friendInfo() {
System.out.println("나는 당신의 남자친구입니다.");
}
}
class GirlFriend extends Friend {
public void friendInfo() {
System.out.println("나는 당신의 여자친구입니다.");
}
}
public class FriendTest {
public static void main(String[] args) {
Friend friend = new Friend(); // 객체 타입과 참조변수 타입의 일치
BoyFriend boyfriend = new BoyFriend();
Friend girlfriend = new GirlFriend(); // 객체 타입과 참조변수 타입의 불일치
friend.friendInfo();
boyfriend.friendInfo();
girlfriend.friendInfo();
}
}
// 출력값
나는 당신의 친구입니다.
나는 당신의 남자친구입니다.
나는 당신의 여자친구입니다.
상위 클래스를 참조변수의 타입으로 지정했기 때문에 자연스럽게 참조변수가 사용할 수 있는 멤버의 개수는 상위 클래스의 멤버의 수가 됩니다.
상위 클래스 타입의 참조변수로 하위 클래스의 객체를 참조하는 것’이자 다형성의 핵심적인 부분이라 할 수 있다.
그 반대로 하위 클래스타입으로 상위 클래스 객체를 참조하는 것은 불가능하다.
참조변수의 타입 변환
참조 변수의 타입 변환은 사용할 수 있는 멤버의 개수를 조절하는 것을 의미한다.
타입 변환을 위해서는 다음의 세 가지 조건을 충족해야 한다.
- 서로 상속관계에 있는 상위 클래스 - 하위 클래스 사이에만 타입 변환이 가능합니다.
- 하위 클래스 타입에서 상위 클래스 타입으로의 타입 변환(업캐스팅)은 형변환 연산자(괄호)를 생략할 수 있습니다.
- 반대로 상위 클래스에서 하위 클래스 타입으로 변환(다운캐스팅)은 형변환 연산자(괄호)를 반드시 명시해야합니다.
public class VehicleTest {
public static void main(String[] args) {
Car car = new Car();
Vehicle vehicle = (Vehicle) car; // 상위 클래스 Vehicle 타입으로 변환(생략 가능)
Car car2 = (Car) vehicle; // 하위 클래스 Car타입으로 변환(생략 불가능)
MotorBike motorBike = (MotorBike) car; // 상속관계가 아니므로 타입 변환 불가 -> 에러발생
}
}
class Vehicle {
String model;
String color;
int wheels;
void startEngine() {
System.out.println("시동 걸기");
}
void accelerate() {
System.out.println("속도 올리기");
}
void brake() {
System.out.println("브레이크!");
}
}
class Car extends Vehicle {
void giveRide() {
System.out.println("다른 사람 태우기");
}
}
class MotorBike extends Vehicle {
void performance() {
System.out.println("묘기 부리기");
}
}
상속관계에 있는 클래스 간에는 상호 타입변환이 자유롭게 수행될 수 있습니다. 다만 하위 클래스를 상위 클래스 타입으로 변환하는 경우 타입 변환 연산자(괄호)를 생략할 수 있는 반면, 그 반대의 경우는 타입 변환 연산자를 생략할 수 없다는 점에서만 차이가 있다.
한편, 위 예제 에서 Car 클래스와 MotorBike 클래스는 상속관계가 아니므로 타입 변환이 불가하여 에러가 발생하는 것을 확인하실 수 있다.
instanceof 연산자
instanceof 연산자는 참조변수의 타입 변환, 즉 캐스팅이 가능한 지 여부를 boolean 타입으로 확인할 수 있는 자바의 문법요소이다.
캐스팅 가능 여부를 판단하기 위해서는 두 가지, 즉 ‘객체를 어떤 생성자로 만들었는가’와 ‘클래스 사이에 상속관계가 존재하는가’를 판단해야 한다. 프로젝트의 규모가 커지고, 클래스가 많아지면 매번 이러한 정보를 확인하는 것이 어려워진다.
이를 해결하기 위해 자바는 instanceof라는 연산자를 제공한다.
참조_변수 instanceof 타입
만약 참조_변수 instanceof 타입을 입력했을 때 리턴 값이 true가 나오면 참조 변수가 검사한 타입으로 타입 변환이 가능하며, 반대로 false가 나오는 경우에는 타입 변환이 불가능하다.
추상화
먼저 “추상”이라는 용어의 사전적 의미를 보면 “사물이나 표상을 어떤 성질, 공통성, 본질에 착안하여 그것을 추출하여 파악하는 것"이라고 정의한다.
여기서 핵심적인 개념은 공통성과 본질을 모아 추출하는 것이다. 자바에서의 추상화는 객체의 공통적인 속성과 기능을 추출하여 정의하는 것을 의미합니다.
상속이 하위 클래스를 정의하는데 상위 클래스를 사용하는 것이라고 한다면 추상화는 반대로 기존 클래스들의 공통적인 요소들을 뽑아서 상위 클래스를 만들어 내는 것이라고 할 수 있다. 공통적인 속성과 기능을 정의하고 하위 클래스들을 생성할 수도 있고, 반대로 하위 클래스들의 공통성을 모아 상위 클래스를 정의할 수 있다.

그림을 보면 자동차와 오토바이의 공통적인 분모들을 모아 이동수단이라는 클래스에 담았다.
반대로 이동수단이 가지는 공통적인 특징을 자동차와 오토바이에 내려줬다고 생각해도 공식은 유효하다.
이렇게 공통적인 속성과 기능을 모아서 정의해주면 코드의 중복을 줄일 수 있고, 보다 효과적으로 클래스 간의 관계를 설정할 수 있으며, 유지/보수가 용이해진다.
abstract 제어자
abstract는 주로 클래스와 메서드를 형용하는 키워드로 사용되는데, 메서드 앞에 붙은 경우를 ‘추상 메서드(abstract method)’, 클래스 앞에 붙은 경우를 ‘추상 클래스(abstract class)’라 각각 부른다.
어떤 클래스에 추상 메서드가 포함되어있는 경우 해당 클래스는 자동으로 추상 클래스가 됩니다.
abstract class AbstractExample { // 추상 메서드가 최소 하나 이상 포함돼있는 추상 클래스
abstract void start(); // 메서드 바디가 없는 추상메서드
}
추상 메서드는 메서드의 시그니처만 있고 바디가 없는 메서드를 의미하는데, abstract 키워드를 메서드 이름 앞에 붙여주어 해당 메서드가 추상 메서드임을 표시한다.
즉, 추상 메서드는 충분히 구체화되지 않은 ‘미완성 메서드’이며, 미완성 메서드를 포함하는 클래스는 ‘미완성 클래스'를 의미하는 추상 클래스이다.
추상 클래스는 앞서 설명한대로 미완성 설계도이기 때문에 메서드 바디가 완성이 되기 전까지 이를 기반으로 객체 생성이 불가하다.
AbstractExample abstractExample = new AbstractExample(); // 에러발생.
추상 클래스
추상 클래스란, 메서드 시그니처만 존재하고 바디가 선언되어있지 않은 추상 메서드를 포함하는 ‘미완성 설계도’ 이다.
또한 미완성된 구조를 가지고 있기에 이를 기반으로 객체를 생성하는 것이 불가능하다.
왜 객체도 생성하지 못하는 미완성 클래스를 만드는 걸까요?
1. 추상 클래스는 상속 관계에 있어 새로운 클래스를 작성하는데 매우 유용하다.
메서드의 내용이 상속을 받는 클래스에 따라서 종종 달라지기 때문에 상위 클래스에서는 선언부만을 작성하고, 실제 구체적인 내용은 상속을 받는 하위 클래스에서 구현하도록 비워둔다면 설계하는 상황이 변하더라도 보다 유연하게 대응할 수 있습니다.
abstract class Animal { // 추상클래스
public String kind;
public abstract void sound(); // 추상 메서드로 선언
}
class Dog extends Animal { // Animal 클래스로부터 상속
public Dog() {
this.kind = "포유류";
}
public void sound() { // 메서드 오버라이딩 -> 구현부 완성
System.out.println("멍멍");
}
}
class Cat extends Animal { // Animal 클래스로부터 상속
public Cat() {
this.kind = "포유류";
}
public void sound() { // 메서드 오버라이딩 -> 구현부 완성
System.out.println("야옹");
}
}
class DogExample {
public static void main(String[] args) throws Exception {
Animal dog = new Dog();
dog.sound();
Cat cat = new Cat();
cat.sound();
}
}
// 출력값
멍멍
야옹
추상 클래스를 사용하면 상속을 받는 하위 클래스에서 오버라이딩을 통해 각각 상황에 맞는 메서드 구현이 가능하다는 장점이 있다.
2. 추상 클래스는 자바 객체지향 프로그래밍의 추상화를 구현하는데 핵심적인 역할을 수행한다.
만약 여러 사람이 함께 개발하는 경우, 공통된 속성과 기능임에도 불구하고 각각 다른 변수와 메서드로 정의되는 경우 발생할 수 있는 오류를 미연에 방지할 수 있다.
결론적으로 상속계층도의 상층부에 위치할 수록 추상화의 정도가 높고 그 아래로 내려갈수록 구체화된다고 정리할 수 있다.
다른 말로, 상층부에 가까울수록 더 공통적인 속성과 기능들이 정의되어 있다고 생각할 수 있다.
final 키워드
final 키워드는 필드, 지역 변수, 클래스 앞에 위치할 수 있으며 그 위치에 따라 그 의미가 조금씩 달라지게 된다.
| 위치 | 의미 |
| 클래스 | 변경 또는 확장 불가능한 클래스, 상속 불가 |
| 메서드 | 오버라이딩 불가 |
| 변수 | 값 변경이 불가한 상수 |
각각 조금의 차이점이 있지만 결국 공통적으로 변경이 불가능하고 확장할 수 없다는 점에서 유사하다.
각각의 클래스, 메서드, 그리고 변수 앞에 final 제어자가 추가되면 이제 해당 대상은 더이상 변경이 불가하거나 확장되지 않는 성질을 지니게 된다.
인터페이스(interface)
기본적으로 인터페이스도 추상 클래스처럼 자바에서 추상화를 구현하는 데 활용된다는 점에서 동일하지만, 추상클래스에 비해 더 높은 추상성을 가진다는 점에서 큰 차이가 있습니다.
추상 클래스를 설계가 모두 끝나지 않은 “미완성 설계도"에 비유할 수 있다면, 인터페이스는 그보다 더 높은 추상성을 가지는 가장 기초적인 “밑그림"에 빗대어 표현할 수 있습니다.
추상 클래스는 메서드 바디가 없는 추상 메서드를 하나 이상 포함한다는 점 외에는 기본적으로 일반 클래스와 동일하다고 할 수 있습니다.
반면 인터페이스는 기본적으로 추상 메서드와 상수만을 멤버로 가질 수 있다는 점에서 추상 클래스에 비해 추상화 정도가 더 높다고 할 수 있습니다.
인터페이스는 기본적으로 “추상 메서드의 집합"으로 이뤄져있다. ( 참고로 새로운 메서드 추가의 어려움 등 기존 인터페이스가 가지는 한계를 보완하기 위해 자바 8 이후에 default/static 메서드를 인터페이스 포함시킬 수 있도록 업데이트가 되었다. 하지만 여기서는 “추상 메서드의 집합"으로 이뤄져있다는 사실만 기억하자 )
인터페이스를 작성하는 것은 기본적으로 클래스를 작성하는 것과 유사하지만, class 키워드 대신 interface 키워드를 사용한다.
또한 일반 클래스와 다르게, 내부의 모든 필드가 public static final로 정의되고, 앞서 간단하게 언급한 static과 default 메서드 이외의 모든 메서드가 public abstract로 정의된다.

인터페이스의 기본 구조
인터페이스를 작성하는 것은 기본적으로 클래스를 작성하는 것과 유사하지만, class 키워드 대신 interface 키워드를 사용한다는 점에서 차이가 있다.
또한 일반 클래스와 다르게, 내부의 모든 필드가 public static final로 정의되고, 앞서 간단하게 언급한 static과 default 메서드 이외의 모든 메서드가 public abstract로 정의된다.
다만 모든 인터페이스의 필드와 메서드에는 위의 요소가 내포되어있기 때문에 명시하지 않아도 생략이 가능함.
public interface InterfaceEx {
public static final int rock = 1; // 인터페이스 인스턴스 변수 정의
final int scissors = 2; // public static 생략
static int paper = 3; // public & final 생략
public abstract String getPlayingNum();
void call() //public abstract 생략
}
여기서 생략된 부분은 컴파일러가 자동으로 추가해준다.
인터페이스의 구현
추상클래스와 마찬가지로 인터페이스도 그 자체로 인스턴스를 생성할 수 없고, 메서드 바디를 정의하는 클래스를 따로 작성해야한다.
이 과정은 앞서 배운 extends 키워드를 사용하는 클래스의 상속과 기본적으로 동일하지만, “구현하다"라는 의미를 가진 implements 키워드를 사용한다는 점에서 차이가 있다.
class 클래스명 implements 인터페이스명 {
... // 인터페이스에 정의된 모든 추상메서드 구현
}
특정 인터페이스를 구현한 클래스는 해당 인터페이스에 정의된 모든 추상메서드를 구현해야한다.
즉, 어떤 클래스가 특정 인터페이스를 구현한다는 것은 그 클래스에게 인터페이스의 추상 메서드를 반드시 구현하도록 강제하는 것을 의미한다. 다른말로, 어떤 클래스가 어떤 인터페이스를 구현한다는 것은 그 인터페이스가 가진 모든 추상 메서드들을 해당 클래스 내에서 오버라이딩하여 바디를 완성한다라는 의미를 가진다.
인터페이스의 다중 구현
하위 클래스는 단 하나의 상위 클래스만 상속받을 수 있다. 클래스 간의 다중 상속은 허용되지 않았다.
하지만 인터페이스는 다중적 구현이 가능하다. 다시 말해, 하나의 클래스가 여러 개의 인터페이스를 구현할 수 있습니다.
다만 인터페이스는 인터페이스로부터만 상속이 가능하고, 클래스와 달리 Object 클래스와 같은 최고 조상이 존재하지 않는다
interface Animal { // 인터페이스 선언. public abstract 생략 가능.
public abstract void cry();
}
interface Pet {
void play();
}
class Dog implements Animal, Pet { // Animal과 Pet 인터페이스 다중 구현
public void cry(){ // 메서드 오버라이딩
System.out.println("멍멍!");
}
public void play(){ // 메서드 오버라이딩
System.out.println("원반 던지기");
}
}
class Cat implements Animal, Pet { // Animal과 Pet 인터페이스 다중 구현
public void cry(){
System.out.println("야옹~!");
}
public void play(){
System.out.println("쥐 잡기");
}
}
public class MultiInheritance {
public static void main(String[] args) {
Dog dog = new Dog();
Cat cat = new Cat();
dog.cry();
dog.play();
cat.cry();
cat.play();
}
}
// 출력값
멍멍!
원반 던지기
야옹~!
쥐 잡기
왜 인터페이스는 클래스와 달리 다중 구현이 가능할까?
클래스에서 다중 상속이 불가능했었던 핵심적인 이유는 만약 부모 클래스에 동일한 이름의 필드 또는 메서드가 존재하는 경우 충돌이 발생하기 때문이었다.
반면 인터페이스는 애초에 미완성된 멤버를 가지고 있기 때문에 충돌이 발생할 여지가 없고, 따라서 안전하게 다중 구현이 가능하다.
특정 클래스는 다른 클래스로부터의 상속을 받으면서 동시에 인터페이스를 구현할 수 있습니다.
abstract class Animal { // 추상 클래스
public abstract void cry();
}
interface Pet { // 인터페이스
public abstract void play();
}
class Dog extends Animal implements Pet { // Animal 클래스 상속 & Pet 인터페이스 구현
public void cry(){
System.out.println("멍멍!");
}
public void play(){
System.out.println("원반 던지기");
}
}
class Cat extends Animal implements Pet { // Animal 클래스 상속 & Pet 인터페이스 구현
public void cry(){
System.out.println("야옹~!");
}
public void play(){
System.out.println("쥐 잡기");
}
}
인터페이스의 장점
인터페이스는 가장 큰 장점 중에 하나는 앞서 봤었던 일반적인 인터페이스의 기능처럼 역할과 구현을 분리시켜 사용자 입장에서는 복잡한 구현의 내용 또는 변경과 상관없이 해당 기능을 사용할 수 있다는 것이다.

여기 Provider 클래스에 의존하고 있는 User 클래스가 있다. ( 여기서 “의존한다"라는 말의 의미가 조금 어렵게 느껴질 수 있는데, 쉽게 표현하면 User 클래스에서 Provider 에 정의된 특정 속성 또는 기능을 가져와 사용하고 있다는 의미이다. )
그런데 만약 코드에서 User클래스가 의존하고 있는 Provider클래스에 변경 사항이 발생해서 Provider 클래스가 아닌 Provider2 클래스로 교체해야하는 상황이 발생한다면, Provider 클래스에 의존했던 User 클래스의 의존관계를 Provider2 클래스로 변경하기 위해 Provider2 객체를 새롭게 생성해주고, User 클래스의 callProvider 메서드가 동일한 타입의 매개변수를 받을 수 있도록 매개변수의 타입을 Provider2 로 변경해주아야한다.
Provider 클래스에 인터페이스라를 적용하면. User 클래스는 더이상 Provider 의 교체 또는 내용의 변경에 상관없이 인터페이스와의 상호작용을 통해서 의도한 목적을 달성할 수 있습니다.
interface Cover { // 인터페이스 정의
public abstract void call();
}
public class Interface4 {
public static void main(String[] args) {
User user = new User();
// Provider provider = new Provider();
// user.callProvider(new Provider());
user.callProvider(new Provider2());
}
}
class User {
public void callProvider(Cover cover) { // 매개변수의 다형성 활용
cover.call();
}
}
class Provider implements Cover {
public void call() {
System.out.println("무야호~");
}
}
class Provider2 implements Cover {
public void call() {
System.out.println("야호~");
}
}
//출력값
야호~
예제를 보면 먼저 Cover 라는 인터페이스를 정의한 후에 각각의 구현체에 implements 키워드를 사용하여 각각 기능을 구현하고 있다.
그리고 User 클래스에서는 매개변수의 다형성을 활용하여 구체적인 구현체가 아닌 인터페이스를 매개변수로 받도록 정의한다.
이에따라 이제 Provider 클래스의 내용 변경 또는 교체가 발생하더라도 User 클래스는 더이상 코드를 변경해주지 않아도 같은 결과를 출력할 수 있다.
위의 코드 예제에서는 설명을 위한 편의상 코드를 단순화시켰기 때문에 아직 인터페이스의 장점이 크게 와닿지 않을 수 있다.
만약 User 클래스에서 변경되어야 하는 코드가 한 줄이 아니라, 수 백 수 천 줄이 되는 경우라면 일일이 그 많은 코드를 변경해야하는 번거로움은 분명 개발자에게 썩 달가운 일은 아닐 것이다.
결론적으로 정리하면, 인터페이스는 기능이 가지는 역할과 구현을 분리시켜 사용자로 복잡한 기능의 구현이나 교체/변경을 신경쓰지 않고도 코드 변경의 번거로움을 최소화하고 손쉽게 해당 기능을 사용할 수 있도록한다.
반대로 기능을 구현하는 개발자의 입장에서도 선언과 구현을 분리시켜 개발시간을 단축할 수 있고, 독립적인 프로그래밍을 통해 한 클래스의 변경이 다른 클래스에 미치는 영향을 최소화할 수 있다는 큰 장점이 있다.
'Java' 카테고리의 다른 글
| Java컬렉션 (0) | 2022.09.14 |
|---|---|
| Java기초 사칙연산 계산기 만들기 (0) | 2022.09.09 |
| Java 상속, 캡슐화 (0) | 2022.09.07 |
| Java 생성자, 내부 클래스 (1) | 2022.09.05 |
| Java 객체지향 프로그래밍 기초 (0) | 2022.09.03 |