F-lab Java 1주차 / Phase 1 / Unit 1.1 본격 학습 자료
9-섹션 마스터 프롬프트 형식으로 깊이 파헤친다.선수 지식: 없음 (학습 여정의 출발점)
다음 Unit: 1.2 — 클래스와 객체의 본질
가장 근본적인 차이는 "데이터의 주인이 누구냐" 이다.
이 차이는 사고방식에서도 드러난다:
객체지향은 절차지향의 3가지 큰 문제 를 해결하기 위해 등장했습니다.
같은 일을 두 방식으로 표현해보자.
상황: 강아지를 짖게 하고 싶다.
짖게_하다(강아지)
→ "짖다" 라는 동사(함수) 가 주인공
→ 강아지는 그냥 재료(데이터)
→ 사고 흐름: "짖는 행동을 어떻게 처리할까?"
강아지.짖다()
→ "강아지" 라는 명사(객체) 가 주인공
→ 짖는 능력은 강아지가 소유
→ 사고 흐름: "누가 짖는 책임을 가질까?"
| 절차지향 (동사 중심) | 객체지향 (명사 중심) | |
|---|---|---|
| 코드 형태 | 짖게_하다(강아지) | 강아지.짖다() |
| 주인공 | 동사 (행동) | 명사 (객체) |
| 사고 | "어떻게 처리할까" | "누가 책임질까" |
| 데이터 | 함수에 전달됨 | 객체가 소유함 |
| 명령 방식 | "이 데이터를 처리해" | "너 이거 해" |
이 사고방식의 차이가 마을의 진화 비유로 풀어보면 더 명확해진다.
옛날 한 마을이 있었다. 처음엔 작은 마을이었지만 점점 커졌다. 그러면서 문제가 생겼고, 마을의 운영 방식이 진화했다.
이 마을의 이야기가 곧 절차지향에서 객체지향으로의 진화 이야기다.
마을 한가운데에 공동 창고 가 있다. 모든 마을 사람의 물건이 여기에 모여있다.
마을 사람들은 "일꾼" 들. 누가 무엇을 할지는 정해져 있다:
마을이 작을 땐 이 방식이 잘 굴러갔다.
[공동 창고: 쌀, 옷, 도구, ...] ← 데이터 (명사)
↓ (가져감)
[일꾼 A] → 요리 ← 함수 (동사)
[일꾼 B] → 빨래 ← 함수 (동사)
[일꾼 C] → 청소 ← 함수 (동사)
→ 여기서 주인공은 일꾼(동사) 이다. 창고는 그냥 재료 보관소.
→ 코드도 마찬가지: 요리하다(쌀), 빨래하다(옷) — 동사가 주인공.
// 공동 창고 = 구조체에 모든 데이터 모음 (명사 — 단순 재료)
struct Warehouse {
int chulsuRice; // 철수의 쌀
char younghuiClothes[100]; // 영희의 옷
char minsuTools[100]; // 민수의 도구
};
// 일꾼들 = 독립된 함수들 (동사 — 주인공)
void cook(struct Warehouse* w) {
// 창고에서 쌀 가져가서 밥 함
w->chulsuRice -= 10;
printf("밥 완성\n");
}
void wash(struct Warehouse* w) {
// 창고에서 옷 가져가서 빨래
printf("%s 빨래 완료\n", w->younghuiClothes);
}
void clean(struct Warehouse* w) {
// 창고에서 도구 가져가서 청소
printf("%s로 청소\n", w->minsuTools);
}
// 사용 — 동사(함수)가 주인공, 명사(데이터)는 인자로 넘겨짐
int main() {
struct Warehouse warehouse = { 50, "셔츠", "빗자루" };
cook(&warehouse); // "요리하다(창고)" — 동사 중심
wash(&warehouse); // "빨래하다(창고)" — 동사 중심
clean(&warehouse); // "청소하다(창고)" — 동사 중심
}
→ 이게 절차지향: 데이터(창고 구조체) 와 행동(함수) 이 분리됨. 함수(동사)는 데이터를 인자로 받아서 처리.
마을이 커지면서 문제가 폭발하기 시작했다.
상황: 공동 창고에 누구나 접근 가능하다.
왜 그럴까? 데이터는 주인 없는 명사 라서, 동사(함수)들이 마음대로 만질 수 있기 때문.
struct Warehouse warehouse = { 50, "셔츠", "빗자루" };
// 누구나 직접 데이터를 만질 수 있음 — 명사가 보호받지 못함
warehouse.chulsuRice = -99999; // 음수? 가능 ❌
strcpy(warehouse.younghuiClothes, ""); // 빈 문자열? 가능 ❌
strcpy(warehouse.minsuTools, "🤡"); // 이상한 값? 가능 ❌
// 검증 함수를 만들어도, 호출 안 하면 그만
void validateRice(int rice) {
if (rice < 0) printf("에러!\n");
}
// → 누군가 validateRice 호출 안 하고 직접 수정하면 끝 ❌
// 누가 망가뜨렸는지 추적 불가
// 100개 함수 중 어느 함수가 chulsuRice를 음수로 만들었나? 모름
→ 데이터(명사)에 주인이 없으니, 누구나 망가뜨림.
상황: 마을에 외국인 가족이 이사 왔다. 이 가족은 다른 음식을 먹고, 다른 옷을 입는다.
이제 모든 일꾼에게 새 규칙을 가르쳐야 한다:
또 다른 외국인 가족이 오면? 모든 일꾼을 또 다 가르쳐야 함.
왜 그럴까? 동사(함수)가 주인공이라, 각 동사 안에 모든 종류의 처리를 다 넣어야 하기 때문. 새 종류가 추가되면 모든 동사에 새 분기 추가.
struct Person {
char name[50];
char nationality[20]; // "한국", "외국", "일본", "중국" ...
};
// 모든 함수(동사)에 if 분기가 늘어남
void cook(struct Person* p) {
if (strcmp(p->nationality, "한국") == 0) {
printf("밥을 합니다\n");
} else if (strcmp(p->nationality, "외국") == 0) {
printf("빵을 굽습니다\n");
} else if (strcmp(p->nationality, "일본") == 0) { // 새 국적 추가
printf("스시를 만듭니다\n");
}
// 새 국적 추가될 때마다 여기 수정 ❌
}
void wash(struct Person* p) {
if (strcmp(p->nationality, "한국") == 0) {
printf("한국식 빨래\n");
} else if (strcmp(p->nationality, "외국") == 0) {
printf("외국식 빨래\n");
} else if (strcmp(p->nationality, "일본") == 0) { // 또 추가
printf("일본식 빨래\n");
}
// 또 수정 ❌
}
void clean(struct Person* p) {
if (strcmp(p->nationality, "한국") == 0) { ... }
else if (strcmp(p->nationality, "외국") == 0) { ... }
else if (strcmp(p->nationality, "일본") == 0) { ... } // 또또 추가 ❌
}
// "중국인" 가족 추가 → 위 모든 함수 수정 필요
// 함수가 200개라면? 200개 다 수정 ❌
// 한 군데라도 빠뜨리면? 버그 ❌
→ 새 종류(명사)가 추가되면 모든 동사를 수정해야 함.
상황: 마을 인구가 1,000명이 되었다.
// 거대한 공용 데이터 (주인 없는 명사)
struct VillageData {
int totalPopulation;
int totalRice;
int totalClothes;
char warehouseStatus[1000];
// ... 100개 필드
};
// 5,000개의 함수(동사)가 이 데이터를 만짐
void functionA(struct VillageData* v) { v->totalRice += 10; }
void functionB(struct VillageData* v) { v->totalRice -= 5; }
void functionC(struct VillageData* v) { v->totalRice = v->totalRice * 2; }
// ... 함수 5,000개 ...
void function4999(struct VillageData* v) { v->totalRice = 0; } // 누가 만든 함수?
// 어느 날 totalRice 값이 이상함
// → 5,000개 함수 중 어느 게 잘못 건드렸나?
// → 추적 거의 불가능 ❌
// 신입 개발자가 들어옴
// "totalRice 필드를 어떻게 사용하면 되나요?"
// → "5,000개 함수 다 봐야 알 수 있어요" ❌
// 한 개발자가 totalRice를 long으로 바꾸면
// → 5,000개 함수 모두 수정 필요 ❌
→ 시스템이 커지면 협업 자체가 불가능.
마을 사람들이 모여서 결정했다:
"공동 창고를 없애자. 각자 자기 물건을 자기가 관리하자."
그래서 각자의 집 을 짓고, 각자의 물건 은 각자의 집 에 두기로 했다.
[철수의 집] [영희의 집] [민수의 집]
- 가진 것: 쌀, 도구 - 가진 것: 옷 - 가진 것: 도구
- 할 줄 아는 것: - 할 줄 아는 것: - 할 줄 아는 것:
• 밥 짓기 • 빨래하기 • 청소하기
• 청소하기 • 옷 정리하기 • 도구 정비
핵심 변화:
→ 이제 주인공은 명사(객체) 다.
→ 코드도 바뀐다: 요리하다(쌀) → 철수.밥짓다() — 명사가 주인공, 동사는 명사의 능력.
→ 이게 객체지향: 데이터(명사)와 행동(동사)을 객체가 함께 소유.
비유: 영희의 옷장에는 영희만 손댈 수 있다.
Java 코드로:
public class Fare {
private int amount; // private — 외부 직접 접근 X
public void changeAmount(int newAmount) {
if (newAmount < 0) {
throw new IllegalArgumentException("음수 불가"); // 자기가 검증
}
this.amount = newAmount;
}
}
// 사용 — 명사(fare)가 주인공
Fare fare = new Fare();
fare.changeAmount(50000); // "fare가 자기 금액을 바꾼다" — 명사 중심
fare.changeAmount(-99999); // 즉시 예외! ✅ — fare 자신이 거부
// fare.amount = -99999; // 컴파일 에러! ✅ — 애초에 접근 불가
→ 데이터(명사)에 주인이 생기니, 주인이 보호함.
비유: 외국인 가족이 이사 왔다.
Java 코드로:
public abstract class Customer {
public abstract int calculateDiscount(int amount);
}
public class VipCustomer extends Customer {
public int calculateDiscount(int amount) { return amount * 20 / 100; }
}
public class PartnerCustomer extends Customer {
public int calculateDiscount(int amount) { return amount * 30 / 100; }
}
// 새 등급 추가 — 새 클래스(명사) 1개만 만들면 끝 ✅
public class StudentCustomer extends Customer {
public int calculateDiscount(int amount) { return amount * 15 / 100; }
}
// 사용하는 쪽은 한 줄도 안 바뀜
customer.calculateDiscount(amount); // 명사가 알아서 자기 방식으로
// → "customer가 자기 할인을 계산한다" — 명사가 주인공
// → if 분기 없음, 각 명사가 자기 행동 책임
→ 새 명사(종류) 추가 시 기존 코드는 안 건드림.
비유: 1,000명이 사는 마을이지만 이제 작동한다.
Java 코드로:
프로젝트 인원: 50명
- A팀: Fare 클래스(명사) 담당
- B팀: Customer 클래스(명사) 담당
- C팀: Notification 클래스(명사) 담당
각 팀은 자기 명사만 책임
다른 팀의 내부 구현은 알 필요 X
인터페이스(메서드 시그니처)만 약속하면 협업 가능 ✅
→ 명사별로 책임이 분리되니 대규모 협업 가능.
| 작은 마을 (절차지향) | 큰 마을 (객체지향) | |
|---|---|---|
| 사고방식 | 동사 중심 | 명사 중심 |
| 코드 형태 | 처리하다(데이터) | 객체.처리하다() |
| 데이터 위치 | 공동 창고 | 각자의 집 |
| 주인 | 없음 | 각 집의 주인 |
| 접근 방식 | 누구나 직접 | 주인에게 부탁 |
| 새 종류 추가 | 모든 일꾼(동사) 재교육 | 새 집(명사) 짓기만 |
| 무결성 | 깨지기 쉬움 | 주인이 보호 |
| 확장 비용 | 폭발적 증가 | 낮음 |
| 협업 | 불가능 | 가능 |
절차지향은 "동사가 주인공" 이라 공동 창고에 모두의 데이터가 떠다녔고, 누구나 망가뜨릴 수 있고, 새 종류 추가 시 모든 동사를 재교육해야 했다.
객체지향은 "명사가 주인공" 으로 바뀌어, 각자의 집에 자기 데이터를 두고 주인을 통해서만 접근하게 함으로써, 무결성 보장 + 확장 용이 + 협업 가능이라는 3가지 문제를 한 번에 해결했다.
컴퓨터 프로그래밍 초기에는 절차지향이 자연스러웠다. 왜냐하면:
대표 언어:
이 시대의 프로그램은 "함수의 모음" 이었다. 데이터는 별도로 관리하고, 함수가 데이터를 받아 처리하는 방식.
문제가 생기기 시작했다. 소프트웨어가 커지면서:
이 위기를 해결하려는 시도로 객체지향이 탄생:
자바는 "플랫폼 독립성" 과 "순수 OOP" 를 무기로 폭발적으로 성장. 90년대 인터넷 폭발과 함께 표준 언어가 됨.
"절차지향은 틀렸다 가 아니다. 큰 시스템에서 한계 가 있다."
소규모 스크립트는 여전히 절차지향이 효율적. 하지만 ILIC 같은 102 테이블, 431 API 규모의 시스템은 객체지향 없이는 관리 불가능.
객체지향이 없다면? 절차지향만으로 큰 시스템을 만들면 어떤 문제가 생기는지 구체적인 예시 로 보자.
// 데이터 (구조체)
struct Fare {
int id;
int customerId;
int amount;
char status[20];
char currency[10];
int createdAt;
// ... 30개 필드
};
struct Customer {
int id;
char name[100];
char email[200];
int level; // 0=일반, 1=VIP
// ...
};
// 함수들
int calculateFare(struct Fare* fare, int distance);
void applyDiscount(struct Fare* fare, struct Customer* customer);
void sendNotification(struct Fare* fare, struct Customer* customer);
void saveFare(struct Fare* fare);
void validateFare(struct Fare* fare);
void printFareReceipt(struct Fare* fare);
// ... 200개 함수
struct Fare fare;
fare.amount = 50000;
fare.status = "DRAFT";
// 검증 안 했는데 저장 가능 ❌
saveFare(&fare);
// 누군가 직접 수정 가능 ❌
fare.amount = -99999; // 음수! 무결성 깨짐
saveFare(&fare);
왜 위험한가:
VIP 고객을 위한 새 기능 추가 시나리오:
// 모든 함수에 if 분기 추가 ⚠️
int calculateFare(struct Fare* fare, int distance) {
if (fare->customer->level == 1) { // VIP
return distance * 400; // 할인
} else {
return distance * 500;
}
}
void applyDiscount(struct Fare* fare, struct Customer* customer) {
if (customer->level == 1) { // 또 if!
// VIP 할인
} else {
// 일반 할인
}
}
void sendNotification(struct Fare* fare, struct Customer* customer) {
if (customer->level == 1) { // 또 if!!
// VIP 전용 알림
} else {
// 일반 알림
}
}
// ... 200개 함수에 if 추가 ❌
왜 끔찍한가:
void send(struct Email* email); // 이메일 보내기
void send(struct SMS* sms); // 컴파일 에러! ❌
void send(struct Slack* slack); // C에서는 같은 이름 함수 X
해결책으로 만들어진 이상한 이름들:
void sendEmail(...);
void sendSMS(...);
void sendSlackMessage(...);
void sendNotificationToCustomer(...);
void sendUrgentNotificationToVIPCustomer(...); // 점점 길어짐
// A 프로젝트에서 만든 운임 계산 로직
int calculateFare(struct Fare* fare) { ... }
// B 프로젝트에서 가져다 쓰려면?
// → struct Fare 정의도 가져와야 함
// → 의존하는 함수 5개도 가져와야 함
// → 그 함수들이 의존하는 함수 10개도...
// → 결국 거의 다 가져와야 함 ❌
큰 시스템에서 절차지향은:
1. 데이터 무결성 보장 어려움
2. 수정 시 영향 범위 폭발 (한 곳 고치면 100곳)
3. 확장성 제로 (새 기능 추가가 두려움)
4. 재사용 불가 (다른 프로젝트 활용 X)
5. 협업 지옥 (누가 뭘 만지는지 모름)
→ 이 모든 문제를 해결하려고 객체지향이 등장.
객체지향은 위 문제들을 4가지 핵심 원칙 으로 해결한다. (이건 모든 면접의 단골이다 ⭐)
"데이터와 그 데이터를 다루는 행동을 하나의 객체로 묶고, 외부에서 직접 접근을 막음"
위 문제 1 해결 (데이터 무결성):
// 객체지향 버전
public class Fare {
private int amount; // private — 외부 직접 접근 X
private String status;
// 검증된 방법으로만 변경 가능
public void changeAmount(int newAmount) {
if (newAmount < 0) {
throw new IllegalArgumentException("음수 불가");
}
this.amount = newAmount;
}
public int getAmount() {
return this.amount;
}
}
// 사용
Fare fare = new Fare();
fare.changeAmount(50000); // OK
fare.changeAmount(-99999); // 즉시 예외! ✅
// fare.amount = -99999; // 컴파일 에러! ✅
효과:
"기존 클래스의 속성과 행동을 물려받아 확장"
위 문제 2 해결 (확장성):
public class Customer {
protected String name;
protected String email;
public int calculateDiscount(int amount) {
return 0; // 기본 할인 없음
}
}
public class VipCustomer extends Customer {
@Override
public int calculateDiscount(int amount) {
return amount * 20 / 100; // 20% 할인
}
}
public class PartnerCustomer extends Customer {
@Override
public int calculateDiscount(int amount) {
return amount * 30 / 100; // 30% 할인
}
}
효과:
name, email) 은 부모에 한 번만"같은 이름의 메서드가 객체에 따라 다르게 동작"
위 문제 2 해결의 확장:
public class FareService {
public int calculate(Fare fare, Customer customer) {
int baseAmount = fare.getAmount();
int discount = customer.calculateDiscount(baseAmount); // ⭐
return baseAmount - discount;
}
}
// 사용
Customer alice = new VipCustomer();
Customer bob = new PartnerCustomer();
Customer charlie = new Customer();
fareService.calculate(fare, alice); // 20% 할인 적용
fareService.calculate(fare, bob); // 30% 할인 적용
fareService.calculate(fare, charlie); // 할인 없음
핵심 통찰 ⭐ :
FareService 코드에 if 분기가 사라짐FareService 는 손댈 필요 X→ 이게 객체지향의 진짜 마법.
"복잡한 내부를 숨기고 본질만 드러냄"
위 문제 3, 4 해결:
// 인터페이스로 추상화
public interface NotificationSender {
void send(String message, String recipient);
}
// 다양한 구현체
public class EmailSender implements NotificationSender {
@Override
public void send(String message, String recipient) {
// 이메일 발송 로직
}
}
public class SMSSender implements NotificationSender {
@Override
public void send(String message, String recipient) {
// SMS 발송 로직
}
}
public class SlackSender implements NotificationSender {
@Override
public void send(String message, String recipient) {
// Slack 발송 로직
}
}
// 사용 — 추상화의 힘
public class NotificationService {
private NotificationSender sender; // 인터페이스 타입!
public void notify(String message, String recipient) {
sender.send(message, recipient); // 어떤 구현이든 OK
}
}
효과:
send) 으로 다양한 동작 가능 (이름 충돌 해결)| 원칙 | 영문 | 한 줄 설명 |
|---|---|---|
| 캡슐화 | Encapsulation | 데이터와 행동을 묶고 외부 접근 통제 |
| 상속 | Inheritance | 부모의 특성을 자식이 물려받음 |
| 다형성 | Polymorphism | 같은 메시지에 객체마다 다른 응답 |
| 추상화 | Abstraction | 복잡함을 숨기고 본질만 노출 |
암기 팁: "캡상다추" (캡슐화-상속-다형성-추상화)
이론을 코드 레벨에서 어떻게 구현되는지 보자.
// C 코드
struct Fare {
int amount;
char status[20];
};
void calculateFare(struct Fare* fare) {
fare->amount = fare->amount * 2;
}
int main() {
struct Fare fare;
fare.amount = 50000;
calculateFare(&fare);
}
메모리 구조:
[Stack]
fare (구조체) ────────┐
│
[Code 영역] │
calculateFare 함수 ────┘ (포인터로 전달받음)
핵심:
public class Fare {
private int amount;
public void doubleAmount() {
this.amount = this.amount * 2;
}
}
Fare fare = new Fare();
fare.doubleAmount<();
메모리 구조 (4주차 JVM 메모리 모델 미리보기):
[Stack]
fare (참조) ──────┐
│
[Heap] │
Fare 인스턴스 ◄──┘
- amount: 50000
- (메서드는 포함 X)
[Method Area]
Fare 클래스 정보
- doubleAmount() 메서드 코드 ⭐
핵심 차이 ⭐ :
this 의 정체public class Fare {
private int amount;
public void doubleAmount() {
this.amount = this.amount * 2;
// ↑
// "이 메서드를 호출한 객체"
}
}
Fare fare1 = new Fare(); fare1.amount = 100;
Fare fare2 = new Fare(); fare2.amount = 200;
fare1.doubleAmount(); // this = fare1, fare1.amount = 200
fare2.doubleAmount(); // this = fare2, fare2.amount = 400
JVM 내부:
doubleAmount(fare1) 같은 형태로 변환다형성은 어떻게 가능할까? Virtual Method Table 덕분.
class Customer {
public int calculateDiscount(int amount) { return 0; }
}
class VipCustomer extends Customer {
@Override
public int calculateDiscount(int amount) { return amount * 20 / 100; }
}
Customer c = new VipCustomer();
c.calculateDiscount(1000); // 200 반환 — 어떻게?
JVM의 메서드 호출 흐름 ⭐ :
1. c.calculateDiscount(1000) 호출
2. JVM: "c가 가리키는 객체의 실제 클래스는?"
→ VipCustomer 인스턴스
3. JVM: "VipCustomer의 VMT 확인"
Customer의 VMT: VipCustomer의 VMT:
[calculateDiscount → Customer.calculateDiscount] [calculateDiscount → VipCustomer.calculateDiscount] ⭐
[상속받은 다른 메서드 → Customer.xxx]
4. VipCustomer의 VMT에서 calculateDiscount 찾음
5. VipCustomer.calculateDiscount(1000) 실행 → 200 반환
핵심:
→ 4-5주차에 더 깊이 다룰 주제. 지금은 "다형성이 마법이 아닌 VMT 덕분" 만 기억.
ILIC 도메인으로 절차지향과 객체지향을 직접 비교해보자.
public class FareService {
// 데이터 클래스 (Anemic Domain Model — 안티패턴 ⚠️)
public static class Fare {
public int amount;
public String status;
public int customerId;
}
public static class Customer {
public int id;
public String name;
public String level; // "NORMAL", "VIP", "PARTNER"
}
// 모든 로직이 service에 (절차지향)
public int calculateFinalAmount(Fare fare, Customer customer) {
int amount = fare.amount;
// ❌ if 지옥 시작
int discount = 0;
if (customer.level.equals("VIP")) {
discount = amount * 20 / 100;
} else if (customer.level.equals("PARTNER")) {
discount = amount * 30 / 100;
} else if (customer.level.equals("NORMAL")) {
discount = 0;
}
// 새 등급 추가 시 여기 수정 필요 ❌
return amount - discount;
}
public String getNotificationTemplate(Customer customer) {
// ❌ 또 if!
if (customer.level.equals("VIP")) {
return "VIP 고객님, 운임이 등록되었습니다.";
} else if (customer.level.equals("PARTNER")) {
return "파트너님, 운임 정보를 확인해주세요.";
} else {
return "운임이 등록되었습니다.";
}
// 새 등급 추가 시 여기도 수정 ❌
}
public boolean canApplyExtraDiscount(Customer customer) {
// ❌ 또또 if!
if (customer.level.equals("VIP") || customer.level.equals("PARTNER")) {
return true;
}
return false;
}
public void changeAmount(Fare fare, int newAmount) {
// ❌ 검증을 호출자가 알아서 해야 함
fare.amount = newAmount; // 음수도 들어감!
}
}
문제점:
1. if 지옥: 등급별 분기가 곳곳에 흩어짐
2. 확장 불가: 새 등급 추가 시 모든 메서드 수정
3. 무결성 X: fare.amount = -99999 가능
4. 응집도 ↓: 운임 관련 로직이 service에 다 모임
// 1. Customer는 자기 할인을 안다 (다형성)
public abstract class Customer {
private final String name;
private final String email;
public Customer(String name, String email) {
this.name = name;
this.email = email;
}
// 추상 메서드 — 자식이 구현
public abstract int calculateDiscountRate();
public abstract String getNotificationGreeting();
// 공통 로직
public int applyDiscount(int amount) {
return amount * calculateDiscountRate() / 100;
}
public boolean canApplyExtraDiscount() {
return calculateDiscountRate() > 0;
}
// getter
public String getName() { return name; }
public String getEmail() { return email; }
}
public class NormalCustomer extends Customer {
public NormalCustomer(String name, String email) {
super(name, email);
}
@Override
public int calculateDiscountRate() { return 0; }
@Override
public String getNotificationGreeting() {
return "안녕하세요";
}
}
public class VipCustomer extends Customer {
public VipCustomer(String name, String email) {
super(name, email);
}
@Override
public int calculateDiscountRate() { return 20; }
@Override
public String getNotificationGreeting() {
return "VIP 고객님";
}
}
public class PartnerCustomer extends Customer {
public PartnerCustomer(String name, String email) {
super(name, email);
}
@Override
public int calculateDiscountRate() { return 30; }
@Override
public String getNotificationGreeting() {
return "파트너님";
}
}
// 2. Fare는 자기 데이터의 무결성을 책임진다 (캡슐화)
public class Fare {
private int amount;
private FareStatus status;
private final Customer customer;
public Fare(int initialAmount, Customer customer) {
validateAmount(initialAmount);
this.amount = initialAmount;
this.status = FareStatus.DRAFT;
this.customer = customer;
}
public void changeAmount(int newAmount) {
validateAmount(newAmount);
this.amount = newAmount;
}
private void validateAmount(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("운임은 음수일 수 없습니다");
}
}
public int calculateFinalAmount() {
int discount = customer.applyDiscount(amount);
return amount - discount;
}
// getter
public int getAmount() { return amount; }
public Customer getCustomer() { return customer; }
}
// 3. Service는 흐름만 조율 (얇아짐)
public class FareService {
public int calculateFinalAmount(Fare fare) {
return fare.calculateFinalAmount(); // 객체에 위임 ⭐
}
public String createNotification(Fare fare) {
Customer customer = fare.getCustomer();
return String.format("%s, 운임이 등록되었습니다 (%d원)",
customer.getNotificationGreeting(),
fare.calculateFinalAmount());
}
}
효과 ⭐ :
1. if 지옥 사라짐 — 다형성이 처리
2. 확장 쉬움 — 새 등급 추가 시 Customer 자식만 만들면 끝
3. 무결성 보장 — Fare 가 자기 검증
4. 책임 분리 — 각 클래스가 자기 일만 함
절차지향:
// 5개 메서드 모두 수정 필요
if (level.equals("STUDENT")) { ... } // calculateFinalAmount
if (level.equals("STUDENT")) { ... } // getNotificationTemplate
if (level.equals("STUDENT")) { ... } // canApplyExtraDiscount
// ... 모든 분기에 추가
객체지향:
// 새 클래스 1개만 만들면 끝!
public class StudentCustomer extends Customer {
public StudentCustomer(String name, String email) {
super(name, email);
}
@Override
public int calculateDiscountRate() { return 15; }
@Override
public String getNotificationGreeting() {
return "학생 고객님";
}
}
// 기존 코드는 한 줄도 안 건드림 ⭐
→ 이게 객체지향의 진짜 가치. SOLID의 OCP(Open-Closed Principle)와 직결.
빈혈 도메인 모델 = 객체지향의 탈을 쓴 절차지향
Service 같은 외부 클래스에 모여있음이름의 유래: "빈혈(Anemic)" = 피(생명력=행동)가 없는 객체라는 뜻. Martin Fowler가 2003년 명명.
사람의 몸에 비유하면:
건강한 사람 (Rich Domain Model):
빈혈 환자 (Anemic Domain Model):
→ 객체에 자기 책임이 없으면 빈혈.
옛날 TV를 상상하세요:
→ TV는 자기 행동이 없음. 사용자가 모든 걸 함.
public class TV {
private int channel; // 데이터만
private int volume;
private int brightness;
public int getChannel() { return channel; }
public void setChannel(int channel) { this.channel = channel; }
// ... 다른 getter/setter들
}
// 모든 로직은 외부에서
public class TVUserService {
public void changeChannel(TV tv, int newChannel) {
if (newChannel < 1 || newChannel > 999) return;
tv.setChannel(newChannel);
}
public void increaseVolume(TV tv) {
int current = tv.getVolume();
if (current < 100) tv.setVolume(current + 1);
}
}
→ TV는 자기 채널/볼륨/밝기를 모름. TVUserService가 다 알아야 함.
현대 스마트 TV:
→ TV가 자기 일을 자기가 함. 사용자는 명령만.
public class SmartTV {
private int channel;
private int volume;
private int brightness;
// 자기 책임을 가짐
public void changeChannel(int newChannel) {
if (newChannel < 1 || newChannel > 999) {
throw new IllegalArgumentException("유효하지 않은 채널");
}
this.channel = newChannel;
}
public void increaseVolume() {
if (this.volume >= 100) return; // 자기가 한계 검증
this.volume += 1;
}
}
// ❌ 빈혈 모델
@Entity
public class Fare {
@Id
private Long id;
private int amount;
private String status; // "DRAFT", "SUBMITTED", "PAID", "CANCELLED"
private Long customerId;
private LocalDateTime createdAt;
private LocalDateTime paidAt;
// getter/setter만 잔뜩
public Long getId() { return id; }
public int getAmount() { return amount; }
public void setAmount(int amount) { this.amount = amount; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
// ... 12개 getter, 11개 setter
}
// 모든 비즈니스 로직이 Service에 폭증
@Service
public class FareService {
public void changeAmount(Fare fare, int newAmount) {
if (fare.getStatus().equals("PAID")) {
throw new IllegalStateException("결제된 운임은 변경 불가");
}
if (newAmount < 0) {
throw new IllegalArgumentException("음수 불가");
}
if (newAmount > 100_000_000) {
throw new IllegalArgumentException("최대 금액 초과");
}
fare.setAmount(newAmount);
}
public void submit(Fare fare) {
if (!fare.getStatus().equals("DRAFT")) {
throw new IllegalStateException("DRAFT 상태만 제출 가능");
}
if (fare.getAmount() <= 0) {
throw new IllegalStateException("금액이 0 이하인 운임은 제출 불가");
}
fare.setStatus("SUBMITTED");
}
public void pay(Fare fare) {
if (!fare.getStatus().equals("SUBMITTED")) {
throw new IllegalStateException("SUBMITTED 상태만 결제 가능");
}
fare.setStatus("PAID");
fare.setPaidAt(LocalDateTime.now());
}
public void cancel(Fare fare) {
if (fare.getStatus().equals("PAID")) {
throw new IllegalStateException("결제된 운임은 취소 불가");
}
if (fare.getStatus().equals("CANCELLED")) {
throw new IllegalStateException("이미 취소됨");
}
fare.setStatus("CANCELLED");
}
public boolean isModifiable(Fare fare) {
return fare.getStatus().equals("DRAFT");
}
public boolean isCancellable(Fare fare) {
return !fare.getStatus().equals("PAID") &&
!fare.getStatus().equals("CANCELLED");
}
// ... 운임 관련 모든 로직이 여기에 ❌
}
문제점이 한눈에 보이시나요?
Fare fare = fareRepository.findById(1L);
// 어디서든 직접 setter 호출 가능 ❌
fare.setAmount(-99999); // 음수 가능!
fare.setStatus("괴상한값"); // 잘못된 상태!
fare.setStatus("PAID"); // 결제 안 했는데 결제됨으로!
// 검증은 FareService 호출할 때만 일어남
// → setter를 직접 부르면 검증 우회
→ 객체가 자기 무결성을 보호 못함. 누구나 망가뜨림.
public class FareService {
public void changeAmount(Fare fare, int newAmount) {
if (newAmount < 0) throw new ...; // 검증 1
// ...
}
}
public class FareImportService {
public void importFare(int amount, ...) {
if (amount < 0) throw new ...; // 같은 검증 또 작성
Fare fare = new Fare();
fare.setAmount(amount);
}
}
public class FareApiController {
public void create(@RequestBody FareDto dto) {
if (dto.getAmount() < 0) throw new ...; // 또또 작성
}
}
→ 동일 규칙이 여러 곳에 분산 → 한 곳만 수정하면 다른 곳에 버그.
@Service
public class FareService {
// 운임 생성 로직
// 운임 수정 로직
// 운임 결제 로직
// 운임 취소 로직
// 운임 검증 로직
// 할인 계산 로직
// 상태 전이 로직
// ... 50개 메서드, 2000줄 ❌
}
→ 단일 책임 원칙(SRP) 위반. 테스트 어려움. 변경 영향 큼.
"운임을 결제 상태로 바꾸는 규칙이 뭐예요?"
↓
"FareService에 있어요"
↓
"FareService에 메서드가 50개인데 어디에..."
↓
"음... pay() 메서드 같아요. 근데 PaymentService에도 있고,
BatchService에도 결제 처리 로직이 있어서 정확한 답은 모르겠네요"
→ 규칙이 흩어져서 추적 불가.
@Test
void 운임_결제_테스트() {
Fare fare = new Fare();
fare.setStatus("SUBMITTED");
fare.setAmount(50000);
// Service를 mock해야 함
FareService service = new FareService(mockRepo, mockEventPublisher, mockAuditLogger, ...);
service.pay(fare);
assertThat(fare.getStatus()).isEqualTo("PAID");
}
vs
@Test
void 운임_결제_테스트() {
Fare fare = Fare.draft(50000);
fare.submit();
fare.pay(); // 단순!
assertThat(fare.isPaid()).isTrue();
}
→ 객체 자체로 테스트 불가, Service 의존성 주입 지옥.
신입: "Fare를 어떻게 사용하나요?"
선임: "음, FareService를 봐야 해요"
신입: "FareService에 50개 메서드가 있는데..."
선임: "그리고 PaymentService, FareImportService, FareBatchService도 봐야 해요"
신입: "............"
vs
신입: "Fare를 어떻게 사용하나요?"
선임: "Fare 클래스 자체를 보세요. 가능한 행동이 다 거기 있어요"
신입: "아, draft(), submit(), pay(), cancel() ... 명확하네요!"
같은 ILIC 운임 시스템을 정상 모델로:
@Entity
public class Fare {
@Id
private Long id;
private int amount;
@Enumerated(EnumType.STRING)
private FareStatus status;
private Long customerId;
private LocalDateTime createdAt;
private LocalDateTime paidAt;
// 정적 팩토리 메서드 — "어떻게 만드는가" 명확
public static Fare draft(Long customerId, int amount) {
validateAmount(amount);
Fare fare = new Fare();
fare.customerId = customerId;
fare.amount = amount;
fare.status = FareStatus.DRAFT;
fare.createdAt = LocalDateTime.now();
return fare;
}
// 행동 1: 금액 변경 (자기 검증)
public void changeAmount(int newAmount) {
if (status != FareStatus.DRAFT) {
throw new IllegalStateException("DRAFT 상태에서만 금액 변경 가능");
}
validateAmount(newAmount);
this.amount = newAmount;
}
// 행동 2: 제출
public void submit() {
if (status != FareStatus.DRAFT) {
throw new IllegalStateException("DRAFT 상태에서만 제출 가능");
}
if (amount <= 0) {
throw new IllegalStateException("금액이 0 이하인 운임은 제출 불가");
}
this.status = FareStatus.SUBMITTED;
}
// 행동 3: 결제
public void pay() {
if (status != FareStatus.SUBMITTED) {
throw new IllegalStateException("SUBMITTED 상태에서만 결제 가능");
}
this.status = FareStatus.PAID;
this.paidAt = LocalDateTime.now();
}
// 행동 4: 취소
public void cancel() {
if (status == FareStatus.PAID) {
throw new IllegalStateException("결제된 운임은 취소 불가");
}
if (status == FareStatus.CANCELLED) {
throw new IllegalStateException("이미 취소됨");
}
this.status = FareStatus.CANCELLED;
}
// 자기 상태 질문
public boolean isModifiable() {
return status == FareStatus.DRAFT;
}
public boolean isCancellable() {
return status != FareStatus.PAID && status != FareStatus.CANCELLED;
}
public boolean isPaid() {
return status == FareStatus.PAID;
}
// private 검증
private static void validateAmount(int amount) {
if (amount < 0) throw new IllegalArgumentException("음수 불가");
if (amount > 100_000_000) throw new IllegalArgumentException("최대 금액 초과");
}
// setter는 없음 — 의도적으로
// getter는 필요한 것만
public Long getId() { return id; }
public int getAmount() { return amount; }
public FareStatus getStatus() { return status; }
}
// Service는 흐름만 조율 — 얇아짐
@Service
@RequiredArgsConstructor
public class FareService {
private final FareRepository fareRepository;
private final NotificationService notificationService;
public Long createFare(Long customerId, int amount) {
Fare fare = Fare.draft(customerId, amount); // ← 객체에 위임
fareRepository.save(fare);
return fare.getId();
}
@Transactional
public void pay(Long fareId) {
Fare fare = fareRepository.findById(fareId).orElseThrow();
fare.pay(); // ← 객체에 위임
notificationService.sendPaymentConfirmation(fare);
}
}
효과:
이게 안티패턴인 걸 알면서도 왜 많은 프로젝트가 빈혈 모델일까요?
전형적인 Spring Boot 튜토리얼:
[Controller] → [Service] → [Repository] → [Entity]
↑
모든 로직은 여기에
이 구조가 "Entity는 데이터, Service는 로직" 이라는 잘못된 인식을 심음.
@Entity
@Getter
@Setter // ← 이거 한 줄이 빈혈 모델 유도
@NoArgsConstructor
@AllArgsConstructor
public class Fare {
private int amount;
private String status;
// ...
}
Lombok의 @Setter 가 있으면 "setter로 모든 필드 변경 가능" 이 기본 가정이 됨.
→ @Setter 를 무조건 붙이는 습관이 빈혈 모델을 만드는 가장 큰 원인 ⚠️
작은 프로젝트, MVP 단계에서는:
문제는 프로젝트가 커지면서 비용이 폭발한다는 것.
대학/학원에서 자바를 배울 때:
→ "객체지향" 이라는 이름만 알고, 본질은 모름.
본인 ILIC 코드를 점검해보세요:
@Setter 를 모든 Entity에 붙인다3개 이상 해당 = 빈혈 모델 가능성 높음.
pay(), cancel() 등)@Setter 대신 의도가 명확한 메서드를 만든다균형 잡힌 시각도 필요합니다.
CRUD가 전부인 단순 시스템
DTO, VO
JPA Projection 결과
복잡한 비즈니스 규칙이 있는 도메인
돈, 결제, 계약 등 무결성이 중요한 도메인
장기 유지보수 프로젝트
→ ILIC는 명백히 Rich Model이 필요한 도메인.
빈혈 도메인 모델은 "객체"라는 이름만 빌렸을 뿐 사고방식은 절차지향이다.
데이터(필드)와 행동(메서드)이 함께 있어야 진짜 객체이고, Setter 남발과 Service 비대화는 빈혈의 가장 흔한 증상이다. ILIC처럼 비즈니스 규칙이 복잡한 도메인은 반드시 Rich Domain Model로 가야 장기 유지보수가 가능하다.
박승제님이 이 주제를 더 깊이 파고 싶다면:
상속은 "코드 재사용 도구" 가 아니라 "관계를 표현하는 도구" 이다.
상속을 잘못 쓰면 유지보수 지옥 이 됩니다. 그래서 현대 자바의 격언은:
"상속보다 합성을 선호하라" (Favor Composition over Inheritance)
— Joshua Bloch, Effective Java
상속(extends)은 단순히 메서드를 가져오는 게 아닙니다. 선언입니다:
public class Stack extends ArrayList { ... }
→ 이 한 줄이 자바에게 "Stack은 ArrayList의 한 종류다" 라고 선언하는 것.
자바와 사용하는 모든 코드가 이 선언을 믿고 행동함:
→ 이게 깨지면 시스템 전체가 흔들림.
이 비유로 상속과 합성의 차이를 명확히 보겠습니다.
public class Doctor extends Human { ... }
→ "의사는 사람의 한 종류다" 라는 선언
이건 자연스럽습니다. 의사는 사람의 일종이니까. is-a 관계 가 성립.
public class Doctor extends Job { ... }
→ "의사는 직업의 한 종류다" 라는 선언
음... 일단 말은 됩니다. 의사도 직업의 일종이긴 하니까.
그런데 문제가 있어요:
상속은 고정된 관계 입니다. 한 번 Doctor extends Job 으로 만들면:
→ "is-a" 처럼 보여도 사실 "has-a" 인 경우가 많음.
public class Person {
private Job currentJob; // "직업을 가짐" (has-a)
private List<Job> sideJobs; // "여러 직업 가질 수 있음"
public void changeJob(Job newJob) { // 직업 바꿀 수 있음
this.currentJob = newJob;
}
public void retire() { // 직업 없을 수도 있음
this.currentJob = null;
}
}
→ 유연함. 사람이 직업을 가지고, 바꾸고, 잃고, 추가하고 — 자유.
Stack extends ArrayList 가 왜 끔찍한지 실제 코드 시나리오 로 보겠습니다.
내가 Stack을 만든 이유:
push() 와 pop() 만 노출하고 싶음public class Stack<E> extends ArrayList<E> {
public void push(E item) {
add(item);
}
public E pop() {
return remove(size() - 1);
}
}
이제 사용자가 이렇게 할 수 있어요:
Stack<String> stack = new Stack<>();
stack.push("A");
stack.push("B");
stack.push("C");
// LIFO대로 사용 — OK
String top = stack.pop(); // "C"
// 그런데... ArrayList의 메서드가 다 노출돼 있음 ❌
stack.add(0, "X"); // 맨 앞에 추가? Stack이 아닌데!
stack.remove(1); // 중간 제거? Stack이 아닌데!
stack.set(0, "Y"); // 임의 위치 수정? Stack이 아닌데!
stack.subList(0, 2); // 부분 리스트? Stack이 아닌데!
stack.sort(...); // 정렬? Stack이 아닌데!
문제 1: Stack의 의도가 무너짐. "LIFO만 허용" 이라는 약속이 깨짐.
문제 2: 사용자가 잘못 쓰면 디버깅 지옥.
stack.add(0, "X"); // 실수로 0번에 추가
String top = stack.pop(); // "C" (X가 아닌데?)
// → 사용자: "어? 내가 마지막에 추가한 건 X인데 왜 C가 나오지?"
// → 한참 디버깅하다 add(0, ...) 발견
문제 3: API 사용자에게 "Stack은 사실 ArrayList입니다" 라는 메시지를 줌.
public class Stack<E> {
private final List<E> items = new ArrayList<>(); // 가짐 (has-a)
public void push(E item) {
items.add(item);
}
public E pop() {
if (items.isEmpty()) {
throw new IllegalStateException("Stack is empty");
}
return items.remove(items.size() - 1);
}
public E peek() {
if (items.isEmpty()) {
throw new IllegalStateException("Stack is empty");
}
return items.get(items.size() - 1);
}
public int size() {
return items.size();
}
public boolean isEmpty() {
return items.isEmpty();
}
}
이제 사용자가 할 수 있는 건:
Stack<String> stack = new Stack<>();
stack.push("A");
stack.push("B");
stack.push("C");
stack.pop(); // OK
stack.peek(); // OK
stack.size(); // OK
stack.isEmpty(); // OK
// 이건 안 됨 ✅
stack.add(0, "X"); // 컴파일 에러! Stack에 add 없음
stack.remove(1); // 컴파일 에러!
stack.sort(...); // 컴파일 에러!
→ Stack의 약속이 지켜짐. 사용자가 잘못 쓸 수 없음.
흥미로운 사실: 자바 표준 라이브러리의 java.util.Stack 도 같은 실수를 했습니다.
// JDK의 Stack — 안티패턴 사례 ⚠️
public class Stack<E> extends Vector<E> {
public E push(E item) { addElement(item); return item; }
public E pop() { ... }
// ...
}
→ Vector를 상속받았기 때문에 Vector의 모든 메서드 노출됨.
자바 공식 문서에서도 인정:
"A more complete and consistent set of LIFO stack operations is provided by the Deque interface and its implementations, which should be used in preference to this class."
(Deque 인터페이스가 더 완전하고 일관된 LIFO 스택 연산을 제공하므로, Stack 클래스 대신 사용해야 한다.)
→ 자바 자신도 "이건 잘못 만들었으니 쓰지 마세요" 라고 인정.
현대 자바의 권장 방식:
Deque<String> stack = new ArrayDeque<>();
stack.push("A");
stack.pop();
부모 클래스의 내부 구현이 바뀌면 자식이 깨짐.
예시:
public class CountingArrayList<E> extends ArrayList<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c); // 내부적으로 add()를 호출함!
}
public int getAddCount() { return addCount; }
}
테스트:
CountingArrayList<String> list = new CountingArrayList<>();
list.addAll(List.of("A", "B", "C"));
System.out.println(list.getAddCount()); // 6 ❌ (3이어야 하는데)
왜?:
addAll() 이 addCount += 3 후add() 를 3번 호출 → addCount 가 또 3 증가→ 부모(ArrayList)의 내부 구현을 모르면 자식이 정확하게 동작 안 함.
→ 자바 다음 버전에서 ArrayList.addAll() 의 구현이 바뀌면? 자식 클래스가 갑자기 깨짐.
이를 "취약한 기반 클래스 문제 (Fragile Base Class Problem)" 라고 부릅니다.
자바는 다중 상속이 불가능 합니다.
public class A { ... }
public class B { ... }
public class C extends A, B { ... } // ❌ 컴파일 에러
왜 문제인가:
// 합성으로는 자유로움
public class C {
private final A a;
private final B b;
// 두 가지 능력 모두 활용 가능
}
public class Customer extends NormalCustomer { ... }
// → 이 Customer는 영원히 NormalCustomer
// → 런타임에 VipCustomer로 바꿀 수 없음
합성이라면 가능:
public class Customer {
private CustomerLevel level; // 런타임에 바꿀 수 있음
public void upgradeToVip() {
this.level = new VipLevel(); // 등급 변경
}
}
→ 현실의 변화 (사용자가 일반 → VIP 승급) 를 표현하려면 합성이 필수.
처음에는 단순했던 계층이 점점 깊어집니다.
Animal
└─ Mammal
└─ Carnivore
└─ Felidae
└─ Cat
└─ DomesticCat
└─ KoreanShortHair
문제:
상속이 무조건 나쁜 건 아닙니다. 명확한 기준 이 있습니다.
다음 모든 조건 을 만족할 때만 상속:
명확한 is-a 관계인가?
부모가 변하지 않을 것인가?
자식이 부모의 모든 메서드를 의미 있게 사용할 수 있나?
add(int, E) 를 의미 있게 사용 X → 상속 X부모-자식 관계가 영구적인가?
"공통 코드 재사용" 이 목적인가?
public abstract class Report {
public final void generate() { // 흐름 고정
loadData();
format();
save();
}
protected abstract void loadData();
protected abstract void format();
protected abstract void save();
}
public class FareReport extends Report { ... }
public class CustomerReport extends Report { ... }
→ "Report의 한 종류다" + 흐름 공통화 → 상속 OK.
public abstract class Customer {
// 공통 속성과 행동
}
public class IndividualCustomer extends Customer { ... }
public class CorporateCustomer extends Customer { ... }
→ 진짜 is-a 관계 → 상속 OK.
public class MyController extends AbstractController { ... }
public class CustomFilter extends OncePerRequestFilter { ... }
→ Spring이 의도한 확장 방식 → 상속 OK.
이미 만든 상속 코드를 합성으로 리팩토링하는 패턴.
public class CountingArrayList<E> extends ArrayList<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c); // 부모 내부 구현 의존
}
public int getAddCount() { return addCount; }
}
public class CountingList<E> {
private final List<E> delegate; // 가짐 (has-a)
private int addCount = 0;
public CountingList(List<E> delegate) {
this.delegate = delegate;
}
public boolean add(E e) {
addCount++;
return delegate.add(e);
}
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return delegate.addAll(c); // 부모 내부 구현 무관
}
public int getAddCount() { return addCount; }
// 필요한 List 메서드만 위임
public E get(int index) { return delegate.get(index); }
public int size() { return delegate.size(); }
// ...
}
효과:
CountingList<String> list = new CountingList<>(new ArrayList<>());
// 또는
CountingList<String> list = new CountingList<>(new LinkedList<>()); // 다른 구현도 OK
ILIC 운임의 Customer 등급별 계산을 상속으로:
public class Customer {
protected String name;
protected String email;
public int calculateDiscount(int amount) { return 0; }
}
public class VipCustomer extends Customer {
@Override
public int calculateDiscount(int amount) { return amount * 20 / 100; }
}
public class PartnerCustomer extends Customer {
@Override
public int calculateDiscount(int amount) { return amount * 30 / 100; }
}
문제:
// 등급은 정책 객체로 분리
public interface DiscountPolicy {
int calculate(int amount);
}
public class NoDiscountPolicy implements DiscountPolicy {
public int calculate(int amount) { return 0; }
}
public class VipDiscountPolicy implements DiscountPolicy {
public int calculate(int amount) { return amount * 20 / 100; }
}
public class PartnerDiscountPolicy implements DiscountPolicy {
public int calculate(int amount) { return amount * 30 / 100; }
}
// Customer는 정책을 "가짐" (has-a)
public class Customer {
private String name;
private String email;
private DiscountPolicy discountPolicy; // 가짐
public int calculateDiscount(int amount) {
return discountPolicy.calculate(amount);
}
public void changeDiscountPolicy(DiscountPolicy newPolicy) { // 런타임 변경 가능
this.discountPolicy = newPolicy;
}
}
효과:
customer.changeDiscountPolicy(new VipDiscountPolicy()) ← 객체 그대로DiscountPolicy 구현체 1개만 추가→ 이게 17주차 Spring Strategy Pattern과 직결되는 사고방식.
[코드를 재사용하고 싶다]
↓
"이건 명확히 부모의 한 종류인가?" (is-a)
↓
YES ─→ 상속 검토
| ↓
| "부모가 안정적이고 변하지 않는가?"
| ↓
| YES ─→ 상속 OK
| NO ─→ 합성으로
|
NO ──→ 합성 (has-a)
한 줄 결정:
확신이 없으면 합성. 잘못된 합성은 수정이 쉽지만, 잘못된 상속은 수정이 매우 어렵다.
"상속은 캡슐화를 위반한다. 상속 대신 합성을 사용하라."
"클래스 상속보다 객체 합성을 선호하라."
"상속은 가장 강한 결합이다. 가능한 약하게 결합하라."
→ 소프트웨어 거장들의 일관된 메시지: 상속을 조심하라.
상속은 "코드 재사용 도구" 가 아니라 "is-a 관계 선언" 이다.
"공통 코드 쓰고 싶어서" 라는 동기로 상속을 쓰면 캡슐화 파괴, 취약한 기반 클래스, 변경 불가, 단일 상속 제약 등의 문제가 발생한다. 확신이 없으면 합성 — 이게 현대 자바의 황금률이다.
"한 클래스가 너무 많은 책임을 가짐"
public class FareManager { // ❌ 모든 걸 다 함
public void createFare(...) { }
public void deleteFare(...) { }
public void calculateDiscount(...) { }
public void sendNotification(...) { }
public void generateReport(...) { }
public void backupData(...) { }
public void validateUser(...) { }
// ... 50개 메서드
}
왜 문제:
해결:
FareService, NotificationService, ReportService...public class Fare {
public int amount; // ❌ 외부에서 직접 수정 가능
}
왜 위험:
해결: private + 메서드를 통한 접근
객체지향이 항상 좋은 건 아니다 ⚠️
객체지향이 적합하지 않은 경우:
현대 트렌드:
이 Unit이 다른 개념과 어떻게 연결되는지 본다.
[Unit 1.1: 절차지향 vs 객체지향] ← 지금 여기
↓
[Unit 1.2: 클래스와 객체의 본질] — 다음 학습
↓
[Phase 2: 클래스 문법과 객체간 관계]
↓
[Phase 3: SOLID — OOP를 망가뜨리지 않는 5계명]
1주차 내:
미래 주차:
[캡슐화] ─────→ [SRP] (단일 책임)
[DIP] (의존 역전)
[상속] ───────→ [LSP] (리스코프 치환)
[ISP] (인터페이스 분리)
[다형성] ─────→ [OCP] (개방-폐쇄)
[DIP] (의존 역전)
[추상화] ─────→ 거의 모든 SOLID 원칙
→ SOLID는 4대 원칙을 잘 쓰기 위한 5가지 규칙.
[1960s] 절차지향 (C)
↓
[1990s] 객체지향 (Java) ⭐ 우리가 학습 중
↓
[2000s] 함수형 부활 (Scala, Haskell)
↓
[2010s] 다중 패러다임 (Kotlin, Modern Java)
↓
[2020s+] 도구별 적재적소 사용
자바도 8버전부터 함수형 요소 (Lambda, Stream) 추가 — 3주차에서 학습.
| 질문 | 이 Unit에서의 답 |
|---|---|
| "객체지향이 뭔가요?" | 데이터와 행동을 객체로 묶고 4대 원칙으로 관리 |
| "객체지향의 4대 원칙은?" | 캡슐화, 상속, 다형성, 추상화 |
| "왜 객체지향을 쓰나요?" | 큰 시스템에서 유지보수/확장/재사용을 위해 |
| "절차지향과 차이는?" | 데이터-함수 분리 vs 객체로 묶음, 그로 인한 if 지옥 vs 다형성 |
1️⃣ 객체지향은 "큰 시스템의 복잡도"를 다루기 위한 패러다임이다.
절차지향이 틀린 게 아니라, 코드가 커지면서 데이터 무결성·확장성·재사용성에 한계가 드러났고, 이를 해결하기 위해 데이터와 행동을 객체 라는 단위로 묶는 사고방식이 등장했다.
2️⃣ 4대 원칙(캡상다추)을 지킬 때만 객체지향의 가치가 살아난다.
캡슐화(데이터 보호), 상속(공통 추출), 다형성(if 지옥 해결), 추상화(본질만 노출) — 이 네 가지가 함께 작동할 때 "새 기능 추가 시 기존 코드 안 건드리기" 같은 마법이 가능하다. 빈혈 모델·God Class 같은 안티패턴은 객체지향의 탈만 쓴 절차지향이다.
3️⃣ 객체지향은 도구다 — 모든 곳에 쓰면 안 된다.
큰 시스템·협업·장기 유지보수에는 강력하지만, 단순 스크립트나 수학 계산에는 과한 도구다. 자바는 객체지향을 기본으로 하되 함수형(Lambda, Stream)도 흡수한 다중 패러다임 언어 로 진화 중이다. 적재적소가 핵심.