B-2: 인터페이스 분리·의존성 역전, 그리고 상속보다 컴포지션 — SOLID를 완성하고, 도그마를 경계한다
목차 25
안녕하세요, 홍순구 튜터입니다. 지난 시간엔 SOLID 다섯 글자 중 앞 세 글자(S·O·L)를 채웠죠. 단일 책임(SRP)으로 "이 클래스가 바뀔 이유는 하나"를 잡고, 개방-폐쇄(OCP)로 "기능 추가에 기존 코드를 안 건드리게" 만들고, 리스코프 치환(LSP)으로 "자식이 부모 약속을 어기지 않게" 하는 법을 봤습니다.
오늘은 나머지 두 글자 I(인터페이스 분리)와 D(의존성 역전)로 SOLID를 완성합니다. 그리고 지난 시간 LSP 끝에서 슬쩍 흘렸던 약속, "상속보다 컴포지션(조합)"을 정면으로 다룹니다. 정사각형이 직사각형을 상속하다 사고를 냈던 그 이야기의 진짜 결말이죠.
마지막엔 이 과목이 가장 경계하는 함정 하나를 짚습니다. SOLID조차 외워서 맹목적으로 따르면 독이 된다는 것. 규칙을 아는 건 주니어, 그 규칙을 언제 깰지 아는 게 미들입니다. 자, 클래스 사이를 잇는 마지막 두 손잡이를 잡으러 가 봅시다.
지난 시간 (B-1) 오늘 (B-2)
────────────── ──────────────
S 단일 책임 (SRP) I 인터페이스 분리 (ISP)
O 개방-폐쇄 (OCP) D 의존성 역전 (DIP)
L 리스코프 치환 (LSP) + 상속보다 컴포지션
+ SOLID는 도그마가 아니다
💡 오늘 수업의 핵심 — "좋은 의존은 구체가 아니라 약속에 기대는 것, 상속보다 조합이 그 약속을 더 유연하게 잇는다. 단, SOLID조차 목표가 아니라 수단이다"
🎯 학습 목표
- ISP로 뚱뚱한 인터페이스를 역할별로 쪼개, 누구도 자기가 안 쓰는 메서드를 떠안지 않게 한다.
- DIP로 고수준이 구체가 아니라 추상(약속)에 기대게 만들고, 그 약속의 구현을 바깥에서 끼워 주는 의존성 주입(DI)과의 관계를 구분한다.
- 상속의 함정을 이해해 컴포지션(조합)으로 더 유연하게 재사용하고, SOLID를 언제 적용하고 언제 깰지 판단하는 눈을 가진다.
Step 1: "인터페이스에 안 쓰는 메서드가 절반이에요" (ISP)
코드 리뷰에서 이런 말을 들으면 십중팔구 ISP를 어긴 겁니다. ISP는 Interface Segregation Principle, 우리말로 인터페이스 분리 원칙이에요. 한 줄로 줄이면 이렇습니다.
어떤 클라이언트도 자기가 쓰지 않는 메서드에 의존하도록 강요받아선 안 된다.
말이 좀 딱딱하죠. 코드로 보면 단번에 와닿습니다. 사무실 복합기를 떠올려 봐요. 인쇄·스캔·팩스를 다 하는 비싼 기계가 있고, 인쇄만 하는 단순 프린터가 있어요. 그런데 누가 "기계라면 응당 이 세 기능을 다 가져야지" 하면서 인터페이스 하나에 셋을 다 몰아넣으면 어떻게 될까요.
// Before — 뚱뚱한 인터페이스 (cleancode.solid.isp.AllInOneMachine)
public interface AllInOneMachine {
String print(String document);
String scan();
String fax(String document);
}
인쇄만 하면 되는 단순 프린터가 이 인터페이스를 구현하는 순간, 쓰지도 않는 scan()과 fax()까지 억지로 채워야 합니다. 어떻게 채울까요? 보통 이렇게, 예외를 던지는 빈 껍데기로 막아요.
// Before — 안 쓰는 메서드를 예외로 채운 거짓 약속 (SimplePrinterBefore)
public class SimplePrinterBefore implements AllInOneMachine {
@Override
public String print(String document) {
return "[인쇄] " + document;
}
@Override
public String scan() {
throw new UnsupportedOperationException("이 프린터는 스캔을 지원하지 않습니다");
}
@Override
public String fax(String document) {
throw new UnsupportedOperationException("이 프린터는 팩스를 지원하지 않습니다");
}
}
이게 왜 나쁠까요. scan()이 인터페이스에 있으니 컴파일러는 "이 프린터, 스캔 됩니다"라고 믿습니다. 그런데 호출하면 터져요. 인터페이스가 거짓말을 하는 겁니다. 게다가 나중에 AllInOneMachine에 staple()(스테이플) 같은 메서드가 하나 추가되면, 스테이플과 아무 상관 없는 단순 프린터까지 또 빈 껍데기를 채워야 하죠. 안 쓰는 메서드에 발이 묶인 거예요.
해법은 단순합니다. 하나의 뚱뚱한 인터페이스를 역할별로 쪼개는 거예요.
// After — 역할별로 쪼갠 인터페이스 (Printer / Scanner / Fax)
public interface Printer {
String print(String document);
}
public interface Scanner {
String scan();
}
public interface Fax {
String fax(String document);
}
이제 각 기기는 자기가 진짜 하는 역할만 골라 구현합니다. 인쇄만 하는 프린터는 Printer 하나만 구현하면 끝이에요. 안 쓰는 메서드가 아예 없습니다.
// After — 인쇄만 하는 프린터는 Printer 약속만 구현 (SimplePrinter)
public class SimplePrinter implements Printer {
@Override
public String print(String document) {
return "[인쇄] " + document;
}
}
진짜로 세 기능을 다 하는 복합기는요? 필요한 역할 인터페이스를 골라 함께 구현하면 됩니다. 자바는 인터페이스를 여러 개 구현할 수 있으니까요.
// After — 세 기능을 다 하는 기기만 세 역할을 함께 구현 (MultiFunctionMachine)
public class MultiFunctionMachine implements Printer, Scanner, Fax {
@Override
public String print(String document) {
return "[인쇄] " + document;
}
@Override
public String scan() {
return "[스캔] 문서를 읽었습니다";
}
@Override
public String fax(String document) {
return "[팩스] " + document + " 전송";
}
}
그림으로 보면 이렇게 갈립니다. 하나의 큰 약속이 단순 프린터까지 옭아매던 구조에서, 역할별 작은 약속을 골라 쓰는 구조로요.
Before — 뚱뚱한 인터페이스 하나가 모두를 옭아맨다
AllInOneMachine
├─ print()
├─ scan() ◀ 단순 프린터는 안 쓰는데 강요당함
└─ fax() ◀ 안 쓰는데 강요당함
After — 역할별로 쪼개 필요한 것만 고른다
Printer ─ print() Scanner ─ scan() Fax ─ fax()
SimplePrinter → Printer
MultiFunctionMachine → Printer, Scanner, Fax
⚠️ 언제 깨나 — ISP를 "메서드 하나당 인터페이스 하나"까지 밀어붙이면 그것대로 지옥입니다. 인터페이스가 수십 개로 폭발해서, 클래스 하나 만들 때마다 어떤 약속들을 조합할지 한참 고민하게 돼요. 쪼개는 기준은 메서드 개수가 아니라 "역할(role)"입니다. 함께 변하고 함께 쓰이는 메서드는 한 인터페이스에 두는 게 맞아요. "인쇄 역할", "스캔 역할"처럼 의미가 한 덩어리인 선에서 멈추세요.
💡 한 줄 정리
뚱뚱한 인터페이스는 안 쓰는 메서드를 강요해 거짓 약속을 만든다. 역할별로 쪼개면 누구도 자기와 상관없는 기능에 묶이지 않는다. 단, 쪼개는 단위는 메서드가 아니라 역할이다.
🙋 학생 질문 — "튜터님, 인터페이스를 어디까지 쪼개야 할지 감이 안 와요. 너무 잘게 쪼개도 문제라면서요?"
좋은 고민이에요. 실전 기준 하나를 드릴게요. "이 메서드들이 늘 함께 쓰이고 함께 바뀌는가?"를 물어보세요.
print()만 쓰는 클라이언트와 scan()만 쓰는 클라이언트가 따로 존재한다면, 그 둘은 다른 역할이니 쪼갭니다. 반대로 어떤 메서드들이 항상 함께 호출된다면(예: open()과 close()), 굳이 떼지 마세요. 같이 변할 운명이니까요.
처음부터 완벽하게 쪼개려 애쓰지 않아도 됩니다. "이 인터페이스 구현하는데 자꾸 빈 껍데기 메서드가 생기네?" 하는 신호가 보일 때 그때 쪼개도 늦지 않아요. 신호가 곧 기준입니다.
Step 2: "구체에 매달리지 마라" (DIP — 의존성 역전)
지난 시간 결합도를 다룰 때 제가 이런 말을 했어요. "구체적인 것 말고 약속(인터페이스)에 기대라." 구체 클래스를 직접 new 하는 대신 인터페이스에 의존하면 변화가 한 곳에 갇힌다고요. 그때 "이 감각을 정식 원칙으로 끌어올린 게 DIP인데 다음에 제대로 다룬다"고 예고했죠. 그 다음이 바로 지금입니다.
DIP는 Dependency Inversion Principle, 의존성 역전 원칙이에요. 두 문장으로 되어 있습니다.
① 고수준 모듈은 저수준 모듈에 의존하면 안 된다. 둘 다 추상에 의존해야 한다. ② 추상은 구체에 의존하면 안 된다. 구체가 추상에 의존해야 한다.
용어부터 풀죠. 고수준 모듈은 "무엇을 할지"를 정하는 업무 정책이에요(예: 주문을 처리한다). 저수준 모듈은 "어떻게 할지"의 세부 구현이고요(예: 메모리에 저장한다, DB에 저장한다). 보통 우리는 위(정책)가 아래(구체)를 직접 끌어다 씁니다. 이게 평범한 방향이에요.
// Before — 고수준이 저수준 구체를 직접 new 한다 (OrderServiceBefore)
public class OrderServiceBefore {
private final InMemoryOrderRepository repository = new InMemoryOrderRepository();
public void place(String item) {
repository.save(item);
}
public int orderCount() {
return repository.count();
}
}
OrderServiceBefore(고수준 정책)가 InMemoryOrderRepository(저수준 저장 방식)를 자기 손으로 직접 만들고 있죠. 문제는 저장 방식을 바꾸고 싶을 때 터집니다. 메모리 말고 파일이나 DB에 저장하려면? 이 고수준 클래스를 열어서 new 부분을 고쳐야 해요. 아래(구체)가 바뀌면 위(정책)가 흔들리는 겁니다.
역전이 바로 이 방향을 뒤집는다는 뜻이에요. 위가 아래를 직접 내려다보는 대신, 둘 다 가운데 있는 약속(추상)을 바라보게 만듭니다.
// After — 고수준이 의존할 '약속'을 먼저 둔다 (OrderRepository)
public interface OrderRepository {
void save(String item);
int count();
}
// After — 고수준은 약속에만 기대고, 구현은 바깥에서 받는다 (OrderServiceAfter)
public class OrderServiceAfter {
private final OrderRepository repository;
public OrderServiceAfter(OrderRepository repository) {
this.repository = repository;
}
public void place(String item) {
repository.save(item);
}
public int orderCount() {
return repository.count();
}
}
OrderServiceAfter는 이제 InMemoryOrderRepository라는 구체 클래스를 모릅니다. 오직 OrderRepository라는 약속만 알아요. 그리고 저수준인 InMemoryOrderRepository가 그 약속을 구현하죠(implements). 의존의 방향이 뒤집힌 겁니다.
Before — 위(정책)가 아래(구체)를 직접 끌어다 쓴다
OrderServiceBefore ──직접 new──▶ InMemoryOrderRepository
(고수준 정책) (저수준 저장 방식)
저장 방식이 바뀌면 위가 흔들린다
After — 둘 다 가운데 '약속'을 바라본다 (방향이 역전)
OrderServiceAfter ──의존──▶ OrderRepository ◀──구현── InMemoryOrderRepository
(고수준) (추상 = 약속) (저수준)
저수준이 약속을 바라본다 → 저장 방식을 갈아 끼워도 위는 그대로
화살표를 보세요. Before에선 화살표가 위에서 아래로 한 방향이었는데, After에선 저수준의 화살표가 위로 올라가 약속을 향합니다. 이 "저수준이 추상을 바라보게 뒤집는 것"이 역전의 핵심이에요.
⚠️ 언제 깨나 — 추상화는 공짜가 아닙니다. 인터페이스를 하나 만들면 파일이 늘고, 코드를 따라갈 때 "이 약속의 진짜 구현이 어디지?" 하고 한 번 더 점프해야 해요. 구현이 딱 하나뿐이고 앞으로도 바뀔 일이 없는 곳에까지 DIP를 들이대면, 그 인터페이스는 가치 없는 군더더기입니다. "이 저수준이 정말 갈아 끼워질 가능성이 있는가, 테스트에서 가짜로 바꿔 끼울 일이 있는가"를 묻고, 답이 "예"일 때 추상화하세요. 미리 다 만들어 두는 건 과잉입니다.
💡 한 줄 정리
고수준이 저수준 구체를 직접 new 하면 아래가 바뀔 때 위가 흔들린다. 둘 사이에 약속(추상)을 두고 저수준이 그 약속을 구현하게 하면 의존 방향이 역전돼, 구현을 갈아 끼워도 정책은 그대로다.
🙋 학생 질문 — "튜터님, 그럼 모든 클래스마다 인터페이스를 미리 만들어 두는 게 좋은 거예요?"
아니요, 그게 딱 ⚠️에서 경고한 함정이에요. "혹시 모르니까" 모든 클래스에 인터페이스를 씌우는 건 과잉 추상화입니다.
기준은 변화의 가능성이에요. 저장소·외부 API·알림 수단처럼 "구현이 여럿 생기거나 바뀔 게 뻔한" 곳은 미리 약속을 두는 게 이득입니다. 반대로 단순한 값 계산기나 절대 안 바뀔 내부 도우미 클래스까지 인터페이스로 감싸면, 읽는 사람만 피곤해져요.
실무 격언으로 YAGNI(You Aren't Gonna Need It, "필요해질 때까진 안 만든다")라는 게 있어요. 추상화도 마찬가지입니다. 정말 두 번째 구현이 필요해지는 순간 그때 인터페이스를 뽑아내도 IDE가 한 방에 해 줍니다. 이 "언제 추상화할지"의 판단은 오늘 마지막 Step에서 더 깊이 다룰게요.
Step 3: "약속을 누가 끼워 주나" (DIP와 DI는 다르다)
DIP를 배우면 거의 항상 같이 나오는 단어가 있어요. DI, 의존성 주입입니다. 둘이 워낙 붙어 다녀서 같은 말처럼 들리는데, 정확히는 다릅니다. 이 구분이 면접에서도 단골이라 짚고 갈게요.
- DIP(의존성 역전 원칙)는 방향에 대한 원칙입니다. "구체 말고 추상에 기대라"는 설계의 방향을 말해요.
- DI(의존성 주입)는 그 추상의 실제 구현을 바깥에서 끼워 주는 손기술입니다. "약속의 진짜 알맹이를 누가, 어떻게 넣어 주나"의 방법이에요.
지난 Step의 OrderServiceAfter를 다시 보면, 생성자로 OrderRepository를 받았죠. 무엇을 쓸지는 이 클래스가 정하지 않고, 만드는 쪽에서 정해 넘겨줍니다. 이게 바로 주입이에요.
// 무엇을 끼울지는 바깥에서 정해 넘긴다 — 이게 '주입'
OrderServiceAfter service = new OrderServiceAfter(new InMemoryOrderRepository());
service.place("책");
지금은 우리가 손으로 new InMemoryOrderRepository()를 만들어 넘기고 있어요. 나중에 후속 과목에서 다룰 프레임워크에서는, 이 "누가 무엇을 끼울지 정해서 넣어 주는 일"을 컨테이너라는 녀석이 대신해 줍니다. 객체를 만들고 끼워 주는 제어권이 내 코드에서 바깥(컨테이너)으로 넘어간다고 해서 이걸 제어의 역전(IoC, Inversion of Control)이라고 불러요. 이 개념이 어떻게 실전 프레임워크로 살아 움직이는지는 후속 spring-boot에서 본격적으로 만납니다. 오늘은 "주입은 바깥에서 끼워 주는 것"이라는 감만 잡으면 충분해요.
주입이 주는 큰 선물이 하나 더 있습니다. 테스트가 쉬워진다는 거예요. 약속에만 기대니까, 테스트할 때 진짜 저장소 대신 가짜를 끼워 넣을 수 있거든요.
// 테스트에서 가짜 저장소를 끼워 넣어 손쉽게 검증한다
List<String> saved = new ArrayList<>();
OrderRepository fake = new OrderRepository() {
@Override
public void save(String item) {
saved.add(item);
}
@Override
public int count() {
return saved.size();
}
};
OrderServiceAfter service = new OrderServiceAfter(fake);
service.place("노트");
// service.orderCount() 은 1, saved 에는 "노트" 가 들어 있다
만약 OrderServiceBefore처럼 내부에서 직접 new를 했다면, 이렇게 가짜로 바꿔 끼울 수가 없어요. DIP가 테스트 용이성까지 데려오는 겁니다.
여기서 Python을 한 줄 곁들이면 시야가 넓어져요. 자바는 OrderRepository라는 인터페이스를 명시적으로 선언하고 implements 해야 합니다. 그런데 Python에는 그런 선언이 없어요. "save와 count 메서드를 가진 무엇이든" 넘기면 그게 곧 약속입니다. 이걸 덕 타이핑(duck typing, "오리처럼 꽥꽥대면 오리다")이라고 불러요.
# Python — 인터페이스 선언 없이 '모양만 맞으면' 끼워진다 (덕 타이핑)
class OrderService:
def __init__(self, repository): # 타입 선언이 없다
self.repository = repository
def place(self, item):
self.repository.save(item) # save 만 있으면 무엇이든 OK
선언은 사라졌지만 원칙은 똑같습니다. 고수준은 여전히 구체 클래스가 아니라 "저장소처럼 행동하는 무엇"에 기대고, 그걸 바깥에서 주입받아요. DIP는 언어 문법이 아니라 의존의 방향에 대한 사고방식이라는 걸 이 대조가 보여 줍니다.
⚠️ 언제 깨나 — DI라고 하면 거창한 프레임워크나 컨테이너부터 떠올리는데, 작은 프로그램엔 그게 과합니다. 방금처럼 생성자에 손으로 넣어 주는 것도 엄연한 의존성 주입이에요. 객체 몇 개짜리 프로그램에 컨테이너를 들이는 건 망치로 압정 박기입니다. "바깥에서 끼워 준다"는 원리만 지키면, 그 수단은 손주입이든 컨테이너든 상황에 맞게 고르면 됩니다.
💡 한 줄 정리
DIP는 "구체 말고 추상에 기대라"는 방향의 원칙이고, DI는 그 추상의 구현을 바깥에서 끼워 주는 손기술이다. 주입 덕분에 구현 교체와 테스트가 쉬워지며, 이 원리는 언어 문법(자바 인터페이스, Python 덕 타이핑)을 넘어 똑같이 통한다.
🙋 학생 질문 — "튜터님, '제어의 역전(IoC)'이랑 '의존성 역전(DIP)'이 둘 다 역전인데 같은 건가요?"
이름이 비슷해서 정말 헷갈리는 짝이에요. 다른 겁니다.
의존성 역전(DIP)은 "의존의 방향을 뒤집어 추상을 바라보게 한다"는 설계 원칙이에요. 오늘 Step 2에서 본 그 방향 이야기죠.
제어의 역전(IoC)은 "객체를 만들고 연결하는 제어권을 내 코드가 아니라 바깥(프레임워크·컨테이너)에 넘긴다"는 더 넓은 개념이에요. 내가 new로 흐름을 주도하는 대신, 바깥이 객체를 만들어 내 코드에 넣어 주고 호출 시점도 정해 줍니다.
관계를 정리하면, DI(의존성 주입)는 IoC를 구현하는 한 가지 방법이고, 그 DI가 잘 작동하려면 DIP로 추상에 기대는 설계가 깔려 있어야 해요. 셋이 한 가족이지만 층위가 다릅니다. 지금은 "원칙(DIP) → 손기술(DI) → 그걸 프레임워크가 떠안으면 IoC" 정도로 줄을 세워 두면 충분해요. 실전은 후속 과목에서요.
Step 4: "상속이 5단계라 따라가다 길을 잃어요" (상속의 함정)
이제 방향을 틀어, 코드 재사용의 두 길을 비교합니다. 객체로 기능을 재사용하는 방법은 크게 둘이에요. 상속(inheritance)과 컴포지션(composition, 조합)이죠. 많은 입문자가 "재사용 = 상속"이라고 배워서 일단 extends부터 꺼내는데, 현대 설계의 기본값은 그 반대입니다. 왜 그런지, 상속이 어떻게 사고를 내는지부터 봐야 해요.
지난 시간 LSP에서 정사각형이 직사각형을 상속하다 넓이 계산을 깨뜨린 걸 기억하죠? 그때 제가 "상속은 부모와 자식을 강하게 묶는 강결합이라 위험하다"고 했어요. 오늘 그 위험의 정체를 더 또렷한 예로 보여 드릴게요.
HashSet에 원소가 몇 번 추가됐는지 세는 기능을 붙이고 싶다고 합시다. "그럼 HashSet을 상속해서 카운터만 얹으면 되겠네!" 하는 게 자연스러운 생각이에요.
// Before — HashSet 을 상속해 추가 횟수를 세려 한다 (CountingHashSet)
public class CountingHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E element) {
addCount++;
return super.add(element);
}
@Override
public boolean addAll(Collection<? extends E> elements) {
addCount += elements.size();
return super.addAll(elements);
}
public int getAddCount() {
return addCount;
}
}
논리적으로 완벽해 보이죠. add 한 번에 카운트 +1, addAll로 3개 넣으면 +3. 그런데 이 코드로 원소 3개를 addAll 하면, getAddCount()가 3이 아니라 6을 돌려줍니다. 분명 3개를 넣었는데 6이라뇨?
범인은 부모 클래스의 보이지 않는 내부 구현이에요. HashSet의 addAll()은 사실 내부에서 원소 하나하나에 대해 다시 add()를 호출하도록 만들어져 있습니다. 그런데 우리가 add()를 오버라이드해서 카운트를 올리게 했죠. 그래서 이런 일이 벌어져요.
addAll(["a","b","c"]) 호출 시 실제 흐름
1. 우리 addAll 이 addCount += 3 → addCount = 3
2. super.addAll() 이 내부에서
add("a") → 우리 add 가 또 +1 → addCount = 4
add("b") → 우리 add 가 또 +1 → addCount = 5
add("c") → 우리 add 가 또 +1 → addCount = 6
결과: 3개를 넣었는데 6으로 잘못 센다
우리는 부모의 addAll이 내부에서 add를 부른다는 걸 몰랐습니다. 그건 HashSet의 비공개 구현 세부사항이니까요. 그런데 상속을 한 죄로, 그 보이지 않는 내부 동작에까지 발이 묶여 버린 겁니다. 이걸 취약한 기반 클래스 문제(fragile base class)라고 불러요. 부모가 자기 내부 구현을 살짝만 바꿔도 자식이 소리 없이 깨질 수 있다는 뜻이에요.
상속의 함정은 이뿐만이 아닙니다.
- 캡슐화를 깬다: 자식이 옳게 동작하려면 부모의 내부 구현까지 알아야 해요. "속을 감춘다"는 캡슐화 원칙과 정면으로 부딪힙니다.
- 부모의 모든 것을 물려받는다:
HashSet을 상속하면 우리가 원하지 않는remove·clear같은 메서드까지 전부 외부에 노출돼요. 카운터의 일관성을 깨는 통로가 활짝 열립니다. - 상속 트리가 깊어진다: A를 B가, B를 C가, C를 D가 상속하면, D 한 줄을 이해하려고 부모 넷을 거슬러 올라가야 해요. "5단계라 길을 잃는다"는 리뷰 지적이 여기서 나옵니다.
💡 한 줄 정리
상속은 자식을 부모의 보이지 않는 내부 구현에 묶는 강결합이다. 부모의 addAll이 속으로 add를 부르는 것 하나에 카운터가 이중 집계되듯, 상속은 캡슐화를 깨고 깊은 트리로 길을 잃게 만든다.
🙋 학생 질문 — "튜터님, 그럼 상속은 무조건 쓰면 안 되는 나쁜 건가요?"
아니에요, 그렇게 단정하면 그게 또 다른 도그마예요(오늘 마지막 Step에서 정면으로 다룹니다). 상속 자체가 악은 아닙니다. 함부로, 재사용만을 노리고 쓰는 상속이 문제인 거예요.
상속이 정당한 경우가 분명히 있어요. 부모가 처음부터 확장을 의도하고 설계됐고(어떤 메서드를 자식이 오버라이드해도 안전한지 문서로 약속해 둔 경우), 자식이 부모를 진짜로 "~는 ~다"의 관계로 대체할 수 있을 때입니다. 이런 의도된 상속은 뒤에 디자인 패턴(템플릿 메서드)에서 다시 만나요.
방금 CountingHashSet이 나빴던 건, HashSet이 "나를 상속해서 add를 오버라이드해도 좋아"라고 설계된 클래스가 아니었기 때문이에요. 남이 만든, 확장을 염두에 두지 않은 클래스를 재사용 욕심에 상속한 게 화근이었죠. 다음 Step에서 이걸 어떻게 안전하게 푸는지 보여 드릴게요.
Step 5: "is-a 말고 has-a" (상속보다 컴포지션)
방금 그 이중 집계 버그를, 상속을 버리고 고쳐 봅시다. 핵심 발상은 이거예요. HashSet을 물려받는(extends) 대신, Set을 하나 품고(가지고) 일을 시킨다.
// After — 상속 대신 Set 을 '필드로 품고' 일을 위임한다 (CountingSet)
public class CountingSet<E> {
private final Set<E> set;
private int addCount = 0;
public CountingSet(Set<E> set) {
this.set = set;
}
public boolean add(E element) {
addCount++;
return set.add(element);
}
public boolean addAll(Collection<? extends E> elements) {
addCount += elements.size();
return set.addAll(elements);
}
public int getAddCount() {
return addCount;
}
public boolean contains(E element) {
return set.contains(element);
}
public int size() {
return set.size();
}
}
달라진 점을 보세요. CountingSet은 더 이상 HashSet의 자식이 아닙니다. 대신 Set 하나를 필드로 가지고, 필요한 일을 그 안쪽 set에 넘깁니다(위임, delegation). 이렇게 객체를 품어 기능을 합치는 게 컴포지션(조합)이에요.
이제 addAll로 3개를 넣으면 카운트가 정확히 3이 됩니다. 왜냐하면 set.addAll() 안에서 내부적으로 add가 불리더라도, 그건 우리가 감싼 set의 add지 우리 CountingSet의 add가 아니거든요. 우리 카운터를 건드릴 수가 없어요. 부모의 내부 구현에 묶였던 발이 풀린 겁니다.
상속 (Before) — 부모의 보이지 않는 내부 구현에 발이 묶인다
CountingHashSet ──extends──▶ HashSet
→ addAll(3개) 하면 addCount = 6 (부모 addAll 이 내부 add 를 다시 불러 이중 집계)
컴포지션 (After) — Set 을 품고(has-a) 필요한 것만 위임한다
CountingSet ──has-a──▶ Set
→ addAll(3개) 하면 addCount = 3 (감싼 set 의 내부 add 는 우리 카운트를 못 건드린다)
여기서 설계의 갈림길을 가르는 질문 하나를 기억하세요. "is-a냐, has-a냐."
- is-a (~는 ~다): "정사각형 is a 직사각형?" 수학적으론 맞는데, 코드 계약에선 지난 시간처럼 깨졌죠. is-a가 흔들리면 상속은 위험합니다.
- has-a (~를 가진다): "
CountingSethas aSet." 자동차가 엔진을 가지듯, 한 객체가 다른 객체를 품어 그 기능을 빌려 쓰는 관계예요.
판별법은 간단해요. "진짜로 그것의 한 종류인가(is-a), 아니면 그것을 부품으로 쓰는가(has-a)?"를 물어보세요. CountingSet은 "특별한 종류의 Set"이라기보다 "Set을 부품으로 쓰는 카운터"에 가깝죠. has-a면 상속이 아니라 컴포지션입니다.
컴포지션의 선물은 유연함이에요. 우리는 Set이라는 약속에만 기댔으니, 생성자에 HashSet을 넣든 TreeSet을 넣든 그대로 동작합니다(DIP가 여기서도 살아 있죠). 게다가 remove나 clear처럼 노출하고 싶지 않은 메서드는 위임하지 않으면 그만이에요. 내가 공개할 기능만 골라 내보내는 겁니다. 상속이 부모의 모든 걸 강제로 물려받던 것과 정반대죠.
⚠️ 언제 깨나 — "상속보다 컴포지션"이 기본값이라고 해서 상속을 0으로 만들라는 게 아니에요. 진짜 is-a 관계가 성립하고, 부모가 확장을 의도해 설계된 경우엔 상속이 더 깔끔합니다. 또 컴포지션은 위임 메서드를 일일이 써 줘야 하는 보일러플레이트 비용이 있어요(
contains,size처럼 넘기는 코드가 늘죠). 기능을 거의 통째로 물려받아 그대로 쓸 거면 상속이 코드가 짧습니다. 기본은 컴포지션, 단 진짜 is-a엔 상속 — 이 균형이 핵심이에요.
💡 한 줄 정리
상속(extends) 대신 객체를 품어(has-a) 일을 위임하는 컴포지션은, 부모 내부 구현에 묶이지 않고 공개할 기능만 골라 내보낸다. "is-a냐 has-a냐"를 물어 has-a면 조합을 택하라. 단, 진짜 is-a + 확장 의도가 있으면 상속도 답이다.
🙋 학생 질문 — "튜터님, 컴포지션으로 기능을 겹겹이 쌓는 것도 가능한가요? 예를 들어 카운트도 세고 로그도 남기고 싶으면요?"
정확히 그 발상이 다음 카테고리로 이어집니다. 컴포지션의 진짜 힘이 거기서 폭발해요.
객체를 품어 위임한다는 발상을 끝까지 밀면, "기능을 입은 객체를 또 다른 기능이 감싸는" 식으로 겹겹이 쌓을 수 있어요. 카운트 세는 Set을 로그 남기는 Set이 감싸고, 그걸 또 다른 기능이 감싸고요. 상속이라면 "카운트+로그", "카운트+로그+검증"처럼 조합마다 클래스가 폭발하는데, 컴포지션은 부품을 끼워 맞추듯 조립합니다.
이걸 정식 패턴으로 다듬은 게 데코레이터 패턴이에요. 다음 카테고리 디자인 패턴(C-2 구조 패턴)에서 오늘 배운 컴포지션을 그대로 이어받아 제대로 다룹니다. 오늘은 "조합은 겹쳐 쌓을 수 있다"는 씨앗만 심어 두죠.
Step 6: "추상화가 너무 많아 오히려 복잡해요" (SOLID는 도그마가 아니다)
자, 오늘로 SOLID 다섯 글자가 다 모였습니다. 한 장으로 정리해 볼게요.
SOLID — 클래스 사이를 '바꾸기 쉽게' 만드는 다섯 손잡이
S 단일 책임 (SRP) 한 클래스가 바뀔 이유는 하나 (B-1)
O 개방-폐쇄 (OCP) 확장엔 열고, 수정엔 닫고 (B-1)
L 리스코프 (LSP) 자식이 부모 약속을 온전히 지킨다 (B-1)
I 인터페이스 (ISP) 안 쓰는 메서드를 강요하지 않는다 (오늘)
D 의존성 역전 (DIP) 구체가 아니라 약속에 기댄다 (오늘)
다섯 모두 한 곳을 향한다 → "낮은 결합, 높은 응집"
다섯 원칙이 결국 지난 시간 첫머리에 봤던 그 두 축, 낮은 결합·높은 응집으로 모인다는 게 보이시죠. 여기까지가 SOLID의 밝은 면입니다. 그런데 이 과목은 여기서 한 걸음 더 나갑니다. 이 다섯 원칙조차 맹신하면 독이 된다는 것, 이게 오늘의 진짜 결론이에요.
코드 리뷰에서 "추상화가 너무 많아 오히려 복잡해요"라는 말을 듣는다면, 십중팔구 SOLID를 과하게 적용한 겁니다. 어떤 모습으로 나타나는지 볼게요.
① 원칙끼리 충돌한다. SRP를 극단으로 밀면 클래스가 잘게 폭발해서, 기능 하나를 따라가려고 파일 열 개를 뒤지게 돼요. OCP를 위해 확장점을 여기저기 열어 두면, 정작 그 확장이 영영 일어나지 않아 추상화만 짐으로 남죠. 한 원칙을 끝까지 밀면 다른 가치(단순함·가독성)와 부딪힙니다. SOLID 안에서도 균형이 필요해요.
② 추상화가 비용을 청구한다. 인터페이스 하나에 구현이 딱 하나뿐인데도 "DIP니까" 하고 약속을 끼워 넣으면, 코드를 읽는 사람은 약속과 구현 사이를 계속 점프해야 합니다. 추상화는 미래의 변화에 대비하는 보험인데, 일어나지 않을 변화에 든 보험료는 그냥 낭비예요.
③ 규칙을 외워 맹목적으로 적용한다(카고컬트). "인터페이스를 쓰면 좋은 코드", "상속은 무조건 나쁨" 같은 만트라를 이유도 모른 채 따르는 걸 카고컬트(cargo cult, 형식만 흉내 내는 맹신)라고 해요. 형식은 SOLID인데 정작 코드는 더 복잡해지는, 가장 흔한 함정입니다.
그래서 한 문장으로 못 박을게요. SOLID는 목표가 아니라 수단입니다. 진짜 목표는 늘 하나예요. "읽기 좋고, 바꾸기 쉬운 코드." SOLID는 거기 도달하기 위한 손잡이일 뿐이고, 손잡이를 잡는 것 자체가 목적이 되면 본말이 뒤집힙니다. 어떤 원칙을 적용했더니 코드가 더 어려워졌다면, 그건 원칙을 잘못 쓴 거예요.
⚠️ 이 과목의 척추 — 규칙을 외우는 게 주니어, 언제 깰지 아는 게 미들이다. SOLID·클린 코드를 둘러싼 현장의 오랜 논쟁도 결국 여기로 모입니다. 원칙 자체는 유효하지만, 그 조언은 맥락에 기댄 것이라 "항상·무조건"이 붙는 순간 위험해진다는 거죠. 유능한 개발자는 규칙을 알되, 이 상황에서 그 규칙이 득인지 짐인지를 판단합니다. 오늘 각 Step의 ⚠️가 다 그 판단의 연습이었어요. 이 메타 스킬(언제 깰지 판단하는 힘)은 마지막 모듈에서 레거시·코드 리뷰와 함께 정면으로 다룹니다.
💡 한 줄 정리
SOLID 다섯 원칙은 "낮은 결합·높은 응집"을 향한 수단이지 목표가 아니다. 과하게 적용하면 원칙끼리 충돌하고 추상화가 짐이 된다. 규칙을 외우는 건 주니어, 맥락을 보고 언제 깰지 판단하는 게 미들이다.
🙋 학생 질문 — "튜터님, 그럼 결국 SOLID를 지키라는 거예요, 말라는 거예요? 헷갈려요."
그 헷갈림이 사실 정상이고, 거기서 한 단계 올라서는 게 이 과목의 목표예요.
답은 "기본적으로 지키되, 이유를 알고 지켜라"입니다. SOLID는 수많은 코드가 망가지고 고쳐지며 쌓인 경험의 결론이라, 기본값으로는 거의 항상 옳아요. 잘 모르겠을 땐 일단 따르는 게 안전합니다.
다만 따르는 이유를 알아야 해요. "왜 이 인터페이스를 두는가"에 "구현이 바뀔 것 같아서"라고 답할 수 있으면 잘 쓰는 거고, "남들이 좋다니까"밖에 안 나오면 카고컬트 신호예요. 그럴 땐 멈추고 물어보세요. "이걸 안 하면 무슨 문제가 생기지? 정말 생기나?"
그 질문에 스스로 답하다 보면, 어느 순간 "여긴 원칙대로, 여긴 과하니 덜어내자"가 보이기 시작해요. 그 분별이 생기는 지점이 바로 주니어에서 미들로 넘어가는 문턱입니다. 오늘 배운 모든 ⚠️가 그 연습이었어요.
마무리
오늘은 SOLID의 마지막 두 글자를 채우고, 코드 재사용의 더 나은 길과 함께, 그 모든 원칙을 대하는 태도까지 짚었어요.
오늘 배운 핵심 세 가지
💡 하나 — ISP(인터페이스 분리). 뚱뚱한 인터페이스는 안 쓰는 메서드를 강요해 거짓 약속을 만든다. 역할별로 쪼개면 누구도 자기와 상관없는 기능에 묶이지 않는다. 단, 쪼개는 단위는 메서드가 아니라 역할이다.
💡 둘 — DIP(의존성 역전)와 DI(의존성 주입). 고수준은 구체가 아니라 약속(추상)에 기대고, 저수준이 그 약속을 구현하게 해 의존 방향을 뒤집는다. 약속의 구현은 바깥에서 주입받으며, 이 덕분에 교체와 테스트가 쉬워진다. DIP는 방향의 원칙, DI는 끼워 주는 손기술.
💡 셋 — 상속보다 컴포지션, 그리고 도그마 경계. 상속은 부모 내부 구현에 묶이는 강결합이라, 기본값은 객체를 품어 위임하는 컴포지션(has-a)이다. 그리고 SOLID조차 목표가 아닌 수단이라, 규칙을 외우기보다 언제 깰지 판단하는 게 미들의 실력이다.
다음 시간 예고
여기까지가 카테고리 B, 객체지향 설계 원칙의 끝입니다. 응집도·결합도라는 두 축으로 시작해 SOLID 다섯 원칙으로 클래스 사이의 구조를 보는 눈을 길렀어요. 이제 우리는 "어떤 설계가 좋은가"의 기준을 갖췄습니다.
다음 시간부터는 카테고리 C, 디자인 패턴으로 넘어갑니다. 선배 개발자들이 같은 문제를 수없이 마주치며 정리해 둔 검증된 해법의 카탈로그예요. 첫 모듈인 C-1에서는 객체를 어떻게 만드나를 다루는 생성 패턴 — 팩토리·빌더, 그리고 그 유명한 싱글톤을 만납니다. 특히 싱글톤은 한때 교과서적 패턴이었다가 지금은 왜 안티패턴으로 재평가됐는지, 오늘 배운 DIP·DI가 그 대안으로 어떻게 등장하는지를 보게 될 거예요. 오늘 Step 5에서 씨앗을 심은 데코레이터(컴포지션을 겹겹이)는 그다음 C-2에서 회수합니다. 설계 원칙이라는 잣대를 손에 쥐었으니, 이제 패턴이 "왜 좋은지"가 제대로 와닿을 차례입니다.
과제
오늘 배운 세 원칙을 직접 손으로 적용해 봅니다. 각 Before 코드를 더 나은 구조로 고쳐 보세요. 정답은 예시답안에서 확인할 수 있어요.
[기초] 뚱뚱한 인터페이스를 역할별로 쪼개기 (ISP)
아래 MediaPlayer는 재생·녹음·스트리밍을 한 인터페이스에 다 몰아넣었습니다. 그래서 재생만 하는 BasicPlayer가 안 쓰는 record()와 stream()을 예외로 채우고 있어요.
// Before — 뚱뚱한 인터페이스
public interface MediaPlayer {
String play(String file);
String record(String file);
String stream(String url);
}
public class BasicPlayer implements MediaPlayer {
@Override
public String play(String file) {
return "[재생] " + file;
}
@Override
public String record(String file) {
throw new UnsupportedOperationException("녹음 미지원");
}
@Override
public String stream(String url) {
throw new UnsupportedOperationException("스트리밍 미지원");
}
}
이 인터페이스를 역할별로 쪼개고, BasicPlayer가 자기가 쓰는 역할만 구현하도록 고쳐 보세요. 재생·녹음·스트리밍을 다 하는 기기는 어떻게 표현할지도 생각해 보고요.
[응용] 고수준이 구체에 직접 의존하는 코드를 역전시키기 (DIP)
아래 ReportServiceBefore는 출력 방법인 ConsolePrinter를 자기 손으로 직접 new 하고 있습니다. 출력을 콘솔에서 파일로 바꾸려면 이 고수준 클래스를 고쳐야 해요.
// Before — 고수준이 저수준 구체에 직접 의존
public class ConsolePrinter {
public String print(String content) {
return "[콘솔] " + content;
}
}
public class ReportServiceBefore {
private final ConsolePrinter printer = new ConsolePrinter();
public String report(String content) {
return printer.print(content);
}
}
출력이라는 약속(인터페이스)을 도입하고, 그 구현을 생성자로 주입받도록 고쳐 보세요. 그런 다음 콘솔 대신 다른 출력으로 갈아 끼워도 ReportService가 한 줄도 안 바뀌는지 확인해 보세요.
[심화] 상속으로 깨지는 코드를 컴포지션으로 (상속보다 컴포지션)
아래 LoggingList는 ArrayList를 상속해 "원소가 추가될 때마다 로그를 쌓는" 기능을 붙이려 했습니다. add()를 오버라이드해 로그를 남기게 했죠.
// Before — add() 를 오버라이드해 로그를 쌓으려 한다
public class LoggingList<E> extends ArrayList<E> {
private final List<String> logs = new ArrayList<>();
@Override
public boolean add(E element) {
logs.add("추가: " + element);
return super.add(element);
}
public List<String> getLogs() {
return logs;
}
}
add() 하나씩 넣으면 로그가 잘 쌓여요. 그런데 addAll()로 원소 여러 개를 한꺼번에 넣으면 로그가 어떻게 될까요? Step 4의 CountingHashSet은 부모의 addAll이 내부에서 add를 불러 이중 집계됐는데, ArrayList.addAll도 똑같이 그럴까요? 직접 확인해 보고, 왜 그런 결과가 나오는지(부모의 내부 구현이 Step 4와 어떻게 다른지) 짚은 뒤, 상속을 버리고 List를 품는 컴포지션으로 다시 작성해 보세요. is-a와 has-a 중 무엇이 맞는지도 판단해 보고요.
생각해볼 주제
오늘 배운 원칙들의 "경계"를 스스로 더 밀어 보는 질문 셋입니다. 정답이 하나로 떨어지지 않는, 판단을 묻는 주제예요.
1. ISP를 끝까지 밀면 "메서드 하나당 인터페이스 하나"가 되는데, 어디서 멈춰야 할까?
인터페이스를 잘게 쪼갤수록 "안 쓰는 메서드 강요"는 줄어듭니다. 그런데 극단으로 가면 인터페이스가 수십 개로 폭발해 오히려 복잡해지죠. 쪼개기와 응집(함께 변하는 것은 함께 둔다) 사이의 균형점은 어디일까요? "역할 단위"라는 기준을 어떻게 실전에서 가늠할 수 있을까요?
2. DIP를 위해 구현이 하나뿐인 클래스에도 인터페이스를 만들어야 할까?
"구체 말고 추상에 기대라"는 원칙을 글자 그대로 따르면, 모든 클래스에 인터페이스를 씌우게 됩니다. 하지만 그건 과잉 추상화라고 배웠죠. 구현이 지금은 하나뿐인데 인터페이스를 미리 둘지(미래 대비), 정말 필요해질 때 뽑아낼지(YAGNI)를 어떤 기준으로 가를 수 있을까요?
3. "상속보다 컴포지션"이 기본값이라면, 상속이 정답인 경우는 언제일까?
현대 설계는 컴포지션을 기본으로 권합니다. 그런데 상속을 0으로 만들 수는 없어요. 자바 표준 라이브러리도, 프레임워크도 상속을 씁니다. 상속이 컴포지션보다 더 나은 선택이 되는 조건은 무엇일까요? "is-a가 성립한다"는 것만으로 충분할까요, 아니면 더 필요한 조건이 있을까요?
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안입니다. 정답이 하나뿐인 건 아니에요. 인터페이스 이름이나 쪼개는 단위는 "이렇게도 할 수 있다"는 한 갈래일 뿐, 안 쓰는 메서드가 사라지고 의존이 약속을 향하면 다른 모습도 좋습니다. 중요한 건 "왜 이렇게 바꿨는가"입니다.
과제 예시답안
🎯 [과제 1 예시답안] 뚱뚱한 인터페이스를 역할별로 쪼개기 (ISP)
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 역할 식별 | 재생·녹음·스트리밍을 별도 약속으로 갈랐는가 | ★★★ |
| 필요한 역할만 구현 | 재생만 하는 기기가 Playable만 구현하는가 |
★★★ |
| 복합 기기 표현 | 다 하는 기기가 세 역할을 함께 구현하는가 | ★★☆ |
| 동작 보존 | 재생 결과가 그대로인가 | ★★☆ |
풀이 예시
❌ Before — 재생만 하는 BasicPlayer가 안 쓰는 record()·stream()을 예외로 채운다.
public interface MediaPlayer {
String play(String file);
String record(String file);
String stream(String url);
}
✅ After — 기능을 역할별 약속으로 쪼갠다.
public interface Playable {
String play(String file);
}
public interface Recordable {
String record(String file);
}
public interface Streamable {
String stream(String url);
}
이제 재생만 하는 기기는 Playable 하나만 구현합니다. 안 쓰는 메서드가 아예 없어요.
public class MusicPlayer implements Playable {
@Override
public String play(String file) {
return "[재생] " + file;
}
}
진짜 세 기능을 다 하는 기기만 세 역할을 함께 구현합니다.
public class MediaStation implements Playable, Recordable, Streamable {
@Override
public String play(String file) {
return "[재생] " + file;
}
@Override
public String record(String file) {
return "[녹음] " + file;
}
@Override
public String stream(String url) {
return "[스트리밍] " + url;
}
}
💡 튜터의 한마디: 핵심은 "역할"로 가른 겁니다. MediaPlayer 하나에 묶여 있던 세 기능을, 실제로 따로 쓰이는 단위인 재생·녹음·스트리밍으로 쪼갰어요. 그러니 재생만 필요한 기기는 거짓 약속을 채울 일이 없죠. 단, 쪼개는 기준은 메서드 개수가 아니라 역할입니다. 늘 함께 쓰이는 기능까지 떼면 그건 과분리예요. "이 기능을 따로 쓰는 클라이언트가 진짜 있나"를 물어보세요.
🎯 [과제 2 예시답안] 고수준이 구체에 직접 의존하는 코드를 역전시키기 (DIP)
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 추상 도입 | OutputTarget 약속으로 출력을 빼냈는가 |
★★★ |
| 의존 역전 | 고수준이 구체 대신 약속에 기대는가 | ★★★ |
| 주입 | 구현을 생성자로 주입받는가 | ★★★ |
| 교체 확인 | 파일 출력으로 갈아도 서비스가 안 바뀌는가 | ★★☆ |
풀이 예시
❌ Before — ReportServiceBefore가 ConsolePrinter를 직접 new 한다. 출력을 바꾸려면 이 고수준 클래스를 고쳐야 한다.
public class ReportServiceBefore {
private final ConsolePrinter printer = new ConsolePrinter();
public String report(String content) {
return printer.print(content);
}
}
✅ After — "출력"이라는 약속을 두고, 그 구현을 바깥에서 주입받는다.
public interface OutputTarget {
String output(String content);
}
public class ConsoleOutput implements OutputTarget {
@Override
public String output(String content) {
return "[콘솔] " + content;
}
}
public class ReportServiceAfter {
private final OutputTarget target;
public ReportServiceAfter(OutputTarget target) {
this.target = target;
}
public String report(String content) {
return target.output(content);
}
}
이제 파일 출력이 필요하면 FileOutput을 하나 더해 끼워 주면 끝입니다. ReportServiceAfter는 한 줄도 안 바뀌어요.
public class FileOutput implements OutputTarget {
@Override
public String output(String content) {
return "[파일] " + content;
}
}
💡 튜터의 한마디: 의존의 방향을 뒤집은 게 핵심입니다. Before에선 고수준 서비스가 구체 출력 클래스를 직접 알고 만들었죠. After에선 OutputTarget이라는 약속만 알고, 무엇을 끼울지는 바깥에서 정합니다. 그래서 콘솔이든 파일이든, 심지어 테스트용 가짜 출력이든 갈아 끼워도 서비스는 그대로예요. 단, 출력 방식이 정말 하나로 고정이고 바뀔 일이 없다면 이 추상화는 과할 수 있습니다. "갈아 끼울 일이 있나"가 판단의 기준이에요.
🎯 [과제 3 예시답안] 상속으로 깨지는 코드를 컴포지션으로 (상속보다 컴포지션)
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 함정 진단 | ArrayList.addAll이 add를 안 불러 일괄 로그가 빠짐을 짚었는가 |
★★★ |
| 컴포지션 전환 | 상속을 버리고 List를 품었는가 |
★★★ |
| 위임 제어 | addAll에서 로그를 직접 챙겼는가 |
★★☆ |
| is-a/has-a 판단 | has-a가 맞다고 판단했는가 | ★★☆ |
풀이 예시
❌ Before — add()만 오버라이드했다. add("a") 한 건은 로그가 남지만, addAll로 둘을 한꺼번에 넣으면 로그는 여전히 1건이다.
public class LoggingList<E> extends ArrayList<E> {
private final List<String> logs = new ArrayList<>();
@Override
public boolean add(E element) {
logs.add("추가: " + element);
return super.add(element);
}
public List<String> getLogs() {
return logs;
}
}
왜 일괄 추가가 로그에서 빠질까요? Step 4의 HashSet은 addAll이 내부에서 add를 불러 이중 집계됐는데, ArrayList는 정반대예요. ArrayList.addAll은 성능을 위해 배열을 통째로 복사하도록 오버라이드돼 있어서, 우리가 오버라이드한 add를 부르지 않습니다. 그래서 원소는 다 들어가는데 로그만 소리 없이 빠져요. 같은 "부모 내부 구현에 발이 묶이는" 함정의 반대 얼굴입니다.
✅ After — 상속을 버리고 List를 품어(has-a) 일을 위임한다. addAll도 우리가 직접 제어하니 로그가 빠짐없이 남는다.
public class LoggingListComposed<E> {
private final List<E> list;
private final List<String> logs = new ArrayList<>();
public LoggingListComposed(List<E> list) {
this.list = list;
}
public boolean add(E element) {
logs.add("추가: " + element);
return list.add(element);
}
public boolean addAll(Collection<? extends E> elements) {
for (E element : elements) {
logs.add("추가: " + element);
}
return list.addAll(elements);
}
public List<String> getLogs() {
return logs;
}
public int size() {
return list.size();
}
}
💡 튜터의 한마디: Step 4의 HashSet은 이중 집계(3을 넣으면 6), 이번 ArrayList는 누락(3을 넣어도 로그 1). 증상은 정반대지만 뿌리는 똑같아요. 자식이 부모의 보이지 않는 내부 구현에 발이 묶인 겁니다. 컴포지션으로 가면 list라는 약속에만 기대고 모든 동작을 내가 제어하니, 부모의 내부 사정에 휘둘릴 일이 없어요. is-a와 has-a로 보면, LoggingListComposed는 "특별한 종류의 리스트(is-a)"가 아니라 "리스트를 부품으로 쓰는 기록기(has-a)"라서 컴포지션이 맞습니다.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] ISP를 끝까지 밀면 어디서 멈춰야 하는가?
문제 상황 요약
인터페이스를 잘게 쪼갤수록 "안 쓰는 메서드 강요"는 줄어듭니다. 그런데 극단으로 가면 메서드 하나당 인터페이스 하나가 되어 수십 개로 폭발하죠. 쪼개기와 응집 사이의 균형점은 어디일까요?
튜터의 가이드 및 해설
핵심은 인터페이스에도 응집이 있다는 점입니다. ISP의 분리 단위는 메서드가 아니라 "함께 쓰이는 클라이언트의 묶음", 즉 역할이에요. 그래서 질문을 바꿔야 합니다. "이 메서드들을 쪼갤까"가 아니라 "실제로 서로 다른 클라이언트가 서로 다른 메서드 집합을 쓰는가"로요.
재생만 하는 기기와 녹음까지 하는 기기가 따로 존재하니, 재생과 녹음은 다른 역할이라 쪼갭니다. 반대로 open()과 close()처럼 늘 한 쌍으로 같이 호출되는 메서드를 떼면, 그건 과분리예요. 같이 변하고 같이 쓰일 운명이니까요. ISP를 "무조건 잘게"로 받아들이는 것 자체가 또 다른 카고컬트입니다. 인터페이스 하나에도 SRP를 적용한다고 생각하면 균형이 잡혀요. "이 약속은 한 가지 역할로 설명되는가"를 물어보세요.
🎯 면접에선 이렇게 나온다
"ISP의 분리 단위는 메서드가 아니라 '함께 쓰는 클라이언트 묶음', 곧 역할입니다. 인터페이스에도 응집이 있어서, 늘 함께 호출되는 메서드를 떼면 그게 과분리예요. 저는 '실제로 다른 클라이언트가 다른 메서드 집합을 쓰는가'를 분리 기준으로 둡니다."
💡 실무에선
처음부터 잘게 쪼개려 애쓰지 마세요. 구현체에 빈 껍데기 메서드가 생기거나, 한 클라이언트가 인터페이스의 일부만 쓰는 게 보일 때 — 그 신호가 곧 쪼갤 시점입니다.
🤔 [생각해볼 주제 2] 구현이 하나뿐인 클래스에도 인터페이스를 만들어야 하는가?
문제 상황 요약
"구체 말고 추상에 기대라"를 글자 그대로 따르면 모든 클래스에 인터페이스를 씌우게 됩니다. 하지만 그건 과잉 추상화죠. 구현이 지금 하나뿐인데 인터페이스를 미리 둘지(미래 대비), 정말 필요해질 때 뽑아낼지(YAGNI)를 무엇으로 가를까요?
튜터의 가이드 및 해설
DIP는 "모든 의존을 뒤집어라"가 아니라 "바뀌거나 갈아 끼울 의존을 뒤집어라"입니다. 추상화는 공짜가 아니라서, 인터페이스 하나가 파일을 늘리고 코드를 읽을 때 한 번 더 점프하게 만들어요. 그러니 판단 기준은 두 축입니다. 변화의 가능성과 테스트 대역의 필요예요.
저장소·외부 API·알림 채널처럼 구현이 여럿 생기거나 바뀔 게 뻔한 경계, 또는 테스트에서 가짜로 바꿔 끼워야 하는 의존은 미리 추상화하는 게 이득입니다. 반대로 절대 안 바뀔 내부 계산기까지 인터페이스로 감싸면 읽는 사람만 피곤해져요. 다행히 현대 IDE는 "인터페이스 추출"을 한 번에 해 줍니다. 그래서 정말 두 번째 구현이 필요해지는 순간 그때 뽑아내도 늦지 않아요. 확신이 없으면 구체 클래스로 두는 게 낫습니다.
🎯 면접에선 이렇게 나온다
"DIP는 모든 의존을 뒤집으라는 게 아니라, 바뀌거나 갈아 끼울 의존을 뒤집으라는 원칙입니다. 구현이 하나뿐이고 변화도 테스트 대역 필요도 없으면 인터페이스는 읽기 비용만 늘리는 군더더기예요. 저는 외부 경계와 테스트가 필요한 곳을 우선 추상화하고, 나머지는 필요해질 때 추출합니다."
💡 실무에선
인터페이스 하나에 Impl 하나가 영원히 그대로라면 과잉 추상화 신호입니다. 거꾸로 외부 시스템에 닿는 의존은 지금 구현이 하나여도 처음부터 감싸 두세요. 그쪽은 거의 항상 바뀝니다.
🤔 [생각해볼 주제 3] "상속보다 컴포지션"이 기본값이라면, 상속이 정답인 경우는?
문제 상황 요약
현대 설계는 컴포지션을 기본으로 권합니다. 그런데 상속을 0으로 만들 수는 없어요. 자바 표준 라이브러리도, 프레임워크도 상속을 씁니다. 상속이 컴포지션보다 나은 선택이 되는 조건은 무엇일까요? "is-a가 성립한다"는 것만으로 충분할까요?
튜터의 가이드 및 해설
is-a "만으로는" 부족합니다. 안전한 상속에는 조건이 둘 더 붙어요. 첫째, 자식이 부모를 행동까지 온전히 치환할 수 있어야 합니다(LSP). 둘째, 부모가 상속을 의도해 설계돼 있어야 해요. 어떤 메서드를 자식이 오버라이드해도 안전한지가 문서로 약속돼 있거나, 애초에 abstract로 "여기를 채우라"고 열어 둔 경우죠.
이 둘로 보면 Step 4의 사고가 왜 났는지 분명해집니다. HashSet이나 ArrayList는 "나를 상속해서 메서드를 오버라이드해도 좋아"라고 설계된 클래스가 아니었어요. 남이 만든, 확장을 염두에 두지 않은 구체 클래스를 재사용 욕심에 상속한 게 화근이었죠. 반대로 프레임워크가 제공하는 추상 클래스를 확장하는 건 정당한 상속입니다. 부모가 "이 자리를 채우라"고 의도해 연 것이고, 자식은 그 약속을 지키며 일부만 채우니까요. 이렇게 부모가 확장을 위해 뼈대를 열어 두는 방식은 뒤에 디자인 패턴(템플릿 메서드)에서 다시 만나요. 정리하면, 안정적이고 진짜 is-a가 성립하며 부모가 확장을 의도해 설계됐을 때 상속이 정답입니다.
🎯 면접에선 이렇게 나온다
"상속의 조건은 is-a에 더해 둘입니다. 자식이 부모를 행동까지 치환할 수 있어야 하고(LSP), 부모가 상속을 의도해 설계돼 있어야 해요. 남이 만든, 확장을 염두에 두지 않은 구체 클래스를 재사용 욕심에 상속하는 게 가장 흔한 실수입니다. 프레임워크의 추상 클래스를 확장하는 건 그 둘을 만족하는 정당한 상속이고요."
💡 실무에선
상속을 쓸지 고민될 땐 세 가지를 물어보세요. "확장할 그 클래스가 abstract인가, 문서가 상속을 권하는가, 내가 만든 안정적인 부모인가." 셋 다 아니라면 거의 항상 컴포지션이 답입니다.