PUBLISHED

인터페이스와 추상 클래스

작성일: 2025.01.26

인터페이스와 추상 클래스

언젠가 동생이 내게 이런 질문을 한 적이 있다. “형은 어떨 때 type을 쓰고 어떨 때 interface를 써?”

당시의 나는 대부분의 경우 type을 쓴다고 대답했다. 선언 병합을 제외하면 interface가 제공하는 기능적 이점을 크게 느끼지 못했기 때문이다. 실제로 타입스크립트에서 표현력만 놓고 보면, type은 interface가 할 수 있는 거의 모든 일을 할 수 있고, 때로는 그 이상도 가능하다. 그래서 그때의 나는 interface를 선택하는 이유를 기능적으로 설명할 수 없었다. 하지만 동생이 다시 한 번 질문을 해온다면 지금의 나는 이렇게 대답할 것 같다.

인터페이스는 구현이 아니라 ‘약속’

인터페이스라는 단어를 가장 자연스럽게 접하는 곳은 API다. 특히 REST API를 떠올려보면, 인터페이스의 본질이 분명해진다. 웹 서버와 백엔드 서버는 서로의 내부 구현을 전혀 알지 못한다. 어떤 언어를 쓰는지, 어떤 데이터베이스를 사용하는지, 심지어 서버가 교체되었는지조차 알 필요가 없다. 그럼에도 불구하고 이 둘은 문제없이 통신한다. 이유는 단 하나다. 정해진 규약, 즉 계약을 따르기 때문이다.

타입스크립트의 interface도 동일하다. interface는 객체의 내부 구조를 설명하기 위한 도구가 아니다. 객체가 외부와 어떤 방식으로 소통할 수 있는지를 정의하는 계약이다. 이 계약이 지켜지는 한, 내부 구현은 얼마든지 바뀔 수 있다.

untitled
TS
interface Payment {
  pay(amount: number): void;
}

이 인터페이스를 사용하는 쪽은 pay가 어떻게 구현되어 있는지 알지 못한다. 신용카드인지, 계좌이체인지, 포인트인지도 중요하지 않다. 중요한 것은 pay(amount)라는 메시지를 보낼 수 있다는 점이다.

이 관점에서 보면, “인터페이스는 상태를 포함하지 않고 메서드만 정의한다”는 설명은 정확하지 않다. 인터페이스는 상태도, 메서드도 포함하지 않는다. 메서드는 구현의 일부이며, 구현은 객체 내부의 책임이다. 인터페이스가 정의하는 것은 메서드가 아니라 메시지의 시그니처와 의미다.

이것은 캡슐화의 본질과 맞닿아 있다. 객체 지향에서 객체는 다른 객체의 내부를 들여다보지 않는다. 오직 메시지를 보낼 뿐이고, 메시지를 받은 객체는 그 메시지를 어떻게 처리할지 스스로 결정한다.

“캐셔가 손님의 지갑을 가져가는 것이 아니라, 손님이 스스로 돈을 지불해야 한다”라는 비유는 이를 잘 설명한다. 손님이 어떤 지갑을 쓰는지, 돈을 어디에 보관하는지는 캐셔의 관심사가 아니다. 캐셔는 오직 계산_부탁드립니다(금액)이라는 메시지를 보낼 뿐이다.

untitled
TS
interface Customer {
  requestPayment(total: number): void;
}

캐셔는 Customer가 어떤 상태를 가지고 있는지 알지 못한다. 그저 이 메시지를 보낼 수 있는 대상이라는 사실만 안다. 이처럼 interface는 객체가 외부에 드러내는 행동의 표면만을 정의한다.

이러한 관점에서 보면, 하나의 클래스가 여러 인터페이스를 구현할 수 있는 이유 역시 명확해진다. 인터페이스는 구현의 방식이 아니라, 객체가 외부에 제공할 수 있는 역할에 대한 계약이기 때문이다. 현실 세계의 존재가 단일한 정체성만을 갖지 않는 것처럼, 소프트웨어 객체 역시 하나의 역할로만 환원되지 않는다. 여러 인터페이스를 구현한다는 것은, 하나의 객체가 여러 맥락에서 서로 다른 책임을 질 수 있음을 선언하는 행위다.

untitled
TS
interface Identifiable {
  getId(): string;
}

interface User extends Identifiable {
  login(): void;
}

interface Admin extends Identifiable {
  deleteUser(userId: string): void;
}

class Account implements User, Admin {
  constructor(private id: string) {}

  getId(): string {
    return this.id;
  }

  login(): void {
    // 사용자로서의 책임
  }

  deleteUser(userId: string): void {
    // 관리자로서의 책임
  }
}

이 클래스가 두 인터페이스를 구현하는 데 아무런 모순이 없는 이유는, 인터페이스가 객체의 본질이나 정체성을 규정하지 않기 때문이다. 인터페이스는 객체가 외부와 어떤 메시지를 주고받을 수 있는지만을 정의하며, 그 메시지들이 서로 충돌하지 않는 한 얼마든지 함께 존재할 수 있다.

UserAdmin은 서로 다른 역할을 나타내지만, 이 둘은 배타적인 관계가 아니다. 두 인터페이스가 공통으로 의존하는 Identifiable 역시 마찬가지다. getId라는 메시지는 사용자로서도, 관리자로서도 동일한 의미를 가진다. 이는 하나의 객체가 여러 역할을 수행하더라도, 메시지의 의미가 일관되게 유지될 수 있음을 보여준다.

즉, 인터페이스는 객체를 분류하기 위한 수단이 아니라, 객체가 수행할 수 있는 역할의 집합을 기술하는 도구다. 하나의 객체는 여러 역할을 동시에 수행할 수 있고, 타입스크립트의 인터페이스는 이러한 역할의 중첩을 언어 차원에서 자연스럽게 허용한다.

추상 클래스라는 이름의 레시피

추상 클래스는 완성된 구현이 아니다. 대신 구체 클래스가 따라야 할 기본적인 흐름과 공통 동작을 미리 정리해둔 틀에 가깝다. 이 점에서 추상 클래스는 설계도라기보다 레시피에 가깝다. 그대로 실행할 수는 없지만, 그 레시피를 따르는 한 결과물은 일정한 방향성을 유지하게 된다.

레시피의 중요한 특징은, 모든 것을 세세하게 규정하지 않는다는 데 있다. 어떤 단계는 반드시 지켜야 하지만, 어떤 부분은 조리자의 판단에 맡긴다. “적당히 익었을 때 건져낸다”라는 문장이 그렇다. 익혀야 한다는 사실과 그 순서는 명확하지만, ‘적당함’의 기준은 상황과 경험에 따라 달라진다. 레시피는 이 판단을 조리자에게 위임한다.

추상 클래스 역시 마찬가지다. 전체적인 실행 흐름은 추상 클래스가 책임지지만, 그 흐름 안의 일부 단계는 구현을 비워둔 채 구체 클래스에게 맡긴다. 무엇을 언제 호출할지는 정해져 있지만, 그 내부에서 어떤 로직으로 동작할지는 각 구현이 스스로 결정한다.

아래의 예시에서 pay 메서드는 결제라는 작업이 따라야 할 전체 흐름을 정의한다. 검증을 거쳐 처리하고, 마지막에 공통된 후처리를 수행한다는 순서는 바뀔 수 없다. 하지만 validateprocess어떻게 동작하는지는 추상 클래스가 관여하지 않는다. 이 판단은 구체 클래스에게 위임된다.

untitled
TS
abstract class PaymentProcessor {
  pay(amount: number) {
    this.validate(amount);
    this.process(amount);
    this.afterProcess();
  }

  protected abstract validate(amount: number): void;
  protected abstract process(amount: number): void;

  protected afterProcess() {
    // 공통 후처리 로직
  }
}

CardPaymentProcessor 클래스는 추상 클래스가 제시한 레시피를 그대로 따른다. 다만 “적당히 익었을 때”가 무엇인지는 스스로 정의한다. 금액 검증의 기준도, 처리 방식도 이 클래스의 맥락에 맞게 결정된다. 중요한 것은, 그 판단이 정해진 흐름 안에서 이루어진다는 점이다.

untitled
TS
class CardPaymentProcessor extends PaymentProcessor {
  protected validate(amount: number) {
    // 카드 결제에 맞는 검증 로직
  }

  protected process(amount: number) {
    // 카드 결제 처리
  }
}

이 지점에서 인터페이스와의 차이는 더욱 분명해진다. 인터페이스는 객체가 외부에 어떤 메시지를 제공할 수 있는지만을 정의한다. 메시지의 순서나 내부 구현의 공통 흐름에는 관여하지 않는다. 반면 추상 클래스는 내부 구현의 방향성을 강하게 규정한다. 그래서 추상 클래스는 “이 객체가 무엇을 할 수 있는가”보다, “이 작업은 어떤 순서로 이루어져야 하는가”에 관심을 가진다.

이 차이는 상속 구조에서도 그대로 드러난다. 인터페이스는 여러 개를 동시에 구현할 수 있다. 하나의 객체가 여러 역할을 수행하는 것은 자연스럽기 때문이다. 하지만 추상 클래스는 단일 상속만 허용한다. 레시피는 동시에 여러 개를 따를 수 없기 때문이다. 한 요리가 두 개의 조리 순서를 동시에 따를 수 없듯, 하나의 객체도 서로 다른 실행 흐름을 동시에 강제받을 수는 없다.

정리하면, 인터페이스는 객체가 외부에 제공하는 역할과 책임의 계약이고, 추상 클래스는 그 책임을 수행하기 위한 구현의 기본 흐름, 즉 레시피다. 하나는 외부와의 관계를, 다른 하나는 내부의 구조와 순서를 다룬다. 이 둘은 서로를 대체하지 않는다. 오히려 함께 사용할 때, 객체 지향 설계는 가장 자연스럽고 견고한 형태를 갖게 된다.

+ “Semantics are important.” 이건 레딧에서 들은 조언인데, 그 이후로 꾸준히 명심하고 있다. 물론 type으로도 클래스를 구현할 수는 있지만, 그 선택은 종종 의도를 숨긴다. type은 무엇이든 표현할 수 있는 범용적인 도구이지만, 바로 그 범용성 때문에 이 타입이 어떤 역할을 맡고 있는지까지는 말해주지 않는다.

반면 interface라는 단어는 그 자체로 강한 의미를 가진다. 이 타입은 데이터 구조가 아니라, 객체가 외부와 맺는 계약이라는 사실을 드러낸다. 구현이 아니라 역할, 내부가 아니라 경계를 표현하려는 의도가 코드 위에 명확히 남는다. 이는 타입 시스템의 기능을 넘어서, 코드를 읽는 사람과의 커뮤니케이션에 가깝다.

좋은 설계는 컴파일러를 만족시키는 것에서 끝나지 않는다. 다음에 이 코드를 읽게 될 사람, 혹은 미래의 나에게 이 타입이 왜 존재하는지를 설명할 수 있어야 한다. interface를 선택하는 일은, “이 객체를 이렇게 사용해달라”는 메시지를 코드로 남기는 행위다.

그래서 나는 이제, 단순히 가능하다는 이유만으로 type을 선택하지 않는다. 이 타입이 표현하려는 것이 데이터인지, 역할인지, 계약인지 스스로에게 먼저 묻는다. 그리고 그 대답이 ‘역할’이라면, interface를 선택한다. 의미를 드러내는 선택이 결국 더 나은 설계로 이어진다고 믿기 때문이다.