본문 바로가기
Backend/Java & Kotlin

[Java] 객체 지향 4대 특징-3 (추상화)

by Hyeri.dev 2024. 1. 22.

추상화

좋은 코드는 새로운 기능이 추가되었을 때 변하는 부분을 최소화하는 것을 말한다. 이렇게 하기 위해서는 변하는 부분과 변하지 않는 부분을 명확하게 구분 지어 코드를 작성하는 것이 필요하다. 상속을 통해 다형성있는 코드를 구현하면서 부모와 자식 타입에 같은 이름을 가진 메서드를 작성한 경우 여러 가지 문제와 불편함이 있음을 알 수 있었다. 

단순 상속과 다형성의 한계

public class Music {  // 상속을 위한 부모 클래스 역할만 수행
	public void play() {
    	System.out.println("음악 재생)";
    }
}

public class Dash extends Music {
	@Override
    public void play() {
    	System.out.println("Dash를 재생합니다.");
    }
}

public class Drama extends Music {
	@Override
    public void play() {
    	System.out.println("Drama를 재생합니다.");
    }
}

public class Sonar extends Music {
	@Override
    public void play() {
    	System.out.println("Sonar를 재생합니다.");
    }
}

 

1. 부모 클래스의 인스턴스

부모 클래스가 여러 자식 클래스들의 공통된 부분들을 모아 중복을 없애고 상속을 위해 작성된 클래스라고 할 때, 부모 클래스의 역할은 상속 이외에는 존재하지 않는다.

Music music = new Music();  // 부모 클래스 인스턴스 생성

만약 그런 부모 클래스의 인스턴스를 생성하게 된다면 어떻게 될까? 인스턴스를 생성하는 것에는 전혀 문제가 없다. 다만해당 인스턴스는 작동은 하지만 제대로된 기능을 수행하지 못한다. 부모 클래스의 Music은 정확한 어떤 곡을 의미하지 않고 전체를 묶는 추상적인 개념이라는 것을 기억하자.

 

2. 메서드 오버라이딩 생성 X

위에서 정의한 코드에서 Music을 상속 받는 또 다른 클래스를 추가한다고 해보자.

public class Girls extends Music { // Music을 상속 받음
	public void play() {           // Music의 메서드를 오버라이딩 하지 않음
    	System.out.println("Girls를 재생합니다.");
    }
}

개발자의 실수로 Music의 메서드를 오버라이딩 하지 않고 play() 메서드를 정의했다. 이렇게 작성을 하더라도 문제가 발생하지 않는다. 하지만 아래와 같이 코드를 실행하게 되면 의도와는 다른 결과물을 가져올 수 있다.

public class Main {
	public static void maim(String[] args) {
    	Music[] musics = new Music[]{new Dash(), new Drama(), new Sonar(), new Girls());
        
        for(Music m : musics) {
        	playMusic(m);
        }
        
    }
    
    public static void playMusic(Music music) {
    	music.play();
    }
}

위 코드를 실행하면 개발자의 기대하는 결과값과 다른 결과값을 가지고 오는 것을 알 수 있다. 

// 기대하는 결과값.
Dash를 재생합니다.
Drama를 재생합니다.
Sonar를 재생합니다.
Girls를 재생합니다.

// 실제 결과값.
Dash를 재생합니다.
Drama를 재생합니다.
Sonar를 재생합니다.
음악 재생

이 이유는 다형성을 통해 부모 타입에 자식 인스턴스를 정의한 후, 메서드를 실행했기 때문인데 메서드를 오버라이딩 하지 않으면 정의한 타입에 우선 탐색 후 호출하기 때문에 Music의 메서드가 호출된 것이다. 

 

이러한 문제들은 추상화와 추상 클래스를 통해 해결할 수 있다.

추상 클래스

추상 클래스란 추상 메서드를 가진 클래스로 하나의 추상 메서드만 가져도 해당 클래스는 추상 클래스로 분류한다. 추상 클래스와 추상 메서드는 abstract 키워드를 사용한다.

public abstract class AbstractClass {           // 추상 클래스
    public abstract void abstractMethod();      // 추상 메서드
    
    public void method(){
    	System.out.println("일반 메서드 실행");  // 일반 메서드
    }
}

 

 

추상 메서드는 위 코드에서 알 수 있듯이 메서드 바디를 가지지 않는 특징을 가지고 있다. 이 때문에 자식 클래스에서 오버라이딩을 통해 재정의를 하지 않으면 해당 메서드를 사용할 수 없기 때문에 개발자가 실수로 오버라이딩을 하지 않는 문제를 방지할 수 있다. 

 

또한, 추상 클래스는 일반적인 실행 메서드를 가지고 있을 수 있으나 오직 추상 메서드만 존재하는 추상 클래스도 존재한다.

순수 추상 클래스

순수 추상 클래스는 어떠한 실행 메서드(로직)을 가지고 있지 않은 오직 추상 메서드만을 가지고 있는 추상 클래스를 말하며 다형성을 위한 부모 타입으로서의 껍데기 역할만 수행한다.

순수 추상 클래스의 특징

  • 인스턴스 생성이 불가능하다. -> 실행할 수 없는 로직이 포함되어 있기 때문에
  • 상속 시 자식은 모든 메서드를 오버라이딩 해야 한다.
  • 주로 다형성을 위해 사용한다. 

추상화된 클래스를 통해 상속을 구현할 경우 더 좋은 코드를 작성할 수는 있으나 이 또한 한계가 있다. 바로 모든 메서드를 오버라이딩 해야 한다는 규칙때문이다. 

인터페이스

인터페이스는 추상 클래스의 한계를 보완해줄 수 있으며 더욱 편리하게 추상화를 구현할 수 있도록 한다

// 추상 클래스
public abstract class AbstaractClass {
	public abstract void move();
	public abstract void play();
}

// 인터페이스
public interface InterfaceClass {
	public abstract void move();    // public abstract 생략 권고
	void play();                  
}

 인터페이스는 순수 추상 클래스와 비슷하게 작성하지만 추상 클래스는 abstract class 키워드를 사용하지만 인터페이스는 interface 키워드를 사용한다. 

인터페이스 특징

  • 인스턴스 생성 불가
  • 인터페이스의 모든 메서드는 public, abstract 키워드를 가지며 생략이 권장된다.
  • 인터페이스는 다중 구현(상속)을 지원한다.
  • implements 키워드를 통해 구현한다.
public interface InterfaceEx {
	void sound();
	void move();
}

public class Ex1 implements InterfaceEx {
	@Override
    public void sound() {
    	System.out.println("사운드체크");
    }
    
    @Override
    public void move() {
    	System.out.println("움직임 테스트");
    }
}

인터페이스는 상속이라는 개념보다는 구현이라는 개념에 더 부합한다. 따라서 순수 추상 클래스처럼 메서드 바디가 없는 추상 메서드만을 가지고 있으나 인터페이스는 멤버 변수를 선언할 수 있다.

 

인터페이스의 멤버 변수 조건

  • public 키워드를 가진다.
  • static 키워드를 가진다.
  • final 키워드를 가진다.

즉, 인터페이스는 상수와 추상 메서드만 가질 수 있다는 의미이다. 

public interface InterfaceEx {
	public static final int EX = 10;   // 상수 
	int EX2 = 20;                      // 키워드 생략 권고
}

 

인터페이스와 순수 추상 클래스는 비슷한 성격을 가지고 있다. 근데 왜 순수 추상 클래스의 한계점을 인터페이스가 보완한다고 하는 것일까?

인터페이스라는 개념 자체가 개발자에게 암시적으로 반드시 구현해야 한다는 것을 알려줄 수 있다. 또한, 순수 추상 클래스의 경우 추후에 다른 개발자에 의해 실행이 가능한 메서드가 추가될 위험이 존재하지만 인터페이스는 추상 메서드만을 가질 수 있기 때문에 이런 위험을 방지할 수 있다.

// 다중 상속이 가능할 경우
public class Parent1 {
	public void method() {
    	System.out.println(Parent1.method);
    }
}

public class Parent2 {
	public void method() {
    	System.out.println(Parent2.method);
    }
}

public class Child extends Parent1, Parent2 {
    
}

// Child가 Parent의 메서드를 호출한다면?
Child child = new Child();
child.method();

 

다중 상속이 가능할 경우

또한 인터페이스는 단일 상속만 가능한 일반적인 클래스와 달리 다중 구현이 가능하다. 자바가 다중 상속을 지원하지 않는 이유는 두 부모에 같은 이름을 가진 메서드가 있을 경우 어떤 부모의 메서드를 호출 해야 하는지 애매한 상황이 발생하기 때문에 사전에 금지 시켜놓은 것이다.

 

하지만 인터페이스의 경우에는 모든 메서드가 추상 메서드로 이루어져 있기 때문에 자식 클래스에서 해당 메서드를 반드시 오버라이딩으로 구현해야 한다. 상속의 특징 사 메서드는 오버라이딩 된 메서드를 우선 호출하기 때문에 해당 메서드가 어떤 부모에서 왔는지를 신경 쓰지 않고 호출할 수 있다. 이런 문제가 발생하지 않기 때문에 인터페이스의 경우에는 다중 구현을 가능하게 한 것이다.