들어가기에 앞서, 이 문서는 추상 클래스와 인터페이스의 기본적인 설명은 하지 않는다는 점을 알려드립니다.
제가 느끼기에 추상 클래스와 인터페이스는 문법적인 이해는 쉬운데 반해,
실무에서 직관적으로 적용하기 어려웠습니다.
따라서 문법을 설명하기보다는 실제 적용에 관해 이야기하는 편이 유익하다고 판단했습니다.
"이걸 왜 쓰지?"
추상 클래스와 인터페이스의 사용법을 배운 다음 개발에 들어갔을 때 들었던 생각입니다.
쓸 일이 없다는 생각이 들었습니다.
클래스 A, 클래스 B, C, D, E ...
의존성 주입 Dependency Injection의 무한 반복.
비교적 작은 프로그램이었고, 복잡하지 않았습니다.
이것만으로도 제 개발은 충분했습니다.
그럼 프로그램이 크고 복잡해야만 인터페이스가 효율을 내는 걸까요?
인터페이스는 객체지향의 목적을 상기시켜줍니다.
객체지향은 코드가 어때야 한다고 주장하나요?
클린 코드, 오브젝트, 객체지향의 사실과 오해 등의 책을 읽어보고 추려낸 핵심은 아래와 같습니다.
"결합도Coupling는 낮추고, 응집도Cohesion는 높인다."
"모듈은 재사용 가능하게 하고, 심플하게 유지한다."
인터페이스를 잘 사용하면, 객체 지향의 핵심 아이디어를 활용하게 되고
자연스럽게 위의 두 문장을 만족하는 프로그래밍을 할 수 있게 됩니다.
유용한 디자인 패턴도 인터페이스를 자유자재로 사용해야 이해하고 활용할 수 있습니다.
인터페이스와 추상클래스를 구분하는 것이 이 글의 한 가지 목표이지만
이에 더해서 제가 어떻게 인터페이스의 유용성을 납득했는지에 대해 서술하겠습니다.
최종적으로는 인터페이스를 통해 어떻게 객체지향의 목표를 달성할 수 있는지 이야기하려 합니다.
조금만 알면 더 모르게 되는 인터페이스와 추상클래스
두 가지 사용법을 간단하게 짚어보겠습니다.
- 상속 클래스에 대해 메서드 구현이 동일한 경우 추상 클래스를 사용합니다.
- 상속 클래스가 뒤따르는 구현이 다르거나 정의되지 않은 경우 인터페이스를 사용합니다.
이런 설명이 저는 와닿지 않았습니다.
왜 써야 하는지 정확히 납득이 안 되었습니다.
인터페이스를 사용하겠다 마음먹고 설계하지 않는 이상 인터페이스를 기피하게 되었습니다.
줄곧 "더 큰 구조의 프로그램에서나 쓰이겠거니" 하고 넘어갔습니다.
제가 진행했던 프로젝트들은 결국 그렇게 거대해질수록 관리하기 어려운 코드들로 가득해졌습니다.
마음먹고 설계한 다음에 프로그램을 짜야 인터페이스를 사용할 수 있다는 주장은 맞는 말일 수 있습니다.
하지만 설계를 하지 않았다고 해서 인터페이스를 쓰지 못하는 것은 아닙니다.
이미 쓰여진 코드에서 공통부분을 '뽑아내는' 방법이 있기 때문입니다.
이를 인터페이스 추출 Extract Interface Refactoring이라 합니다.
둘의 구분
아무 생각 없이 프로그래밍을 시작하면 인터페이스와 추상 클래스에 손이 잘 안 갑니다.
인터페이스와 추상 클래스는 다분히 설계와 관련되어 있으므로
설계가 허술한 코드에는 바로 사용하기 어렵습니다.
인터페이스는 논리정연한 생각과 함께 빛을 발합니다.
프로그램의 최종 동작은 결국 일반 클래스에 기술되고 그 인스턴스가 수행하다보니,
우리의 머릿속도 객체와 인스턴스를 구현하는 일만으로 가득 찹니다.
인터페이스와 추상 클래스는 말 그대로 더 추상적인 계층이고, 설계에 관련된 개념입니다.
그러니 우리가 설계를 소홀히 할수록 그들과는 멀어지게 됩니다.
우선 추상클래스와 인터페이스는 문법적, 의미적으로 구분해서 사용합니다.
문법과 의미, 이 두 가지가 별개는 아닙니다.
문법적 구분을 인지하고 있으면 의미적 구분도 이해하게 됩니다.
어찌 보면 당연한 문법적인 구분은 이렇습니다.
- 추상클래스는 클래스입니다. (단일 상속)
- 인터페이스는 클래스가 아닙니다. (다중 상속)
결국 이런 문법적 특성으로 인해서 인터페이스 명칭을 어떻게 지어야 하는지,
소프트웨어 구조를 어떻게 짜야 하는지가 모두 정해집니다.
어떻게 그게 가능한지 왜 그런지를 알아가 봅시다.
추상 클래스는 이럴 때
우리는 클래스 간 상속 관계를 정의할 때, 다음과 같은 문장이 만족되는지 체크해봐야 합니다.
"자식클래스A 는 부모클래스B 이다." (is-a-relationship)
가능하다면 "고양이는 동물이다"처럼 집합의 위계가 구분되도록 합니다.
이는 클래스의 '단일 상속'이라는 특성으로 인해 자리잡혔습니다.
다중 상속이 가능할 때 생기는 문제들이 여러 가지가 있는데, 그것을 비유적으로 설명해보겠습니다.
- "A는 B이기도 하고 C이기도 하다." 동시에 "C는 D인데 E이기도 하고"
... 복잡합니다.
그래서 A가 무엇이란 말입니까?
A가 수행하는 일이 무엇인지 알 수 없다. 모호성 Inheritance Ambiguity이라 합니다.
그래서 다중 상속은 컴파일 단에서 애초에 막혀 있는 경우가 많습니다.
다중 상속을 허용하는 언어도 있지만, 좋지 않은 습관이라고 권장하지 않습니다.
반면 "새는 비행하는 생물이다"와 같은 문장은 "새는 비행할 수 있다"로 바꿔적을 수 있으므로 인터페이스가 적절합니다.
추상 클래스는 클래스입니다.
구현이 없는 추상 메소드를 사용하지만 여전히 '구체적인 구현도 가능'한 혼종 '클래스'입니다.
이것이 의미하는 바가 무엇이라고 생각하시나요?
첫째로, 구체적인 구현만 할 생각이라면, 일반 클래스를 사용해야 합니다.
둘째로, 추상 메소드만 사용할 거라면, 인터페이스를 고려해봐야 합니다.
추상 메소드와 일반 메소드를 섞어 쓰는 게 향후에 효과적일지 생각해 보세요.
불필요하고 지저분한 상속은 결합도를 높이기 때문에 사용에 있어서 주의를 요합니다.
결합도가 높아짐은 객체지향 설계 원칙을 위배한다는 의미이고, 문제를 일으킬 가능성을 내포합니다.
인터페이스는 이럴 때
인터페이스는 '객체가 해낼 수 있는 능력'을 의미하며 '그 능력을 사용할 수 있음'을 보장할 때 쓰입니다.
그러니 "A는 B를 할 수 있다"라는 문장을 성립시킵니다. (it does support)
이는 인터페이스의 '다중 상속'이라는 특성으로 인해 자리잡혔습니다.
"A는 B, C, D, E를 할 수 있다"와 같이 기술하는 상황이 자주 나오기 때문입니다.
달랑 "A는 B를 할 수 있다"로 끝나기 보다는요.
반면 "삼각형은 다각형이 될 수 있다"와 같은 문장은 클래스 혹은 추상 클래스를 고려해야 합니다.
"삼각형은 다각형이다"로 정리되어야 할 문장입니다.
당연히도 객체는 여러 능력을 가질 수 있습니다.
개발이 진행되다보면 객체의 능력은 점점 다양해집니다.
한 객체가 여러 가지 능력을 가지는 케이스, 여러 객체가 여러 능력을 가지는 케이스를 떠올려 봅시다.
그 능력들은 중복되기 시작합니다.
게다가 어느 순간부터는 하나하나가 무겁고 복잡해서 해체하고 정리해줘야 할 것입니다.
이런 문제를 클래스의 단일 상속만으로는 해결하기 어렵습니다.
여기서 인터페이스가 필요해집니다.
예를 들어 보겠습니다.
추상 메소드는 자식 클래스에서 구현을 다시 해줘야 한다는 사실을 알고 계실 것입니다.
"비행할 수 있음"이라는 인터페이스를 만들고
그 안에 "날다" 메소드를 선언했다고 합시다.
그 인터페이스는 헬리콥터, 비행기, 참새가 구현합니다.
비행 방식이 다르기 때문에 모두 재정의해주는 것이 필수적입니다.
우리는 헬리콥터, 비행기, 참새 객체를 사용해야겠다고 생각한 순간부터 인터페이스를 떠올려야 합니다.
다르게 말하면, '인터페이스를 사용한다는 것'은 일종의 계약입니다. 인터페이스를 설명하는 많은 개발자들이 계약Contract이라는 단어를 사용합니다.
"이 오브젝트는 이러한 동작을 수행할 수 있습니다."
특정 인터페이스를 사용하기로 약속한 개발 팀원들(또는 코드를 까먹을 미래의 나)은
인터페이스에 적힌 계약 내용을 이해하고, 준수합니다.
인터페이스의 메소드는 클래스에서 구현이 강제되는데,
바로 이렇게 계약을 준수하는 것입니다.
인터페이스는 이러한 방식으로 생산성을 높여줍니다.
또한 인터페이스가 붙은 클래스는 사용하기 쉽습니다.
팀원들은 당신이 작성한 클래스의 모든 메소드를 알지 못하더라도
당신이 사용한 인터페이스의 선언부만 읽어 본다면 당신의 코드를 활용할 수 있습니다.
이것이 인터페이스가 인터페이스라는 이름으로 불리는 이유입니다.
인터페이스 사용례
게시자 Hesham Massoud
첫 번째 예시는 스택오버플로우 답변입니다.
"Programming to Interface"라는 표현의 의미를 질문한 사람에게
팩토리 디자인 패턴의 예시를 가져와 인터페이스 사용의 효과를 설명하고 있습니다.
추상 클래스의 용례도 함께 보여주고 있습니다.
천천히 살펴봅시다.
이 프로그램은 '언어'를 정하면, 해당 언어를 쓸 수 있는 '화자'를 생성합니다.
첫 번째로, 사용할 수 있는 언어 목록 Enum이 있습니다.
두 번째로, ISpeaker 인터페이스가 있습니다. 이 인터페이스는 "사람은 말할 수 있다"라는 문장을 만들어 줍니다.
세 번째로, 세 종류의 사람(클래스) ; EnglishSpeaker, GermanSpeaker, SpanishSpeaker가 있습니다.
네 번째로, 사람들 클래스를 인스턴스화하는 SpeakerFactory가 있습니다.
//----- 말할 수 있는 언어 목록 -----
public enum Language
{
English, German, Spanish
}
//---- '말할 수 있는 사람'을 만들어주는 팩토리 클래스 ----
public class SpeakerFactory
{
//---- 리턴이 ISpeaker인 메소드. 매개변수로 언어를 받습니다. ----
public static ISpeaker CreateSpeaker(Language language)
{
//---- 각 언어에 맞는 화자를 생성합니다. ----
switch (language)
{
case Language.English:
return new EnglishSpeaker();
case Language.German:
return new GermanSpeaker();
case Language.Spanish:
return new SpanishSpeaker();
default:
throw new ApplicationException("No speaker can speak such language");
}
}
}
[STAThread]
static void Main()
{
// ---- 당신은 이 화자가 누가 됐든, Speak() 할 줄 안다는 것을 압니다.
// ---- ISpeaker에 Speak() 메소드가 선언되어 있기 때문입니다.
ISpeaker speaker = SpeakerFactory.CreateSpeaker(Language.English);
speaker.Speak();
Console.ReadLine();
}
// ---- 인터페이스에 Speak()가 선언되어 있습니다. ----
public interface ISpeaker
{
void Speak();
}
// ---- 영어 화자는 Speak를 시키면 영어로 말합니다. ----
public class EnglishSpeaker : ISpeaker
{
public EnglishSpeaker() { }
public void Speak()
{
Console.WriteLine("I speak English.");
}
}
// ---- 독일어 화자는 Speak를 시키면 독일어로 말합니다. ----
public class GermanSpeaker : ISpeaker
{
public GermanSpeaker() { }
public void Speak()
{
Console.WriteLine("I speak German.");
}
}
// ---- 스페인어 화자는 Speak를 시키면 스페인어로 말합니다. ----
public class SpanishSpeaker : ISpeaker
{
public SpanishSpeaker() { }
public void Speak()
{
Console.WriteLine("I speak Spanish.");
}
}
이 예제에서 인터페이스는 SpeakerFactory 클래스로 인해 유용해졌습니다.
만약 ISpeaker가 없었다면 세 명의 화자에게 다음과 같은 방식으로 일을 시켜야 했을 것입니다.
EnglishSpeaker englishSpeaker = new EnglishSpeaker();
englishSpeaker.speak();
GermanSpeaker germanSpeaker = new GermanSpeaker();
germanSpeaker.speak();
SpanishSpeaker spanishSpeaker = new SpanishSpeaker();
spanishSpeaker.speak();
이렇게 객체를 수동으로 생성하게 되면 당신의 코드는
"통합할 수 있는 일을 매번 반복" 하고 있다고 할 수 있습니다.
DRY 원칙을 어기는 것입니다.
또다른 문제점을 들어 보겠습니다.
만약 Speaker가 100 종류 존재하고, 100명의 프로그래머가 Speaker를 짠다고 생각해 봅시다.
ISpeaker라는 '계약'이 없이 말이죠.
우리는 약속을 어기고 다음과 같이 프로그램을 서서히 망가뜨리게 됩니다.
- 추가된 KoreanSpeaker 클래스에서 깜빡하고 Speak()를 빼먹습니다.
- KoreanSpeaker를 추가했는데 SpeakKorean()이라고 제멋대로 메소드명을 짓습니다.
- void여야 할 리턴 타입이 KoreanSpeaker 혼자만 string으로 되어 있습니다.
- JapaneseSpeaker를 담당한 개발자는 또 어떤 창의적인 방법으로 클래스를 짰을지 모릅니다.
ISpeaker speaker; 로 만들어진 화자 인스턴스는, 안전하게 언어를 골라 화자를 할당할 수 있고,
ISpeaker를 사용하는 새로운 메소드를 만들어서 응용할 수도 있습니다.
게시자 Hesham Massoud는 위 코드를 확장하는 과정을 예시로 추가했습니다.
위의 예제를 업데이트하고 추상 Speaker기본 클래스를 추가했습니다.
이번 업데이트에서는 모든 스피커에 "SayHello" 기능을 추가했습니다.
클라이언트 코드(메인함수), SpeakerFactory, ISpeaker는 변경하지 않았습니다.
이것이 우리가 Programming-to-Interface 를 통해 달성한 것 입니다.
이것보다 더 큰 확장이 필요하더라도, 여러분의 코드는 망가지지 않을 것입니다.
인터페이스를 활용한 팩토리 패턴이 구조를 간단명료하게 잡아주고 있기에 가능한 일입니다.
아래의 코드 블록은 확장에 대한 부분만 발췌했습니다.
나머지는 그대로 두어도 아무런 수정 없이 멀쩡하게 돌아갑니다.
// 추가된 추상 클래스입니다.
public abstract class Speaker : ISpeaker
{
public abstract void Speak();
// SayHello()를 사용하면 Hello World라고 말합니다.
// virtual이므로 자식은 이 메소드를 재정의해도 되고 안 해도 됩니다.
// abstract였다면 반드시 재정의해야 합니다.
public virtual void SayHello()
{
Console.WriteLine("Hello world.");
}
}
public class EnglishSpeaker : Speaker
{
public EnglishSpeaker() { }
// 영어 화자는 인사를 한 다음 자기소개를 합니다.
public override void Speak()
{
this.SayHello();
Console.WriteLine("I speak English.");
}
// 영어 화자는 SayHello를 재정의하지 않습니다.
}
public class GermanSpeaker : Speaker
{
public GermanSpeaker() { }
// 독일어 화자는 자기소개를 먼저 하고 그 다음에 인사를 합니다.
public override void Speak()
{
Console.WriteLine("I speak German.");
this.SayHello();
}
// 독일어 화자는 SayHello()를 재정의하지 않네요.
}
public class SpanishSpeaker : Speaker
{
public SpanishSpeaker() { }
// 스페인어 화자는 Speak()를 시켰다고 인사를 하지는 않습니다.
public override void Speak()
{
Console.WriteLine("I speak Spanish.");
}
// 스페인어 화자는 인사를 시키면 에러를 일으키네요.
public override void SayHello()
{
throw new ApplicationException("I cannot say Hello World.");
}
}
이 코드는 여러 가지 면에서 객체지향적입니다.
- 캡슐화 : IDE에서 speaker는 Speak(), SayHello()만 보여줄 것입니다. 복잡한 내용은 감추고 사용법만 노출했습니다.
- 추상화 : 말할 수 있는 기능을 분리하여 하나의 계층으로 삼았습니다.
- 다형성 : Speak() 메소드 하나만으로 객체들은 자신이 해야 할 일을 수행하고 있습니다.
- DRY, KISS, SOLID 원칙들을 비추어 보아도 문제가 없습니다.
추상 클래스 사용례
템플릿-메소드 패턴은 추상 클래스의 특징을 잘 활용합니다.
추상 클래스는 추상 메소드와 일반 메소드를 모두 작성할 수 있는데,
이런 특징을 통해 효과를 볼 수 있는 패턴이 템플릿-메소드 패턴입니다.
//추상 클래스 선생님
abstract class Teacher{
public void startClass() {
getInside();
attendance();
teach();
}
// 재정의할 필요가 없는 공통 메서드입니다. 필요한 경우 재정의 가능합니다.
public void getInside() {
System.out.println("교실로 들어갑니다.");
}
public void attendance() {
System.out.println("선생님이 출석을 부릅니다.");
}
// 추상 메소드 : 이 메소드는 자식에서 반드시 구현해야 합니다.
abstract void teach();
}
// 국어 선생님
class KoreanTeacher extends Teacher{
// teach()만 재정의해주면 startClass()로 수업을 진행할 수 있습니다.
@Override
public void teach() {
System.out.println("선생님이 국어를 수업합니다.");
}
}
//수학 선생님
class MathTeacher extends Teacher{
@Override
public void teach() {
System.out.println("선생님이 수학을 수업합니다.");
}
}
public class Main {
public static void main(String[] args) {
KoreanTeacher kr = new KoreanTeacher(); //국어 선생님 생성
MathTeacher mt = new MathTeacher(); //수학 선생님 생성
kr.startClass(); // 한 번의 메소드 호출로 모든 동작 수행
mt.startClass();
}
}
getInside(), attendance() 처럼 '변경이 필요 없는 행동'이자 '공통적인 행동'을 선생님 클래스마다
반복적으로 정의하는 것이 아니라, 부모 클래스에 딱 한 번 일반 메소드로 정의해두었습니다.
템플릿 메소드를 정확하게 이용하려면 어떤 기능이 자주 변경될지, 변하지 않을지를 예상해야합니다.
- 변하지 않는 기능은 부모 클래스에 만듭니다. ( 다시 작성할 필요 없음 )
- 자주 변하는 기능은 자식 클래스에 만듭니다. ( 바뀌는 부분을 자식에 맡겨, 사이드이펙트 전파를 막음 )
추상 메소드가 있기 때문에 잊지 않고 재정의할 수 있습니다.
이런 부분이 일반 클래스의 상속과 구분되는 특징입니다.
댓글