NYO_O

객체지향 프로그래밍(OOP)의 4대 원칙, (feat. Java) 본문

Architecture

객체지향 프로그래밍(OOP)의 4대 원칙, (feat. Java)

NYO_O 2026. 5. 27. 11:14
반응형

OOP를 왜 다시 정리해야 하는가

지난 시간, 우리는 좋은 코드가 갖춰야 할 두 가지 척도인 응집도와 결합도를 살펴보았습니다. 응집도는 높게, 결합도는 낮게 유지하는 것이 변경에 강한 구조를 만드는 핵심이라는 이야기였습니다.

2026.05.27 - [IT 용어 사전] - 응집도와 결합도, 좋은 코드가 지켜야 할 두 가지 원칙 (feat. Java)

 

응집도와 결합도, 좋은 코드가 지켜야 할 두 가지 원칙 (feat. Java)

소프트웨어를 처음 만들 때는 동작만 하면 충분해 보입니다. 하지만 서비스가 성장하고, 팀원이 늘고, 요구사항이 바뀌기 시작하는 순간 코드의 구조가 얼마나 중요한지를 몸으로 느끼게 됩니

ddangnyo.tistory.com

그렇다면 이런 구조는 어떤 사상 위에서 만들어지는 걸까요? 그 뿌리에 있는 것이 바로 오늘 이야기할 객체지향 프로그래밍(Object-Oriented Programming, OOP) 입니다.

OOP는 Java를 배우기 시작한 첫날부터 등장하는 개념입니다. 그런데 막상 실무에서 "캡슐화가 뭐예요?"라는 질문을 받으면, 교과서적인 정의는 떠오르지만 코드로 설명하려니 막히는 경험을 한 번쯤 하게 됩니다. 오늘은 이 네 가지 원칙을 개념으로만 두지 않고, 실제 Java 코드를 통해 왜 그렇게 해야 하는지와 함께 정리해 보겠습니다.

객체지향이란 무엇인가

객체지향 프로그래밍은 프로그램을 "데이터와 그 데이터를 다루는 행동을 하나로 묶은 객체들의 상호작용"으로 구성하는 패러다임입니다. 절차적 프로그래밍이 "무엇을 순서대로 할 것인가"에 집중한다면, 객체지향은 "어떤 객체가 어떤 책임을 맡을 것인가"에 집중합니다.

현실 세계에 비유하면, 은행 시스템을 만들 때 절차적 방식은 "입금 → 잔액 확인 → 출금 → 이체"라는 절차를 정의하는 방식이고, 객체지향 방식은 Account라는 객체가 deposit(), withdraw(), transfer()라는 행동을 스스로 책임지도록 설계하는 방식입니다.

OOP를 구성하는 핵심 원칙은 캡슐화, 상속, 다형성, 추상화 네 가지입니다. 이 원칙들은 독립적이라기보다 서로를 전제로 하며 함께 작동합니다.

캡슐화 (Encapsulation)

개념

캡슐화는 객체의 내부 데이터(필드)와 그것을 다루는 메서드를 하나로 묶고, 외부에서 내부 데이터에 직접 접근하지 못하도록 보호하는 것입니다. 의약품 캡슐처럼 내용물을 감싸서 외부 환경으로부터 보호하되, 필요한 방식으로만 작용하게 한다는 의미에서 캡슐화라는 이름이 붙었습니다.

Java에서는 접근 제어자(private, protected, public)와 getter/setter를 통해 구현합니다.

캡슐화가 없으면 어떤 일이 생기는가

// 캡슐화가 없는 경우
public class BankAccount {
    public double balance;  // 외부에서 직접 접근 가능
}

public class Main {
    public static void main(String[] args) {
        BankAccount account = new BankAccount();
        account.balance = -999999;  // 잔액을 음수로 직접 조작 가능
        account.balance = Double.MAX_VALUE;  // 어떤 값이든 마음대로 설정 가능
    }
}

balance 필드가 public으로 열려 있으면, 어디서든 잔액을 임의로 수정할 수 있습니다. 유효성 검사가 들어갈 자리가 없고, 잔액이 어디서 어떻게 바뀌었는지 추적하기도 어렵습니다.

캡슐화를 적용한 설계

public class BankAccount {

    private double balance;  // 외부에서 직접 접근 불가
    private final String accountNumber;

    public BankAccount(String accountNumber, double initialBalance) {
        if (initialBalance < 0) {
            throw new IllegalArgumentException("초기 잔액은 0 이상이어야 합니다.");
        }
        this.accountNumber = accountNumber;
        this.balance = initialBalance;
    }

    // 잔액 변경은 반드시 이 메서드를 통해서만 가능
    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("입금액은 0보다 커야 합니다.");
        }
        this.balance += amount;
    }

    public void withdraw(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("출금액은 0보다 커야 합니다.");
        }
        if (amount > this.balance) {
            throw new IllegalStateException("잔액이 부족합니다.");
        }
        this.balance -= amount;
    }

    // 읽기만 허용, 쓰기는 불가
    public double getBalance() {
        return balance;
    }

    public String getAccountNumber() {
        return accountNumber;
    }
}

이제 balance는 deposit()과 withdraw()를 통해서만 변경됩니다. 유효성 검사 로직이 한 곳에 모여 있어 중복이 없고, BankAccount의 상태는 언제나 유효함이 보장됩니다. 외부에서는 잔액을 읽을 수 있지만 직접 조작할 수 없습니다.

캡슐화의 핵심: 데이터를 숨기는 것이 목적이 아닙니다. 객체의 상태를 항상 유효하게 유지하기 위해, 상태 변경의 경로를 통제하는 것이 목적입니다.

상속 (Inheritance)

개념

상속은 기존 클래스(부모 클래스, 슈퍼클래스)의 필드와 메서드를 새로운 클래스(자식 클래스, 서브클래스)가 물려받는 메커니즘입니다. 공통된 속성과 행동을 부모 클래스에 정의하고, 자식 클래스는 그것을 재사용하면서 자신만의 특성을 추가하거나 재정의할 수 있습니다.

상속 없이 설계한다면

// 상속 없이 유사한 클래스를 각각 따로 작성
public class SavingsAccount {
    private String accountNumber;
    private double balance;
    private double interestRate;

    public void deposit(double amount) { this.balance += amount; }
    public void withdraw(double amount) { this.balance -= amount; }
    public double getBalance() { return balance; }
    // 이자 계산 메서드...
}

public class CheckingAccount {
    private String accountNumber;
    private double balance;
    private double overdraftLimit;

    // deposit, withdraw, getBalance 코드가 그대로 중복됨
    public void deposit(double amount) { this.balance += amount; }
    public void withdraw(double amount) { this.balance -= amount; }
    public double getBalance() { return balance; }
    // 당좌 한도 관련 메서드...
}

accountNumber, balance, deposit(), withdraw() 같은 공통 요소가 두 클래스에 그대로 중복됩니다. 나중에 deposit() 로직을 수정해야 할 때 모든 계좌 클래스를 찾아다니며 고쳐야 합니다.

상속을 적용한 설계

// 공통 속성과 행동을 부모 클래스에 정의
public abstract class Account {

    private final String accountNumber;
    protected double balance;  // 자식 클래스에서 접근 가능

    public Account(String accountNumber, double initialBalance) {
        this.accountNumber = accountNumber;
        this.balance = initialBalance;
    }

    public void deposit(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("입금액은 0보다 커야 합니다.");
        this.balance += amount;
    }

    public double getBalance() { return balance; }
    public String getAccountNumber() { return accountNumber; }

    // 자식 클래스마다 다르게 구현해야 하는 메서드는 추상 메서드로 선언
    public abstract void withdraw(double amount);
}

// 저축 계좌: 공통 로직은 상속받고, 출금 규칙만 별도로 구현
public class SavingsAccount extends Account {

    private final double interestRate;

    public SavingsAccount(String accountNumber, double initialBalance, double interestRate) {
        super(accountNumber, initialBalance);
        this.interestRate = interestRate;
    }

    @Override
    public void withdraw(double amount) {
        if (amount > this.balance) throw new IllegalStateException("잔액이 부족합니다.");
        this.balance -= amount;
    }

    public double calculateInterest() {
        return this.balance * interestRate;
    }
}

// 당좌 계좌: 마이너스 한도 내에서 출금 가능
public class CheckingAccount extends Account {

    private final double overdraftLimit;

    public CheckingAccount(String accountNumber, double initialBalance, double overdraftLimit) {
        super(accountNumber, initialBalance);
        this.overdraftLimit = overdraftLimit;
    }

    @Override
    public void withdraw(double amount) {
        if (amount > this.balance + overdraftLimit) {
            throw new IllegalStateException("당좌 한도를 초과했습니다.");
        }
        this.balance -= amount;
    }
}

공통 로직은 Account에 한 번만 작성하고, 각 계좌 유형은 자신만의 출금 규칙을 withdraw()에 구현합니다. deposit() 로직을 수정해야 할 때는 Account 한 곳만 고치면 됩니다.

상속 사용 시 주의할 점

상속은 강력하지만, 잘못 사용하면 결합도를 높이는 원인이 됩니다. 부모 클래스를 수정하면 모든 자식 클래스에 영향이 가고, 상속 계층이 깊어질수록 코드를 따라가기 어려워집니다.

실무에서는 "is-a 관계"가 명확할 때만 상속을 사용하고, 그 외의 경우에는 컴포지션(composition, 객체를 필드로 포함하는 방식)을 사용하는 것을 권장합니다. SavingsAccount는 Account다 → 이 문장이 자연스럽다면 상속이 적합합니다. 그렇지 않다면 상속보다 컴포지션을 먼저 고려하는 것이 좋습니다.

다형성 (Polymorphism)

개념

다형성은 같은 인터페이스나 부모 클래스 타입으로 여러 형태의 객체를 다룰 수 있는 능력입니다. "많은 형태"를 뜻하는 그리스어에서 유래한 이름처럼, 하나의 타입이 상황에 따라 다른 방식으로 동작할 수 있습니다.

Java에서 다형성은 주로 메서드 오버라이딩(런타임 다형성)과 메서드 오버로딩(컴파일타임 다형성)으로 구현됩니다. 실무에서 더 중요한 것은 런타임 다형성입니다.

다형성이 없으면 어떤 일이 생기는가

// 다형성 없이 작성하면 타입 분기가 계속 늘어남
public class NotificationSender {

    public void send(Object notification, String type) {
        if (type.equals("email")) {
            EmailNotification email = (EmailNotification) notification;
            // 이메일 전송 로직
            System.out.println("이메일 전송: " + email.getAddress());
        } else if (type.equals("sms")) {
            SmsNotification sms = (SmsNotification) notification;
            // SMS 전송 로직
            System.out.println("SMS 전송: " + sms.getPhoneNumber());
        } else if (type.equals("push")) {
            // 푸시 알림이 추가될 때마다 이 메서드를 수정해야 함
        }
    }
}

새로운 알림 유형이 생길 때마다 이 메서드에 else if를 추가해야 합니다. 앞서 살펴본 결합도와 응집도 관점에서도 좋지 않은 구조입니다.

다형성을 활용한 설계

// 공통 인터페이스 정의
public interface Notification {
    void send();
}

// 각 구현체는 send()를 자신만의 방식으로 구현
public class EmailNotification implements Notification {
    private final String address;
    private final String content;

    public EmailNotification(String address, String content) {
        this.address = address;
        this.content = content;
    }

    @Override
    public void send() {
        System.out.println("[이메일] " + address + "로 전송: " + content);
    }
}

public class SmsNotification implements Notification {
    private final String phoneNumber;
    private final String content;

    public SmsNotification(String phoneNumber, String content) {
        this.phoneNumber = phoneNumber;
        this.content = content;
    }

    @Override
    public void send() {
        System.out.println("[SMS] " + phoneNumber + "로 전송: " + content);
    }
}

public class PushNotification implements Notification {
    private final String deviceToken;
    private final String content;

    public PushNotification(String deviceToken, String content) {
        this.deviceToken = deviceToken;
        this.content = content;
    }

    @Override
    public void send() {
        System.out.println("[Push] " + deviceToken + "으로 전송: " + content);
    }
}

// 전송 로직은 타입을 전혀 신경 쓰지 않음
public class NotificationSender {

    public void sendAll(List<Notification> notifications) {
        for (Notification notification : notifications) {
            notification.send();  // 런타임에 실제 타입의 send()가 호출됨
        }
    }
}

// 사용 예
public class Main {
    public static void main(String[] args) {
        List<Notification> notifications = List.of(
            new EmailNotification("user@example.com", "가입을 환영합니다."),
            new SmsNotification("010-1234-5678", "인증번호: 1234"),
            new PushNotification("device-token-abc", "새 메시지가 도착했습니다.")
        );

        NotificationSender sender = new NotificationSender();
        sender.sendAll(notifications);
        // 새로운 알림 유형이 생겨도 NotificationSender는 수정할 필요가 없음
    }
}

NotificationSender는 Notification 인터페이스만 알면 됩니다. 새로운 알림 유형이 추가되어도 sendAll() 메서드는 전혀 수정할 필요가 없습니다. 이것이 다형성이 주는 가장 큰 이점입니다.

다형성의 핵심: "무엇인지"가 아닌 "무엇을 할 수 있는지"에 의존하도록 설계하는 것입니다. 타입 분기(instanceof, if-else type check)가 보인다면, 다형성으로 대체할 수 있는지 검토해 보는 것이 좋습니다.

추상화 (Abstraction)

개념

추상화는 복잡한 내부 구현을 숨기고, 사용자에게 필요한 인터페이스(기능의 명세)만 노출하는 것입니다. 자동차를 운전할 때 엔진 내부 작동 방식을 알 필요 없이 핸들, 액셀, 브레이크만 알면 되는 것처럼, 잘 설계된 클래스는 내부 구현을 몰라도 사용할 수 있어야 합니다.

Java에서 추상화는 추상 클래스(abstract class)와 인터페이스(interface)를 통해 구현합니다.

추상 클래스 vs 인터페이스

두 가지는 비슷해 보이지만 사용하는 상황이 다릅니다.

추상 클래스는 공통된 상태(필드)와 일부 구현을 공유하면서, 특정 메서드는 자식 클래스가 반드시 구현하도록 강제할 때 사용합니다. 상속 계층에서 "공통 뼈대"를 제공하는 역할입니다.

인터페이스는 구현 없이 순수하게 "무엇을 할 수 있는가"만 정의합니다. 서로 관계없는 클래스들이 같은 계약을 따르도록 할 때 사용합니다. Java 8 이후로는 default 메서드를 통해 기본 구현을 포함할 수 있지만, 여전히 상태(인스턴스 필드)는 가질 수 없습니다.

// 추상 클래스 — 공통 상태와 공통 구현을 포함, 일부는 자식에게 위임
public abstract class Shape {

    private final String color;

    public Shape(String color) {
        this.color = color;
    }

    public String getColor() {
        return color;
    }

    // 넓이 계산 공식은 도형마다 다르므로 추상 메서드로 선언
    public abstract double calculateArea();

    // 공통 출력 로직은 여기서 구현
    public void printInfo() {
        System.out.println(color + " " + getClass().getSimpleName()
            + " — 넓이: " + calculateArea());
    }
}

public class Circle extends Shape {
    private final double radius;

    public Circle(String color, double radius) {
        super(color);
        this.radius = radius;
    }

    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

public class Rectangle extends Shape {
    private final double width;
    private final double height;

    public Rectangle(String color, double width, double height) {
        super(color);
        this.width = width;
        this.height = height;
    }

    @Override
    public double calculateArea() {
        return width * height;
    }
}
// 인터페이스 — 구현 없이 계약만 정의
public interface Exportable {
    byte[] export();
    String getSupportedFormat();
}

public interface Printable {
    void print();
}

// 관계없는 클래스들도 같은 인터페이스를 구현할 수 있음
public class SalesReport implements Exportable, Printable {

    @Override
    public byte[] export() {
        // PDF 바이트 데이터 반환
        return new byte[0];
    }

    @Override
    public String getSupportedFormat() {
        return "PDF";
    }

    @Override
    public void print() {
        System.out.println("매출 보고서를 출력합니다.");
    }
}

public class UserDataCsv implements Exportable {

    @Override
    public byte[] export() {
        // CSV 바이트 데이터 반환
        return new byte[0];
    }

    @Override
    public String getSupportedFormat() {
        return "CSV";
    }
}

SalesReport와 UserDataCsv는 전혀 다른 클래스지만, Exportable이라는 계약을 공유하기 때문에 같은 방식으로 다룰 수 있습니다. 사용하는 쪽에서는 내부 구현이 어떻게 되어 있는지 알 필요가 없습니다.

추상화의 핵심: "어떻게 하는가"가 아닌 "무엇을 하는가"를 정의하는 것입니다. 잘 설계된 추상화는 내부 구현이 완전히 바뀌어도 사용하는 쪽 코드에 영향을 주지 않습니다.

네 가지 원칙은 어떻게 함께 작동하는가

네 가지 원칙은 각각 독립적인 개념이 아닙니다. 실제 코드에서는 항상 함께 작동합니다.

추상화로 인터페이스를 정의하고, 상속(또는 구현) 으로 구체적인 클래스를 만들고, 다형성으로 구체 타입에 의존하지 않고 인터페이스 타입으로 다루며, 캡슐화로 각 객체의 내부 상태를 보호합니다.

지난 글에서 다뤘던 응집도와 결합도의 관점으로 보면, 캡슐화는 응집도를 높이는 데 기여하고, 추상화와 다형성은 결합도를 낮추는 데 직접적으로 기여합니다. 상속은 적절히 사용하면 코드 재사용성을 높이지만, 남용하면 오히려 결합도를 높이는 원인이 되기도 합니다.

아래 표로 네 원칙의 핵심을 정리해 보겠습니다.

원칙 질문 Java 구현 수단
캡슐화 이 객체의 상태를 어떻게 보호할 것인가? 접근 제어자, getter/setter
상속 공통 로직을 어떻게 재사용할 것인가? extends, abstract class
다형성 타입 분기 없이 어떻게 유연하게 확장할 것인가? 메서드 오버라이딩, 인터페이스 구현
추상화 사용자에게 무엇만 보여줄 것인가? interface, abstract class

마무리

오늘은 객체지향 프로그래밍의 네 가지 원칙인 캡슐화, 상속, 다형성, 추상화를 Java 코드와 함께 살펴보았습니다.

네 원칙 모두 결국 같은 방향을 가리키고 있습니다. 변경이 필요할 때 영향 범위를 최소화하고, 코드를 이해하고 테스트하기 쉽게 만드는 것입니다. 이전 글에서 다룬 응집도와 결합도도 이 네 원칙을 제대로 적용했을 때 자연스럽게 따라오는 결과입니다.

반응형