🎯 1주차 Unit 1.1-2 — 절차지향 vs 객체지향

Psj·2026년 5월 6일

F-lab

목록 보기
22/239

🎯 Unit 1.1-2 — 절차지향 vs 객체지향

F-lab Java 1주차 / Phase 1 / Unit 1.1 본격 학습 자료
9-섹션 마스터 프롬프트 형식으로 깊이 파헤친다.

선수 지식: 없음 (학습 여정의 출발점)
다음 Unit: 1.2 — 클래스와 객체의 본질


🌍 1. 세상 속 비유

절차지향의 한계와 객체지향의 등장

결론부터

가장 근본적인 차이는 "데이터의 주인이 누구냐" 이다.

  • 절차지향: 데이터는 주인이 없다. 누구나 가져다 처리할 수 있다.
  • 객체지향: 데이터는 주인(객체)이 있다. 주인을 통해서만 다룰 수 있다.

이 차이는 사고방식에서도 드러난다:

  • 절차지향은 "동사" 중심 — "무엇을 할 것인가" (행동의 순서가 주인공)
  • 객체지향은 "명사" 중심 — "무엇이 있는가" (객체가 주인공)

객체지향은 절차지향의 3가지 큰 문제 를 해결하기 위해 등장했습니다.


🎬 사고방식의 차이 — "동사 vs 명사"

같은 일을 두 방식으로 표현해보자.

상황: 강아지를 짖게 하고 싶다.

절차지향 — 동사 중심

짖게_하다(강아지)

→ "짖다" 라는 동사(함수) 가 주인공
→ 강아지는 그냥 재료(데이터)
→ 사고 흐름: "짖는 행동을 어떻게 처리할까?"

객체지향 — 명사 중심

강아지.짖다()

→ "강아지" 라는 명사(객체) 가 주인공
→ 짖는 능력은 강아지가 소유
→ 사고 흐름: "누가 짖는 책임을 가질까?"


같은 일, 다른 시각

절차지향 (동사 중심)객체지향 (명사 중심)
코드 형태짖게_하다(강아지)강아지.짖다()
주인공동사 (행동)명사 (객체)
사고"어떻게 처리할까""누가 책임질까"
데이터함수에 전달됨객체가 소유함
명령 방식"이 데이터를 처리해""너 이거 해"

이 사고방식의 차이가 마을의 진화 비유로 풀어보면 더 명확해진다.


🏘️ 전체 비유 — "마을의 진화"

옛날 한 마을이 있었다. 처음엔 작은 마을이었지만 점점 커졌다. 그러면서 문제가 생겼고, 마을의 운영 방식이 진화했다.

이 마을의 이야기가 곧 절차지향에서 객체지향으로의 진화 이야기다.


1단계 — 절차지향의 시대 (작은 마을)

마을의 모습

마을 한가운데에 공동 창고 가 있다. 모든 마을 사람의 물건이 여기에 모여있다.

  • 철수의 쌀
  • 영희의 옷
  • 민수의 도구
  • ... 모두 한 창고에

마을 사람들은 "일꾼" 들. 누가 무엇을 할지는 정해져 있다:

  • 일꾼 A: 요리 담당 → 창고에서 쌀 가져가서 밥 함
  • 일꾼 B: 빨래 담당 → 창고에서 옷 가져가서 빨래
  • 일꾼 C: 청소 담당 → 창고에서 도구 가져가서 청소

마을이 작을 땐 이 방식이 잘 굴러갔다.

[공동 창고: 쌀, 옷, 도구, ...]  ← 데이터 (명사)
       ↓ (가져감)
[일꾼 A] → 요리              ← 함수 (동사)
[일꾼 B] → 빨래              ← 함수 (동사)
[일꾼 C] → 청소              ← 함수 (동사)

→ 여기서 주인공은 일꾼(동사) 이다. 창고는 그냥 재료 보관소.
→ 코드도 마찬가지: 요리하다(쌀), 빨래하다(옷)동사가 주인공.

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);  // "청소하다(창고)" — 동사 중심
}

이게 절차지향: 데이터(창고 구조체) 와 행동(함수) 이 분리됨. 함수(동사)는 데이터를 인자로 받아서 처리.


절차지향의 3가지 큰 문제

마을이 커지면서 문제가 폭발하기 시작했다.

문제 1: 데이터가 엉망이 됨 (무결성 붕괴)

상황: 공동 창고에 누구나 접근 가능하다.

  • 일꾼 A가 실수로 영희의 옷을 가져가서 행주로 씀
  • 일꾼 B가 민수의 도구를 빨래판으로 씀
  • 누군가 쌀을 음수로 기록함 (-50kg) — 막을 방법 없음
  • 누가 무엇을 망가뜨렸는지 추적 불가

왜 그럴까? 데이터는 주인 없는 명사 라서, 동사(함수)들이 마음대로 만질 수 있기 때문.

C 언어 코드로 보면

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를 음수로 만들었나? 모름

데이터(명사)에 주인이 없으니, 누구나 망가뜨림.


문제 2: 새 마을 사람 추가가 지옥 (확장성 붕괴)

상황: 마을에 외국인 가족이 이사 왔다. 이 가족은 다른 음식을 먹고, 다른 옷을 입는다.

이제 모든 일꾼에게 새 규칙을 가르쳐야 한다:

  • 일꾼 A (요리): "외국인이면 빵을 굽고, 한국인이면 밥을 해"
  • 일꾼 B (빨래): "외국인 옷이면 다르게 빨고, 한국인 옷이면..."
  • 일꾼 C (청소): "외국인 집이면 신발 신고, 한국인 집이면 벗고..."

또 다른 외국인 가족이 오면? 모든 일꾼을 또 다 가르쳐야 함.

왜 그럴까? 동사(함수)가 주인공이라, 각 동사 안에 모든 종류의 처리를 다 넣어야 하기 때문. 새 종류가 추가되면 모든 동사에 새 분기 추가.

C 언어 코드로 보면

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개 다 수정 ❌
// 한 군데라도 빠뜨리면? 버그 ❌

새 종류(명사)가 추가되면 모든 동사를 수정해야 함.


문제 3: 누가 뭘 하는지 모름 (협업 붕괴)

상황: 마을 인구가 1,000명이 되었다.

  • 일꾼이 500명, 창고 물건이 10만 개
  • 누가 어떤 물건을 만지는지 추적 불가
  • 한 일꾼이 규칙을 바꾸면 다른 일꾼들이 영향받음
  • 새로 온 일꾼은 500명의 규칙을 다 외워야 일할 수 있음

C 언어 코드로 보면

// 거대한 공용 데이터 (주인 없는 명사)
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개 함수 모두 수정 필요 ❌

시스템이 커지면 협업 자체가 불가능.


2단계 — 객체지향의 등장 (커진 마을의 해법)

마을 사람들이 모여서 결정했다:

"공동 창고를 없애자. 각자 자기 물건을 자기가 관리하자."

그래서 각자의 집 을 짓고, 각자의 물건각자의 집 에 두기로 했다.

새 마을의 모습

[철수의 집]                  [영희의 집]                [민수의 집]
- 가진 것: 쌀, 도구          - 가진 것: 옷               - 가진 것: 도구
- 할 줄 아는 것:             - 할 줄 아는 것:           - 할 줄 아는 것:
  • 밥 짓기                   • 빨래하기                  • 청소하기
  • 청소하기                   • 옷 정리하기              • 도구 정비

핵심 변화:

  • 각 집(명사)이 자기 물건의 주인
  • 각 집(명사)이 자기 행동도 소유
  • 외부에서 직접 만지는 것 금지
  • "철수야, 밥 좀 해줄래?" 라고 부탁 만 가능
  • 철수가 자기 쌀로 알아서 밥 함

→ 이제 주인공은 명사(객체) 다.
→ 코드도 바뀐다: 요리하다(쌀)철수.밥짓다()명사가 주인공, 동사는 명사의 능력.

이게 객체지향: 데이터(명사)와 행동(동사)을 객체가 함께 소유.


객체지향이 3가지 문제를 해결하는 방식

해결 1: 무결성 보장 — "주인을 통해서만 접근"

비유: 영희의 옷장에는 영희만 손댈 수 있다.

  • 누가 와서 "옷 좀 빌려줘" 부탁 → 영희가 검토 후 결정
  • 더러운 손으로 만지려 하면 → 영희가 거부
  • 옷이 망가질 일도, 잘못 쓰일 일도 없음

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;     // 컴파일 에러! ✅ — 애초에 접근 불가

데이터(명사)에 주인이 생기니, 주인이 보호함.


해결 2: 확장이 쉬워짐 — "새 집을 짓기만 하면 됨"

비유: 외국인 가족이 이사 왔다.

  • 새 가족은 자기 집 을 짓고 자기만의 방식 으로 산다
  • 기존 마을 사람들은 신경 안 써도 됨
  • "철수야 밥 줘", "John, give me bread" — 각자 알아서 처리

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 분기 없음, 각 명사가 자기 행동 책임

새 명사(종류) 추가 시 기존 코드는 안 건드림.


해결 3: 협업이 가능해짐 — "각자 자기 집만 책임"

비유: 1,000명이 사는 마을이지만 이제 작동한다.

  • 각자 자기 집의 일 만 책임
  • 다른 사람의 집 내부는 알 필요 없음
  • 새로 이사 온 사람도 자기 집만 정리 하면 일 시작 가능
  • "영희네 집 일은 영희에게 물어봐" — 명확한 책임 분담

Java 코드로:

프로젝트 인원: 50명
- A팀: Fare 클래스(명사) 담당
- B팀: Customer 클래스(명사) 담당
- C팀: Notification 클래스(명사) 담당

각 팀은 자기 명사만 책임
다른 팀의 내부 구현은 알 필요 X
인터페이스(메서드 시그니처)만 약속하면 협업 가능 ✅

명사별로 책임이 분리되니 대규모 협업 가능.


정리 — 한눈에 보는 진화

작은 마을 (절차지향)큰 마을 (객체지향)
사고방식동사 중심명사 중심
코드 형태처리하다(데이터)객체.처리하다()
데이터 위치공동 창고각자의 집
주인없음각 집의 주인
접근 방식누구나 직접주인에게 부탁
새 종류 추가모든 일꾼(동사) 재교육새 집(명사) 짓기만
무결성깨지기 쉬움주인이 보호
확장 비용폭발적 증가낮음
협업불가능가능

한 문장 요약

절차지향은 "동사가 주인공" 이라 공동 창고에 모두의 데이터가 떠다녔고, 누구나 망가뜨릴 수 있고, 새 종류 추가 시 모든 동사를 재교육해야 했다.

객체지향은 "명사가 주인공" 으로 바뀌어, 각자의 집에 자기 데이터를 두고 주인을 통해서만 접근하게 함으로써, 무결성 보장 + 확장 용이 + 협업 가능이라는 3가지 문제를 한 번에 해결했다.


🔥 2. 탄생 배경

절차지향의 시대 (1950s ~ 1980s)

컴퓨터 프로그래밍 초기에는 절차지향이 자연스러웠다. 왜냐하면:

  1. 하드웨어가 절차적이었다 — CPU는 명령어를 순서대로 실행
  2. 프로그램이 작았다 — 한 사람이 모두 이해 가능
  3. 수학적 사고와 맞았다 — 함수 f(x) = ... 처럼

대표 언어:

  • 포트란(Fortran, 1957) — 과학 계산
  • 코볼(COBOL, 1959) — 비즈니스
  • C (1972) — 시스템 프로그래밍

이 시대의 프로그램은 "함수의 모음" 이었다. 데이터는 별도로 관리하고, 함수가 데이터를 받아 처리하는 방식.


객체지향의 등장 (1960s ~ 1990s)

문제가 생기기 시작했다. 소프트웨어가 커지면서:

  1. 수만 줄, 수십만 줄 코드 — 한 사람이 다 못 봄
  2. 여러 명이 협업 — 누가 어떤 함수를 고치는지 충돌
  3. 유지보수 지옥 — 한 데이터 구조 바꾸면 수백 함수 수정
  4. 재사용 불가 — 다른 프로젝트에서 못 가져옴

이 위기를 해결하려는 시도로 객체지향이 탄생:

  • 시뮬라(Simula 67) — 1967, 최초 OOP 언어 (시뮬레이션용)
  • 스몰토크(Smalltalk-80) — 1980, OOP 개념 정립 ⭐
  • C++ — 1985, C에 OOP 추가 (실용성)
  • 자바(Java) — 1995, OOP의 대중화 ⭐

자바는 "플랫폼 독립성""순수 OOP" 를 무기로 폭발적으로 성장. 90년대 인터넷 폭발과 함께 표준 언어가 됨.


핵심 통찰

"절차지향은 틀렸다 가 아니다. 큰 시스템에서 한계 가 있다."

소규모 스크립트는 여전히 절차지향이 효율적. 하지만 ILIC 같은 102 테이블, 431 API 규모의 시스템은 객체지향 없이는 관리 불가능.


💣 3. 없으면 생기는 문제

객체지향이 없다면? 절차지향만으로 큰 시스템을 만들면 어떤 문제가 생기는지 구체적인 예시 로 보자.

시나리오: ILIC의 운임 견적 시스템 (절차지향 버전)

// 데이터 (구조체)
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개 함수

문제 1: 데이터와 함수가 따로 놀음

struct Fare fare;
fare.amount = 50000;
fare.status = "DRAFT";

// 검증 안 했는데 저장 가능 ❌
saveFare(&fare);

// 누군가 직접 수정 가능 ❌
fare.amount = -99999;  // 음수! 무결성 깨짐
saveFare(&fare);

왜 위험한가:

  • 누가 데이터를 어떻게 만지는지 추적 불가
  • 잘못된 상태로 저장되어도 컴파일러가 잡아주지 않음
  • 100명이 협업하면 누군가는 반드시 잘못 사용

문제 2: 새 기능 추가 시 모든 함수 수정

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 추가 ❌

왜 끔찍한가:

  • 새 등급 (PARTNER) 추가 → 200개 함수 다 수정
  • 한 군데라도 빠뜨리면 버그
  • 코드 리뷰 지옥
  • "신입 개발자가 들어오면" 이 모든 if를 다 알아야 함

문제 3: 같은 이름 함수 못 만듦 (이름 충돌)

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(...);  // 점점 길어짐

문제 4: 코드 재사용 불가

// A 프로젝트에서 만든 운임 계산 로직
int calculateFare(struct Fare* fare) { ... }

// B 프로젝트에서 가져다 쓰려면?
// → struct Fare 정의도 가져와야 함
// → 의존하는 함수 5개도 가져와야 함
// → 그 함수들이 의존하는 함수 10개도...
// → 결국 거의 다 가져와야 함 ❌

결론: 절차지향의 한계

큰 시스템에서 절차지향은:
1. 데이터 무결성 보장 어려움
2. 수정 시 영향 범위 폭발 (한 곳 고치면 100곳)
3. 확장성 제로 (새 기능 추가가 두려움)
4. 재사용 불가 (다른 프로젝트 활용 X)
5. 협업 지옥 (누가 뭘 만지는지 모름)

이 모든 문제를 해결하려고 객체지향이 등장.


✅ 4. 해결책 — 객체지향 4대 원칙

객체지향은 위 문제들을 4가지 핵심 원칙 으로 해결한다. (이건 모든 면접의 단골이다 ⭐)

원칙 1: 캡슐화 (Encapsulation)

"데이터와 그 데이터를 다루는 행동을 하나의 객체로 묶고, 외부에서 직접 접근을 막음"

위 문제 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;    // 컴파일 에러! ✅

효과:

  • 데이터는 내부에 숨김 (private)
  • 검증된 방법 (메서드) 으로만 접근
  • 잘못된 상태 자체가 불가능

원칙 2: 상속 (Inheritance)

"기존 클래스의 속성과 행동을 물려받아 확장"

위 문제 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) 은 부모에 한 번만
  • 각자 다른 부분만 구현
  • 새 등급 추가 → 새 클래스 1개만 만들면 됨

원칙 3: 다형성 (Polymorphism)

"같은 이름의 메서드가 객체에 따라 다르게 동작"

위 문제 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

이게 객체지향의 진짜 마법.


원칙 4: 추상화 (Abstraction)

"복잡한 내부를 숨기고 본질만 드러냄"

위 문제 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) 으로 다양한 동작 가능 (이름 충돌 해결)
  • 인터페이스만 알면 어떤 구현이든 사용 가능 (재사용성)
  • 새 알림 채널 추가 시 인터페이스만 구현하면 끝

4대 원칙 요약 ⭐⭐⭐ (면접 단골)

원칙영문한 줄 설명
캡슐화Encapsulation데이터와 행동을 묶고 외부 접근 통제
상속Inheritance부모의 특성을 자식이 물려받음
다형성Polymorphism같은 메시지에 객체마다 다른 응답
추상화Abstraction복잡함을 숨기고 본질만 노출

암기 팁: "캡상다추" (캡슐화-상속-다형성-추상화)


🏗️ 5. 내부 동작 원리

이론을 코드 레벨에서 어떻게 구현되는지 보자.

절차지향 코드의 메모리 모델

// 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() 메서드 코드 ⭐

핵심 차이 ⭐ :

  • 객체 (인스턴스) 는 Heap에, 데이터 (필드) 만 보유
  • 메서드 코드 는 Method Area에 클래스당 한 벌
  • 메서드 호출 시 JVM이 "어느 객체의 메서드인가" 를 추적 (this)

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 내부:

  • 메서드 호출 시 JVM이 자동으로 객체 참조를 첫 인자로 전달
  • 컴파일러가 doubleAmount(fare1) 같은 형태로 변환
  • 우리 눈에는 안 보이지만 그렇게 동작

다형성의 내부 동작 — Virtual Method Table (VMT)

다형성은 어떻게 가능할까? 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 반환

핵심:

  • 참조 타입 (Customer) 가 아닌 실제 객체 타입 (VipCustomer) 의 메서드 호출
  • 이게 동적 바인딩 (Dynamic Binding)
  • 컴파일 시점이 아닌 런타임에 결정

→ 4-5주차에 더 깊이 다룰 주제. 지금은 "다형성이 마법이 아닌 VMT 덕분" 만 기억.


💻 6. 실전 코드 예시

ILIC 도메인으로 절차지향과 객체지향을 직접 비교해보자.

시나리오: 운임 견적 + 고객 등급별 할인


Bad — 절차지향 스타일 (자바로 작성하지만 절차적 사고)

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에 다 모임


Good — 객체지향 스타일

// 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)와 직결.


⚠️ 7. 주의사항 & 흔한 실수

실수 1: Anemic Domain Model (빈혈 도메인 모델)

빈혈 도메인 모델 (Anemic Domain Model) — 깊이 보기

결론부터

빈혈 도메인 모델 = 객체지향의 탈을 쓴 절차지향

  • 클래스는 만들어 놓았지만, 데이터(필드)만 있고 행동(메서드)이 없는 객체
  • 모든 로직은 Service 같은 외부 클래스에 모여있음
  • 형태만 자바 클래스이지, 사고방식은 100% 절차지향

이름의 유래: "빈혈(Anemic)" = 피(생명력=행동)가 없는 객체라는 뜻. Martin Fowler가 2003년 명명.


🩸 왜 "빈혈" 이라고 부르는가

사람의 몸에 비유하면:

건강한 사람 (Rich Domain Model):

  • 뼈와 살 (데이터)
  • 혈액 = 행동 (생명력, 스스로 움직임)
  • 자기 일을 자기가 함

빈혈 환자 (Anemic Domain Model):

  • 뼈와 살 (데이터)
  • 혈액 없음 (생명력 X)
  • 누군가가 옮겨줘야 움직임

→ 객체에 자기 책임이 없으면 빈혈.


📺 비유 — "리모컨 없는 TV"

빈혈 모델 = 리모컨 없는 TV (수동 구식 TV)

옛날 TV를 상상하세요:

  • TV는 그냥 화면 + 스피커가 있는 상자 (데이터)
  • 채널을 바꾸려면 사용자가 직접 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)

현대 스마트 TV:

  • TV가 자기 행동을 가짐
  • "채널 바꿔줘" → TV가 검증 후 알아서 바꿈
  • "볼륨 올려줘" → TV가 최대치 확인하고 알아서 조절
  • "넷플릭스 틀어줘" → 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;
    }
}

🚨 빈혈 모델의 실제 사례 — ILIC 운임 시스템

빈혈 버전 (안티패턴)

// ❌ 빈혈 모델
@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");
    }
    
    // ... 운임 관련 모든 로직이 여기에 ❌
}

문제점이 한눈에 보이시나요?


빈혈 모델이 만드는 6가지 구체적 문제

1. 무결성이 깨짐 — "누구나 setter 호출 가능"

Fare fare = fareRepository.findById(1L);

// 어디서든 직접 setter 호출 가능 ❌
fare.setAmount(-99999);          // 음수 가능!
fare.setStatus("괴상한값");        // 잘못된 상태!
fare.setStatus("PAID");           // 결제 안 했는데 결제됨으로!

// 검증은 FareService 호출할 때만 일어남
// → setter를 직접 부르면 검증 우회

객체가 자기 무결성을 보호 못함. 누구나 망가뜨림.


2. 같은 검증 로직이 여러 곳에 중복

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 ...;  // 또또 작성
    }
}

동일 규칙이 여러 곳에 분산 → 한 곳만 수정하면 다른 곳에 버그.


3. Service가 거대해짐 (God Class)

@Service
public class FareService {
    // 운임 생성 로직
    // 운임 수정 로직
    // 운임 결제 로직
    // 운임 취소 로직
    // 운임 검증 로직
    // 할인 계산 로직
    // 상태 전이 로직
    // ... 50개 메서드, 2000줄 ❌
}

단일 책임 원칙(SRP) 위반. 테스트 어려움. 변경 영향 큼.


4. 비즈니스 로직 추적 불가

"운임을 결제 상태로 바꾸는 규칙이 뭐예요?"
  ↓
"FareService에 있어요"
  ↓
"FareService에 메서드가 50개인데 어디에..."
  ↓
"음... pay() 메서드 같아요. 근데 PaymentService에도 있고, 
 BatchService에도 결제 처리 로직이 있어서 정확한 답은 모르겠네요"

규칙이 흩어져서 추적 불가.


5. 테스트가 어려움

@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 의존성 주입 지옥.


6. 신규 개발자 온보딩 지옥

신입: "Fare를 어떻게 사용하나요?"
선임: "음, FareService를 봐야 해요"
신입: "FareService에 50개 메서드가 있는데..."
선임: "그리고 PaymentService, FareImportService, FareBatchService도 봐야 해요"
신입: "............"

vs

신입: "Fare를 어떻게 사용하나요?"
선임: "Fare 클래스 자체를 보세요. 가능한 행동이 다 거기 있어요"
신입: "아, draft(), submit(), pay(), cancel() ... 명확하네요!"

✅ 정상 모델 (Rich Domain Model)

같은 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);
    }
}

효과:

  • ✅ Fare가 자기 무결성 보호 (잘못된 상태 자체가 불가능)
  • ✅ 검증 로직이 한 곳에
  • ✅ Service가 얇음 (흐름만 조율)
  • ✅ 비즈니스 로직 추적 쉬움 (Fare 클래스만 보면 됨)
  • ✅ 테스트 쉬움 (Fare 객체만으로 가능)
  • ✅ 신입 온보딩 쉬움

🤔 왜 빈혈 모델이 그렇게 흔한가?

이게 안티패턴인 걸 알면서도 왜 많은 프로젝트가 빈혈 모델일까요?

이유 1: JPA + Spring 패턴의 영향

전형적인 Spring Boot 튜토리얼:

[Controller] → [Service] → [Repository] → [Entity]
                  ↑
              모든 로직은 여기에

이 구조가 "Entity는 데이터, Service는 로직" 이라는 잘못된 인식을 심음.

이유 2: Lombok의 함정

@Entity
@Getter
@Setter   // ← 이거 한 줄이 빈혈 모델 유도
@NoArgsConstructor
@AllArgsConstructor
public class Fare {
    private int amount;
    private String status;
    // ...
}

Lombok의 @Setter 가 있으면 "setter로 모든 필드 변경 가능" 이 기본 가정이 됨.

@Setter 를 무조건 붙이는 습관이 빈혈 모델을 만드는 가장 큰 원인 ⚠️

이유 3: 트랜잭션 스크립트 패턴이 빠른 개발에는 유리

작은 프로젝트, MVP 단계에서는:

  • 빈혈 + Service 패턴이 빠르게 개발할 수 있음
  • 비즈니스 로직이 단순하면 큰 문제 X

문제는 프로젝트가 커지면서 비용이 폭발한다는 것.

이유 4: 진짜 객체지향 학습 부족

대학/학원에서 자바를 배울 때:

  • 문법 위주
  • "Customer 클래스 만들기" 정도만
  • 도메인 모델링, DDD는 거의 안 가르침

→ "객체지향" 이라는 이름만 알고, 본질은 모름.


🚦 빈혈 모델 자가 진단

본인 ILIC 코드를 점검해보세요:

빈혈 모델 신호 ⚠️

  • Entity 클래스에 getter/setter만 있다
  • @Setter 를 모든 Entity에 붙인다
  • Service 클래스가 1000줄 이상이다
  • 같은 검증 로직이 여러 Service에 중복된다
  • Entity의 상태를 외부에서 자유롭게 바꿀 수 있다
  • "이 로직이 어디 있나" 라는 질문에 즉답하기 어렵다
  • Entity 단위 테스트가 거의 없다

3개 이상 해당 = 빈혈 모델 가능성 높음.

정상 모델 신호 ✅

  • Entity에 의미 있는 메서드가 있다 (pay(), cancel() 등)
  • @Setter 대신 의도가 명확한 메서드를 만든다
  • Service는 흐름 조율만 하고 얇다
  • 검증 로직이 Entity 안에 있다
  • Entity는 잘못된 상태를 가질 수 없다
  • Entity 단위 테스트가 풍부하다

⚖️ 그러나 — 빈혈 모델이 무조건 나쁜 건 아니다

균형 잡힌 시각도 필요합니다.

빈혈 모델이 적합한 경우

  1. CRUD가 전부인 단순 시스템

    • 게시판, 단순 관리 페이지
    • 비즈니스 로직 거의 없음
    • → Rich Model이 오히려 과한 설계
  2. DTO, VO

    • 데이터 전달만 목적
    • 행동 없는 게 정상
  3. JPA Projection 결과

    • 읽기 전용
    • 변경 안 함

Rich Model이 필수인 경우

  1. 복잡한 비즈니스 규칙이 있는 도메인

    • ILIC 운임 시스템 ⭐
    • 상태 전이가 복잡 (DRAFT → SUBMITTED → PAID 등)
    • 다양한 검증 규칙
  2. 돈, 결제, 계약 등 무결성이 중요한 도메인

    • 잘못된 상태 = 사고
  3. 장기 유지보수 프로젝트

    • 규칙이 시간에 따라 변함

ILIC는 명백히 Rich Model이 필요한 도메인.


한 문장 요약

빈혈 도메인 모델은 "객체"라는 이름만 빌렸을 뿐 사고방식은 절차지향이다.

데이터(필드)와 행동(메서드)이 함께 있어야 진짜 객체이고, Setter 남발과 Service 비대화는 빈혈의 가장 흔한 증상이다. ILIC처럼 비즈니스 규칙이 복잡한 도메인은 반드시 Rich Domain Model로 가야 장기 유지보수가 가능하다.


🎓 추가 학습 자료

박승제님이 이 주제를 더 깊이 파고 싶다면:

  1. Martin Fowler, "Anemic Domain Model" (블로그 — 원조 글)
  2. Eric Evans, "Domain-Driven Design" (책)
  3. "Implementing Domain-Driven Design" (Vaughn Vernon) — 더 실용적
  4. 17주차 Phase 8 — Bounded Context (도메인 분리)

실수 2: 무분별한 상속 남용

무분별한 상속 남용 — 깊이 보기

결론부터

상속은 "코드 재사용 도구" 가 아니라 "관계를 표현하는 도구" 이다.

  • "이 코드 쓰고 싶으니까 상속" → ❌ 절대 X
  • "이건 명확히 부모의 한 종류다" → ✅ 그때만 상속

상속을 잘못 쓰면 유지보수 지옥 이 됩니다. 그래서 현대 자바의 격언은:

"상속보다 합성을 선호하라" (Favor Composition over Inheritance)
— Joshua Bloch, Effective Java


🤔 왜 상속이 위험한가 — 본질부터

상속의 진짜 의미

상속(extends)은 단순히 메서드를 가져오는 게 아닙니다. 선언입니다:

public class Stack extends ArrayList { ... }

→ 이 한 줄이 자바에게 "Stack은 ArrayList의 한 종류다" 라고 선언하는 것.

자바와 사용하는 모든 코드가 이 선언을 믿고 행동함:

  • "Stack도 ArrayList처럼 쓸 수 있겠지"
  • "Stack에 ArrayList의 모든 메서드를 호출해도 되겠지"
  • "ArrayList가 가능한 모든 일을 Stack도 할 수 있겠지"

이게 깨지면 시스템 전체가 흔들림.


🎓 비유 — "사람과 직업"

이 비유로 상속과 합성의 차이를 명확히 보겠습니다.

잘못된 상속

public class Doctor extends Human { ... }

→ "의사는 사람의 한 종류다" 라는 선언

이건 자연스럽습니다. 의사는 사람의 일종이니까. is-a 관계 가 성립.


위험한 상속

public class Doctor extends Job { ... }

→ "의사는 직업의 한 종류다" 라는 선언

음... 일단 말은 됩니다. 의사도 직업의 일종이긴 하니까.

그런데 문제가 있어요:

  • 사람이 직업을 바꿀 수 있나요? YES
  • 사람이 두 직업을 가질 수 있나요? YES (의사 + 작가)
  • 사람이 직업이 없을 수도 있나요? YES

상속은 고정된 관계 입니다. 한 번 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;
    }
}

유연함. 사람이 직업을 가지고, 바꾸고, 잃고, 추가하고 — 자유.


💥 ArrayList 상속이 만드는 구체적 재앙

Stack extends ArrayList 가 왜 끔찍한지 실제 코드 시나리오 로 보겠습니다.

의도

내가 Stack을 만든 이유:

  • LIFO (Last In, First Out) 자료구조
  • push()pop() 만 노출하고 싶음
  • 안전하게 사용

실제 결과 — ArrayList의 모든 메서드가 노출됨 ⚠️

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입니다" 라는 메시지를 줌.

  • IDE 자동완성에 ArrayList의 50개 메서드 모두 뜸
  • 사용자는 어느 게 진짜 Stack 메서드인지 헷갈림

✅ 합성으로 다시 만들면

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();

📖 상속의 4가지 치명적 문제

문제 1: 캡슐화 파괴 — "부모의 내부 구현에 자식이 의존"

부모 클래스의 내부 구현이 바뀌면 자식이 깨짐.

예시:

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 증가
  • 결과: 6

부모(ArrayList)의 내부 구현을 모르면 자식이 정확하게 동작 안 함.
→ 자바 다음 버전에서 ArrayList.addAll() 의 구현이 바뀌면? 자식 클래스가 갑자기 깨짐.

이를 "취약한 기반 클래스 문제 (Fragile Base Class Problem)" 라고 부릅니다.


문제 2: 단일 상속 제약 — "한 부모만 가능"

자바는 다중 상속이 불가능 합니다.

public class A { ... }
public class B { ... }
public class C extends A, B { ... }  // ❌ 컴파일 에러

왜 문제인가:

  • "이 클래스는 두 가지 특성을 가져야 한다" 같은 자연스러운 요구를 표현 못 함
  • 인터페이스로는 가능 (메서드 정의만 가능)
  • 합성으로는 가능 (여러 객체를 가질 수 있음)
// 합성으로는 자유로움
public class C {
    private final A a;
    private final B b;
    // 두 가지 능력 모두 활용 가능
}

문제 3: 변경 불가능 — "런타임에 부모를 못 바꿈"

public class Customer extends NormalCustomer { ... }
// → 이 Customer는 영원히 NormalCustomer
// → 런타임에 VipCustomer로 바꿀 수 없음

합성이라면 가능:

public class Customer {
    private CustomerLevel level;  // 런타임에 바꿀 수 있음
    
    public void upgradeToVip() {
        this.level = new VipLevel();  // 등급 변경
    }
}

현실의 변화 (사용자가 일반 → VIP 승급) 를 표현하려면 합성이 필수.


문제 4: 상속 계층의 폭발

처음에는 단순했던 계층이 점점 깊어집니다.

Animal
  └─ Mammal
       └─ Carnivore
            └─ Felidae
                 └─ Cat
                      └─ DomesticCat
                           └─ KoreanShortHair

문제:

  • 7단계 깊이 — 어디서 어떤 메서드가 오는지 추적 어려움
  • 중간에 메서드 하나 바꾸면 영향 범위 폭발
  • 새 종류 추가 시 어디에 끼워야 할지 고민 (고양이 + 강아지 혼혈은?)

🎯 그럼 언제 상속을 써야 하나?

상속이 무조건 나쁜 건 아닙니다. 명확한 기준 이 있습니다.

상속 사용 체크리스트 ✅

다음 모든 조건 을 만족할 때만 상속:

  1. 명확한 is-a 관계인가?

    • "VipCustomer Customer 이다" — YES → 상속 OK
    • "Stack ArrayList 이다" — NO → 상속 X
  2. 부모가 변하지 않을 것인가?

    • 안정된 라이브러리, 인터페이스 — OK
    • 자주 바뀌는 내부 구현 — NO
  3. 자식이 부모의 모든 메서드를 의미 있게 사용할 수 있나?

    • VipCustomer는 Customer의 모든 메서드 사용 OK
    • Stack은 ArrayList의 add(int, E) 를 의미 있게 사용 X → 상속 X
  4. 부모-자식 관계가 영구적인가?

    • VipCustomer는 영원히 Customer — OK
    • 등급이 바뀌는 거라면 — NO (합성이 답)
  5. "공통 코드 재사용" 이 목적인가?

    • YES → ❌ 잘못된 동기 → 합성 사용
    • NO, 진짜 is-a 관계 → 상속 OK

실무에서 상속이 적합한 사례

1. 추상 클래스 + Template Method 패턴

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.

2. 명확한 도메인 계층

public abstract class Customer {
    // 공통 속성과 행동
}

public class IndividualCustomer extends Customer { ... }
public class CorporateCustomer extends Customer { ... }

→ 진짜 is-a 관계 → 상속 OK.

3. 프레임워크의 확장 포인트

public class MyController extends AbstractController { ... }
public class CustomFilter extends OncePerRequestFilter { ... }

→ Spring이 의도한 확장 방식 → 상속 OK.


🔄 상속을 합성으로 바꾸는 방법

이미 만든 상속 코드를 합성으로 리팩토링하는 패턴.

Before — 상속

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; }
}

After — 합성 (Wrapper / Decorator 패턴)

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(); }
    // ...
}

효과:

  • ArrayList의 내부 구현 변경에 영향 X
  • ArrayList든 LinkedList든 자유롭게 받을 수 있음
  • 노출할 메서드만 명시적으로 노출
CountingList<String> list = new CountingList<>(new ArrayList<>());
// 또는
CountingList<String> list = new CountingList<>(new LinkedList<>());  // 다른 구현도 OK

🏗️ ILIC 운임 시스템에서의 적용

잘못된 예 — 상속 남용

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; }
}

문제:

  • 사용자가 일반 → VIP 승급하면? 객체를 새로 만들어야 함
  • 한 사용자가 여러 등급을 가질 수 있다면? (예: 카드 등급 + 멤버십 등급)
  • 등급 정책이 자주 바뀐다면? 새 클래스 매번 생성

좋은 예 — 합성

// 등급은 정책 객체로 분리
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;
    }
}

효과:

  • 일반 → VIP 승급: customer.changeDiscountPolicy(new VipDiscountPolicy()) ← 객체 그대로
  • 새 정책 (시즌 할인): 새 DiscountPolicy 구현체 1개만 추가
  • A/B 테스트, 임시 정책 등 유연성 폭발

이게 17주차 Spring Strategy Pattern과 직결되는 사고방식.


⚖️ 상속 vs 합성 결정 트리

[코드를 재사용하고 싶다]
        ↓
"이건 명확히 부모의 한 종류인가?" (is-a)
        ↓
   YES ─→ 상속 검토
   |       ↓
   |    "부모가 안정적이고 변하지 않는가?"
   |       ↓
   |    YES ─→ 상속 OK
   |    NO  ─→ 합성으로
   |
   NO ──→ 합성 (has-a)

한 줄 결정:

확신이 없으면 합성. 잘못된 합성은 수정이 쉽지만, 잘못된 상속은 수정이 매우 어렵다.


🎓 거장들의 격언

Joshua Bloch (Effective Java 저자)

"상속은 캡슐화를 위반한다. 상속 대신 합성을 사용하라."

GoF (Design Patterns 저자들)

"클래스 상속보다 객체 합성을 선호하라."

Uncle Bob (Clean Code 저자)

"상속은 가장 강한 결합이다. 가능한 약하게 결합하라."

소프트웨어 거장들의 일관된 메시지: 상속을 조심하라.


한 문장 요약

상속은 "코드 재사용 도구" 가 아니라 "is-a 관계 선언" 이다.

"공통 코드 쓰고 싶어서" 라는 동기로 상속을 쓰면 캡슐화 파괴, 취약한 기반 클래스, 변경 불가, 단일 상속 제약 등의 문제가 발생한다. 확신이 없으면 합성 — 이게 현대 자바의 황금률이다.


실수 3: God Class (신 클래스)

"한 클래스가 너무 많은 책임을 가짐"

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개 메서드
}

왜 문제:

  • 한 곳에서 너무 많은 변화 → 수정 시 영향 범위 큼
  • 테스트 불가능
  • 단일 책임 원칙(SRP) 위반 (Phase 3에서)

해결:

  • 책임별로 분리: FareService, NotificationService, ReportService...

실수 4: 캡슐화 위반 (public 필드)

public class Fare {
    public int amount;  // ❌ 외부에서 직접 수정 가능
}

왜 위험:

  • 누구나 무결성 깨뜨림
  • 변경 시 추적 불가

해결: private + 메서드를 통한 접근


실수 5: 객체지향이 만능이라는 착각

객체지향이 항상 좋은 건 아니다 ⚠️

객체지향이 적합하지 않은 경우:

  • 단순 스크립트 (50줄 미만)
  • 수학 계산 위주 (함수형이 더 나음)
  • 데이터 변환 파이프라인 (스트림이 더 나음)

현대 트렌드:

  • 다중 패러다임 — 객체지향 + 함수형 + 절차적 혼합
  • 자바도 함수형 요소 추가 (Stream, Lambda — 3주차)
  • "도구로 봐야지 종교로 보면 안 됨"

🔗 8. 연관 개념 맵

이 Unit이 다른 개념과 어떻게 연결되는지 본다.

직접 이어지는 학습

[Unit 1.1: 절차지향 vs 객체지향]  ← 지금 여기
        ↓
[Unit 1.2: 클래스와 객체의 본질] — 다음 학습
        ↓
[Phase 2: 클래스 문법과 객체간 관계]
        ↓
[Phase 3: SOLID — OOP를 망가뜨리지 않는 5계명]

이 Unit의 개념이 활용되는 곳

1주차 내:

  • Phase 4 (JVM 메모리): 객체가 Heap에, 메서드가 Method Area에 위치
  • Phase 5 (GC): 객체의 생성과 소멸 관리

미래 주차:

  • 3주차 (제네릭/컬렉션): 다형성을 타입에 적용
  • 5주차 (Spring IoC): 객체지향의 의존성 분리 응용
  • 8-9주차 (AOP): 캡슐화의 한계를 보완하는 기술
  • 11-12주차 (JPA): 객체와 RDB의 매핑 (Object-Relational Mapping)

4대 원칙과 SOLID의 관계 ⭐

[캡슐화] ─────→ [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 다형성

📝 9. 핵심 요약 — 3줄 정리

1️⃣ 객체지향은 "큰 시스템의 복잡도"를 다루기 위한 패러다임이다.

절차지향이 틀린 게 아니라, 코드가 커지면서 데이터 무결성·확장성·재사용성에 한계가 드러났고, 이를 해결하기 위해 데이터와 행동을 객체 라는 단위로 묶는 사고방식이 등장했다.

2️⃣ 4대 원칙(캡상다추)을 지킬 때만 객체지향의 가치가 살아난다.

캡슐화(데이터 보호), 상속(공통 추출), 다형성(if 지옥 해결), 추상화(본질만 노출) — 이 네 가지가 함께 작동할 때 "새 기능 추가 시 기존 코드 안 건드리기" 같은 마법이 가능하다. 빈혈 모델·God Class 같은 안티패턴은 객체지향의 탈만 쓴 절차지향이다.

3️⃣ 객체지향은 도구다 — 모든 곳에 쓰면 안 된다.

큰 시스템·협업·장기 유지보수에는 강력하지만, 단순 스크립트나 수학 계산에는 과한 도구다. 자바는 객체지향을 기본으로 하되 함수형(Lambda, Stream)도 흡수한 다중 패러다임 언어 로 진화 중이다. 적재적소가 핵심.


🎓 학습 자기 점검

기본 이해

  • 절차지향과 객체지향의 결정적 차이를 한 문장으로 설명할 수 있다
  • 객체지향 4대 원칙의 이름을 외울 수 있다 (캡상다추)
  • 각 원칙이 어떤 문제를 해결하는지 1줄로 답할 수 있다

실전 적용

  • ILIC 코드에서 빈혈 모델인 클래스를 찾을 수 있다
  • if 지옥을 다형성으로 리팩토링하는 시나리오를 설명할 수 있다
  • "새 등급 추가 시 무엇을 수정해야 하는가" 를 절차지향/객체지향 두 버전으로 답할 수 있다

면접 대비 (5분 답변)

  • "객체지향이 뭔가요?" 에 5분 답변 가능
  • "왜 객체지향을 사용하나요?" 에 ILIC 사례를 들어 답변 가능
  • "객체지향의 한계는 무엇인가요?" 에 균형 잡힌 답변 가능

다음 Unit으로

  • 클래스와 객체의 본질 을 학습할 준비 완료
  • "클래스는 청사진, 객체는 인스턴스" 라는 표현이 어떤 의미인지 궁금하다

profile
Software Developer

0개의 댓글