Skip to main content

6. 객체지향 핵심 심화: 캡슐화, 상속, 다형성, 추상화

지난 장에서는 클래스와 객체, 생성자, 접근 제어자를 통해 객체지향의 문을 열었다. 이제는 한 단계 더 들어가 볼 차례다. 이번 장의 주제는 캡슐화, 상속, 다형성, 추상화다.

이 네 가지는 단순히 시험에 나오는 용어가 아니다. 프로그램이 커질수록 코드가 덜 엉키고, 기능이 늘어나도 덜 무너지게 만드는 핵심 도구다. 쉽게 말해, 일단 돌아가는 코드를 오래 버티는 코드로 바꿔 주는 감각이라고 생각하면 된다.

객체지향 핵심 심화: 캡슐화, 상속, 다형성, 추상화

왜 이 장이 중요한가

처음 프로그램을 만들 때는 기능이 하나만 잘 돌아가면 꽤 뿌듯하다. 그런데 기능이 두 배, 세 배로 늘어나면 문제가 시작된다. 값은 여기저기서 바뀌고, 비슷한 코드는 복사되고, 새로운 기능 하나 추가할 때 기존 코드를 잔뜩 뜯어고치게 된다.

객체지향의 핵심 개념들은 바로 이런 순간을 대비한다.

  • 캡슐화는 객체가 자기 상태를 스스로 지키게 한다.

  • 상속은 공통점을 정리해 중복을 줄인다.

  • 다형성은 같은 방식으로 여러 객체를 다루게 해 준다.

  • 추상화는 복잡한 세부 사항보다 중요한 약속에 집중하게 만든다.

이 네 가지가 연결되면 코드가 훨씬 유연해진다. 그리고 그때부터 Java는 단순한 문법 묶음이 아니라, 설계의 언어처럼 느껴지기 시작한다.

캡슐화: 데이터를 숨기는 것이 아니라 규칙을 지키게 하는 것

캡슐화는 흔히 "필드를 private으로 숨기는 것"이라고 배우지만, 거기서 멈추면 반만 이해한 것이다. 진짜 핵심은 객체가 자기 상태를 올바르게 유지하도록 만드는 것이다.

은행 계좌를 떠올려 보자. 잔액은 아무 데서나 마음대로 바꾸면 안 된다. 입금은 0보다 큰 금액만 가능해야 하고, 출금은 잔액보다 많이 할 수 없어야 한다. 이런 규칙은 계좌 객체 안에 있어야 자연스럽다.

class BankAccount {
    private String owner;
    private int balance;

    BankAccount(String owner, int balance) {
        this.owner = owner;
        this.balance = balance < 0 ? 0 : balance;
    }

    public void deposit(int amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    public boolean withdraw(int amount) {
        if (amount <= 0 || amount > balance) {
            return false;
        }

        balance -= amount;
        return true;
    }

    public int getBalance() {
        return balance;
    }

    public String getOwner() {
        return owner;
    }
}

이 클래스의 좋은 점은 balance를 외부에서 직접 건드릴 수 없다는 것이다. 대신 deposit()과 withdraw()를 통해서만 바뀐다. 즉, 계좌의 규칙이 한곳에 모여 있다.

이것이 캡슐화의 매력이다. 프로그램 전체가 계좌의 규칙을 기억할 필요가 없다. 계좌는 계좌답게 행동하면 된다.

getter와 setter를 만든다고 캡슐화가 되는 것은 아니다

초보자가 자주 하는 실수는 모든 필드에 getter와 setter를 무조건 만드는 것이다. 그러면 겉으로는 private이지만, 실제로는 누구나 마음대로 값을 바꿀 수 있는 구조가 된다.

예를 들어 아래 코드는 조금 아쉽다.

class BankAccount {
    private int balance;

    public void setBalance(int balance) {
        this.balance = balance;
    }
}

이렇게 되면 외부에서 setBalance(-100000) 같은 값을 넣을 수도 있다. 그 순간 계좌는 계좌 같지 않게 된다.

좋은 캡슐화는 "값을 바꾸는 통로"를 주는 것이 아니라, 의미 있는 행동을 제공하는 것이다. 계좌라면 입금, 출금, 이체가 자연스럽고, 잔액 직접 설정은 대개 자연스럽지 않다.

상속: 공통점을 위로 올리고, 차이점을 아래에 남기기

상속은 부모 클래스의 속성과 기능을 자식 클래스가 물려받는 기능이다. 문법적으로는 extends를 사용한다.

동물 예제로 보면 이해가 쉽다. 강아지와 고양이는 다르지만, 이름이 있고 먹고 소리 낸다는 점에서는 비슷하다. 이런 공통점을 부모 클래스에 모아 둘 수 있다.

class Animal {
    private String name;

    Animal(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void eat() {
        System.out.println(name + "가 먹습니다.");
    }

    public void sound() {
        System.out.println("동물이 소리를 냅니다.");
    }
}

class Dog extends Animal {
    Dog(String name) {
        super(name);
    }
}

class Cat extends Animal {
    Cat(String name) {
        super(name);
    }
}

여기서 Dog와 Cat은 Animal을 상속받는다. 그래서 getName()과 eat() 같은 공통 기능을 다시 작성하지 않아도 된다.

상속의 장점은 분명하다. 중복이 줄고, 공통 구조가 한곳에 모인다. 하지만 중요한 전제가 있다. 진짜 공통점일 때만 상속을 써야 한다는 점이다.

강아지는 동물이다. 고양이도 동물이다. 이런 관계는 자연스럽다. 반대로 "로그인 기능이 있어서 사용자 클래스를 상속한다" 같은 식으로 억지 관계를 만들면 설계가 금방 꼬인다.

오버라이딩: 부모의 기능을 자식에게 맞게 다시 정의하기

상속을 했다고 해서 자식이 부모와 완전히 똑같이 행동해야 하는 것은 아니다. 공통 뼈대는 물려받되, 필요한 동작은 자식에 맞게 바꿀 수 있다. 이것이 오버라이딩이다.

class Animal {
    private String name;

    Animal(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void sound() {
        System.out.println("동물이 소리를 냅니다.");
    }
}

class Dog extends Animal {
    Dog(String name) {
        super(name);
    }

    @Override
    public void sound() {
        System.out.println(getName() + "가 멍멍 짖습니다.");
    }
}

class Cat extends Animal {
    Cat(String name) {
        super(name);
    }

    @Override
    public void sound() {
        System.out.println(getName() + "가 야옹 웁니다.");
    }
}

@Override는 "이 메서드는 부모 메서드를 재정의한 것"이라는 표시다. 이 표시를 붙여 두면 메서드 이름을 잘못 쓰거나 매개변수를 다르게 적었을 때 컴파일러가 더 잘 잡아 준다. 실수를 줄여 주는 친절한 안전벨트라고 생각하면 된다.

오버라이딩을 이해하면 객체지향의 재미가 슬슬 살아난다. 부모는 공통 규칙을 제공하고, 자식은 자기답게 행동한다. 마치 같은 무대 위에서 각 배우가 자기 대사를 치는 느낌이다.

오버로딩과 헷갈리지 말기

이쯤에서 이름이 비슷한 개념 하나를 짚고 넘어가자.

  • 오버라이딩은 부모의 메서드를 자식이 다시 정의하는 것이다.

  • 오버로딩은 같은 이름의 메서드를 매개변수 다르게 여러 개 만드는 것이다.

둘 다 자주 나오지만 완전히 다른 개념이다. 이름이 비슷해서 헷갈리면, 오버라이딩은 상속 관계에서 다시 정의, 오버로딩은 같은 이름의 여러 버전이라고 기억하면 된다.

다형성: 같은 타입으로 여러 객체를 다루는 힘

다형성은 객체지향의 진짜 주인공 같은 개념이다. 말은 조금 어렵지만 뜻은 꽤 멋지다. 같은 타입으로 여러 종류의 객체를 다룰 수 있는 성질이다.

부모 타입 변수에 자식 객체를 담을 수 있다는 점이 출발점이다.

Animal animal1 = new Dog("초코");
Animal animal2 = new Cat("나비");

두 변수의 타입은 둘 다 Animal이지만, 실제 객체는 각각 Dog, Cat이다. 그리고 메서드를 호출하면 실제 객체에 맞는 동작이 실행된다.

animal1.sound();
animal2.sound();

결과는 이렇게 된다.

초코가 멍멍 짖습니다.
나비가 야옹 웁니다.

조금 더 확장하면 반복문과도 아주 잘 어울린다.

Animal[] animals = {
    new Dog("초코"),
    new Cat("나비")
};

for (Animal animal : animals) {
    animal.sound();
}

이 코드의 좋은 점은 Dog와 Cat을 따로따로 다루지 않는다는 것이다. 그냥 Animal로 다루면 된다. 하지만 실제 행동은 각 객체가 알아서 한다.

이것이 다형성의 힘이다. 코드를 사용하는 쪽은 단순해지고, 새로운 종류를 추가하는 쪽은 쉬워진다.

다형성이 왜 그렇게 중요할까

다형성이 없으면 프로그램은 금방 if, else if, switch로 가득 차기 쉽다.

예를 들어 알림을 보내는 기능을 만든다고 해 보자.

if (type.equals("email")) {
    // 이메일 전송
} else if (type.equals("sms")) {
    // 문자 전송
} else if (type.equals("push")) {
    // 푸시 전송
}

처음에는 단순해 보이지만, 알림 종류가 늘어날수록 이 조건문은 점점 길어진다. 새 기능 하나 추가할 때마다 기존 코드를 뜯어고쳐야 한다. 그러다 보면 "건드리기 무서운 코드"가 탄생한다.

다형성을 쓰면 이런 구조를 훨씬 깔끔하게 바꿀 수 있다. 핵심은 종류를 분기하지 말고, 객체에게 맡기는 것이다.

추상화: 복잡한 세부 사항보다 중요한 약속에 집중하기

추상화는 중요한 것만 남기고 덜 중요한 세부 사항은 뒤로 숨기는 것이다. 어렵게 들릴 수 있지만, 사실 우리는 일상에서도 늘 추상화를 사용한다.

자동차를 운전할 때 운전자는 엔진 내부의 폭발 순서를 몰라도 된다. 엑셀, 브레이크, 핸들만 알면 된다. 즉, 복잡한 내부 구조는 숨기고, 사용에 필요한 핵심만 드러낸 것이다.

프로그래밍에서도 마찬가지다. 어떤 객체가 "무엇을 해야 하는가"를 먼저 정하고, "어떻게 하는가"는 각 구현에 맡길 수 있다. 이 생각이 바로 추상 클래스와 인터페이스로 이어진다.

추상 클래스: 공통 뼈대는 주고, 빈칸은 자식이 채우게 하기

어떤 클래스는 공통 속성과 공통 메서드를 가지지만, 일부 동작은 자식마다 다르게 정해야 할 때가 있다. 이럴 때 추상 클래스를 쓴다.

추상 클래스는 객체를 직접 만들기 위한 클래스라기보다, 자식 클래스들의 공통 기반이 되는 클래스다. abstract 키워드를 사용한다.

abstract class Unit {
    private String name;

    Unit(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void move() {
        System.out.println(name + "가 이동합니다.");
    }

    public abstract void attack();
}

class Warrior extends Unit {
    Warrior(String name) {
        super(name);
    }

    @Override
    public void attack() {
        System.out.println(getName() + "가 검으로 공격합니다.");
    }
}

class Mage extends Unit {
    Mage(String name) {
        super(name);
    }

    @Override
    public void attack() {
        System.out.println(getName() + "가 마법으로 공격합니다.");
    }
}

Unit은 공통적으로 이름을 가지고 이동할 수 있다. 하지만 공격 방식은 전사와 마법사가 다르다. 그래서 attack()은 추상 메서드로 남겨 두고, 자식이 반드시 구현하게 만든다.

사용하는 쪽에서는 이렇게 볼 수 있다.

Unit[] units = {
    new Warrior("아서"),
    new Mage("메린")
};

for (Unit unit : units) {
    unit.move();
    unit.attack();
}

추상 클래스의 장점은 공통 코드를 공유하면서도, 자식에게 필요한 구현을 강제할 수 있다는 점이다.

인터페이스: 무엇을 할 수 있는지에 대한 약속

인터페이스는 클래스가 "무엇을 할 수 있는가"를 표현하는 도구다. 상속이 종류를 나타내는 데 가깝다면, 인터페이스는 기능을 나타내는 데 더 잘 어울린다.

예를 들어 알림 전송 기능을 인터페이스로 설계해 보자.

interface Notifier {
    void send(String message);
}

class EmailNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("이메일 전송: " + message);
    }
}

class SmsNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("문자 전송: " + message);
    }
}

class PushNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("푸시 전송: " + message);
    }
}

이제 실제 알림 서비스를 만들어 보자.

class NotificationService {
    private Notifier notifier;

    NotificationService(Notifier notifier) {
        this.notifier = notifier;
    }

    public void notifyUser(String message) {
        notifier.send(message);
    }
}

사용 코드는 아주 단순해진다.

NotificationService service1 = new NotificationService(new EmailNotifier());
service1.notifyUser("회원 가입을 환영합니다.");

NotificationService service2 = new NotificationService(new SmsNotifier());
service2.notifyUser("인증 번호는 1234입니다.");

NotificationService는 이메일인지 문자인지 푸시인지 구체적으로 몰라도 된다. 그저 Notifier라는 약속만 믿고 send()를 호출하면 된다.

이 구조가 왜 좋은가 하면, 나중에 KakaoNotifier를 추가해도 NotificationService는 거의 바뀌지 않기 때문이다. 이것이 바로 확장에 유연한 설계다.

추상 클래스와 인터페이스는 어떻게 다를까

둘 다 추상화 도구이지만 쓰임새가 조금 다르다.

  • 추상 클래스는 공통 상태와 공통 구현을 함께 나누고 싶을 때 적합하다.

  • 인터페이스는 공통 기능의 약속을 정하고 싶을 때 적합하다.

  • 클래스는 하나의 부모 클래스만 상속할 수 있지만, 인터페이스는 여러 개 구현할 수 있다.

간단히 말해, 추상 클래스는 "공통 뼈대", 인터페이스는 "공통 약속"에 가깝다.

예를 들어 Animal은 종류를 나타내므로 추상 클래스가 잘 어울릴 수 있다. 반면 Flyable, Runnable, Payable, Notifier처럼 "할 수 있는 기능"은 인터페이스가 잘 어울린다.

상위 타입에 의존하면 설계가 유연해진다

이번 장에서 꼭 가져가야 할 감각이 하나 있다. 구체적인 클래스보다 상위 개념에 의존하라는 것이다.

예를 들어 NotificationService가 아래처럼 작성되어 있다고 해 보자.

class NotificationService {
    private EmailNotifier notifier = new EmailNotifier();
}

이 구조에서는 이메일 방식에 꽉 묶여 버린다. 나중에 문자나 푸시로 바꾸려면 클래스 내부를 수정해야 한다.

반면 인터페이스에 의존하면 이렇게 바뀐다.

class NotificationService {
    private Notifier notifier;

    NotificationService(Notifier notifier) {
        this.notifier = notifier;
    }
}

이제 서비스는 구현 세부 사항에서 한 발 떨어져 있게 된다. 덕분에 변경이 쉬워진다. 좋은 설계는 종종 이런 식으로 구현보다 약속에 기대는 구조에서 나온다.

상속이 만능은 아니다

상속은 강력하지만, 많이 쓴다고 무조건 좋은 것이 아니다. 상속은 부모와 자식이 아주 강하게 연결되는 관계다. 그래서 잘못 사용하면 자식 클래스가 부모의 구조에 지나치게 끌려다니게 된다.

예를 들어 "자동차는 탈것이다"는 자연스럽다. 하지만 "알림 기능은 사용자다" 같은 관계는 이상하다. 기능이 비슷하다는 이유만으로 상속을 사용하면 설계가 어색해진다.

이럴 때는 상속보다 인터페이스나 다른 객체를 포함하는 방식이 더 잘 맞을 수 있다. 예를 들어 캐릭터가 무기를 "상속"하는 것은 어색하지만, 캐릭터가 무기를 "가진다"는 구조는 자연스럽다.

좋은 설계는 문법을 많이 쓰는 것이 아니라, 관계를 자연스럽게 표현하는 것이다.

좋은 코드로 가는 설계 감각

작동하는 코드에서 좋은 코드로 넘어가려면 다음 감각을 자꾸 떠올리면 좋다.

  • 필드를 함부로 열지 말고, 규칙을 메서드 안에 모은다.

  • 상속은 "비슷하다"가 아니라 "~이다" 관계일 때 사용한다.

  • 구체 클래스보다 부모 타입이나 인터페이스에 의존한다.

  • 종류에 따라 조건문이 길어지면 다형성으로 바꿀 수 없는지 살펴본다.

이 네 가지를 의식하는 것만으로도 코드의 분위기가 꽤 달라진다. Main 메서드에 모든 로직이 몰리던 코드가, 역할을 나누어 협력하는 구조로 바뀌기 시작한다.

초보자가 자주 하는 실수

객체지향 심화 단계에서 자주 만나는 실수도 미리 알아 두면 좋다.

  • private만 붙이고 setter를 남발한다. 그러면 캡슐화가 아니라 우회 공개가 되기 쉽다.

  • 중복 코드를 줄이겠다고 아무 데나 상속을 사용한다. 공통 조상이 어색하면 코드가 더 복잡해진다.

  • 다형성을 쓸 수 있는데도 종류마다 if-else를 늘린다. 처음엔 쉬워 보여도 금방 커진다.

  • 추상 클래스와 인터페이스를 어렵게만 생각해 아예 피한다. 사실 둘은 복잡함을 줄이기 위한 도구다.

실수 자체는 괜찮다. 중요한 것은 "왜 이 구조가 불편해졌는가"를 느끼는 것이다. 그 순간 설계 감각이 자라기 시작한다.

이번 장의 핵심 정리

이번 장에서 꼭 기억할 내용은 다음과 같다.

  • 캡슐화는 데이터를 감추는 기술이 아니라, 객체의 규칙을 보호하는 설계다.

  • 상속은 공통점을 재사용하게 해 주지만, 자연스러운 관계에서만 써야 한다.

  • 오버라이딩은 부모의 동작을 자식에게 맞게 다시 정의하는 것이다.

  • 다형성은 같은 타입으로 여러 객체를 다루게 해 주며, 조건문을 줄이고 확장성을 높인다.

  • 추상 클래스는 공통 뼈대를 제공하고, 인터페이스는 공통 약속을 제공한다.

  • 좋은 설계는 구체 구현보다 상위 개념에 의존할수록 유연해진다.

마무리

객체지향의 핵심은 멋진 용어를 외우는 데 있지 않다. 오히려 질문을 잘 던지는 데 있다.

이 값은 누가 지켜야 할까? 이 동작은 어느 객체의 책임일까? 새로운 종류가 추가되면 기존 코드를 얼마나 바꿔야 할까? 공통점은 어디까지이고, 차이점은 어디부터일까?

이 질문에 답하면서 코드를 고치다 보면, 캡슐화는 단순한 private이 아니고, 상속은 단순한 extends가 아니며, 다형성은 단순한 문법 트릭이 아니라는 사실을 알게 된다.

이제 여러분은 클래스와 객체를 만드는 단계에서 한 걸음 더 나아가, 객체들이 어떻게 관계를 맺고 협력해야 좋은 설계가 되는지 보기 시작했다. 바로 그 지점부터 Java 코드는 훨씬 더 재미있어진다.

책 목록

처음부터 실무까지 단계별로 배우는 JAVA 학습

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기