CS: 추상 클래스와 인터페이스

hyeppy·2025년 11월 16일

CS

목록 보기
10/11
post-thumbnail

[10분 테코톡] 서기의 추상 클래스와 인터페이스


하나의 프로젝트에는 여러 개의 기능이 존재하며, 이 기능들은 서로 연관되어 있을 수도 있고 독립적일 수도 있다. 혼자서 순차적으로 기능을 구현한다면 큰 문제가 없겠지만, 팀 프로젝트에서는 상황이 달라진다. 특히 B라는 기능을 구현하기 위해 A라는 기능이 선제적으로 완성되어야 하는 의존 관계가 존재할 때, 병렬 개발을 어떻게 진행해야 하는지가 중요한 문제가 된다.
실제로 분석 결과를 커뮤니티에 공유하는 프로젝트를 진행했을 때 이러한 문제를 직접 마주했다. 커뮤니티에 글을 올리기 위해서는 분석 결과와 관련된 기능이 먼저 구현되어 있어야 했다. 하지만 프로젝트 기간은 제한되어 있었고, 그 기간 내에 모든 기능을 완성하기 위해서는 도메인을 병렬로 개발해야 했다. 분석 기능이 아직 완성되지 않은 상태에서 커뮤니티 기능을 구현해야 하는 문제가 발생한 것이다.
당시에는 해결 방법을 몰라서 BaseInitData와 같이 전체 도메인의 Entity를 우선적으로 구현한 후, 기본 데이터를 생성하는 방식으로 임시 해결했다. 하지만 이런 방식은 동일한 비즈니스 로직이 여러 도메인에 중복될 가능성이 있었고, 나중에 실제 구현체가 완성되면 또 다시 수정해야 하는 비효율이 발생했다.
멘토님께 이러한 문제 상황을 여쭤봤을 때, "실무에서는 연관된 기능 개발자들끼리 모여 인터페이스를 먼저 정의하고 개발에 착수한다"는 답변을 들었다. 이 말이 의미하는 인터페이스가 무엇인지, 왜 추상 클래스가 아닌 인터페이스를 사용하는지, 그리고 두 개념의 차이와 활용 방법에 대해 정리해 보고자 한다.


1. 추상 클래스(Abstract Class)

1-1. 정의

추상 클래스는 하나 이상의 프로퍼티나 함수가 불완전한 클래스를 말한다. "불완전하다"는 것은 함수의 시그니처(메서드 선언부)만 존재하고 구현이 없거나, 변수의 타입만 선언되고 초기화되지 않은 상태를 의미한다.

1-2. 특징

public abstract class Animal {
    // 상태를 가질 수 있음
    protected String name;
    protected int age;

    // 생성자를 가질 수 있음
    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 추상 메서드 - 반드시 구현해야 함
    public abstract void makeSound();

    // 구현이 있는 메서드도 포함 가능
    public void sleep() {
        System.out.println(name + "이(가) 잠을 잡니다.");
    }

    // protected 접근 제어자 사용 가능
    protected void breathe() {
        System.out.println("호흡 중...");
    }
}

// 추상 클래스를 상속받는 구체 클래스
public class Dog extends Animal {
    public Dog(String name, int age) {
        super(name, age);
    }

    // 추상 메서드를 반드시 오버라이드해야 함
    @Override
    public void makeSound() {
        System.out.println("멍멍!");
    }
}

// 사용
// Animal animal = new Animal("동물", 5); // 컴파일 에러: 추상 클래스는 직접 인스턴스화 불가능
Animal dog = new Dog("바둑이", 3); // 가능: 구현체를 통한 인스턴스화
dog.makeSound(); // "멍멍!"
dog.sleep();     // "바둑이이(가) 잠을 잡니다."

주요 규칙:

  • 함수의 시그니처만 적거나 변수의 타입만 적는 경우 해당 참조를 abstract로 선언해야 함
  • 추상 멤버가 하나라도 있으면 클래스에도 abstract 키워드를 붙여야 함
  • 추상 클래스는 직접 인스턴스화할 수 없음
  • 상속받는 클래스는 추상 멤버를 반드시 구현해야 함

실제 예시: 자동차 제조를 생각해 보자. "자동차"라는 개념 자체는 추상적이다. 모든 자동차는 "엔진"과 "바퀴"를 가지고 있고, "시동을 건다"는 공통 동작을 하지만, 구체적으로 어떤 엔진을 사용하는지는 세단, SUV, 트럭마다 다르다. 이때 "자동차"를 추상 클래스로 정의하면, 공통된 속성(엔진, 바퀴)과 구체적 구현(시동 거는 절차)을 상속하면서도, 각 차종별로 달라져야 하는 부분(엔진 타입 결정)은 하위 클래스에서 구현하도록 강제할 수 있다.


2. 인터페이스(Interface)

2-1. 정의

인터페이스는 기본적으로 불완전한 함수와 프로퍼티의 집합이다. 클래스가 "무엇을 할 수 있는지"에 대한 명세를 정의하며, 구현의 세부사항은 포함하지 않는 것이 원칙이다. (단, Java 8 이후 default 메서드를 통해 구현이 있는 메서드도 정의 가능)

2-2. 특징

public interface Flyable {
    // 추상 메서드 (public abstract가 생략됨)
    void fly();

    // Java 8 이후: 구현이 있는 default 메서드 정의 가능
    default void takeOff() {
        System.out.println("이륙 준비 중...");
        fly();
    }

    // 상수는 정의 가능 (public static final이 생략됨)
    int MAX_ALTITUDE = 10000;
}

public interface Swimmable {
    void swim();
}

// 다중 인터페이스 구현 가능
public class Duck implements Flyable, Swimmable {
    @Override
    public void fly() {
        System.out.println("오리가 날아갑니다.");
    }

    @Override
    public void swim() {
        System.out.println("오리가 헤엄칩니다.");
    }
}

// 사용
Duck duck = new Duck();
duck.fly();     // "오리가 날아갑니다."
duck.swim();    // "오리가 헤엄칩니다."
duck.takeOff(); // "이륙 준비 중..." -> "오리가 날아갑니다."

// 다형성 활용
Flyable flyable = new Duck();
flyable.fly(); // "오리가 날아갑니다."
// flyable.swim(); // 컴파일 에러: Flyable 타입으로는 swim 호출 불가

주요 규칙:

  • 추상 메서드뿐만 아니라 구현이 있는 메서드(default 메서드)를 정의할 수 있음
  • 내부에 정의된 프로퍼티가 상태를 바꿀 수 없음 (상수만 가능)
  • 여러 인터페이스를 동시에 구현할 수 있음 (다중 상속)
  • 메서드 시그니처가 충돌하는 경우, 구현 클래스에서 직접 오버라이딩해야 함 시그니처 충돌 예시:
    interface A {
        default void print() {
            System.out.println("A");
        }
    }
    
    interface B {
        default void print() {
            System.out.println("B");
        }
    }
    
    // 두 인터페이스 모두 print() 메서드를 가지고 있음
    class C implements A, B {
        // 반드시 충돌하는 메서드를 오버라이드해야 함
        @Override
        public void print() {
            System.out.println("C");
            // 또는 특정 인터페이스의 구현을 선택할 수도 있음
            // A.super.print();
        }
    }

실제 예시: 전자제품의 기능을 생각해 보자. 스마트폰, 태블릿, 노트북은 서로 다른 제품이지만 "충전할 수 있다(Chargeable)", "무선 연결할 수 있다(Wireless)" 같은 공통된 능력을 가질 수 있다. 이들은 부모-자식 관계가 아니라 단지 같은 기능을 제공할 뿐이다. 이때 각 능력을 인터페이스로 정의하면, 계층 구조와 무관하게 "이 제품은 충전이 가능하다"는 계약을 명시할 수 있다.


3. 추상 클래스 vs 인터페이스

3-1. 공통점

  1. 구현이 있는 함수를 포함할 수 있다
    • 추상 클래스: 일반 메서드를 자유롭게 포함
    • 인터페이스: Java 8부터 default 메서드로 구현 제공 가능
  2. 직접 인스턴스화할 수 없다
    • 둘 다 구현체(구체 클래스)를 통해서만 인스턴스화 가능
  3. 추상 멤버를 반드시 구현해야 한다
    • 상속/구현하는 클래스는 추상 메서드를 반드시 오버라이드해야 함

3-2. 차이점

구분추상 클래스인터페이스
상태(필드)인스턴스 변수를 가질 수 있음상수만 가질 수 있음 (상태 불가)
생성자생성자를 가질 수 있음생성자를 가질 수 없음
접근 제어자public, protected, private 등 자유롭게 사용기본적으로 public만 가능 (단, default 메서드는 private 가능)
다중 상속단일 상속만 가능다중 구현 가능
관계is-a 관계 (부모-자식)can-do 관계 (능력 부여)
목적공통 기능 및 상태 공유공통 동작 명세 정의

상태와 생성자

// 추상 클래스: 상태를 가질 수 있음
public abstract class Vehicle {
    private String brand;     // 상태 보유
    private int maxSpeed;     // 상태 보유

    // 생성자 존재
    public Vehicle(String brand, int maxSpeed) {
        this.brand = brand;
        this.maxSpeed = maxSpeed;
    }

    public abstract void drive();

    public String getBrand() {
        return brand;
    }
}

// 인터페이스: 상태를 가질 수 없음
public interface Drivable {
    // int speed; // 컴파일 에러: 인스턴스 변수 선언 불가
    int MAX_SPEED = 200; // 상수는 가능 (public static final)

    void drive();
}

다중 상속과 관계의 차이

// 추상 클래스: 단일 상속만 가능
public abstract class Animal {
    public abstract void eat();
}

public abstract class Mammal extends Animal {
    public abstract void giveBirth();
}

// public class Dolphin extends Mammal, Aquatic { }
// 컴파일 에러: Java는 다중 상속 불가

// 인터페이스: 다중 구현 가능
public interface Swimmable {
    void swim();
}

public interface Jumpable {
    void jump();
}

// 여러 인터페이스를 동시에 구현 가능
public class Dolphin extends Mammal implements Swimmable, Jumpable {
    @Override
    public void eat() {
        System.out.println("물고기를 먹습니다.");
    }

    @Override
    public void giveBirth() {
        System.out.println("새끼를 낳습니다.");
    }

    @Override
    public void swim() {
        System.out.println("헤엄칩니다.");
    }

    @Override
    public void jump() {
        System.out.println("점프합니다.");
    }
}

실제 예시:

  • 추상 클래스의 관계: "포유류(Mammal)"는 "동물(Animal)"이다 → is-a 관계, 명확한 계층
  • 인터페이스의 관계: "돌고래(Dolphin)"는 "헤엄칠 수 있다(Swimmable)", "점프할 수 있다(Jumpable)" → can-do 관계, 능력 명시

돌고래와 참치는 둘 다 헤엄칠 수 있지만, 돌고래는 포유류이고 참치는 어류다. 계층 구조상 전혀 다른 생물이지만 "헤엄치는 능력"은 공유한다. 이때 Swimmable 인터페이스를 사용하면 계층과 무관하게 "헤엄칠 수 있는 모든 생물"을 같은 타입으로 다룰 수 있다.

타입 정의의 차이

// 추상 클래스: 새로운 타입 정의 시 제약 존재
public abstract class PaymentMethod {
    public abstract void pay(int amount);
}

// CreditCard는 반드시 PaymentMethod의 하위 클래스여야 함
public class CreditCard extends PaymentMethod {
    @Override
    public void pay(int amount) {
        System.out.println("신용카드로 " + amount + "원 결제");
    }
}

// 인터페이스: 계층 구조와 무관하게 타입 정의
public interface Payable {
    void pay(int amount);
}

// 어떤 클래스를 상속했든, Payable 구현만 하면 같은 타입으로 취급
public class BankAccount {
    private int balance;
    // ... 기타 코드
}

public class DebitCard extends BankAccount implements Payable {
    @Override
    public void pay(int amount) {
        System.out.println("직불카드로 " + amount + "원 결제");
    }
}

// MobileApp은 전혀 다른 계층이지만, Payable 구현으로 같은 타입
public class MobileApp implements Payable {
    @Override
    public void pay(int amount) {
        System.out.println("모바일 앱으로 " + amount + "원 결제");
    }
}

// 사용
List<Payable> paymentMethods = new ArrayList<>();
paymentMethods.add(new DebitCard());
paymentMethods.add(new MobileApp());

for (Payable method : paymentMethods) {
    method.pay(10000); // 계층과 무관하게 같은 방식으로 처리
}

핵심 차이:

  • 추상 클래스: 부모-자식 관계를 만듦 (수직적 관계)
  • 인터페이스: 형제 관계를 만듦 (수평적 관계)

역할의 차이

// 추상 클래스: 공통 기능을 묶어 중복 제거
public abstract class HttpServlet {
    // 모든 서블릿이 공유하는 공통 로직
    public void service(HttpRequest req, HttpResponse res) {
        // 전처리 로직
        logRequest(req);

        // HTTP 메서드별 분기
        if ("GET".equals(req.getMethod())) {
            doGet(req, res);
        } else if ("POST".equals(req.getMethod())) {
            doPost(req, res);
        }

        // 후처리 로직
        logResponse(res);
    }

    // 하위 클래스가 구현해야 할 메서드
    protected abstract void doGet(HttpRequest req, HttpResponse res);
    protected abstract void doPost(HttpRequest req, HttpResponse res);

    // 공통으로 사용하는 private 메서드
    private void logRequest(HttpRequest req) {
        System.out.println("Request: " + req.getUri());
    }

    private void logResponse(HttpResponse res) {
        System.out.println("Response: " + res.getStatus());
    }
}

// 인터페이스: 서로 관련 없는 클래스에 공통 동작 명세
public interface NotificationSender {
    void send(String message, String recipient);
}

// 이메일, SMS, 카카오톡은 서로 관련이 없지만 "알림을 보낼 수 있다"는 동작은 공통
public class EmailSender implements NotificationSender {
    @Override
    public void send(String message, String recipient) {
        System.out.println("이메일 전송: " + message + " to " + recipient);
    }
}

public class SmsSender implements NotificationSender {
    @Override
    public void send(String message, String recipient) {
        System.out.println("SMS 전송: " + message + " to " + recipient);
    }
}

public class KakaoSender implements NotificationSender {
    @Override
    public void send(String message, String recipient) {
        System.out.println("카카오톡 전송: " + message + " to " + recipient);
    }
}

실제 예시:

  • 추상 클래스: 자동차 제조사(현대, 기아, 삼성)가 모두 "자동차 제조 프로세스"를 공유. 각 제조사는 세부 모델은 다르지만 기본 제조 절차는 같다.
  • 인터페이스: 택배, 우편, 퀵서비스는 서로 다른 회사지만 모두 "배송할 수 있다"는 기능을 제공. 배송 방식은 완전히 다르지만 "픽업하고 전달한다"는 계약은 동일하다.

4. 언제 무엇을 사용할 것인가?

4-1. 추상 클래스를 사용하는 경우

다음 조건 중 하나라도 해당되면 추상 클래스를 고려한다:

  1. 밀접하게 관련된 여러 클래스 간에 코드를 공유하고 싶은 경우

    // 예시: 모든 게임 캐릭터가 공유하는 기본 속성과 동작
    public abstract class GameCharacter {
        protected int hp;
        protected int mp;
        protected String name;
    
        public GameCharacter(String name, int hp, int mp) {
            this.name = name;
            this.hp = hp;
            this.mp = mp;
        }
    
        // 모든 캐릭터가 공유하는 공통 로직
        public void takeDamage(int damage) {
            this.hp -= damage;
            if (this.hp <= 0) {
                die();
            }
        }
    
        // 캐릭터마다 다른 공격 방식
        public abstract void attack();
    
        // 캐릭터마다 다른 죽음 처리
        protected abstract void die();
    }
  2. 구현체 클래스들이 공통적인 필드나 메서드가 많은 경우

    • 여러 클래스가 같은 상태(필드)와 일부 동작을 공유할 때
  3. 멤버에 public 이외의 접근자가 필요한 경우

    public abstract class Database {
        // protected: 하위 클래스만 접근 가능
        protected void connect() {
            // 연결 로직
        }
    
        // private: 하위 클래스도 접근 불가, 내부에서만 사용
        private void validateConnection() {
            // 검증 로직
        }
    
        public abstract void query(String sql);
    }
  4. 단순한 중복 제거를 넘어, 클래스 간 명확한 계층 구조가 필요한 경우

    • "동물 → 포유류 → 개" 같은 is-a 관계가 명확할 때

4-2. 인터페이스를 사용하는 경우

다음 조건 중 하나라도 해당되면 인터페이스를 고려한다:

  1. 연관이 없는 클래스들을 묶고 싶은 경우

    // 자동차, 컴퓨터, 스마트폰은 서로 관련 없지만 모두 "전원을 켤 수 있다"
    public interface Powerable {
        void powerOn();
        void powerOff();
    }
    
    public class Car implements Powerable {
        @Override
        public void powerOn() {
            System.out.println("시동을 겁니다.");
        }
    
        @Override
        public void powerOff() {
            System.out.println("시동을 끕니다.");
        }
    }
    
    public class Computer implements Powerable {
        @Override
        public void powerOn() {
            System.out.println("부팅합니다.");
        }
    
        @Override
        public void powerOff() {
            System.out.println("종료합니다.");
        }
    }
  2. 특정 타입의 행동을 명시하고, 누가 구현하는지는 상관없는 경우

    // "저장할 수 있다"는 기능만 명시
    public interface Repository<T> {
        void save(T entity);
        T findById(Long id);
        void delete(T entity);
    }
    
    // 구현은 MySQL, MongoDB, Redis 등 어디서든 가능
    public class MySqlRepository implements Repository<User> { ... }
    public class MongoRepository implements Repository<User> { ... }
  3. 다중 상속이 필요한 경우

    public interface Readable {
        String read();
    }
    
    public interface Writable {
        void write(String data);
    }
    
    // 파일은 읽기도 쓰기도 가능
    public class File implements Readable, Writable {
        @Override
        public String read() { ... }
    
        @Override
        public void write(String data) { ... }
    }
  4. 클래스와 별도로 구현 객체가 같은 동작을 한다는 것을 보장하기 위한 경우

    • 의존성 주입, 테스트 더블(Mock) 작성 시 유용

5. 병렬 개발을 위한 인터페이스 활용

처음 제기했던 문제로 돌아가 보자. "분석 기능이 완성되지 않았는데 커뮤니티 기능을 어떻게 구현할 것인가?"

5-1. 문제 상황 재정리

// 커뮤니티 서비스는 분석 서비스에 의존
public class CommunityService {
    private AnalysisService analysisService; // 아직 구현되지 않음!

    public void createPost(Long userId) {
        // 분석 결과를 가져와서 게시글 작성
        AnalysisResult result = analysisService.analyze(userId); // 호출 불가능
        // ...
    }
}

이 상황에서 BaseInitData처럼 임시 데이터를 만들면:

  • 분석 서비스의 비즈니스 로직을 커뮤니티 서비스에서 중복 구현
  • 나중에 실제 분석 서비스가 완성되면 또 다시 수정 필요
  • 테스트가 어려움

5-2. 인터페이스를 활용한 해결

1단계: 개발자들이 모여 인터페이스 정의

// 분석 팀과 커뮤니티 팀이 함께 정의한 계약
public interface AnalysisService {
    /**
     * 사용자의 GitHub 저장소를 분석하여 결과를 반환한다.
     *
     * @param userId 분석 대상 사용자 ID
     * @return 분석 결과 객체
     * @throws UserNotFoundException 사용자를 찾을 수 없는 경우
     * @throws AnalysisException 분석 중 오류 발생 시
     */
    AnalysisResult analyze(Long userId);

    /**
     * 분석 결과를 조회한다.
     *
     * @param analysisId 분석 결과 ID
     * @return 분석 결과, 없으면 Optional.empty()
     */
    Optional<AnalysisResult> getAnalysisResult(Long analysisId);
}

// 반환 타입도 함께 정의
public class AnalysisResult {
    private Long id;
    private Long userId;
    private int totalCommits;
    private int totalPullRequests;
    private LocalDateTime analyzedAt;

    // getter, setter, constructor
}

2단계: 각 팀이 병렬로 개발

// 커뮤니티 팀: 인터페이스에 의존하여 개발
@Service
public class CommunityService {
    private final AnalysisService analysisService; // 인터페이스에 의존

    public CommunityService(AnalysisService analysisService) {
        this.analysisService = analysisService;
    }

    public void createPost(Long userId, String content) {
        // 인터페이스 메서드 호출 - 실제 구현은 몰라도 됨
        AnalysisResult result = analysisService.analyze(userId);

        Post post = new Post(userId, content, result);
        // ... 게시글 저장 로직
    }
}

// 분석 팀: 인터페이스를 구현
@Service
public class GitHubAnalysisService implements AnalysisService {
    @Override
    public AnalysisResult analyze(Long userId) {
        // 실제 분석 로직 구현
        // GitHub API 호출, 데이터 수집, 분석 등

        AnalysisResult result = new AnalysisResult();
        result.setUserId(userId);
        result.setTotalCommits(calculateCommits(userId));
        result.setTotalPullRequests(calculatePRs(userId));
        result.setAnalyzedAt(LocalDateTime.now());

        return result;
    }

    @Override
    public Optional<AnalysisResult> getAnalysisResult(Long analysisId) {
        // DB에서 조회
        return analysisRepository.findById(analysisId);
    }

    private int calculateCommits(Long userId) {
        // 실제 계산 로직
        return 0;
    }

    private int calculatePRs(Long userId) {
        // 실제 계산 로직
        return 0;
    }
}

5-3. 인터페이스 방식의 장점

  1. 독립적인 개발
    • 커뮤니티 팀은 분석 팀의 구현을 기다리지 않고 개발 가능
    • 인터페이스만 합의하면 각자 진행
  2. 테스트 용이
    • Mock 구현체로 단위 테스트 작성 가능
    • 실제 분석 로직이 없어도 커뮤니티 기능 검증 가능
  3. 변경에 유연
    • 분석 구현 방식이 바뀌어도 커뮤니티 코드는 수정 불필요
    • GitHub에서 GitLab으로 변경되어도 인터페이스만 지키면 됨
  4. 명확한 계약
    • 어떤 메서드가 필요한지, 어떤 예외가 발생하는지 명시
    • 문서화 효과

5-4. 실무 프로세스

멘토님이 말씀하신 "인터페이스를 먼저 정의하고 개발에 착수한다"는 것은 다음과 같은 과정을 의미한다:

  1. 킥오프 미팅: 연관된 기능 담당자들이 모여 의존 관계 파악
  2. 인터페이스 설계:
    • 어떤 메서드가 필요한가?
    • 파라미터와 리턴 타입은?
    • 어떤 예외를 던지는가?
    • 성능 요구사항은? (예: 응답 시간 1초 이내)
  3. 문서화: JavaDoc, API 명세서 작성
  4. 병렬 개발: 각 팀이 인터페이스를 기준으로 독립 개발
  5. 통합 테스트: 실제 구현체들을 연결하여 검증

이 방식은 DIP(Dependency Inversion Principle) 원칙과도 연결된다. 구체적인 구현에 의존하지 않고 추상화(인터페이스)에 의존하면, 코드의 결합도가 낮아지고 유연성이 높아진다.


profile
Backend

0개의 댓글