추상 클래스 와 추상화
지금까지 사용해왔던 일반적인 클래스는 구체적(concrete)으로 데이터를 담아 인스턴스화 하여 직접 다루는 클래스이다. 이와는 반대로 추상 클래스는 구체적이지 않은 추상적인(abstract) 데이터를 담고 있는 클래스이다. 그래서 추상 클래스는 일반 클래스와 달리 인스턴스 화가 불가능한 클래스이며, 추상 클래스를 선언 할때는 abstract 키워드를 사용한다는 차이점이 있다고 말하곤 한다. 하지만 착각하지 말아야 할 것이, 추상클래스의 문법적인 특징이나 객체 생성이 되고 안되고 이런 특징들은 중요한게 아니다. 추상 클래스가 무엇이고 왜 사용하는지 본질적인 개념부터 알아야 자연스럽게 객체 지향 프로그래밍의 추상 클래스 용도를 이해할 수 있다.
객체 지향 프로그래밍의 특징에 대해서 알아보면 '추상화' 라는 것에 대해 학습하게 된다. 이 추상화는 객체 지향(oop)의 핵심이며 객체 프로그래밍의 시발점이 되기도 한다. 추상화는 어떠한 예술가들이나 전문가들의 영역이 아니다. 사실 우리는 일상에서 코딩을 할때 알게 모르게 이 '추상화' 라는 설계 개념의 효과를 톡톡히 보고 있다.
예를 들어 문자를 대문자로 변환해주는 String.toUpperCase() 라는 메서드일 경우, 우리는 이 메서드가 내부에서 어떤식으로 동작하여 소문자를 대문자로 변환 해주는 로직에 대해 자세히 알지도 못하며 별로 관심도 없다. 그저 대문자로 모두 변환된 문자열을 결과값으로 받아서 사용할 뿐이다. 즉, 우리는 구체적으로 알지도 못하는 String.toUpperCase() 라는 클래스와 그 안에 있는 메서드를 그냥 생각없이 사용해왔던 것이고, 이는 곧 추상적이라고 말할 수 있는 것이다.
String text = "Hello world";
String textUpper = text.toUpperCase();
// 우리는 메서드 내부가 어떤식으로 돌아가는지 생각 없이 결과값만 받을 뿐이다.
// 그냥 메서드 내부에서 대충 알아서 잘 대문자로 마술같이 변환해주겠지 하고 막연하게 추상적으로 생각할 뿐이다.
System.out.println(textUpper); // "HELLO WORLD";
좀더 단적으로 비유를 들자면 우리가 정말 자주 사용하는 for, while 문도 사실 반복하는 개념을 추상화 한 것이라고 볼 수 있다. 코드를 반복하는데 있어 내부 CPU 와 메모리 동작이 어떻게 이루어지는가를 알 필요없이, 반복 기능을 해주는 for, while 코드를 문법에 맞게 사용함으로써, 우리는 복잡한 컴퓨터 지식없이 간단하게 루프 기능을 이용할 수 있다.
추상화 라는 개념이 바로 머릿속에 들어오지 않았던 이유는 위처럼 우리는 프로그래밍을 처음 배울때 당연한걸 당연하게 사용해 왔기 때문인 것이다. 추가로 객체 지향 프로그래밍의 추상화 특징에 대해 구체적으로 알고 싶다면 다음 포스팅을 참고하길 바란다.
정리하자면 추상 클래스는 '추상화'를 클래스에 접목 시킨 것이라 말 할 수 있다. 그럼 추상 클래스의 용도는 무엇일까?
클래스라 함은 인스턴스를 생성해주는 템플릿 같은 개념이다. 그리고 이러한 인스턴스 객체 자료형을 사용하여 우리는 보다 구조적으로 프로그램을 설계 할 수 있다. 이것이 객체 지향적 프로그래밍이라고 불리우는 이유이다.
즉, 추상 클래스는 클래스에 추상화를 접목 시켜 보다 구조적이게 객체를 설계하고, 프로그램의 유지보수성을 올려주며, 만일 프로그램에 어떠한 기능을 업그레이드한다고 하면 수정 / 추가에 대해 유연적이게 해주어, 퀄리티 높은 프로그램과 솔루션을 개발할 수 있게 해준다. 실제로 추상 클래스는 개별 프로젝트 보다는 범용 라이브러리나 프레임워크 시스템을 설계하는데 유용하게 사용된다.
※ 참고
추상 클래스는 많은 프레임워크에서 지금도 사용되고 있는 구현방식이다.
예를 들어 안드로이드 스튜디오 SDK에서 앱을 만들 때 안드로이드 라이브러리에서 제공하는 많은 클래스를 사용하는데, 이들 클래스 중에는 모두 구현된 클래스도 있지만, 일부만 구현되어 있어서 상속을 받아 구현하는 경우가 많이 있다.
실제로 어떤 앱을 만드냐에 따라 다르게 구현해야할 내용이 달라지므로, 따로 코드에서 구현하도록 하기 위해 선언만 해놓은 것이 추상 클래스의 추상 메서드이다.
추상 클래스 기본 문법
추상 클래스 & 추상 메서드
자바에서는 abstract 키워드를 클래스명과 메서드명 옆에 붙임으로서 컴파일러에게 추상 클래스와 추상 메서드임을 알려주게 된다.
추상 메서드는 작동 로직은 없고 이름만 있는 메서드라고 보면 된다. 즉, 메서드의 선언부만 작성하고 구현부는 미완성인 채로 남겨둔 메소드인 것이다.
보통 문법적인 측면으로 하나 이상의 추상 메소드를 포함하는 클래스를 가리켜 추상 클래스라고 정의 하기도 한다.
추상 클래스 안의 메서드를 미완성으로 남겨놓는 이유는 추상 클래스를 상속받는 자식 클래스의 주제에 따라서 상속 받는 메서드의 내용이 달라질 수 있기 때문이다. 부모(추상) 클래스에서 메서드를 선언부만을 작성하고, 실제 내용은 상속받는 클래스에서 구현하도록 하기 위해 일부러 비워두는 개념이라고 보면 된다.
따라서 추상 클래스를 상속받는 자식 클래스는 부모의 추상 메서드를 상황에 맞게 적절히 재정의 하여 구현해 주어야 비로소 사용이 가능해 진다. 즉, 클래스의 선언부에 abstract 키워드가 있다는 말은 안에 추상 메서드(abstract method)가 있으니 상속을 통해서 구현해주라는 지침 이기도 한다.
// 추상 클래스
abstract class Pet {
abstract public void walk(); // 추상 메소드
abstract public void eat(); // 추상 메소드
public int health; // 인스턴스 필드
public void run() { // 인스턴스 메소드
System.out.println("run run");
}
}
class Dog extends Pet {
// 상속 받은 부모(추상) 메소드를 직접 구현
public void walk() {
System.out.println("Dog walk");
}
public void eat() {
System.out.println("Dog eat");
}
}
public class main {
public static void main(String[] args) {
Dog d = new Dog();
d.eat(); // 부모(추상) 클래스로 부터 상속받은 추상 메소드를 직접 구현한 메소드를 실행
d.walk();
d.run(); // 부모(추상) 클래스의 인스턴스 메소드 실행
}
}
사실 추상 클래스는 추상 메서드를 포함하고 있다는 것을 제외하고는 일반 클래스와 전혀 다르지 않다. 추상 클래스에도 생성자가 있으며 위의 코드 처럼 독립적인 인스턴스 멤버 변수와 메서드도 가질 수 있기 때문이다.
추상 클래스 생성자
추상 클래스는 클래스의 일종이라고 하지만 new 생성자를 통해 인스턴스 객체로 직접 만들 수 없다. 왜냐하면 추상클래스는 상속 구조에서 부모 클래스를 나타내는 역할로만 이용 되기 때문이다.
abstract class Animal {
}
Animal a = new Animal(); // ERROR !! - 추상 클래스는 인스턴스를 직접 바로 생성할 수 없음.
따라서 반드시 추상 클래스를 어느 자식의 클래스에 상속시키고, 자식 클래스를 인스턴스화 하여 사용해야 한다.
abstract class Animal {
}
class Cat extends Animal { // 추상 클래스 상속
}
class Dog extends Animal {
}
public class Main {
public static void main(String[] args) {
// 추상 클래스를 상속한 자식 클래스를 객체로 초기화
Cat c = new Cat();
Dog d = new Dog();
}
}
그렇다고 추상 클래스의 생성자를 전혀 이용 못하는 것은 아니다. 직접적인 인스턴스화가 불가능 하다 뿐이지, super() 메소드를 이용해 추상 클래스 생성자 호출이 가능하다. 이는 객체의 기본 생성자 메서드 실행 순서 원리를 그대로 따른 것이다.
// 추상 클래스
abstract class Shape {
public String type;
// 추상 클래스 생성자
public Shape(String type) {
this.type = type;
}
// 추상 메서드
public abstract void draw();
}
class Figure extends Shape {
public String name;
public Figure(String type1, String type2) {
super(type1); // 부모 추상 클래스 생성자 호출
name = type2;
}
@Override
public void draw() { ... } // 추상 메서드 구현
}
public class main {
public static void main(String[] args) {
Figure f = new Figure("polygon", "square");
f.name; // "square"
f.type; // "polygon" - 부모(추상) 클래스의 멤버를 추상 클래스 생성자를 호출하는 super()을 통해 초기화
}
}
추상 클래스를 상속한 자식 클래스를 new 생성자로 객체를 초기화할때, 자식 클래스 생성자 메소드 내에서 가장 먼저 부모 클래스인 추상 클래스의 생성자가 실행되게 된다. 그래서 만일 위와 같이 부모 추상 클래스 생성자 실행에 있어 인자를 주어 제어를 하고 싶다면, 자식 클래스 생성자 메서드 내에서 super() 부모 생성자 호출 메서드를 통해 가능하다.
추상 클래스의 활용
추상 클래스는 미완성 설계도와 비슷하다. 추상 클래스만으로는 인스턴스를 생성할 수 없고 자식 클래스에서 상속받아야만 완성시킬 수 있기 때문이다. 이처럼 추상 클래스는 클래스로서의 역할을 다 못하지만, 새로운 클래스를 작성하는 데 있어서 바탕이 되는 부모 클래스로서 중요한 의미를 갖는다. 추상 클래스를 이용하면 객체 지향 프로그래밍에서 중요한 특징인 다형성을 가지는 메소드의 집합을 정의할 수 있도록 해주기 때문이다.
이제 자바에서 추상 클래스가 어떤식으로 활용이 되는지 알아보자
공통 멤버의 통합으로 중복 제거
다음과 같이 Marine, Tank, Dropship 클래스가 정의되어 있다고 하자. 클래스의 멤버들을 자세히 살펴보니 사용처와 이름이 겹치는 필드와 메서드들이 몇몇 보인다.
class Marine {
int x, y;
void move(int x, int y) {} // 지정된 위치로 이동
void stop() {} // 현재 위치에 정지
void stimPack() {} // 고유 능력 스팀팩 사용
}
class Tank {
int x, y;
void move(int x, int y) {} // 지정된 위치로 이동
void stop() {} // 현재 위치에 정지
void siegeMode() {} // 고유 능력 시즈 모드 사용
}
class DropShip {
int x, y;
void move(int x, int y) {} // 지정된 위치로 이동
void stop() {} // 현재 위치에 정지
void loadUnload() {} // 고유 능력 탑승 사용
}
따라서 상속(extends) 기능을 이용해 3개의 클래스를 대표할 수 있는 부모 추상 클래스로 묶으면, 상위 클래스의 특징을 하위클래스에서 그대로 물려 받아 사용할 수 있는 상속 특징을 이용하여 코드의 중복 제거, 코드 재사용성 증대 효과를 누릴 수 있게 된다.
즉, 자주 사용될 것이 예상되는 기능을 모아놓은 추상 클래스를 한번 만들어 놓으면 편하게 재사용을 함으로써 유지보수 효율화를 추구할 수 있는 것이다.
abstract class Unit {
int x, y;
abstract void move(int x, int y); // 지정된 위치로 이동
void stop() {} // 현재 위치에 정지
}
class Marine extends Unit{
void move(int x, int y) {
System.out.println("걸어서 이동");
}
void stimPack() {} // 고유 능력 스팀팩 사용
}
class Tank extends Unit{
void move(int x, int y) {
System.out.println("굴러서 이동");
}
void siegeMode() {} // 고유 능력 시즈 모드 사용
}
class DropShip extends Unit{
void move(int x, int y) {
System.out.println("날아서 이동");
}
void loadUnload() {} // 고유 능력 탑승 사용
}
대신에 현재 위치에 정지하는 메소드인 stop() 은 어떤 유닛이건 간에 명령이 동일하지만, 유닛이 이동하는 메소드인 move() 는 각 자식 클래스마다 이동하는 로직이 다르기 때문에 부모 클래스의 메소드를 오버라이딩 하여 재정의 해주었다.
하지만 가만 생각해보면 굳이 추상 클래스로 선언할 필요가 없다. abstract 키워드를 빼고 일반 부모 클래스로 선언해도 상속하고 중복 멤버를 제거하는데 전혀 문제가 없기 때문이다.
class Unit { // abstract 뺌
int x, y;
void move(int x, int y) {} // abstract 뺌
void stop() {}
}
class Marine extends Unit{
void move(int x, int y) {
System.out.println("걸어서 이동");
}
void stimPack() {}
}
class Tank extends Unit{
void move(int x, int y) {
System.out.println("굴러서 이동");
}
void siegeMode() {}
}
class DropShip extends Unit{
void move(int x, int y) {
System.out.println("날아서 이동");
}
void loadUnload() {}
}
그럼에도 부모 클래스를 추상 클래스로서 이용해야 하는 이유는 자식 클래스를 업캐스팅해서 다형성을 이용하여 프로그래밍할때 필요성이 나타나기 때문이다.
※ 참고
사실 공통된 필드와 메서드를 통일하는 목적으로는 일반 클래스로도 가능하여 꼭 추상 클래스만의 고유 용도라고는 보기에는 힘들다. 하지만 이 부분을 추상 클래스 활용 예제로 넣은 이유는 인터페이스(Interface)와의 차이점을 위해서 이다.
Java8의 인터페이스도 똑같이 안에 필드를 선언해 줄 수 있지만, 자동으로 public static final 처리가 되기 때문에 이른바 공통 상수가 되어 버린다. 따라서 자식 클래스에서 중복되는 변수들을 상속으로 묶어 통합 시켜주는 기능 자체는 인터페이스로 구현할 수 없고 오로지 추상 클래스 로만 가능하다는 소리이다.
구현의 강제성을 통한 기능 보장
위의 자식 클래스들을 다음 메인 메소드에서 인스턴스화 하여 사용한다고 하자.
각 자식들을 Unit 이라는 부모 클래스 타입으로 묶었으니, 업캐스팅을 통해 Unit 배열에 자식 객체들을 할당 할 수 있게 된다. 그리고 for문을 통해 배열 요소들을 순회하여 move() 메소드를 실행하도록 다형성을 이용한 효율적인 코드를 구성했다.
public class Test1 {
public static void main(String[] args) {
Unit[] group = new Unit[3];
group[0] = new Marine();
group[1] = new Tank();
group[2] = new DropShip();
for(Unit u : group) {
u.move(100, 200);
}
}
}
이때 만일 스펙이 추가되어, Battlecruiser 클래스를 추가하게 되었고 여타 자식 클래스와 똑같이 Unit 부모 클래스에 상속 시킨다고 가정하자.
abstract class Unit {
int x, y;
abstract void move(int x, int y); // 지정된 위치로 이동
void stop() {} // 현재 위치에 정지
}
class Battlecruiser extends Unit {
void yamato() {} // 고유 능력 야마토 사용
}
class Marine extends Unit { ... }
class Tank extends Unit { ... }
class DropShip extends Unit { ... }
그런데 개발자가 깜빡하고 배틀크루저의 이동에 관한 메소드를 정의를 안해버렸다고 해보자. 유닛의 치명적인 버그가 일어 날 수 있는 상황임에도 불구하고 만일 추상 클래스의 추상 메소드로 상속 관계를 맺었다면 이는 큰 문제가 되지 않는다.
에디터에서 미리 다음과 같이 빨간줄을 통해 추상 메서드 move() 를 재정의 하지 않았다면서 오류를 보여주어 바로 수정이 가능하기 때문이다.
위에서 언급했듯이 자바에서 추상 클래스와 추상 메소드를 선언하여 사용하는 목적은 추상 클래스를 상속받는 자식 클래스가 반드시 추상 메소드를 구현하도록 하기 위함이다.
이렇게 강제적으로 구현하도록 하기 때문에 개발자가 실수해도 바로 고칠수 있게 된다.
그럼 추상 클래스, 추상 메소드가 아닌 일반 클래스로 상속 관계를 맺는 상황에서 위와 같은 프로그래밍 실수가 일어나면 어떻게 될까?
우선 에디터에서는 어떤 에러도 발생하지 않는다. 왜냐하면 일반 클래스의 메서드를 오버라이딩 하든 안하든 그건 자유이기 때문이다.
하지만 문제는 메인부의 move() 메소드 호출 부분이다.
배틀크루저 클래스내에 직접 move() 메소드를 정의해 놓지 않으면, 호출 됐을때 결국 부모 클래스의 move() 메소드가 실행 될 것이고 이는 잘못된 동작이 나타나게 하거나 아예 동작 자체가 안되는 심각한 게임 버그 현상이 일어나게 된다. 미리 에디터에서 알려주면 좋겠지만 실제 프로그램을 구동하고 유닛을 조종해 봐야 일어날 수 있는 버그 현상이라 디버깅으로도 찾기 매우 힘든 부분이다.
이처럼 추상 메소드를 통한 강제 구현의 멘토링은 기존 프로그램 스펙에서 수정하거나 기능을 추가할때 일어날 수 있는 문제 되는 점을 미리 방지함으로써 보다 안정적이고 구조적으로 프로그래밍 할 수 있게 도와 준다.
※ 중요
이러한 특성은 인터페이스(interface) 에도 똑같이 적용되기 때문에 매우 중요하다.
규격에 맞는 설계 구현
어떤 제품이든 그 제품이 추구하는의 설계서 즉, 규격이 정해져 있을 것이다. 실생활의 예로 USB의 단자에는 규격이 존재한다. 이 규격이 존재한 덕분에 우리는 어떤 제조사의 USB를 사도 모든 컴퓨터에 연결해서 저장 기능을 이용할 수 있는 것이다. 만일 규격이 정해져 있지 않는다면 호환성에 매우 애를 먹을 것이다. 당장 마이크로소프트 USB와 애플 USB 단자 모양 규격이 달라서 불편함을 겪는 사람도 있다.
이처럼 '규격'은 소비자가 제품을 사용함에 있어서 큰 장점을 발휘하지만, 개발자가 제품을 구현하는데 있어서도 도움을 준다.
예를 들어 갑자기 안드로이드 어플을 구성하는 클래스를 만들라고하면, 어플과 휴대폰의 연동에 대한 규격이 없다면, 어디서부터 설게해야 될지도 몰라 시간을 잡아 먹게 될 것이다. 하지만 구글이 미리 만들어둔 안드로이드 SDK에서 제공하는 추상 클래스를 상속 받아 필요한 추상 메소드를 구현함으로써 개발자는 내부 동작만 창의적인 스타일을 구현해 심장박동 어플을 내든, 배달 어플을 내든, 게임 어플을 내든 다양한 제품을 내보일 수 있는 것이다.
만일 안드로이드 규격에 따르지 않으면 위의 배틀크루저 클래스 예시와 같이 IDE에서 에러를 일으키기 때문에 오동작에 대한 걱정을 필터링 할 수도 있다.
이처럼 실제 프로젝트에서 어플리케이션 아키텍쳐(Application Architecture)가 설계해 놓은 추상 클래스를 상속받으면, 개발자는 프로젝트에서 필요하고 공통적으로 들어가야하는 필드와 메서드를 오버라이딩해서 큰 설계를 생각할 필요 없이 구현만 하면 된다. 이렇게 하면 초기 설계 시간이 절약되고, 구현에만 집중할 수 있게 된다는 장점이 있다.
정리하자면 추상클래스를 상속받아서 미리 정의된 공통 기능들을 구현하고, 실체클래스에서 필요한 기능들을 클래스별로 확장시킴으로써 소스 수정시 다른 소스의 영향도를 적게 가져가면서 변화에는 유연하게 만들 수 있다.
미리 규격에 맞게 소스가 구현되어 있기 때문에 해당 규격에 대한 구현부만 수정하면 손 쉽게 기능 수정이 가능하기 때문이다.
참고