상속은 공통 코드를 부모 클래스에 모으는 방법이다
객체 지향 프로그램을 만들다 보면 여러 클래스가 비슷한 필드와 메서드를 갖는 경우가 생깁니다. 예를 들어 일반 회원, VIP 회원, 직원 회원은 모두 이름을 가지고 있고 할인율을 계산할 수 있습니다. 차이는 할인율의 세부 규칙입니다.
이때 공통되는 부분을 부모 클래스에 두고, 차이가 나는 부분만 자식 클래스에서 작성할 수 있습니다. 이것을 상속이라고 합니다.
상속 관계는 보통 "A는 B이다"라고 말할 수 있을 때 자연스럽습니다. VipMember는 Member라고 말할 수 있으므로 상속이 어울립니다.
아래 예제들은 한 파일에 붙여 넣어 실행하기 쉽도록 실행 코드를 먼저 쓰고, 클래스 정의를 아래에 두는 형태로 보여 드리겠습니다. 이미 Main 메서드 안에서 연습하고 있다면 실행 부분만 Main 안에 넣으면 됩니다.
VipMember vip = new VipMember("민서");
vip.PrintName();
vip.UseVipLounge();
class Member
{
public string Name { get; }
public Member(string name)
{
Name = name;
}
public void PrintName()
{
Console.WriteLine($"회원 이름: {Name}");
}
}
class VipMember : Member
{
public VipMember(string name) : base(name)
{
}
public void UseVipLounge()
{
Console.WriteLine($"{Name}님이 VIP 라운지를 이용합니다.");
}
}실행 결과는 다음과 같습니다.
회원 이름: 민서
민서님이 VIP 라운지를 이용합니다.class VipMember : Member는 VipMember가 Member를 상속한다는 뜻입니다. Member를 부모 클래스 또는 기반 클래스라고 부르고, VipMember를 자식 클래스 또는 파생 클래스라고 부릅니다.
base(name)은 부모 클래스의 생성자를 호출합니다. 자식 객체가 만들어질 때 부모 부분도 함께 초기화되어야 하기 때문입니다.
상속을 사용하면 공통 코드를 한곳에 모을 수 있습니다. 이름을 출력하는 방식이 바뀐다면 Member의 PrintName만 수정하면 됩니다.
AI 생성 이미지
virtual과 override로 동작을 바꾸기
상속의 진짜 힘은 공통 구조를 유지하면서 일부 동작을 다르게 만들 때 나타납니다. 부모 클래스에 기본 동작을 만들고, 자식 클래스가 필요할 때 그 동작을 다시 정의할 수 있습니다.
C#에서는 부모 클래스의 메서드 앞에 virtual을 붙이면 자식 클래스에서 재정의할 수 있습니다. 자식 클래스에서는 override를 붙여 실제 동작을 바꿉니다.
Member normal = new Member("일반 회원");
Member vip = new VipMember("VIP 회원");
Member employee = new EmployeeMember("직원 회원");
normal.PrintDiscount();
vip.PrintDiscount();
employee.PrintDiscount();
class Member
{
public string Name { get; }
public Member(string name)
{
Name = name;
}
public virtual decimal GetDiscountRate()
{
return 0.01m;
}
public void PrintDiscount()
{
Console.WriteLine($"{Name}: {GetDiscountRate() * 100:0}% 할인");
}
}
class VipMember : Member
{
public VipMember(string name) : base(name)
{
}
public override decimal GetDiscountRate()
{
return 0.10m;
}
}
class EmployeeMember : Member
{
public EmployeeMember(string name) : base(name)
{
}
public override decimal GetDiscountRate()
{
return 0.30m;
}
}실행 결과는 다음과 같습니다.
일반 회원: 1% 할인
VIP 회원: 10% 할인
직원 회원: 30% 할인여기서 중요한 점은 변수의 자료형이 모두 Member라는 것입니다. 그런데 실제로 호출되는 GetDiscountRate는 객체의 실제 종류에 따라 달라집니다.
vip 변수의 자료형은 Member이지만, 실제 객체는 VipMember입니다. 그래서 PrintDiscount 안에서 GetDiscountRate를 호출하면 VipMember의 재정의된 메서드가 실행됩니다.
이처럼 같은 명령을 보냈는데 실제 객체에 따라 다른 동작이 실행되는 성질을 다형성이라고 합니다.
다형성은 같은 코드로 여러 객체를 다루게 한다
다형성을 사용하면 조건문을 줄이고 확장하기 쉬운 코드를 만들 수 있습니다. 회원 목록을 순회하면서 할인율을 출력하는 코드를 보겠습니다.
List<Member> members = new List<Member>
{
new Member("지우"),
new VipMember("하린"),
new EmployeeMember("도윤")
};
foreach (Member member in members)
{
member.PrintDiscount();
}실행 결과는 다음과 같습니다.
지우: 1% 할인
하린: 10% 할인
도윤: 30% 할인반복문은 각 객체가 일반 회원인지, VIP 회원인지, 직원 회원인지 검사하지 않습니다. 그냥 Member로 보고 PrintDiscount를 호출합니다. 할인율 차이는 각 클래스 안에 들어 있습니다.
이 구조의 장점은 새 회원 종류를 추가할 때 분명해집니다. 예를 들어 StudentMember를 추가하려면 새 클래스를 만들고 GetDiscountRate만 재정의하면 됩니다. 기존 반복문은 수정하지 않아도 됩니다.
class StudentMember : Member
{
public StudentMember(string name) : base(name)
{
}
public override decimal GetDiscountRate()
{
return 0.05m;
}
}다형성은 "사용하는 코드"와 "세부 동작"을 분리합니다. 사용하는 코드는 부모 타입을 기준으로 단순하게 작성하고, 세부 차이는 자식 클래스가 책임집니다.
abstract는 반드시 구현해야 하는 동작을 표현한다
부모 클래스가 공통 구조를 제공하지만, 부모 자체로는 완성된 객체가 아닐 때가 있습니다. 예를 들어 Animal은 너무 일반적인 개념입니다. 실제 프로그램에서는 강아지, 고양이처럼 구체적인 동물이 소리를 내야 합니다.
이때 abstract를 사용합니다. abstract class는 직접 객체를 만들 수 없는 클래스입니다. abstract 메서드는 자식 클래스가 반드시 구현해야 하는 메서드입니다. 본문을 작성하지 않고, 자식 클래스에서 override로 구현합니다.
List<Animal> animals = new List<Animal>
{
new Dog("초코"),
new Cat("나비")
};
foreach (Animal animal in animals)
{
animal.Speak();
animal.Sleep();
}
abstract class Animal
{
public string Name { get; }
protected Animal(string name)
{
Name = name;
}
public abstract void Speak();
public void Sleep()
{
Console.WriteLine($"{Name}가 잠을 잡니다.");
}
}
class Dog : Animal
{
public Dog(string name) : base(name)
{
}
public override void Speak()
{
Console.WriteLine($"{Name}: 멍멍");
}
}
class Cat : Animal
{
public Cat(string name) : base(name)
{
}
public override void Speak()
{
Console.WriteLine($"{Name}: 야옹");
}
}실행 결과는 다음과 같습니다.
초코: 멍멍
초코가 잠을 잡니다.
나비: 야옹
나비가 잠을 잡니다.Speak는 동물마다 다르므로 부모 클래스에서 구현하지 않았습니다. 대신 자식 클래스가 반드시 구현하도록 강제했습니다. 반면 Sleep은 모든 동물이 공통으로 사용할 수 있으므로 부모 클래스에 일반 메서드로 두었습니다.
abstract는 "이 클래스는 공통 규칙을 제공하지만, 혼자서는 완성된 객체가 아니다"라는 뜻으로 이해하면 됩니다.
인터페이스는 사용 규칙을 분리한다
상속은 공통 코드와 계층 관계를 표현할 때 유용합니다. 하지만 어떤 클래스들이 같은 부모를 가질 필요는 없고, 단지 같은 기능을 제공하기만 하면 되는 경우도 많습니다.
예를 들어 이메일 알림과 콘솔 알림은 구현 방식이 다릅니다. 하지만 둘 다 "메시지를 보낸다"는 규칙을 만족할 수 있습니다. 이럴 때 인터페이스를 사용합니다.
인터페이스는 클래스가 지켜야 할 사용 규칙입니다. C#에서는 관례적으로 이름 앞에 I를 붙입니다.
SendNotice(new EmailNotifier());
SendNotice(new ConsoleNotifier());
void SendNotice(INotifier notifier)
{
notifier.Send("서비스 점검이 예정되어 있습니다.");
}
interface INotifier
{
void Send(string message);
}
class EmailNotifier : INotifier
{
public void Send(string message)
{
Console.WriteLine($"이메일 발송: {message}");
}
}
class ConsoleNotifier : INotifier
{
public void Send(string message)
{
Console.WriteLine($"콘솔 출력: {message}");
}
}실행 결과는 다음과 같습니다.
이메일 발송: 서비스 점검이 예정되어 있습니다.
콘솔 출력: 서비스 점검이 예정되어 있습니다.SendNotice 메서드는 이메일인지 콘솔인지 알 필요가 없습니다. INotifier라는 규칙만 믿고 Send를 호출합니다. 나중에 문자 메시지 알림을 추가해도 SendNotice를 고칠 필요가 없습니다.
class SmsNotifier : INotifier
{
public void Send(string message)
{
Console.WriteLine($"문자 발송: {message}");
}
}인터페이스는 구현 세부 사항과 사용하는 코드를 분리합니다. 또한 C# 클래스는 부모 클래스를 하나만 상속할 수 있지만, 인터페이스는 여러 개 구현할 수 있습니다. 그래서 여러 종류의 객체가 같은 기능 규칙을 공유해야 할 때 인터페이스가 자주 쓰입니다.
상속보다 조합이 나은 경우도 있다
상속을 배웠다고 해서 모든 관계를 상속으로 만들면 안 됩니다. 상속은 "A는 B이다" 관계일 때 자연스럽습니다. 반대로 "A는 B를 가진다" 관계라면 조합이 더 적절합니다.
예를 들어 자동차는 엔진입니다. 이 문장은 어색합니다. 자동차는 엔진을 가지고 있습니다. 따라서 Car : Engine보다는 Car 안에 Engine을 필드로 두는 편이 자연스럽습니다.
Car car = new Car();
car.Drive();
class Engine
{
public void Start()
{
Console.WriteLine("엔진을 시작합니다.");
}
}
class Car
{
private Engine engine = new Engine();
public void Drive()
{
engine.Start();
Console.WriteLine("자동차가 출발합니다.");
}
}실행 결과는 다음과 같습니다.
엔진을 시작합니다.
자동차가 출발합니다.조합은 필요한 객체를 내부에 가지고 사용합니다. 상속보다 관계가 느슨해서 나중에 부품을 바꾸거나 기능을 나누기 쉽습니다.
간단한 판단 기준은 다음과 같습니다.
"A는 B이다"가 자연스러우면 상속을 검토합니다.
"A는 B를 가진다"가 자연스러우면 조합을 검토합니다.
같은 기능 규칙만 공유하면 인터페이스를 검토합니다.
흔한 실수
첫째, 부모 타입 변수로 자식 객체를 가리킬 때 부모에 없는 멤버를 바로 호출하려는 실수입니다.
Member member = new VipMember("민서");
// member.UseVipLounge(); // 오류: Member 타입에는 UseVipLounge가 없음변수의 자료형이 Member이면 컴파일러는 Member에 정의된 멤버만 사용할 수 있다고 판단합니다. 실제 객체가 VipMember라는 사실은 재정의된 메서드를 호출할 때 영향을 줍니다.
둘째, 재정의하려는 메서드에 virtual을 빼먹는 실수입니다. 부모 메서드가 virtual, abstract, override 중 하나로 열려 있어야 자식 클래스에서 override할 수 있습니다.
셋째, 상속 계층을 너무 깊게 만드는 실수입니다. 부모, 자식, 손자, 그 아래로 계속 이어지면 작은 수정도 전체 구조에 영향을 줄 수 있습니다. 초보 단계에서는 1~2단계 상속만으로도 충분한 경우가 많습니다.
넷째, 단순히 코드를 재사용하려고 상속을 사용하는 실수입니다. 코드 재사용만 목적이라면 조합이나 별도 메서드가 더 단순할 수 있습니다.
작은 연습 문제
Shape라는 추상 클래스를 만들고GetArea()추상 메서드를 작성해 보세요.Circle,Rectangle클래스에서 각각 넓이를 계산하도록 재정의해 보세요.List<Shape>에 원과 사각형 객체를 함께 담고, 반복문으로 모든 도형의 넓이를 출력해 보세요.IPrintable인터페이스를 만들고Print()메서드를 선언해 보세요.Report,Invoice클래스가 이 인터페이스를 구현하도록 작성해 보세요."학생은 사람이다"와 "학생은 가방을 가진다"를 코드 구조로 표현한다면 각각 상속과 조합 중 무엇이 어울리는지 판단해 보세요.
이번 장의 핵심은 하나입니다. 여러 클래스가 공통 규칙을 공유하고 서로 대체될 수 있으면 사용하는 코드는 단순해지고, 새 기능을 추가하기 쉬워집니다. 상속, 다형성, 인터페이스는 그 구조를 만드는 대표적인 도구입니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.