상속은 잘 쓰면 편한데, 한 번 꼬이기 시작하면 "누가 누구를 상속했고, 어디서 뭐가 섞였는지" 추적하는 데만 시간이 다 간다.
그래서 모던 개발에서 자주 나오는 말이 하나 있다.
"상속보다 합성(Composition)을 먼저 떠올려라."
이번 글은 그 말이 무엇을 의미하는지, 코드에서 어떻게 쓰는지 정리하는 글이다.
결론 먼저
- 합성(Composition): 객체가 다른 객체를 포함해서(has-a) 기능을 조합하는 방식
- 상속(is-a) 대신 합성을 쓰면
- 결합도 ↓ (서로 덜 얽힘)
- 재사용성 ↑ (독립 모듈 재사용)
- 유지보수 쉬움 (교체/확장 유연)
- 클래스든, 생성자 함수든, 자바스크립트 어디에서나 쓸 수 있는 설계 아이디어
한 줄 요약하자면
"복잡한 상속 계층 짜기 전에, 먼저 합성으로 조합할 수 없는지 생각해 보자."
합성(Composition)이 뭐냐?
정의부터 짚고 가자
합성은 객체가 다른 객체를 속성으로 포함해서(has-a) 기능을 조합하는 방식이다.
- A가 B를 포함한다 → A has a B
- A가 B를 상속한다 → A is a B
상속은 "A는 B다"라는 관계를 강하게 만들어 버린다.
반대로 합성은 "A는 B를 가진다"라서, 서로 조금 더 느슨하게 묶인다.
합성의 장점 키워드
- 캡슐화: 포함된 객체(Logger 등)의 내부 구현을 외부에서 신경 쓸 필요 없다.
- 유연성: 나중에 Logger를 다른 구현으로 갈아끼우기 쉽다.
- 재사용성: Logger, Storage, Validator 같은 모듈을 여러 곳에서 재사용하기 좋다.
간단히 말하면
상속은 "클래스 계층"에 기능을 얹어 가는 느낌이고,
합성은 "레고 블록"처럼 기능을 조합해서 완성하는 느낌이다.
합성은 어떻게 동작하냐?
구조는 생각보다 단순하다.
- 어떤 객체 안에 다른 객체를 속성으로 포함한다.
- 그 포함된 객체의 메서드를 호출하거나, 그대로 위임(delegate)한다.
- 필요하면 나중에 그 포함된 객체만 교체해서 기능을 바꾼다.
이 패턴은
- class 문법에서도 쓸 수 있고
- 예전 스타일의 생성자 함수에서도 그대로 쓸 수 있다.
예시 1 - class로 합성하기
class Logger {
log(message) {
console.log(`[LOG] ${message}`);
}
}
class Calculator {
constructor() {
// 합성: Calculator가 Logger를 "가지고" 있다.
this.logger = new Logger();
}
add(x, y) {
this.logger.log(`Calculating ${x} + ${y}`);
return x + y;
}
}
const calc = new Calculator();
calc.add(5, 3);
// [LOG] Calculating 5 + 3
// 8
여기서 관계를 정리해 보면
- Calculator is a Logger? → 아니다.
- Calculator has a Logger? → 맞다.
그래서 상속보다는 합성이 자연스럽다.
만약 Logger 구현을 나중에 바꾸고 싶다면?
class SilentLogger {
log(message) {
// 아무것도 안 함
}
}
class Calculator {
constructor(logger = new Logger()) {
this.logger = logger; // 의존성 주입
}
add(x, y) {
this.logger.log(`Calculating ${x} + ${y}`);
return x + y;
}
}
const calc1 = new Calculator(new Logger());
const calc2 = new Calculator(new SilentLogger());
이렇게 로그 전략만 갈아끼우는 식으로 유연하게 가져갈 수 있다.
예시 2 - 생성자 함수로 합성하기
class를 쓰지 않는 스타일에서도 똑같이 합성을 적용할 수 있다.
function Logger() {
this.log = function (message) {
console.log(`[LOG] ${message}`);
};
}
function Calculator() {
this.logger = new Logger(); // 합성: Logger 포함
this.add = function (x, y) {
this.logger.log(`Calculating ${x} + ${y}`);
return x + y;
};
}
const calc = new Calculator();
calc.add(10, 5);
// [LOG] Calculating 10 + 5
// 15
문법만 다를 뿐, 구조는 class 예시와 똑같다.
- Calculator는 Logger를 상속하지 않는다.
- 단지 내부에 this.logger로 가지고 있을 뿐이다.
합성 vs 상속, 테이블로 정리
| 관점 | 합성 (Composition) - has-a | 상속 (Inheritance) - is-a |
| 관계 | A는 B를 가진다 (has-a) | A는 B이다 (is-a) |
| 결합도 | 낮음 (서로 상대적으로 독립적) | 높음 (부모 변경이 자식에 바로 영향) |
| 유연성 | 구성 요소만 교체하면 동작 변경 가능 | 계층 구조 자체를 손봐야 하는 경우 많음 |
| 재사용성 | 모듈 단위 재사용에 강함 | 계층 전체를 상속받아야 하는 경우 많음 |
| 복잡도 | 구조가 비교적 단순, 추론 쉬움 | 계층이 깊어질수록 추적이 어려워짐 |
상속이 항상 나쁜 건 아니다. 다만
- 계층이 조금만 꼬여도 어디서 뭐가 물려왔는지 추적하는 데 시간이 많이 들고
- 공통 로직을 올리다 보면 나중에 애매한 Base/Abstract 클래스가 쌓이기 쉽다.
"is-a 관계가 정말 분명한 경우가 아니라면, 일단 합성을 먼저 떠올려라"
언제 합성을, 언제 상속을 쓸까? (선택 가이드)
합성을 우선 고려할 때
- 유연한 기능 조합이 필요할 때
- 예: 다양한 Logger, Storage, Transport, Strategy를 끼워 넣고 싶은 경우
- 테스트를 쉽게 하고 싶을 때
- 가짜 Logger, Mock API Client 등으로 갈아끼우기 좋다.
- 단일 책임 원칙(SRP)을 지키고 싶을 때
- 각 역할을 작은 객체로 나누고, 합성해서 조립하면 책임이 분리된다.
상속을 고려해도 되는 경우
- is-a 관계가 정말 명확할 때
- 예: Triangle is a shape, AdminUser is a User 등
- 이미 상속 기반으로 잘 설계된 레거시 코드와 호환해야 할 때
- 구조를 크게 흔들 수 없을 때는 기존 패턴을 따르는 것도 현실적인 선택
내 체크리스트
실제 코드 짤 때, 머릿속에서 대충 이렇게 물어본다.
- "이 관계, 진짜 is-a인가? 아니면 has-a가 더 자연스럽나?"
- "이 기능을 나중에 다른 구현으로 갈아끼울 일이 있을까?"
- "이 객체 하나가 너무 많은 책임을 지고 있진 않나? (로그 + 저장 + 렌더링 + 검증 등)"
- "테스트에서 진짜 객체 대신 가짜 객체를 넣어보고 싶은가?"
대부분의 경우, 이 질문들에 "그렇다"가 섞여 있으면
상속보다는 합성이 더 자연스러운 선택이다.
오늘 내용 한 줄씩 다시 정리
- 합성(Composition): 객체가 다른 객체를 "포함"해서(has-a) 기능을 조합하는 설계 방식.
- 상속(Inheritance): 객체가 다른 객체를 "상속"해서(is-a) 기능을 이어받는 설계 방식.
- 합성은
- 결합도를 낮추고
- 재사용성을 높이고
- 테스트/유지보수를 쉽게 만들어 준다.
- 상속은
- 계층이 깊어질수록 복잡성이 급격히 올라가기 때문에
- "정말 is-a 관계가 분명한가?"를 항상 한 번 더 의심해 보는 게 좋다.
정리하자면,
새 구조를 설계할 때 "상속부터 생각하는 습관" 대신,
"작게 쪼개고, 합성으로 조립할 수는 없을까?"를 먼저 떠올리면
코드가 훨씬 덜 꼬이고, 나중에 손댈 때도 편해진다.
'FrontEnd > JavaScript' 카테고리의 다른 글
| JavaScript 반복문 한 번에 정리하기 (0) | 2025.09.09 |
|---|---|
| JavaScript new 연산자 (0) | 2025.09.09 |
| ES6 클래스 vs 생성자 함수 - 요즘 자바스크립트에서 뭘 써야 할까? (0) | 2025.09.09 |
| DOM으로 HTML 다루기 - innerHTML, insertAdjacentHTML, createContextualFragment (0) | 2025.09.09 |
| JavaScript Function.call / apply / bind - this (0) | 2025.09.09 |