Java 추상 클래스와 인터페이스, 설계 의도에 맞게 선택하는 법
Java 추상 클래스와 인터페이스의 차이, 상속과 계약 기반 설계의 선택 기준, Spring 계층 구조에서의 활용 방식을 정리한다.
2026-08-14 · 최초 발행 2025-05-23
상태와 공통 구현이 필요할 때의 추상 클래스
Java에서 추상 클래스와 인터페이스는 모두 구현체에 규칙을 부여하지만, 설계의 출발점은 다르다. 추상 클래스는 서로 밀접한 객체가 공통 상태와 동작을 물려받도록 하는 기반 클래스다. 일반 메서드와 구현하지 않은 메서드를 함께 선언할 수 있으며, 직접 인스턴스를 만들 수는 없다.
하위 클래스는 추상 메서드를 구현해야 하고, 부모 생성자를 통해 초기화 과정을 공유할 수 있다. 상수와 일반 변수도 선언할 수 있지만 Java 클래스 상속 원칙상 하나의 추상 클래스만 상속할 수 있다.
abstract class Database {
// 일반 변수
protected String dbName;
// 생성자
public Database(String name) {
this.dbName = name;
}
// 일반 메서드
public void connect() {
System.out.println(dbName + " 데이터베이스에 연결합니다.");
}
// 추상 메서드
abstract void executeQuery(String query);
}
// 추상 클래스 구현
class MySQLDatabase extends Database {
public MySQLDatabase(String name) {
super(name);
}
@Override
void executeQuery(String query) {
System.out.println("MySQL에서 쿼리 실행: " + query);
}
}
Database처럼 연결 대상의 이름을 보관하고 연결 동작을 공통으로 제공해야 한다면, 추상 클래스가 자연스럽다. 각 데이터베이스가 쿼리를 실행하는 방식만 구현체에 남길 수 있다.
여러 구현체가 공유하는 행동 계약
인터페이스는 특정 역할을 수행하기 위한 계약을 표현한다. 하나의 클래스가 여러 인터페이스를 구현할 수 있으므로, 상속 구조와 별개로 필요한 동작을 조합할 수 있다.
인터페이스의 변수는 자동으로 public static final이며, 메서드는 별도 표기가 없어도 public abstract로 간주된다. Java 8 이전에는 추상 메서드만 둘 수 있었지만, 이후에는 default 메서드와 static 메서드를 포함할 수 있게 됐다. Java 9부터는 내부 구현을 감추는 private 메서드도 사용할 수 있다.
interface DataProcessor {
// 상수 (자동으로 public static final)
int MAX_DATA_SIZE = 1000;
// 추상 메서드 (자동으로 public abstract)
void processData(String data);
// Java 8: default 메서드
default void initialize() {
System.out.println("데이터 프로세서 초기화");
}
// Java 8: static 메서드
static boolean validateData(String data) {
return data != null && !data.isEmpty();
}
}
// 인터페이스 구현
class JSONProcessor implements DataProcessor {
@Override
public void processData(String data) {
System.out.println("JSON 데이터 처리: " + data);
}
}
상속과 역할을 구분하는 선택 기준
추상 클래스는 관련 객체 사이에 공통 기능이 많고, 공통 필드나 비공개 멤버 접근이 필요한 경우에 적합하다. 객체가 상태를 가져야 하거나 여러 구현체가 같은 처리 흐름을 공유한다면, 부모 클래스에 공통 구현을 둘 수 있다.
결제 처리처럼 가맹점 정보와 수수료를 공유하면서 실제 결제 처리만 구현체마다 달라지는 구조가 한 예다.
abstract class PaymentProcessor {
protected double fee;
protected String merchantId;
public PaymentProcessor(String merchantId) {
this.merchantId = merchantId;
this.fee = calculateFee();
}
// 일반 메서드 - 공통 구현
public void verifyMerchant() {
System.out.println("가맹점 ID 확인: " + merchantId);
}
// 서브클래스에서 override 가능한 일반 메서드
protected double calculateFee() {
return 0.02; // 기본 수수료 2%
}
// 추상 메서드 - 서브클래스에서 반드시 구현
public abstract boolean processPayment(double amount);
}
반면 인터페이스는 관련이 없는 클래스에도 같은 동작을 요구해야 할 때 유용하다. API의 형태만 정하고 여러 구현을 허용하거나, 여러 역할을 한 클래스에 적용해야 하는 경우가 이에 해당한다.
interface Exportable {
void export(String path);
}
interface Printable {
void print();
}
// 다중 인터페이스 구현
class Report implements Exportable, Printable {
private String content;
public Report(String content) {
this.content = content;
}
@Override
public void export(String path) {
System.out.println("리포트를 " + path + "에 저장합니다.");
}
@Override
public void print() {
System.out.println("리포트 인쇄: " + content);
}
}
is-a 관계처럼 상속 자체가 도메인 모델을 설명한다면 추상 클래스를 고려할 수 있다. 반대로 can-do 관계처럼 객체가 할 수 있는 행동을 붙이는 일이라면 인터페이스가 더 잘 맞는다.
도형 모델에서 구조를 함께 쓰기
도형은 위치와 면적 계산이라는 공통 기반을 갖지만, 그리기·크기 조절·회전 같은 역할은 인터페이스로 분리할 수 있다. 아래 구조에서 Shape는 공통 상태와 추상 동작을 제공하고, Drawable, Transformable은 구현체가 수행해야 할 역할을 정의한다.
계층형 애플리케이션의 계약 경계
기업용 애플리케이션에서는 계층 사이의 의존성을 관리하기 위해 인터페이스를 계약으로 두는 방식이 쓰인다. Controller, Service, Repository 계층은 역할을 나누고, 각 계층의 구현체는 인터페이스를 기준으로 교체하거나 테스트할 수 있다.
Spring 기반 서비스에서도 서비스 인터페이스와 구현 클래스를 분리해 의존 관계를 구성할 수 있다.
// 인터페이스 정의
public interface UserService {
User findById(Long id);
List<User> findAll();
User save(User user);
void delete(Long id);
}
// 구현 클래스
@Service
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
@Autowired
public UserServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
}
@Override
public List<User> findAll() {
return userRepository.findAll();
}
@Override
public User save(User user) {
return userRepository.save(user);
}
@Override
public void delete(Long id) {
userRepository.deleteById(id);
}
}
확장된 인터페이스 메서드
Java 8 이후 인터페이스는 구현 코드도 제공할 수 있게 됐다. default 메서드는 구현체에 기본 동작을 제공하고, static 메서드는 인터페이스에 유틸리티 메서드를 둘 수 있게 한다. Java 9 이후의 private 메서드는 이러한 내부 구현을 캡슐화하는 데 사용한다.
interface ModernInterface {
// 추상 메서드
void abstractMethod();
// default 메서드
default void defaultMethod() {
privateHelper();
System.out.println("기본 구현 제공");
}
// static 메서드
static void staticMethod() {
System.out.println("정적 유틸리티 메서드");
}
// private 메서드 (Java 9+)
private void privateHelper() {
System.out.println("내부 구현 세부사항");
}
}
호출 비용보다 구조의 복잡도를 먼저 본다
인터페이스 메서드 호출에는 간접 참조에 따른 미세한 오버헤드가 있을 수 있다. 다만 컴파일러는 구현체가 계약을 지키는지 확인한다. 설계에서는 호출 비용만 보기보다 상속 계층이 지나치게 깊어져 유지보수가 어려워지지 않는지 함께 판단해야 한다.
인터페이스 분리 원칙(ISP)도 같은 맥락이다. 넓은 하나의 인터페이스보다 목적이 분명한 작은 인터페이스를 나누면, 구현체가 필요 없는 메서드까지 떠안는 일을 줄일 수 있다. 인터페이스 타입을 중심으로 코드를 작성하면 Mock 객체를 이용한 테스트도 수월해진다.
추상 클래스는 공통 코드와 상태를 가진 유사 객체의 기반을 만들고, 인터페이스는 객체가 제공하는 행동을 계약으로 고정한다. 두 수단을 역할에 맞게 나누면 확장과 유지보수가 필요한 Java 시스템의 경계를 더 선명하게 설계할 수 있다.