OOP 원칙 핵심 정리 — 4가지 기둥과 접근제어자

2026-05-03AWS SAA-C03 스터디

디자인 패턴 핵심 정리 시리즈 5편. OOP의 4가지 기둥(캡슐화·상속·다형성·추상화)과 Java 접근제어자 4단계, 인터페이스 vs 추상 클래스, 결합도·응집도, UML 클래스 다이어그램까지 — 사물함 비밀번호·부모 회사 노하우·리모컨 버튼 같은 일상 비유로 풀어쓴 5편.

📚 디자인 패턴 핵심 정리 · 5편 / 14편 — 4가지 기둥과 접근제어자

이 글은 디자인 패턴 핵심 정리 시리즈의 다섯 번째 편입니다. 1편에서 SOLID 원칙을, 2~4편에서 GoF 23가지 디자인 패턴을 풀어왔는데 — 이번 편은 한 발짝 뒤로 물러나 그 모든 게 서 있는 OOP라는 토대 자체를 다시 짚어봅니다.

순서가 좀 거꾸로 느껴질 수 있어요. "OOP가 토대면 1편이 아니라 5편으로 둔 이유가 뭐냐" — 그건 SOLID와 패턴을 한 바퀴 돌고 나서 OOP 4가지 기둥을 보면 "아, 이래서 캡슐화가 필요한 거였구나, 추상화가 왜 그렇게 강조됐는지 이제 보이네" 같은 깨달음이 훨씬 강하게 옵니다. 패턴들이 결국 OOP의 4가지 기둥을 어떻게 깊이 활용하는가의 변주였거든요.

이번 편에서 다루는 건 OOP의 4가지 기둥(캡슐화·상속·다형성·추상화), Java 접근 제어자 4단계, 인터페이스 vs 추상 클래스, 결합도·응집도, UML 클래스 다이어그램, 그리고 DRY 원칙과 자주 빠지는 함정 4가지예요. 비유는 이전 편들처럼 회사·일상에서 가져옵니다.

왜 OOP가 처음엔 어렵게 느껴질까요

이유는 네 가지예요.

첫째, 4가지 기둥의 이름이 비슷비슷합니다. 캡슐화·상속·다형성·추상화 — 한자어와 외래어가 섞여 있고, "캡슐화는 보호고 추상화는 숨김"이라고 들어도 두 개가 어떻게 다른지 한 번에 안 잡혀요.

둘째, "객체"라는 단어가 너무 추상적입니다. "데이터와 동작을 묶은 단위" 같은 정의를 들어도 손에 잡히는 게 아니라 머릿속을 한 번 더 거쳐야 해요.

셋째, 접근 제어자 4가지가 잘 안 외워져요. public · protected · default · private — 같은 패키지 / 자식 클래스 / 다른 패키지 같은 조건이 4개씩 곱해지면 표가 16칸이 되고, 어디서 보이는지가 안 잡힙니다.

넷째, 인터페이스와 추상 클래스가 어디서 어떻게 다른지가 헷갈려요. 둘 다 "추상 메서드를 가질 수 있다"는 점에서 비슷하니, 언제 어느 걸 써야 하는지 매번 물음표가 뜹니다.

해결법은 한 가지예요. 각 개념을 일상 비유 한 줄로 박아두는 것 — 캡슐화는 사물함 비밀번호, 상속은 부모 회사 노하우, 다형성은 한 단어가 상황에 따라 다르게 동작, 추상화는 리모컨 버튼만 보고 안 보이는 회로는 가림, 접근 제어자 4단계는 출입 가능 영역 4단계. 이 비유 다섯 개를 머리에 넣고 코드를 보면 갑자기 풀립니다. 이 글은 그 비유를 따라 처음부터 풀어 갑니다.

OOP가 도대체 뭘 풀어주나

OOPObject-Oriented Programming의 약자예요. 한국어로는 객체 지향 프로그래밍. 정의는 한 줄로 — 데이터(필드)와 그 데이터를 다루는 동작(메서드)을 하나의 단위(객체)로 묶는 프로그래밍 패러다임입니다.

회사 비유로 풀면 — 회계 부서를 떠올려 보세요. 그 부서 안에는 "회계 장부"라는 데이터와 "장부에 기입한다·결산한다·세금 계산한다" 같은 동작이 같이 들어 있어요. 영업 부서가 회계 장부를 직접 만지는 게 아니라 회계 부서에 "결산해 주세요" 하고 부탁하는 식이에요. 이게 OOP의 정신이에요.

OOP가 풀려고 하는 핵심 문제는 — 변경의 영향 범위를 좁히고, 코드를 재사용하고, 복잡도를 추상화로 가리는 것입니다. 그리고 그걸 위한 4가지 도구가 있어요.

기둥영문비유로 한 줄
캡슐화Encapsulation"사물함 비밀번호" — 안은 가리고 정해진 통로로만 접근
상속Inheritance"부모 회사 노하우 자식 회사가 물려받기" — IS-A 관계
다형성Polymorphism"한 단어가 상황에 따라 다른 동작" — pay()가 카드별로 다름
추상화Abstraction"리모컨 버튼만 보고 안 보이는 회로는 가림" — WHAT만 보여줌

이 4가지가 OOP의 4가지 기둥이에요. 더 자세한 정의는 위키피디아의 Object-oriented programming 항목에서도 같이 볼 수 있어요.

💡 한 줄 정리

OOP = 데이터 + 동작을 한 단위로 묶고, 변경의 영향 범위를 좁히기 위한 4가지 도구(캡슐화·상속·다형성·추상화) 세트.

캡슐화 — 사물함 비밀번호

OOP의 첫 번째 기둥은 캡슐화(Encapsulation)예요. 정의는 한 줄로 — 데이터와 메서드를 하나의 단위로 묶고, 외부로부터 데이터를 보호하는 원칙입니다. "정보 은닉(Information Hiding)"이라고도 해요.

회사 비유로 — 사물함 비밀번호가 가장 가까운 그림이에요. 사물함 안에 뭐가 들었는지는 본인만 알고, 외부 사람은 정해진 통로(주인의 허락·관리자 마스터키)로만 접근할 수 있어요. 클래스의 필드를 private로 박아 외부에서 직접 못 만지게 하고, 공개된 메서드(getter/setter)로만 통로를 열어주는 거예요.

캡슐화 없는 코드 — 공개된 사물함

// 나쁜 예: 모든 필드가 public → 외부에서 직접 수정 가능
public class BankAccount {
    public String owner;    // 누구나 변경 가능!
    public double balance;  // 음수 잔액도 가능!
    public String pin;      // PIN이 노출됨!

    public BankAccount(String owner, double initialBalance) {
        this.owner = owner;
        this.balance = initialBalance;
    }
}

// 문제: 외부에서 잔액을 직접 수정할 수 있음
public class Hacker {
    public void hackAccount(BankAccount account) {
        account.balance = 1000000; // 직접 잔액 수정! 검증 없음
        account.pin = "0000";      // PIN 변경!
    }
}

문제 — 잔액을 검증 없이 음수로도 박을 수 있고, PIN도 외부에 노출돼요. 이건 OOP의 세계가 아니라 그냥 자료구조 묶음이에요.

캡슐화 적용 — 비밀번호 잠긴 사물함

// 좋은 예: private 필드 + public 메서드 (getter/setter)
public class BankAccount {
    private String owner;    // private: 외부 직접 접근 불가
    private double balance;  // private: setter를 통해 검증 후 변경
    private String pin;      // private: 절대 외부 노출 안 됨

    public BankAccount(String owner, double initialBalance, String pin) {
        this.owner = owner;
        // 초기 잔액 검증
        if (initialBalance < 0) {
            throw new IllegalArgumentException("Initial balance cannot be negative");
        }
        this.balance = initialBalance;
        this.pin = pin;
    }

    // Getter: 읽기 허용
    public String getOwner() {
        return owner;
    }

    public double getBalance() {
        return balance; // 잔액 조회는 허용
    }

    // PIN은 getter 없음 → 절대 외부에서 조회 불가

    // 입금: 비즈니스 로직(검증)이 포함됨
    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Deposit amount must be positive");
        }
        this.balance += amount;
        System.out.println("Deposited: " + amount + ", New balance: " + balance);
    }

    // 출금: 잔액 부족 검증
    public void withdraw(double amount, String pin) {
        if (!this.pin.equals(pin)) {
            System.out.println("Incorrect PIN!");
            return;
        }
        if (amount > balance) {
            System.out.println("Insufficient funds!");
            return;
        }
        if (amount <= 0) {
            throw new IllegalArgumentException("Withdrawal amount must be positive");
        }
        this.balance -= amount;
        System.out.println("Withdrawn: " + amount + ", New balance: " + balance);
    }
}

// 이제 외부에서는 검증된 메서드로만 접근 가능
public class SafeClient {
    public static void main(String[] args) {
        BankAccount account = new BankAccount("Alice", 1000.0, "1234");

        account.deposit(500);        // OK
        account.withdraw(200, "1234"); // OK, PIN 검증 통과
        account.withdraw(2000, "1234"); // Insufficient funds
        account.withdraw(100, "wrong"); // Incorrect PIN

        // account.balance = 999999; // 컴파일 에러! private 접근 불가
        // account.pin = "0000";     // 컴파일 에러!
    }
}

여기서 시험 함정이 하나 있어요. "private + getter/setter 자동 생성"이 곧 캡슐화는 아닙니다. 모든 필드에 setter를 무지성으로 박으면 외부에서 우회로 다 만지는 셈이라 public 필드와 다를 게 없어요. setter는 정말 필요할 때만, 그리고 검증 로직이 들어가야 캡슐화가 살아납니다.

캡슐화의 이점

  • 보안 — 민감한 데이터(PIN·비밀번호) 보호
  • 무결성 — 잘못된 데이터(음수 잔액 등) 설정 방지
  • 유연성 — 내부 구현을 바꿔도 외부 인터페이스는 그대로
  • 유지보수성 — 변경의 영향 범위 최소화

상속 — 부모 회사 노하우 물려받기

OOP의 두 번째 기둥은 상속(Inheritance)이에요. 정의는 한 줄로 — 기존 클래스(부모/슈퍼 클래스)의 속성과 메서드를 새로운 클래스(자식/서브 클래스)가 물려받는 메커니즘입니다.

회사 비유로 — 부모 회사가 쌓아온 노하우를 자식 회사가 그대로 물려받고 거기에 자기 색깔을 더하는 그림이에요. 모든 카드가 공통으로 갖는 속성(카드번호·소유자)과 동작(번호 가리고 보여주기)은 부모 클래스 Card에 두고, 신용카드만의 속성(한도)은 CreditCard에, 체크카드만의 속성(잔액)은 DebitCard에 두는 식.

상속의 핵심 표현은 "IS-A" 관계예요. CreditCard IS-A Card, Car IS-A Vehicle — 자식이 부모의 한 종류로 자연스럽게 읽힐 때만 상속을 씁니다.

결제 시스템 예시

// 부모 클래스: 공통 속성과 기본 동작 정의
public abstract class Card implements PaymentMethod {
    protected String cardNumber;  // protected: 자식 클래스에서 직접 접근 가능
    protected String username;

    public Card(String cardNumber, String username) {
        this.cardNumber = cardNumber;
        this.username = username;
    }

    // 공통 메서드: 자식이 공통으로 사용
    public String getCardNumber() {
        return "****" + cardNumber.substring(cardNumber.length() - 4); // 마지막 4자리만
    }

    public String getUsername() { return username; }

    // 추상 메서드: 자식이 반드시 구현해야 함
    @Override
    public abstract void pay();
}

// 자식 클래스 1: 신용카드
public class CreditCard extends Card {
    private double creditLimit;

    public CreditCard(String cardNumber, String username, double creditLimit) {
        super(cardNumber, username); // 부모 생성자 호출
        this.creditLimit = creditLimit;
    }

    @Override
    public void pay() {
        System.out.println("Making payment via Credit Card");
        System.out.println("Card: " + getCardNumber() + " (Owner: " + username + ")");
        System.out.println("Credit limit: $" + creditLimit);
    }
}

// 자식 클래스 2: 체크카드
public class DebitCard extends Card {
    private double accountBalance;

    public DebitCard(String cardNumber, String username, double accountBalance) {
        super(cardNumber, username);
        this.accountBalance = accountBalance;
    }

    @Override
    public void pay() {
        System.out.println("Making payment via Debit Card");
        System.out.println("Card: " + getCardNumber() + " (Owner: " + username + ")");
        System.out.println("Account balance: $" + accountBalance);
    }
}

// 인터페이스: 상속 관계가 아닌 클래스들도 공통 계약 가능
public interface PaymentMethod {
    void pay();
}

// 다른 결제 방식: Card 상속이 아니지만 PaymentMethod는 구현
public class MobilePay implements PaymentMethod {
    private String accountId;

    public MobilePay(String accountId) {
        this.accountId = accountId;
    }

    @Override
    public void pay() {
        System.out.println("Making payment via Mobile Pay: " + accountId);
    }
}

여기서 시험 함정이 하나 있어요. 상속은 "부모 회사 노하우를 그대로 물려받는다"는 의미라, 부모와 자식의 관계가 진짜로 IS-A일 때만 써야 합니다. "Engine을 상속받는 Car"는 잘못된 그림이에요 — Car는 Engine의 한 종류가 아니라 Engine을 부품으로 갖고 있는 거니까요.

상속 vs 컴포지션 — IS-A인가 HAS-A인가

// 상속 방식 (IS-A): "Engine IS-A Car"이면 안 됨
// 실수 예: Engine을 상속받는 Car
class Car extends Engine { // 잘못됨! Car IS-A Engine이 아님
    // ...
}

// 컴포지션 방식 (HAS-A): "Car HAS-A Engine"
class Car {
    private Engine engine; // Car는 Engine을 포함(소유)
    public Car(Engine engine) {
        this.engine = engine;
    }
    public void start() { engine.start(); } // 위임
}

판단 기준 — "X IS-A Y" 라고 읽었을 때 자연스러우면 상속, "X HAS-A Y" 라고 읽었을 때 자연스러우면 컴포지션이에요. 4편에서 봤던 데코레이터·프록시 같은 패턴이 컴포지션을 적극적으로 활용한 사례입니다.

💡 실무 포인트

"Favor Composition over Inheritance" — 상속이 안전하지 않다 싶으면 컴포지션을 먼저 고려한다. 깊은 상속 계층(3단계 이상)은 거의 항상 빨간 신호.

다형성 — 한 단어가 상황에 따라 다르게

OOP의 세 번째 기둥은 다형성(Polymorphism)이에요. Poly(여러) + morph(모양) — 그리스어 어원 그대로 같은 이름의 메서드가 다양한 형태로 동작하는 거예요.

회사 비유로 — "결제하세요" 라는 한 마디가 신용카드에서는 한도를 확인하고, 체크카드에서는 잔액을 차감하고, 모바일 페이에서는 인증을 띄우는 식으로 — 같은 명령이 상황에 따라 다른 동작으로 풀리는 그림이에요.

OOP에서는 다형성이 두 가지 형태로 나타나요.

형태시점메커니즘
컴파일 타임 다형성컴파일 시 결정메서드 오버로딩 (같은 이름·다른 파라미터)
런타임 다형성런타임 시 결정메서드 오버라이딩 (자식이 부모 메서드 재정의)

런타임 다형성이 OOP의 진짜 힘이에요. 부모 타입 변수에 자식 객체를 넣으면 실제 객체의 메서드가 호출되는데, 이게 1편의 OCP·DIP, 2~4편의 거의 모든 패턴을 가능하게 한 메커니즘입니다.

컴파일 타임 다형성 — 메서드 오버로딩

public class Calculator {
    // 같은 이름, 다른 파라미터 타입/개수 → 오버로딩
    public int add(int a, int b) {
        return a + b;
    }

    public double add(double a, double b) {
        return a + b;
    }

    public int add(int a, int b, int c) {
        return a + b + c;
    }

    public String add(String a, String b) {
        return a + b; // 문자열 연결
    }
}

public class Main {
    public static void main(String[] args) {
        Calculator calc = new Calculator();
        System.out.println(calc.add(1, 2));          // int 버전: 3
        System.out.println(calc.add(1.5, 2.5));      // double 버전: 4.0
        System.out.println(calc.add(1, 2, 3));        // 3개 int 버전: 6
        System.out.println(calc.add("Hello", " World")); // String 버전: Hello World
    }
}

런타임 다형성 — 메서드 오버라이딩

// 부모 타입 변수에 다양한 자식 객체 저장
PaymentMethod[] methods = {
    new CreditCard("4111-1111-1111-1111", "Alice", 5000),
    new DebitCard("1234-5678-9012-3456", "Bob", 2000),
    new MobilePay("alice@bank"),
    // 새로운 결제 수단 추가도 이 배열에 그냥 넣으면 됨
};

// 동일한 pay() 호출, 런타임에 실제 타입에 따라 다른 구현 실행
for (PaymentMethod method : methods) {
    method.pay(); // 어떤 구현이 실행될지는 런타임에 결정됨
}

더 직관적인 예시는 동물 소리예요.

class Animal {
    public void makeSound() {
        System.out.println("Some animal sound");
    }
}

class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Woof!");
    }
}

class Cat extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Meow!");
    }
}

class Duck extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Quack!");
    }
}

public class AnimalOrchestra {
    public static void main(String[] args) {
        Animal[] animals = { new Dog(), new Cat(), new Duck(), new Dog() };

        for (Animal animal : animals) {
            animal.makeSound(); // 런타임에 각 동물의 실제 소리 출력
        }
        // Woof! Meow! Quack! Woof!
    }
}

여기서 시험 함정이 하나 있어요. 다형성을 잘 활용하면 새 타입 추가 시 기존 코드 수정이 불필요해져요(1편의 OCP). 반대로 if (obj instanceof Type) 으로 타입을 일일이 검사하는 코드가 보이면 다형성을 못 살리고 있다는 신호예요.

// 나쁜 예: instanceof로 타입 구분 → OCP 위반
public void processPaymentBad(Object payment) {
    if (payment instanceof CreditCard) {
        ((CreditCard) payment).pay();
    } else if (payment instanceof MobilePay) {
        ((MobilePay) payment).pay();
    }
    // 새 결제 수단 추가 시 여기 else if 추가해야 함 → OCP 위반
}

// 좋은 예: 다형성 활용 (새 타입 추가해도 이 메서드 수정 불필요)
public void processPaymentGood(PaymentMethod payment) {
    payment.pay(); // 어떤 타입이든 pay() 호출만 하면 됨
    // 새 결제 수단이 PaymentMethod를 구현하면 자동으로 지원 → OCP 준수
}

추상화 — 리모컨 버튼만 보고 안 보이는 회로는 가림

OOP의 네 번째 기둥은 추상화(Abstraction)예요. 정의는 한 줄로 — 복잡한 내부 구현을 숨기고, 필요한 기능만 외부에 노출하는 원칙입니다. WHAT은 보여주고 HOW는 가린다.

회사 비유로 — 리모컨이 가장 가까운 그림이에요. 채널 버튼을 누르면 채널이 바뀐다는 사실(WHAT)만 보여주고, 적외선 신호가 어떻게 인코딩되고 회로를 어떻게 도는지(HOW)는 사용자에게 보이지 않아요. 클래스 사용자는 메서드 시그니처만 보고도 "이 메서드는 무엇을 한다"를 알 수 있고, 내부 구현이 바뀌어도 사용자 코드는 영향을 받지 않아요.

추상화의 두 가지 도구

추상화를 구현하는 주요 수단은 두 가지예요.

// 추상 클래스: 공통 구현을 제공할 수 있음
public abstract class Vehicle {
    protected String brand;      // 공통 속성
    protected int year;

    // 구체적인 메서드: 모든 자식이 공유
    public String getBrand() { return brand; }
    public int getYear() { return year; }

    // 추상 메서드: 자식마다 다른 구현 강제
    public abstract int getMaxSpeed();
    public abstract void start();

    // 공통 로직이 있지만 오버라이드 가능
    public void stop() {
        System.out.println(brand + " is stopping");
    }
}

// 인터페이스: 순수한 계약 정의
public interface Electric {
    void charge();
    int getBatteryLevel();
    int getRange(); // 충전당 주행 가능 거리
}

// 인터페이스를 활용한 다중 구현
public class ElectricCar extends Vehicle implements Electric {
    private int batteryLevel = 80;

    @Override
    public int getMaxSpeed() { return 250; }

    @Override
    public void start() {
        System.out.println("Electric car starting silently...");
    }

    // Electric 인터페이스 구현
    @Override
    public void charge() {
        System.out.println("Charging at fast charging station...");
        batteryLevel = 100;
    }

    @Override
    public int getBatteryLevel() { return batteryLevel; }

    @Override
    public int getRange() { return 600; } // km
}

여기서 시험 함정이 하나 있어요. 추상화 ≠ 캡슐화예요. 두 개념을 자주 혼동하는데, 핵심 차이는 이래요.

항목캡슐화추상화
목적데이터 보호복잡도 가림
수단private + getter/setter추상 클래스 + 인터페이스
비유사물함 비밀번호리모컨 버튼
시점인스턴스 차원설계 차원

캡슐화는 데이터의 무결성을 위한 거고, 추상화는 인터페이스의 단순함을 위한 거예요. 둘 다 OOP의 기둥이지만 풀려고 하는 문제가 달라요.

Java 접근 제어자 — 출입 가능 영역 4단계

캡슐화와 추상화를 실제 코드에서 구현하는 도구가 접근 제어자예요. Java는 4가지를 제공해요.

회사 비유로 — 출입증 등급 4단계예요. 게스트(public)는 사옥 어디든 들어갈 수 있고, 같은 부서원(default)은 본인 부서 사무실까지, 협력사 직원(protected)은 본인 부서 + 자회사 사무실까지, 중요한 자료를 다루는 임원(private)은 본인 사무실 안에서만.

접근 제어자 비교표

| 접근 제어자 | 같은 클래스 | 같은 패키지 | 자식 클래스 | 다른 패키지 | |:---|:-:|:-:|:-:|:-:| | public | O | O | O | O | | protected | O | O | O | X | | default (package) | O | O | X | X | | private | O | X | X | X |

이 표는 외워두면 두고두고 써먹어요. 특히 protecteddefault의 차이가 헷갈리는데 — protected는 다른 패키지의 자식 클래스에서도 보이고, default는 같은 패키지 안에서만 보이고 자식 클래스라 해도 다른 패키지면 차단돼요.

코드 예시

package com.example.banking;

public class Account {
    public String accountId;        // 어디서나 접근 가능
    protected double balance;       // 같은 패키지 + 자식 클래스
    String accountType;             // default: 같은 패키지 내에서만
    private String secretPin;       // 이 클래스 내에서만

    // 생성자
    public Account(String id, double balance, String pin) {
        this.accountId = id;
        this.balance = balance;
        this.secretPin = pin;
    }

    // public 메서드: 외부에서 잔액 조회 가능
    public double getBalance() {
        return balance;
    }

    // private 메서드: 내부에서만 사용
    private boolean verifyPin(String pin) {
        return this.secretPin.equals(pin);
    }

    // public 메서드: PIN 검증 후 출금
    public void withdraw(double amount, String pin) {
        if (verifyPin(pin)) { // private 메서드 내부 호출
            balance -= amount;
        }
    }
}

// 자식 클래스: protected 멤버에 접근 가능
package com.example.banking; // 같은 패키지

public class SavingsAccount extends Account {
    private double interestRate;

    public SavingsAccount(String id, double balance, String pin, double rate) {
        super(id, balance, pin);
        this.interestRate = rate;
    }

    public void addInterest() {
        // protected 필드 balance에 직접 접근 가능 (자식 클래스)
        balance += balance * interestRate;
        System.out.println("Interest added. New balance: " + balance);

        // secretPin에는 접근 불가 (private)
        // secretPin = "0000"; // 컴파일 에러!
    }
}

여기서 시험 함정이 하나 있어요. default 접근 제어자에는 키워드가 없어요. 아무 키워드도 안 붙이고 String accountType; 으로 박으면 그게 default예요. private·protected·public은 명시적인데 default만 묵시적이라 처음 보면 헷갈립니다.

protected가 필요한 이유

// Card 클래스: protected 필드 사용 이유
public abstract class Card {
    // private이면 자식 클래스에서 직접 접근 불가 → super.cardNumber 불가
    // protected이면 자식 클래스에서 직접 접근 가능
    protected String cardNumber;
    protected String username;

    public Card(String cardNumber, String username) {
        this.cardNumber = cardNumber;
        this.username = username;
    }
}

public class CreditCard extends Card {
    public void pay() {
        // protected 필드에 직접 접근 가능
        System.out.println("Paying with card: ****" +
            cardNumber.substring(cardNumber.length() - 4)); // 직접 접근
        System.out.println("Owner: " + username); // 직접 접근
    }
}

자식 클래스가 부모의 내부 데이터를 자주 만질 필요가 있으면 protected, 외부에는 절대 노출하면 안 되는 진짜 비밀이면 private로 박고 자식 클래스는 부모의 메서드를 통해서만 접근하게 하면 돼요.

인터페이스 vs 추상 클래스 — CAN-DO vs IS-A

추상화의 두 도구인 인터페이스추상 클래스 — 언제 어느 걸 쓰는지가 매번 헷갈리는 페어예요. 정리하면 이래요.

비교표

항목인터페이스추상 클래스
목적계약(Contract) 정의공통 구현 + 계약
메서드기본적으로 추상 (default·static 예외)구체·추상 혼합 가능
상속다중 구현 가능단일 상속만 가능
상태상태(필드) 없음 (상수만)인스턴스 변수 가능
생성자없음있음
접근제어자모두 public다양하게 설정 가능
관계CAN-DOIS-A

핵심 차이는 마지막 줄이에요. 인터페이스는 "X CAN-DO Y" — 이걸 할 수 있다, 추상 클래스는 "X IS-A Y" — 이런 종류다.

// 인터페이스 사용: "할 수 있다(CAN-DO)" 관계
// 여러 상속 계층에 걸쳐 공통 기능 추가
public interface Flyable { void fly(); }
public interface Swimmable { void swim(); }

class Duck extends Bird implements Flyable, Swimmable {
    public void fly() { System.out.println("Duck flying"); }
    public void swim() { System.out.println("Duck swimming"); }
}

// 추상 클래스 사용: "이다(IS-A)" 관계
// 공통 구현을 공유하면서 일부 메서드만 자식에게 위임
public abstract class DataParser {
    // 공통 로직: 모든 파서가 공유
    public final void parse() {
        openFile();
        parseData(); // 서브클래스가 구현
        closeFile();
    }

    protected void openFile() { System.out.println("Opening file"); }
    protected void closeFile() { System.out.println("Closing file"); }
    protected abstract void parseData(); // 서브클래스 구현 강제
}

위의 DataParser 예시는 4편에서 다룬 Template Method 패턴의 전형적인 모습이에요. 공통 골격은 추상 클래스에 박고, 가변 부분만 자식이 채우는 방식.

여기서 시험 함정이 하나 있어요. Java 8부터 인터페이스에 default 메서드가 들어왔어요. 인터페이스에도 구체 메서드를 넣을 수 있게 됐다는 뜻이라, "인터페이스 = 추상 메서드만"이라는 정의가 더는 정확하지 않아요. 그래도 핵심 차이는 여전히 CAN-DO vs IS-A, 그리고 다중 구현 vs 단일 상속입니다.

결합도와 응집도 — 좋은 설계의 두 지표

OOP에서 좋은 설계가 뭐냐고 물으면 답은 두 단어로 압축돼요 — 높은 응집도, 낮은 결합도. 이 두 지표가 높을수록 변경에 강하고 테스트하기 쉬운 코드가 돼요.

지표좋은 방향의미
결합도 (Coupling)낮을수록 좋음클래스들이 서로에게 덜 의존
응집도 (Cohesion)높을수록 좋음한 클래스의 모든 요소가 단일 목적

결합도 — 강한 결합 vs 느슨한 결합

// 강한 결합(Tight Coupling) - 나쁜 예
public class OrderService {
    // EmailService 구체 클래스에 직접 의존
    private EmailService emailService = new EmailService();
    // MySQLDatabase 구체 클래스에 직접 의존
    private MySQLDatabase database = new MySQLDatabase();

    public void processOrder(Order order) {
        database.save(order);        // MySQLDatabase에 종속
        emailService.sendEmail(order); // EmailService에 종속
        // DB나 이메일 서비스 변경 시 이 클래스도 수정 필요!
    }
}

// 느슨한 결합(Loose Coupling) - 좋은 예
public class OrderService {
    // 인터페이스에 의존 → 구현체 교체 가능 (DIP 준수)
    private NotificationChannel notificationChannel;
    private OrderRepository orderRepository;

    // 의존성 주입 (DI)
    public OrderService(NotificationChannel channel, OrderRepository repo) {
        this.notificationChannel = channel;
        this.orderRepository = repo;
    }

    public void processOrder(Order order) {
        orderRepository.save(order);              // 인터페이스 메서드 호출
        notificationChannel.send("Order placed!"); // 인터페이스 메서드 호출
    }
}

낮은 결합도를 만드는 핵심 도구가 1편의 DIP(인터페이스에 의존하기)예요. 구체 클래스에 직접 의존하는 대신 인터페이스에 의존하면 구현체를 교체해도 사용 코드가 안 깨져요.

응집도 — 단일 목적의 묶음

// 낮은 응집도 (Low Cohesion) - 나쁜 예
// 관계없는 기능들이 한 클래스에 모여 있음
public class Utility {
    public void sendEmail(String to, String message) { ... }
    public double calculateTax(double amount) { ... }
    public void saveToDatabase(Object obj) { ... }
    public String formatDate(Date date) { ... }
    public void printReport(Report report) { ... }
    // 이것들이 하나의 클래스에 있을 이유가 없음!
}

// 높은 응집도 (High Cohesion) - 좋은 예
public class EmailService {
    public void send(String to, String message) { ... }
    public void sendBulk(List<String> recipients, String message) { ... }
    private String formatEmailBody(String message) { ... }
    private boolean validateEmail(String email) { ... }
    // 모두 이메일과 관련된 기능들
}

public class TaxCalculator {
    public double calculateSalesTax(double amount) { ... }
    public double calculateIncomeTax(double income) { ... }
    private double applyTaxBracket(double amount, double rate) { ... }
    // 모두 세금 계산과 관련된 기능들
}

높은 응집도를 만드는 핵심 도구가 1편의 SRP(한 클래스 한 책임)예요. SRP를 잘 지키면 자연스럽게 응집도가 올라가요.

💡 한 줄 정리

SRP → 높은 응집도 / DIP → 낮은 결합도. 1편의 SOLID 원칙 두 개가 곧 좋은 OOP 설계의 두 지표를 만들어내는 도구.

UML 클래스 다이어그램 — 건축 도면

코드를 그림으로 정리하는 표준이 UML(Unified Modeling Language) 클래스 다이어그램이에요. 회사 비유로는 건축 도면이에요. 집을 짓기 전에 평면도를 그리듯, 시스템을 짜기 전에 클래스들이 어떻게 관계 맺는지 그림으로 한 번 정리하는 도구.

관계 종류 다섯 가지

관계의미화살표한 줄 비유
연관 (Association)"가지고 있다" 약한 의존A ──→ B선생님이 학생들을 안다
집약 (Aggregation)"부분-전체" 독립 가능A ◇──→ B부서가 직원들을 가지나 직원은 부서 없이도 존재
합성 (Composition)"강한 부분-전체" 같이 사라짐A ◆──→ B집이 무너지면 방도 사라짐
상속 (Inheritance)"IS-A"A ──▷ B신용카드는 카드의 일종
의존 (Dependency)"사용한다" 임시적A ╌╌→ B결제 서비스가 송장을 파라미터로 받음
실현 (Realization)인터페이스 구현A ╌╌▷ B모바일 페이가 결제 방식 계약을 구현

연관 관계 (Association)

// Teacher HAS students
public class Teacher {
    private List<Student> students; // 연관: Teacher가 Student들을 참조
}

집약 관계 (Aggregation)

// Department HAS Employees (Employee는 Department 없이도 존재 가능)
public class Department {
    private List<Employee> employees; // 집약: Employee가 Department 없이도 존재
}

합성 관계 (Composition)

// House HAS Rooms (Room은 House 없이 존재 의미 없음)
public class House {
    private List<Room> rooms; // 합성: House가 사라지면 Room도 의미 없어짐

    public House() {
        rooms = new ArrayList<>();
        rooms.add(new Room("Living Room")); // House가 Room을 직접 생성
        rooms.add(new Room("Bedroom"));
    }
}

상속 (Inheritance)

public class CreditCard extends Card { ... } // CreditCard IS-A Card

의존 관계 (Dependency)

// PaymentService는 Invoice를 파라미터로 사용
public class PaymentService {
    public void processPayment(Invoice invoice) { ... } // 의존: 메서드 파라미터로만 사용
}

실현 관계 (Realization)

public class MobilePay implements PaymentMethod { ... } // MobilePay REALIZES PaymentMethod

여기서 시험 함정이 하나 있어요. 집약과 합성의 차이는 "전체가 사라질 때 부분도 사라지는가"예요. 부서가 해체돼도 직원은 다른 부서로 이동하면 되니 집약, 집이 무너지면 방이라는 개념도 의미 없어지니 합성. 같은 코드 모양(private List ys)이라도 도메인 의미에 따라 다이어그램이 달라져요.

DRY 원칙 — 같은 지식은 한 곳에만

OOP의 4가지 기둥과 별도로, 좋은 설계의 정신을 한 줄로 압축한 게 DRY 원칙이에요. Don't Repeat Yourself — "자신을 반복하지 마라". 모든 지식은 시스템 내에서 단 하나의 명확하고 권위 있는 표현을 가져야 한다.

DRY 위반과 해결

// DRY 위반: 세금 계산 로직이 여러 곳에 중복
public class InvoiceService {
    public double calculateInvoiceTotal(double amount) {
        return amount * 1.1; // 10% 세금
    }
}

public class ReceiptService {
    public double calculateReceiptTotal(double amount) {
        return amount * 1.1; // 10% 세금 - 중복!
    }
}

public class OrderService {
    public double calculateOrderTotal(double amount) {
        return amount * 1.1; // 10% 세금 - 또 중복!
    }
}
// 세율이 15%로 바뀌면 3곳을 모두 수정해야 함!

// DRY 준수: 세금 계산을 한 곳에서 관리
public class TaxCalculator {
    private static final double TAX_RATE = 0.10; // 세율을 한 곳에서 관리

    public static double calculateTotal(double amount) {
        return amount * (1 + TAX_RATE);
    }
}

// 모든 서비스에서 TaxCalculator 사용
public class InvoiceService {
    public double calculateInvoiceTotal(double amount) {
        return TaxCalculator.calculateTotal(amount); // 단일 소스
    }
}

public class ReceiptService {
    public double calculateReceiptTotal(double amount) {
        return TaxCalculator.calculateTotal(amount); // 단일 소스
    }
}
// 세율 변경 시 TaxCalculator만 수정하면 됨!

YAGNI 원칙도 함께

DRY와 짝을 이루는 원칙이 YAGNI (You Aren't Gonna Need It) — "지금 필요하지 않은 기능은 미리 구현하지 마라". DRY가 "중복은 줄여라"라면 YAGNI는 "필요 없는 추상화는 추가하지 마라"예요. 두 원칙이 균형을 잡아줘야 코드가 너무 늘어지지도, 너무 추상화되지도 않아요.

자주 빠지는 함정 4가지

OOP를 처음 배울 때 흔히 빠지는 함정 네 가지를 짚어둘게요.

1. 상속의 남용

상속은 강력하지만 잘못 사용하면 코드를 더 복잡하게 만듭니다. "IS-A" 관계가 명확하지 않으면 컴포지션(Has-A)을 쓰세요. 깊은 상속 계층(3단계 이상)은 이해하기 어려워져요. Java 표준 라이브러리의 유명한 실수가 Stack extends Vector 인데 — Stack은 Vector의 한 종류가 아니라 다른 자료구조예요.

2. Getter/Setter 남용이 캡슐화를 깸

모든 필드에 getter/setter를 자동 생성하면 캡슐화의 이점이 사라집니다. setter는 정말 필요할 때만 제공하고, getter도 꼭 필요한 것만 노출하세요.

// 나쁜 예: 모든 필드에 setter 제공 → private 의미 없음
public class Person {
    private String name;
    private int age;
    public String getName() { return name; }
    public void setName(String name) { this.name = name; } // 필요한가?
    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; } // 검증도 없이?
}

// 좋은 예: 필요한 것만 노출, setter에 검증 포함
public class Person {
    private final String name; // final: 불변
    private int age;

    public Person(String name, int age) {
        if (age < 0 || age > 150) throw new IllegalArgumentException("Invalid age");
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; } // getter만 있음 (불변)
    public int getAge() { return age; }
    public void setAge(int age) { // 검증 있는 setter
        if (age < 0 || age > 150) throw new IllegalArgumentException("Invalid age");
        this.age = age;
    }
}

3. 다형성과 타입 캐스팅 남용

instanceof와 타입 캐스팅을 자주 사용한다면 다형성을 제대로 활용하지 못하는 신호입니다. 부모 타입의 메서드를 자식이 오버라이드하는 식으로 풀어가는 게 OOP의 정석이에요.

// 나쁜 예: instanceof로 타입 구분 → OCP 위반
public void process(Shape shape) {
    if (shape instanceof Circle) {
        ((Circle) shape).drawCircle();
    } else if (shape instanceof Rectangle) {
        ((Rectangle) shape).drawRectangle();
    }
}

// 좋은 예: 다형성 활용
public void process(Shape shape) {
    shape.draw(); // 각 Shape가 자신을 그림
}

4. 추상화 과도하게 사용

모든 것을 인터페이스로 추상화할 필요는 없습니다. 변경 가능성이 낮고 구현체가 하나뿐인 것은 굳이 인터페이스를 만들지 않아도 돼요. "구현보다 인터페이스로 프로그래밍"은 좋은 원칙이지만, 단순한 케이스에 적용하면 오버엔지니어링입니다. YAGNI를 함께 떠올리세요.

시험 직전 한 번 더 — OOP 함정 압축 노트

여기까지가 OOP 5편의 핵심입니다. 시험 직전·코드 리뷰 직전에 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • OOP 4가지 기둥 — 캡슐화 · 상속 · 다형성 · 추상화
  • OOP 정의 — 데이터 + 동작을 한 단위(객체)로 묶고 변경 영향 범위 좁히기
  • 캡슐화 = "사물함 비밀번호" — private 필드 + 검증 있는 getter/setter, 정보 은닉
  • 캡슐화의 핵심은 검증 있는 setter — 무지성 setter 자동 생성은 캡슐화 깸
  • 상속 = "부모 회사 노하우 자식이 물려받기" — IS-A 관계일 때만, 깊은 계층(3단계+) 회피
  • 상속 vs 컴포지션 — IS-A면 상속, HAS-A면 컴포지션. Favor Composition over Inheritance
  • 다형성 = "한 단어가 상황에 따라 다른 동작" — 컴파일 타임(오버로딩) + 런타임(오버라이딩)
  • 런타임 다형성이 OCP·DIP·거의 모든 패턴의 메커니즘
  • instanceof + 타입 캐스팅 자주 쓰면 다형성 미활용 신호
  • 추상화 = "리모컨 버튼만 보고 회로는 가림" — WHAT 보여주고 HOW 가림
  • 추상화 도구 — 추상 클래스(IS-A·공통 구현 + 계약) vs 인터페이스(CAN-DO·순수 계약)
  • 추상화 ≠ 캡슐화 — 추상화 = 복잡도 가림 / 캡슐화 = 데이터 보호
  • 접근 제어자 4단계 = "출입 가능 영역 4단계" — public > protected > default > private
  • default는 키워드 없음 (묵시적). 같은 패키지까지만 보임
  • protected = 같은 패키지 + (다른 패키지의) 자식 클래스
  • 인터페이스 vs 추상 클래스 — CAN-DO vs IS-A, 다중 구현 vs 단일 상속
  • Java 8+ 부터 인터페이스에 default 메서드 가능 (구체 메서드 들어옴)
  • 결합도 — 낮을수록 좋음. DIP가 도구 (인터페이스에 의존)
  • 응집도 — 높을수록 좋음. SRP가 도구 (한 클래스 한 책임)
  • UML 6가지 관계 — 연관 / 집약 / 합성 / 상속 / 의존 / 실현
  • 집약 vs 합성 — 전체가 사라지면 부분도 사라지는가가 기준
  • DRY — 같은 지식은 한 곳에만 (TaxCalculator 패턴)
  • YAGNI — 지금 필요 없는 기능은 미리 구현 X. DRY와 균형
  • 함정 — 상속 남용·setter 자동 생성·instanceof 남발·과도한 추상화

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!