Java 28
인터페이스, extends, implements: 객체의 '종류'와 '역할'을 명확히
상속도 부모의 기능을 물려받는데, 인터페이스는 왜 따로 필요하고 구현이 없는 메서드를 왜 약속해야 하는지 헷갈린다.
근거 · 교안 p203-p209
1장: 클래스 상속: ‘종류’는 하나만, ‘역할’은?
솔라는 노트북 화면에 떠 있는 빈 클래스 파일과 머릿속에 떠다니는 생각들 사이에서 길을 잃었다. ‘장난감 자동차’를 객체로 만들어보고 싶었다. 이건 분명 ‘자동차’의 한 종류다. 앞으로 가고, 뒤로 가고, 멈추는 기능이 있어야 하니까. 동시에 ‘장관감’이기도 하다. 가지고 놀 수 있고, 가격도 매겨져 있다.
솔라는 키보드에 손을 올렸다. 아주 직관적이고 당연한 생각이었다. ‘장난감 자동차’는 ‘자동차’로부터 기능을 물려받고, ‘장난감’으로부터도 기능을 물려받으면 완벽할 텐데.
“언니, 이거 좀 이상해.”
옆에서 조용히 책을 읽던 루나가 고개를 들었다. 솔라는 화면을 가리키며 빠르게 말했다.
“봐봐. Car 클래스가 있고, Toy 클래스가 있다고 쳐. Car에는 move() 메서드가 있고, Toy에는 play() 메서드가 있어. 그럼 ToyCar는 Car이기도 하고 Toy이기도 하니까, 두 클래스를 전부 상속받으면 되는 거 아니야? extends Car, Toy 이런 식으로. 그러면 move()도 할 수 있고 play()도 할 수 있는 완벽한 ToyCar가 되잖아.”
솔라는 마치 멋진 아이디어를 떠올린 사람처럼 신나게 설명했지만, 정작 손가락은 키보드 위에서 머뭇거리고 있었다. 무언가 잘못되었다는 걸 이미 예감하고 있었기 때문이다. 책에서 언뜻 봤던 ‘자바는 다중 상속을 지원하지 않는다’는 문장이 머릿속을 스쳤다. 하지만 왜? 이렇게 명확하고 편리한 방법을 왜 막아 놓았을까?
루나는 솔라의 말을 잠자코 듣더니, 대답 대신 화면을 향해 턱짓했다.
“한번 직접 써볼래?”
그 말에 솔라는 망설임을 털어내고 코드를 입력하기 시작했다. 먼저 간단한 두 개의 부모 클래스를 만들었다.
class Machine {
void turnOn() {
System.out.println("기계를 켭니다.");
}
}
class Doll {
void playWith() {
System.out.println("인형을 가지고 놉니다.");
}
}
이제 이 둘을 합칠 차례. 솔라는 Robot이라는 새로운 클래스를 만들고, 자신의 생각대로 두 부모를 모두 extends 키워드 뒤에 나열했다.
class Robot extends Machine, Doll {
// ...
}
코드를 완성하기도 전에, extends Machine, Doll 부분에 즉시 붉은 밑줄이 그어졌다. 컴파일 에러. 마우스를 올려보니 ‘하나의 클래스만 상속할 수 있습니다’라는 차가운 메시지가 나타났다.
“이거 봐. 안 되잖아. 근데 이유를 모르겠어. 기능을 물려받는 건데, 여러 곳에서 받으면 더 좋은 거 아니야?”
솔라는 의자를 뒤로 밀며 팔짱을 꼈다. 상속은 부모의 코드를 재사용하는 효율적인 방법이라고 배웠다. 그렇다면 더 많이 재사용할수록 더 효율적인 게 아닐까? 이 간단한 논리가 왜 통하지 않는지 답답했다.
루나는 솔라의 노트북 옆에 작은 메모지 두 장을 놓았다. 한 장에는 ‘기계’, 다른 한 장에는 ‘인형’이라고 썼다.
“솔라야, Robot 객체의 ‘정체성’이 뭐라고 생각해?”
“정체성?”
“응. Robot은 ‘기계’야, 아니면 ‘인형’이야?”
“음… 둘 다인데?”
솔라의 대답에 루나는 고개를 저었다.
“extends는 ‘기능을 추가한다’는 뜻이라기보다, ‘…의 한 종류다’라고 선언하는 것에 가까워. 정체성을 규정하는 거지. class Dog extends Animal은 ‘개는 동물의 한 종류다’라는 뜻이잖아. 이 관계는 굉장히 강력하고 근본적인 연결이야. 개의 정체성은 동물이 되는 거지.”
루나는 ‘기계’라고 쓰인 메모지를 솔라 쪽으로 밀었다.
“만약 자바가 Robot extends Machine, Doll을 허용한다면, Robot의 정체성은 ‘기계’이면서 동시에 ‘인형’이 되어버려. 여기서 문제가 생겨. 만약 Machine 클래스에도 checkStatus()라는 메서드가 있고, Doll 클래스에도 이름이 똑같은 checkStatus() 메서드가 있다면 Robot은 누구의 메서드를 물려받아야 할까? 기계의 상태를 점검해야 할까, 인형의 상태를 점검해야 할까?”
솔라는 순간 말문이 막혔다. 전혀 생각해보지 못한 문제였다. 이름이 같은 메서드가 두 부모에게 동시에 존재할 때의 혼란. 컴퓨터는 인간처럼 ‘문맥에 맞게’ 알아서 처리해주지 않는다. 어떤 것을 따라야 할지 명확한 규칙이 없다면, Robot의 정체성은 모호해지고 시스템 전체가 불안정해질 수밖에 없다.
“아…”
솔라의 입에서 짧은 탄식이 흘러나왔다. 붉은 밑줄이 그저 문법적인 제약이 아니라는 것을 깨달았다. extends는 단순히 기능 목록을 복사해오는 장바구니가 아니었다. 그것은 객체의 근본, 즉 ‘종류’를 단 하나로 결정하는 행위였다. 한 객체가 동시에 두 가지 다른 종류일 수는 없다는, 현실 세계의 상식과도 닮아있는 규칙이었다.
“그럼 상속은… 객체가 어떤 ‘종류’인지 딱 하나만 정하는 거구나. 그래서 부모는 하나만 가질 수 있는 거고.”
솔라는 자신이 썼던 extends Machine, Doll 코드를 지웠다. 붉은 밑줄이 사라졌다. 하지만 문제는 해결되지 않았다. 오히려 더 큰 질문이 남았다.
“알겠어. Robot은 ‘기계’의 한 종류라고 정체성을 정할 수 있어. class Robot extends Machine. 이건 이제 이해돼. 그런데… 로봇은 기계이기도 하지만, ‘가지고 노는 역할’도 해야 하잖아. 상속으로 정체성은 하나만 정할 수 있다면, 이런 여러 가지 ‘역할’은 도대체 어떻게 부여해야 하는 거야? 기능을 물려받지도 못하는데?”
2장: 인터페이스: ‘역할’을 위한 빈 계약서
솔라의 질문이 허공에 맴돌고 있었다. extends가 객체의 ‘종류’를 단 하나로 정하는 강력한 선언이라면, 여러 ‘역할’은 대체 어떻게 부여해야 하는가. 이 근본적인 질문에 대한 답을 찾지 못한 채, 솔라는 지워버린 코드의 빈자리를 멍하니 바라보고 있었다.
그때 루나가 책상 위에 놓여 있던 메모지 두 장, ‘기계’와 ‘인형’ 옆으로 새로운 빈 메모지 한 장을 스윽 밀어 넣었다. 그리고는 그 위에 펜으로 이렇게 썼다. ‘판매할 수 있음’.
루나는 그 세 번째 메모지를 톡톡 가리켰다.
“Robot이 Machine의 한 종류인 건 이제 명확해졌어. extends Machine이지. 그런데 로봇 장난감은 가게에서 ‘판매할 수 있는’ 물건이기도 하잖아. 이 ‘판매할 수 있음’이라는 성질은 ‘기계’나 ‘인형’처럼 어떤 ‘종류’를 말하는 걸까, 아니면 네가 말한 ‘역할’에 가까울까?”
“역할이지!” 솔라는 즉시 대답했다. “자동차도 판매할 수 있고, 인형도, 심지어 컴퓨터도 판매할 수 있으니까. ‘판매 가능함’은 여러 종류의 객체가 공통으로 가질 수 있는 역할이야.”
“좋아. 그럼 이 ‘판매할 수 있는 역할’을 코드로 어떻게 표현할래? Robot 클래스에 getPrice() 같은 메서드를 추가해야 할 텐데, 이 역할을 필요로 하는 모든 클래스에 일일이 같은 메서드를 추가하는 건 비효율적이잖아. 그리고 어떤 객체가 판매 가능한지 아닌지 구분할 기준도 필요하고.”
솔라는 다시 고민에 빠졌다. 지난번과 똑같은 함정이었다. Sellable이라는 클래스를 만들어서 Robot이 상속받게 하면 되지 않을까? 하지만 Robot은 이미 Machine을 상속받고 있다. class Robot extends Machine, Sellable… 또다시 붉은 밑줄이 그어질 게 뻔했다.
“결국 똑같은 문제네. 상속으로는 하나의 종류만 표현할 수 있으니까, 역할을 추가할 방법이 없어.”
솔라의 목소리에 실망감이 묻어났다. 바로 그때, 루나가 솔라의 노트북에 새로운 파일을 열고 다른 종류의 코드를 보여주었다.
interface Sellable {
int getPrice();
String getBarcode();
}
솔라는 눈을 가늘게 떴다. class가 아니라 interface라는 낯선 키워드가 보였다. 그리고 더 이상한 점이 있었다. 메서드 뒤에 {} 구현부가 전혀 없었다. 그냥 이름과 괄호, 세미콜론이 전부였다.
“이게 뭐야? getPrice()가 뭘 하는지, getBarcode()가 뭘 반환하는지 아무 내용이 없잖아. 텅 비어있네. 이건 미완성 설계도 아니야? 이걸로 뭘 할 수 있는데?”
솔라의 반응은 당연했다. 구체적인 동작이 정의되지 않은 코드는 그저 공허한 약속처럼 보였다. 기능을 물려받기는커녕, 아무 기능도 없는 껍데기 같았다.
루나는 고개를 끄덕였다.
“맞아, 텅 비어있어. 그게 핵심이야. 인터페이스는 기능을 물려주기 위한 설계도가 아니거든. 이건 ‘역할 수행에 대한 계약서’야.”
“계약서?”
“응. Sellable이라는 계약서에는 두 가지 조항이 적혀 있는 거야. ‘가격을 알려줄 것 (getPrice)’, 그리고 ‘바코드를 알려줄 것 (getBarcode)’. 이 계약서에 서명하는 클래스는, 이 두 가지를 반드시 수행하겠다고 약속하는 거지.”
루나는 Robot 클래스를 수정하기 시작했다. extends와는 다른, implements라는 새로운 키워드를 사용했다.
class Robot extends Machine implements Sellable {
@Override
public int getPrice() {
return 10000; // 로봇의 가격을 구현
}
@Override
public String getBarcode() {
return "880123456789"; // 로봇의 바코드를 구현
}
// Machine으로부터 물려받은 turnOn() 메서드는 그대로 있음
}
코드를 보자 솔라의 눈이 동그래졌다. extends Machine으로 ‘기계’라는 종류를 정의하면서, 동시에 implements Sellable로 ‘판매 가능한’ 역할을 부여했다. 그리고 Sellable 인터페이스에 적혀만 있던 getPrice()와 getBarcode() 메서드를 Robot 클래스 안에서 직접 구현하고 있었다. 만약 둘 중 하나라도 구현하지 않으면, 컴파일러가 마치 계약 위반을 잡아내는 변호사처럼 오류를 띄웠다.
“아…! interface는 기능을 주는 게 아니라, ‘이런 기능들을 반드시 네가 직접 만들어야 한다’고 강제하는 거구나. 약속을 하도록 만드는 거네.”
“정확해. 그리고 한 클래스는 여러 계약서에 동시에 서명할 수 있어. 예를 들어 ‘운전할 수 있음’을 정의하는 Drivable 인터페이스가 있다면, Car 클래스는 이렇게 만들 수 있지.”
루나가 또 다른 예시를 보여주었다.
class Vehicle {
// 차량의 공통 기능
}
interface Drivable {
void drive();
}
interface Sellable {
int getPrice();
}
class Car extends Vehicle implements Drivable, Sellable {
@Override
public void drive() {
System.out.println("차가 달립니다.");
}
@Override
public int getPrice() {
return 20000000;
}
}
이제 모든 조각이 맞춰지는 기분이었다. Car는 Vehicle의 한 ‘종류’다. 그래서 extends로 상속받는다. 동시에 ‘운전 가능한’ 역할과 ‘판매 가능한’ 역할을 수행한다. 그래서 쉼표로 구분하여 여러 인터페이스를 implements로 구현할 수 있다. 다중 상속의 모호함 없이, 객체의 정체성과 역할을 명확하게 구분한 것이다.
솔라는 자신이 처음 가졌던 의문, ‘여러 곳에서 기능을 물려받으면 더 좋은 거 아니야?’가 얼마나 순진한 생각이었는지 깨달았다. 자바는 그보다 훨씬 정교한 방법을 준비해두고 있었다.
“그렇구나. extends는 객체가 태생적으로 어떤 ‘종류’인지 정하는 거고, implements는 그 객체가 후천적으로 어떤 ‘역할’들을 수행할 수 있는지 명찰처럼 붙여주는 거구나. 그래서 종류는 단 하나지만, 역할은 여러 개가 될 수 있는 거였어.”
이제 인터페이스의 텅 빈 메서드가 더는 불완전하게 보이지 않았다. 그것은 무엇이든 채워 넣을 수 있는, 유연하고 강력한 ‘역할에 대한 약속’의 목록이었다. 하지만 솔라의 머릿속에 새로운 궁금증이 피어올랐다.
“알겠어. ‘종류’는 상속으로, ‘역할’은 인터페이스로. 그런데… 언제는 역할을 클래스로 만들고 싶고, 언제는 인터페이스로 만들어야 하는지 그 경계가 조금 애매한 것 같아. 예를 들어 Drivable을 ‘운전 능력이 있는 것’이라는 부모 클래스로 생각할 수도 있지 않을까? 뭘 기준으로 extends와 implements를 선택해야 하는 거지?”
3장: 종류(extends)와 역할(implements) 구분하기
솔라는 자신이 짰던 Car 클래스 코드를 가만히 들여다보고 있었다. class Car extends Vehicle implements Drivable, Sellable. 코드는 완벽하게 작동했다. extends로 ‘종류’를 정하고, implements로 ‘역할’을 부여한다는 개념도 머릿속에 자리 잡았다. 하지만 마음 한구석의 안개가 완전히 걷히지는 않았다.
‘운전할 수 있는 능력’을 뜻하는 Drivable을 왜 꼭 인터페이스로 만들어야 했을까? ‘운전 가능한 것’이라는 상위 개념으로 보고, Drivable이라는 부모 클래스를 만들어서 Car가 상속하게 할 수도 있지 않았을까? 물론 자바는 다중 상속이 안 되니 Vehicle과 Drivable을 동시에 상속할 순 없지만, 그게 유일한 이유일까? extends와 implements를 가르는 더 근본적인 기준, 설계의 핵심을 꿰뚫는 원칙이 있을 것만 같았다. 그 경계선이 여전히 흐릿했다.
솔라의 고민을 눈치챘는지, 루나는 솔라의 노트북 화면을 닫고 대신 하얀 스케치북을 펼쳤다.
“그 경계선을 한번 직접 그려보자. 동물 세계를 예시로 들어볼까?”
루나는 스케치북 중앙에 동물(Animal)이라는 단어를 쓰고 동그라미를 쳤다.
“모든 동물의 공통 조상이야. 모든 동물은 먹는다는 특징이 있으니 eat() 메서드를 가지고 있겠지.”
그러고는 동물 동그라미 아래로 선을 두 개 내려 개(Dog)와 고양이(Cat)를 그렸다.
“개와 고양이는 동물의 한 종류지? 그러니 extends Animal이 자연스러워. ‘개는 동물이다.’, ‘고양이는 동물이다.’ 문장이 성립하잖아.”
솔라는 고개를 끄덕였다. 여기까지는 명확했다. extends는 ‘~의 한 종류다’라는, 존재의 근본을 규정하는 관계였다.
“자, 이제 여기에 ‘애완동물(Pet)’이라는 개념을 추가해보자.”
루나는 Pet이라는 단어를 스케치북 한쪽에 썼다.
“솔라야, 이 Pet을 어디에 그려 넣어야 할까? 클래스로 만들어서 Dog이나 Cat이 상속하게 해야 할까?”
솔라의 머릿속이 복잡해졌다. Drivable과 똑같은 질문이었다. 애완동물은 주인과 교감하고, 이름을 불리면 반응하는 등의 공통 행동을 가질 수 있다. 그렇다면 Pet을 부모 클래스로 만들고, Dog이 Pet을 상속하는 것도 말이 되는 것 같았다.
“class Dog extends Pet…?”
솔라가 조심스럽게 입을 열었지만, 곧바로 스스로의 논리에 구멍을 발견했다.
“아니, 잠깐. ‘개는 애완동물이다’는 항상 참이 아니잖아. 세상에는 야생 개도 있으니까. 그리고 Dog은 Animal도 상속해야 하는데… class Dog extends Animal, Pet은 다중 상속이라 안 되고. 그럼 Pet이 Animal을 상속하고, Dog이 Pet을 상속하는 계층 구조는 어떨까? Animal → Pet → Dog?”
솔라는 스케치북에 새로운 계층도를 그려보려다 펜을 멈췄다.
“이것도 이상해. 이 구조대로라면 모든 애완동물은 동물이지만, 세상의 모든 동물이 애완동물인 건 아니잖아. 그런데 Dog은 무조건 Pet을 거쳐야만 Animal의 자식이 될 수 있어. 이 설계는 야생 개의 존재를 설명할 수 없게 돼.”
바로 그 지점이었다. 루나는 솔라가 스스로 막다른 길에 다다를 때까지 기다려주었다.
“맞아. ‘애완동물’이라는 건 개의 ‘종류’가 아니기 때문이야. 그건 개가 가질 수 있는 여러 ‘역할’ 중 하나일 뿐이지. 어떤 개는 ‘애완동물’의 역할을 하고, 어떤 개는 ‘경찰견’의 역할을, 또 다른 개는 ‘맹인 안내견’의 역할을 할 수 있잖아.”
그 순간, 솔라의 머릿속을 가리고 있던 안개가 걷히는 기분이었다.
“아…! ‘~이다(is-a)’ 와 ‘~을 할 수 있다(can-do)’ 의 차이구나!”
솔라는 스케치북에 새로 깨달은 것을 적어 내려갔다.
extends→ is-a 관계:Dog is an Animal(개는 동물의 한 종류이다). 근본적인 정체성, 종류를 나타낸다. 변경될 수 없는 관계.implements→ can-do 관계:Dog can do a Pet's role(개는 애완동물의 역할을 할 수 있다). 특정 기능이나 역할을 수행할 수 있는 능력을 나타낸다. 붙였다 뗐다 할 수 있는 명찰 같은 것.
이 기준으로 보니 모든 것이 명확해졌다. Pet은 클래스가 아니라 인터페이스가 되어야 했다.
// is-a 관계: 종류
abstract class Animal {
abstract void cry();
}
class Dog extends Animal {
@Override
void cry() {
System.out.println("멍멍!");
}
}
// can-do 관계: 역할
interface Pet {
void beFriendly();
}
// '개'라는 종류이면서, '애완동물'의 역할을 수행하는 클래스
class PetDog extends Dog implements Pet {
@Override
public void beFriendly() {
System.out.println("꼬리를 흔듭니다.");
}
}
솔라는 직접 코드를 작성해보았다. PetDog은 Dog의 한 종류이므로 extends Dog을 사용해 cry() 메서드를 물려받았다. 동시에 Pet의 역할을 수행하기로 계약했으므로 implements Pet을 사용하고, beFriendly() 메서드를 스스로 구현했다. 한 클래스가 상속과 인터페이스 구현을 함께 사용하는 완벽한 예시였다. extends로 정해진 종류는 단 하나지만, implements로 부여받는 역할은 얼마든지 추가할 수 있었다. implements Pet, GuideDog, PoliceDog… 처럼 말이다.
이제 솔라는 자신이 처음 가졌던 질문으로 돌아갔다. ‘장난감 자동차’.
망설임 없이, 솔라는 노트북을 열고 새로운 클래스 파일을 만들었다. 이전과는 비교할 수 없는 확신에 찬 손놀림이었다.
// is-a: 자동차는 '차량'의 한 종류이다.
class Vehicle {
void moveForward() {
System.out.println("앞으로 전진합니다.");
}
}
class Car extends Vehicle {
// Car 고유의 기능들...
}
// can-do: '가지고 놀 수 있는' 역할을 정의한다.
interface Playable {
void play();
}
// ToyCar는 Car의 한 종류(is-a)이면서,
// Playable한 역할(can-do)을 수행한다.
class ToyCar extends Car implements Playable {
@Override
public void play() {
System.out.println("부릉부릉~ 자동차를 가지고 놉니다.");
}
}
코드를 완성한 솔라는 만족스러운 미소를 지었다. 더 이상 extends와 implements 사이에서 혼란스러워하지 않았다. 객체의 근본적인 정체성, 즉 ‘종류’는 extends로 깊고 강하게 연결하고, 객체가 수행할 수 있는 다채로운 능력, 즉 ‘역할’은 implements로 유연하게 부여하면 된다는 명확한 설계 원칙이 생긴 것이다. ‘is-a’와 ‘can-do’. 이 두 가지 질문만 던지면 앞으로 어떤 객체를 만나든 그 관계를 명확하게 꿰뚫어 볼 수 있을 것 같았다.