자바 - 추상 클래스(abstract class)

2024. 3. 26. 20:26백엔드/자바

자바 - 추상 클래스(abstract class)

 

우리가 지금까지 사용해 왔던 일반적인 클래스는 구체적(concrete)으로 데이터를 담아 인스턴스화하여 직접 다루는 클래스이다. 반대로 추상 클래스는 구체적이지 않은 추상적인(abstract) 데이터를 담고 있는 클래스다. 그래서 추상 클래스는 일반 클래스와 달리 인스턴스화가 불가능한 클래스이며, 추상 클래스를 선언할 때는 `abstract` 키워드를 사용한다는 차이점이 있다. 하지만 정말로 중요한 것은 추상 클래스를 왜 사용하냐는 것이다.

 

객체 지향 프로그래밍의 특징에는 '추상화'라는 것이 있다. 이 추상화는 객체 지향의 핵심이며 객체 프로그래밍의 시발점이 되기도 한다. 추상화라는 말을 얼핏 들으면 반드시 철학적이고 뜻이 모호하지만, 사실 일상 속에도 '추상화' 설계 개념은 자연스럽게 녹아 있다.

 

예를 들어 문자를 대문자로 변환해주는 `String.toUpperCase()`라는 메서드일 경우, 우리는 이 메서드가 내부에서 어떤 식으로 동작하여 소문자를 대문자로 변환해 주는지 자세히 알지 못하며 또 굳이 신경 쓸 필요도 없다. 그저 대문자로 모두 변환된 문자열을 결과값으로 받아서 사용하면 된다. 결국 우리는 잘 알지도 못하는 `String.toUpperCase()`라는 클래스와 그 그 안의 메서드를 받아서 쓰기만 하면 되는 것이다. 아주 복잡한 로직은 아주 단순하게 표현하는 것이 '추상화'이다. 

 

이런 추상화 기법을 사용함으로서 우리는 더욱 풍부한 세계를 만들어낼 수 있다. 예를 들어 음료수 자판기가 있다. 자판기에서 음료수를 뽑아 먹기 위해선 그저 돈을 넣고 원하는 음료수를 뽑으면 된다. 우리가 음료수를 뽑아 먹자고 자판기의 알고리즘을 알아야 한다면 음료수를 뽑아 먹을 때마다 너무 번거로울 것이다. 하지만 자판기는 추상적이기 때문에 우리가 해야 하고 알아야 할 일은 딱 두 가지다. 돈을 넣고 음료수 버튼을 누른다. 

 

코딩할 때도 마찬가지다. 우리가 모든 클래스, 메서드의 로직을 알고 쓴다면 개발을 하기도 전에 메서드의 원리를 학습하느라 지쳐버리고 말 것이다. 하지만 다행히 메서드는 추상적이기 때문에 들어가는 매개 변수가 무엇인지, 리턴 값은 무엇인지만 알면 메서드를 쓰는 데 큰 문제가 없다.

 

다시 돌아와 추상 클래스는 클래스에 추상화를 접목시켜 보다 구조적이게 객체를 설계하고, 프로그램의 유지보수성을 올려주며, 만일 프로그램에 어떠한 기능을 업그레이드한다고 하면 수정/ 추가에 대해 유연적이게 해 주어 개발자의 스트레스를 줄여준다. 실제로 추상 클래스는 개별 프로젝트보다는 범용 라이브러리나 프레임워크 시스템을 설계하는데 유용하게 사용된다.

 


추상 클래스 기본 문법

자바에서는 `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(); // 확 그냥! - 추상 클래스는 인스턴스를 직접 생성할 수 없당게

 

따라서 반드시 추상 클래스를 어떤 자식 클래스에 상속시키고, 자식 클래스를 인스턴스화하여 사용해야 한다.

 

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()` 를 재정의하지 않았다며 오류를 내기 때문이다.

 

추상 클래스가 아닌 경우
추상 클래스인 경우

 

위에서 언급했듯이 자바에서 추상 클래스와 추상 메서드를 선언하여 사용하는 목적은 추상 클래스를 상속받는 자식 클래스가 반드시 추상 메서드를 구현하도록 하기 위함이다.

 

이렇게 강제적으로 구현하도록 귀띔을 해주기 때문에 개발자가 실수해도 바로 고칠 수 있다.

 

이처럼 추상 메서드를 통한 강제 구현의 멘토링은 기존 프로그램 스펙에서 수정하거나 기능을 추가할 때 일어날 수 있는 문제점을 사전에 예방함으로써 안정적이고 구조적으로 프로그래밍할 수 있게 도와준다.

 

이러한 특성은 인터페이스(interface) 에도 똑같이 적용된다.

 

 

규격에 맞는 설계 구현

어떤 제품이든 그 제품이 추구하는 설계서 즉, 규격이 정해져 있을 것이다. 실생활의 예로 각 나라에는 각각의 콘센트 규격이 정해져 있다. 이 규격 덕분에 우리는 어떤 제조사의 제품이든 단자를 콘센트에 꼽아 사용할 수 있다. 하지만 이 규격이 맞지 않는다면 상당히 난감할 것이다. 일례로 일본 여행에 아무 생각 없이 우리나라의 제품을 들고 갔다. 콘센트 규격이 맞지 않아 제품을 사용하지 못하는 경우가 그럴 것이다.

 

이처럼 '규격'은 소비자가 제품을 사용함에 있어서 큰 장점을 발휘하지만, 개발자가 제품을 구현하는데 있어서도 도움을 준다. 

 

예를 들어 갑자기 안드로이드 어플을 구성하는 클래스를 만들라고 하는 마당에 어플과 휴대폰의 연동에 대한 규격이 없다면, 어디서부터 설계해야 할지 몰라 난감할 것이다. 하지만 구글이 미리 만들어 놓은 안드로이드 SDK에서 제공하는 추상 클래스를 상속받아 필요한 추상 메서드를 구현함으로써 개발자는 내부 동작만 개별적으로 구현해 배달 어플이든 게임 어플이든 다양한 제품을 내보일 수 있다.

 

만일 안드로이드 규격에 따르지 않으면 위의 배틀크루저 클래스 예시와 같이 IDE에서 에러를 일으키기 때문에 오동작에 대한 걱정을 필터링할 수도 있다.

 

이처럼 실제 프로젝트에서 어플리케이션 아키텍처(Application Architecture)가 설계해 놓은 추상 클래스를 상속받으면, 개발자는 프로젝트에서 필요하고 공통적으로 들어가야 하는 필드와 메서드를 오버라이딩해서 큰 설계를 생각할 필요 없이 구현만 하면 된다. 이렇게 하면 초기 설계 시간이 절약되고, 구현에만 집중할 수 있게 된다는 장점이 있다.

 

 

정리하자면 추상클래스를 상속받아서 미리 정의된 공통 기능들을 구현하고, 실체클래스에서 필요한 기능들을 클래스별로 확장시킴으로써 소스 수정시 다른 소스의 영향도를 적게 가져가면서 변화에는 유연하게 대처할 수 있다.

 

미리 규격에 맞게 소스가 구현되어 있기 때문에 해당 규격에 대한 구현부만 수정하면 손 쉽게 기능 수정이 가능하기 때문이다.

 


원본 게시글

https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%B6%94%EC%83%81-%ED%81%B4%EB%9E%98%EC%8A%A4Abstract-%EC%9A%A9%EB%8F%84-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0

 

☕ 추상 클래스(Abstract) 용도 완벽 이해하기

추상 클래스 와 추상화 우리가 지금 까지 사용해왔던 일반적인 클래스는 구체적(concrete)으로 데이터를 담아 인스턴스화 하여 직접 다루는 클래스이다. 그 반대로 추상 클래스는 구체적이지 않은

inpa.tistory.com