실무에서 다시 보는 객체 지향과 디자인 패턴: 상속 대신 컴포지션을 쓰는 이유
Dev
마지막 업데이트

실무에서 다시 보는 객체 지향과 디자인 패턴: 상속 대신 컴포지션을 쓰는 이유


학부 시절이나 취업 준비를 할 때 우리는 객체 지향 프로그래밍(OOP)을 “현실 세계를 모델링하는 기술”, “부모 클래스를 물려받는 상속(Inheritance)” 정도로 배웠습니다.

하지만 실무에서 코드를 수만 줄씩 쌓아가다 보면, 부모 클래스 하나 잘못 건드렸다가 자식 클래스 수십 개가 동시에 깨져나가는 참사를 겪게 됩니다. 그제서야 왜 대선배 개발자들이 “상속보다는 합성(Composition)을 써라”, **“인터페이스에 의존해라”**라고 신신당부했는지 뼈저리게 깨닫게 되죠.

이번 글에서는 교과서적인 설명은 걷어내고, 유지보수하기 좋은 코드를 작성하기 위해 실무에서 꼭 기억해야 할 핵심 원칙과 자주 쓰는 실전 디자인 패턴을 정리해 보겠습니다.


1. 실무 관점으로 다시 보는 SOLID 5대 원칙

이름만 외우는 SOLID는 아무 도움이 안 됩니다. “이 원칙을 어기면 실무에서 어떤 피를 보는지”를 알아야 합니다.

1) SRP (단일 책임 원칙)

  • 한 줄 요약: 하나의 클래스나 모듈은 단 하나의 변경 이유만 가져야 합니다.
  • 실전 예시: UserService 클래스 안에 회원 가입 로직, 이메일 템플릿 생성, 결제 카드 검증, PDF 영수증 발급 코드가 한데 섞여 있다면 명백한 위반입니다. 이메일 문구 바꾸려고 UserService를 건드리다가 회원 가입 버그를 내는 일이 벌어지기 때문이죠.

2) OCP (개방-폐쇄 원칙)

  • 한 줄 요약: 기존 코드를 수정하지 않고도 새로운 기능을 쉽게 확장할 수 있어야 합니다.
  • 실전 팁: 새로운 결제 수단(카카오페이, 토스페이, 애플페이)이 추가될 때마다 if-else나 switch 문을 찾아다니며 코드를 뜯어고치고 있다면 OCP가 깨진 상태입니다. 공통 결제 인터페이스를 정의하고 각 수단을 별도 클래스로 꽂아 넣는 구조로 바꿔야 합니다.

3) LSP (리스코프 치환 원칙)

  • 한 줄 요약: 자식 클래스는 부모 클래스의 계약(규약)을 배신하지 않고 완전히 대체할 수 있어야 합니다.
  • 실전 함정: Rectangle(직사각형)을 상속받아 Square(정사각형)를 만들었는데, 가로 길이를 바꿨더니 세로 길이까지 멋대로 바뀌어 부모의 전제 조건을 깨뜨리는 식의 설계는 피해야 합니다.

4) ISP (인터페이스 분리 원칙)

  • 한 줄 요약: 자신이 쓰지도 않는 메서드에 의존하도록 강제당하지 않아야 합니다.
  • 실전 팁: 10개짜리 거대한 인터페이스 하나보다는, 2~3개씩 역할별로 쪼갠 작은 인터페이스 여러 개를 만드는 편이 훨씬 깔끔합니다.

5) DIP (의존성 역전 원칙)

  • 한 줄 요약: 구체적인 구현 클래스에 기대지 말고, 추상화된 인터페이스에 기대세요.
  • 실전 예시: 비즈니스 로직이 MySqlDatabase라는 구체 클래스를 직접 new 해서 쓰지 않고, DatabaseRepository라는 인터페이스를 주입(DI)받아 쓰는 구조입니다. 그래야 테스트할 때 가짜(Mock) DB로 손쉽게 갈아 끼울 수 있습니다.

2. 왜 상속보다 합성(Composition)일까?

객체 지향의 꽃이 상속이라고 배웠지만, 실무에서는 상속이 오히려 코드 결합도를 극단적으로 높이는 독이 되곤 합니다.

[상속 (Inheritance) - 강한 결합: 부모 변경 시 자식이 전부 영향받음]
CustomList ──상속(is-a)──> ArrayList

[합성 (Composition) - 느슨한 결합: 필요한 부품을 레고처럼 갈아 끼움]
OrderService ──소유(has-a)──> PaymentProcessor (Interface)
  • 깨지기 쉬운 기반 클래스 문제: 부모 클래스의 내부 구현이 아주 조금만 바뀌어도 이를 상속받은 자식들의 동작이 예측 불가능하게 망가질 수 있습니다.
  • 합성의 우아함: 클래스 내부의 필드로 인터페이스 인스턴스를 소유(has-a)하게 만들면, 런타임에 언제든 부품을 레고처럼 갈아 끼울 수 있고 단위 테스트도 믿을 수 없을 만큼 쉬워집니다.

3. 실무에서 가장 자주 써먹는 3대 디자인 패턴

💡 1. 전략 패턴 (Strategy Pattern)

알고리즘군을 정의하고 각각을 캡슐화하여 교체 가능하게 만듭니다.

// 결제 전략 인터페이스
interface PaymentStrategy {
  pay(amount: number): Promise<void>;
}

class KakaoPayStrategy implements PaymentStrategy {
  async pay(amount: number) { console.log(`카카오페이로 ${amount}원 결제`); }
}

class TossPayStrategy implements PaymentStrategy {
  async pay(amount: number) { console.log(`토스페이로 ${amount}원 결제`); }
}

// 결제 프로세서는 전략만 갈아 끼우면 새로운 결제 수단이 추가되어도 코드 수정이 없습니다 (OCP 준수!)
class PaymentService {
  constructor(private strategy: PaymentStrategy) {}
  async checkout(amount: number) {
    await this.strategy.pay(amount);
  }
}

💡 2. 팩토리 패턴 (Factory Pattern)

어떤 객체를 생성할지 결정하는 책임을 한곳으로 몰아주는 패턴입니다. 객체 생성 로직이 복잡하거나 조건에 따라 다른 객체를 만들어야 할 때 클라이언트 코드를 깔끔하게 지켜줍니다.

💡 3. 옵저버 패턴 (Observer / Pub-Sub)

한 객체의 상태가 바뀌었을 때 이를 구독하고 있는 다른 객체들에게 자동으로 알림을 날려주는 패턴입니다. 회원 가입 완료 후 “환영 이메일 발송”, “가입 축하 쿠폰 지급”, “슬랙 알림” 같은 후속 작업들을 메인 가입 로직과 완벽히 분리할 수 있습니다.

먼저 읽어볼 가이드

검색 유입이 많은 핵심 글부터 이어서 보세요.