본문 바로가기
코딩/Java

추상클래스, 왜 사용할까?

by jsjin 2026. 2. 16.
반응형

"추상클래스는 왜 사용하는 거지? 그냥 일반 클래스를 상속받아서 오버라이딩하면 되는 거 아닌가?"

자바를 공부하다 보면 한 번쯤 들게 되는 의문입니다. 이론적으로는 "구현을 강제한다"고 배우지만, 실제로 일반 클래스로도 똑같은 결과를 만들 수 있는데 왜 굳이 추상클래스를 사용해야 할까요?

이 글에서는 실무 관점에서 추상클래스가 필요한 진짜 이유를 알아보겠습니다.


추상클래스의 핵심 목적

1. 불완전한 객체 생성을 막는다

// 일반 클래스
class Animal {
    void sound() {
        // 구현 안 함 (또는 의미 없는 구현)
    }
}

Animal animal = new Animal(); // ✅ 가능하지만... 의미가 있나?
animal.sound(); // 아무 일도 안 일어남

위 코드는 문법적으로는 문제가 없지만, 개념적으로 이상합니다. "동물"이라는 추상적인 개념 자체를 객체로 만드는 게 말이 될까요?

// 추상 클래스
abstract class Animal {
    abstract void sound();
}

Animal animal = new Animal(); // ❌ 컴파일 에러! 강제로 막음

추상클래스를 사용하면 이런 개념적으로 불완전한 객체 생성을 원천적으로 차단할 수 있습니다.

예시: 결제 시스템

abstract class Payment {
    abstract void processPayment(int amount);
    abstract void refund(int amount);
}

// 이건 말이 안 됨
Payment payment = new Payment(); // ❌ "결제"라는 추상 개념 자체는 실행 불가

// 구체적인 결제 수단만 생성 가능
Payment creditCard = new CreditCardPayment(); // ✅
Payment kakao = new KakaoPayment(); // ✅

"결제"라는 개념 자체는 실체가 없습니다. 신용카드 결제, 카카오페이 결제 등 구체적인 결제 수단만 실제로 존재하죠.


2. 메서드 구현을 강제한다 (컴파일 타임에 체크)

이게 추상클래스의 가장 큰 장점입니다.

// 일반 클래스 - 오버라이딩 안 해도 에러 없음
class Animal {
    void sound() { }
}

class Dog extends Animal {
    // sound() 안 만들어도 컴파일 성공 ✅
    // 하지만 나중에 런타임 버그 발생 가능
}

일반 클래스는 개발자가 메서드 오버라이딩을 깜빡해도 컴파일러가 알려주지 않습니다. 나중에 프로그램을 실행했을 때 버그가 발견되죠.

// 추상 클래스 - 반드시 구현해야 함
abstract class Animal {
    abstract void sound();
}

class Dog extends Animal {
    // sound() 안 만들면 컴파일 에러! ❌
    // 컴파일러가 "sound() 메서드 구현하세요!" 라고 강제함
}

추상클래스는 컴파일 타임에 구현 누락을 체크해줍니다. 런타임 버그를 미리 방지하는 거죠.


3. 설계 의도를 명확히 한다

// 일반 클래스 - 의도가 불분명
class Shape {
    double getArea() {
        return 0; // 이게 뭐지? 기본값? 버그? 미구현?
    }
}

위 코드를 보는 다른 개발자는 혼란스럽습니다. "0을 리턴하는 게 의도된 건가? 아니면 나중에 구현할 예정인가?"

// 추상 클래스 - 의도가 명확
abstract class Shape {
    abstract double getArea(); // "각 도형마다 반드시 구현하세요!"
}

추상 메서드를 보면 누가 봐도 명확합니다. "이건 하위 클래스에서 반드시 구현해야 하는 메서드구나!"


활용 예시

예시 1: 게임 캐릭터 시스템

abstract class Character {
    String name;
    int hp;
    
    // 공통 로직 (모든 캐릭터가 같은 방식으로 동작)
    void takeDamage(int damage) {
        hp -= damage;
        if (hp <= 0) die();
    }
    
    // 각 캐릭터마다 다르게 동작 (반드시 구현)
    abstract void attack();
    abstract void useSkill();
    abstract void die();
}

class Warrior extends Character {
    void attack() { 
        System.out.println(name + "이(가) 검으로 공격!"); 
    }
    
    void useSkill() { 
        System.out.println(name + "이(가) 강타 스킬 사용!"); 
    }
    
    void die() { 
        System.out.println(name + " 전사가 쓰러졌다..."); 
    }
}

class Mage extends Character {
    void attack() { 
        System.out.println(name + "이(가) 마법 공격!"); 
    }
    
    void useSkill() { 
        System.out.println(name + "이(가) 파이어볼!"); 
    }
    
    void die() { 
        System.out.println(name + " 마법사가 소멸했다..."); 
    }
}

// Character character = new Character(); // ❌ 불가능
// "캐릭터"라는 추상 개념은 직접 생성할 수 없음

Character warrior = new Warrior(); // ✅ 구체적인 캐릭터만 생성 가능
Character mage = new Mage(); // ✅

이 설계의 장점:

  • 모든 캐릭터는 반드시 attack(), useSkill(), die()를 구현해야 함
  • 새로운 캐릭터를 추가할 때 메서드를 깜빡하면 컴파일 에러로 바로 알려줌
  • takeDamage() 같은 공통 로직은 한 번만 작성하면 됨

예시 2: 데이터베이스 연결

abstract class DatabaseConnection {
    String url;
    String username;
    
    // 모든 DB가 동일하게 동작
    void printConnectionInfo() {
        System.out.println("Connecting to: " + url);
    }
    
    // DB마다 다르게 동작 (반드시 구현)
    abstract void connect();
    abstract void disconnect();
    abstract void executeQuery(String sql);
}

class MySQLConnection extends DatabaseConnection {
    void connect() { 
        System.out.println("MySQL 연결 중..."); 
    }
    
    void disconnect() { 
        System.out.println("MySQL 연결 종료"); 
    }
    
    void executeQuery(String sql) { 
        System.out.println("MySQL 쿼리 실행: " + sql); 
    }
}

class PostgreSQLConnection extends DatabaseConnection {
    void connect() { 
        System.out.println("PostgreSQL 연결 중..."); 
    }
    
    void disconnect() { 
        System.out.println("PostgreSQL 연결 종료"); 
    }
    
    void executeQuery(String sql) { 
        System.out.println("PostgreSQL 쿼리 실행: " + sql); 
    }
}

만약 새로운 DB(예: MongoDB)를 추가하는데 executeQuery()를 깜빡 구현했다면?

  • 일반 클래스: 컴파일 성공 → 나중에 런타임에서 버그 발견
  • 추상 클래스: 컴파일 에러 → 바로 발견하고 수정

일반 클래스 vs 추상 클래스 비교표

구분 일반 클래스 추상 클래스

객체 생성 ✅ 가능 ❌ 불가능 (개념적으로 불완전)
구현 강제 ❌ 선택 사항 (깜빡하면 버그) ✅ 컴파일러가 강제
설계 의도 불명확 명확 ("반드시 구현하세요")
안전성 런타임 에러 가능 컴파일 타임에 체크
실수 방지 개발자의 주의력에 의존 컴파일러가 체크

그럼 언제 추상 클래스를 사용해야 할까?

다음 상황이라면 추상 클래스를 고려하세요:

  1. 개념적으로 불완전한 클래스
    • "동물", "도형", "결제", "캐릭터" 같은 추상적 개념
    • 직접 생성하는 게 의미 없는 경우
  2. 하위 클래스가 반드시 구현해야 하는 메서드가 있을 때
    • 구현을 깜빡하면 안 되는 중요한 메서드
    • 컴파일 타임에 체크하고 싶을 때
  3. 공통 로직 + 개별 구현이 섞여 있을 때
    • 일부는 공통으로 처리하고
    • 일부는 각자 다르게 구현해야 할 때
  4. 팀 프로젝트나 큰 시스템
    • 다른 개발자가 실수할 가능성을 줄이고 싶을 때
    • 설계 의도를 명확히 전달하고 싶을 때

정리

추상클래스를 사용하는 진짜 이유:

  1. 실수 방지: 개발자가 메서드 구현을 깜빡하면 컴파일 에러로 막아줌
  2. 의도 전달: "이건 직접 생성하면 안 되는 개념이에요"를 코드로 표현
  3. 안전성: 런타임 에러를 컴파일 타임에 잡음 (빠른 버그 발견)
  4. 유지보수: 나중에 다른 개발자가 봐도 즉시 이해 가능
  5. 강제성: "해도 되고 안 해도 되는" 게 아니라 "반드시 해야 하는" 것으로 만듦

일반 클래스로도 "할 수는" 있습니다. 하지만 추상클래스를 쓰면:

  • 실수를 원천 차단하고
  • 코드의 의도를 명확히 하며
  • 런타임 버그를 컴파일 타임에 잡을 수 있습니다

특히 팀 프로젝트나 큰 시스템에서는 이런 "강제성"이 버그를 크게 줄여줍니다. 한 명이 만드는 작은 프로젝트에서는 차이를 못 느낄 수 있지만, 여러 사람이 협업하는 실무에서는 추상클래스의 진가가 발휘됩니다!

728x90
반응형

'코딩 > Java' 카테고리의 다른 글

실행코드는 왜 반드시 메서드 안에 있어야 할까?  (0) 2026.02.15