C# 추상 클래스(abstract class)는 공통 코드와 상태를 한곳에 두면서, 파생 클래스가 반드시 구현해야 할 동작까지 정하고 싶을 때 사용해요. 서로 관련 없는 형식에 같은 기능 계약만 붙이려면 interface가 알맞고, 같은 계열의 클래스가 생성자·필드·기본 구현을 공유해야 한다면 추상 클래스가 더 자연스럽습니다.
abstract 클래스는 직접 new할 수 없습니다. 대신 파생 클래스가 공통 구현은 물려받고, 구현이 비어 있는 추상 멤버는 override로 완성합니다. 공통 기반과 구현 강제가 동시에 필요할 때 쓰는 도구라고 생각하면 이해가 쉬워요.
C# abstract는 무엇을 막고 무엇을 허용할까요?
클래스 선언 앞에 abstract를 붙이면 그 클래스는 불완전한 기반 형식이 됩니다. 따라서 new ReportExporter()처럼 직접 객체를 만들 수는 없어요. 그렇다고 내용이 비어 있는 것은 아닙니다. 생성자, 필드, 속성, 일반 메서드, 가상 메서드를 가질 수 있고, 본문이 없는 추상 메서드나 추상 속성도 함께 선언할 수 있습니다.
추상 멤버를 물려받은 구체 클래스는 해당 멤버를 모두 구현해야 합니다. 하나라도 구현하지 않으면 그 파생 클래스 역시 abstract로 선언해야 해요. 이 규칙 덕분에 “이 계열의 클래스라면 내보내기 기능을 반드시 제공해야 한다” 같은 설계 의도를 컴파일 단계에서 지킬 수 있습니다.
실행 가능한 예제로 흐름을 확인해 볼게요
보고서를 파일로 내보내는 기능을 예로 들어보겠습니다. 파일 이름과 로그 출력은 모든 내보내기 클래스가 공유하지만, 실제 변환 방식은 PDF와 CSV가 서로 다릅니다. 이때 공통 부분은 기반 클래스에 구현하고, 달라지는 부분만 추상 메서드로 남겨 둘 수 있어요.
using System;
public abstract class ReportExporter
{
protected ReportExporter(string fileName)
{
FileName = fileName;
}
public string FileName { get; }
public void WriteLog()
{
Console.WriteLine($"{FileName} 내보내기 준비");
}
public abstract byte[] Export();
}
public sealed class PdfReportExporter : ReportExporter
{
public PdfReportExporter(string fileName) : base(fileName) { }
public override byte[] Export()
{
Console.WriteLine("PDF 형식으로 변환");
return Array.Empty<byte>();
}
}
ReportExporter exporter = new PdfReportExporter("sales.pdf");
exporter.WriteLog();
byte[] result = exporter.Export();
ReportExporter는 직접 생성하지 못하지만 변수의 형식으로는 사용할 수 있습니다. 실제 객체는 PdfReportExporter이고, 공통 메서드인 WriteLog()는 기반 클래스의 구현을 그대로 실행합니다. 반면 Export()는 파생 클래스가 자신에게 맞게 구현해요. 새로운 CsvReportExporter를 추가하더라도 생성자와 로그 코드를 되풀이할 필요가 없습니다.
abstract와 virtual은 어떻게 다를까요?
abstract 멤버에는 기본 동작이 없으며, 구체 파생 클래스는 반드시 구현해야 합니다. virtual 멤버에는 기반 클래스가 제공하는 기본 구현이 있고, 파생 클래스는 필요할 때만 재정의합니다. “반드시 각 형식이 자기 방식을 정해야 한다”면 abstract, “기본 동작은 충분하지만 일부 형식만 바꾸면 된다”면 virtual이 맞습니다.
두 경우 모두 파생 클래스에서는 override를 사용합니다. 같은 이름의 메서드를 새로 숨기는 new와는 목적이 달라요. 다형성을 기대하는 코드라면 기반 형식 변수로 호출해도 실제 객체의 재정의된 동작이 실행되도록 override를 써야 합니다.
추상 클래스와 interface 중 무엇을 고르면 될까요?
예전에는 인터페이스를 “구현을 전혀 가질 수 없는 선언 묶음”이라고 설명하는 경우가 많았습니다. 현재 C#의 인터페이스는 기본 구현을 포함할 수 있어 이 설명만으로는 부족해요. 그래도 두 도구의 중심 역할은 여전히 다릅니다.
- 추상 클래스: 같은 계열의 형식이 생성자, 인스턴스 상태, 보호된 멤버와 공통 코드를 공유할 때 적합합니다.
- 인터페이스: 서로 다른 계열의 형식에 “이 기능을 제공한다”는 계약을 붙일 때 적합합니다.
- 상속 개수: 클래스는 직접 기반 클래스를 하나만 가질 수 있지만, 인터페이스는 여러 개를 구현할 수 있습니다.
예를 들어 PdfReportExporter와 CsvReportExporter가 파일 이름, 검증, 로그 처리까지 공유한다면 추상 클래스가 편합니다. 반대로 보고서, 이메일, 프린터처럼 계층이 다른 객체가 모두 “내보낼 수 있음”만 표현해야 한다면 IExportable 같은 인터페이스가 더 유연해요. 둘 중 하나만 고집할 필요도 없습니다. 추상 기반 클래스가 인터페이스를 구현하고, 파생 클래스가 그 기반을 이어받는 설계도 자주 사용합니다.
처음 작성할 때 자주 만나는 오류
추상 메서드를 구현하지 않은 경우
구체 파생 클래스에 override 구현이 빠지면 컴파일 오류가 발생합니다. 아직 구현 시점을 미루고 싶다면 그 파생 클래스도 추상 클래스로 남겨야 해요.
추상 클래스를 직접 생성한 경우
new ReportExporter()는 허용되지 않습니다. new PdfReportExporter(...)처럼 모든 추상 멤버가 구현된 구체 클래스를 생성한 뒤, 필요하면 기반 형식 변수에 담으세요.
abstract와 sealed를 함께 사용한 경우
abstract는 상속을 통해 완성하라는 뜻이고, sealed는 더 이상 상속할 수 없다는 뜻입니다. 방향이 반대이므로 같은 클래스에 함께 붙일 수 없습니다. 다만 예제처럼 완성된 파생 클래스를 sealed로 선언하는 것은 가능합니다.
실무에서 판단하는 간단한 기준
먼저 객체들이 실제로 같은 종류의 계층인지 생각해 보세요. 공통 상태와 동작을 물려주는 관계가 자연스럽고, 일부 동작을 파생 클래스에 강제해야 한다면 추상 클래스가 잘 맞습니다. 기능 계약만 필요하거나 여러 역할을 조합해야 한다면 인터페이스부터 검토하는 편이 안전해요.
추상 클래스는 코드 중복을 줄이는 도구이면서 설계 규칙을 컴파일러가 확인하게 만드는 장치입니다. 다만 공통 코드가 조금 보인다는 이유만으로 성급하게 상속 계층을 만들면 결합이 강해질 수 있어요. “정말 같은 계열인가, 상태를 공유해야 하는가, 구현을 반드시 강제해야 하는가”에 답할 수 있을 때 사용하는 것이 좋습니다.
공식 자료
최초 작성: 2015년 1월 9일 · 내용 및 예제 업데이트: 2026년 8월 24일
0 댓글