Dev Study/Java

[JAVA & Antigravity] 인터페이스: 강한 결합을 끊고 유연함을 얻다

parkhh98 2026. 2. 5. 22:53

 

개발을 하다 보면 "나중에 이 모듈이 바뀔 수도 있다"는 불안감을 마주한다. 특정 기술이나 라이브러리에 의존하는 코드를 작성했다가, 요구사항 변경으로 인해 코드 전체를 뒤엎어야 했던 경험은 누구에게나 있다. 이 문제를 해결하기 위해 자바는 인터페이스라는 강력한 규격 시스템을 제공한다. 인터페이스가 해결하려는 문제와 그 진화 과정, 그리고 다형성을 통한 설계의 유연성에 대해 정리한다.


1. 문제 상황: 구체적인 것에 의존하다

온라인 쇼핑몰의 결제 시스템을 개발한다고 가정한다. 초기 요구사항은 '신용카드' 결제만 지원하는 것이었다. 개발자는 별다른 고민 없이 CreditCard 클래스를 만들고, 주문 로직에서 이를 직접 가져다 썼다.

// 구체 클래스
public class CreditCard {
    public void pay(int amount) {
        System.out.println("신용카드로 " + amount + "원 결제");
    }
}

// 주문 로직
public class OrderService {
    // 문제: CreditCard라는 구체적인 클래스에 직접 의존하고 있다.
    private CreditCard paymentRunner = new CreditCard();

    public void processOrder(int amount) {
        paymentRunner.pay(amount);
    }
}

문제는 서비스가 성장하여 '계좌이체' 기능을 추가해야 할 때 발생한다. OrderService는 이미 CreditCard 타입에 묶여 있다. 이를 BankTransfer로 바꾸려면 변수 선언부터 메서드 호출까지 관련된 모든 코드를 수정해야 한다. 이것이 강한 결합이다. 구체적인 구현체에 의존할수록 변경의 비용은 기하급수적으로 늘어난다.


2. 해결: 규격의 도입

이 문제를 해결하려면 '신용카드냐 계좌이체냐'가 중요한 것이 아니라, '결제 기능을 수행할 수 있는가'에 집중해야 한다. 인터페이스는 바로 이 "할 수 있음"을 정의하는 규격이다.

// 인터페이스 도입: 결제라는 행위만 정의
public interface Payment {
    void pay(int amount);
}

// 각 결제수단은 이 규격을 따름
public class CreditCard implements Payment {
    @Override
    public void pay(int amount) { ... }
}

public class BankTransfer implements Payment {
    @Override
    public void pay(int amount) { ... }
}

이제 구현체들은 서로 달라도 Payment라는 같은 명함을 내밀 수 있게 되었다. 이것이 느슨한 결합의 시작이다.


3. 진화: 인터페이스의 변화 (Java 8 이후)

과거의 자바 인터페이스는 오직 추상 메서드(껍데기)만 가질 수 있었다. 하지만 Java 8부터는 디폴트 메서드정적 메서드가 도입되면서 인터페이스의 역할이 확장되었다.

3-1. 디폴트 메서드

이미 100개의 클래스가 Payment를 구현하고 있는데, 갑자기 모든 결제 수단에 cancel()(결제 취소) 기능을 추가해야 한다고 상상해 보자. 인터페이스에 추상 메서드를 추가하는 순간, 이를 구현한 100개의 클래스에서 컴파일 에러가 발생한다. 모두 오버라이딩을 해줘야 하기 때문이다.

디폴트 메서드는 이런 하위 호환성 문제를 해결한다. 인터페이스 안에서 기본 구현을 제공함으로써, 기존 구현체들을 깨뜨리지 않고 새로운 기능을 추가할 수 있다.

public interface Payment {
    void pay(int amount);

    // 구현체들이 강제로 오버라이딩 하지 않아도 됨
    default void cancel() {
        System.out.println("기본 결제 취소 처리");
    }
}

3-2. 정적 메서드

해당 인터페이스와 관련된 유틸리티 기능을 제공하고 싶을 때 사용한다. 객체 생성 없이 인터페이스 이름으로 바로 호출할 수 있다. 보통 null 체크나 간단한 변환 로직 등을 담는다.

public interface Payment {
    static void validate(int amount) {
        if (amount <= 0) throw new IllegalArgumentException("결제 금액 오류");
    }
}

// 사용: Payment.validate(1000);

주의할 점은 인터페이스의 static 메서드는 상속되지 않는다는 것이다. 구현 클래스에서 CreditCard.validate()처럼 호출할 수 없으며, 반드시 Payment.validate()로 호출해야 한다.


4. 결론: 다형성이 주는 자유

인터페이스를 도입한 최종 목적지는 다형성이다. 이제 OrderService는 더 이상 구체적인 결제 수단에 연연하지 않는다.

public class OrderService {
    // 구체 클래스가 아닌 인터페이스에 의존
    private Payment paymentRunner;

    // 외부에서 무엇을 주입하든(CreditCard든 BankTransfer든) 다 받아서 처리함
    public OrderService(Payment paymentRunner) {
        this.paymentRunner = paymentRunner;
    }

    public void processOrder(int amount) {
        paymentRunner.pay(amount); // 동적 바인딩
    }
}

이제 신용카드에서 계좌이체로 교체하는 작업은 OrderService 코드를 수정하는 것이 아니라, new CreditCard()new BankTransfer()로 갈아 끼우는 설정 변경만으로 끝난다. 핵심 로직은 보호하면서 부품만 자유롭게 교체할 수 있는 상태, 이것이 객체지향이 추구하는 유연함이다.