메타데이터
- 날짜: 2026-08-29
- 채널: ArjanCodes
- 원문 제목: You Think This Is Good OOP… It’s Not
- 원문 길이: 1,162초
- URL: https://www.youtube.com/watch?v=RqcEK7sWesQ
- Video ID:
RqcEK7sWesQ
📌 핵심 질문 / 전체 논점
==객체지향(Object-Oriented Programming)의 문법을 사용했다는 사실이 좋은 설계를 보장하지 않는다. 클래스·상속·추상화가 도메인의 의미와 변화 이유를 정확히 표현하는지 먼저 확인해야 한다.==
- 구현 세부 사항을 재사용하려는 상속은 거짓된
is-a관계를 만들므로 Composition으로 대체해야 한다. - 값의 조합이나 독립적인 기능을 서브클래스 계층으로 인코딩하면 조합 폭발과 결합도 문제가 생긴다.
- 작은 Protocol, 분리된 계약, 구체적인 함수가 거대한 기본 클래스나 시기상조의 추상화보다 명확한 경계를 만든다.
좋은 OOP는 모든 것을 클래스로 만드는 기법이 아니다. 객체는 불변식(Invariant)을 보호하고, 상태와 그 상태에 의미 있는 동작을 결합하며, 명확한 경계를 드러낼 때 가치를 만든다. 상태나 식별자가 없는 작업은 일반 함수로 두고, 독립적으로 변하는 요소는 Composition으로 조립하며, 필요한 능력만 Protocol로 요구해야 한다.
1. 구현 메서드가 필요하다는 이유로 상속하기
상속은 의미 있는 is-a 관계를 표현해야 하며, 단순히 다른 객체의 메서드를 빌려 쓰는 도구가 되어서는 안 된다.
1.1. 일일 판매 보고서와 거짓된 is-a 관계
-
메서드 재사용을 위한 다중 상속
- 예제 구성:
DailySalesReport클래스가 데이터베이스 서버(Database Server)와 로거(Logger)를 각각 상속한다. 보고서가 두 객체에서 유용한 메서드를 하나씩 필요로 한다는 이유다. - 겉보기의 그럴듯함: 두 기본 클래스의 이름이 잘 지어져 있어 코드가 전형적인 OOP 상속처럼 보인다. 실제 실행 결과도 데이터베이스 조회와 로깅이 기대한 대로 동작한다.
- 예제 구성:
-
관계 모델링의 오류
is-a검증: 상속은 일반적으로 “A는 B다”라는 관계를 표현한다. 그러나 일일 판매 보고서는 데이터베이스 서버도 아니고 로거도 아니다.- 계층과 현실의 불일치: 클래스 계층은 존재하지만 도메인 관계를 올바르게 나타내지 못한다. 호출에 필요한 구현 메서드와 객체의 본질을 혼동한 결과다.
1.2. Composition과 의존성 주입
-
필요한 객체를 생성자에서 받기
- 초기화 함수:
DailySalesReport에 initializer를 추가하고 데이터베이스 서버와 로거를 인자로 받는다. - 인스턴스 보관: 전달받은 두 객체를 보고서 인스턴스에 저장한다. 보고서는 부모 클래스의 메서드를 물려받지 않고 저장된 의존성(Dependency)을 직접 호출한다.
- 초기화 함수:
-
동일한 기능, 더 나은 경계
- 실행 결과: 코드를 다시 실행하면 상속을 사용한 버전과 결과는 완전히 동일하다. 달라진 점은 상속 관계가 사라지고 두 능력에만 의존한다는 사실이다.
- 테스트 용이성: 메인 함수에서 데이터베이스 서버와 로거 인스턴스를 명시적으로 조립하므로 테스트에서는 대체 객체(Mock 또는 Stub)를 주입할 수 있다.
-
판단 규칙
- 구현 세부 사항의 상속 금지: 다른 클래스의 구현 메서드가 필요해서 상속했다면, 실제 의도는 Composition일 가능성이 높다.
- 의미를 먼저 확인: “이 객체가 정말 저 객체의 한 종류인가?”에 아니오라고 답해야 한다면, 기능을 필드와 생성자 인자로 연결한다.
2. 값의 조합을 타입으로 바꾸기
국가, 세율, 재시도 횟수처럼 데이터로 표현할 수 있는 변형을 클래스와 서브클래스로 만들면 의미 있는 타입이 아니라 설정 조합의 계층이 된다.
2.1. Checkout 서브클래스와 조합 폭발
-
값만 바꾸는 타입들
- 기본 Checkout: 표준
Checkout이 특정 세율(Tax Rate)과 재시도 횟수(Retry Count)를 갖고, 여러 메서드가 그 값을 사용한다. - 국가별 변형:
GermanCheckout은 19% 세율을 사용한다.ReliableGermanCheckout은 독일 결제에 더 높은 재시도 횟수를 적용한다.
- 기본 Checkout: 표준
-
행동과 설정의 혼동
- 새 행동의 부재: 두 서브클래스는 실질적으로 다른 동작을 도입하지 않고 세율과 재시도 횟수라는 값만 바꾼다.
- 이름이 주는 착시:
German과Reliable이라는 의미 있는 이름이 설정에 붙어 객체지향적으로 보이지만, 이름이 붙었다고 값이 타입으로 변하는 것은 아니다.
-
차원 추가에 따른 폭발
- 국가 차원: 프랑스 결제를 추가하면
FrenchCheckout이 필요하다. - 신뢰성 차원:
ReliableFrenchCheckout까지 추가해야 하며, 세계 각국마다 표준형과 안정형의 모든 조합이 생긴다. 설정 차원이 늘어날수록 서브클래스 수가 조합적으로 증가한다. - 가벼운 농담: 독일 결제 시스템에서 문제를 겪은 적은 한 번도 없지만 단지 예시일 뿐이라는 말로 국가별 이름의 인위성을 짚는다.
- 국가 차원: 프랑스 결제를 추가하면
2.2. Frozen Data Class와 Factory
-
설정을 데이터로 모델링하기
CheckoutConfig:tax_rate와retry_count를 담는data class를 만든다.- 불변 설정: 설정을
frozen으로 선언해 결제 객체가 생성된 뒤 정책 값이 임의로 바뀌지 않게 한다. - 단일 Checkout 타입:
Checkout은 특정 국가나 신뢰성 조합을 나타내는 여러 타입 대신CheckoutConfig를 가진다.
-
생성 함수로 의도 표현하기
- 명시적 Factory:
create_reliable_german_checkout같은 함수를 만들고 필요한 세율과 재시도 횟수를 담은CheckoutConfig와 함께Checkout을 반환한다. - 조합 관리: 여러 함수를 만들거나 Factory를 사용해 다양한 설정을 생성할 수 있지만, 설정마다 상속 계층을 만들 필요는 없다.
- 명시적 Factory:
-
Law of Demeter 주의점
- 실행 중 드러난 문제: Checkout 내부 설정에 직접 접근하려고 하면 설정 경계를 잘못 통과하는 코드가 생긴다. 예제도 처음 실행했을 때
config에 접근해야 해서 오류가 발생한다. - 개선 방법: 필요한 정보를 별도 함수에서 만들거나
Checkout에 값을 확인하는 메서드·프로퍼티를 제공한다.checkout.config.tax_rate처럼 객체 내부의 객체를 연쇄적으로 탐색하는 방식은 Law of Demeter 위반으로 이어질 수 있다.
- 실행 중 드러난 문제: Checkout 내부 설정에 직접 접근하려고 하면 설정 경계를 잘못 통과하는 코드가 생긴다. 예제도 처음 실행했을 때
-
타입이 표현해야 할 것
- 행동: 타입과 클래스는 실제로 다른 행동을 나타내야 한다.
- 제약과 의미: 타입이 필요한 이유는 서로 다른 제약(Constraint)이나 도메인 의미가 있기 때문이지, 가능한 설정 조합을 모두 이름 붙이기 위해서가 아니다.
3. 독립적인 기능을 계층 구조로 모델링하기
서로 독립적으로 켜고 끄거나 조합할 수 있는 기능은 모든 조합을 서브클래스로 만들지 말고, 설정·전략·별도 파이프라인으로 조립해야 한다.
3.1. 제품 카탈로그 동기화와 기능 조합
-
기본 동기화 흐름
- 구성 요소: 카탈로그 API(Catalog API)가 있고,
ProductCatalogSynchronizer가 API를 사용해 제품 목록과 카탈로그를 동기화한다. - 재시도 기능: 동기화 중 재시도 동작이 필요하며, 예제에서는
for루프가 최대 시도 횟수만큼 작업을 반복하는 방식으로 구현된다.
- 구성 요소: 카탈로그 API(Catalog API)가 있고,
-
계층에 덧붙인 독립 기능
- 검증과 재시도:
ValidatingRetryingProductCatalogSynchronizer가 재시도 기능을 상속한 뒤 검증(Validation) 기능을 추가한다. - 서브클래스의 연쇄: 다른 옵션이 생길 때마다 앞선 서브클래스에 또 다른 서브클래스를 붙인다. 검증과 재시도처럼 서로 독립적인 기능이 계층의 순서에 종속된다.
- 검증과 재시도:
3.2. Sync Options와 동작의 분리
-
작업별 설정 객체
SyncOptions: 검증을 수행할지 여부(yes/no)와 허용할 최대 시도 횟수(maximum attempts)를 한 객체에 담는다.SyncResult: 동기화가 실제로 어떻게 진행됐는지 보고서(Report)를 포함한 결과 객체를 반환한다.
-
함수 기반 조립
- 단일 작업 함수: API, 제품 목록, 동기화 옵션을 받는
sync_catalog함수로 전환한다. - 옵션에 따른 동작: 옵션 값에 따라 검증과 재시도를 수행한다. 모든 조합을 클래스 이름으로 인코딩하지 않아도 된다.
- 단일 작업 함수: API, 제품 목록, 동기화 옵션을 받는
-
결합도라는 절충점
- 옵션의 영향:
SyncOptions가 카탈로그 동기화의 검증 여부를 제어하므로 설정과 동작 사이에 결합이 생긴다. - 더 나은 분리 가능성: 어떤 상황에서는 동기화 과정에서 검증할 필요가 없다. 이미 검증된 제품 목록을 입력으로 받고 검증 기능을 동기화 바깥으로 완전히 분리할 수 있다.
- 옵션의 영향:
-
판단 규칙
- 독립적인 토글: 각 기능을 독립적으로 켜거나 끌 수 있다면 기능의 모든 조합을 서브클래스로 만들지 않는다.
- 설계 연습: 현재 옵션이 동기화에 꼭 필요한지, 검증된 제품만 받는 별도 단계가 더 적절한지 점검하면 결합도를 더 낮출 수 있다.
4. 모든 객체에 하나의 모양을 강요하는 거대한 기본 클래스
서로 다른 워크플로가 실제로 사용하는 능력이 다르다면, 모든 기능을 가진 God Base Class를 전달하는 대신 필요한 능력별 Protocol을 정의해야 한다.
4.1. Store Integration과 Stamp Coupling
-
거대한 기본 통합 클래스
- 도메인 데이터: 공급업체 세션 데이터 클래스(Supplier Session Data Class)와 재고 품목(Inventory Item)이 있다.
BaseStoreIntegration: 제품 이미지 업로드, 재고 다운로드, 예약된 SKU 다운로드, 주문 Webhook 구독, 반품 라벨 요청 등 여러 Store 기능을 한 기본 클래스에 넣는다.
-
Warehouse Supplier 흐름
- 공급업체 구현: 창고 공급업체는 인증(Authentication), 재고 다운로드, 몇 가지 보조 메서드를 제공한다. 창고와 무관한 제품 이미지 업로드나 반품 라벨 기능은 실제로 지원하지 않는다.
- 연결과 동기화:
connect_to_supplier는 기본 Store Integration을 받아 공급업체 세션을 만들고,sync_inventory는 같은 통합 객체와 세션을 받아 재고 품목 목록을 반환한다.
-
Stamp Coupling의 문제
- 과잉 전달: 재고 워크플로에 필요한 것은 인증과 재고·예약 SKU 조회뿐인데, 함수는 이미지 업로드와 주문 Webhook 같은 사용하지 않는 기능까지 포함한 전체 기본 클래스를 받는다.
- 잘못된 접근 가능성: 함수가 사용하면 안 되는 메서드에도 접근할 수 있다. 응집도(Cohesion)가 낮아지고 결합도(Coupling)가 높아진다.
- 미래의 불투명한 고장: Warehouse Supplier의 다른 메서드는 사실상 구현되지 않았거나
NotImplementedError를 낼 수 있다. 나중에 코드가 깨져도 거대한 계약이 원인인지 알아내기 어렵다.
4.2. Capability별 Protocol과 Interface Segregation
-
작은 구조적 인터페이스
AuthenticatedProtocol:authenticate메서드만 선언한다.InventorySourceProtocol:download_inventory와download_reserved_skus처럼 재고 동기화에 필요한 두 메서드만 선언한다.
-
구현과 요구사항의 정렬
- 상속 제거:
WarehouseSupplier는 거대한 기본 클래스와 상속 관계를 맺지 않고 인증과 재고 기능에 필요한 메서드만 가진다. - 워크플로의 최소 요구:
connect_to_supplier는Authenticated객체를 받고,sync_inventory는InventorySource객체를 받는다. 각 함수는 실제로 호출할 메서드만 알고 있다.
- 상속 제거:
-
Python다운 구조적 타이핑
- 큰 객체를 넘겨도 경계는 작게: 메인 함수는 여전히 하나의
WarehouseSupplier객체를 만들어 두 함수에 전달할 수 있다. 호출되는 함수는 그 객체가 더 큰 객체라는 사실을 알지 못하고 필요한 Protocol만 본다. - 동일한 동작, 안전한 계약: 이전 버전과 기능은 같지만 신급(God-like) 클래스, 사용하지 않는 메서드, 구현되지 않았다는 오류가 사라진다.
- 이름: 함수가 사용하는 정확한 능력만 계약으로 요청하는 방식이 Interface Segregation이다. Python에서는 복잡한 클래스·서브클래스 트리보다 작고 구조적인 Protocol 인터페이스가 자연스럽다.
- 큰 객체를 넘겨도 경계는 작게: 메인 함수는 여전히 하나의
5. 부모처럼 행동할 수 없는 하위 유형 만들기
서브타입(Subtype)은 부모 타입이 약속한 모든 동작을 보장해야 한다. 읽기 전용이라는 제약을 추가하려고 쓰기 가능한 타입의 하위 타입을 만들면 Liskov Substitution Principle을 위반한다.
5.1. Read-Only Order Queue와 LSP 위반
-
원래 계약
Order: 현재는 ID만 가진 주문 객체다.OrderQueue: 주문을 큐에 추가하고(add), 큐에서 조회하는(retrieve) 기능을 제공한다.
-
읽기 전용 하위 클래스의 함정
- 의도: 아무도 큐에 쓰지 못하게 하려고
ReadOnlyOrderQueue를OrderQueue의 서브클래스로 만든다. - 계약 변경:
add가 호출되면 주문을 추가하는 대신 “큐가 읽기 전용”이라는 Runtime Error를 발생시킨다.
- 의도: 아무도 큐에 쓰지 못하게 하려고
-
호출 위치와 실패 시점
- 서로 다른 요구:
print_order_history는 읽기 전용 큐를 원하고,add_expedited_order는 일반 주문 큐에 긴급 주문을 추가한다. - 잘못된 대체:
ReadOnlyOrderQueue가OrderQueue의 서브클래스이므로 일반 큐를 받는 위치에 전달할 수 있다. 예컨대 주문 이력 객체를add_expedited_order에 넘겨도 정적 타입 관점에서는 허용된다. - Runtime Error: 실제 실행 단계에서만 읽기 전용 큐에 쓰려는 문제가 드러난다.
- 서로 다른 요구:
5.2. Order Reader와 Order Writer로 계약 분리
-
Liskov Substitution Principle
- 핵심 의미: 하위 유형은 상위 유형의 보장과 모순되어서는 안 된다.
- 현재 모순: 부모
OrderQueue는 “큐에 항목을 추가할 수 있다”고 보장하지만, 자식ReadOnlyOrderQueue는 같은 메서드의 동작을 바꿔 버린다.
-
읽기·쓰기 계약 분리
OrderReaderProtocol: 주문 정보를 읽는 계약을 제공한다.print_order_history는 이 Protocol만 요구한다.OrderWriterProtocol: 주문을 추가하는 계약을 제공한다.add_expedited_order는 이 Protocol만 요구한다.- 서로 다른 객체:
OrderQueue와OrderHistory를 하나의 상속 계층으로 억지로 묶지 않는다. 두 객체는 서로 다른 존재이며 서로 다르게 행동한다.
-
오류를 앞당기는 효과
- 계약 명확화: 주문 이력은 읽기 계약에만 맞고, 주문 큐는 쓰기 계약에 맞는다.
- IDE의 조기 진단: 외부에서
OrderHistory를add_expedited_order인자로 넘기면 실행할 때까지 기다리지 않고 IDE가 타입 오류를 표시한다.
6. 유사성을 이해하기 전에 추상화하기
코드 모양이 비슷하다는 이유만으로 공통 기본 클래스를 만들면 의미 있는 도메인 타입과 구체적인 동작이 사라진다. 추상화는 같은 의미를 갖고 같은 이유로 변화할 때 정당화된다.
6.1. Customer Importer와 Order Importer의 거짓된 공통점
-
기본 Importer의 형태
- 공통 파이프라인 주장:
BaseImporter가 레코드를 로드하고, 변환(Transformation)을 적용하고, 원본 레코드가 유효한지 검증한다. - 추상 메서드: 각 단계의 차이를 처리하려고 여러 Abstract Method를 둔다.
- 공통 파이프라인 주장:
-
서로 다른 두 Importer
- 고객 CSV Importer: CSV 파일에서 고객 정보를 읽고 고객을 생성한다. 예제 구현은 실제 CSV 처리의 단순화·가짜 버전이지만, 고객이라는 구체 타입을 만든다는 의도는 분명하다.
- 주문 Importer: API에서 유료 주문을 가져온다. 로드 방식, 검증 규칙, 변환 과정이 고객 CSV Importer와 모두 다르며 Imported Order를 생성한다.
-
추상화가 잃는 것
- 의미 없는 반환 타입: 기본 클래스가 지나치게 일반적인 객체를 반환하므로
Customer와ImportedOrder라는 유용한 타입 정보가 사라진다. - 공통 의미의 부족: 고객과 가져온 주문은 완전히 별개의 타입이다. CSV와 API라는 입력 경로도 다르고, 검증과 변환도 다르다.
- 명확성 저하: 호출부만 봐서는 반환 객체가 고객인지 주문인지, 아니면 전혀 다른 객체인지 알기 어렵다.
- 의미 없는 반환 타입: 기본 클래스가 지나치게 일반적인 객체를 반환하므로
6.2. 구체 함수와 지역적 중복
-
추상화 대신 작업을 이름 붙이기
- 고객 전용 함수:
import_customers_from_csv는 CSV에서 고객을 가져오는 작업만 수행한다. - 주문 전용 함수:
import_paid_orders는 API에서 유료 주문을 가져오는 작업만 수행한다. - 작은 중복의 허용: 두 함수가 레코드나 행 목록을 가져온다는 점에서 약간의 코드 중복이 생겨도 중복은 눈에 띄고 지역적이다.
- 고객 전용 함수:
-
잘못된 공통 추상화보다 나은 이유
- 도메인 타입 보존: 각 함수가 고객과 Imported Order라는 구체 타입을 그대로 반환하므로 사용자가 다뤄야 할 대상이 분명하다.
- 변화 이유 보존: CSV 입력·고객 검증과 API 입력·주문 검증은 서로 다른 이유로 바뀐다. 하나의 추상화 아래 묶지 않아야 변경 영향이 국소화된다.
-
추상화 기준
- 모양만 같으면 추상화하지 않기: 코드가 비슷해 보인다는 이유만으로 공통 클래스를 만들지 않는다.
- 의미와 변화 이유: 같은 의미를 갖고 같은 이유로 변경될 가능성이 높을 때만 추상화한다.
7. 좋은 OOP의 실제 기준
앞선 여섯 가지 실수는 클래스, 상속, Abstract Base Class 자체가 나쁘다는 뜻이 아니다. 문제는 도구를 도메인의 의미와 무관하게 기본값으로 사용하는 습관이다.
7.1. 객체가 맡아야 할 책임
-
불변식 보호
- 상태의 유효성: 객체는 상태가 허용된 범위를 벗어나지 않도록 불변식을 보호한다.
- 변경 통제: 외부 코드가 아무 값이나 직접 바꾸게 두지 않고 상태와 관련된 규칙을 객체 경계 안에 둔다.
-
상태와 의미 있는 행동의 결합
- 행동의 맥락: 객체의 메서드는 보관한 상태와 실제로 의미 있는 관계를 가져야 한다.
- 이름만 있는 클래스 금지: 값의 조합이나 단순한 함수 호출을 도메인 행동인 것처럼 포장하기 위해 클래스를 만들지 않는다.
-
명확한 경계 노출
- 최소 계약: 객체나 함수가 제공하는 능력과 사용자가 의존해도 되는 경계를 분명히 한다.
- 변화 격리: 경계 바깥의 코드는 내부 구현 세부 사항이나 사용하지 않는 메서드에 의존하지 않아야 한다.
7.2. Python에서 선택할 도구
-
Composition
- 독립 변화: 구성 요소가 서로 독립적으로 바뀌어야 한다면 객체를 상속 계층에 넣지 말고 조립한다.
- 교체 가능성: 생성자나 함수 인자로 의존성을 전달하면 테스트와 런타임 환경에 따라 구현을 바꿀 수 있다.
-
Protocol
- 능력 중심 계약: 복잡한 클래스 트리 대신 함수가 실제로 사용하는 메서드만 작은 Structural Protocol로 선언한다.
- 덕 타이핑과 정적 검사: 필요한 메서드를 가진 객체라면 구체적인 상속 관계 없이 전달할 수 있고, 타입 검사기는 계약 위반을 조기에 찾을 수 있다.
-
Plain Function
- 상태나 식별자가 없는 작업: 의미 있는 상태나 Identity가 없다면 일반 함수가 가장 직접적인 표현이다.
- 기본값을 클래스에 두지 않기: Python에서 클래스는 모든 문제의 기본 해결책이 아니다. 도메인 의미가 있을 때만 선택한다.
주요 발언 모음
“If you inherit because you need some implementation detail, you probably wanted composition instead.”
“When you’re dealing with types and classes and subclasses, it should represent different behavior or constraints or meaning, not every possible combination of settings.”
“If you have features that can be switched or combined independently, don’t encode every combination as a subclass.”
“The functions in this case simply request exactly the capability that they use, and this is what we also call interface segregation.”
“A subtype shouldn’t contradict the guarantees of the parent.”
“Don’t abstract something because the code looks similar. Abstract when it has the same meaning and it’s likely going to change for the same reason.”
“You’re using your objects mainly to protect invariants, to combine state with meaningful behavior on that state, and expose clear boundaries.”
“Classes shouldn’t be a default solution for everything.”
핵심 데이터 & 수치
- 여섯 가지 실수: 구현 메서드 재사용을 위한 상속, 값을 타입으로 변환, 독립 기능의 계층화, 하나의 모양을 강요하는 기본 클래스, 부모처럼 행동하지 못하는 하위 유형, 유사성 이해 전의 시기상조 추상화다.
- 일일 판매 보고서 의존성: 데이터베이스 서버와 로거라는 두 능력을 필요로 하지만 두 객체 자체의 하위 유형은 아니다.
- 독일 결제 예시:
GermanCheckout의 세율은 19%다. - 동기화 옵션: 검증 여부와 최대 시도 횟수가 독립 기능을 데이터로 표현한다.
- 재고 동기화 Protocol:
InventorySource는 재고 다운로드와 예약 SKU 다운로드라는 두 메서드만 요구한다. - 주문 계약: 읽기와 쓰기를
OrderReader,OrderWriter로 분리하면 읽기 전용 객체를 쓰기 가능한 큐의 하위 유형으로 위장할 필요가 없다. - 홍보 안내: 더 체계적인 소프트웨어 설계 판단을 배우고 싶다면
karn.codes/mastery의 Software Design Mastery 프로그램을 확인하라는 안내가 초반에 나온다. 프로그램은 YouTube 영상보다 더 깊은 내용을 제공하며 링크는 설명란에 있다고 말한다. - 개인 경험: 예시 대부분은 비슷한 설계 실수를 과거에 직접 겪으며 “왜 작동하지 않지? 내가 어떤 실수를 했지?”라고 자문했던 경험에서 가져왔다고 밝힌다.
결론 및 시사점
- 상속을 선택하기 전에 하위 객체가 상위 객체의 진정한
is-a관계인지 검증한다. - 메서드 구현을 재사용하려는 목적이면 Composition과 의존성 주입을 우선 검토한다.
- 세율·국가·재시도 횟수처럼 값으로 저장할 수 있는 차이는 Frozen Data Class와 Factory로 모델링한다.
- 설정 조합을 클래스 이름으로 늘리는 대신 행동·제약·도메인 의미가 실제로 달라지는지 확인한다.
- 독립적으로 조합되는 기능은 옵션이나 별도 단계로 분리하고 모든 조합을 서브클래스로 만들지 않는다.
- 워크플로가 거대한 기본 클래스를 받는다면 Stamp Coupling과 God Class 문제를 의심한다.
- 함수가 실제로 사용하는 능력만 작은 Protocol 계약으로 요청한다.
- 하위 타입이 부모의 보장을 깨뜨린다면 Liskov Substitution Principle 위반이므로 계약을 분리한다.
- 읽기와 쓰기처럼 서로 다른 능력은
OrderReader와OrderWriter처럼 별도 인터페이스로 표현한다. - 코드 모양이 비슷하다는 이유만으로 추상화하지 말고, 같은 의미와 같은 변경 이유가 있는지 확인한다.
- 작고 눈에 띄는 지역적 중복은 의미를 잃게 만드는 거대한 추상화보다 유지하기 쉽다.
- Python에서 클래스는 기본값이 아니며, 의미 있는 상태·식별자·불변식이 있을 때 선택한다.
핵심 요약 (20줄)
좋은 객체지향 설계는 클래스와 상속을 많이 사용하는 설계가 아니라 도메인의 의미와 변화 경계를 정확히 표현하는 설계다.
일일 판매 보고서는 데이터베이스 서버나 로거가 아니므로 메서드를 재사용하려고 두 클래스를 상속하면 거짓된 is-a 관계가 생긴다.
구현 세부 사항이 필요할 때는 데이터베이스 서버와 로거를 생성자에서 받아 저장하는 Composition이 더 적절하다.
명시적 의존성 주입은 상속을 없애면서도 같은 실행 결과를 유지하고 테스트에서 대체 객체를 쉽게 주입하게 한다.
독일 결제처럼 세율과 재시도 횟수만 바꾸는 서브클래스는 새로운 행동이 아니라 설정 값을 타입으로 포장한 것이다.
국가와 신뢰성 같은 설정 차원을 서브클래스로 계속 추가하면 표준형과 안정형의 조합이 폭발한다.
세율과 재시도 횟수는 불변 CheckoutConfig 데이터 클래스로 모델링하고 Factory 함수로 의도를 표현해야 한다.
클래스와 서브클래스는 모든 설정 조합이 아니라 서로 다른 행동과 제약과 의미를 나타내야 한다.
카탈로그 동기화의 검증과 재시도처럼 독립적으로 조합되는 기능은 모든 조합을 서브클래스로 인코딩하지 않아야 한다.
SyncOptions에 검증 여부와 최대 시도 횟수를 담고 SyncResult에 실행 보고서를 담으면 동작과 설정을 분리할 수 있다.
재고 워크플로에 거대한 Store Integration을 전달하면 사용하지 않는 메서드까지 노출되는 Stamp Coupling이 생긴다.
Authenticated와 InventorySource처럼 필요한 능력만 선언한 작은 Protocol은 God Base Class보다 안전한 계약을 만든다.
읽기 전용 큐를 쓰기 가능한 OrderQueue의 하위 클래스로 만들면 부모의 보장을 깨뜨리는 Liskov Substitution Principle 위반이 발생한다.
주문 읽기와 주문 쓰기를 별도의 Protocol로 나누면 잘못된 객체 전달을 실행 전 IDE 타입 오류로 발견할 수 있다.
고객 CSV Importer와 주문 API Importer는 로드·검증·변환 방식과 반환 타입이 모두 달라 공통 기본 클래스의 의미가 약하다.
코드가 비슷해 보여도 같은 의미와 같은 변경 이유가 없다면 추상화하지 말고 구체적인 함수를 작성해야 한다.
작고 눈에 잘 보이는 지역적 중복은 의미 없는 일반 객체를 반환하는 잘못된 추상화보다 명확하다.
객체는 불변식을 보호하고 상태와 의미 있는 행동을 결합하며 명확한 경계를 노출할 때 가장 큰 가치를 만든다.
Python에서는 독립적으로 변하는 요소에 Composition을, 인터페이스에 Protocol을, 상태와 식별자가 없는 작업에 일반 함수를 사용해야 한다.
설계 도구의 유행보다 도메인의 의미와 변경 이유를 기준으로 클래스·상속·추상화를 선택해야 한다.
