[객체지향] 상속과 합성(코드 재사용, 서브타이핑, 유연한 설계)

개요

객체지향 설계의 절반은 코드를 어떻게 재사용할지에 관한 결정이다. 같은 로직이 여러 클래스에 흩어지면 수정할 때마다 모든 곳을 찾아 고쳐야 하고, 한 군데를 빠뜨리면 곧바로 버그가 된다. 그래서 중복을 제거하는 일은 단순한 미관 문제가 아니라 유지보수 비용을 좌우하는 핵심 설계 결정이다.

재사용의 첫 번째 도구로 흔히 상속(inheritance)을 떠올린다. 부모 클래스에 공통 로직을 두고 자식이 물려받으면 코드가 줄어든다. 하지만 상속은 부모와 자식을 컴파일타임에 강하게 묶는다. 자식이 부모의 내부 구현에 의존하기 시작하면 부모의 작은 변경이 자식 전체로 번지고, 정책을 조합해야 하는 순간 클래스 수가 곱셈으로 불어난다. 이른바 클래스 폭발(class explosion)이다.

두 번째 도구는 합성(composition)이다. 기능을 물려받는 대신, 그 기능을 가진 객체를 외부에서 주입받아 위임한다. 합성은 구체 클래스가 아니라 인터페이스에 의존하므로 결합이 느슨하고, 의존성이 런타임에 결정되므로 동적으로 정책을 교체하거나 조립할 수 있다. has-a 관계가 is-a 관계보다 유연한 설계를 가능하게 한다는 격언은 여기서 나온다.

상속을 무조건 배척하자는 뜻은 아니다. 상속에는 장단이 있다. 구현을 재사용하려는 서브클래싱(subclassing)과, 타입 계층을 정의하려는 서브타이핑(subtyping)이다. 서브타이핑은 다형성을 위한 올바른 상속이며 리스코프 치환 원칙(LSP, Liskov Substitution Principle)을 지키는 한 권장된다.

키워드의미
상속 (inheritance)부모 클래스의 구현이나 타입을 자식이 물려받는 재사용 방식
합성 (composition)기능을 가진 객체를 보유하고 위임하는 재사용 방식 (has-a)
클래스 폭발 (class explosion)정책 조합마다 클래스가 곱셈으로 늘어나는 현상
취약한 기반 클래스 (fragile base class)부모 변경이 자식을 예측 불가능하게 깨뜨리는 문제
서브클래싱 (subclassing)구현 재사용을 목적으로 한 상속
서브타이핑 (subtyping)타입 계층 정의를 목적으로 한 상속
리스코프 치환 원칙 (LSP)서브타입은 슈퍼타입을 완전히 대체할 수 있어야 한다는 원칙
의존성 주입 (DI, Dependency Injection)객체가 쓸 의존성을 외부에서 넣어 주는 방식
의존성 역전 원칙 (DIP, Dependency Inversion Principle)고수준 모듈과 저수준 모듈이 모두 추상화에 의존하도록 방향을 뒤집는 원칙

중복 코드와 상속의 유혹

설계의 출발점은 중복이다. 다음 두 세금 클래스는 세율만 다를 뿐 계산 로직이 완전히 같다.

// 로직이 중복된 두 클래스
class RegularTax {
    val percentage = 10
    fun calculate(amount: Int, age: Int): Int {
        if (age <= 20) return 0 // 20세 이하 면제
        return amount * percentage / 100
    }
}
 
class MedicalTax {
    val percentage = 20
    fun calculate(amount: Int, age: Int): Int {
        if (age <= 20) return 0 // 동일 로직 중복
        return amount * percentage / 100
    }
}

만약 면제 기준이 20세에서 19세로 바뀌면 두 클래스를 모두 고쳐야 하고, 한쪽을 놓치면 정합성이 깨진다.

가장 단순한 해결은 타입을 파라미터로 받아 한곳에서 분기하는 것이다.

enum class TaxType { REGULAR, MEDICAL }
 
class TaxCalculator {
    fun calculate(amount: Int, age: Int, taxType: TaxType): Int {
        if (age <= 20) return 0
        return when (taxType) {
            TaxType.REGULAR -> amount * 10 / 100
            TaxType.MEDICAL -> amount * 20 / 100
        }
    }
}

중복은 사라졌지만 세금 종류가 늘 때마다 when 분기가 비대해지고 TaxCalculator 가 모든 세금 정책을 떠안는다. 새 정책이 추가될 때 기존 코드를 수정해야 하므로 개방-폐쇄 원칙(OCP, Open-Closed Principle)에 어긋난다.

상속을 사용한다면 공통 정책은 부모가 갖고, 세부 계산은 자식이 채우는 템플릿 메서드 패턴(template method pattern)으로 해결할 수 있다.

abstract class Tax {
    abstract val percentage: Int
 
    // 공통 템플릿
    fun calculate(amount: Int, age: Int): Int {
        if (age <= 20) return 0
        return performCalculation(amount) // 세부 계산은 자식에 위임
    }
 
    protected abstract fun performCalculation(amount: Int): Int
}
 
class RegularTax : Tax() {
    override val percentage get() = 10
    override fun performCalculation(amount: Int) = amount * percentage / 100
}

새 세금을 추가할 때 부모 코드를 건드리지 않아도 된다는 장점이 분명하다.

상속을 통한 재사용의 위험

캡슐화를 깨뜨리는 강결합

상속이 위험해지는 첫 신호는 자식이 부모의 내부 구현을 호출하기 시작할 때다.

abstract class Tax {
    protected abstract val percentage: Int
 
    // 나이 면제는 부모가 감추고 싶은 내부 규칙이다
    protected fun isTaxExempt(age: Int): Boolean = age <= 20
}
 
class MedicalTax : Tax() {
    override val percentage get() = 20
 
    fun calculate(amount: Int, age: Int): Int {
        if (isTaxExempt(age)) return 0        // 부모의 내부 규칙에 직접 손을 뻗는다
        return amount * percentage / 100
    }
}

상속은 부모의 구현 세부를 자식에게 노출하므로 본질적으로 캡슐화를 약화한다. 자식이 부모의 메서드를 호출하는 순간 부모와 의존성이 생기고, 부모의 변경이 자식에게도 전파될 수 있다. 좋은 상속이라면 자식이 부모의 내부를 몰라도 동작해야 한다.

취약한 기반 클래스

부모의 상태를 자식이 공유하는 구조는 더 위험하다. 부모가 보유한 가변 필드의 의미가 바뀌면 그 필드에 기대던 모든 자식의 결과가 틀어진다. 부모와 자식을 항상 함께 수정해야 하는 상황이 반복되면, 그 계층은 취약한 기반 클래스 문제에 빠진 것이다. 부모의 가변 필드에 자식이 의존하게 만들지 않는 편이 안전하다.

클래스 폭발

상속의 한계가 가장 극적으로 드러나는 지점은 정책 조합이다. 핸드폰 요금제에 기본 정책(일반, 심야)과 부가 정책(세금, 할인)을 조합해야 한다고 하자. 상속만으로 풀면 모든 경우의 수마다 클래스를 만들어야 한다.

// 조합마다 클래스가 곱셈으로 늘어난다
open class RegularPhone
class TaxableRegularPhone : RegularPhone()
class DiscountableRegularPhone : RegularPhone()
class TaxableDiscountableRegularPhone : RegularPhone()
 
open class NightlyPhone
class TaxableNightlyPhone : NightlyPhone()
class DiscountableNightlyPhone : NightlyPhone()
class TaxableDiscountableNightlyPhone : NightlyPhone()
// 정책이 하나 더 늘면 클래스 수가 다시 두 배가 된다

기본 정책 M 종류와 부가 정책 N 종류를 조합하면 클래스가 M 곱하기 N 으로 불어난다. 게다가 조합마다 비슷한 코드가 흩어지면서 중복까지 다시 생긴다. 중복을 없애려고 도입한 상속이 더 큰 중복을 부르는 역설이다.

불필요한 상속과 LSP 위반

코드를 줄이려고 맥락이 다른 클래스를 상속받으면 치명적인 버그가 된다.

// 정산 계산기가 세금 정책을 상속받아 엉뚱하게 동작
class AdjustmentCalculator : Tax() {
    override val percentage get() = 0
    override fun performCalculation(amount: Int) = amount
}
 
val calculator = AdjustmentCalculator()
val result = calculator.calculate(amount = 100, age = 10)
// 나이 제한 정책이 적용되어 0 이 반환되는 버그

AdjustmentCalculatorTax 가 아닌데도 단지 계산 로직이 비슷하다는 이유로 상속받았다. 그 결과 필요 없는 나이 검증까지 물려받아, 정산해야 할 금액이 0으로 사라진다. 이는 리스코프 치환 원칙 위반인 동시에 쓰지 않을 기능까지 떠안았다는 점에서 인터페이스 분리 원칙(ISP, Interface Segregation Principle) 위반이기도 하다.

상속은 강력하지만 강결합이라는 비용을 치른다. 다음 세 조건을 모두 만족할 때만 상속이 안전하다. 첫째 is-a 관계일 것, 둘째 자식이 부모의 내부 구현을 몰라도 될 것, 셋째 부모의 가변 상태에 자식이 의존하지 않을 것이다.

합성을 통한 재사용

위임으로 결합을 끊는다

합성은 기능을 물려받지 않고 그 기능을 가진 객체를 보유한다. 요금 정책을 인터페이스로 추출하고, Phone 이 정책 객체를 보유해 계산을 위임한다.

// 요금 정책 추상화
interface RatePolicy {
    fun calculateFee(phone: Phone): Money
}
 
class Phone(private val ratePolicy: RatePolicy) {
    private val callHistory = mutableListOf<Call>()
 
    fun calls(): List<Call> = callHistory
 
    // 계산을 보유한 정책 객체에 위임
    fun calculateFee(): Money = ratePolicy.calculateFee(this)
}

Phone 은 어떤 정책이 들어오든 calculateFee 메시지만 보낸다. 정책의 구체 타입을 모르므로 새 정책이 추가돼도 Phone 은 변하지 않는다. 상속이 부모의 구현에 의존했다면, 합성은 인터페이스의 공개된 행동에만 의존한다.

데코레이터로 조합한다

부가 정책은 다른 정책을 감싸 실행 순서를 제어하는 데코레이터 패턴(decorator pattern)으로 표현한다.

// 기본 요금 정책
class RegularPolicy(
    private val amount: Money,
    private val seconds: Duration,
) : RatePolicy {
    override fun calculateFee(phone: Phone): Money = phone.calls()
        .map { it.duration.seconds / seconds.seconds * amount }
        .fold(Money.ZERO) { acc, fee -> acc + fee }
}
 
// 부가 정책: 다른 정책을 감싸 위임 후 부가 기능 수행
abstract class AdditionalRatePolicy(
    private val next: RatePolicy,
) : RatePolicy {
    override fun calculateFee(phone: Phone): Money {
        val fee = next.calculateFee(phone) // 감싼 정책에 먼저 위임
        return afterCalculated(fee)        // 부가 기능 적용
    }
 
    protected abstract fun afterCalculated(fee: Money): Money
}
 
class TaxablePolicy(
    private val taxRate: Double,
    next: RatePolicy,
) : AdditionalRatePolicy(next) {
    override fun afterCalculated(fee: Money): Money = fee + fee * taxRate
}
 
class RateDiscountablePolicy(
    private val discountAmount: Money,
    next: RatePolicy,
) : AdditionalRatePolicy(next) {
    override fun afterCalculated(fee: Money): Money = fee - discountAmount
}

이제 정책을 런타임에 조립한다. 세금, 할인, 기본 요금의 순서를 코드 한 줄로 바꿀 수 있다.

// 세금 -> 할인 -> 기본 요금 순으로 조립
val phone = Phone(
    TaxablePolicy(
        taxRate = 0.05,
        next = RateDiscountablePolicy(
            discountAmount = Money.wons(1000),
            next = RegularPolicy(Money.wons(10), Duration.ofSeconds(10)),
        ),
    ),
)

상속으로는 M 곱하기 N 개의 클래스가 필요했던 조합이, 합성에서는 정책마다 클래스 하나로 끝난다. 새 부가 정책을 추가해도 기존 정책 코드는 그대로다. 조합의 책임이 클래스 계층이 아니라 객체 조립으로 옮겨졌기 때문이다.

상속과 합성의 구조 차이는 다음 그림으로 비교한다. 왼쪽은 컴파일타임에 고정된 강결합 계층이고, 오른쪽은 런타임에 위임으로 조립되는 약결합 구조다.

서브클래싱과 서브타이핑

합성이 항상 정답은 아니다. 상속이 옳은 자리도 있다. 그 자리를 가르는 기준이 서브클래싱과 서브타이핑의 구분이다. 핵심은 상속의 목적이 코드 재사용이냐 타입 계층 설계냐에 있다.

잘못된 상속: 행동 호환성 위반

문서 관리 시스템에서 File 을 상위 개념으로 두고 WordExcel 을 하위에 두려 한다. 모든 파일은 PDF 로 내보낼 수 있다고 가정하고 FileexportPdf 를 넣었다.

interface File {
    fun exportPdf()
}
 
class Word : File {
    override fun exportPdf() { /* 정상 동작 */ }
}
 
class Excel : File {
    override fun exportPdf() {
        // Excel 은 PDF 변환을 지원하지 않는다
        throw UnsupportedOperationException()
    }
}

Excel 은 PDF 변환을 지원하지 않으므로 구현을 비우거나 예외를 던질 수밖에 없다. 클라이언트는 File 타입을 믿고 exportPdf 를 호출했다가 예기치 않은 런타임 오류를 만난다. 이름이 비슷하다는 언어적 직관만으로 상속을 쓴 결과다. 서브타입은 슈퍼타입의 행동을 완전히 대체할 수 있어야 한다는 약속이 깨졌다.

타입을 검사해 분기하는 방식도 해결책이 아니다.

// 타입 분기는 OCP 를 위반한다
fun export(file: File) {
    if (file is Word) file.exportPdf()
    // 새 타입이 추가될 때마다 이 분기를 계속 고쳐야 한다
}

올바른 상속 1: 인터페이스 분리

해법은 공통 타입과 특수 행동을 분리하는 것이다. PDF 내보내기가 가능한 객체만 별도 인터페이스를 구현한다.

interface File {
    val name: String
}
 
interface PdfExporter {
    fun exportPdf()
}
 
class Word(override val name: String) : File, PdfExporter {
    override fun exportPdf() { /* 정상 동작 */ }
}
 
class Excel(override val name: String) : File {
    // PdfExporter 를 구현하지 않으므로 불필요한 오버라이딩이 없다
}

Excel 은 자신이 할 수 없는 행동을 약속하지 않는다. 클라이언트는 PDF 가 필요할 때 PdfExporter 타입을 요구하면 되고, 그 타입을 만족하는 객체는 언제나 안전하게 변환된다.

올바른 상속 2: 계약에 의한 설계

타입 계층을 안전하게 유지하려면 슈퍼타입과 서브타입 사이의 계약을 명시한다. 계약은 사전 조건(pre-condition)과 사후 조건(post-condition)으로 표현된다. 서브타입은 사전 조건을 더 강하게 만들지 않고, 사후 조건을 더 약하게 만들지 않아야 리스코프 치환 원칙을 지킨다.

abstract class File {
    private var exportCount = 0L
 
    // 슈퍼타입이 보장하는 흐름
    fun export() {
        validatePreCondition() // 사전 조건 검사
        exportFile()
        exportCount++
        validatePostCondition() // 사후 조건 검사
    }
 
    private fun validatePreCondition() {
        if (exportCount < 0) throw IllegalStateException()
    }
 
    private fun validatePostCondition() {
        if (exportCount <= 0) throw IllegalStateException()
    }
 
    protected abstract fun exportFile()
}
 
class WordFile : File() {
    override fun exportFile() { /* Word 변환 */ }
}

클라이언트는 구체적인 서브타입이 무엇인지 몰라도 export 를 안심하고 호출한다. 어떤 서브타입이 들어와도 사전 조건과 사후 조건이 동일하게 보장되기 때문이다. 이것이 다형성을 떠받치는 진짜 상속이다.

구분서브클래싱 (subclassing)서브타이핑 (subtyping)
목적코드 재사용 (구현 상속)타입 계층 정의 (인터페이스 상속)
관점부모의 내부 구현을 물려받음부모의 행동을 대체함
결합도강결합 (부모 구현 변경에 취약)느슨한 결합 (메시지에 의존)
LSP 준수보장하지 않음 (오버라이딩 오용 가능)필수 (완전한 대체 가능성)
권장 여부지양 (합성으로 대체 권장)지향 (다형성을 위한 올바른 상속)

유연한 설계: 의존성 주입과 의존성 역전

서브타이핑으로 인터페이스에 의존하더라도 한 가지 문제가 남는다. 객체를 생성하는 시점이다. 클라이언트가 인터페이스를 사용하면서도 TossPayment() 처럼 구체 클래스를 직접 생성하면 강결합이 그대로 남는다.

OCP의 한계: 생성과 사용의 결합

interface Payment {
    fun pay(amount: Long)
}
 
class TossPayment : Payment {
    override fun pay(amount: Long) { /* 토스 결제 */ }
}
 
class NaverPay : Payment {
    override fun pay(amount: Long) { /* 네이버 결제 */ }
}
 
class Order(val id: Long, var amount: Long) {
    // 사용은 추상화에 의존 (OCP 준수처럼 보임)
    fun pay(payment: Payment) = payment.pay(amount)
}
 
fun main() {
    val order = Order(id = 0L, amount = 100)
    order.pay(TossPayment()) // 생성 시점에 구체 클래스를 직접 호출
}

Order 자체는 구현체를 모르지만, 호출하는 쪽이 여전히 TossPayment 를 직접 생성한다. 결제 수단을 추가할 때마다 이 호출 코드를 고쳐야 하므로 진짜 유연성은 아직 없다.

생성 책임의 분리: Factory

유연성을 위한 첫 단계는 객체를 생성하는 책임과 사용하는 책임을 분리하는 것이다. 생성만 전담하는 Factory를 둔다. 도메인 모델에는 속하지 않지만 설계를 위해 의도적으로 만든 순수한 가공물(pure fabrication)이다.

enum class PaymentType { TOSS, NAVER }
 
// 생성 책임만 전담
class PaymentFactory {
    fun getPayment(type: PaymentType): Payment = when (type) {
        PaymentType.TOSS -> TossPayment()
        PaymentType.NAVER -> NaverPay()
    }
}

이제 클라이언트는 구체 결제 구현체를 몰라도 Factory에 종류만 요청하면 된다. new 연산자를 쓰는 자리가 한곳으로 모인다.

런타임 유연성: 의존성 주입

다음 단계는 Order 가 스스로 의존성을 고르지 않고 외부에서 주입받는 것이다. 컴파일타임 의존성(Payment 인터페이스)과 런타임 의존성(TossPayment 인스턴스)이 분리된다.

class Order(
    val id: Long,
    var amount: Long,
) {
    // 컴파일타임 의존성: 인터페이스
    private lateinit var payment: Payment
 
    // 사용: payment 가 무엇이든 같은 메시지
    fun pay() = payment.pay(amount)
 
    // 의존성 주입: 런타임에 교체 가능한 통로
    fun inject(payment: Payment) {
        this.payment = payment
    }
}
 
fun main() {
    val order = Order(id = 0L, amount = 100)
    val factory = PaymentFactory()
 
    order.inject(factory.getPayment(PaymentType.TOSS)) // 토스 주입
    order.pay()
 
    order.inject(factory.getPayment(PaymentType.NAVER)) // 네이버로 교체, Order 수정 없음
    order.pay()
}

Order 코드를 한 글자도 바꾸지 않고 결제 수단을 교체했다. 의존성이 컴파일 시점이 아니라 실행 시점에 결정되기 때문이다. 이것이 합성이 상속보다 유연한 근본 이유다.

의존성 역전 원칙

마지막은 패키지 수준의 방향 문제다. 고수준 모듈(Order)이 저수준 모듈(TossPayment)에 의존하면, 저수준의 변경이 고수준으로 거슬러 올라온다. 의존성 역전 원칙은 인터페이스의 소유권을 고수준 쪽에 둠으로써 방향을 뒤집는다.

// 고수준 패키지: Order 와 Payment 인터페이스를 함께 소유
package com.example.order
 
interface Payment {
    fun pay(amount: Long)
}
 
class Order(private val payment: Payment) {
    fun pay(amount: Long) = payment.pay(amount)
}
// 저수준 패키지: 고수준의 인터페이스를 향해 의존
package com.example.payment
 
import com.example.order.Payment
 
class TossPayment : Payment {
    override fun pay(amount: Long) { /* 토스 결제 */ }
}

Payment 인터페이스가 고수준 패키지에 속하므로, 저수준 구현이 고수준을 향해 의존하게 된다. 결제 구현이 아무리 바뀌어도 Order 가 속한 패키지는 영향받지 않는다. 의존의 화살표가 추상화를 향해 모이는 모습이 다음 그림이다.

자바로 옮기면 정적 팩토리 메서드 관용구가 함께 쓰인다. 같은 추상화 의존을 유지하되 생성 의도를 메서드 이름으로 드러낸다.

public interface Payment {
    void pay(long amount);
 
    // 정적 팩토리: 생성 의도를 이름으로 표현
    static Payment of(PaymentType type) {
        return switch (type) {
            case TOSS -> new TossPayment();
            case NAVER -> new NaverPay();
        };
    }
}
 
public class Order {
    private final Payment payment; // 추상화에만 의존
 
    public Order(Payment payment) {
        this.payment = payment;
    }
 
    public void pay(long amount) {
        payment.pay(amount);
    }
}

주의사항과 베스트프랙티스

지금까지의 논의를 실무 기준으로 압축하면, 재사용 수단을 고를 때는 합성을 먼저 검토하고 상속은 타입 계층이 필요한 자리로 한정한다.

상속과 합성 선택 기준

판단 항목상속을 쓴다합성을 쓴다
관계의 성격is-a (자식이 부모의 한 종류)has-a (기능을 빌려 쓴다)
재사용 목적타입 계층·다형성 (서브타이핑)구현 재사용·정책 조합
변경 빈도부모가 안정적이고 거의 바뀌지 않음정책이 자주 바뀌거나 조합됨
결정 시점컴파일타임에 고정해도 무방런타임에 교체·조립이 필요
LSP 만족서브타입이 슈퍼타입을 완전히 대체해당 없음

상속을 쓰기 전 점검할 세 가지

  • is-a 관계인가. 이름이 비슷하다는 이유만으로 상속하지 않는다. AdjustmentCalculator 가 Tax 가 아니듯, 계산식이 닮았다는 것은 상속의 근거가 아니다.
  • 자식이 부모의 내부 구현을 몰라도 되는가. 자식이 부모의 protected 메서드나 가변 필드에 의존하기 시작하면 취약한 기반 클래스로 가는 길이다.
  • 리스코프 치환 원칙(LSP)을 지키는가. 서브타입이 슈퍼타입 자리에서 예외를 던지거나 다른 결과를 낸다면 그 상속은 잘못됐다.

자주 나오는 실수

  • 코드가 겹친다는 이유로 맥락이 다른 클래스를 상속한다. 나이 검증처럼 필요 없는 정책까지 딸려 와 버그가 된다.
  • 부가 기능마다 서브클래스를 만들어 클래스가 조합 수만큼 폭발한다. 데코레이터로 위임하면 정책당 클래스 하나로 끝난다.
  • 인터페이스에 의존하면서도 호출부에서 구체 클래스를 직접 생성해 강결합을 남긴다. 생성 책임을 Factory 로 분리하고 의존성 주입으로 런타임에 넣는다.