[Java] 다형성, 컴포지션, DI - 세 개념을 한 줄로 잇기

2026. 5. 17. 01:42·Java

다형성을 학습하다가 Effective Java의 "상속보다는 컴포지션을 사용하라"는 문장이 떠올랐다. 

그 과정에서 implements와 컴포지션의 관계가 머릿속에서 명확히 정리되지 않아 개념을 다시 정리해본다.

다형성이란

다형성(polymorphism)은 같은 메소드 호출이 실제 객체에 따라 다르게 동작하는 성질이다.

Payment payment = new KakaoPayment();
payment.pay(1000);

위 코드에서 변수 타입은 Payment지만, 실제 실행되는 것은 KakaoPayment.pay()다.

 

즉 다형성의 핵심은

  • 부모 타입(인터페이스/상위 클래스)으로 객체를 다룰 수 있고
  • 실제 어떤 구현이 실행될지는 런타임에 결정된다는 점이다.

왜 필요한가 - 결제 예시

다형성이 없는 코드

public class OrderService {
  public void checkout(String type, int amount) {
      if (type.equals("kakao")) {
          new KakaoPayment().pay(amount);
      } else if (type.equals("naver")) {
          new NaverPayment().pay(amount);
      }
      // 결제 수단이 추가될 때마다 분기 추가
  }
}

결제 수단이 늘어날 때마다 클라이언트 코드를 매번 수정해야 한다. if / switch가 코드 곳곳에 박힌다.

 

 

다형성을 적용한 뒤

public class OrderService {
  private final Payment payment;

  public OrderService(Payment payment) {
      this.payment = payment;
  }

  public void checkout(int amount) {
      payment.pay(amount); // 카카오든 네이버든 신경 안 씀
  }
}

클라이언트는 Payment라는 추상 타입만 의존한다. OrderService는 내부 구현체를 알 필요가 없다.

 

추상화와의 관계

  • 클라이언트는 Payment 인터페이스에만 의존한다.
  • 구체적인 구현은 인터페이스 뒤로 캡슐화된다.
  • 각 구현체의 내부 동작 방식은 서로 달라도 무방하다.

클라이언트는 구현 방식과 무관하게 payment.pay()만 호출한다.

 

런타임에 결정된다

다형성의 핵심은 어떤 구현체가 실행될지 컴파일 타임이 아니라 런타임에 결정된다는 점이다.

Payment payment = getPayment(userChoice);
payment.pay(1000);

 payment의 타입은 Payment로 고정돼 있지만, 실제 호출되는 객체는 런타임에 들어온 값에 따라 다르다.

  • KakaoPayment.pay()
  • NaverPayment.pay()

이것이 동적 바인딩(dynamic binding)이다.


implements / 컴포지션 / DI

처음 던졌던 질문으로 돌아가면, implements와 컴포지션은 함께 쓰이는 개념이다.

1. 인터페이스 구현 (implements) — 타입 공유

public interface Payment {
  void pay(int amount);
}

public class KakaoPayment implements Payment { ... }
public class NaverPayment implements Payment { ... }

KakaoPayment와 NaverPayment는 Payment라는 타입을 공유한다.

개념적으로 implements도 넓은 의미의 상속이다.

2. 컴포지션 (Composition) — 객체를 보유

public class OrderService {
  private final Payment payment;
}

OrderService는 Payment를 상속하지 않고 객체를 필드로 가진다. is-a가 아닌 has-a 관계다.

3. DI (의존성 주입) — 외부에서 주입

@Service
public class OrderService {
  private final Payment payment;

  public OrderService(Payment payment) {
      this.payment = payment;
  }
}

OrderService가 직접 객체를 생성하지 않는다.

어떤 구현체를 넣을지는 외부(스프링 컨테이너)가 결정한다.

 

여러 개의 구현체가 동시에 존재한다면 전략 패턴(Strategy Pattern)을 쓸 수 있다.

인터페이스에 supports() 같은 메소드를 두고, 컨테이너가 모든 구현체를 List로 주입해주면, 런타임에 조건에 맞는 구현체를 골라서 호출한다.

public interface Payment {

    boolean supports(PaymentType type);

    void pay(int amount);
}
@Service
@RequiredArgsConstructor
public class OrderService {
  private final List<Payment> payments; // 모든 구현체가 주입됨

  public void checkout(PaymentType type, int amount) {
      payments.stream()
          .filter(p -> p.supports(type))
          .findFirst()
          .orElseThrow()
          .pay(amount);
  }
}

새 결제 수단이 추가돼도 @Service만 붙이면 자동으로 List에 포함된다.


 "상속보다 컴포지션"의 의미

처음 이 문장을 봤을 때 의문이 들었다.

implements도 상속의 일종인데, 그러면 implements도 쓰지 말라는 뜻일까?


그것은 아니다.

여기서 말하는 “상속”은 대부분 클래스 상속(extends)을 의미한다.

  • 인터페이스 상속(implements) — 타입 상속이라 권장된다.
  • 클래스 상속(extends) — 구현 상속이라 강한 결합 발생 가능성이 있다.

대표적인 사례가 Stack이다.

Vector를 extends한 Stack

자바 표준 라이브러리의 Stack은 Vector를 상속한다.

public class Stack<E> extends Vector<E> { ... }

이것의 단점이 있다.

1.  의도하지 않은 동작까지 상속됨

Vector의 모든 메소드는 synchronized로 선언되어 있다. thread-safe를 보장하기 위해서다.

문제는 Stack이 Vector를 extends하는 순간, Stack도 자동으로 모든 메소드가 synchronized가 된다는 것이다.

  • 새로 정의한 push(), pop()도 synchronized
  • 부모(Vector)에서 내려온 get(), size()도 synchronized

Stack을 쓸 때 락이 필요 없는 상황에서도 무조건 모니터 락이 걸린다.

void doSomething() {
  Stack<Integer> stack = new Stack<>(); // 지역 변수, 한 스레드만 접근
  stack.push(1);
  stack.pop();
  // 메소드 끝나면 사라짐 — 동기화가 전혀 필요 없는데도 락 오버헤드 발생
}

2. 추상화가 깨진다

Stack은 LIFO(Last-In-First-Out)라는 약속을 가진 자료구조다.
그런데 Vector를 상속하면서 Vector의 임의 위치 접근 메소드까지 그대로 노출된다.

Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);

stack.remove(0);          // 가장 아래(1)를 제거 — LIFO 위반
stack.insertElementAt(99, 0); // 중간에 끼워넣기 — LIFO 위반
stack.get(1);             // 임의 위치 조회 — LIFO 위반

자식이 원하지 않아도 부모의 모든 public 메소드는 그대로 자식의 API가 되는 단점이 있다.

 

이것이 "상속보다 컴포지션"이라는 말이 나온 이유다.


정리

  • 다형성은 같은 호출이 객체에 따라 다르게 동작하는 성질이다.
  • 보통 인터페이스(implements)로 공통 타입을 만들고, 클라이언트는 구현체 대신 추상 타입에 의존한다.
  • 컴포지션은 객체를 상속(extends)하지 않고 필드로 조합하는 방식이다.
  • DI는 객체 생성 책임을 외부로 분리하는 것이다.
  • "상속보다 컴포지션"은 extends를 두고 한 말이다. implements는 타입만 공유하지만, extends는 부모의 API와 동작까지 전부 따라오기 때문이다.

'Java' 카테고리의 다른 글

[Java] Arrays.copyOf가 얕은 복사인 이유 — 어셈블리까지 내려가기  (0) 2026.05.19
[Java] enum - 왜 안전한가, private 생성자로 강등되는 이유  (0) 2026.05.14
[Java] equals() & hashCode() 깊게 보기 - 31의 의미, String 내부 동작  (2) 2026.05.12
[Java] synchronized 동작 원리 - 모니터 락, CAS  (0) 2026.05.12
'Java' 카테고리의 다른 글
  • [Java] Arrays.copyOf가 얕은 복사인 이유 — 어셈블리까지 내려가기
  • [Java] enum - 왜 안전한가, private 생성자로 강등되는 이유
  • [Java] equals() & hashCode() 깊게 보기 - 31의 의미, String 내부 동작
  • [Java] synchronized 동작 원리 - 모니터 락, CAS
oniwon
oniwon
  • oniwon
    눙눙의 개발일지
    oniwon
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Java
      • Spring
      • CS
      • Infra
      • Projects
      • F-Lab
      • Airflow
  • 인기 글

  • 블로그 메뉴

    • 홈
    • 개인정보처리방침 (Privacy Policy)
    • 블로그 소개 (About)
  • 최근 글

  • 태그

    Flab커넥터스
    커넥터스2385
    Flab 멘토링
    자바 다형성
    DAG
    네트워크
    inconcert
    에프랩멘토링
    Airflow
    자바면접
    비동기 처리
    병렬 처리
    Java
    F-Lab
    SSE
    redis
    동시성
    자바 내부 동작
    FLAB
    https
    자바백엔드
    JVM
    Flab 추천인코드
    Flab 3주차 후기
    tls
    Flab후기
    dns
    에프랩
    Flab추천인코드
    Flab멘토링
  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.1
oniwon
[Java] 다형성, 컴포지션, DI - 세 개념을 한 줄로 잇기
상단으로

티스토리툴바